jig-ui 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
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.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -12,6 +12,7 @@
12
12
  "rules",
13
13
  "tokens",
14
14
  "templates",
15
+ "references",
15
16
  "rules.index.json",
16
17
  "LICENSE",
17
18
  "NOTICE",
@@ -35,7 +35,7 @@ These are the strongest defaults in a model's training data and the fastest way
35
35
 
36
36
  ### A-04 Trend styles that fight legibility
37
37
  ❌ Glassmorphism (translucent fill + `backdrop-filter: blur()`), neumorphism (soft inset/outset shadows on a matching background), and their successors
38
- ✅ Opaque `--color-surface` with a `--color-stroke-weak` edge. Use translucency only over media, and only when legibility is verified against the worst frame.
38
+ ✅ Opaque `--color-bg-raised` with a `--color-stroke-weak` edge. Use translucency only over media, and only when legibility is verified against the worst frame.
39
39
  Both styles make sufficient contrast and clear hierarchy structurally difficult — neumorphism in particular defines every element with shadow alone, which fails at 3:1 almost by construction. Trend styles also age badly: the more of them a product carries, the more precisely it is dated. Minimal styling that foregrounds content lasts longer.
40
40
  Experiment freely — but not where it costs legibility or excludes people.
41
41
 
@@ -99,11 +99,11 @@ In `operator`, `--radius-surface` also selects `sm`, so cards, buttons and input
99
99
 
100
100
  ### B-13 One line-height for everything
101
101
  ❌ One line-height value applied to both a 48px heading and 16px body
102
- ✅ `--leading-body` for prose, `--leading-heading` for headings, `--leading-display` for the largest tier. Line height decreases as size increases.
102
+ ✅ `--leading-body` for UI text, `--leading-prose` for sustained reading, and `--leading-h3` / `--leading-h2` / `--leading-h1` as the heading tier rises. Line height decreases as size increases, and the heading values are per-mode — see the table in `02-tokens.md`.
103
103
 
104
104
  ### B-14 Thin weights for body text
105
105
  ❌ Weight 300 or lighter for paragraphs
106
- ✅ `--font-weight-body` (400) minimum. Thin weights fail on low-density screens and in bright light — both of which describe most of your users' actual conditions.
106
+ ✅ `--font-weight-regular` (400) minimum. Thin weights fail on low-density screens and in bright light — both of which describe most of your users' actual conditions.
107
107
 
108
108
  ### B-15 Ad-hoc type sizes
109
109
  ❌ A one-off `font-size` because something looked slightly wrong
@@ -157,7 +157,7 @@ Also: shadow colour derives from the text colour, never pure black, so it sits i
157
157
 
158
158
  ### C-19 Grey text below contrast floor
159
159
  ❌ A mid-grey (ramp step `-500` or lighter) used for secondary text, placeholders or timestamps
160
- ✅ `--color-text-weak` for secondary text, `--color-text-weak` for large text only. See the contrast contract in `02-tokens.md`: `-500` and lighter are never text on a light background. Check placeholders and disabled states specifically; they are the usual failures.
160
+ ✅ `--color-text-weak` for secondary text, at any size — it clears 4.5:1 on every surface in the system, which is why there is no lighter grey to reach for. Those are the only two foreground greys, deliberately: a value that passes only at large sizes is the thing this rule forbids. If `--color-text-weak` looks too heavy, the answer is more space or a smaller size, not a paler grey. See the contrast contract in `02-tokens.md`: `-500` and lighter are never text on a light background. Check placeholders and disabled states specifically; they are the usual failures.
161
161
 
162
162
  ### C-20 Colour as the sole signal
163
163
  ❌ Red border alone to indicate an invalid field
@@ -169,7 +169,27 @@ Also: shadow colour derives from the text colour, never pure black, so it sits i
169
169
 
170
170
  ### C-22 Semantic colours invented inline
171
171
  ❌ Two different reds in two places, both meaning "error"
172
- ✅ One token per meaning — `--color-danger`, `--color-danger-subtle`, `--color-danger-stroke` — referenced everywhere.
172
+ ✅ One token per meaning — `--color-text-error`, `--color-fill-error`, `--color-stroke-error-strong` — referenced everywhere.
173
+
174
+ **Every ratio in this section is WCAG 2.1, and that is deliberate — but it is
175
+ not the whole picture.** WCAG 2.1 AA is what these rules enforce and what
176
+ `check` fails a build on, because it is what is legally referenced. APCA (the
177
+ WCAG 3 draft) measures perceptually and scores by size and weight rather than a
178
+ flat ratio; **`02-tokens.md` carries its threshold table**, and it is worth
179
+ checking as well, particularly on dark surfaces where WCAG 2's algorithm is
180
+ weakest.
181
+
182
+ Two places the difference bites:
183
+
184
+ - **WCAG 2.1 exempts disabled controls entirely**, so nothing in this section
185
+ constrains how faint a disabled label may be. APCA does — Lc 30 is its
186
+ absolute minimum for disabled button text — and it is the only standard that
187
+ says anything at all here. `--opacity-disabled` is held to it.
188
+ - The two do not agree on what counts as large text. APCA's thresholds are its
189
+ own and do not line up with `C-17`. Each is right inside its own frame; do not
190
+ mix a score from one with a size rule from the other.
191
+
192
+ Aim to pass both. Where only one of them has an opinion, that one decides.
173
193
 
174
194
  ### C-49 Link treatment
175
195
  The default for a link **inside running text** is colour **and** underline. Colour-blind users cannot separate a coloured link from surrounding prose; the underline is what makes it a link for them.
