Skip to content

Commit 66ab4f7

Browse files
committed
docs(agents): consolidate guidance and add the local gate to CLAUDE.md
- CLAUDE.md: "How your work is checked" (the local gate and a rule-to-check map), sharper comment rules (no narration, restated names, or change history), drop filler, fix the type-check command description. - Rules: one source for the text scale (sim-styling), use-client boundary (sim-queries), testing principles (CLAUDE.md); fix an in-place sort in a list-ordering example and an ESLint directive the repo does not use; drop change-history narration and rotting line-number references.
1 parent c947017 commit 66ab4f7

10 files changed

Lines changed: 63 additions & 59 deletions

‎.claude/rules/emcn-components.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
2121

2222
## Component catalogue
2323

24-
- **`Chip` / `ChipLink`** — the pill button (`<button>` / Next `<Link>`). Variants: `primary`, `destructive`, `border-shadow`, `border`, `outline` (a true `--border` border, no shadow or hover fill); the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`. The old `mx-0.5` default and its `flush` opt-out are gone; do not reintroduce either, and never add a margin to a chip through `className`.
24+
- **`Chip` / `ChipLink`** — the pill button (`<button>` / Next `<Link>`). Variants: `primary`, `destructive`, `border-shadow`, `border`, `outline` (a true `--border` border, no shadow or hover fill); the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`, so never give a chip a margin, by default or through `className`.
2525
- **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner `<input>`); `className` styles the chrome wrapper.
2626
- **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit.
2727
- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
@@ -52,8 +52,8 @@ Declare keyboard intent on the action-owning primitive; never add document-level
5252
- **Props over `className` overrides.** When a consumer needs to change chrome, expose a prop (`error`, `icon`, `endAdornment`, `inputClassName`); reaching for `className` to restyle chrome is the smell.
5353
- **`cn()` for a single state toggle, CVA for genuine multiple variants.** A lone `error` boolean is `cn()`, not a CVA variant.
5454
- **Discriminated-union props for modes** (e.g. `multiple`, the modal field `type`) instead of near-duplicate components.
55-
- **Delete legacy variants after migration** — don't leave dead paths (this paradigm removed `Input variant='chip'` and `ChipMultiSelect`).
56-
- **Verify CSS vars exist.** An undefined var resolves to `currentColor` (caused a real black-border bug). Align to the canonical tokens: normal weight, `--text-body`, `--text-icon`.
55+
- **Delete legacy variants after migration** — leave no dead paths.
56+
- **Verify CSS vars exist.** An undefined var resolves to `currentColor`, which draws black borders. Align to the canonical tokens: normal weight, `--text-body`, `--text-icon`.
5757
- Use Radix UI primitives for accessibility. Export the component and its `variants` (when using CVA). Document with TSDoc + a usage example.
5858

5959
Color tokens and icon-size conventions are canonical in `.claude/rules/sim-styling.md` — follow it rather than restating.

‎.claude/rules/sim-architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Repository layout, package boundaries, the application operation boundary, namin
1010

1111
## The `'use client'` server boundary
1212

13-
Every export of a `'use client'` module becomes a *client reference* on the server — server-evaluated code (RSC pages/layouts, `prefetch.ts`, route handlers, block definitions, triggers) can only *render* it as a component or pass it as a prop, never *call* it (doing so throws at runtime, e.g. `tableKeys.list is not a function`; `next build` does not catch it). Keep server-importable query primitives (key factories, fetchers, mappers, constants) in non-`'use client'` modules — see `.claude/rules/sim-queries.md`. Enforced by `scripts/check-client-boundary-imports.ts`.
13+
Server-evaluated code (RSC pages/layouts, `prefetch.ts`, route handlers, block definitions, triggers) can render an export of a `'use client'` module but never call it; the call throws at runtime and `next build` does not catch it. Keep anything a server module calls (key factories, fetchers, mappers, constants) in a non-`'use client'` module. Detail and the escape hatch: `.claude/rules/sim-queries.md`. Enforced by `bun run check:client-boundary`.
1414

