@tristan2828/ui-foundation 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/bin/ui-foundation.mjs +249 -0
  4. package/conventions/AGENTS.md +149 -0
  5. package/conventions/agents/deny-impl-read.mjs +36 -0
  6. package/conventions/agents/skills/new-entity/SKILL.md +22 -0
  7. package/conventions/agents/spec-tester.md +35 -0
  8. package/conventions/docs/add-an-entity.md +204 -0
  9. package/conventions/docs/cell-patterns.md +204 -0
  10. package/conventions/docs/column-options.md +122 -0
  11. package/conventions/docs/design-language.md +116 -0
  12. package/conventions/docs/entity-plan-template.md +67 -0
  13. package/dist/api/contracts.d.ts +20 -0
  14. package/dist/api/contracts.js +4 -0
  15. package/dist/api/gateway/auth.d.ts +9 -0
  16. package/dist/api/gateway/auth.js +37 -0
  17. package/dist/api/gateway/errors.d.ts +5 -0
  18. package/dist/api/gateway/errors.js +36 -0
  19. package/dist/api/query-client.d.ts +2 -0
  20. package/dist/api/query-client.js +19 -0
  21. package/dist/api/schema.d.ts +246 -0
  22. package/dist/api/schema.js +5 -0
  23. package/dist/api/transport/index.d.ts +5 -0
  24. package/dist/api/transport/index.js +14 -0
  25. package/dist/auth/auth-context.d.ts +19 -0
  26. package/dist/auth/auth-context.js +12 -0
  27. package/dist/auth/auth-provider.d.ts +4 -0
  28. package/dist/auth/auth-provider.js +101 -0
  29. package/dist/auth/use-auth.d.ts +2 -0
  30. package/dist/auth/use-auth.js +9 -0
  31. package/dist/components/app/app-shell.d.ts +28 -0
  32. package/dist/components/app/app-shell.js +64 -0
  33. package/dist/components/app/data-table.d.ts +56 -0
  34. package/dist/components/app/data-table.js +167 -0
  35. package/dist/components/app/entity-form.d.ts +14 -0
  36. package/dist/components/app/entity-form.js +8 -0
  37. package/dist/components/app/error-state.d.ts +6 -0
  38. package/dist/components/app/error-state.js +20 -0
  39. package/dist/components/app/foundation-providers.d.ts +9 -0
  40. package/dist/components/app/foundation-providers.js +15 -0
  41. package/dist/components/app/mock-mode-banner.d.ts +3 -0
  42. package/dist/components/app/mock-mode-banner.js +16 -0
  43. package/dist/components/app/multi-choice.d.ts +14 -0
  44. package/dist/components/app/multi-choice.js +13 -0
  45. package/dist/components/app/password-input.d.ts +3 -0
  46. package/dist/components/app/password-input.js +30 -0
  47. package/dist/components/app/route-error-boundary.d.ts +1 -0
  48. package/dist/components/app/route-error-boundary.js +13 -0
  49. package/dist/components/theme-provider.d.ts +2 -0
  50. package/dist/components/theme-provider.js +8 -0
  51. package/dist/components/ui/badge.d.ts +7 -0
  52. package/dist/components/ui/badge.js +50 -0
  53. package/dist/components/ui/button.d.ts +8 -0
  54. package/dist/components/ui/button.js +34 -0
  55. package/dist/components/ui/calendar.d.ts +10 -0
  56. package/dist/components/ui/calendar.js +76 -0
  57. package/dist/components/ui/card.d.ts +11 -0
  58. package/dist/components/ui/card.js +25 -0
  59. package/dist/components/ui/combobox.d.ts +24 -0
  60. package/dist/components/ui/combobox.js +62 -0
  61. package/dist/components/ui/dialog.d.ts +17 -0
  62. package/dist/components/ui/dialog.js +37 -0
  63. package/dist/components/ui/empty.d.ts +11 -0
  64. package/dist/components/ui/empty.js +33 -0
  65. package/dist/components/ui/field.d.ts +24 -0
  66. package/dist/components/ui/field.js +67 -0
  67. package/dist/components/ui/input-group.d.ts +18 -0
  68. package/dist/components/ui/input-group.js +57 -0
  69. package/dist/components/ui/input.d.ts +3 -0
  70. package/dist/components/ui/input.js +8 -0
  71. package/dist/components/ui/label.d.ts +3 -0
  72. package/dist/components/ui/label.js +7 -0
  73. package/dist/components/ui/popover.d.ts +9 -0
  74. package/dist/components/ui/popover.js +23 -0
  75. package/dist/components/ui/select.d.ts +15 -0
  76. package/dist/components/ui/select.js +35 -0
  77. package/dist/components/ui/separator.d.ts +3 -0
  78. package/dist/components/ui/separator.js +8 -0
  79. package/dist/components/ui/sheet.d.ts +14 -0
  80. package/dist/components/ui/sheet.js +38 -0
  81. package/dist/components/ui/sidebar.d.ts +63 -0
  82. package/dist/components/ui/sidebar.js +255 -0
  83. package/dist/components/ui/skeleton.d.ts +2 -0
  84. package/dist/components/ui/skeleton.js +6 -0
  85. package/dist/components/ui/sonner.d.ts +3 -0
  86. package/dist/components/ui/sonner.js +24 -0
  87. package/dist/components/ui/spinner.d.ts +2 -0
  88. package/dist/components/ui/spinner.js +7 -0
  89. package/dist/components/ui/table.d.ts +13 -0
  90. package/dist/components/ui/table.js +45 -0
  91. package/dist/components/ui/textarea.d.ts +3 -0
  92. package/dist/components/ui/textarea.js +7 -0
  93. package/dist/components/ui/tooltip.d.ts +6 -0
  94. package/dist/components/ui/tooltip.js +16 -0
  95. package/dist/gateway.d.ts +2 -0
  96. package/dist/gateway.js +5 -0
  97. package/dist/hooks/table-url-changes.d.ts +14 -0
  98. package/dist/hooks/table-url-changes.js +32 -0
  99. package/dist/hooks/use-debounced-value.d.ts +1 -0
  100. package/dist/hooks/use-debounced-value.js +12 -0
  101. package/dist/hooks/use-mobile.d.ts +1 -0
  102. package/dist/hooks/use-mobile.js +14 -0
  103. package/dist/hooks/use-table-url-state.d.ts +14 -0
  104. package/dist/hooks/use-table-url-state.js +43 -0
  105. package/dist/index.d.ts +22 -0
  106. package/dist/index.js +28 -0
  107. package/dist/lib/utils.d.ts +1 -0
  108. package/dist/lib/utils.js +1 -0
  109. package/dist/mocks/auth.d.ts +9 -0
  110. package/dist/mocks/auth.js +79 -0
  111. package/dist/mocks/e2e-hooks.d.ts +3 -0
  112. package/dist/mocks/e2e-hooks.js +28 -0
  113. package/dist/mocks/index.d.ts +3 -0
  114. package/dist/mocks/index.js +5 -0
  115. package/dist/mocks/override.d.ts +23 -0
  116. package/dist/mocks/override.js +1 -0
  117. package/dist/routes/login-schema.d.ts +7 -0
  118. package/dist/routes/login-schema.js +13 -0
  119. package/dist/routes/login.d.ts +8 -0
  120. package/dist/routes/login.js +58 -0
  121. package/dist/routes/register-schema.d.ts +9 -0
  122. package/dist/routes/register-schema.js +23 -0
  123. package/dist/routes/register.d.ts +1 -0
  124. package/dist/routes/register.js +58 -0
  125. package/dist/routes/return-path.d.ts +1 -0
  126. package/dist/routes/return-path.js +9 -0
  127. package/dist/testing/index.d.ts +40 -0
  128. package/dist/testing/index.js +103 -0
  129. package/eslint/index.d.ts +11 -0
  130. package/eslint/index.js +179 -0
  131. package/openapi/foundation.yaml +156 -0
  132. package/package.json +158 -0
  133. package/styles/index.css +88 -0
  134. package/styles/theme.css +209 -0