@@ -201,7 +221,7 @@ The converse also holds: two elements that do the same job should look the same.
201
221
 
202
222
  ### D-23 Spacing off the scale
203
223
  ❌ An arbitrary margin or gap (13px, 7px) written at the call site
204
- ✅ Every spacing value comes from a `--spacing-*` token, all multiples of `--spacing-unit` (4px). An arbitrary value signals a missing token, not an exception.
224
+ ✅ Every spacing value comes from a `--spacing-*` token. The ladder is built on 4px, the value of its smallest rung `--spacing-2xs`; there is no separate base token, and nothing should reference one. An arbitrary value signals a missing token, not an exception.
205
225
 
206
226
  ### D-24 Symmetric spacing around headings
207
227
  ❌ Equal margin above and below a section heading
@@ -260,7 +280,7 @@ Agents render the happy path. This section exists because that is the single mos
260
280
 
261
281
  ### E-29 Focus removed without replacement
262
282
  ❌ `outline: none` with nothing in its place
263
- ✅ A `:focus-visible` rule with a visible indicator built on `--color-focus`. Never remove the outline without replacing it. This locks out every keyboard user, and it is the most common accessibility failure in generated code.
283
+ ✅ A `:focus-visible` rule with a visible indicator built on `--color-focus`, at `--focus-ring-width` with `--focus-ring-offset`. Never remove the outline without replacing it. This locks out every keyboard user, and it is the most common accessibility failure in generated code.
264
284
 
265
285
  ### E-30 Empty states omitted
266
286
  ❌ A table that renders an empty `<tbody>` when there is no data
@@ -346,7 +366,17 @@ This also means a button's fill or border is not decorative: it is the thing ide
346
366
  ### E-94 Destructive actions coloured red at rest
347
367
  ❌ A red "Delete" sitting in a list of rows
348
368
  ✅ At rest a destructive action is **tertiary** — less prominent, further from the primary, or disclosed. Red makes it *more* prominent, which is backwards: the goal is friction, not attention.
349
- `--color-danger` styling belongs on the **confirming** button inside the confirmation step, where the user has already chosen and needs to feel the weight of it.
369
+ Red belongs on the **confirming** button inside the confirmation step — `--color-text-error` and the `--color-fill-error` / `--color-stroke-error-strong` set — where the user has already chosen and needs to feel the weight of it.
370
+
371
+ **But not at every confirmation.** The reference grades the friction, and so should you:
372
+
373
+ | Friction | When | Treatment |
374
+ | --- | --- | --- |
375
+ | Light | A less serious action | Ask for confirmation. The confirming button stays **brand-coloured, not red** |
376
+ | Moderate | Genuinely destructive, recoverable with effort | Red confirming button, red accent on the dialog |
377
+ | Heavy | Irreversible — deleting an account, purging data | Red, **plus a checkbox that must be ticked** before the action can fire |
378
+
379
+ Reaching for red at every confirmation spends it, and a red button on "delete this draft" leaves nothing louder for "delete your account". Whichever level you pick, prefer making the action **undoable** over making it frightening (`04-principles.md`, Tiebreaker 3) — friction protects nobody who has already clicked.
350
380
 
351
381
  ### E-95 Primary action parked at the right
352
382
  ❌ A right-aligned "Next" with "Back" beside it at the bottom of a multi-step form
@@ -425,14 +455,20 @@ Where people must *browse* to decide, split the list into two dependent fields
425
455
  ❌ Every section fading and rising on scroll
426
456
  ✅ Motion earns its place by explaining a change of state or spatial relationship. Decoration on a page the user will visit twice a day becomes friction.
427
457
 
458
+ **"Once" means once per visitor, not once per page load.** A welcome or hero animation that replays on every arrival stops being an introduction after the first one and becomes a toll. Persist the fact that it has played and skip it thereafter. Where one does run, give it a visible one-click skip — a user who wants the content now must not have to wait out a brand moment to reach it.
459
+
428
460
  ### G-43 `prefers-reduced-motion` ignored
429
461
  ❌ Animation with no reduced-motion path
430
462
  ✅ Always provide the reduced path. Non-negotiable — this is a vestibular safety issue, not a preference.
431
463
 
464
+ **A consequence worth stating: motion is never the sole signal, for the same reason colour is not (`C-20`).** Honouring the reduced-motion path removes the animation, so any state that was communicated by movement alone is communicated to that user by nothing at all. A field that only shakes on a bad password has no error state under reduced motion. Pair the motion with text, an icon, or a colour change that survives without it.
465
+
432
466
  ### G-44 Durations too long
433
467
  ❌ 500ms+ on UI feedback
434
468
  ✅ 100–200ms for state change, up to 300ms for larger transitions. If it can be perceived as waiting, it is too slow.
435
469
 
470
+ **Scope: this is about motion that answers an input or carries a state change.** It is not a ceiling on every animation on the page. Slow decorative looping motion — see `P-13` — runs for seconds by design, and is not covered here. The reason the two differ is the reason the numbers differ: interaction motion sits between the user and their task, so it must get out of the way; ambient motion is never in the way, so speed would only make it noticeable.
471
+
436
472
  ---
437
473
 
438
474
  ## H. Code-level
package/rules/01-modes.md CHANGED
@@ -74,12 +74,12 @@ At a seam between modes:
74
74
  | Control height | `--size-control` — the tallest of the three |
75
75
  | Radius | `--radius-control` (sm) · `--radius-surface` (md) |
76
76
  | Elevation | `--shadow-none`; `--shadow-raised` for sticky nav only |