1515
## The app/worker runtime boundary
1616

‎.claude/rules/sim-components.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ export function Component({ requiredProp, optionalProp = false }: ComponentProps
3232

3333
When rendering or sorting a list of rows against a lookup collection (members, folders, tags), keep the per-row work O(1):
3434

35-
- **Precompute a lookup `Map` once**, never `array.find(...)` per row. Build `const byId = useMemo(() => { const m = new Map<string, T>(); for (const x of items ?? []) m.set(x.id, x); return m }, [items])` and read `byId.get(id)` in the sort comparator, `.map(...)`, and cell builders. A `.find` inside a sort comparator is O(n²·log n) — the worst offender. Depend memos on the derived `Map`, not the raw array.
36-
- **Sort a copy with `[...array].sort(cmp)`**, never `toSorted` in client code — see `sim-react-performance.md` → "Never mutate a shared array in place".
35+
- **Precompute a lookup `Map` once** (`useMemo` over the collection) and read `byId.get(id)` in the sort comparator, `.map(...)`, and cell builders. A `.find` inside a sort comparator is O(n²·log n). Depend downstream memos on the derived `Map`, not the raw array. Map-building and copy-then-sort rules: `sim-react-performance.md`.
3736
- **Partition in a single pass** — when splitting one collection into several (`fileIds`/`folderIds`), do one `for…of` pushing into each bucket and return `{ a, b }` from a single `useMemo`, not two memos that each `map→filter→map` the same source twice.
3837

3938
## react-doctor (`bunx react-doctor`) — apply the wins, skip the false positives

‎.claude/rules/sim-list-ordering.md‎

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ paths:
1010

1111
**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in a toolbar, in the settings nav, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time.
1212

13-
This is not a style preference. Order is the cheapest affordance a list has, and the only one that costs nothing to get right.
14-
1513
## The rule
1614

1715
Before writing a list of items, find where the user sees those same items *first*. That surface owns the order; your list mirrors it.
@@ -94,14 +92,10 @@ const hasDestructiveSection = (showLeave && onLeave) || showDelete
9492
{hasActionsAboveDestructive && hasDestructiveSection && <DropdownMenuSeparator />}
9593
```
9694

97-
This is the failure that put a dangling rule at the bottom of the logs row menu, where two
98-
unconditional separators sat above conditional items.
95+
Unconditional separators above conditional items leave a dangling rule at the bottom of the menu.
9996

100-
**Do not add a prop to move a rule.** The shared workflow context menu grew
101-
`groupNonDestructiveActions` and `separateNavigationAction` for this; between them they moved one
102-
separator for one caller, four of six branches were unreachable, and `separateNavigationAction`
103-
had no observable effect anywhere in the repo. Both are gone. A menu that wants different
104-
grouping wants the standard grouping.
97+
**Never add a prop to move a rule.** Per-caller grouping props multiply unreachable branches; a
98+
menu that wants different grouping wants the standard grouping.
10599

106100
## Encode the order once
107101

@@ -131,7 +125,7 @@ The most common way a canonical order gets silently defeated: emitting all items
131125
{groups.filter((g) => !FOLDERED.has(g.type)).map(renderFlat)}
132126

133127
// ✓ Good — one ordered pass; each entry picks its own rendering
134-
{entries.sort(byResourceMenuOrder).map((entry) =>
128+
{[...entries].sort(byResourceMenuOrder).map((entry) =>
135129
sectionByType.has(entry.type) ? renderTree(entry) : renderFlat(entry)
136130
)}
137131
```

‎.claude/rules/sim-queries.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,6 @@ const handler = useCallback(() => {
140140
// ✓ Good — omit from deps, mutate is stable
141141
const handler = useCallback(() => {
142142
createEntity.mutate(data)
143-
// eslint-disable-next-line react-hooks/exhaustive-deps
144143
}, [data])
145144
```
146145

‎.claude/rules/sim-settings-pages.md‎

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -113,17 +113,9 @@ Adding a new settings page:
113113

114114
## Text-scale tokens (no literal pixel sizes)
115115

116-
Settings pages never use a literal `text-[Npx]` class — always the named Tailwind
117-
scale token from the `@theme` block in `apps/sim/app/_styles/globals.css` (`text-micro`
118-
10px, `text-xs` 11px, `text-caption` 12px, `text-small` 13px, `text-sm` 14px
119-
[Tailwind default, unmodified], `text-base` 15px, `text-md` 16px, `text-lg` 18px
120-
[Tailwind default]). A literal size is either a straight rename to the equivalent
121-
token (if the pixel value matches one exactly) or a sign the page never migrated —
122-
grep `text-\[1[0-8]px\]` under `apps/sim/app/workspace/*/settings/**` and
123-
`apps/sim/ee/**` to find stragglers.
124-
125-
Watch `text-xs`: it is 11px here, so a "caption" written as `text-xs` is a pixel
126-
short. See `sim-styling.md` for the full scale.
116+
Settings pages never use a literal `text-[Npx]` class — always a named token from
117+
the scale in `sim-styling.md` ("Text Scale"). Find stragglers with
118+
`rg 'text-\[1[0-8]px\]' apps/sim/app/workspace apps/sim/ee`.
127119

128120
The two-line list row (title over a muted subtitle — a name + email, a tool name
129121
+ description, a server name + status) is **not something you build**: it is
@@ -402,4 +394,4 @@ A settings page is design-system-clean when:
402394
- [ ] Decorative trailing content is in `badge`, not `trailing`.
403395
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/error use `SettingsEmptyState`.
404396
- [ ] Delete is a plain `id:'delete'` header action behind a `ChipConfirmModal`; `destructive` is reserved for bulk actions.
405-
- [ ] `tsc`, `biome`, and the page's tests pass.
397+
- [ ] The local gate in the root `CLAUDE.md` ("How your work is checked") passes.

‎.claude/rules/sim-styling.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ paths:
1515
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
1616
3. **Exact values over approximations** - `h-[26px]`, not `h-6`. But **type size is
1717
always a named token** (`text-sm`, `text-caption`) — never `text-[14px]`, which
18-
sets font-size only and inherits a different line-height. See
19-
`sim-settings-pages.md` for the scale.
18+
sets font-size only and inherits a different line-height. The scale is under
19+
"Text Scale" below.
2020
4. **Transitions** - `transition-colors` for interactive states
2121

2222
## Conditional Classes
@@ -48,7 +48,7 @@ setWidth: (width) => {
4848

4949
## Text Scale
5050

51-
Custom font sizes (the `@theme` block in `apps/sim/app/_styles/globals.css`): `text-micro`=10px, `text-xs`=11px, `text-caption`=12px, `text-small`=13px, `text-base`=15px. `text-sm` is Tailwind default 14px. Field titles use `text-small` (13px); hints/errors use `text-caption` (12px).
51+
The `@theme` block in `apps/sim/app/_styles/globals.css`: `text-micro` 10px, `text-xs` 11px, `text-caption` 12px, `text-small` 13px, `text-sm` 14px (Tailwind default), `text-base` 15px, `text-md` 16px, `text-lg` 18px (Tailwind default). `text-xs` is 11px here, so a caption written as `text-xs` is a pixel short. Field titles use `text-small`; hints/errors use `text-caption`. A literal `text-[Npx]` is either a rename to the matching token or an unmigrated straggler.
5252

5353
Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N` or `h-[Npx] w-[Npx]`.
5454

@@ -70,7 +70,7 @@ A scroll region that can hide rows past an edge uses `useScrollEdges` with `scro
7070

7171
Three steps, Tailwind's stock scale, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class only to step *up* from body.
7272

73-
Never write an arbitrary weight (`font-[380]`, `font-[430]`, `font-[450]`, …), and never set `fontWeight` in an inline `style`. There was previously a CSS-variable weight scale (`--font-weight-base/medium/semibold`, remapping `font-medium` to 440/480) plus seven ad-hoc values clustered between 380 and 500; it was deleted because nothing read as hierarchical. Off-scale values are only acceptable where the design system genuinely cannot reach — react-email templates and the static `apps/sim/emails/broadcasts/*.html`, which email clients render without CSS variables.
73+
Never write an arbitrary weight (`font-[380]`, `font-[450]`, …) or a `--font-weight-*` variable, and never set `fontWeight` in an inline `style`: values clustered between the three steps do not read as hierarchy. Off-scale values are acceptable only where the design system cannot reach — react-email templates and the static `apps/sim/emails/broadcasts/*.html`, which email clients render without CSS variables.
7474

7575
Headings inherit their weight. Tailwind preflight resets `h1`–`h6` to `font-weight: inherit`, so an `<h1>` is 400 unless you say otherwise — that is the intended look, not a bug to patch.
7676

@@ -95,19 +95,19 @@ Draw a line with a real `border-*` utility. Never hand-roll one as `shadow-[inse
9595
- **Errors** → `error` prop. Never `className={cn(err && 'border-[var(--text-error)]')}`.
9696
- **Leading icon** → `icon` prop (rendered 14px in `--text-icon`).
9797
- **Trailing buttons** (reveal/copy/fetch) → `endAdornment`.
98-
- **Inner-input styling** (e.g. `font-mono`, number-spinner reset) → `inputClassName` (ChipInput only). See `app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx:117`.
99-
- **`ChipModalField` controls take NO className.** Pass `title`/`value`/`onChange`/`error`/`hint`/`required`/`flush`. The field owns label, control, and error/hint rendering. See `app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx:170`.
98+
- **Inner-input styling** (e.g. `font-mono`, number-spinner reset) → `inputClassName` (ChipInput only). See `app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx`.
99+
- **`ChipModalField` controls take NO className.** Pass `title`/`value`/`onChange`/`error`/`hint`/`required`/`flush`. The field owns label, control, and error/hint rendering. See `app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx`.
100100

101101
### What className MAY carry
102102

103-
Layout/sizing ONLY: `flex-1`, `w-full`, `w-[Npx]`, `min-w-0`, `max-w-*`, margins. `truncate` is allowed only for the explicit overflow exceptions above, never as a general layout class. Example: `<ChipInput icon={Search} className='min-w-0 flex-1' .../>` (`app/workspace/[workspaceId]/integrations/integrations.tsx:257`). NEVER re-specify canonical chrome — the component already applies it.
103+
Layout/sizing ONLY: `flex-1`, `w-full`, `w-[Npx]`, `min-w-0`, `max-w-*`, margins. `truncate` is allowed only for the explicit overflow exceptions above, never as a general layout class. Example: `<ChipInput icon={Search} className='min-w-0 flex-1' .../>` (`app/workspace/[workspaceId]/integrations/integrations.tsx`). NEVER re-specify canonical chrome — the component already applies it.
104104

105105
### Form / chip-modal layout rhythm
106106

107107
- **Field row** = `ChipModalField`: label↔control `gap-[9px]`, field gutter `px-2` (`px-0` when `flush`). Title = `Label` at `text-small` (13px), muted, normal weight; hint/error at `text-caption` (12px).
108108
- **Modal body** (`ChipModalBody`): `gap-4` between fields, padding `px-2 pt-4 pb-4.5`.
109109
- **Header/footer**: horizontal gutter `px-4` (header `pt-3`; footer `px-4 pt-2 pb-2`, tinted bar).
110-
- **Every body field MUST be a `ChipModalField`** — NEVER hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). WHY: body `px-2` + field `px-2` = effective `px-4`, exactly matching the `px-4` header/footer. A hand-rolled row skips the field gutter, sits at `px-2`, and is visibly misaligned (this bug shipped in the scheduled-tasks "Create new scheduled task" modal). Inline errors go through the `error` prop, not a hand-rolled `<p>`.
110+
- **Every body field MUST be a `ChipModalField`** — NEVER hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). WHY: body `px-2` + field `px-2` = effective `px-4`, exactly matching the `px-4` header/footer. A hand-rolled row skips the field gutter, sits at `px-2`, and is visibly misaligned. Inline errors go through the `error` prop, not a hand-rolled `<p>`.
111111
- **Uncovered controls** (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX) → `ChipModalField type='custom'` with a `title`. It still applies the `px-2` gutter and renders the canonical `Label`, so it stays aligned. Never drop such a control into a raw `<div>`, and never add a body-level wrapper `<div>` with a custom `gap-*` that fights `gap-4`.
112112
- **Page section rhythm** (integrations/skills/settings): muted `text-small` label + `mt-[9px] mb-3 h-px bg-[var(--border)]` divider, sections stacked `gap-7`. Reuse `SettingsSection` (`app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx`) rather than re-deriving it.
113113

‎.claude/rules/sim-testing.md‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,9 @@ paths:
99

1010
# Testing
1111

12-
Before writing or changing any test, run the `test-audit` skill's authoring gate. The short
13-
version: never write unit tests after the code; prefer E2E tests at the real boundary and end
14-
them with a verifiable artifact; if you must test in isolation, write the failure modes down
15-
first. A test that cannot name the bug it catches does not get written.
12+
The testing principles are in the root `CLAUDE.md` → Testing. Before writing or changing any test,
13+
run the `test-audit` skill's authoring gate: a test that cannot name the bug it catches does not
14+
get written.
1615

1716
## Layers
1817

@@ -27,9 +26,7 @@ What already catches bugs, in the order to reach for it:
2726

2827
A unit test is justified only for a failure the layers above cannot see: security boundaries,
2928
billing math, executor semantics, parsers and algorithms with edge cases, cross-process wire
30-
contracts, and demonstrated regressions. Never restate declarations (block/tool/provider config,
31-
registries, constants, schemas accepting valid input), assert that mocks were called, check
32-
rendered text or class names, or test mocks and factories themselves.
29+
contracts, and demonstrated regressions.
3330

3431
## Naming
3532

‎.claude/rules/sim-url-state.md‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,7 @@ URL query state is managed with [`nuqs`](https://nuqs.dev). The `NuqsAdapter` is
1414

1515
## Decision framework — where does this state live?
1616

17-
Pick exactly one home for each piece of state:
18-
19-
- **React Query** → server/remote data. Unchanged; see `.claude/rules/sim-queries.md`.
20-
- **URL params (nuqs)** → client view-state worth putting in a link: active tab/panel, selected entity id, filters, search query, pagination, view mode (list/grid), an open "view" drawer/modal that represents a destination.
21-
- **Zustand** → cross-component client state that must NOT be in the URL: high-frequency, large, ephemeral, or socket-synced (canvas pan/zoom, cursor, drag state, resize widths, unsaved buffers, live collaborative selection).
22-
- **`useState`** → purely local, single-component UI.
23-
24-
Put state in the URL **only** when it is *all* of: shareable, deep-linkable, bookmarkable, survives reload + back/forward — **and** is discrete, low-frequency, and small. If it fails any of those, it does not go in the URL.
25-
26-
### When to use what (decision table)
17+
Pick exactly one home for each piece of state (table below). Put state in the URL **only** when it is *all* of: shareable, deep-linkable, bookmarkable, survives reload + back/forward — **and** is discrete, low-frequency, and small. If it fails any of those, it does not go in the URL.
2718

2819
| Home | Trigger | Example |
2920
| --- | --- | --- |

0 commit comments

Comments
 (0)