@@ -0,0 +1,204 @@
1
+ # Add an Entity
2
+
3
+ The most-repeated task in this system: add a full CRUD entity — spec, mocks,
4
+ gateway, tests, table, form, routes — following the patterns in
5
+ `src/routes/widgets/`. This file is the only copy of the steps, written for
6
+ **any AI coding tool or a person**: follow it directly ("add a Game entity
7
+ following docs/foundation/add-an-entity.md"). In Claude Code,
8
+ `/new-entity <Name>` is a shortcut that runs this same file. It is synced
9
+ from `@tristan2828/ui-foundation` — don't edit it here.
10
+
11
+ Do not skip steps or reorder them. Replace `<Entity>` with the PascalCase
12
+ entity name (e.g. `Invoice`) and `<entity>` with its kebab-case form
13
+ (`invoice`) throughout.
14
+
15
+ ## Before anything: the entity plan
16
+
17
+ **Never guess what an entity is.** Every field, type, option list and rule
18
+ comes from `docs/entities/<entity>.md` — the developer's plan, in the
19
+ format of `docs/foundation/entity-plan-template.md` (`docs/entities/widget.md`
20
+ is a filled-in example while the Widgets demo is still here).
21
+
22
+ - **The plan file exists:** it is the approved spec. Read it in full and
23
+ build exactly what it says — no extra fields, no invented options, no
24
+ renamed labels. If something the build needs isn't in it, stop and ask;
25
+ don't fill the gap yourself.
26
+ - **It doesn't exist:** don't build anything yet. Work the plan out with
27
+ the developer — ask about purpose, each field and its type, required,
28
+ option lists, what the table shows, sorts and filters on, and ownership
29
+ — for as long as it takes. Write `docs/entities/<entity>.md` from the
30
+ answers, show it, and wait for an explicit go-ahead before step 1.
31
+ - **Either way, stop and raise it (don't improvise) if** the plan has an
32
+ unresolved item under "Open questions", or needs a field type or screen
33
+ shape the foundation doesn't support yet (the supported list is in the
34
+ template). Write the case in `docs/BLOCKERS.md`.
35
+
36
+ 1. **Add `<Entity>` to `openapi.yaml`** — schema, list, get, create, update,
37
+ delete — exactly as the plan specifies: its fields, types, required
38
+ fields, option lists (as enums) and length/format rules, and a list
39
+ query parameter for each field the plan marks as a filter. Reuse the
40
+ `Page` and error components already in the spec; do not redefine
41
+ pagination or error shapes per entity, and leave the foundation's part
42
+ of the spec (`/auth/*`, the error bodies) alone — `ui-foundation
43
+ check-contract` fails verify if it changes.
44
+ 2. **`npm run gen:api`** to regenerate `src/api/schema.d.ts`. Never
45
+ hand-edit it.
46
+ 3. **Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from
47
+ the spec** — `openapi.yaml` and the UI contracts (`Page<T>`, `AppError`,
48
+ `QuerySpec`) only, never the code in `src/api/gateway/`, so the tests assert
49
+ what the spec promises, not what an implementation happens to do. Write
50
+ them to fail first; the gateway that makes them pass doesn't exist yet.
51
+ - **If your tool can run an isolated subagent, use one** that can't see
52
+ those two folders. In Claude Code that's `spec-tester`
53
+ (`.claude/agents/spec-tester.md`), whose hook *blocks* reading it —
54
+ the enforced version of this rule.
55
+ - **Otherwise** write the tests yourself, now, before step 5 creates the
56
+ gateway, and don't open that folder while writing them. It's the
57
+ same rule, kept by discipline instead of a hook.
58
+ 4. **Add MSW handlers in `src/mocks/<entity>.ts`** and register them in
59
+ `src/mocks/handlers.ts`. **Include one deliberately sparse row** whose
60
+ every optional field is empty at once (see `Blank Slate` in
61
+ `src/mocks/data.ts`). Rows that all populate every field are the reason
62
+ an "empty value" path goes untested: nothing ever loads a record with a
63
+ field unset, so an em dash that never renders, or a form control showing
64
+ a raw sentinel instead of a "not set" label, passes `verify` unnoticed.
65
+ One row costs nothing and step 8 asserts it. Extend `tests/mocks/conformance.test.ts` so the
66
+ new handlers are validated against `openapi.yaml`, the same way
67
+ `widgets`/`categories` already are.
68
+ 5. **Add a gateway module in `src/api/gateway/<entity>.ts`** until step 3's
69
+ tests pass, built on `safeFetch`/`toAppError` from
70
+ `@tristan2828/ui-foundation/gateway` (see `src/api/gateway/widgets.ts`).
71
+ Wire → `Page<T>` / `AppError` translation only; no hand-written types
72
+ (everything comes from `schema.d.ts`).
73
+ 6. **Copy the widgets reference files, one for one, not just the two
74
+ screens** — shaped by the plan: table columns and sortable columns from
75
+ its "List" column, toolbar filters from its "Filter" column, form fields
76
+ and labels from its field table, and field types by pattern (a
77
+ `reference` field copies Category's searchable combobox, a
78
+ `single choice` copies Status's select, a `date-time` copies
79
+ Available From's date picker, a `multi choice` copies Tags — the
80
+ `<MultiChoice>` control from the foundation on the
81
+ form and as a toolbar filter via `useTableUrlState`'s multi filters,
82
+ badges in the table, and a `filters` array the gateway sends as a
83
+ repeated parameter):
84
+ - `src/routes/widgets/use-widgets.ts`, `use-categories.ts` →
85
+ `src/routes/<entity>/use-<entity>.ts` (TanStack Query hooks over the
86
+ new gateway module)
87
+ - `src/routes/widgets/widget-schema.ts` →
88
+ `src/routes/<entity>/<entity>-schema.ts` (zod schema mirroring
89
+ `<Entity>Create`/`<Entity>Update`, plus the form ↔ wire conversion
90
+ functions — see `widgetToFormValues`/`formValuesToWidgetCreate` for
91
+ the shape)
92
+ - `src/routes/widgets/widgets-columns.tsx` →
93
+ `src/routes/<entity>/<entity>-columns.tsx` (column defs; split any
94
+ row-action dialogs into their own file, as
95
+ `delete-widget-action.tsx` does — a fast-refresh hazard otherwise)
96
+ - `src/routes/widgets/widgets-table.tsx` →
97
+ `src/routes/<entity>/<entity>-table.tsx` — thin consumer of the
98
+ foundation's `<DataTable>`. Swap the type, columns, and toolbar
99
+ filters.
100
+ - `src/routes/widgets/widget-form.tsx` →
101
+ `src/routes/<entity>/<entity>-form.tsx` — thin consumer of the
102
+ foundation's `<EntityForm>` (its `danger` slot takes an edit-only
103
+ delete). Swap the zod schema and fields.
104
+
105
+ **`<SelectValue>` shows the value, not the label.** Base UI renders the
106
+ raw *value*, and `placeholder` only applies when the value is `null`.
107
+ Any `Select` whose "nothing chosen" state is a sentinel rather than
108
+ `null` therefore displays that sentinel on screen unless you pass the
109
+ children function. This bit every `Select` in the first app built on
110
+ this foundation. Two cases, both needing it:
111
+
112
+ - **A toolbar filter**, where "no filter" is `'all'` — copied straight
113
+ from `widgets-table.tsx`, which now carries the shape to copy: a
114
+ label constant read by both the trigger and its `<SelectItem>`.
115
+ - **An optional `single choice` field**, which Widget has no example of
116
+ (its `status` is required and its `categoryId` is a combobox). A Base
117
+ UI `Select` item's value cannot be `''`, so the field's own unset
118
+ value is remapped through a sentinel:
119
+
120
+ ```tsx
121
+ const UNSET = '__unset__'
122
+ const UNSET_LABEL = 'Not set' // the plan's "not set" label
123
+ // ...
124
+ <Controller
125
+ control={form.control}
126
+ name="priority"
127
+ render={({ field }) => (
128
+ <Select
129
+ value={field.value === '' ? UNSET : field.value}
130
+ onValueChange={(value) => field.onChange(value === UNSET ? '' : value)}
131
+ >
132
+ <SelectTrigger id="<entity>-priority">
133
+ {/* Without this the trigger reads the literal "__unset__" */}
134
+ <SelectValue>{(value) => (value === UNSET ? UNSET_LABEL : value)}</SelectValue>
135
+ </SelectTrigger>
136
+ <SelectContent>
137
+ <SelectItem value={UNSET}>{UNSET_LABEL}</SelectItem>
138
+ {PRIORITIES.map((option) => (
139
+ <SelectItem key={option} value={option}>{option}</SelectItem>
140
+ ))}
141
+ </SelectContent>
142
+ </Select>
143
+ )}
144
+ />
145
+ ```
146
+
147
+ The zod schema keeps `''` as the unset value and converts it to
148
+ `null` on the way to the wire, the way `widget-schema.ts` already
149
+ handles a blank `assigneeEmail`. Step 8's specs must assert the
150
+ **unset** display, not only a picked one — a select whose enum values
151
+ equal their labels passes either way, which is exactly how this
152
+ shipped unnoticed.
153
+
154
+ **Choosing how each column looks.** The plan says what a field *means*,
155
+ not how its cell should render. For most columns plain text is right and
156
+ there is nothing to decide. Where there is:
157
+
158
+ - [`cell-patterns.md`](cell-patterns.md) is the catalogue — icon + label,
159
+ icon in a badge, icon-only with a tooltip, enum → tone-mapped badge,
160
+ clustered multi-value, link button, relative date, ordinal scale,
161
+ summary + popover — each with the parts a first attempt gets wrong.
162
+ They are markup to copy, not components to import.
163
+ - If the developer doesn't have a preference, don't pick for them and
164
+ don't guess: follow [`column-options.md`](column-options.md) to build
165
+ a short page of numbered options over the same sample rows, and let
166
+ them choose. That is how every pattern in the catalogue was picked.
167
+ - Any colour involved: [`design-language.md`](design-language.md). Most
168
+ values should stay grey, and colour is never the only signal.
169
+ 7. **Register routes** for `/<entity>`, `/<entity>/new`,
170
+ `/<entity>/:id/edit` in `src/App.tsx`, and add a nav entry to
171
+ `src/nav.ts`.
172
+ 8. **Add Playwright specs for both screens**, one test per state
173
+ (`loading`, `empty`, `error`, `validation`, `success`), forced through
174
+ MSW overrides — `forceMswOverride`, `waitForMswReady` and the
175
+ `window.__msw` handle from `@tristan2828/ui-foundation/testing`, and
176
+ `e2e/widgets-table.spec.ts`/`e2e/widget-form.spec.ts` for the pattern.
177
+ States forced via a first-load init script (loading, load-time error)
178
+ need `page.addInitScript` before navigation; states forced after the
179
+ page is already up (a mutation's error response) can use a
180
+ post-navigation `worker.use()` call gated on
181
+ `waitForFunction(() => window.__msw !== undefined)`. To stub what an
182
+ `<img>` loads (a cover or avatar cell), give the override `bodyBase64`
183
+ and `contentType` instead of `body` — a JSON body makes the image fire
184
+ `onError` — and stub it through the override, never `page.route`,
185
+ which cannot see requests the MSW service worker makes
186
+ (`e2e/msw-contract.spec.ts` has an example).
187
+ 9. **Register the nav entry and route names** in `e2e/shell.spec.ts`'s
188
+ `NAV_ENTRIES` so the shell smoke test covers the new screen, and add
189
+ `/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes` (the table page is
190
+ picked up from the sidebar automatically; the form isn't in it).
191
+ 10. **`npm run verify`.** Fix until it passes. Then stop — do not add
192
+ anything beyond what this list covers; note ideas in the app's
193
+ backlog instead.
194
+
195
+ ## What not to copy
196
+
197
+ - `src/api/gateway/widgets.ts`, `src/mocks/data.ts`, `src/mocks/handlers.ts`
198
+ are demo-domain content — write the entity's own versions rather than
199
+ adapting these by find-and-replace.
200
+ - The composites (`DataTable`, `EntityForm`, `ErrorState`, `AppShell`)
201
+ are the foundation's and shared by every app. If a new entity needs a
202
+ capability they don't have (a new field-type widget, say), don't copy
203
+ one into `src/` to change it: raise it for the foundation (AGENTS.md
204
+ "Changing the Foundation") and build the screen once the release ships.
@@ -0,0 +1,204 @@
1
+ # Cell Patterns
2
+
3
+ How a column's value should *look*. Each entry is markup plus the bits a
4
+ first attempt gets wrong — **not components**: the lookup maps are always
5
+ per-entity, and only the shape is reusable. Copy the markup into your
6
+ `<entity>-columns.tsx`.
7
+
8
+ Every pattern below was chosen for a real column, from a page of
9
+ alternatives ([`column-options.md`](column-options.md)), not designed in the abstract.
10
+ A pattern that proves generic is worth raising for the foundation, so it
11
+ ships here for every app.
12
+
13
+ ## The rules that apply to every cell
14
+
15
+ - **An empty value renders an em dash**, never a blank cell: a blank reads
16
+ as a rendering bug. Define it once per columns file —
17
+ `const emptyCell = <span className="text-muted-foreground">—</span>` —
18
+ and use it everywhere. The one exception is a value whose "nothing" state
19
+ has its own meaning (an unrated 0-of-5 scale, a missing image), where the
20
+ shape itself should say so.
21
+ - **Colour is never the only signal.** Greyscale, colourblindness and print
22
+ all lose it. The label usually carries the meaning; where a cell is a
23
+ glyph, vary the *shape* too. See [`design-language.md`](design-language.md).
24
+ - **Type every lookup as `Record<TheEnum, …>`** so `tsc` fails when
25
+ `openapi.yaml` gains a value the map doesn't cover, instead of silently
26
+ falling back.
27
+ - **A cell that reads across the row needs `row.original`**, not just
28
+ `getValue()`.
29
+ - **Give a repeated control a row-specific `aria-label`.** Twenty-five
30
+ buttons all named "Link" are useless to a screen reader.
31
+
32
+ ---
33
+
34
+ ## 1. Plain text
35
+
36
+ The default, and it wins more often than it looks like it should. Reach for
37
+ something else only when the extra ink buys a decision the reader would
38
+ otherwise have to make themselves.
39
+
40
+ Always offer a plain-text option when exploring a column — in practice it
41
+ gets picked about as often as the visual ones.
42
+
43
+ ## 2. Icon + label
44
+
45
+ A single-value enum where a glyph speeds up scanning but the word still
46
+ matters.
47
+
48
+ ```tsx
49
+ const GENRE_ICON: Record<GameGenre, typeof Flame> = { Survival: Flame, Shooter: Crosshair, /* … */ }
50
+
51
+ cell: ({ getValue }) => {
52
+ const genre = getValue() as GameGenre
53
+ const Icon = GENRE_ICON[genre]
54
+ return (
55
+ <span className="inline-flex items-center gap-1.5">
56
+ <Icon className="size-4 shrink-0" aria-hidden />
57
+ {genre}
58
+ </span>
59
+ )
60
+ }
61
+ ```
62
+
63
+ - **Don't wrap it in `Badge`** — beside a multi-value badge column it reads
64
+ as one more tag.
65
+ - The glyph is decorative next to a visible label, so `aria-hidden`.
66
+ - Check lucide before drawing anything; it covers most vocabularies.
67
+
68
+ ## 3. Icon in a badge
69
+
70
+ A count or short scalar that should read as a unit.
71
+
72
+ ```tsx
73
+ <Badge variant="outline">
74
+ <UsersIcon data-icon="inline-start" />
75
+ <span className="tabular-nums">{maxPlayers}</span>
76
+ </Badge>
77
+ ```
78
+
79
+ `Badge` already sizes child `svg`s and pads for
80
+ `data-icon="inline-start"|"inline-end"` — don't add a wrapper.
81
+ `tabular-nums` keeps a numeric column from jittering.
82
+
83
+ ## 4. Icon only, label in a tooltip
84
+
85
+ An enum where the glyph alone is enough at a glance and the column is
86
+ narrow.
87
+
88
+ ```tsx
89
+ <Tooltip>
90
+ <TooltipTrigger render={<span role="img" aria-label={support} tabIndex={0} />}>
91
+ <GamepadGlyph support={support} />
92
+ </TooltipTrigger>
93
+ <TooltipContent>{support}</TooltipContent>
94
+ </Tooltip>
95
+ ```
96
+
97
+ - Base UI's `TooltipTrigger` renders a **`<button>`** by default. A
98
+ read-only cell isn't a button: `render={<span role="img" aria-label … tabIndex={0} />}`
99
+ keeps it focusable and named without faking one.
100
+ - Needs the `TooltipProvider` that `AppShell` mounts — free in any
101
+ cell, absent in an isolated Storybook story.
102
+ - Encode the value in the glyph's **shape** (solid / half / slashed /
103
+ dashed), not its colour.
104
+
105
+ ## 5. Enum → tone-mapped badge
106
+
107
+ A many-valued enum that collapses to good / neutral / bad. **The grouping
108
+ is the decision, not the style** — see
109
+ [`design-language.md`](design-language.md#mapping-an-enum-to-tones).
110
+
111
+ Keep a separate short-label map when the API strings are full sentences
112
+ (`"Yes – Full Crossplay"` → `Full`): read once in a dropdown they're right,
113
+ read down 25 rows under a column header they're noise.
114
+
115
+ ## 6. Multi-value enum, clustered
116
+
117
+ A column holding several values from one enum. Cluster by family, with the
118
+ family's icon once at the start of each cluster, rather than one badge per
119
+ value — a six-value row is the stress case that kills per-value styles.
120
+
121
+ - The family taxonomy is a **display decision made in the columns file**,
122
+ not part of the API contract.
123
+ - Clustering **discards the picked order**, so if "first = primary" means
124
+ something, say in a comment that the form still preserves it.
125
+ - Needs an empty case and a wrap policy (`flex flex-wrap gap-1`).
126
+ - Tests that matched each value as its own element break when values share
127
+ a badge — assert `toContainText` on the row.
128
+
129
+ ## 7. Link as an outline button
130
+
131
+ An external URL.
132
+
133
+ ```tsx
134
+ <Button
135
+ variant="outline" size="sm" nativeButton={false}
136
+ aria-label={`Link to ${row.original.name} store page`}
137
+ render={<a href={link} target="_blank" rel="noreferrer" />}
138
+ >
139
+ Link
140
+ <ExternalLinkIcon />
141
+ </Button>
142
+ ```
143
+
144
+ - `render={<a />}` **plus** `nativeButton={false}` is what keeps it a real
145
+ anchor styled as a button. A `<button>` wrapping an `<a>` is invalid, and
146
+ Base UI warns without the flag.
147
+ - Every row shows the same word, so the `aria-label` must name the row —
148
+ and must still contain the visible text.
149
+ - Null renders `emptyCell`, not a disabled button.
150
+ - **Cost:** the loudest link style; a long table becomes a wall of buttons.
151
+
152
+ ## 8. Date, relative
153
+
154
+ When the useful question is "how soon?" rather than "which day?".
155
+
156
+ - `Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })` gives
157
+ `tomorrow`/`today` free, but it takes **one unit** — write a ladder (days
158
+ under 2 weeks, weeks under ~2 months, months under 2 years, then years)
159
+ or you get "in 44 days".
160
+ - An API `format: date` is a calendar day with no time. Parse as
161
+ `T00:00:00Z`, format with `timeZone: 'UTC'`, and count from **local today
162
+ rebuilt as UTC midnight** (`Date.UTC(y, m, d)`). Subtracting `Date.now()`
163
+ shifts the answer by a day for anyone west of UTC.
164
+ - Relative text **goes stale**, so a fixture pinning a date drifts. Freeze
165
+ the clock (Playwright's `page.clock`) in any test asserting one.
166
+ - `title` is mouse-only. If the exact date matters, use pattern 4 instead.
167
+ - **Cost:** "2 years ago" is vague, and the column doesn't look sorted even
168
+ though server-side sorting still works.
169
+
170
+ ## 9. Ordinal scale (bounded integer)
171
+
172
+ A 1–5 rating or similar. The grouping step from pattern 5 applies, but the
173
+ values are a **range**: read the min/max from `schema.d.ts`, band it
174
+ (5 and 4 share a tone, 3 alone, 2 and 1 share), and treat "not set" as its
175
+ own state.
176
+
177
+ - Token colours work as SVG fills (`fill-success-text` plus
178
+ `text-success-text`), and the tone classes must be whole strings picked
179
+ by a function — Tailwind won't generate one built from a value.
180
+ - The filled **count** carries the value; the tone only reinforces it, so
181
+ the cell survives greyscale.
182
+ - Accessible name on a wrapping `<span role="img">` with the glyphs
183
+ `aria-hidden`, saying the value in words and handling "not set".
184
+ - Very faint "empty" glyphs fall below the 3:1 non-text minimum, and axe
185
+ does not check icon contrast. That's a defensible choice — make it
186
+ deliberately, in a comment, not by accident.
187
+
188
+ ## 10. Summary in the cell, detail in a popover
189
+
190
+ A one-to-many value (`★ 4.7 (3)` opening a per-person breakdown).
191
+
192
+ - Use `Popover` with `<PopoverTrigger openOnHover delay={100}>`, **not**
193
+ `Tooltip`: a tooltip can't hold rich content and is unreachable on touch.
194
+ Here the trigger *should* be a real `<button>` — it opens something —
195
+ unlike pattern 4.
196
+ - shadcn's `hover-card` exists but has nothing to tap on touch, so it is
197
+ deliberately not used for information not available elsewhere in the row.
198
+ Say so, or someone will "fix" it.
199
+ - `PopoverContent` defaults to `w-72`; use `w-auto min-w-48` for a short list.
200
+ - The popup is **portaled** — Playwright must query
201
+ `page.getByRole('dialog')`, not within the row.
202
+ - **Cost:** the detail can't be scanned down the column, and an average
203
+ can't be sorted server-side without a backend change
204
+ (`enableSorting: false`).
@@ -0,0 +1,122 @@
1
+ # Choosing How a Column Displays
2
+
3
+ An optional step in [`add-an-entity.md`](add-an-entity.md), for a developer
4
+ who knows what the data *means* but not what it should look like.
5
+
6
+ Instead of the agent picking a cell style and the developer reacting to it,
7
+ the agent builds a page of numbered options rendering **the same sample
8
+ rows** in each style, and the developer replies with a number. That has now
9
+ picked eight columns across a real app, and the choices it produced are
10
+ [`cell-patterns.md`](cell-patterns.md).
11
+
12
+ Run it per column, and only for columns where it isn't obvious. A plain
13
+ text column doesn't need a page.
14
+
15
+ ## What the page must contain
16
+
17
+ **Open with the value, before any style.** Two lines that decide most of
18
+ what follows:
19
+
20
+ - The field's **type, range and how "unset" is represented**, read from
21
+ `schema.d.ts` — not every field is an enum. A 1–5 integer whose
22
+ "not rated" state is *absence from a list* is a different problem from a
23
+ nullable enum, and the page should say which it is.
24
+ - A **value grouping**: how many values mean good, bad, or neither, and
25
+ which ones can share a tone. For a many-valued enum this is the real
26
+ decision, and making it first renders half the styles obviously wrong.
27
+ If the agent invented a taxonomy (families, bands, thresholds), show it
28
+ as a **legend and flag it as a guess** — that's usually what the
29
+ developer actually wants to change.
30
+ - **Why this column differs from the last one explored.** "One value, no
31
+ good/bad axis, so colour has nothing to say by default" saves a round.
32
+
33
+ **Sample rows must span the value's cardinality, not just its values.**
34
+ For a multi-value column: 1, 2, 3, 5 and 6 values *plus an empty row*. For
35
+ a bounded scale: one row per value plus the unset row. The six-value row
36
+ is what kills one-badge-per-value styles; the empty row surfaces the null
37
+ case for free. For a date column, pin "today" in the header and choose
38
+ rows relative to it, or the page's own relative strings rot.
39
+
40
+ **Number the cards** so the developer can reply "5". If a second round is
41
+ requested, **append** rather than renumber — the earlier numbers have
42
+ already been used in conversation.
43
+
44
+ **Per card:**
45
+
46
+ - A **one-line tradeoff** and a **cost line**: what this style trades away
47
+ (an extra text shade, ambiguity of blank, needs colour, more ink).
48
+ - **Capability tags** for a multi-value column — ✓ shows who rated, ✓ shows
49
+ disagreement, ✓ still fits 5+ values. This compares options on what each
50
+ *preserves*, which for a multi-value column matters more than looks.
51
+ - A **backend-implication note** where one applies. "Sorting by average
52
+ needs a backend change — not sortable server-side today" reads to a
53
+ backend developer the way visual weight reads to a designer, and it stops
54
+ an unbuildable option winning on looks.
55
+ - **Collision with neighbouring columns.** An outline badge beside a badge
56
+ column, or "★ n" beside an existing ratings column, looks identical to
57
+ its neighbour — a failure only visible in context.
58
+
59
+ **Group the cards by what they cost the design system**, not by look:
60
+ *fits as-is* / *needs new tokens* / *removes the column*. In a system whose
61
+ Hard Rule bans palette colours, whether colour is worth new tokens **is**
62
+ the decision, and grouping puts it on the page before any style is judged.
63
+
64
+ **Include the boring option.** A plain-text style gets picked about as
65
+ often as the visual ones. A page of only visual options is a loaded
66
+ question.
67
+
68
+ **Include options that remove the column** where they exist (the value as
69
+ a subline under the name; rows grouped under a header), with their cost in
70
+ `DataTable` terms: no column to sort by, and grouping fights both column
71
+ sorting and server-side pagination.
72
+
73
+ **For an editable cell, answer read-versus-edit up front.** Every display
74
+ style silently decides whether one-click editing survives. Put a "decision
75
+ for you" callout on the page, tag each card *keeps picker / read-only*, and
76
+ list what else touches the cell — e2e tests driving the control, docs
77
+ describing it. Moving a rating to the edit screen is a test-and-docs
78
+ change, not a one-line cell change.
79
+
80
+ ## Controls on the page
81
+
82
+ - **"Try it in a table"** — a switcher that re-renders one sample table
83
+ (real column names, ~7 rows including an empty one) in the selected
84
+ style. A style that looks fine in an isolated card can be far too noisy
85
+ down 25 rows; this is the single most useful control on the page.
86
+ - **Greyscale toggle** (`filter: grayscale(1)` over every card) — the cheap
87
+ colourblind check, and it settles which options depend on hue alone.
88
+ - **Tone toggle** (none / three tones) and **order toggle** (as picked / by
89
+ family) where they apply. Global toggles over all cards beat per-card
90
+ variants: a request like "card 1's shape with card 5's tone rule" is a
91
+ toggle that was missing.
92
+ - **Demo interaction live** (CSS `:hover`/`:focus`) for any style whose
93
+ point is an interaction. A static screenshot hides exactly what's being
94
+ judged.
95
+
96
+ ## The one recurring failure: drift
97
+
98
+ Every options page so far re-implemented the cells in vanilla CSS with
99
+ copied tokens, and every one drifted:
100
+
101
+ - an amber the app had no token for
102
+ - a text shade of `oklch(0.42)` where the app shipped `0.5`
103
+ - lucide glyph paths typed from memory
104
+ - a `color-mix` tinted fill the design system had no variant for — and that
105
+ one **changed the outcome**: the developer picked it, and it had to be
106
+ rebuilt as the app's existing `secondary` fill
107
+
108
+ An option using a fill the design system can't produce is being judged as
109
+ something the app can't render.
110
+
111
+ **Render the options from the app's real components.** Storybook is already
112
+ in this repo and is the obvious host: the cards then use the real tokens
113
+ and the real `Badge`, and the winner can be copied straight out. Do this
114
+ rather than hand-writing a standalone HTML page.
115
+
116
+ ## After the pick
117
+
118
+ Implement it in `<entity>-columns.tsx`. If the pattern isn't already in
119
+ [`cell-patterns.md`](cell-patterns.md), raise it for the foundation — that
120
+ file is synced from the package, so an addition there reaches every app —
121
+ with the bits that were non-obvious while building it, not just the final
122
+ markup.
@@ -0,0 +1,116 @@
1
+ # Design Language
2
+
3
+ The part of this foundation a consuming app cannot cheaply get right on
4
+ its own: color that carries meaning, checked for contrast in both themes.
5
+ Tokens and styles here **grow freely**, in the foundation package, so every
6
+ app gets them: they do not wait for a second app to need them. New
7
+ composites don't grow that way; they are built against a real app's screen.
8
+
9
+ ## Semantic tones
10
+
11
+ Three tones carry meaning: **success**, **warning**, **destructive**. Grey
12
+ is not a tone — it is the absence of one, and most values should stay grey.
13
+
14
+ Each tone has two kinds of token, and picking the wrong one is the single
15
+ most common way to ship an unreadable label:
16
+
17
+ | Token | For | Why it can't be the other one |
18
+ |---|---|---|
19
+ | `--success` / `--warning` / `--destructive` (+ `-foreground`) | a **solid fill**, always with white text on it | chosen to contrast with *white*, so on the page background it's too light in light mode |
20
+ | `--success-text` / `--warning-text` / `--destructive-text` | the tone as **text, a border or an icon** on the page background | chosen to contrast with *the page*, so as a fill under white text it's needlessly dark |
21
+
22
+ Only the `*-text` tokens are remapped in `.dark`. A solid fill keeps one
23
+ shade in both themes, because it always carries white text and the page
24
+ behind it never enters the calculation.
25
+
26
+ Measured ratios (WCAG 2.x, sRGB) are recorded next to each primitive in
27
+ the package's `styles/theme.css`, and its `e2e/storybook-visual.spec.ts` re-checks all
28
+ nine tone/style combinations with axe against the rendered DOM in both
29
+ themes. **A new tone or style is not done until it appears in that story.**
30
+
31
+ ## The three badge styles
32
+
33
+ `Badge` ships each tone in three styles. They are not interchangeable:
34
+
35
+ - **Outline** — `outline-success`, `outline-warning`, `outline-destructive`.
36
+ The default choice, and the one proven across real columns. Quiet enough
37
+ to repeat down 25 rows without the column becoming a wall of color.
38
+ - **Tinted** — `tinted-success`, `tinted-warning`, `tinted-destructive`. A
39
+ low-alpha wash of the tone with `*-text` on top. More presence than
40
+ outline; use when the value is the point of the row, not a detail of it.
41
+ - **Solid** — `success`, `warning`, `destructive`. The loudest. Use for one
42
+ value that must be impossible to miss, rarely for a whole column: solid
43
+ reads as an alert, and a column of alerts reads as noise.
44
+
45
+ Pick **one style per table** and vary only the tone. Mixing styles across
46
+ columns makes the difference look meaningful when it isn't.
47
+
48
+ ## Mapping an enum to tones
49
+
50
+ The decision is the **grouping**, not the style. Before choosing how it
51
+ looks, write down which values mean good, which mean bad, and which mean
52
+ neither — including the ones that look distinct as text but should share a
53
+ tone.
54
+
55
+ ```tsx
56
+ const STATUS_BADGE_VARIANT: Record<WidgetStatus, 'outline' | 'outline-success' | 'secondary'> = {
57
+ draft: 'outline',
58
+ active: 'outline-success',
59
+ archived: 'secondary',
60
+ }
61
+ ```
62
+
63
+ Type the lookup as `Record<TheEnum, …>` so `tsc` fails when `openapi.yaml`
64
+ gains a value with no tone, rather than falling back silently.
65
+
66
+ Two rules that keep this honest:
67
+
68
+ - **Most values are neutral.** If more than about a third of an enum is
69
+ coloured, the colour has stopped meaning anything. Widget's `archived` is
70
+ an end state, not a failure — it stays grey.
71
+ - **Never let colour be the only signal.** A colourblind reader, a
72
+ greyscale screenshot and a printed page all lose it. The label text
73
+ usually carries the meaning already; where a cell is a glyph rather than
74
+ a word, vary the *shape* (filled / half / slashed) too, not just the hue.
75
+
76
+ ## Categorical colour
77
+
78
+ Tones say good or bad. A value that is merely *different* from its
79
+ neighbours — a genre, a team, a category — needs a different mechanism, and
80
+ it is `--category-1` through `--category-8`.
81
+
82
+ They are **generic slots, not per-value tokens**. An app maps its enum to a
83
+ slot in its own columns file, so a new enum value costs a line there rather
84
+ than three tokens in the design system:
85
+
86
+ ```tsx
87
+ const GENRE_COLOR: Record<Genre, string> = {
88
+ Survival: 'text-category-1',
89
+ Shooter: 'text-category-2',
90
+ RPG: 'text-category-3',
91
+ }
92
+ ```
93
+
94
+ Tailwind needs whole class names, so map the value to a complete class —
95
+ never build one by interpolation, or the class won't be generated.
96
+
97
+ Four things about them:
98
+
99
+ - **The hues avoid red, green and amber**, which the tones already spend. A
100
+ category drawn in red reads as "this one is bad" even when nothing is
101
+ wrong.
102
+ - **They are ordered by distinctness, not by hue angle.** An app using
103
+ three categories gets three obviously different colours. Past about five,
104
+ hue alone stops separating them — slots 3 and 7 are both yellow-greens —
105
+ so the glyph has to carry the meaning and the colour only reinforces it.
106
+ - **Tint the glyph, never fill behind text.** The label stays
107
+ `text-foreground`, so readability never depends on the hue. A slot used
108
+ as a background needs its own contrast check, which nothing here does
109
+ for you.
110
+ - **Nothing re-measures them automatically.** All eight clear the 3:1
111
+ non-text minimum in both themes (measured, recorded in `theme.css`), but
112
+ axe checks text contrast, not icon contrast — so a change to these
113
+ values is not caught by any gate.
114
+
115
+ If you find yourself wanting a ninth, that is usually a sign the column
116
+ should be showing a shape or a label rather than more colours.