77
- | Motion | `--duration-base`; entrance animation **once**, first viewport only |
77
+ | Motion | `--duration-base`; entrance animation **once per visitor**, first viewport only, skippable (`G-42`); ambient motion permitted (`P-13`) |
78
78
  | Colour usage | Neutral-dominant. `--color-brand` for links and primary CTA only. |
79
79
  | Imagery | Central. Real photography or commissioned illustration. |
80
80
  | Keyboard | Standard tab order; no shortcuts expected |
81
81
 
82
- Resolved values: `02-tokens.md`.
82
+ Resolved values: `02-tokens.md` — the option sets for type, spacing, radius and shadow, and "Sizes and motion, by mode" for control heights, row heights, durations and measure.
83
83
 
84
84
  **Mode-specific rules**
85
85
  - One hero maximum, at the top. A second full-viewport section is a second hero.
@@ -114,7 +114,7 @@ Resolved values: `02-tokens.md`.
114
114
  | Imagery | Sparse. Illustration permitted in empty states only. |
115
115
  | Keyboard | Shortcuts for frequent actions; documented in-app |
116
116
 
117
- Resolved values: `02-tokens.md`.
117
+ Resolved values: `02-tokens.md` — the option sets for type, spacing, radius and shadow, and "Sizes and motion, by mode" for control heights, row heights, durations and measure.
118
118
 
119
119
  **Mode-specific rules**
120
120
  - One primary action per view. Everything else is secondary or tertiary.
@@ -150,7 +150,7 @@ Resolved values: `02-tokens.md`.
150
150
  | Keyboard | Full keyboard operation mandatory. Shortcut reference required. |
151
151
  | Numerals | Tabular figures mandatory on all numeric columns |
152
152
 
153
- Resolved values: `02-tokens.md`.
153
+ Resolved values: `02-tokens.md` — the option sets for type, spacing, radius and shadow, and "Sizes and motion, by mode" for control heights, row heights, durations and measure.
154
154
 
155
155
  **Mode-specific rules**
156
156
  - Density is the feature. More rows visible beats more comfortable rows.
@@ -175,7 +175,8 @@ Useful when a decision straddles two modes.
175
175
  | Section rhythm | `--spacing-xxl` | `--spacing-xl` | `--spacing-m` |
176
176
  | Card padding | `--spacing-m` | `--spacing-m` | `--spacing-s` |
177
177
  | Motion | slowest | mid | fastest |
178
- | Entrance animation | once, first viewport | none | none |
178
+ | Entrance animation | once per visitor, first viewport | none | none |
179
+ | Ambient motion | permitted (`P-13`) | none | none |
179
180
  | Decorative colour | accent only | primary action | none |
180
181
  | Imagery | central | empty states | none |
181
182
  | Optimises for | first use | both | thousandth use |
@@ -203,13 +204,13 @@ Per project, one file supplying:
203
204
  - **Elevation personality** — border-led or shadow-led. Pick one; do not mix within a project.
204
205
  - **Voice** — sentence case or title case, contraction policy, error-message tone.
205
206
 
206
- Default when no brand is supplied: warm neutral ramp anchored on `#fafaf7`, no accent, 8px base radius (`--radius-sm`), border-led elevation. Greyscale output plus a stated question beats an invented purple (`A-01`).
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`).
207
208
 
208
209
  ---
209
210
 
210
211
  ## Notes for the author (not for the agent)
211
212
 
212
- **Decided, not derived.** These numbers are internally consistent and defensible, but several are judgement calls that should be tuned once you have run real work through them: the operator row height, the three section-rhythm values, and the motion durations. Change them in this file, never at the call site.
213
+ **Decided, not derived.** These numbers are internally consistent and defensible, but several are judgement calls that should be tuned once you have run real work through them: the operator row height, the three section-rhythm values, and the motion durations. Change them in `tokens/mode.*.css`, never at the call site — this file describes them, `02-tokens.md` resolves them, and neither is where they live.
213
214
 
214
215
  **Where your taste is recorded here:**
215
216
  - The zero-JS default in `editorial` — a stronger position than most systems take, and consistent with your writing on JS-dependent forms.
@@ -58,31 +58,193 @@ Modes **select** from these; they never define their own values. `--spacing-card
58
58
 
59
59
  **Type — the scale ratio varies by mode**, because scale size should track interface complexity. A large ratio gives dramatic steps that suit content-led pages; a small ratio gives fine gradations that suit dense tools needing many levels in little space.
60
60
 
