Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 132 additions & 33 deletions .claude/rules/sim-settings-pages.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
---
paths:
- "apps/sim/app/workspace/*/settings/**"
- "apps/sim/app/workspace/*/{integrations,skills,upgrade}/**"
- "apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**"
- "apps/sim/components/{settings,permissions}/**"
- "apps/sim/ee/**/components/**"
---

Expand All @@ -20,7 +23,7 @@ Do NOT hand-roll any of these in a settings page — they are owned by the layou
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 header bar — compose `PAGE_HEADER_BAR` (`@/components/page-header-bar`); never rewrite its padding
- 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'>`)
Expand Down Expand Up @@ -55,21 +58,23 @@ return (
## `SettingsPanel` props

- `actions?: SettingsAction[]` — right-aligned header chips, **data only**:
`{ text, icon?, variant?: 'primary'|'destructive', active?, onSelect, disabled?, tooltip? }`.
`{ id?, text, textTone?: 'error', icon?, variant?: 'primary'|'destructive', active?,
onSelect, onPrefetch?, 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`.
`tooltip` (the shell renders the hover tooltip, disabled chip included). An action
that wants to warm a route on hover sets `onPrefetch`; the shell wires it. A label
that flips while pending (`Delete` → `Deleting...`) sets a stable `id`, or the chip
remounts mid-action. Save/Discard pairs come from the `saveDiscardActions()`
helper (spread it into `actions`).
- `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.
- `docsLink?: string` — renders the header's `Docs` `ChipLink`.
- `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
Expand All @@ -82,15 +87,15 @@ return (

## 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
`apps/sim/components/settings/navigation.ts` is the single source of truth (the
`settings/navigation.ts` in the route tree is only a re-export shim). Every `NavigationItem` carries a one-line `description`; `SettingsPanel`
resolves both via `getSettingsSectionMeta(plane, 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,
1. Add the section id to the `UnifiedSettingsSection` union + a `NavigationItem`
(with `label` **and** `description`) in `components/settings/navigation.ts`. Keep descriptions verb-first, one line,
~40–55 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`.
Expand All @@ -107,19 +112,14 @@ 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:
Watch `text-xs`: it is 11px here, so a "caption" written as `text-xs` is a pixel
short. See `sim-styling.md` for the full scale.

- **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.
The two-line list row (title over a muted subtitle — a name + email, a tool name
+ description, a server name + status) is **not something you build**: it is
`SettingsResourceRow`, which owns the pairing
(`text-[var(--text-body)] text-sm` over `text-[var(--text-muted)] text-caption`).
See "The resource row" below.

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
Expand All @@ -145,21 +145,114 @@ 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.

## The resource row

**`SettingsResourceRow`** (`…/components/settings-resource-row`) is *the* list row
for every settings resource — and for skills, integrations, and the `ee/` surfaces
too. It owns the tile, the title/subtitle tokens, the row padding and bleed
(`-mx-2 … rounded-lg p-2`), the hit area, the focus ring, the navigation chevron,
and — on activatable rows only — the hover band. Never hand-roll any of it, and never wrap the row in your own
`<button>` or `<Link>` — that is what `onClick`/`href` are for.

```tsx
<div className={RESOURCE_LIST_STACK}>
{items.map((item) => (
<SettingsResourceRow
key={item.id}
icon={<Wrench className='text-[var(--text-icon)]' />}
iconFilled
title={item.name}
description={item.summary}
onClick={() => open(item.id)} // or href={`…/${item.id}`}
clickLabel={`Open ${item.name}`}
navigable
/>
))}
</div>
```

- `icon?` + `iconVariant` — `tile` (default, the 36px bordered tile), `plain` (a
bare 14px glyph), `custom` (you supply the whole tile, e.g. the brand-tinted
`IntegrationTile`). Omit `icon` entirely for resources with no identity glyph
(an API key, a permission group). `iconFilled` uses the skills/tools fill;
`iconFill` lets an uploaded image reach the tile edge.
- `onClick` / `href` — makes the **whole row** activatable via a stretched
overlay. Prefer `href` when the destination is a route, so the row keeps
prefetch, middle-click, and open-in-new-tab. Always pass `clickLabel` with
either — the overlay holds no text, so it is the control's *only* accessible
name. The prop is optional in the type (nothing enforces it), so omitting it
ships a nameless button rather than failing the build.
- `navigable` — appends the one canonical chevron. Set it on rows that open a
detail page; leave it off when `onClick` acts in place (revealing a folder).
Never import an arrow yourself: `lucide-react` and `@sim/emcn/icons` ship
visibly different glyphs, and the row already picked one. A sanctioned bespoke
row (below) draws it with `RESOURCE_ROW_ARROW_CLASSES` from the same module.
- `trailing` vs `badge` — `trailing` is for **interactive** controls (a `Chip`, a
`RowActionsMenu`) and sits above the hit area. `badge` is for **decoration** (a
status tag) and is click-through. Putting a badge in `trailing` turns the row's
right edge into a dead zone.
- `RESOURCE_LIST_STACK` / `RESOURCE_LIST_GRID` — the single-column and two-up
containers. A `SettingsResourceRow` carries its own `-mx-2` bleed and padding,
so a container holding one only sets rhythm: never add a second `-mx-2` (they
stack into a 16px bleed) and never a different gap. A list of hand-rolled rows
is the opposite — there the container owns the bleed. A row inside a
fixed-height `overflow-y-auto` box, or a heading that must line up with the
section labels under it, passes `flush` to drop the bleed and padding.
`RESOURCE_LIST_GRID` budgets its column gap for the bleed (24px of track gap
minus 16px of bleed = an 8px gutter); narrowing that gap makes neighbouring
rows — and their stretched hit areas — overlap, so a click in the gutter opens
the wrong card.

**Three-dots vs. chevron** is not a taste call:

- Opens a **detail page** → `navigable` + a whole-row click, and no `Delete` in
the row (it lives in the detail header).
- **No detail page** → a `RowActionsMenu` in `trailing`, and no `navigable`
chevron. The row may still take an `onClick` for an in-place action — a folder
mount reveals itself in Finder and also carries a `...` menu — but a row must
never offer both a chevron and a menu.

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

- **`SettingsSection`** (`…/components/settings-section/settings-section` — this
directory has no barrel) — muted label, hairline
divider, body. Also carries `headerAccessory` and `action` slots. Never
re-derive the label/divider chrome; `sim-styling.md` owns those tokens.
- **`SettingsField`** (`…/components/settings-field`) — a read-only label/value
pair in a detail body: muted caption over the value. Pair it with
`SETTINGS_FIELD_VALUE_CLASSES` for the value text.
- **`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.
muted status message, for empty lists, "no results", loading gates, **and
failed loads** (`tone='error'`). `variant='fill'` (default) centers in the
available height; `variant='inline'` sits in flow. Never hand-roll
`<div className='flex h-full items-center justify-center …'>` or
`<div className='py-4 text-center …'>`.
- **`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.
- **`RESOURCE_TILE_BASE`** + one of `RESOURCE_TILE_FILL` / `RESOURCE_TILE_PLAIN`
(`app/workspace/[workspaceId]/components/resource-tile` — note: *not* under
`settings/`, unlike the other `…/` paths on this page) — the 36px tile chrome, for
any tile the row does not draw itself: a detail heading, or a caller-supplied
`iconVariant='custom'` tile. `ResourceTile` wraps the filled pairing. Use
`RESOURCE_TILE_FILL` for a glyph, `RESOURCE_TILE_PLAIN` for a brand logo or favicon.


**Member avatars are deliberately two components, not one.** `member-list.tsx`
renders a 14px neutral marker for the dense Teammates/Organization roster, where
the email is the primary content; `components/permissions/member-row.tsx` renders
a 36px `getUserColor`-hashed avatar for member *management* rows that carry a name,
an email, and a role control. Same shape, different job — do not merge them.

## Deleting a resource

Delete lives in the **detail header**, as
`{ text: 'Delete', variant: 'destructive', onSelect: … }` behind a
`ChipConfirmModal` — never `textTone: 'error'`, never a bare `Chip`, and never
unconfirmed. A list row does not carry Delete when the resource has a detail page.

## Save / Discard + unsaved-changes guard

Expand Down Expand Up @@ -208,8 +301,9 @@ shells. Reach for it before hand-rolling a `Chip`.
(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`.
- **`useSettingsBeforeUnload`** is mounted by the settings shells
(`settings/layout.tsx` and `components/settings/standalone-settings-shell.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`.
Expand Down Expand Up @@ -244,6 +338,11 @@ A settings page is design-system-clean when:
- [ ] 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 business logic, handlers, or conditional rendering changed by the migration — except where the shared primitive makes a gate structural (a permission gate becomes `onClick={can ? … : undefined}` + `navigable={can}`, which renders a plain non-interactive row).
- [ ] No literal `text-[Npx]` classes — named scale tokens only (see "Text-scale tokens" above).
- [ ] Every **resource** list row (a thing with an identity — a tool, a server, a key, a credential) is a `SettingsResourceRow` in a `RESOURCE_LIST_STACK`/`RESOURCE_LIST_GRID` — no wrapper `<button>`/`<Link>`, no hand-passed arrow, no re-derived title/subtitle spans. Rows with a genuinely different shape stay bespoke, and draw their own arrow with `RESOURCE_ROW_ARROW_CLASSES`: multi-line bodies (inbox tasks), tabular columns (billing invoices, credit usage), grids (secrets), and the member rows (see the avatar note above).
- [ ] Rows that open a detail page use `navigable` + `clickLabel`; flat records use `RowActionsMenu`. Not both.
- [ ] Decorative trailing content is in `badge`, not `trailing`.
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/error use `SettingsEmptyState`.
- [ ] Delete is a `destructive` header action behind a `ChipConfirmModal`.
- [ ] `tsc`, `biome`, and the page's tests pass.
8 changes: 6 additions & 2 deletions .claude/rules/sim-styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ paths:

## Tailwind

1. **No inline styles** - Use Tailwind classes
1. **No inline styles** - Use Tailwind classes. Exception: a genuinely dynamic
value (a hashed avatar colour, a brand tile background) that cannot be a class.
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
3. **Exact values** - `text-[14px]`, `h-[26px]`
3. **Exact values over approximations** - `h-[26px]`, not `h-6`. But **type size is
always a named token** (`text-sm`, `text-caption`) — never `text-[14px]`, which
sets font-size only and inherits a different line-height. See
`sim-settings-pages.md` for the scale.
4. **Transitions** - `transition-colors` for interactive states

## Conditional Classes
Expand Down
34 changes: 32 additions & 2 deletions .claude/skills/add-settings-page/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ skill is the procedure.

Key paths:
- Layout primitive: `apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx`
- Nav metadata (titles + descriptions): `apps/sim/app/workspace/[workspaceId]/settings/navigation.ts`
- Nav metadata (titles + descriptions): `apps/sim/components/settings/navigation.ts`
- Section switch + provider: `apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx`
- Pages: `apps/sim/app/workspace/[workspaceId]/settings/components/<name>/<name>.tsx` and EE pages under `apps/sim/ee/<feature>/components/`

Expand Down Expand Up @@ -53,7 +53,9 @@ For each page component, confirm the checklist in `.claude/rules/sim-settings-pa
3. Find literal pixel text sizes (should be 0 — see "Text-scale tokens" in
`.claude/rules/sim-settings-pages.md` for the token map and the row
title/subtitle pairing convention):
`git grep -n "text-\[1[0-8]px\]" -- 'apps/sim/**/settings/' 'apps/sim/ee/'`
`git grep -nE "text-\[1[0-8]px\]" -- 'apps/sim/**/settings/' 'apps/sim/ee/'` — should
be 0. Display type above the scale (`text-[40px]` hero headings, the `text-[8px]`
member-avatar initial) is deliberate and out of scope.
4. Confirm each page imports `SettingsPanel` and that its `NavigationItem` has an
accurate `description` of consistent length with its peers.
- Editable pages: confirm Save/Discard go through `SaveDiscardActions` and
Expand All @@ -77,3 +79,31 @@ For each page component, confirm the checklist in `.claude/rules/sim-settings-pa
8. **Verify the whole sweep:** `tsc --noEmit`, `biome check` on every touched
file, and run the affected pages' tests. Diff each file against the base and
confirm the change is purely structural before shipping.

## Mode C — Migrate list rows to `SettingsResourceRow`

Read "The resource row" in `.claude/rules/sim-settings-pages.md` first — it is the
contract. Then, per page:

1. Find hand-rolled rows:
`git grep -n "truncate text-\[var(--text-body)\] text-sm" -- 'apps/sim/app/workspace/' 'apps/sim/ee/'`
Every match outside `settings-resource-row.tsx` is either a row to migrate or a
genuinely different shape (multi-line body, tabular columns, a grid) that stays
bespoke — decide which, and say so.
2. Replace the row *and* its wrapper: a `<button>`/`<Link>` around the row becomes
`onClick`/`href` on the row itself. Wrapping the row is what the primitive
exists to stop — it is also invalid HTML once `trailing` holds a control.
3. Sort the trailing content: interactive → `trailing`, decorative → `badge`.
Getting this backwards makes the row's right edge a dead zone.
4. Add `navigable` only if the row opens a detail page, and `clickLabel` always.
5. Drop the container's `-mx-2` — the row now owns the bleed. Use
`RESOURCE_LIST_STACK` / `RESOURCE_LIST_GRID`; do not hand-write the gap.
6. Unlike Mode B, this migration **may** change conditional rendering: a
`<button disabled={!can}>` becomes `onClick={can ? … : undefined}` +
`navigable={can}`, which renders a plain non-interactive row. Verify the gated
state has no clickable affordance left.
7. Check what the old row rendered *beside* the title (a badge, a timestamp, a
transport label). The row's title truncates as one unit, so anything folded
into it can be ellipsised away — move it to `description` or `badge`.
8. Verify: `tsc --noEmit`, `biome check`, the page's tests, and a diff read of
every converted block for lost props, conditions, and `key` placement.
Loading
Loading