@freetesh/canon 0.16.0 → 0.17.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.
@@ -0,0 +1,857 @@
1
+ # Design sets
2
+
3
+ How a repo's HTML design set is built. Opt in with `standards: [design-sets]`.
4
+
5
+ Every rule below is a hard rule with a reason attached. The reason is there so
6
+ you can tell when a rule truly does not apply — not so you can argue past it.
7
+
8
+ The product's UX is its own. A game and a tax app should look nothing alike.
9
+ **Everything around the screens is fixed**, so that learning one set teaches you
10
+ all of them: where the gallery is, what the bar does, how a variant is named,
11
+ where the reasoning lives.
12
+
13
+ ---
14
+
15
+ ## 0. The one-line version
16
+
17
+ A design set is a folder of static HTML screens, one screen per file, declared
18
+ in a single manifest, wrapped in an identical demo navbar, indexed by a flow
19
+ chart, and checked against the app it describes.
20
+
21
+ ---
22
+
23
+ ## 1. Layout on disk
24
+
25
+ ```
26
+ <repo>/.context/designs/
27
+ brand/ # source art: icon.svg · splash-icon.svg · adaptive layers · wordmark. Not a surface.
28
+ architecture/ # diagram sources (<slug>.json) and their drawn pages. Not a surface — see §4
29
+ email/ # transactional email HTML. An email is a surface of its own, drawn in HTML
30
+ <surface>/ # `mobile` or `web` — the surfaces that carry the set machinery below
31
+ index.html # the gallery — a flow chart, nothing else
32
+ screenshots.html # every screen at once, as live iframes
33
+ routes.js # THE MANIFEST — the one place a screen is declared
34
+ _gallery.js # renders the flow chart from the model
35
+ _chrome.js # derives the model (window.WF), injects the navbar
36
+ shared.css # CORE, then PALETTE, then this product's components
37
+ tailwind.config.js # loaded AFTER the CDN; every value is var(--token)
38
+ <flow>/<screen>.html # one screen per file
39
+ ```
40
+
41
+ `brand/` is the only home of source art. Store images, press media and the
42
+ app's own `assets/` are exports of it.
43
+
44
+ Documentation lives in `.context/wiki/`, never beside the HTML. No top-level
45
+ `docs/` or `wireframes/` folder: that material belongs under `.context/`.
46
+
47
+ ---
48
+
49
+ ## 2. `routes.js` is the single source
50
+
51
+ `_chrome.js`, `index.html` and `screenshots.html` all read it. Before it existed
52
+ the screen order lived in three places — the gallery's table, each screen's
53
+ hand-written sibling strip, and nobody's idea of prev/next — and they disagreed,
54
+ because three copies of a list always do.
55
+
56
+ **`routes.js` is read exactly once, by `_chrome.js`**, which publishes the
57
+ derived model as `window.WF` and is loaded by every page. Nothing else may walk
58
+ the manifest itself. The three readers each used to rebuild their own maps from
59
+ it and came to disagree about what a state was: the chart collapsed
60
+ `Board · loading` onto Board, the contact sheet gave it a tile of its own, and
61
+ back/next stepped through a list that matched neither.
62
+
63
+ | `window.WF` | |
64
+ |---|---|
65
+ | `flat` | every file, states inline, manifest order — what `back`/`next` walk |
66
+ | `meta` / `base` | one entry per screen; `base(file)` maps a state onto its screen |
67
+ | `links` / `col` | the graph collapsed onto screens, and each screen's rank |
68
+ | `mainPath` / `mainNodes` | the happy path, start to first end |
69
+ | `journey` | `[{ flow, screens }]` — flows and screens in the order reached |
70
+
71
+ ```js
72
+ window.ROUTES = {
73
+ product: "My Game",
74
+ surface: "mobile",
75
+ surfaces: ["mobile"], // every surface this product draws
76
+ themes: ["light", "dark"], // every theme this SET draws, not the app
77
+ frame: { w: 390, h: 844 },
78
+ architecture: "architecture/overview.html", // see §4; omit if none
79
+
80
+ /* Reserved order: states · auth · onboarding · home · * · settings · store · patterns */
81
+ flows: [
82
+ { flow: "game", screens: [
83
+ { file: "game/board.html", screen: "Board",
84
+ states: { loading: "game/board-loading.html" },
85
+ status: "built", // built | approved | review
86
+ notes: "board" }, // wiki slug, or null — see §10
87
+ ]},
88
+ ],
89
+
90
+ flow: { // the chart
91
+ start: "states/splash.html",
92
+ end: ["game/win.html"],
93
+ edges: [ /* see §6 */ ],
94
+ },
95
+
96
+ updated: "2026-09-22",
97
+ missing: [{ what: "Interstitial", why: "ships and has no wireframe" }],
98
+ states: { empty: { drawn: null, note: "why not" } },
99
+ };
100
+ ```
101
+
102
+ **A screen not in `routes.js` appears nowhere.** That is the point: adding one is
103
+ a single edit, and forgetting is visible immediately rather than months later.
104
+
105
+ Status (`built` / `approved` / `review`) and the five-state coverage live in
106
+ `routes.js`, where the bar reads them. They are data, not a page.
107
+
108
+ A screen file declares only its own path. Everything else is derived, and the
109
+ bar is injected from it — never hand-write chrome into a screen:
110
+
111
+ ```html
112
+ <body data-chrome data-file="game/board.html">
113
+ ```
114
+
115
+ ---
116
+
117
+ ## 3. Tailwind, and only Tailwind
118
+
119
+ Static HTML plus Tailwind via the CDN. **No React, no build step, no other CSS
120
+ framework, no hand-written utility classes.**
121
+
122
+ Every screen loads exactly this, in this order, with **pinned** versions:
123
+
124
+ ```html
125
+ <script src="https://cdn.tailwindcss.com/3.4.17"></script>
126
+ <script src="../tailwind.config.js"></script>
127
+ <script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.js"></script>
128
+ <link rel="stylesheet" href="../shared.css" />
129
+ ...
130
+ <script src="../routes.js"></script>
131
+ <script src="../_chrome.js"></script>
132
+ <script>lucide.createIcons();</script>
133
+ ```
134
+
135
+ An unpinned CDN can silently restyle every screen in every repo overnight.
136
+
137
+ `index.html` and `screenshots.html` load the lucide line too. They have no
138
+ screen on them, but they carry the same navbar, and the navbar is icons.
139
+
140
+ - **Icons are lucide**, the same set the Expo app uses via `lucide-react-native`,
141
+ so an icon never has to be re-chosen at build time. Use the *lucide* name of
142
+ the component the app imports: `CircleCheckBig` → `circle-check-big`.
143
+ - **One icon set, and nothing hand-copied.** No inline `<svg>` in a screen and
144
+ no path data in `_chrome.js`: ask lucide for the icon by name. A mark lucide
145
+ cannot supply — the product's own logo — is **one file under `brand/`**,
146
+ referenced by every screen that shows it. Pasted inline it exists twice and
147
+ drifts twice; one game's mark had two copies with two different sets of
148
+ gradient ids before it became `brand/mark.svg`.
149
+ - **A DRAWING is not an icon, and the rule above is about icons.** A progress
150
+ ring, a sparkline, a chart — something the app itself renders as SVG and whose
151
+ shape carries data — has no lucide name and no static file, because it changes
152
+ with the number. Draw it, but **declare the geometry once, as a component
153
+ class in `shared.css`**, and leave only the datum in the markup:
154
+
155
+ ```html
156
+ <svg class="ring-svg [--arc:116]" viewBox="0 0 118 118">
157
+ <circle class="ring-track" /><circle class="ring-fill" />
158
+ </svg>
159
+ ```
160
+
161
+ The test is the same one as for a mark: is there one source of truth? One
162
+ set's calorie ring was five copies of nine identical attributes differing in
163
+ one number — nine chances to drift, on the first thing on the home screen.
164
+ Prefer CSS where CSS can say it (a countdown timer can be a conic gradient),
165
+ and keep SVG where it cannot — round caps and a bottom gap are the shape of a
166
+ ring like that, and a conic gradient has neither.
167
+ - `style="..."` is for what a class truly cannot express — an exact type
168
+ ramp step, a one-off overlay alpha. Not for colour that a token exists for.
169
+
170
+ ---
171
+
172
+ ## 4. The demo navbar
173
+
174
+ **One bar, identical in every product**, injected by `_chrome.js`. It renders on
175
+ a screen, on the flow chart and on the screenshots sheet, at the same height,
176
+ with every control in the same place. Navigation on the left, settings on the
177
+ right, and what you are looking at in between — so the two never trade places
178
+ and you stop hunting for the control you just used.
179
+
180
+ | Group | Contents |
181
+ |---|---|
182
+ | **Left** | all routes · screenshots · architecture · back · next — all icons |
183
+ | **Middle** | every screen in this flow, then this screen's states — words |
184
+ | **Right** | light / dark · mobile / web · notes — all icons |
185
+
186
+ - **Architecture** (`network`) opens how the product is built, between
187
+ screenshots and back. `routes.js` names the page in `architecture`, relative
188
+ to the surface folder; a set without one shows the chip struck out, so the
189
+ chips after it never move. See "The architecture pages" below.
190
+ - **Key screens.** With more than a handful of screens, every box at once is a
191
+ wall nobody can read. A set marks the few screens that explain the product
192
+ with `key` in `routes.js`: `key: "Editor"` puts a screen in a named row,
193
+ `key: true` in its flow's own row. Rows group related flows (for example
194
+ Start · Onboarding · Editor · Settings). Base screens only, never a state.
195
+ One to five a row, about 8–25 in all.
196
+ - The chart then opens on those rows, one row per lane, each read left to
197
+ right, arrows only between neighbours, and a label only on a direct link (a
198
+ link that steps over hidden screens would borrow a label the reader cannot
199
+ see). Rows come in the order their first screen appears in `routes.js`.
200
+ - The contact sheet opens on the key tiles, grouped by the same rows.
201
+ - Both have a switch to every screen. A set that marks none shows everything.
202
+ - **A control that means the same thing on every page is an icon.** All eight
203
+ of them — all routes, screenshots, architecture, back, next, theme, surface,
204
+ notes — never change meaning between products, so the word was only ever a
205
+ label for something you already knew. They are lucide, asked for by name, so
206
+ the bar carries no icon set of its own. Icons: `workflow`, `layout-grid`,
207
+ `network`, `chevron-left`, `chevron-right`, `sun`, `moon`, `smartphone`,
208
+ `monitor`, `info` — theme and surface are two-state, so they take two each.
209
+ - **The middle fits on one line.** The screen names scroll when they overflow,
210
+ and the state chips sit after them, so an overflowing flow hides its states.
211
+ Keep each flow small enough to fit the frame's width: split a long flow by
212
+ what it is about (twenty settings screens became product, company and account
213
+ settings), and **never repeat the flow in a screen's name** — in `pages`,
214
+ "Code", not "Pages · Code"; the flow already says it. A state key outside the
215
+ closed set (§5) is not shown at all, so anything else is a screen.
216
+ - **A word stays where the word IS the content.** The screen names in the
217
+ middle and the state names beside them are the only text left in the bar.
218
+ - **Every icon keeps its label** in `title` and `aria-label`, so hovering says
219
+ which control it is. An icon you have to click to understand is worse than
220
+ the word it replaced. If the lucide CDN fails, each icon falls back to
221
+ rendering its own label rather than an empty square.
222
+ - **There is no breadcrumb.** `mobile · /game ›` said what the surface toggle
223
+ on the right already says and what the flow tint in the chart already says,
224
+ and it sat between the nav chips and the screen chips pushing the two apart
225
+ for no information.
226
+ - **The bar has its own fixed colours**, `--chrome-*`, declared in the CORE
227
+ block of `shared.css`. **It must never read a PALETTE token.** Reading
228
+ `--brand-500` made the active chip blue in one set and navy in another, so the
229
+ same control looked like a different control, and the bar read as part of the
230
+ product rather than as the thing holding it. Other CORE tokens are fine and
231
+ are used — `--font-mono` is the same stack in every set, so it cannot drift.
232
+ - **Both halves of each right-hand control are always rendered.** A set with no
233
+ dark palette shows `dark` struck through; a product with no web surface shows
234
+ `web` struck through. A control that appears on some pages and not others
235
+ moves everything beside it — and a theme the app ships but nobody has drawn is
236
+ a gap, which has to *look* like a gap rather than like "there is no dark here".
237
+ - `back` / `next` step through the set in route order and are struck through at
238
+ the ends, so the bar does not reflow as you walk it.
239
+ - **The bar is measured, not assumed.** It wraps to two rows on a narrow window,
240
+ and `_chrome.js` publishes `--chrome-h` so the frame can clamp under it. A
241
+ guessed constant is wrong exactly when the window is small, which is when
242
+ fitting matters.
243
+ - **`?bare` renders a screen with no bar at all** — what the screenshots sheet
244
+ embeds, and what you want when capturing one screen. It also sets `data-bare`
245
+ on the root, which drops the stage gutter and the device rounding.
246
+
247
+ ### The notes button
248
+
249
+ - **The `info` icon at the far right, past the display controls.** It is the
250
+ only control in the bar that opens something *over* the screen rather than
251
+ changing which screen you are looking at, so it sits apart from the ones that
252
+ navigate, and it is the only round single chip — the segmented pairs beside
253
+ it are pills, every other chip is a rounded rectangle. As a word among the
254
+ screen names it read as another screen.
255
+ - The drawer is collapsed by default. It opens on the right, has a **header
256
+ naming the screen and a close button**, and closes on Escape. A panel covering
257
+ a third of the window needs a way out that is not the button you opened it
258
+ with, which the bar may have scrolled past.
259
+ - **Every screen has a notes button.** A screen with no wiki page still renders
260
+ the button, struck out, for the same reason a missing theme is struck out.
261
+
262
+ ### The architecture pages
263
+
264
+ Every set carries an `architecture` flow: how the product is built.
265
+
266
+ ```
267
+ <repo>/.context/designs/
268
+ architecture/ # ASSETS, like brand/: <slug>.json (the source) + <slug>.html (drawn)
269
+ <surface>/architecture/ # SCREENS: overview.html + one screen per diagram, declared in routes.js
270
+ ```
271
+
272
+ - **Diagrams are small and answer one question each**: who uses it, how it is
273
+ built, the backend (only when there is a server), what it relies on. At most
274
+ six boxes and straight arrows. One big diagram of everything was unreadable.
275
+ - They are drawn by the **Archify** agent skill from their `.json`. Edit the
276
+ `.json`, then in `designs/architecture/` run
277
+ `ARCHIFY_UPDATE_CHECK_DISABLED=1 node <archify>/bin/archify.mjs finalize
278
+ architecture <slug>.json <slug>.html --quality showcase`. It must pass; delete
279
+ its receipts after.
280
+ - **The screens are generated** from a spec that names the set, the diagrams,
281
+ the stack tables (library, version, what it is for), one line on the
282
+ product's own code, and the monthly cost. The generator writes the screens
283
+ and their `routes.js` entries.
284
+ - A screen frames its diagram in Archify's embed mode (`?embed=1&theme=light`),
285
+ in a frame of the diagram's own shape, so nothing is cropped and the only
286
+ theme switch is the set's own bar. In a phone set the architecture screens
287
+ drop the 390px frame and fill the window under the bar.
288
+ - **The overview is architecture and cost only**: the diagrams, the stack by
289
+ layer with versions, the monthly cost. How a feature works belongs in the
290
+ wiki and the docs, not here. Facts come from the repo; a fact that is not in
291
+ it is left out or marked, never invented.
292
+ - Notes: `.context/wiki/surfaces/architecture.md`.
293
+
294
+ ---
295
+
296
+ ## 5. One screen per file
297
+
298
+ A second frame in a file has no URL, cannot appear in `routes.js`, cannot be
299
+ linked to, and does not show up in the screenshots sheet. So:
300
+
301
+ - **A theme is not a second frame.** It is the toggle. One file, both themes.
302
+ - **A state is its own file**: `<screen>-<state>.html`, with the state drawn
303
+ from the closed set `empty · loading · error · offline · locked`.
304
+ - **Anything the closed set cannot name is its own screen, not a state.** "Draw"
305
+ and "Ads off" are screens with their own base names, not `-draw` and `-off`
306
+ variants. Stretching a state word to cover them is how a vocabulary stops
307
+ meaning anything.
308
+ - **No prose inside a screen file.** No annotation sidebar, no rationale column,
309
+ no explanatory paragraph. The screen is the thing being reviewed, and an essay
310
+ beside it competes for the eye. Comments in the markup are fine and expected.
311
+
312
+ Not every screen has every state; a loading state on an about page is a rule
313
+ people learn to ignore. Draw the real ones and let `routes.js` say in one line
314
+ why each of the others is not drawn.
315
+
316
+ ### Flow names
317
+
318
+ One folder per flow, lowercase kebab-case. Seven names recur across products
319
+ and mean the same thing every time, so they are reserved and always sort to the
320
+ same place; everything else is the product's own domain and sorts
321
+ alphabetically in the `*` slot:
322
+
323
+ `states · auth · onboarding · home · * · settings · store · patterns`
324
+
325
+ **`states` sorts first because it holds `splash`** — the screen the product
326
+ starts on. `back`/`next` walk manifest order and the contact sheet breaks ties
327
+ with it, so a manifest that does not begin where the product begins puts the
328
+ entry screen near the end and makes the first `back` dead in the wrong place.
329
+ The rest of `states` — `404`, `offline`, `maintenance` — comes along with it,
330
+ which costs nothing: nobody browses to a 404 on purpose.
331
+
332
+ | Flow | Owns |
333
+ |---|---|
334
+ | `auth` | sign-in, verify, profile — getting an identity |
335
+ | `onboarding` | first run only. A screen shown again later is not onboarding |
336
+ | `home` | the first screen after entry |
337
+ | `settings` | changing the thing, including how-to-play and about |
338
+ | `store` | paywall, packs, upgrade, ads — anything about money |
339
+ | `states` | app-wide: `404`, `500`, `offline`, `maintenance`, `splash` |
340
+ | `patterns` | reusable pieces shown alone: toasts, sheets, components |
341
+
342
+ **A `patterns` page is a screen file in every respect but one: the frame is
343
+ optional, and what it shows decides.** A modal, a sheet or a toast is a device
344
+ thing — it has a backdrop, it sits over a screen, and it only means anything at
345
+ 390pt, so it belongs in the frame. A colour ramp, a type ramp or a component
346
+ inventory is not a device thing: nothing in the product renders it, it is wider
347
+ than 390pt, and squeezed into a phone it teaches less than the same ramp laid
348
+ out, so it goes frameless. Never more than one frame, the same as any other
349
+ file.
350
+
351
+ **Everything else about a patterns page is the ordinary rule**, including the
352
+ one most often broken here: **it carries no prose.** The specimens and their
353
+ legends are the content — a badge row that does not say what the badges mean is
354
+ useless, and a legend is inventory, not rationale. What a pattern is FOR goes in
355
+ its wiki page, which the notes button already opens. Pattern pages that open
356
+ with the same paragraph as their wiki page are two copies of one sentence —
357
+ exactly what §10 exists to prevent.
358
+
359
+ Screens are named for what the screen **is**. **No `index.html` inside a flow** —
360
+ at surface root it is the gallery, and one level down the same name meaning "the
361
+ main screen of this flow" is ambiguous and has no variant form.
362
+
363
+ ---
364
+
365
+ ## 6. `index.html` is a flow chart
366
+
367
+ **A horizontal flow chart and nothing else.** No title, no description, no meta
368
+ line, no tables, no status legend. The chart already names every screen, and
369
+ anything that needs explaining is a wiki page. It runs start → … → end, and
370
+ every screen in `routes.js` appears in it.
371
+
372
+ Drawn with **React Flow v11**, from `WF` — never from `routes.js`. `_gallery.js`
373
+ draws; it derives nothing.
374
+
375
+ ```html
376
+ <script src="https://cdn.tailwindcss.com/3.4.17"></script>
377
+ <script src="tailwind.config.js"></script>
378
+ <script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.js"></script>
379
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js"></script>
380
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js"></script>
381
+ <script src="https://unpkg.com/reactflow@11.11.4/dist/umd/index.js"></script>
382
+ <link rel="stylesheet" href="https://unpkg.com/reactflow@11.11.4/dist/style.css" />
383
+ <link rel="stylesheet" href="shared.css" />
384
+ ...
385
+ <script src="routes.js"></script>
386
+ <script src="_chrome.js"></script>
387
+ <script src="_gallery.js"></script>
388
+ ```
389
+
390
+ `_chrome.js` comes before `_gallery.js`: it publishes the model the chart reads.
391
+
392
+ v11 and not v12: the v12 UMD bundle expects `react/jsx-runtime` as a page global,
393
+ which React's own UMD build does not provide.
394
+
395
+ **No layout library.** React Flow wants explicit positions, and the graph is a
396
+ shallow DAG with a declared start, so longest-path depth gives the column
397
+ directly. dagre or elk would be a third dependency solving a problem that is
398
+ already solved — and their columns would line up with what the engine thought
399
+ looked balanced rather than with the journey.
400
+
401
+ ### Declaring the graph
402
+
403
+ ```js
404
+ flow: {
405
+ start: "states/splash.html",
406
+ end: ["game/score.html"],
407
+ edges: [
408
+ { from: "states/splash.html", to: "home/home.html" },
409
+ { from: "home/home.html", to: "game/play.html", label: "Let's Play" },
410
+ { from: "game/score.html", to: "store/bundle.html", label: "more packs", side: true },
411
+ { from: "game/score.html", to: "home/home.html", label: "Home", back: true },
412
+ ],
413
+ }
414
+ ```
415
+
416
+ **Four kinds of edge**, and only the first defines the shape of the chart:
417
+
418
+ | | Meaning | Drawn |
419
+ |---|---|---|
420
+ | *(none)* | a step forward | solid; heavier and brand-coloured on the main path |
421
+ | `side: true` | a shortcut or an upsell — real navigation, no progress | dotted |
422
+ | `back: true` | a return: Home, Back, Play Again | dashed, **hidden by default** |
423
+ | `state: true` | **not a step in the journey** — it involves a state screen | never drawn — dropped in `_chrome.js` before anything is ranked |
424
+
425
+ `side` exists because counting a shortcut as progress drags its target right: an
426
+ upsell from the score put the whole shop *after* the score and stretched the
427
+ spine across the page. Returns are hidden behind a toggle because every screen
428
+ goes back to Home, so they all converge and cross everything on the way.
429
+
430
+ ### `state: true` is a judgement, not a shape
431
+
432
+ It does **not** mean "an edge that touches a state file". It means *this is not
433
+ a step in the journey*. Two edges both touching a state screen can go opposite
434
+ ways:
435
+
436
+ | | |
437
+ |---|---|
438
+ | `balances-offline → add` | The offline variant of Balances offers the same navigation the base screen already offers. Drawn again from the variant it is a duplicate. **`state: true`.** |
439
+ | `board-loading → win` | The game progresses through the thinking state to an outcome, and no other edge says so. Drop it and Win has no way in. **A plain forward edge.** |
440
+
441
+ Ask whether the journey moves. If the same step is already declared from the
442
+ base screen, it is not a second step.
443
+
444
+ ### The ranked graph must be a DAG
445
+
446
+ Collapse the states, drop `state`, `back` and `side`, and what is left **must be
447
+ acyclic**. Longest-path ranking is only defined on a DAG, and those three flags
448
+ are what keep it one — that is what they are *for*, not a side effect.
449
+
450
+ Get it wrong and the failure is silent and enormous: relaxation round a cycle
451
+ never settles, so every screen in the loop is ranked by the pass budget instead
452
+ of by the journey. One set once put 34 screens at column 727 and drew a chart
453
+ **185,385px wide**, which reads as "broken" rather than as "mis-declared".
454
+
455
+ `_chrome.js` detects the cycle instead of waiting it out, falls back to BFS
456
+ depth — defined on any graph, always terminating — and warns in the console
457
+ naming the three flags. The chart stays readable; the warning tells you which
458
+ declaration to go and fix.
459
+
460
+ ### Rules the chart must obey
461
+
462
+ - **One box per screen.** A state is a variant, not a second screen — it
463
+ collapses onto the screen it is a state of, and edges are rewritten through
464
+ the same map so a step that passes through a state still connects the screens
465
+ either side of it. Drawing states as their own nodes put "Board" beside
466
+ "Board · loading" and read as two places.
467
+ - **A node is a labelled rectangle** — the screen's name and its flow. Never a
468
+ thumbnail; the screenshots sheet is where you go to look at screens.
469
+ - **Each node carries an arrow in its corner that opens the screen.** The box
470
+ itself is not a link.
471
+ - **Clicking a box shows how you reach it**: every route into it lights up, the
472
+ rest fades. Computed over the edges currently drawn, so turning returns on
473
+ widens the answer rather than having silently counted paths nobody could see.
474
+ - **The main path is found, not declared** — start to the first end node,
475
+ following forward edges — and pinned to row 0 in every column, so the happy
476
+ path is one straight horizontal line with everything else hanging below it.
477
+ Ordering rows by declaration made the spine zig-zag between columns, which is
478
+ exactly what a flow chart exists to stop.
479
+ - **The main path reads first.** Its edges are heavier and brand-coloured and
480
+ its nodes fully opaque; everything else is lighter and slightly faded.
481
+ - **Each node carries its flow's colour on its left edge**, so you can tell the
482
+ shop from the game without reading a word: `home` brand, `store` accent,
483
+ `states` and `settings` ink, the product's main domain flow success, anything
484
+ else info. A small key names them — one swatch per flow the set uses, four to
485
+ seven of them, not a paragraph.
486
+ - **A flow's colour falls back rather than resolving to nothing.** `store` is
487
+ `var(--accent-500, var(--brand-500))`: only a set with a second brand colour
488
+ declares accent, and an undeclared `var()` paints *nothing at all* — a
489
+ colourless node edge and a blank swatch, which reads as a bug rather than as
490
+ a colour choice.
491
+ - The start node is ringed in `brand`, the end nodes in `success`.
492
+ - **Only forward edges carry a label.** A return is dashed and arrowed, which
493
+ already says "goes back here"; labelling them put three "Back"s on top of each
494
+ other.
495
+ - **A label sits at the target's row**, on the run going into the screen it
496
+ names — that is the end that makes it mean something, and five exits from one
497
+ screen no longer stack in the gutter. Labels landing on the same screen stack
498
+ a line apart. The few that still land on a box (an edge that skips a column
499
+ passes over the node between) are measured once the chart has settled and
500
+ lifted clear, with a halo rather than a background box, which would detach
501
+ from the moved text.
502
+ - **Every edge leaving one node bends at a different distance.** They share a
503
+ bend by default, so five exits from Home ran down one corridor and became a
504
+ single thick line.
505
+ - **Nothing may re-fit the chart after load.** Memoise the node and edge arrays;
506
+ rebuilding them every render made React Flow re-measure and the chart shivered
507
+ continuously.
508
+
509
+ ---
510
+
511
+ ## 7. `screenshots.html`
512
+
513
+ Every screen at once, live, at half scale — the fastest way to spot the one that
514
+ drifted.
515
+
516
+ - **Live iframes, not stills.** A sheet of stills is stale the day after it is
517
+ made, and this page exists to catch the screen that drifted.
518
+ - Each tile embeds `<screen>?bare`, which is why bare mode must fill its tile
519
+ exactly: no stage gutter, no device rounding, no device shadow.
520
+ - Tiles are labelled with the screen name and link to the screen.
521
+ - It carries the same demo navbar as every other page.
522
+ - **One tile per screen. States get no tile of their own** — the same rule as
523
+ the chart, and for the same reason. "Board" beside "Board · loading" beside
524
+ "Game History · empty" is one screen listed twice, and on a page whose whole
525
+ job is *find the one that drifted* the duplicate is noise. The navbar's state
526
+ switcher is where the variants live: there you are already looking at the
527
+ screen they are variants of.
528
+ - **Grouped by flow, in journey order.** The flow is a heading, once, instead
529
+ of a line under every tile; the flows run in the order the product first
530
+ reaches one and the screens inside a flow run by rank — so the sheet opens on
531
+ Splash, then Home, then the game. Manifest order breaks every tie. Both the
532
+ grouping and the order are `WF.journey`, which the chart ranks from the same
533
+ numbers, so the sheet and the chart cannot tell different stories.
534
+
535
+ ---
536
+
537
+ ## 8. Tokens
538
+
539
+ `shared.css` is three blocks, in this order:
540
+
541
+ 1. **CORE** — identical in every product. The frame, the stage, the bar, the
542
+ notes drawer, the chart, the type ramp, the radii, the shadow geometry, and
543
+ the token *names*. Copied byte for byte (§13).
544
+ 2. **PALETTE** — this product's colours, and nothing else.
545
+ 3. **Components** — this product's own classes.
546
+
547
+ The naming rule is the whole naming policy:
548
+
549
+ > **the CSS variable is the Tailwind token path with dots as dashes.**
550
+ > `line.strong` ↔ `--line-strong`. `ink.2` ↔ `--ink-2`. `brand.500` ↔ `--brand-500`.
551
+
552
+ Required in every palette: `page`, `frame`, `surface`, `muted`, `sunken`; `ink`,
553
+ `ink-2`, `ink-3`, `ink-4`, `ink-inverse`; `line`, `line-strong`;
554
+ `brand-50/100/200/400/500/700/900`; `accent-*` where the product has a second
555
+ brand colour; `success`/`warn`/`danger`/`info` each with `-bg` and `-fg`; and
556
+ `--shadow-tint` as an `r g b` triple.
557
+
558
+ - **Colours are named by role** (`brand`, `accent`, `line`), never by hue
559
+ (`navy`, `gold`, `green`), so a repalette is a one-file change.
560
+ - **Every value in `tailwind.config.js` is `var(--token)`, never a literal.** A
561
+ literal compiled into `bg-surface` paints the dark frame white — the utility
562
+ stops following the scope it lands in, and a theme remap can no longer reach
563
+ it.
564
+ - **A theme is a token remap** (`.phone--dark`), never a `dark:` prefix and never
565
+ a second set of utilities.
566
+ - **`.phone` sets `color: var(--ink)` itself.** A variable remap only reaches
567
+ declarations that read the variable; text with no colour class inherits the
568
+ *computed* colour from `body`, resolved in the light scope, and stays black in
569
+ a dark frame.
570
+ - **Each product declares its reserved colours** at the top of the palette block:
571
+ the one or two that carry product meaning and may not be reused. In a party
572
+ game green may mean one role and black another; in a two-player game blue is X
573
+ and rose is O; gold may mark a paid action and nothing else.
574
+ - **Adding a class means declaring it in `shared.css` in the same edit.** A class
575
+ that resolves to nothing does not throw — it quietly does not paint, which is
576
+ how `text-2xs` came to be used 186 times across three apps while declared in
577
+ exactly one. The `tokens` check (§12) catches it.
578
+ - **A component class may not take a name Tailwind generates.** `.ring` looks
579
+ free and is not: `ring` is Tailwind's focus-shadow utility, so a component
580
+ class called `.ring` loses the cascade and paints a blue box round whatever it
581
+ was meant to draw. The CDN build emits the utility whether or not you meant
582
+ it, and nothing warns. Check the name against Tailwind's own before using it,
583
+ or suffix it — `.ring-svg`.
584
+ - **One stylesheet per surface.** `shared.css` and nothing else — no
585
+ `<flow>/<flow>.css`. A second sheet has no slot in §1, is loaded by some
586
+ screens and not others, and puts one flow's rules where no token check will
587
+ look at them. Its contents are either component classes, which belong in the
588
+ third block, or Tailwind utilities that were never written as utilities.
589
+ - **Two tokens are published at runtime, not declared in CSS**: `--chrome-h` and
590
+ `--stage-pad`, both set on the root by `_chrome.js`. Every read of them carries
591
+ a fallback (`var(--chrome-h, 0px)`) so a page without the bar still lays out,
592
+ and a token check must not flag them as undeclared.
593
+
594
+ ---
595
+
596
+ ## 9. The frame
597
+
598
+ - **390 × 844**, the device radius on a normal screen page, square and
599
+ full-bleed under `?bare`.
600
+ - It **clamps to whatever is left under the bar** and scrolls its own content the
601
+ way the phone does:
602
+ `height: min(var(--frame-h), calc(100dvh - var(--chrome-h) - var(--stage-pad)))`.
603
+ Never `transform: scale()` — a transform does not change layout height, so the
604
+ frame still occupies 844px and the page scrolls to reveal a frame that was
605
+ already drawn smaller.
606
+ - The stage's gutter and the frame's height subtraction read **the same
607
+ variable**, or the two disagree and the frame is short by the gutter.
608
+ - **A screen with a modal over it carries `phone--modal`**, which stops the frame
609
+ scrolling. `position: absolute; inset: 0` resolves against the frame's padding
610
+ box, and in a scroll container that is the visible box pinned to the top of the
611
+ scrollable area — so scrolling slid the backdrop off and left the card floating
612
+ over nothing.
613
+ - **A modal card uses `bg-frame`, not `bg-surface`.** In a dark palette
614
+ `--surface` is a translucent white tint, which is right on a solid page and
615
+ wrong over a backdrop: the screen shows straight through the card.
616
+ - **A modal draws the screen underneath unchanged and inert.** A sheet does not
617
+ redraw the screen it opens over, and every link in the underlay is blocked in
618
+ the app, so it must not be a link in the wireframe either.
619
+ - **The device status bar is drawn.** The OS draws it, not the app — but the app
620
+ pads for it, so a wireframe that omits it puts every screen ~44px too high and
621
+ makes the first row look like it has more air than it will.
622
+
623
+ ---
624
+
625
+ ## 10. HTML or wiki — which holds what
626
+
627
+ - **HTML is the spec for anything visual** — layout, component inventory,
628
+ placement, size, state (see C-16 in `pipeline`). The screen inventory is the
629
+ `routes.js` route table, never a second list in Markdown.
630
+ - **A screen's notes live in the wiki, only where it has some** —
631
+ `.context/wiki/surfaces/<slug>.md`, named by `routes.js`; every other screen
632
+ has `notes: null`. The notes button renders it beside the screen on demand.
633
+ - The wiki also holds what a picture cannot: decisions and why, canonical values
634
+ (prices, tier names, copy strings, enums), research, ops.
635
+ - **Where they disagree**: the HTML wins on layout and flow; the canonical-values
636
+ page wins on prices, names and copy strings. Different columns, not a conflict.
637
+
638
+ ### What a notes page contains
639
+
640
+ Decisions and constraints true of **that page** and invisible in its pixels.
641
+ Not a description of the screen — the screen is right there.
642
+
643
+ ```markdown
644
+ ---
645
+ last-verified: 2026-09-22
646
+ ---
647
+ **A lead in bold, then the reason.** One paragraph per decision. Say what was
648
+ tried and why it lost, where that is what makes the decision stick.
649
+ ```
650
+
651
+ No summary, no wireframe link, no related list: the screen and `routes.js`
652
+ already say those. The rules are canon's `wiki-schema` standard.
653
+
654
+ ---
655
+
656
+ ## 11. The designs describe the app that exists
657
+
658
+ This is the rule that matters most and is broken most often.
659
+
660
+ - **When a screen and its wireframe disagree, the wireframe is the spec** — for a
661
+ product still being designed. For a product that has shipped, the *app* is the
662
+ fact and the wireframe is the thing that is wrong. Fix the wireframe, or change
663
+ it deliberately and say so in its notes page.
664
+ - **Read the source, do not remember it.** Icons come from the component the app
665
+ imports. Names come from the data table. Copy comes from the i18n dictionary.
666
+ Colours come from the app's theme file. A wireframe drawn from memory invents
667
+ packs that do not exist, screens that were deleted, and copy nobody wrote.
668
+ - **A deliberate departure is allowed and must be recorded** in the screen's
669
+ notes page, saying what the code does and why the drawing differs.
670
+
671
+ ---
672
+
673
+ ## 12. Checks
674
+
675
+ There is no lint and no build for a design set. **`canon check` runs the design
676
+ checks** for every surface under `.context/designs/<surface>/` that has a
677
+ `routes.js`, after every change:
678
+
679
+ | Check | Asks |
680
+ |---|---|
681
+ | **inventory** | is every file on disk declared in `routes.js`, and every declared file on disk? |
682
+ | **well-formed** | is every `<div>` closed exactly once, in order? |
683
+ | **links** | does every local `href` point at a file that exists? |
684
+ | **tokens** | does every class and `var(--…)` in the set resolve against `shared.css` + `tailwind.config.js`? |
685
+ | **click-through** | is every forward edge in `routes.js` a real link in the screen it leaves? |
686
+ | **flow-complete** | is every link between screens a declared edge? |
687
+ | **acyclic** | with states collapsed and `state`/`back`/`side` dropped, is the forward graph a DAG? |
688
+ | **routes** | is every app screen drawn, and does every drawing have an app screen? |
689
+ | **app-flow** | do the app and the chart navigate alike, in both directions? |
690
+ | **copy** | is every word a dialog or a toast puts on screen drawn somewhere? |
691
+
692
+ Two things are exempt from the link checks and from the chart: edges marked
693
+ `state: true`, which are not journey steps, and the links inside the *underlay*
694
+ of a modal screen, which the modal blocks in the app.
695
+
696
+ **acyclic** is the one that cannot be eyeballed at all, and the one whose
697
+ failure is worst — see §6.
698
+
699
+ click-through and flow-complete are a pair and a set needs both:
700
+
701
+ - **Every declared edge is a real link**, or the chart is a diagram of an
702
+ intention. A chart that claims Home leads to the store, on a Home whose pack
703
+ tile is a `<span>`, is exactly that — and the whole reason to draw screens in
704
+ HTML rather than a design tool is that you can walk the product.
705
+ - **Every link is a declared edge**, or the chart quietly under-reports the
706
+ product. This one found 24 missing transitions across two migrated sets: "Play
707
+ Again", "Continue playing", "Save & go home", the resume banner, both upsell
708
+ paths.
709
+
710
+ **Serve the set over http to look at it**, never `file://`. The notes drawer
711
+ fetches Markdown, which `file://` blocks; and a plain static server without
712
+ `Cache-Control: no-store` will serve you a stale stylesheet and make every visual
713
+ check a lie.
714
+
715
+ ---
716
+
717
+ ## 13. Where each part comes from
718
+
719
+ **The canonical set lives in the repo named in `copies.source`** in
720
+ `.claude/canon.yaml`. It should exercise nearly everything in this guide: the
721
+ reserved flows, a few domain flows, modal screens, a state variant, and at least
722
+ one edge of each of the four kinds. If it draws one theme only, the estate names
723
+ a second reference for a two-theme palette. **Copy from those and from nothing
724
+ else** — other sets may be on older versions of the shared files, and copying
725
+ from them silently reverts the bar, the chart, or both. A migration that copied
726
+ from the nearest set would look finished and be a version behind.
727
+
728
+ `canon check` holds the copied parts equal to the source, and
729
+ `canon copies --sync` repairs a drifted one.
730
+
731
+ | Part | Where it comes from |
732
+ |---|---|
733
+ | `_chrome.js` | copied **byte for byte**. Never edited per product |
734
+ | `_gallery.js` | copied **byte for byte**. Never edited per product |
735
+ | CORE block of `shared.css` | copied **byte for byte** — everything above the PALETTE banner |
736
+ | `index.html` | copied; change the `<title>` and nothing else |
737
+ | `screenshots.html` | copied; change the `<title>` and nothing else |
738
+ | PALETTE + components in `shared.css` | written for this product |
739
+ | `tailwind.config.js` | copied, then this product's own colour groups swapped in — most of the file is the shared token keys. Every value `var(--token)` |
740
+ | `routes.js` | written for this product |
741
+ | `<flow>/<screen>.html` | drawn |
742
+ | `brand/` | this product's artwork |
743
+
744
+ Those five copied files are the whole reason a set is legible at a glance. If a
745
+ product needs one of them to behave differently, change it in the source repo
746
+ and sync — that is a change to every set, not a local edit in one.
747
+
748
+ **A formatter is an edit, and it is the one that gets past everybody.** One
749
+ repo ran `prettier --write` on every `*.js` through lint-staged, so every commit
750
+ silently rewrote `_chrome.js`, `_gallery.js` and `routes.js` — quote style,
751
+ trailing commas, wrapped object literals. Behaviour identical; bytes not. That
752
+ is how a set ends up the only one whose bar and chart do not match the others,
753
+ with nobody having decided anything, and it survives being fixed because the
754
+ next commit does it again. **Add the shared parts to the repo's
755
+ `.prettierignore`** (or its equivalent) in any repo whose tooling formats
756
+ JavaScript:
757
+
758
+ ```
759
+ .context/designs/*/_chrome.js
760
+ .context/designs/*/_gallery.js
761
+ .context/designs/*/routes.js
762
+ ```
763
+
764
+ Check for it before trusting a drift report: a set can be one `prettier --write`
765
+ away from looking like it has drifted a version when it has not, and one commit
766
+ away from drifting for real.
767
+
768
+ ### Building a new set
769
+
770
+ 1. Read the app's routes, components, theme file and i18n dictionary first. If
771
+ there is no app yet, the designs are the spec and this step is the brief.
772
+ 2. Copy the five shared parts from the canonical set, per the table above.
773
+ 3. Write the PALETTE block. Role names only. Declare the reserved colours.
774
+ 4. Write `routes.js`: every screen, every state, every edge.
775
+ 5. Draw the screens, one per file, no prose.
776
+ 6. Write a notes page for every screen that has notes.
777
+ 7. Run `canon check`. Open the set in a browser at ≥640px and walk it.
778
+
779
+ ### Migrating a set that predates this guide
780
+
781
+ A set with no `routes.js` and no bar is not "an existing set to fix" — none of
782
+ the machinery it would be checked with is there yet. Do it in this order; each
783
+ step is checkable before the next one starts, and going out of order means
784
+ drawing screens against a manifest that is still moving.
785
+
786
+ 1. **Inventory first, change nothing.** List every `.html` under the surface,
787
+ and for each one decide: a screen, a state of another screen, or a file that
788
+ should not exist. Two things are always the third case — an `index.html`
789
+ inside a flow (§5) and a second frame in one file (§5). Write the list down;
790
+ it becomes `routes.js`.
791
+ 2. **Pin the CDNs.** A pre-standard set usually loads `cdn.tailwindcss.com` and
792
+ `lucide@latest` with no version. Pin every one to the versions in §3 in a
793
+ single mechanical pass, before anything visual is judged — an unpinned set
794
+ is not the set you are looking at tomorrow.
795
+ 3. **Copy the five shared parts** from the canonical set, per the table above.
796
+ 4. **Write the PALETTE and `tailwind.config.js`** from the colours the set
797
+ already uses. Role names, no hues, every config value a `var(--token)`.
798
+ This is where a pre-standard set usually has literals; §8 says why they
799
+ cannot stay.
800
+ 5. **Write `routes.js` — flows and screens first, the graph second.** Order the
801
+ flows by the reserved order. Give every screen its `screen` name, `status`
802
+ and `notes` slug.
803
+ 6. **Derive the edges; do not recall them.** Scan the real `href`s in every
804
+ screen and declare one edge per link — the `flow-complete` check of §12 run
805
+ as an authoring step, and on a 40-screen set the only tractable way. Then
806
+ classify each: plain forward, `side`, `back`, or `state`. A set migrated from
807
+ memory under-reports itself by about a quarter.
808
+ 7. **Add `data-chrome` and `data-file` to every screen**, and the `routes.js` +
809
+ `_chrome.js` lines to its tail. A screen whose `data-file` disagrees with its
810
+ path logs a warning and appears nowhere.
811
+ 8. **Fold stray per-flow CSS into `shared.css`.** A `<flow>/<flow>.css` has no
812
+ slot in §1 and no way to be checked. Its rules belong in the components
813
+ block, or they are Tailwind classes that were never written as classes.
814
+ 9. **Deduplicate the brand artwork.** Any mark pasted inline into more than one
815
+ screen becomes one file under `brand/` (§3).
816
+ 10. **Write the notes pages**, then run `canon check` and walk the set in a
817
+ browser.
818
+
819
+ **If the product has no splash screen**, `flow.start` is the first screen the
820
+ app actually opens on — Home, or the first onboarding step. `start` is required
821
+ and is what the column rank counts from; a chart with no declared start ranks
822
+ every screen at zero and draws one column.
823
+
824
+ ### Fixing an existing set
825
+
826
+ 1. Run `canon check` first — it tells you what is broken before you look.
827
+ 2. Diff the set against the app: icon names, data tables, copy strings, screen
828
+ inventory, navigation. Most drift is here, and none of it throws.
829
+ 3. Check the frame fits with no page scroll, and that the screenshots sheet has
830
+ no gaps or clipped corners.
831
+ 4. Check the bar and CORE match the canonical set (`canon check` reports drift).
832
+ 5. Re-run `canon check` and walk the set.
833
+
834
+ ---
835
+
836
+ ## 14. Known-wrong patterns
837
+
838
+ Things that have already been done wrong once. Do not repeat them.
839
+
840
+ | Pattern | Why it fails |
841
+ |---|---|
842
+ | `transform: scale()` to fit the frame | does not change layout height; the page still scrolls |
843
+ | Several frames in one file | no URL, no route entry, no link, no tile |
844
+ | A prose sidebar beside the screen | competes with the thing under review |
845
+ | A literal hex in `tailwind.config.js` | the utility stops following the theme scope |
846
+ | Hue-named colours (`navy`, `gold`) | a repalette becomes a rename across 500 files |
847
+ | A class used but never declared | resolves to nothing, throws nothing, paints nothing |
848
+ | Unpinned CDN | every set restyles itself overnight |
849
+ | Per-tile semantic colours where the app tints by category | one shelf reads as four unrelated things |
850
+ | The bar reading a palette token | the same control looks like a different control per product |
851
+ | Drawing states as chart nodes | the same screen appears twice |
852
+ | Inventing a screen, a pack, or a string | the set stops being a reference and becomes fiction |
853
+ | A per-flow stylesheet beside the screens | loaded by some screens, checked by nothing |
854
+ | Copying shared files from whichever set is nearest | older sets are behind; the bar silently reverts |
855
+ | A data-driven SVG pasted into every screen that shows it | five copies of one geometry, and the datum is the only line that should differ |
856
+ | A component class named after a Tailwind utility | the utility wins the cascade and paints something nobody asked for |
857
+ | The same sentence in a screen file and in its wiki page | two copies of one decision, and §10 says there is one |