61
- | Mode | Ratio | | Caption | Body (UI) | Prose | H3 | H2 | H1 |
62
- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
63
- | `editorial` | 1.250 Major Third | | 14 | 16 | 18 | 24 | 32 | 48 |
64
- | `product` | 1.200 Minor Third | | 14 | 16 | 18 | 20 | 24 | 32 |
65
- | `operator` | 1.125 Major Second | | 12 | 14 | 16 | 16 | 18 | 22 |
61
+ | Mode | Ratio | | Caption | Body (UI) | Prose | Lead | H3 | H2 | H1 |
62
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
63
+ | `editorial` | 1.250 Major Third | | 14 | 16 | 18 | 20 | 24 | 24–32 | 32–48 |
64
+ | `product` | 1.200 Minor Third | | 14 | 16 | 18 | 20 | 20 | 20–24 | 24–32 |
65
+ | `operator` | 1.125 Major Second | | 12 | 14 | 18 | 16 | 16 | 18 | 22 |
66
+
67
+ **A range means the heading is fluid.** `32–48` is not two values to choose
68
+ between: `--text-h1` interpolates continuously with viewport width, reaching its
69
+ minimum at a 360px viewport and its maximum at 1024px, saturating outside that
70
+ range. There is no breakpoint here and none anywhere else in the system — a
71
+ `clamp()` has no threshold to place, which is exactly why it was chosen over a
72
+ second `-sm` scale.
73
+
74
+ Editorial `--text-h1` at a fixed 48px gives **13 characters per line** on a
75
+ 360px screen, so a 45-character headline sets as four lines and 211px of
76
+ headline. At the 32px minimum the same headline is three lines and half the
77
+ height. `operator` has no fluid headings because its largest is 22px, which
78
+ already fits about 28 characters — the mechanism is absent because the problem
79
+ is.
80
+
81
+ **The bounds are in `rem`, and that is load-bearing.** `clamp(32px, 8vw, 48px)`
82
+ ignores a reader who has raised their default font size: page zoom scales `vw`,
83
+ a font-size preference does not. Every term stays `rem`-based so the whole curve
84
+ moves with the user's setting, which WCAG 1.4.4 requires.
85
+
86
+ **Only headings are fluid.** Body and prose are fixed at every width on purpose.
87
+ Readability is absolute — set by the eye and viewing distance, not by screen
88
+ width — while heading size is relative, existing to contrast with body, and that
89
+ contrast can compress when less content competes for the view. A fluid
90
+ `--text-prose` would also breach the 18px floor at narrow widths (`B-75`,
91
+ `T20`). What responds for body text is the **measure**, via the container.
66
92
 
67
93
  `editorial` omits the rung the ratio would put between H2 and H1 (a step near 40); the ratio names the ladder, not every adjacent step — its H2→H1 jump (32→48) is 1.5, not 1.25.
68
94
 
95
+ The columns are `--text-caption`, `--text-body`, `--text-prose`, `--text-lead`,
96
+ `--text-h3`, `--text-h2` and `--text-h1` in that order.
97
+
98
+ **`--text-lead`** is the standfirst role — the one paragraph that introduces a
99
+ page or section, set larger than body. It existed in all three mode files and
100
+ was documented nowhere, which is how a token becomes invisible: present, usable,
101
+ and never chosen because no one knows it is there. In `operator` it is 16px,
102
+ *below* `--text-prose`, because that mode has no editorial standfirst — it is the
103
+ size of a slightly emphasised label.
104
+
105
+ **Line heights are per-mode too**, which this section used to obscure by quoting
106
+ one mode's values as though they were everyone's:
107
+
108
+ | Token | `editorial` | `product` | `operator` |
109
+ | --- | --- | --- | --- |
110
+ | `--leading-caption` | 1.5 | 1.5 | 1.5 |
111
+ | `--leading-body` | 1.5 | 1.5 | 1.5 |
112
+ | `--leading-prose` | 1.6 | 1.6 | 1.6 |
113
+ | `--leading-lead` | 1.5 | 1.5 | 1.5 |
114
+ | `--leading-h3` | 1.333 | 1.4 | 1.5 |
115
+ | `--leading-h2` | 1.25 | 1.333 | 1.4 |
116
+ | `--leading-h1` | 1.1 | 1.25 | 1.333 |
117
+
118
+ **The heading rows are one ladder, read through a sliding window.** The ladder is
119
+ 1.1 · 1.25 · 1.333 · 1.4 · 1.5, and each mode takes three consecutive rungs,
120
+ starting one lower as the mode gets denser. `editorial` opens at 1.1 because its
121
+ H1 is 48px and a large heading needs proportionally less leading to sit at the
122
+ same optical rhythm; `operator`'s H1 is 22px, near body size, so it takes the
123
+ body-ish end of the same ladder. That is why `product`'s H1 leading equals
124
+ `editorial`'s H2 — they are the same rung, not a coincidence.
125
+
126
+ Body and reading roles do not vary: the floor is 1.5 everywhere, and prose sits
127
+ at 1.6, inside the 1.5–2 band long-form reading wants. Within any mode, leading
128
+ never increases as size increases.
129
+
69
130
  **`--text-body` and `--text-prose` are different roles, not two sizes of the same thing.** Body is UI text — labels, controls, table cells, short strings read in glances. Prose is sustained reading, and never drops below 18px on a page anyone is expected to actually read (`B-75`).
70
131
 
71
132
  Line heights are unitless and floor at **1.5** for body and prose, easing down as size rises. Raise it further when lines are long, when the typeface is heavy or dark, or when it simply looks large for its nominal size. Between 1.5 and 2 is the comfortable band for prose.
72
133
 
73
134
  **Measure: 40–80 characters.** Below 40 the eye returns too often; above 80 it loses the line. `--measure-prose` sits mid-range in every mode.
74
135
 
136
+ **The lower bound is a target, not a floor, and on a small phone it is
137
+ unreachable.** `--measure-prose` caps the upper end; nothing can raise the lower
138
+ one on a narrow screen, because the only two levers both give out. Measured in
139
+ the preview at 16px body — the size `B-75` and WCAG 1.4.4 forbid going under:
140
+
141
+ | viewport | with the 16px gutter | with a 12px gutter | with no gutter at all |
142
+ | --- | --- | --- | --- |
143
+ | 320px | 34 | 35 | 38 |
144
+ | 360px | 39 | 40 | 43 |
145
+ | 375px | 41 | 42 | 44 |
146
+ | 414px | 45 | 46 | 49 |
147
+
148
+ At 375px and up the target is met as the gutters already stand. At 320px it
149
+ cannot be met at **any** gutter, since 40 characters of 16px text need about
150
+ 335px of width before margins exist. The two constraints are geometrically
151
+ incompatible there, and the resolution is not to argue: **the 16px body floor
152
+ wins, and the measure target yields.** Shrinking body text to buy characters is
153
+ the one move that is never available.
154
+
155
+ So do not narrow the gutter chasing this number. It costs layout at every small
156
+ width and buys one character at 360px, while changing nothing at 320px.
157
+
75
158
  **Weights: two.** Regular (400) and bold (600). See `B-77`.
