jig-ui 0.9.0 → 0.11.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/CHANGELOG.md +95 -0
- package/README.md +55 -12
- package/dist/index.js +1463 -159
- package/layers.json +100 -0
- package/package.json +3 -2
- package/rules/00-anti-patterns.md +80 -2
- package/rules/01-modes.md +6 -6
- package/rules/02-tokens.md +13 -10
- package/rules/03-patterns.md +44 -4
- package/rules/04-principles.md +12 -12
- package/rules/05-copy.md +1 -1
- package/rules.index.json +246 -95
- package/templates/COMMAND.md.tmpl +1060 -17
- package/templates/SKILL.md.tmpl +52 -13
- package/templates/command-metadata.json +43 -3
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.
|
|
4
|
-
"description": "A design system for coding agents.
|
|
3
|
+
"version": "0.11.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 | `
|
|
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
|
|
193
|
+
- **The anti-pattern file.** All 96 rules in it apply everywhere.
|
|
194
194
|
|
|
195
195
|
---
|
|
196
196
|
|
|
197
|
-
##
|
|
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
|
|
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`).
|
package/rules/02-tokens.md
CHANGED
|
@@ -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
|
package/rules/03-patterns.md
CHANGED
|
@@ -437,7 +437,45 @@ done, and `G-42` applies instead.
|
|
|
437
437
|
|
|
438
438
|
---
|
|
439
439
|
|
|
440
|
-
##
|
|
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
|
-
|
|
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
|
|
package/rules/04-principles.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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