@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.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/bin/ui-foundation.mjs +249 -0
- package/conventions/AGENTS.md +149 -0
- package/conventions/agents/deny-impl-read.mjs +36 -0
- package/conventions/agents/skills/new-entity/SKILL.md +22 -0
- package/conventions/agents/spec-tester.md +35 -0
- package/conventions/docs/add-an-entity.md +204 -0
- package/conventions/docs/cell-patterns.md +204 -0
- package/conventions/docs/column-options.md +122 -0
- package/conventions/docs/design-language.md +116 -0
- package/conventions/docs/entity-plan-template.md +67 -0
- package/dist/api/contracts.d.ts +20 -0
- package/dist/api/contracts.js +4 -0
- package/dist/api/gateway/auth.d.ts +9 -0
- package/dist/api/gateway/auth.js +37 -0
- package/dist/api/gateway/errors.d.ts +5 -0
- package/dist/api/gateway/errors.js +36 -0
- package/dist/api/query-client.d.ts +2 -0
- package/dist/api/query-client.js +19 -0
- package/dist/api/schema.d.ts +246 -0
- package/dist/api/schema.js +5 -0
- package/dist/api/transport/index.d.ts +5 -0
- package/dist/api/transport/index.js +14 -0
- package/dist/auth/auth-context.d.ts +19 -0
- package/dist/auth/auth-context.js +12 -0
- package/dist/auth/auth-provider.d.ts +4 -0
- package/dist/auth/auth-provider.js +101 -0
- package/dist/auth/use-auth.d.ts +2 -0
- package/dist/auth/use-auth.js +9 -0
- package/dist/components/app/app-shell.d.ts +28 -0
- package/dist/components/app/app-shell.js +64 -0
- package/dist/components/app/data-table.d.ts +56 -0
- package/dist/components/app/data-table.js +167 -0
- package/dist/components/app/entity-form.d.ts +14 -0
- package/dist/components/app/entity-form.js +8 -0
- package/dist/components/app/error-state.d.ts +6 -0
- package/dist/components/app/error-state.js +20 -0
- package/dist/components/app/foundation-providers.d.ts +9 -0
- package/dist/components/app/foundation-providers.js +15 -0
- package/dist/components/app/mock-mode-banner.d.ts +3 -0
- package/dist/components/app/mock-mode-banner.js +16 -0
- package/dist/components/app/multi-choice.d.ts +14 -0
- package/dist/components/app/multi-choice.js +13 -0
- package/dist/components/app/password-input.d.ts +3 -0
- package/dist/components/app/password-input.js +30 -0
- package/dist/components/app/route-error-boundary.d.ts +1 -0
- package/dist/components/app/route-error-boundary.js +13 -0
- package/dist/components/theme-provider.d.ts +2 -0
- package/dist/components/theme-provider.js +8 -0
- package/dist/components/ui/badge.d.ts +7 -0
- package/dist/components/ui/badge.js +50 -0
- package/dist/components/ui/button.d.ts +8 -0
- package/dist/components/ui/button.js +34 -0
- package/dist/components/ui/calendar.d.ts +10 -0
- package/dist/components/ui/calendar.js +76 -0
- package/dist/components/ui/card.d.ts +11 -0
- package/dist/components/ui/card.js +25 -0
- package/dist/components/ui/combobox.d.ts +24 -0
- package/dist/components/ui/combobox.js +62 -0
- package/dist/components/ui/dialog.d.ts +17 -0
- package/dist/components/ui/dialog.js +37 -0
- package/dist/components/ui/empty.d.ts +11 -0
- package/dist/components/ui/empty.js +33 -0
- package/dist/components/ui/field.d.ts +24 -0
- package/dist/components/ui/field.js +67 -0
- package/dist/components/ui/input-group.d.ts +18 -0
- package/dist/components/ui/input-group.js +57 -0
- package/dist/components/ui/input.d.ts +3 -0
- package/dist/components/ui/input.js +8 -0
- package/dist/components/ui/label.d.ts +3 -0
- package/dist/components/ui/label.js +7 -0
- package/dist/components/ui/popover.d.ts +9 -0
- package/dist/components/ui/popover.js +23 -0
- package/dist/components/ui/select.d.ts +15 -0
- package/dist/components/ui/select.js +35 -0
- package/dist/components/ui/separator.d.ts +3 -0
- package/dist/components/ui/separator.js +8 -0
- package/dist/components/ui/sheet.d.ts +14 -0
- package/dist/components/ui/sheet.js +38 -0
- package/dist/components/ui/sidebar.d.ts +63 -0
- package/dist/components/ui/sidebar.js +255 -0
- package/dist/components/ui/skeleton.d.ts +2 -0
- package/dist/components/ui/skeleton.js +6 -0
- package/dist/components/ui/sonner.d.ts +3 -0
- package/dist/components/ui/sonner.js +24 -0
- package/dist/components/ui/spinner.d.ts +2 -0
- package/dist/components/ui/spinner.js +7 -0
- package/dist/components/ui/table.d.ts +13 -0
- package/dist/components/ui/table.js +45 -0
- package/dist/components/ui/textarea.d.ts +3 -0
- package/dist/components/ui/textarea.js +7 -0
- package/dist/components/ui/tooltip.d.ts +6 -0
- package/dist/components/ui/tooltip.js +16 -0
- package/dist/gateway.d.ts +2 -0
- package/dist/gateway.js +5 -0
- package/dist/hooks/table-url-changes.d.ts +14 -0
- package/dist/hooks/table-url-changes.js +32 -0
- package/dist/hooks/use-debounced-value.d.ts +1 -0
- package/dist/hooks/use-debounced-value.js +12 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +14 -0
- package/dist/hooks/use-table-url-state.d.ts +14 -0
- package/dist/hooks/use-table-url-state.js +43 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +28 -0
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +1 -0
- package/dist/mocks/auth.d.ts +9 -0
- package/dist/mocks/auth.js +79 -0
- package/dist/mocks/e2e-hooks.d.ts +3 -0
- package/dist/mocks/e2e-hooks.js +28 -0
- package/dist/mocks/index.d.ts +3 -0
- package/dist/mocks/index.js +5 -0
- package/dist/mocks/override.d.ts +23 -0
- package/dist/mocks/override.js +1 -0
- package/dist/routes/login-schema.d.ts +7 -0
- package/dist/routes/login-schema.js +13 -0
- package/dist/routes/login.d.ts +8 -0
- package/dist/routes/login.js +58 -0
- package/dist/routes/register-schema.d.ts +9 -0
- package/dist/routes/register-schema.js +23 -0
- package/dist/routes/register.d.ts +1 -0
- package/dist/routes/register.js +58 -0
- package/dist/routes/return-path.d.ts +1 -0
- package/dist/routes/return-path.js +9 -0
- package/dist/testing/index.d.ts +40 -0
- package/dist/testing/index.js +103 -0
- package/eslint/index.d.ts +11 -0
- package/eslint/index.js +179 -0
- package/openapi/foundation.yaml +156 -0
- package/package.json +158 -0
- package/styles/index.css +88 -0
- 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.
|