Files
simstudioai--sim/.claude/rules/sim-settings-pages.md
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

12 KiB
Raw Blame History

paths
paths
apps/sim/app/workspace/*/settings/**
apps/sim/ee/**/components/**

Settings Pages

The Next.js settings/[section]/layout.tsx owns all settings page chrome via SettingsHeaderShell — a fixed header bar (a left back chip + right-aligned action chips), a scroll region, and a centered max-w-[48rem] content column led by a title + description from navigation metadata. The chrome stays mounted across section navigation (it never re-renders or re-lays-out). Each section renders through the SettingsPanel registrar (@/app/workspace/[workspaceId]/settings/components/settings-panel), which feeds the shell its header data and renders only the section body. Sections supply data, never chrome.

Do NOT hand-roll any of these in a settings page — they are owned by the layout shell (fed through SettingsPanel):

  • <div className='flex h-full flex-col bg-[var(--bg)]'> shell
  • the header bar (flex flex-shrink-0 … px-[16px] pt-[8.5px] pb-[8.5px])
  • the scroll container (min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges])
  • the content column (mx-auto … max-w-[48rem] … gap-7)
  • a title block (<h1 className='font-medium text-[var(--text-body)] text-lg'> + <p className='text-[var(--text-muted)] text-md'>)
  • the page-level search input

Canonical page shape

import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'

return (
  <SettingsPanel
    actions={[{ text: 'Create', icon: Plus, variant: 'primary', onSelect: onCreate }]}
    search={{ value: searchTerm, onChange: setSearchTerm, placeholder: 'Search …' }}
  >
    {/* body only — sections, lists, forms */}
  </SettingsPanel>
)

When the page has modal/dialog siblings, wrap them with the panel in a fragment:

return (
  <>
    <SettingsPanel actions={}>{body}</SettingsPanel>
    <SomeModal  />
  </>
)

