jig-ui 0.9.0 → 0.10.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/layers.json ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "_comment": "The six layers, as a VIEW over the corpus. Ids are not renumbered to fit: an id is a permanent address, and a rule that moves layer must keep its number. Every rule in rules.index.json is the anti-patterns layer by definition and is not listed here; this file places the 43 `##`-level units. Guarded by layers.test.ts: every spec appears exactly once, and every id listed exists.",
3
+ "layers": {
4
+ "principles": {
5
+ "question": "What should good UI achieve, and what do I do when no rule covers this?",
6
+ "file": "04-principles.md",
7
+ "ids": [
8
+ "R-01",
9
+ "R-02",
10
+ "R-03",
11
+ "R-04",
12
+ "R-05",
13
+ "R-06",
14
+ "R-07",
15
+ "R-08",
16
+ "R-09",
17
+ "R-10",
18
+ "R-11",
19
+ "R-12"
20
+ ]
21
+ },
22
+ "anti-patterns": {
23
+ "question": "What must I never do?",
24
+ "file": "00-anti-patterns.md",
25
+ "ids": [],
26
+ "note": "Every entry in rules.index.json. Listed there, not here — one place per fact."
27
+ },
28
+ "tokens": {
29
+ "question": "What am I allowed to use?",
30
+ "file": "02-tokens.md",
31
+ "ids": [
32
+ "T-01",
33
+ "T-02",
34
+ "T-03",
35
+ "T-04",
36
+ "T-05",
37
+ "T-06",
38
+ "T-07",
39
+ "T-08",
40
+ "T-09",
41
+ "T-10"
42
+ ]
43
+ },
44
+ "components": {
45
+ "question": "What building blocks exist, and how does each behave?",
46
+ "file": "03-patterns.md",
47
+ "ids": [
48
+ "P-02",
49
+ "P-03",
50
+ "P-06",
51
+ "P-07",
52
+ "P-14"
53
+ ],
54
+ "note": "A component is a thing you render. Its spec states anatomy, states and variants."
55
+ },
56
+ "layout": {
57
+ "question": "How do I arrange them?",
58
+ "file": "03-patterns.md",
59
+ "ids": [
60
+ "L-01",
61
+ "L-02"
62
+ ]
63
+ },
64
+ "patterns": {
65
+ "question": "Which proven composition solves this problem?",
66
+ "file": "03-patterns.md",
67
+ "ids": [
68
+ "P-01",
69
+ "P-04",
70
+ "P-05",
71
+ "P-08",
72
+ "P-10",
73
+ "P-11",
74
+ "P-12",
75
+ "P-13"
76
+ ],
77
+ "note": "A pattern is a solution you apply, not a thing you render. P-04 Form composes P-03 fields; P-12 is a decision procedure; P-05 and P-08 answer 'what shows when there is nothing yet'."
78
+ }
79
+ },
80
+ "not_a_layer": {
81
+ "_comment": "The six layers are the design KNOWLEDGE. These are the machinery that decides which of it applies, and the procedures you run. Forcing them into one of the six would make the view lie.",
82
+ "modes": {
83
+ "question": "Which character does this surface inherit?",
84
+ "ids": [
85
+ "M-01",
86
+ "M-02",
87
+ "M-03",
88
+ "L-05"
89
+ ]
90
+ },
91
+ "process": {
92
+ "question": "What do I run, and when?",
93
+ "ids": [
94
+ "L-03",
95
+ "L-04",
96
+ "L-06"
97
+ ]
98
+ }
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.9.0",
4
- "description": "A design system for coding agents. 104 numbered UI rules, brand x mode design tokens, and an installer for Claude Code, Codex, Cursor and opencode.",
3
+ "version": "0.10.0",
4
+ "description": "A design system for coding agents. 113 numbered UI rules, brand x mode design tokens, and an installer for Claude Code, Codex, Cursor and opencode.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "templates",
15
15
  "references",
16
16
  "rules.index.json",
17
+ "layers.json",
17
18
  "LICENSE",
18
19
  "NOTICE",
19
20
  "README.md",
@@ -151,6 +151,14 @@ The habit comes from pairings where it is true: a mono face drawn separately fro
151
151
  A ratio is also the wrong shape of answer. Inline `code` appears inside body text, headings, table cells and captions; one multiplier has to be right for all of them, and a fixed token is worse still — it collapses code in a heading to caption size. Inheriting is correct in every host, which is why this rule has no token.
152
152
  The measurement is one line in a browser: render `x` in both faces at the same size and compare the rendered heights, or read `sxHeight` from each font's `OS/2` table. Do it once per project when the brand file is written, not per component.
153
153
 
154
+ ### B-106 A word stranded on its own line
155
+ ❌ A heading that wraps to leave one word alone on the last line, or a paragraph ending on a single short word
156
+ ✅ `text-wrap: balance` on headings and short blocks, `text-wrap: pretty` on body copy. One declaration in the type layer, not a fix applied per heading.
157
+ The eye reads a block's shape before it reads the words. A heading whose last line holds one word reads as a mistake to someone who could not name what is wrong with it — the silhouette says unfinished, and that impression lands before the sentence does.
158
+ This is invisible in the source. The same heading breaks cleanly at 1280px and badly at 900px: where a line breaks depends on the box, the face and the string together, and none of the three is decidable from the others. It is judged on the rendered page at more than one width, which is why it carries `pass: screen`.
159
+ A manual break is not the fix. `<br>` placed by eye is correct at exactly one viewport width and wrong at the next, and it survives into every layout the component is later used in.
160
+ Ragged-right is not the failure — that is correct, and `B-12` requires it. The failure is a *stranded* word, not an uneven edge. Do not chase every short last line; chase the one that is alone.
161
+
154
162
  ---
155
163
 
156
164
  ## C. Colour and contrast
@@ -279,6 +287,31 @@ The converse also holds: two elements that do the same job should look the same.
279
287
 
280
288
  ---
281
289
 
290
+ ### D-111 A page that never adapts to the viewport
291
+ ❌ Cards in a row, a nav of links in a row, or a fixed page width — and nothing anywhere in the project that changes them when the screen is narrow
292
+ ✅ Compose for the phone first, then add columns as width allows: a single column that becomes a grid, a nav that becomes a different control, not a smaller copy of the desktop. Use a width breakpoint, a container query, or an intrinsic grid (`repeat(auto-fit, minmax(…))`) — any of them, as long as something responds.
293
+ The phone is not the edge case. It is the most common screen a page is read on, and a layout composed for a wide screen and left alone arrives there as a horizontal scroll, a nav whose last links are off the edge, and three cards crushed to a third of 375px each.
294
+ This is decided for the whole project, not per file, because the grid and the query that collapses it routinely live in different stylesheets. It only reports what is laid out side by side: a single column of text with a `max-width` works on a phone with no breakpoint at all, and is not a finding.
295
+ Two things do **not** count as adapting, and both are traps. A `prefers-reduced-motion` query is about the user, not the width — and `L-04` asks every page for one, so counting it would let a fixed-width page pass by following the self-check. And shrinking is not adapting: the same three columns at a smaller size are still three columns. That second failure is `critique`'s to judge on a render; this rule catches only the page that never responds at all.
296
+
297
+ ### D-112 A full-height section sized with `100vh`
298
+ ❌ `min-height: 100vh` on a hero, a sign-in screen or an app shell
299
+ ✅ `min-height: 100svh`. Use `dvh` only for an element that must follow the browser bars as they show and hide. If older browsers matter, keep `100vh` as the line **before** it, as a fallback.
300
+ On a phone, `100vh` is the height with the browser's bars hidden. While they are showing — which is when the page first loads — the bottom of a "full-height" section is under the toolbar, and the call to action placed at its foot is exactly what cannot be seen.
301
+ `svh` is the default because it does not change: `dvh` resizes as the bars move, which makes the content jump while the reader scrolls.
302
+
303
+ ### D-114 A pinned bar under the notch or the home indicator
304
+ ❌ `position: fixed; bottom: 0` on a tab bar, on a page whose viewport meta tag sets `viewport-fit=cover`
305
+ ✅ Pad the pinned edge with its inset — `padding-bottom: env(safe-area-inset-bottom)` for a bottom bar, and the matching inset for any other edge it touches.
306
+ `viewport-fit=cover` extends the page under the notch and the gesture bar. That is what makes an edge-to-edge design possible, and it also means a bar at `bottom: 0` has its labels sitting beneath the home indicator, where a swipe meant for the tab closes the app instead.
307
+ Without `viewport-fit=cover`, the browser keeps the page inside the safe area on its own, and there is nothing to do.
308
+
309
+ ### D-115 The page scrolls sideways on a phone
310
+ ❌ At phone width the whole page is wider than the screen — a data table, a long URL, an image, a `width: 100vw` element or a fixed-width block pushes it out, and the reader can drag the page left and right
311
+ ✅ At every width, nothing makes the page wider than the screen. Content that is genuinely wider — a data table, a code block — scrolls inside its own container with a visible edge (`E-62`), and the page itself never does. `editorial` goes further and allows no scrolling regions on mobile at all (`M-01`).
312
+ Judge it on a render, not in the source: at 360px, `document.documentElement.scrollWidth` must not be greater than `document.documentElement.clientWidth`. The usual causes are each one line to fix — `overflow-wrap: anywhere` on text the author does not control, `max-width: 100%` on media, `width: 100%` instead of `100vw` (which includes the scrollbar), and a wrapper with `overflow-x: auto` around anything tabular.
313
+ A page that scrolls sideways is not merely untidy. The reader's vertical swipes drift, the page slides half off the screen, and every line of text needs re-centring before it can be read.
314
+
282
315
  ## E. States and interaction
283
316
 
284
317
  Agents render the happy path. This section exists because that is the single most common gap in generated UI.
@@ -330,7 +363,7 @@ People arrive with a mental model built from every other product they use (Jakob
330
363
 
331
364
  ### E-61 Important navigation hidden when it fits
332
365
  ❌ A hamburger menu on a viewport with room for three visible links
333
- ✅ Show what fits. People do not use what they cannot see, and every tap behind a menu is a tap some users will not make. Collapse only under genuine space pressure.
366
+ ✅ Show what fits. People do not use what they cannot see, and every tap behind a menu is a tap some users will not make. Collapse only under genuine space pressure. When the space pressure is real, `P-14` is what to build instead — a prohibition alone leaves you to invent the replacement.
334
367
 
335
368
  ### E-62 Off-screen content with no affordance
336
369
  ❌ A horizontally scrolling row that ends flush at the viewport edge
@@ -392,6 +425,12 @@ Reaching for red at every confirmation spends it, and a red button on "delete th
392
425
  ✅ Start-align the primary, ordered most to least important. Right-aligned actions get missed on wide screens and by screen-magnifier users, and sit further from the fields they submit.
393
426
  On multi-step forms put **"Back" as a tertiary button at the top left** — away from the primary, where it cannot be hit by mistake and lose everything just entered.
394
427
 
428
+ ### E-116 A menu that cannot be opened, or does not say it is open
429
+ ❌ The nav links hidden at phone width (`display: none` in a `max-width` query, or by default until a `min-width` one), and a Menu button with no `aria-expanded` — or no button at all
430
+ ✅ The button that shows the links records it: `aria-expanded="false"` while closed, `"true"` while open, and its visible label or icon changes to **Close** while the menu is open. In `editorial`, `<details>` with `<summary>Menu</summary>` inside the `<nav>` does all of this with no script (`P-14`).
431
+ A Menu button that does nothing looks finished in every screenshot. On a phone it is the only way to the rest of the site, so the reader who taps it and sees nothing change has nowhere to go. A button that opens the menu without `aria-expanded` is the same failure for a screen reader: it announces "Menu, button" before and after, and the reader never learns anything happened.
432
+ `jig check` catches the hidden navigation with no recorded open state anywhere in the project. It cannot tell whether the button actually opens the menu — `critique` operates it on a render at 360px: tap it, and the links appear, `aria-expanded` changes, and the label or icon reads as close.
433
+
395
434
  ---
396
435
 
397
436
  ## F. Forms
@@ -458,6 +497,12 @@ Where people must *browse* to decide, split the list into two dependent fields
458
497
 
459
498
  ---
460
499
 
500
+ ### F-113 Form text small enough to make the phone zoom
501
+ ❌ An input, select or textarea whose text is below 16px — including `operator`'s 14px `--text-body`, and `--text-caption` in every mode
502
+ ✅ At least 16px on touch screens. To keep a denser size on desktop, raise it only where it matters: `@media (pointer: coarse) { input, select, textarea { font-size: max(16px, var(--text-body)); } }`
503
+ iOS Safari zooms the whole page when a field whose text is below 16px takes focus, and it does not zoom back out when the field loses it. The reader is left with a form wider than the screen, scrolling sideways to find the next field (`D-115`).
504
+ Setting `maximum-scale=1` on the viewport to stop it is not the fix. That disables pinch zoom for everyone, which is an accessibility failure in its own right.
505
+
461
506
  ## G. Motion
462
507
 
463
508
  ### G-42 Entrance animation on everything
@@ -499,6 +544,12 @@ Deletion is a real answer and the easy one to miss, because the correction point
499
544
  ❌ Scroll listeners for sticky positioning; scripted accordions and dialogs that have native equivalents
500
545
  ✅ Platform first: `position: sticky`, `<details>`, `<dialog>`, `:has()`, container queries, `scroll-behavior`, `popover`. Reach for a framework when the platform genuinely lacks the capability.
501
546
 
547
+ ### H-117 A token name nothing declares
548
+ ❌ `font-family: var(--font-body)`, `padding: var(--space-lg)` — in a project whose token layer declares neither
549
+ ✅ Use the names the token layer declares — `02-tokens.md` lists them, and the token files in the project are the source. If the value you need has no token, that is a finding to report or a value to delete (`H-47`), never a name to make up.
550
+ The browser does not warn. A `var()` that cannot resolve makes its whole declaration invalid, so the property falls back to its initial value: the font becomes the browser default serif, padding becomes 0, the border disappears. The page still renders, the source still looks tokenised, and every file-based review passes it. In a live run three of four pages invented their token names this way and shipped mostly unstyled.
551
+ `jig check` reads every custom property the project declares — the token layer, its own stylesheets, a Tailwind `@theme`, `style` attributes — and reports each reference to one that is not there. A reference with a fallback, `var(--x, 1rem)`, resolves, and is not reported.
552
+
502
553
  ---
503
554
 
504
555
  ## I. Copy
@@ -509,10 +560,16 @@ Load `05-copy.md` whenever writing or reviewing a user-facing string.
509
560
 
510
561
  ---
511
562
 
512
- ## Self-check before finishing
563
+ ## L-04 · Self-check before finishing
513
564
 
514
565
  Run this against what you produced. Any "no" is a defect to fix, not a note to mention.
515
566
 
567
+ **Answer every item** — yes, no, or n/a with the reason. A number you skip reads
568
+ exactly like a pass. **A yes cites where**: `pricing.css:41`, not "✓". A live run
569
+ answered this list from memory and reported interactive states and a
570
+ reduced-motion path as present; neither existed anywhere in the stylesheet, and
571
+ only a reviewer that had never seen the build found that out.
572
+
516
573
  1. **The generic-AI tells, named rather than gestured at.** This used to read
517
574
  "would this look different from a generic template if the accent colour were
518
575
  removed?", which an agent that has just produced a generic template answers
@@ -554,3 +611,24 @@ Run this against what you produced. Any "no" is a defect to fix, not a note to m
554
611
  9. Does the primary action still work with JavaScript disabled? (F-41)
555
612
  10. Is there a `prefers-reduced-motion` path? (G-43)
556
613
  11. Did you reuse existing components and tokens rather than adding new ones? (H-45, H-47)
614
+ 12. **Has anything judged the rules this list does not name?** Items 1–11 are a
615
+ hand-picked sample of the corpus, chosen because they are the failures most
616
+ worth catching early. They are not the corpus, and finishing them is not
617
+ coverage.
618
+
619
+ Run `jig check` and read its attestation. If it says `judgment=not-run` —
620
+ and on its own it always does, because the CLI can only decide what a
621
+ detector decides — then the majority of the rules that apply to what you
622
+ just built have been judged by nothing.
623
+
624
+ That is not a screen you may call done. Either run `critique`, which walks
625
+ the index and returns a verdict per rule, or say plainly in your final
626
+ message that the judgment pass did not run and the work is unverified
627
+ against it. **Saying nothing is the failure this item exists to stop**: a
628
+ report that lists what was checked and stays silent about what was not reads
629
+ as a clean result, and a reader cannot tell the two apart.
630
+ 13. **Was it looked at on a phone?** At 360px: does the composition change rather
631
+ than shrink, is the navigation a control designed for that width rather than
632
+ the desktop row squeezed, and does the page stay inside the screen — no
633
+ sideways scroll? "The CSS has a media query" is not an answer; what the page
634
+ does at that width is.
package/rules/01-modes.md CHANGED
@@ -14,7 +14,7 @@ This system has two orthogonal axes. Keep them separate.
14
14
  | Values | `editorial` · `product` · `operator` | Per-client identity |
15
15
  | Varies | Between surfaces *within* one project | Between projects, constant within one |
16
16
  | Controls | Density, rhythm, type scale, motion budget, colour *usage* | Palette, typeface, radius personality, elevation personality |
17
- | Defined in | This file | `03-brand.md` (per project) |
17
+ | Defined in | This file | The brand file `jig init` writes — the path `brand` names in `jig.config.json` (per project) |
18
18
 
19
19
  A token is resolved as **brand × mode**. Brand says the accent is `oklch(0.55 0.13 25)`; mode says whether it appears on large surfaces or only on the primary action.
20
20
 
@@ -22,7 +22,7 @@ Collapsing these into one switch produces `theme-marketing-dark-compact` and a s
22
22
 
23
23
  ---
24
24
 
25
- ## Choosing a mode
25
+ ## L-05 · Choosing a mode
26
26
 
27
27
  1. If the project config declares a mode for this route or surface, use it.
28
28
  2. If not, infer from the signals below and **state the inference in one line** before building.
@@ -190,18 +190,18 @@ Attempting to vary these by mode is a category error:
190
190
  - **Accessibility floors.** Contrast, focus indication, target size, semantic markup. Identical in all three. `operator` being dense does not license a 24px tap target or a 3:1 body contrast.
191
191
  - **Brand identity.** Palette, typeface, logo, voice.
192
192
  - **State completeness.** Every mode renders loading, empty, error and disabled.
193
- - **The anti-pattern file.** All 87 rules in it apply everywhere.
193
+ - **The anti-pattern file.** All 96 rules in it apply everywhere.
194
194
 
195
195
  ---
196
196
 
197
- ## `03-brand.md` — stub
197
+ ## The brand file
198
198
 
199
- Per project, one file supplying:
199
+ Per project, one token file — the path `brand` names in `jig.config.json`, written by `jig init` — supplying:
200
200
 
201
201
  - **Palette** — neutral ramp (12 steps, warm/cool/true declared), one accent ramp, semantic set (danger, warning, success, info) tuned to the accent's temperature.
202
202
  - **Typeface** — display and text families, and whether they differ. Numeric font-feature settings.
203
203
  - **Radius personality** — the brand-scale radius options (`sm`, `md`, `lg`, `full`) that each mode selects from, not a fixed derivation. This carries more brand character than colour does.
204
204
  - **Elevation personality** — border-led or shadow-led. Pick one; do not mix within a project.
205
- - **Voice** — sentence case or title case, contraction policy, error-message tone.
205
+ - **Voice is not in it.** Sentence case, contraction policy and error-message tone are not tokens; they belong in `DECISIONS.md`, and the defaults are in `05-copy.md`.
206
206
 
207
207
  Default when no brand is supplied: warm neutral ramp anchored on `--color-bg-base` (`oklch(0.980 0.004 95)`, a warm off-white), no accent, 8px base radius (`--radius-sm`), border-led elevation. Greyscale output plus a stated question beats an invented purple (`A-01`).
@@ -5,7 +5,7 @@
5
5
  **Canonical format:** CSS custom properties
6
6
  **Consumed by:** any framework that renders to the web
7
7
 
8
- ## Architecture
8
+ ## T-01 · Architecture
9
9
 
10
10
  Tokens resolve as **brand × mode**. Two layers, loaded in order.
11
11
 
@@ -41,7 +41,7 @@ relocating the layer changes one line instead of every stylesheet:
41
41
 
42
42
  Three separate mode files rather than one file with variants. The trade: a surface cannot switch modes at runtime, and shared values are duplicated across three files. In exchange each surface ships only the tokens it uses, the files are independently readable, and there is no cascade to reason about. For a system where mode is a routing decision rather than a user preference, that is the right trade.
43
43
 
44
- ## Why CSS custom properties
44
+ ## T-02 · Why CSS custom properties
45
45
 
46
46
  They are the only token format every web framework consumes natively with no build step.
47
47
 
@@ -55,7 +55,7 @@ They are the only token format every web framework consumes natively with no bui
55
55
 
56
56
  **Boundary:** this does not cover React Native or native platforms, which cannot read CSS. If a non-web target enters scope, author in DTCG JSON and generate these files with Style Dictionary or Terrazzo. The naming contract below is DTCG-compatible, so that migration is mechanical. Do not build the pipeline before you need it.
57
57
 
58
- ## Predefined option sets
58
+ ## T-03 · Predefined option sets
59
59
 
60
60
  Limited options, chosen once. The point is not the specific values — it is that there are few of them, so a decision is a selection rather than an invention.
61
61
 
@@ -178,7 +178,7 @@ width and buys one character at 360px, while changing nothing at 320px.
178
178
 
179
179
  **Shadow — three, two of which do anything**: `--shadow-raised` sits above the page, `--shadow-overlay` floats over it, and `--shadow-none` is the explicit absence a mode selects when its elevation is stroke-led rather than shadow-led (every mode currently does, via `--shadow-surface`). `A-08` still prefers a stroke; the other two exist for when depth is the point.
180
180
 
181
- ## Sizes and motion, by mode
181
+ ## T-04 · Sizes and motion, by mode
182
182
 
183
183
  `01-modes.md` names these tokens in each mode's profile and points here for the
184
184
  resolved values. They were not here: the option sets above cover type, spacing,
@@ -245,6 +245,9 @@ its durations are shorter: a curve with a long tail makes a 100ms animation feel
245
245
  slower than it is.
246
246
 
247
247
  **`--size-touch-target` is 48px in every mode and is not a density decision.**
248
+ It is the minimum tap target — the hit area a finger needs — for anything that can be
249
+ pressed. 48px is deliberately above both the 44px of the iOS guidance and the 24px
250
+ minimum of WCAG 2.2.
248
251
  It is an accessibility floor, so it is excluded from the table above — there is
249
252
  nothing per-mode about it to resolve. The same is true of `--focus-ring-width`
250
253
  and `--focus-ring-offset`, which live in the brand file for that reason.
@@ -257,7 +260,7 @@ shared ladder rather than stating their own values:
257
260
  | `--spacing-card` | `--spacing-m` | `--spacing-m` | `--spacing-s` |
258
261
  | `--spacing-section` | `--spacing-xxl` | `--spacing-xl` | `--spacing-m` |
259
262
 
260
- ## Colour naming
263
+ ## T-05 · Colour naming
261
264
 
262
265
  Two layers, and only one of them is used in component code.
263
266
 
@@ -279,7 +282,7 @@ The payoff is mode switching: one semantic name maps to a different primitive in
279
282
 
280
283
  Resist per-component tokens (`--button-bg`). They multiply fast and rarely earn it.
281
284
 
282
- ## Naming contract
285
+ ## T-06 · Naming contract
283
286
 
284
287
  Names align to Tailwind v4's theme namespaces. This is free for other frameworks — they are ordinary custom properties — and means most of them can be exposed as Tailwind utilities through an alias block, without any framework taking a dependency on Tailwind.
285
288
 
@@ -313,7 +316,7 @@ Verified by compiling one alias per namespace against `tailwindcss@4.3.3` and re
313
316
  2. No component-scoped tokens. `--button-bg` belongs in the component, referencing `--color-brand`.
314
317
  3. A value that cannot be expressed as a token is a missing token, not an exception (`H-47`).
315
318
 
316
- ## Colour architecture
319
+ ## T-07 · Colour architecture
317
320
 
318
321
  **Foregrounds are transparent. Backgrounds are solid.**
319
322
 
@@ -340,7 +343,7 @@ Brand and each system colour take the same four variations: **100%** text, **80%
340
343
 
341
344
  **Neutral or monochromatic.** The default is neutral (pure black/white opacities), which works with any brand colour. For a monochromatic palette, tint the dark-mode backgrounds with the brand hue and, in light mode, replace the black opacities with a heavily saturated brand hue at low lightness. Change `--brand-h` and `--brand-s`; nothing else moves.
342
345
 
343
- ## Contrast contract
346
+ ## T-08 · Contrast contract
344
347
 
345
348
  **Floor: WCAG 2.1 AA.** Two thresholds, and the boundary between them is a common mistake.
346
349
 
@@ -379,13 +382,13 @@ APCA reference values, with the sizes they apply at — a score means nothing wi
379
382
 
380
383
  These thresholds are APCA's own and do not line up with WCAG's large-text definition (`C-17`, 24px regular / 18.66px bold) — the two systems measure differently, and each is right inside its own frame.
381
384
 
382
- ## Dark mode
385
+ ## T-09 · Dark mode
383
386
 
384
387
  Not an inversion (`C-21`). Each brand file supplies a dark block under `@media (prefers-color-scheme: dark)` and `[data-theme="dark"]`, remapping semantics only. Mode files are theme-independent — density does not change with colour scheme.
385
388
 
386
389
  In dark, elevated surfaces get **lighter**, not shadowed. Border-led elevation survives the switch; shadow-led does not, which is one reason border-led is the unbranded default.
387
390
 
388
- ## Consuming
391
+ ## T-10 · Consuming
389
392
 
390
393
  **Plain CSS, any framework**
391
394
  ```css
@@ -437,7 +437,45 @@ done, and `G-42` applies instead.
437
437
 
438
438
  ---
439
439
 
440
- ## Layout method
440
+ ## P-14 · Site navigation
441
+
442
+ Compose it for the phone first. Mobile navigation is a different control — not the wide row made smaller (`D-111`).
443
+
444
+ **Choose by what fits at the narrowest width you support** (360px if nothing is decided). Stop at the first row that works:
445
+
446
+ | At that width | Use |
447
+ | --- | --- |
448
+ | Every destination fits, each at least `--size-touch-target` | Show them all. Wrapping to a second row is fine (`E-61`) |
449
+ | The one or two most important fit beside the site name, the rest do not | Show those, plus a button labelled **Menu** for the rest |
450
+ | Nothing fits beside the site name | A button labelled **Menu** for all of them |
451
+ | `product`, three to five top-level sections used repeatedly | A bar of labelled items along the bottom edge — the same items, in the same order, on every screen, padded with `env(safe-area-inset-bottom)` so the phone's home indicator does not sit on it |
452
+
453
+ **Rules**
454
+ - **The menu control is named.** A `<button>` with `aria-expanded` reflecting its state, and the accessible name "Menu" — as visible text, or as `aria-label` on an icon. The three-line hamburger icon is widely read as a menu now, so whether the word is visible is the project's choice. Whether assistive technology can name the control is not (`E-34`).
455
+ - **The menu control works, and shows which way it is.** This is behaviour, not markup, and a screenshot cannot show it (`E-116`):
456
+ - Tapping it opens the menu: the links become visible.
457
+ - `aria-expanded` is `"false"` while closed and `"true"` while open.
458
+ - While open, its visible label or icon reads as close — the word **Close**, or a cross — and its accessible name says so. Tapping it again closes the menu.
459
+ - `Escape` closes an open menu and returns focus to the button.
460
+ - `<details>`/`<summary>` gives the first three for free; a hand-rolled button has to do each one.
461
+ - **Where the menu button sits is the project's decision.** Top right, top left, centred — that is taste, and it belongs in `DECISIONS.md`, not here. What the system asks is only that it stays in the same place on every screen and at every width it appears. **The decision is where it sits, never whether it exists:** at a width where every destination fits, the table above shows the links and there is no menu button, whatever `DECISIONS.md` says about its position.
462
+ - **Mark where the reader is, the same way at every width.** Every screen has to answer *where am I?* without the reader remembering how they arrived.
463
+ - The link to the current page carries `aria-current="page"`. A section link whose child page is open may carry `aria-current="true"`.
464
+ - Style the mark from that attribute — `[aria-current="page"]` in CSS — not from a separate `.active` or `.current` class. One source for both what is seen and what is announced means the two cannot drift apart; a class alone looks marked and tells a screen reader nothing.
465
+ - The visible cue is not colour alone (`C-20`): weight, an underline or bar, or a filled state. Which one is the project's decision.
466
+ - Inside an open menu, the current item is marked the same way. When the menu is closed nothing in the navigation is visible, so the page's `<h1>` is what tells the reader where they are — every page has one, and it names the page.
467
+ - **It works with no JavaScript** (`F-41`). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. In `editorial`, where the script budget is zero (`M-01`), use `<details>` with `<summary>Menu</summary>` — a disclosure the browser provides with no script at all.
468
+ - **Never let a row that does not fit scroll sideways.** Its last items go past the edge where nobody sees them (`E-62`), and `editorial` forbids horizontal scrolling on mobile outright. An open menu is a vertical list.
469
+ - **Same destinations, same order, at every width.** The phone may show fewer at once. It never shows different ones, and never reorders them — `product` fixes navigation position across the app (`M-02`), and a reader who learned the order on one screen should not have to relearn it on another.
470
+ - **Every item is at least `--size-touch-target` tall**, made with padding rather than a larger font. The target grows; the text does not.
471
+ - **An open menu does not cover the page unless it has to.** If it does cover the page, it is a dialog and `P-07` applies: focus moves into it, `Escape` closes it, and focus returns to the button. A menu that opens inline needs none of that — but `Escape` still closes it and returns focus to the button.
472
+ - **A sticky header at phone width is one row.** Two sticky rows permanently spend a sixth of a phone's height on chrome.
473
+ - **The wide row appears where the labels fit, not at a device width.** Set the breakpoint from the content — the width at which every destination sits on one line at full touch size — so a longer label moves the breakpoint instead of breaking the row.
474
+ - **`operator`:** the wide screen is the real case. At phone width, one **Menu** button for everything is enough, and keyboard operation of the open menu is mandatory (`M-03`).
475
+
476
+ ---
477
+
478
+ ## L-01 · Layout method
441
479
 
442
480
  Not a component. The procedure for structuring any screen, before styling anything.
443
481
 
@@ -482,11 +520,13 @@ Main containers align to a 12-column grid; small elements *inside* them do not
482
520
 
483
521
  Blur the design, zoom out, or step back. You should still be able to tell what the screen is for and which element matters most. If everything reads at one weight the hierarchy has failed; if elements smear together the white space is too tight.
484
522
 
485
- An agent cannot squint, so use the analogue: **if all type were one size and one colour, would the layout still communicate its order?** If the hierarchy depends entirely on type styling, it is too weak.
523
+ **When the page can be rendered, test it without colour.** Render it with `filter: grayscale(1)` on the root element — in a browser tool, one line of script — and look again at every size. Spacing, contrast and size should carry the order on their own: the primary action still reads first, the heading still leads its section, what belongs together still sits together. Colour is added on top of a hierarchy that already works; it does not make one. If the primary action is only findable by its hue, the hierarchy is too weak (`E-91` is the same failure on a single button). Then blur it — add `blur(2px)` — and ask what the screen is for and which element matters most.
524
+
525
+ **When nothing can render it, use the analogue:** if all type were one size and one colour, would the layout still communicate its order? If the hierarchy depends entirely on type styling, it is too weak. The analogue is a fallback, not an equal — a reading of the source is not a look at the page.
486
526
 
487
527
  ---
488
528
 
489
- ## Building modularly
529
+ ## L-02 · Building modularly
490
530
 
491
531
  Patterns are not built page-first. Build the smallest pieces, then compose.
492
532
 
@@ -505,7 +545,7 @@ Before writing a new component, check whether it is a composite of things that a
505
545
 
506
546
  ---
507
547
 
508
- ## Adding a pattern
548
+ ## L-03 · Adding a pattern
509
549
 
510
550
  A new pattern earns a place here when it has been built three times. Before then it is a component, not a pattern.
511
551
 
@@ -15,7 +15,7 @@ If you reach for Part 2 often, the rules in `00`–`03` are underspecified and t
15
15
 
16
16
  # Part 1 · Frames
17
17
 
18
- ## Frame 1 — Minimise usability risk
18
+ ## R-01 · Frame 1 — Minimise usability risk
19
19
 
20
20
  **Ask: who could struggle with this, and why?**
21
21
 
@@ -35,7 +35,7 @@ The risk is rarely to the median user. It falls on people with reduced vision, l
35
35
 
36
36
  **Floor:** WCAG 2.1 level AA. Meeting AA is the starting point, not the achievement.
37
37
 
38
- ## Frame 2 — Every detail has a reason you can state
38
+ ## R-02 · Frame 2 — Every detail has a reason you can state
39
39
 
40
40
  **Ask: why this way rather than another way?**
41
41
 
@@ -45,7 +45,7 @@ This is the test every rule in this system had to pass, and it is why the token
45
45
 
46
46
  **Use it like this:** when you make a call the rules do not cover, state the reason in one line. If you cannot, you are guessing — and a guess should be surfaced as a question, not shipped as a decision (Tiebreaker 5).
47
47
 
48
- ## Frame 3 — Minimise interaction cost
48
+ ## R-03 · Frame 3 — Minimise interaction cost
49
49
 
50
50
  **Ask: what does this cost the user, counted?**
51
51
 
@@ -59,7 +59,7 @@ Three reliable reductions:
59
59
 
60
60
  **Use it like this:** count before and after, and state it. "3 clicks + 1 scroll → 2 clicks" is reviewable. "Improved the UX" is not. See `P-10`.
61
61
 
62
- ## Frame 4 — Minimise cognitive load
62
+ ## R-04 · Frame 4 — Minimise cognitive load
63
63
 
64
64
  **Ask: how much thinking does this require that is not the user's actual task?**
65
65
 
@@ -73,7 +73,7 @@ Attention spent decoding the interface is unavailable for the work. Reliable red
73
73
 
74
74
  **Use it like this:** when something feels heavy but no rule is broken, the load is usually ungrouped information or an unnecessary decision. Split it or remove it. A long form becomes steps; a wide table becomes fewer default columns; six equal options become two recommended and four behind "more".
75
75
 
76
- ## Frame 5 — Optimise for the common path
76
+ ## R-05 · Frame 5 — Optimise for the common path
77
77
 
78
78
  **Ask: what are most people here to do?**
79
79
 
@@ -91,31 +91,31 @@ Effort should follow it. Make the common task excellent before making the rare o
91
91
 
92
92
  Seven. Each resolves a specific conflict in a specific direction. A principle that does not tell you what to give up is decoration.
93
93
 
94
- ### 1. Prefer the loud failure
94
+ ## R-06 · Tiebreaker 1 — Prefer the loud failure
95
95
 
96
96
  **Between silent failure and visible failure, choose visible.**
97
97
 
98
98
  A form that discards a submission and shows success is worse than one that errors. A page serving stale data without saying so is worse than a slow one. Silent failure is the most expensive class of defect, because the cost is paid by someone who never finds out.
99
99
 
100
- ### 2. Never destroy on suspicion
100
+ ## R-07 · Tiebreaker 2 — Never destroy on suspicion
101
101
 
102
102
  **When the system suspects input is wrong, mark it and hold it. Do not discard it.**
103
103
 
104
104
  Spam scores, validation failures, duplicate detection — all heuristics, all wrong sometimes. Hold the item, record why, let a person decide. Applies equally to the user's typing: never clear a form, drop a draft, or overwrite without a copy.
105
105
 
106
- ### 3. Recoverable beats correct
106
+ ## R-08 · Tiebreaker 3 — Recoverable beats correct
107
107
 
108
108
  **Between preventing a mistake and allowing it to be undone, choose undo.**
109
109
 
110
110
  Prevention charges every user friction on every interaction to guard against a rare error. Recovery costs nothing until the error happens. Exception: genuinely irreversible operations, which confirm — and in `operator`, confirm by typing.
111
111
 
112
- ### 4. Optimise for who is actually there
112
+ ## R-09 · Tiebreaker 4 — Optimise for who is actually there
113
113
 
114
114
  **When density and legibility conflict, decide by the user's real conditions, not by preference.**
115
115
 
116
116
  A first-time visitor on mobile data in bright sun and an operator at a large display for eight hours need opposite things. Mode encodes this. When the mode is genuinely unclear, ask — do not average, because the average serves neither.
117
117
 
118
- ### 5. Restraint is the default
118
+ ## R-10 · Tiebreaker 5 — Restraint is the default
119
119
 
120
120
  **When a decision has not been made, ship the plainer thing and surface the question.**
121
121
 
@@ -125,13 +125,13 @@ An invented accent, a decorative animation, a gradient filling an empty space
125
125
 
126
126
  **Ceiling: restraint applies to decoration, never to information.** Minimal is not the same as simple. A sparse interface that has dropped labels, selected states or visible actions is harder to use than a busier one that keeps them — it just photographs better. Strip styling freely; never strip the answers to *what is this*, *which one is selected*, and *what can I do next* (`E-63`).
127
127
 
128
- ### 6. The platform before the framework
128
+ ## R-11 · Tiebreaker 6 — The platform before the framework
129
129
 
130
130
  **When the browser can already do it, use the browser.**
131
131
 
132
132
  `<dialog>`, `<details>`, `position: sticky`, `:has()`, container queries, native form validation, `popover`. Platform features carry accessibility, keyboard handling and state management that a reimplementation gets wrong and then needs maintaining.
133
133
 
134
- ### 7. Match the codebase before matching this document
134
+ ## R-12 · Tiebreaker 7 — Match the codebase before matching this document
135
135
 
136
136
  **When local convention conflicts with these rules, local convention wins.**
137
137
 
package/rules/05-copy.md CHANGED
@@ -131,7 +131,7 @@ See `P-01` for *where* the message goes and `F-37` for field-level validation te
131
131
 
132
132
  ---
133
133
 
134
- ## Checklist
134
+ ## L-06 · Copy checklist
135
135
 
136
136
  1. Sentence case throughout? (`I-53`)
137
137
  2. Any word removable without loss? (`I-79`)