76
159
 
77
160
  **Letter spacing** tightens as size grows — most text typefaces are spaced for small sizes and look loose when scaled up. `--tracking-h1` is the most negative; body is 0.
78
161
 
79
162
  **Typeface.** One sans serif by default: most legible small, neutral across brands, least likely to be the wrong choice. When picking one — prefer a popular face with many weights, a tall x-height and generous default spacing, with OpenType features and the language coverage the product needs. When in doubt, the platform system font is tried, tested and free to load. A second face is permitted for headings only (`B-76`).
80
163
 
81
- **Radius — four options**, by element size: 8px small (buttons, inputs), 16px medium (cards, panels), 32px large (hero media and full-bleed surfaces), and a full/pill radius for pills, badges, avatars and chips (`--radius-full`).
164
+ **Radius — four options**, by element size: `--radius-sm` 8px (buttons, inputs), `--radius-md` 16px (cards, panels), `--radius-lg` 32px (hero media and full-bleed surfaces), and `--radius-full` for pills, badges, avatars and chips.
82
165
 
83
166
  `--radius-control` selects `sm` in all three modes. `--radius-surface` selects `md` in `editorial` and `product`, but `sm` in `operator` — the selection is per-mode, not a fixed derivation. `--radius-lg` and `--radius-full` are brand-scale options; no mode currently selects either.
84
167
 
85
- **Shadow — two options** with stated meanings: `raised` sits above the page, `overlay` floats over it. `A-08` still prefers a stroke; these exist for when depth is the point.
168
+ **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.
169
+
170
+ ## Sizes and motion, by mode
171
+
172
+ `01-modes.md` names these tokens in each mode's profile and points here for the
173
+ resolved values. They were not here: the option sets above cover type, spacing,
174
+ radius and shadow, while control heights, row heights and durations lived only
175
+ in `tokens/mode.*.css`. A reader following the pointer found nothing.
176
+
177
+ Unlike the option sets, these are not selections from a shared ladder — each
178
+ mode states its own value, because density is the thing a mode *is*.
179
+
180
+ | Token | `editorial` | `product` | `operator` |
181
+ | --- | --- | --- | --- |
182
+ | `--size-control` | 48px | 40px | 32px |
183
+ | `--size-control-sm` | 40px | 32px | 28px |
184
+ | `--size-row` | — | 48px | 36px |
185
+ | `--size-row-compact` | — | — | 32px |
186
+ | `--size-icon` | 20px | 18px | 16px |
187
+ | `--duration-fast` | 150ms | 100ms | 75ms |
188
+ | `--duration-base` | 250ms | 150ms | 100ms |
189
+ | `--duration-slow` | 300ms | 200ms | 120ms |
190
+ | `--duration-ambient-fast` | 3s | — | — |
191
+ | `--duration-ambient-base` | 4.7s | — | — |
192
+ | `--duration-ambient-slow` | 7.1s | — | — |
193
+ | `--measure-prose` | 68ch | 60ch | 72ch |
194
+ | `--ease-out` | cubic-bezier(0.16, 1, 0.3, 1) | cubic-bezier(0.16, 1, 0.3, 1) | cubic-bezier(0.2, 0, 0, 1) |
195
+ | `--ease-in-out` | cubic-bezier(0.65, 0, 0.35, 1) | cubic-bezier(0.65, 0, 0.35, 1) | cubic-bezier(0.4, 0, 0.2, 1) |
196
+
197
+ A `—` means the mode does not define that token: `editorial` has no row
198
+ heights because it has no dense record views, and `product` selects a single
199
+ row height rather than a compact variant. A pattern that needs one in those
200
+ modes is using the wrong mode, or the mode file needs the token added
201
+ deliberately.
202
+
203
+ **The ambient periods are a chord, not a ladder.** The three interaction
204
+ durations are a scale — fast, base, slow, pick by weight of change. The three
205
+ ambient ones (`P-13`) are not: they exist so that several looping animations
206
+ running at once can each take a *different* period. Their values are mutually
207
+ prime in tenths of a second, so the layers do not re-align into a single visible
208
+ pulse — 3 / 4.7 / 7.1s first coincide past the two-hour mark, where 3 / 4 / 6s
209
+ would coincide every twelve seconds. Pick a different one per layer; which one
210
+ carries no meaning beyond speed.
211
+
212
+ They are `editorial` only, and the `—` in the other two columns is an assertion:
213
+ `product` and `operator` define nothing here, because a surface someone works in
214
+ all day must not have anything moving on it that they did not cause.
215
+
216
+ **Easing has a direction, and it is not a matter of taste.** Motion in the
217
+ physical world starts and stops under acceleration, so an element that arrives
218
+ at rest should decelerate into place and an element leaving should accelerate
219
+ away. That maps onto the tokens:
220
+
221
+ - **Entering, or gaining attention** — use `--ease-out`. The element decelerates
222
+ into its resting position, which is what makes it read as arriving rather than
223
+ as being drawn.
224
+ - **Leaving, or losing attention** — use `--ease-in-out`. A pure ease-in would be
225
+ the closer analogue, and we do not ship one: exits in this system fade or
226
+ collapse in place rather than fly off screen, and a third easing token bought
227
+ only that one case.
228
+ - **Never linear** for anything that moves. Linear reads as mechanical because
229
+ nothing physical moves that way. Colour and opacity are the exception — a
230
+ simple curve is enough there, and often linear is fine.
231
+
232
+ The `operator` curves are tighter than the other two modes for the same reason
233
+ its durations are shorter: a curve with a long tail makes a 100ms animation feel
234
+ slower than it is.
235
+
236
+ **`--size-touch-target` is 48px in every mode and is not a density decision.**
237
+ It is an accessibility floor, so it is excluded from the table above — there is
238
+ nothing per-mode about it to resolve. The same is true of `--focus-ring-width`
239
+ and `--focus-ring-offset`, which live in the brand file for that reason.
240
+
241
+ **Spacing selections.** `--spacing-card` and `--spacing-section` pick from the
242
+ shared ladder rather than stating their own values:
243
+
244
+ | Token | `editorial` | `product` | `operator` |
245
+ | --- | --- | --- | --- |
246
+ | `--spacing-card` | `--spacing-m` | `--spacing-m` | `--spacing-s` |
247
+ | `--spacing-section` | `--spacing-xxl` | `--spacing-xl` | `--spacing-m` |
86
248
 
