Skip to content

Commit bbb9048

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
refactor(design): simplify conformance tooling and studio samples
1 parent 4b3f877 commit bbb9048

32 files changed

Lines changed: 177 additions & 2747 deletions

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

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,13 @@ Canonical look: normal font-weight (never `font-medium`/`font-semibold`), value
1919

2020
The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items use `text-small` and `gap-2` (a menu convention, not the chip pill). Keep them distinct.
2121

22-
## Component catalogue
23-
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`.
25-
- **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner `<input>`); `className` styles the chrome wrapper.
26-
- **`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.
27-
- **`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`).
28-
- **`ChipDropdown`** — pill that opens a menu. Single OR multi-select via the discriminated `multiple` prop (one component, not two). Owns its trailing chevron — no `rightIcon`.
29-
- **`ChipSelect` / `ChipCombobox`** — `Combobox`-backed pickers with search, groups, multi-select; for richer lists than `ChipDropdown`.
30-
- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` (with a `title`) is the escape hatch for controls the field doesn't cover. Every body field is a `ChipModalField`; the gutter rhythm that makes this matter is in `sim-styling.md` → "Form / chip-modal layout rhythm".
31-
- **`ChipSwitch`** — segmented pill control (built from `chipVariants`).
32-
- **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger.
33-
- **`ChipDatePicker`** — chip-styled date field.
34-
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
35-
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
36-
- **`useScrollEdges` + `scrollFadeClass` / `scrollFadeAttributes`** — the canonical scroll-region edge treatment. The hook reports which edges hide content (tracking scroll and resizes; pass the element itself, held in state, when the region mounts after its owner, e.g. inside a Radix portal); the class and attributes fade a fixed 12px band at an active edge only, so a list that fits or sits at its top is never fogged. A floating control over the top edge sets `--scroll-fade-inset` to its height. A region that scrolls sideways (a tab row, a chip strip) uses `useScrollEdges(ref, { axis: 'x' })` with `scrollFadeXClass`; the attributes helper is shared. Any divider beside the region belongs to the neighboring block (`border-b` above, `border-t` below), never to the masked element, and shows only while that edge is active. Never hand-roll a `mask-image` gradient for a scroll region.
37-
- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, fade-only clipping (never an ellipsis), the conditional 18px edge mask, and the full-value floating tooltip; consumers pass only layout/typography through `className`. `overflowTextClipClass` and `overflowTextFadeClass` are the complete base/faded treatments for the rare component that must own measurement itself; never pair either with `truncate`, `text-ellipsis`, or hover-time mask removal. Use `DropdownMenuItemLabel` for a menu label beside icons, checks, or actions. A non-editable `Combobox` passes the full visual value through `overlayLabel`; the combobox owns the visual overlay's fade and keeps its one accessible tooltip on the interactive layer. Keep ordinary `truncate` only for editable values, code/log/path content, dense or virtualized grids, and rich composite content that cannot supply a plain tooltip label. Multiline copy uses an intentional `line-clamp-*` treatment instead.
22+
## Public components and design behavior
23+
24+
The current public exports, variants, defaults, styling slots, and relationships are generated from source in `scripts/design-conformance/contracts.generated.json`. Run `bun run design:generate` when EMCN or `globals.css` changes. Browse and interact with the visual exports in the local Design Studio after `bun run studio:refresh`; do not maintain an export list here.
25+
26+
Use chip fields and triggers for product forms, and `DropdownMenu` for context and action lists. Prefer a supported prop to a `className` chrome override. A selected chip uses `active`; outer spacing belongs to its parent. A view-only value stays at full opacity and uses the read-only copy field or `ChipModalField type='copy'`, not a disabled input. `ChipModalField` owns its inner control chrome; use `type='custom'` with a title for a control it does not cover. A chip dropdown owns its chevron and supports single or multi-select through `multiple`; use chip select or combobox for searchable or grouped lists. Inline tags are labels, not pill triggers.
27+
28+
For scroll-region edge fading use `useScrollEdges` with `scrollFadeClass` or `scrollFadeXClass` and `scrollFadeAttributes`; do not hand-roll a mask. For a constrained, single-line, read-only human label use `OverflowText` or `DropdownMenuItemLabel` in a menu; keep its fade and full-value tooltip instead of adding an ellipsis. Keep editable values, code, logs, paths, dense grids, and composite content on their appropriate overflow treatments. Source and variants in EMCN remain the authority if these examples change.
3829

3930
## Modal keyboard defaults
4031

‎.cursor/rules/emcn-components.mdc‎

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,13 @@ Canonical look: normal font-weight (never `font-medium`/`font-semibold`), value
2020

2121
The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items use `text-small` and `gap-2` (a menu convention, not the chip pill). Keep them distinct.
2222

23-
## Component catalogue
24-
25-
- **`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`.
26-
- **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner `<input>`); `className` styles the chrome wrapper.
27-
- **`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.
28-
- **`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`).
29-
- **`ChipDropdown`** — pill that opens a menu. Single OR multi-select via the discriminated `multiple` prop (one component, not two). Owns its trailing chevron — no `rightIcon`.
30-
- **`ChipSelect` / `ChipCombobox`** — `Combobox`-backed pickers with search, groups, multi-select; for richer lists than `ChipDropdown`.
31-
- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` (with a `title`) is the escape hatch for controls the field doesn't cover. Every body field is a `ChipModalField`; the gutter rhythm that makes this matter is in `sim-styling.md` → "Form / chip-modal layout rhythm".
32-
- **`ChipSwitch`** — segmented pill control (built from `chipVariants`).
33-
- **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger.
34-
- **`ChipDatePicker`** — chip-styled date field.
35-
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
36-
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
37-
- **`useScrollEdges` + `scrollFadeClass` / `scrollFadeAttributes`** — the canonical scroll-region edge treatment. The hook reports which edges hide content (tracking scroll and resizes; pass the element itself, held in state, when the region mounts after its owner, e.g. inside a Radix portal); the class and attributes fade a fixed 12px band at an active edge only, so a list that fits or sits at its top is never fogged. A floating control over the top edge sets `--scroll-fade-inset` to its height. A region that scrolls sideways (a tab row, a chip strip) uses `useScrollEdges(ref, { axis: 'x' })` with `scrollFadeXClass`; the attributes helper is shared. Any divider beside the region belongs to the neighboring block (`border-b` above, `border-t` below), never to the masked element, and shows only while that edge is active. Never hand-roll a `mask-image` gradient for a scroll region.
38-
- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, fade-only clipping (never an ellipsis), the conditional 18px edge mask, and the full-value floating tooltip; consumers pass only layout/typography through `className`. `overflowTextClipClass` and `overflowTextFadeClass` are the complete base/faded treatments for the rare component that must own measurement itself; never pair either with `truncate`, `text-ellipsis`, or hover-time mask removal. Use `DropdownMenuItemLabel` for a menu label beside icons, checks, or actions. A non-editable `Combobox` passes the full visual value through `overlayLabel`; the combobox owns the visual overlay's fade and keeps its one accessible tooltip on the interactive layer. Keep ordinary `truncate` only for editable values, code/log/path content, dense or virtualized grids, and rich composite content that cannot supply a plain tooltip label. Multiline copy uses an intentional `line-clamp-*` treatment instead.
23+
## Public components and design behavior
24+
25+
The current public exports, variants, defaults, styling slots, and relationships are generated from source in `scripts/design-conformance/contracts.generated.json`. Run `bun run design:generate` when EMCN or `globals.css` changes. Browse and interact with the visual exports in the local Design Studio after `bun run studio:refresh`; do not maintain an export list here.
26+
27+
Use chip fields and triggers for product forms, and `DropdownMenu` for context and action lists. Prefer a supported prop to a `className` chrome override. A selected chip uses `active`; outer spacing belongs to its parent. A view-only value stays at full opacity and uses the read-only copy field or `ChipModalField type='copy'`, not a disabled input. `ChipModalField` owns its inner control chrome; use `type='custom'` with a title for a control it does not cover. A chip dropdown owns its chevron and supports single or multi-select through `multiple`; use chip select or combobox for searchable or grouped lists. Inline tags are labels, not pill triggers.
28+
29+
For scroll-region edge fading use `useScrollEdges` with `scrollFadeClass` or `scrollFadeXClass` and `scrollFadeAttributes`; do not hand-roll a mask. For a constrained, single-line, read-only human label use `OverflowText` or `DropdownMenuItemLabel` in a menu; keep its fade and full-value tooltip instead of adding an ellipsis. Keep editable values, code, logs, paths, dense grids, and composite content on their appropriate overflow treatments. Source and variants in EMCN remain the authority if these examples change.
3930

4031
## Modal keyboard defaults
4132

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ The `'use client'` server boundary, the app/worker runtime env split, and featur
106106

107107
## Styling and EMCN
108108

109-
- Tailwind only. Inline `style` only for a genuinely dynamic value or a CSS variable. Never update global styles; keep styling local to the component. `cn()` from `@sim/emcn` for conditional classes. `size-*` for equal height and width (icons default `size-[14px]`), never `h-N w-N`.
109+
- Tailwind only. Inline `style` only for a genuinely dynamic value or a CSS variable. Keep component-specific styling local. Change tokens in `globals.css` deliberately when the shared design decision changes, then run `bun run design:generate`. `cn()` from `@sim/emcn` for conditional classes. `size-*` for equal height and width (icons default `size-[14px]`), never `h-N w-N`.
110110
- Import components, `cn`, and tokens from the `@sim/emcn` barrel; icons from `@sim/emcn/icons`; CSS modules by file path. Never deep-import other component subpaths.
111111
- The chip family is the canonical chrome: `ChipInput`, `ChipTextarea`, `ChipModal`/`ChipModalField`, `ChipSelect`/`ChipCombobox`/`ChipDropdown`, `ChipSwitch`, `ChipDatePicker`, `Chip`/`ChipLink`, `ChipTag`; `DropdownMenu` for context/action menus. Components own their chrome: consumers pass props (`error`, `icon`, `endAdornment`, `inputClassName`) and `className` carries only layout/sizing. Every labeled field inside a `ChipModalBody` is a `ChipModalField`.
112112
- Consumer rules, tokens, text scale, and modal rhythm: `.claude/rules/sim-styling.md`. Authoring components in `packages/emcn`: `.claude/rules/emcn-components.md`. Product UI copy: `.claude/rules/sim-ui-copy.md`. Marketing copy and positioning: `.claude/rules/constitution.md`.

‎scripts/check-design-conformance-colour-assignments.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,6 @@ test('landing remains excluded, source is never executed and discovery order is
408408
const forward = inspectControlAnalysis(files).colourAssignments
409409
const reverse = inspectControlAnalysis(
410410
{ ...files, entries: [...files.entries].reverse() },
411-
[],
412411
'reverse'
413412
).colourAssignments
414413
expect(reverse).toEqual(forward)

‎scripts/check-design-conformance-control-comparison.test.ts‎

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -280,38 +280,6 @@ test('registered EMCN chrome is reported once when both analysis passes see it',
280280
).toHaveLength(1)
281281
})
282282

283-
test('changing a reviewed effect recipe or deleting its colour revokes approval despite unchanged shadow text', async () => {
284-
const repo = mkdtempSync(path.join(temp, 'shadow-extra-'))
285-
git(repo, ['init', '-q'])
286-
const global = 'apps/sim/app/_styles/globals.css'
287-
const css =
288-
'apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css'
289-
const tokens = ':root { --selection-bg: #add6ff; --shadow-subtle: 0 2px 4px #000; }'
290-
const rule =
291-
'.rich-markdown-nodes hr.rich-leaf-in-selection { box-shadow: 0 0 0 0.4em var(--selection-bg); border-radius: 1px; }'
292-
put(repo, global, tokens)
293-
const empty = commit(repo)
294-
put(repo, css, rule)
295-
const base = commit(repo)
296-
const introduced = await checkComparison({ repo, base: empty, head: base })
297-
expect(introduced.shadowExtras).toHaveLength(1)
298-
expect(introduced.findings.some((f) => f.rule === 'local-shadow')).toBe(true)
299-
expect(introduced.findings.filter((f) => f.rule === 'central-shadow')).toEqual([])
300-
put(repo, css, rule.replace('border-radius: 1px', 'border-radius: 2px'))
301-
const changed = commit(repo)
302-
const report = await checkComparison({ repo, base, head: changed })
303-
expect(report.findings.filter((f) => f.rule === 'central-shadow')).toHaveLength(1)
304-
expect(report.shadowExtras).toHaveLength(0)
305-
put(repo, css, rule)
306-
put(repo, global, tokens.replace('--selection-bg: #add6ff;', ''))
307-
const removed = commit(repo)
308-
expect(
309-
(await checkComparison({ repo, base, head: removed })).findings.filter(
310-
(f) => f.rule === 'central-shadow'
311-
)
312-
).toHaveLength(1)
313-
})
314-
315283
test('public diff checks approve a verified colour usage but still flag fallbacks and removed writers', async () => {
316284
const repo = mkdtempSync(path.join(temp, 'colour-usage-'))
317285
git(repo, ['init', '-q'])

‎scripts/check-design-conformance-coverage.test.ts‎

Lines changed: 4 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
import { readFileSync } from 'node:fs'
33
import { expect, test } from 'vitest'
44
import { inspectControlAnalysis } from '#control-analysis/analysis'
5-
import { associateFindings } from '#control-analysis/associations'
6-
import type { ControlSource, InventoryFinding } from '#control-analysis/model'
5+
import type { ControlSource } from '#control-analysis/model'
76
import { ReviewCollector } from '#control-analysis/review'
87
import { productScope } from '#control-analysis/scope'
98
import { extract } from '#design-conformance/extract'
@@ -12,6 +11,9 @@ import { inspectionFailure } from '#design-conformance/model'
1211

1312
const globals = 'apps/sim/app/_styles/globals.css'
1413
const ui = 'apps/sim/components/example.tsx'
14+
const metadata = JSON.parse(
15+
readFileSync('scripts/design-conformance/contracts.generated.json', 'utf8')
16+
) as GeneratedContracts
1517
function source(files: Record<string, string>): ControlSource {
1618
return {
1719
entries: Object.entries(files).map(([path, text]) => ({
@@ -24,20 +26,15 @@ function source(files: Record<string, string>): ControlSource {
2426
read: (entry) => files[entry.path],
2527
}
2628
}
27-
const metadata = JSON.parse(
28-
readFileSync('scripts/design-conformance/contracts.generated.json', 'utf8')
29-
) as GeneratedContracts
3029
const inspect = (files: Record<string, string>) =>
3130
inspectControlAnalysis(
3231
source({
3332
[globals]: ':root { --caution: #f59e0b; --color-yellow-500: #eab308; --text-body: #444; }',
3433
...files,
3534
}),
36-
[],
3735
'forward',
3836
undefined,
3937
undefined,
40-
undefined,
4138
metadata
4239
)
4340

@@ -403,38 +400,6 @@ test.each([
403400
)
404401
})
405402

406-
test('conditional input branches remain directly associated with their control', () => {
407-
const finding: InventoryFinding = {
408-
id: 'branch',
409-
rule: 'component-chrome',
410-
category: 'colours',
411-
property: 'color',
412-
file: ui,
413-
line: 1,
414-
column: 10,
415-
context: 'View / @sim/emcn#Button / className/then',
416-
value: 'text-white',
417-
reason: 'owned chrome',
418-
observedFrom: [ui],
419-
}
420-
const uses = [
421-
{
422-
id: 'control',
423-
file: ui,
424-
line: 1,
425-
column: 1,
426-
endLine: 1,
427-
endColumn: 40,
428-
owner: 'View',
429-
tag: 'Button',
430-
slots: [{ name: 'className', line: 1, column: 5, endLine: 1, endColumn: 30 }],
431-
},
432-
]
433-
expect(associateFindings(uses, [finding], () => ['@sim/emcn#Button']).get('control')).toEqual({
434-
direct: ['branch'],
435-
potential: [],
436-
})
437-
})
438403
test.each([
439404
'CSS assignment source is nonregular or exceeds the parsing limit',
440405
'Artwork parser failure; only blob change is known',

0 commit comments

Comments
 (0)