SettingsPanel props

  • actions?: SettingsAction[] — right-aligned header chips, data only: { text, icon?, variant?: 'primary'|'destructive', active?, onSelect, disabled?, tooltip? }. The shell renders each as a Chip — never pass JSX, a <div>, or className (the locked contract: it's structurally impossible to vibe-code a padding change). Multiple/conditional actions are a plain array ([...(canManage ? [{…}] : []), …]). Labels are sentence case (Add override, not Add Override). A disabled action that needs to explain itself sets tooltip (the shell renders the hover tooltip, disabled chip included) — never hand-roll a tooltip-wrapped chip in aside. Save/Discard pairs come from the saveDiscardActions() helper (spread it into actions). Only a widget that genuinely cannot be a chip (e.g. one needing hover-prefetch) goes in aside.
  • back?: SettingsBackAction ({ text, icon?, onSelect }) — left-aligned back chip for a detail sub-view (e.g. a selected MCP server, a permission group, a retention policy). Detail sub-views render through SettingsPanel like list pages — they do NOT hand-roll their own shell.
  • aside?: ReactNode — escape hatch for the rare non-chip header widget. Keep it rare.
  • search?: { value; onChange: (value: string) => void; placeholder?; disabled? } — renders the canonical search field directly below the title. Pass setSearchTerm straight to onChange. Use this for a standalone search; if search shares a row with other controls (sort, filters, a date picker), render that whole row in children instead and omit the prop.
  • title? / description? — overrides for the nav-driven defaults. Only for a detail sub-view that needs a different heading; normal pages never pass these.
  • scrollContainerRef?: React.Ref<HTMLDivElement> — forwards a ref to the scroll region (e.g. programmatic scroll-to-bottom).

Title + description live in navigation metadata

apps/sim/app/workspace/[workspaceId]/settings/navigation.ts is the single source of truth. Every NavigationItem carries a one-line description; SettingsPanel resolves both via getSettingsSectionMeta(section) and the SettingsSectionProvider the settings shell wraps around the active section.

Adding a new settings page:

  1. Add the SettingsSection id + a NavigationItem (with label and description) in navigation.ts. Keep descriptions verb-first, one line, ~4055 chars, in the product voice (see .claude/rules/constitution.md).
  2. Render the component inside the shell's effectiveSection switch in settings/[section]/settings.tsx.
  3. Build the component body inside <SettingsPanel> — no shell, no title block.

Text-scale tokens (no literal pixel sizes)

Settings pages never use a literal text-[Npx] class — always the named Tailwind scale token from apps/sim/tailwind.config.ts's fontSize extension (text-micro 10px, text-xs 11px, text-caption 12px, text-small 13px, text-sm 14px [Tailwind default, unmodified], text-base 15px, text-md 16px, text-lg 18px [Tailwind default]). A literal size is either a straight rename to the equivalent token (if the pixel value matches one exactly) or a sign the page never migrated — grep text-\[1[0-8]px\] under apps/sim/app/workspace/*/settings/** and apps/sim/ee/** to find stragglers.

For a two-line list row (title/value on top, a muted subtitle below — a name + email, a tool name + description, a server name + status), the established pairing is:

  • Title / row value: text-[var(--text-body)] text-sm
  • Subtitle / muted description: text-[var(--text-muted)] text-caption

This is not a stylistic guess — it is the tokenized form of the literal-pixel pairing (text-[14px] text-[var(--text-body)] / text-[12px] text-[var(--text-muted)]) already used for this exact row shape across member-list.tsx, api-keys.tsx, mcp.tsx, billing.tsx, workflow-mcp-servers.tsx, and others — keep new rows consistent with it rather than inventing a new size pairing.

For a toggle row (a Switch with a title and optional description), use the emcn Label component for the title — never a hand-rolled <span> — paired with Switch's id/Label's htmlFor:

<div className='flex items-center justify-between'>
  <div className='flex flex-col gap-1'>
    <Label htmlFor='my-toggle'>Enable thing</Label>
    <p className='text-[var(--text-muted)] text-caption'>One-line description.</p>
  </div>
  <Switch id='my-toggle' checked={enabled} onCheckedChange={onToggle} />
</div>

Label's own default styling (font-medium text-[var(--text-primary)] text-small) already matches the established title treatment — do not add a className overriding its size/color unless the row genuinely needs something different.

--text-primary/--text-secondary and --text-body/--text-muted are both real, independently-defined tokens (not interchangeable — they resolve to different colors) and both see legitimate use across settings pages; this rule only pins down the row title/subtitle shape above, not every text element on every page.

Other shared settings primitives (do not re-roll these)

  • SettingsEmptyState (…/components/settings-empty-state) — the canonical muted status message. variant='fill' (default) centers in the available height (empty list, or a not-entitled/loading gate); variant='inline' sits in flow (a search "no results"). Never hand-roll <div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'> or <div className='py-4 text-center …'>. It owns the --text-muted + text-sm tokens, so it also keeps these messages consistent across pages.
  • RowActionsMenu (…/components/row-actions-menu) — the trailing ... actions menu for a list row. Pass label (aria-label) and actions: RowAction[] ({ label, onSelect, destructive?, disabled? }); the component renders the canonical flush ... trigger + DropdownMenuContent. Conditional items become array spreads: ...(canManage ? [{…}] : []). Never hand-roll the <DropdownMenu> + <MoreHorizontal> trigger per page.

Save / Discard + unsaved-changes guard

Any settings surface with editable state uses one shared stack — never hand-roll a Save button, a Discard button, a beforeunload, or an "Unsaved changes" modal:

  • saveDiscardActions(config) (…/components/save-discard-actions/save-discard-actions) — returns the canonical dirty-gated Discard + Save SettingsAction[] (empty when not dirty). Spread it into a SettingsPanel actions array, beside any sibling actions (a detail view's Delete / Remove override). Config: dirty, saving, onSave, onDiscard, saveDisabled?, saveLabel?, savingLabel?.
  • useSettingsUnsavedGuard({ isDirty }) (…/settings/hooks/use-settings-unsaved-guard) — syncs the page's local isDirty into the shared useSettingsDirtyStore (so the sidebar's section-switch confirm + the centralized beforeunload both apply for free) and returns { showUnsavedModal, setShowUnsavedModal, guardBack, confirmDiscard } for a detail view's in-view back chip.
    • Top-level pages (whitelabeling, sso): call it unassigneduseSettingsUnsavedGuard({ isDirty: hasChanges }) — they only need the store-sync; the sidebar/beforeunload do the rest.
    • Detail sub-views (data-retention, access-control group-detail): route the back chip through onClick={() => guard.guardBack(closeFn)} and render the shared <UnsavedChangesModal open={guard.showUnsavedModal} onOpenChange={guard.setShowUnsavedModal} onDiscard={guard.confirmDiscard} /> (from @/app/workspace/[workspaceId]/components/credential-detail). The in-view header Discard chip (via SaveDiscardActions onDiscard) is a reset to original — distinct from the back-confirm's discard, which leaves.
  • useSettingsBeforeUnload is mounted once in the settings shell (settings/[section]/settings.tsx) — never add a per-page beforeunload.
  • Dirty computation stays local (shapes differ: field-compare vs normalize+stringify) — only how dirty is consumed is shared. Derive it (a const/useMemo), never store it in useState.
  • CRITICAL — rules of hooks: call useSettingsUnsavedGuard(...) unconditionally, before every early-return gate (entitlement / loading / not-entitled return <SettingsEmptyState>). A hook placed after a gate is skipped on gated renders and crashes.
  • The route-based credential detail keeps its own useUnsavedChangesGuard (it guards real router.push navigation + browser Back via a history sentinel); it already shares UnsavedChangesModal, so copy stays unified.

Detail sub-views

A drill-down view reached from a list row (selected MCP server, workflow MCP server, permission group, retention policy) renders through SettingsPanel like a list page: pass back={{ text, icon: ArrowLeft, onSelect }} for the left back chip, title (the entity name), and the header actions, then render the body. Do NOT hand-roll a shell or header bar; a tab bar renders as the first body child. Gate/early-return states (not-entitled, loading, upgrade prompts) stay as-is.

The route-based credential detail (settings/secrets/[credentialId]) is the lone exception — it lives outside [section] and keeps its own CredentialDetailLayout.

Audit checklist

A settings page is design-system-clean when:

  • Its main return is a <SettingsPanel> (or <>…<SettingsPanel>…</> with modal siblings) — no hand-rolled shell/header/scroll/column.
  • It renders no hand-rolled <h1>/description title block — the title comes from nav metadata.
  • Header chips are in actions; a standalone search is in the search prop.
  • Its NavigationItem has an accurate, consistent-length description.
  • Detail sub-views and entitlement/loading gates keep their own chrome (intentional).
  • If it has editable state: Save/Discard go through SaveDiscardActions, dirty is wired via useSettingsUnsavedGuard (called before any early-return gate), and there is no hand-rolled Save button / beforeunload / "Unsaved changes" modal.
  • No business logic, handlers, or conditional rendering changed by the migration.
  • No literal text-[Npx] classes — named scale tokens only (see "Text-scale tokens" above).
  • tsc, biome, and the page's tests pass.