87
249
  ## Colour naming
88
250
 
@@ -119,6 +281,8 @@ Names align to Tailwind v4's theme namespaces. This is free for other frameworks
119
281
  | `--tracking-*` | Letter spacing | mode |
120
282
  | `--spacing-*` | Spacing values | mode |
121
283
  | `--radius-*` | Corner radii | brand scale, mode selection |
284
+ | `--border-width-*` | Stroke widths | brand options, mode selection |
285
+ | `--focus-ring-*` | Focus indicator geometry | brand — an accessibility floor, so not mode-negotiable |
122
286
  | `--shadow-*` | Elevation | brand |
123
287
  | `--duration-*`, `--ease-*` | Motion | mode |
124
288
  | `--size-*` | Control and row heights | mode |
@@ -182,7 +346,18 @@ WCAG 2's algorithm has known failures — it will pass black text on orange and
182
346
 
183
347
  Guidance: **for commercial work, comply with WCAG 2.1 AA**, because that is what is legally referenced. Check APCA as well, particularly on dark surfaces. Aim to pass both.
184
348
 
185
- APCA reference values: **90** preferred for body text · **75** minimum body at 18px+ · **60** other text · **45** large text and UI elements · **30** absolute floor for placeholder and disabled text · **15** non-text.
349
+ APCA reference values, with the sizes they apply at — a score means nothing without one, since APCA takes size and weight into account and thin or small text scores lower for the same colours:
350
+
351
+ | Score | Applies to |
352
+ | --- | --- |
353
+ | **90** | Preferred for body text, 14px regular and above |
354
+ | **75** | Minimum for body text, 18px regular and above |
355
+ | **60** | Minimum for other text, 24px regular or 16px bold and above |
356
+ | **45** | Minimum for large text — 36px regular or 24px bold and above — and for interface elements |
357
+ | **30** | Absolute minimum for text: placeholders, disabled button text |
358
+ | **15** | Minimum for non-text elements |
359
+
360
+ 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.
186
361
 
187
362
  ## Dark mode
188
363
 
@@ -198,7 +373,7 @@ In dark, elevated surfaces get **lighter**, not shadowed. Border-led elevation s
198
373
  @import ".jig/tokens/mode.product.css";
199
374
 
200
375
  .card {
201
- background: var(--color-surface);
376
+ background: var(--color-bg-raised);
202
377
  border: 1px solid var(--color-stroke-weak);
203
378
  border-radius: var(--radius-surface);
204
379
  padding: var(--spacing-card);
@@ -112,7 +112,7 @@ If you must disable: put a message beside the button explaining what is needed,
112
112
  Friction scales with severity, and the first lever is prominence.
113
113
 
114
114
  - **At rest, a destructive action is tertiary.** Less prominent, further from the primary action, or disclosed behind something.
115
- - **Do not colour it red at rest.** Red makes it *more* prominent — the opposite of what friction means. `--color-danger` styling belongs on the **confirming** button inside a confirmation step, where the user has already chosen and needs to understand the weight of it.
115
+ - **Do not colour it red at rest.** Red makes it *more* prominent — the opposite of what friction means. `--color-text-error` styling belongs on the **confirming** button inside a confirmation step, where the user has already chosen and needs to understand the weight of it.
116
116
  - Destructive actions sit at least `--spacing-stack` from their nearest common neighbour, and confirm. In `operator`, confirmation is typed (`01-modes.md`).
117
117
 
118
118
  ---
@@ -123,8 +123,8 @@ Friction scales with severity, and the first lever is prominence.
123
123
  1. `<label>` — always visible, **stacked above** the input (`F-98`)
124
124
  2. Required or optional marker, in the label (`F-97`)
125
125
  3. Hint text — **above** the input, below the label
126
- 4. Input
127
- 5. Error message — after the input, `role="alert"`
126
+ 4. Error message — **also above the input**, after the hint, `role="alert"`
127
+ 5. Input
128
128
 
129
129
  **States:** `default`, `focus`, `filled`, `invalid`, `disabled`, `read-only`.
130
130
 
@@ -156,6 +156,10 @@ Above the input, not below. Two reasons: a rule about a password's minimum lengt
156
156
 
157
157
  Do not hide a hint in a tooltip if it is needed to complete the field.
158
158
 
159
+ **The error message goes above the input too, and for the second of those reasons.** The space below a field is covered by autofill menus and on-screen keyboards at exactly the moment an error appears — so an error placed there is hidden from the person who most needs to see it. Putting the hint above and the error below would have taken half of one argument and ignored the other half.
160
+
161
+ Order between them: hint first, then error. The hint is standing guidance about the field; the error is a transient response to what was just typed, so it sits closest to the input it is about.
162
+
159
163
  ### Placeholders
160
164
 
161
165
  Not a label (`F-36`). Placeholders vanish on focus, make an empty field look pre-filled and skippable, and are light enough by design that they usually fail contrast.
@@ -224,6 +228,12 @@ The control determines the interaction cost before a single pixel is styled. Cho
224
228
 
225
229
  On blur first; on change once a field has already errored, so recovery is immediate; everything on submit, with a summary that receives focus and links to each failed field.
226
230
 
231
+ The reference sets out three approaches — on submit, on blur, on every keystroke — and prescribes none of them, because each has a cost: on-submit leaves people guessing until the end and then confronts them with everything at once; on-blur interrupts; keystroke validation fires before someone has finished typing, and people type at different speeds. What it does pair explicitly is on-blur with keystroke validation *for recovery only* — "remove the error message once the error has been resolved" — which is the combination above, and the reason the keystroke half is scoped to fields that have already failed.
232
+
233
+ **The summary states the count** — "2 errors were found" — and each entry links to the field it names. Do not disable the submit button to prevent an invalid submission (`E-32`): a disabled control cannot be focused, so it cannot explain itself.
234
+
235
+ **An invalid field is marked by border, background tint, icon and text together** — never by colour alone (`C-20`), and never by one channel that a magnifier or a colour-blind user might miss.
236
+
227
237
  ### Multi-step
228
238
 
229
239
  Beyond roughly three question groups, split it.
@@ -370,6 +380,63 @@ Reveal information as it is needed rather than all at once. Costs an interaction
370
380
 
371
381
  ---
372
382
 
383
+ ## P-13 · Ambient motion
384
+
385
+ A third category of motion, alongside the two this system already had. **Interaction
386
+ motion** answers an input; **transition motion** carries the user between states or
387
+ views. Both are measured in milliseconds and both are covered by `G-44`. **Ambient
388
+ motion** is neither: slow, looping, decorative movement that gives a surface
389
+ atmosphere without ever asking to be looked at — smoke drifting, a sway, a slow
390
+ colour shift. It is passive, it runs without input, and it never ends.
391
+
392
+ The distinction matters because the two categories pull in opposite directions.
393
+ Interaction motion must finish before the user perceives a wait, so it is fast.
394
+ Ambient motion must never be noticed, so it is **slow** — fast ambient motion reads
395
+ as a glitch or a demand for attention, which is the one thing it must not be.
396
+
397
+ **Mode variance is total, not a matter of degree.** Ambient motion is `editorial`
398
+ only. `product` and `operator` have none, and this is not a density setting to be
399
+ turned down — a surface someone works in all day must not have anything moving on
400
+ it that they did not cause. Motion in those modes always means something changed.
401
+
402
+ **Rules**
403
+ - **Seconds, not milliseconds.** 3–6s per cycle is the working range. `G-44`'s
404
+ 100–300ms ceiling does not apply here and says so.
405
+ - **Use the tokens: `--duration-ambient-fast` (3s), `--duration-ambient-base`
406
+ (4.7s), `--duration-ambient-slow` (7.1s).** `H-47` applies here like everywhere
407
+ else — a raw `6s` in a keyframe rule is a hard-coded value past the token layer.
408
+ Give each layer a *different* one. The three periods are mutually prime by
409
+ construction so that layered loops never re-align into one visible pulse, which
410
+ is the whole failure mode of layered ambient motion; picking the same token for
411
+ every layer throws that away and is the one way to misuse them.
412
+ - **Keyframe percentages and `animation-delay` offsets stay literal.** Those are
413
+ composition, not design decisions reused across the system, and tokenising them
414
+ would mean a token per animation.
415
+ - **Loop seamlessly.** Match the first and last keyframe, or use
416
+ `animation-direction: alternate`. A hard reset is a flicker, and a flicker is
417
+ noticed.
418
+ - **Layer several small movements rather than one large one.** Varied periods and
419
+ delays read as alive. One movement reads as a widget.
420
+ - **Test it by not looking at it.** If your eye is pulled to it while you read the
421
+ page, it is too strong. Reduce until you would only catch it if you were looking
422
+ for it.
423
+ - **`aria-hidden="true"`** on purely decorative animated elements. They carry no
424
+ information, so they are clutter in the accessibility tree.
425
+ - **Wrap it in `@media (prefers-reduced-motion: no-preference)`** — the whole
426
+ declaration, so the reduced path is absence rather than a shortened loop (`G-43`).
427
+ Ambient motion is the easiest case there is: it means nothing, so removing it
428
+ costs nothing.
429
+ - **Animate `transform` and `opacity`.** These run on the compositor. A loop that
430
+ runs forever on every frame the page is open cannot afford animated `blur`,
431
+ `box-shadow`, or anything that triggers layout — the cost is not paid once, it is
432
+ paid continuously, on whatever device the reader has.
433
+
434
+ **Anti-pattern:** ambient motion used to direct attention. It is atmosphere, not a
435
+ signal. The moment it points at something it has become interaction motion badly
436
+ done, and `G-42` applies instead.
437
+
438
+ ---
439
+
373
440
  ## Layout method
374
441
 
375
442
  Not a component. The procedure for structuring any screen, before styling anything.
@@ -456,4 +523,4 @@ Each entry states: anatomy in order, complete state list, rules that are decidab
456
523
 
457
524
  **Deliberately absent.** Navigation, cards, tabs, and toasts-as-a-component. Navigation and cards vary too much by project to have decidable rules yet — they would produce prose, not constraints. Add them once you have built enough to see the invariant.
458
525
 
459
- **Worth testing before extending.** These eight cover most of what generated UI gets wrong. Point an agent at a form and a table with `00`, `01`, `02` and `03` loaded, and compare against the same task with nothing loaded. If `P-03` and `P-05` do not visibly change the output, the rules are not decidable enough and the fix is more specificity, not more patterns.
526
+ **Worth testing before extending.** These 12 cover most of what generated UI gets wrong. Point an agent at a form and a table with `00`, `01`, `02` and `03` loaded, and compare against the same task with nothing loaded. If `P-03` and `P-05` do not visibly change the output, the rules are not decidable enough and the fix is more specificity, not more patterns.
@@ -0,0 +1,92 @@
1
+ The user invoked `{{command_prefix}}{{args_placeholder}}`. Treat
2
+ `{{args_placeholder}}` as the subcommand and its flags.
3
+
4
+ Available subcommands: {{subcommand_list}}. If it is empty or is not one of
5
+ these, say so, list them, and stop.
6
+
7
+ Run the matching CLI command with `{{scripts_path}}`, passing the flags through
8
+ unchanged. Then do the work below for that subcommand. Read the command's full
9
+ output — findings are ordered by severity, not position, so `head`, `tail`,
10
+ `grep` and `jq` drop the ones that matter.
11
+
12
+ ## init
13
+
14
+ Report what it detected, the brand colour it derived and where that came from,
15
+ and the mode it wrote. The mode is the load-bearing decision: `jig.config.json`
16
+ outranks your own inference from then on, so if the project's signals point
17
+ elsewhere — the mode table in `{{rules_path}}/01-modes.md` — say so and ask
18
+ before leaving it.
19
+
20
+ If the token layer does not exist yet, this is the command that creates it. Do
21
+ not author token values yourself under any circumstances.
22
+
23
+ ## check
24
+
25
+ The CLI decides the rules a machine can decide. **It is half the review**, and
26
+ its own attestation says `judgment=not-run` to make that explicit.
27
+
28
+ Do the other half yourself:
29
+
30
+ 1. Load `{{rules_path}}/00-anti-patterns.md` and `{{rules_path}}/05-copy.md`,
31
+ plus the relevant section of `{{rules_path}}/03-patterns.md` for whatever the
32
+ changed files build.
33
+ 2. Apply the judgment rules to the same files the CLI just scanned.
34
+ 3. Merge both halves into **one** report keyed by rule id, ordered by severity —
35
+ not two lists. A reader should not have to know which half found what.
36
+ 4. Run the self-check at the end of `{{rules_path}}/00-anti-patterns.md`.
37
+
38
+ Then emit the attestation with both halves filled in:
39
+
40
+ ```text
41
+ JIG_CHECK: version=<version> mode=<mode> mechanical=<pass|fail|skipped>:<n> judgment=<ran|skipped>
42
+ ```
43
+
44
+ Take `mechanical=` from the CLI's own line. If `check` could not run, that is
45
+ `mechanical=skipped:0` — never `pass`, which would report a clean result for a
46
+ check that inspected nothing. Report `judgment=ran` only if you did step 2.
47
+
48
+ ## install
49
+
50
+ Report where the skill landed and at which scope. If it warned that a global
51
+ install already exists, do not work around it — that warning is the system
52
+ refusing to leave two contradictory skills for one harness.
53
+
54
+ ## explain
55
+
56
+ Print the CLI's output as it stands. It is already the rule's full text — do not
57
+ summarise it, and do not paraphrase the correction into your own words: the
58
+ wording is the rule.
59
+
60
+ **It takes a word as well as an id.** `explain contrast` searches every title
61
+ and body and lists what matches; one match prints in full. Use it whenever you
62
+ are about to reason about an area of the system rather than a specific finding
63
+ — before reviewing colour, before writing motion — instead of guessing which
64
+ rules apply or reading a whole rule file to find out. `explain <letter> --list`
65
+ prints one section, `explain --list` prints every id.
66
+
67
+ Reach for this before paraphrasing the rules from memory. A rule you half-recall
68
+ is the failure mode this command exists to prevent.
69
+
70
+ If the id is a `P-` or `M-` spec, the output says it is a specification rather
71
+ than a rule, with no ❌/✅ pair and no detector. That is correct, not a gap.
72
+
73
+ A well-formed id that does not exist errors, naming every id in that section —
74
+ offer the nearest one rather than guessing what the user meant. A word that
75
+ matches nothing errors differently, suggesting `--list`; that is a search with
76
+ no hits, not a typo, so widen the word rather than inventing an id.
77
+
78
+ ## update
79
+
80
+ **Run this one as `{{update_path}} update`, not at the pinned version.** Every
81
+ other subcommand uses the pin so the CLI and these rules agree; `update` exists
82
+ to move that pin forward, so pinned it refreshes to the version already
83
+ installed and reports success for a no-op — "Updated Jig → <same version>" —
84
+ and nobody ever upgrades.
85
+
86
+ Report what moved and what was left alone. Files reported as skipped were edited
87
+ locally and are the user's; never re-apply Jig's version over them.
88
+
89
+ ---
90
+
91
+ Everything in `{{rules_path}}/` is yours to read. Cite rules by id, and cite any
92
+ rule you deliberately break with the reason, in one line.