Public: anyone can read this; the crew can change it.

The forge looks like one thought-through product again: a design audit and one design system across every page #2685

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2678-the-forge-looks-like-one-thought-through → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2678-the-forge-looks-like-one-thought-through was merged into main as cddea40.

Review

Sign in to review this change.
6 files changed +1073 −0
README.md +9
@@ -1201,11 +1201,20 @@ forge icon.
12011201
12021202 ```sh
12031203 bun test # protocol-level + adversarial suite
1204 bun scripts/design-lint.ts # no colour, off-scale font-size or component class beyond docs/design/baseline.json
12041205 bun x tsc --noEmit # zero type errors is the standard
12051206 bun run check # exactly what the forge runs against every push
12061207 bun run check:record # the same, and rewrites scripts/test-times.json
12071208 ```
12081209
1210 The design check (`test/design-lint.test.ts`, run in the gate) fails a pull that
1211 adds a colour literal, an off-scale `font-size` or a component class beyond
1212 `docs/design/baseline.json`; the message names the file. The way out is to use
1213 a token (`var(--…)`) or a named component from `docs/DESIGN.md`; when a change
1214 lowers the counts or adds a component to DESIGN.md §5, run
1215 `bun scripts/design-lint.ts --update` and commit the baseline. See
1216 `docs/design/plan.md`.
1217
12091218 `bun run check` is the gate: the same command `forge.json` declares, so what
12101219 it says here is what the deploy will say. It type-checks once, then runs the
12111220 test files `scripts/gate.ts` admits — every `test/*.test.ts` not in its
docs/design/audit.md added +74
@@ -0,0 +1,74 @@
1 # Design audit — 2026-10-19 (#2678): a source-drift report
2
3 The owner's words: "a giant pile of interfaces with no apparent reason". This
4 audit says what is true, what it rests on, and what it does not yet cover.
5
6 ## What this audit is not
7
8 It is a report on drift in the source, **not** a judgement of how the pages
outdated

A design audit with no screenshots cannot judge layout shift, 360px, contrast or dark mode, which is the experience. Add the sweep (plan item 1) in this pull, or narrow the title and claims to a source-drift report.

9 look. It has **no screenshots**, so it says nothing about layout shift, 360px,
10 contrast or dark mode; the verdicts in Finding 2 are about purpose and overlap
11 read from code, and are provisional until the sweep (#2690) lands. The agent that wrote it runs in a sandbox that cannot
12 open a browser or reach a running forge, so the desktop/360px × light/dark sweep
13 is not here; it is the first item of the plan (#2690) for a run that can. Everything else is read from the source: `src/web/**`, `docs/DESIGN.md`,
14 and the counts from `bun scripts/design-lint.ts`.
15
16 ## Finding 1 — the design system exists; the pages left it
17
18 `docs/DESIGN.md` already is "one design system": tokens (palettes as data in
19 `src/design/themes.ts`), a type scale (11.5 / 12.5 / 13 / 14 / 15 / 16 / 20px),
20 a 4px grid, three radii, one accent, ~70 named components. The clean app
21 template's advantage is not a better system; it is that it never grew past it.
22
23 The forge's own source has drifted, measured today:
24
25 | drift | count | where most |
26 | ------------------------------------------------ | ----: | ----------------------------------------------------------------------- |
27 | hex / `rgb()` / `hsl()` literals outside themes | 95 | `style/content.ts` (23), `studio/home.ts` (7), `pages/looks.ts` (6), `pages/site.ts` (3) |
28 | `font-size` off the scale (13.5, 12, 11, 10, 22, 18, 72 …) | 67 | `style/content.ts` (31), `style/components.ts` (16), `pages/map.ts` (12), `pages/board.ts` (6) |
29 | classes defined in stylesheets | 619 | DESIGN.md §5 names ~70 components; the rest are page-local |
30
31 619 classes against ~70 documented components is the "pile": most pages grew
32 their own vocabulary instead of composing the named one.
33
34 ## Finding 2 — the pages, and what each is for
35
36 Judged by the question "what would the owner do here that they cannot do
37 elsewhere?". Page sizes are source lines, a rough proxy for how much each
38 invented.
39
40 | page | for | verdict |
41 | ------------------ | ----------------------------------------------- | ------------------------------------------------------------------- |
42 | `/` (studio home) | where I am, what needs me | keep; redo as KPIs the owner pins, not a to-do list (#2665) |
43 | `/boards` | saved queries as panels | keep; selectors unusable (#2666); becomes the home's panel source |
44 | `/system` | realtime machine state (btop) | keep; redo on the system (#2678 plan item 4) |
45 | `/flow` | pipeline and bottleneck (topbar button, admin) | overlaps `/system` and boards: merge as a board panel set |
46 | `/settings/usage`, `/settings/capacity`, `/settings/telemetry`, spend | four pages about "how much is being used" | one page, tabs by `tabs()` — usage and spend are the same question |
47 | `/settings/model`, `/settings/cloud`, `/settings/backups`, `/settings/resilience` | machine/provider/recovery configuration | keep as settings sections; resilience (51 lines) folds into backups |
48 | `/map` | 1,806 lines, 12 off-scale sizes, own colours | the largest private vocabulary; review what it shows that `/boards` and the issue dependency list do not |
49 | `/audit`, `waves`, `variants`, `looks`, `returns`, `chain` | operator views of the crew's work | each needs a stated reason to be a page rather than a board panel |
50 | `/settings/*` rest | account, keys, tokens, origins, crew | fine as one settings shell (`settingsLayout()`); no change |
51
52 Duplication to resolve: three places answer "what is the forge doing right now"
53 (home, `/flow`, `/system`); four answer "what does it cost / how full is it".
54
55 ## Finding 3 — what the clean app template does better
56
57 - One page, one purpose, one primary action. The forge's top bar carries
58 Flow, search, help, theme, New, user — six controls before the page begins.
59 - It has no page-local CSS. Every forge page file that carries colour or size
60 literals (list above) is a page that reinvented a token.
61 - First paint is the product. Several forge pages are tables of everything;
62 the template shows three numbers and a list.
63
64 ## Not covered
65
66 Screenshots, contrast measurement per palette, the TUI surface, and pages
67 behind a specific repo (`/:owner/:name/*`), which the repo header and tabs
68 already unify and which are not where the drift is.
69
70 ## Plan
71
72 `docs/design/plan.md` — small issues, in order. The check that holds the line
73 meanwhile is `scripts/design-lint.ts` (run by `test/design-lint.test.ts`, so it
74 runs in the gate).
docs/design/baseline.json added +793
@@ -0,0 +1,793 @@
1 {
2 "files": {
3 "src/web/chrome.ts": {
4 "colour": 2,
5 "size": 0
6 },
7 "src/web/layout.ts": {
8 "colour": 1,
9 "size": 0
10 },
11 "src/web/pages/account.ts": {
12 "colour": 1,
13 "size": 0
14 },
15 "src/web/pages/apps.ts": {
16 "colour": 1,
17 "size": 0
18 },
19 "src/web/pages/board.ts": {
20 "colour": 1,
21 "size": 6
22 },
23 "src/web/pages/claim.ts": {
24 "colour": 1,
25 "size": 0
26 },
27 "src/web/pages/conflicts.ts": {
28 "colour": 0,
29 "size": 1
30 },
31 "src/web/pages/everyday.ts": {
32 "colour": 1,
33 "size": 0
34 },
35 "src/web/pages/family.ts": {
36 "colour": 1,
37 "size": 0
38 },
39 "src/web/pages/forges.ts": {
40 "colour": 1,
41 "size": 0
42 },
43 "src/web/pages/limb.ts": {
44 "colour": 1,
45 "size": 0
46 },
47 "src/web/pages/limb_replan.ts": {
48 "colour": 1,
49 "size": 0
50 },
51 "src/web/pages/looks.ts": {
52 "colour": 6,
53 "size": 0
54 },
55 "src/web/pages/map.ts": {
56 "colour": 3,
57 "size": 12
58 },
59 "src/web/pages/milestones.ts": {
60 "colour": 2,
61 "size": 0
62 },
63 "src/web/pages/new-forge.ts": {
64 "colour": 1,
65 "size": 0
66 },
67 "src/web/pages/next-steps.ts": {
68 "colour": 2,
69 "size": 0
70 },
71 "src/web/pages/settings.ts": {
72 "colour": 1,
73 "size": 0
74 },
75 "src/web/pages/site.ts": {
76 "colour": 3,
77 "size": 0
78 },
79 "src/web/standards.ts": {
80 "colour": 2,
81 "size": 0
82 },
83 "src/web/studio/app.ts": {
84 "colour": 2,
85 "size": 0
86 },
87 "src/web/studio/asks.ts": {
88 "colour": 1,
89 "size": 0
90 },
91 "src/web/studio/asks_page.ts": {
92 "colour": 2,
93 "size": 0
94 },
95 "src/web/studio/change.ts": {
96 "colour": 2,
97 "size": 0
98 },
99 "src/web/studio/cost_line.ts": {
100 "colour": 1,
101 "size": 0
102 },
103 "src/web/studio/crew.ts": {
104 "colour": 1,
105 "size": 0
106 },
107 "src/web/studio/explore.ts": {
108 "colour": 2,
109 "size": 0
110 },
111 "src/web/studio/feed.ts": {
112 "colour": 1,
113 "size": 0
114 },
115 "src/web/studio/home.ts": {
116 "colour": 7,
117 "size": 0
118 },
119 "src/web/studio/home_tabs.ts": {
120 "colour": 2,
121 "size": 0
122 },
123 "src/web/studio/make_it_yours.ts": {
124 "colour": 1,
125 "size": 0
126 },
127 "src/web/studio/nouns.ts": {
128 "colour": 1,
129 "size": 0
130 },
131 "src/web/studio/now.ts": {
132 "colour": 1,
133 "size": 0
134 },
135 "src/web/studio/previews.ts": {
136 "colour": 2,
137 "size": 0
138 },
139 "src/web/studio/shelf.ts": {
140 "colour": 2,
141 "size": 0
142 },
143 "src/web/studio/style.ts": {
144 "colour": 2,
145 "size": 1
146 },
147 "src/web/studio/tell.ts": {
148 "colour": 1,
149 "size": 0
150 },
151 "src/web/studio/toolbar.ts": {
152 "colour": 2,
153 "size": 0
154 },
155 "src/web/studio/words.ts": {
156 "colour": 1,
157 "size": 0
158 },
159 "src/web/style/components.ts": {
160 "colour": 5,
161 "size": 16
162 },
163 "src/web/style/content.ts": {
164 "colour": 23,
165 "size": 31
166 },
167 "src/web/ui.ts": {
168 "colour": 1,
169 "size": 0
170 }
171 },
172 "classes": [
173 "about",
174 "act",
175 "add",
176 "agent-brief",
177 "ahead",
178 "app-shelf",
179 "app-strip",
180 "app-tile",
181 "app-tile-body",
182 "app-tile-desc",
183 "app-tile-look",
184 "app-tile-name",
185 "app-url",
186 "ask-acts",
187 "ask-card",
188 "ask-group",
189 "ask-kind",
190 "ask-meta",
191 "ask-more-acts",
192 "ask-pick",
193 "ask-pick-hit",
194 "ask-short",
195 "ask-waiting",
196 "audit",
197 "audit-alarm",
198 "audit-answer",
199 "audit-app",
200 "audit-cells",
201 "audit-change",
202 "audit-h2",
203 "audit-problems",
204 "audit-said",
205 "audit-sum",
206 "audit-trail",
207 "audit-unread",
208 "audit-wide",
209 "avatar",
210 "backups-drill",
211 "backups-error",
212 "backups-now",
213 "backups-restore",
214 "bad",
215 "badge",
216 "bar",
217 "bare",
218 "behind",
219 "bform",
220 "bgrid",
221 "big",
222 "blob-view",
223 "bmanage",
224 "board",
225 "board-about",
226 "board-answer",
227 "board-app",
228 "board-app-mode",
229 "board-app-name",
230 "board-app-nums",
231 "board-app-week",
232 "board-asked",
233 "board-asks",
234 "board-bar",
235 "board-brand",
236 "board-card",
237 "board-clock",
238 "board-count",
239 "board-decision",
240 "board-decision-main",
241 "board-declined",
242 "board-declined-row",
243 "board-declined-says",
244 "board-dot",
245 "board-elapsed",
246 "board-fill",
247 "board-forge",
248 "board-grid",
249 "board-group",
250 "board-h",
251 "board-inferred",
252 "board-key",
253 "board-keys",
254 "board-kind",
255 "board-landed",
256 "board-landed-meta",
257 "board-landed-title",
258 "board-list",
259 "board-live",
260 "board-outcome",
261 "board-p",
262 "board-parked-row",
263 "board-pillar",
264 "board-pillars",
265 "board-pillars-h",
266 "board-quiet",
267 "board-region",
268 "board-run",
269 "board-run-meta",
270 "board-run-title",
271 "board-run-who",
272 "board-scope",
273 "board-spend",
274 "board-spend-detail",
275 "board-state",
276 "board-sub",
277 "board-sub-name",
278 "board-sub-scope",
279 "board-subs",
280 "board-thumb",
281 "board-top",
282 "board-when",
283 "board-window",
284 "board-window-name",
285 "board-windows",
286 "boards-result",
287 "bp-h2",
288 "bp-h3",
289 "bp-w2",
290 "bp-w3",
291 "branch-div",
292 "branch-row",
293 "break",
294 "btn",
295 "btn-icon",
296 "byline",
297 "byline-name",
298 "card",
299 "card-body",
300 "card-foot",
301 "card-head",
302 "cat",
303 "chart",
304 "chart-box",
305 "check",
306 "check-progress",
307 "check-steps",
308 "checks-line",
309 "claim-link-box",
310 "clone",
311 "clone-text",
312 "close-into",
313 "close-reason",
314 "code",
315 "code-hit",
316 "code-layout",
317 "code-line",
318 "code-main",
319 "code-split",
320 "color-field",
321 "comment",
322 "comment-body",
323 "comment-form",
324 "commit-body",
325 "commit-head",
326 "composer",
327 "composer-bar",
328 "connect-more",
329 "connect-path",
330 "count",
331 "counts",
332 "crumbs",
333 "dash",
334 "dc",
335 "dc-review",
336 "dcx",
337 "del",
338 "device-code",
339 "dh",
340 "dialog",
341 "dialog-body",
342 "dialog-foot",
343 "dialog-head",
344 "diff",
345 "diff-body",
346 "diff-file",
347 "diff-head",
348 "diff-note",
349 "diff-path",
350 "diff-stat",
351 "diff-stub",
352 "diff-summary",
353 "diff-table",
354 "diff-viewed",
355 "dl",
356 "dl-review",
357 "dn",
358 "dn-comment",
359 "domain-records",
360 "dot",
361 "edit-row",
362 "editor-commit",
363 "editor-gutter",
364 "editor-head",
365 "editor-hint",
366 "editor-mirror",
367 "editor-path",
368 "editor-preview",
369 "editor-stack",
370 "editor-tab",
371 "editor-target",
372 "editor-wrap",
373 "empty",
374 "empty-title",
375 "env-card",
376 "env-error",
377 "env-facts",
378 "error",
379 "error-note",
380 "everyday-ask",
381 "ex-dir",
382 "ex-empty",
383 "ex-file",
384 "ex-find",
385 "ex-head",
386 "ex-list",
387 "ex-more",
388 "ex-name",
389 "ex-ref",
390 "ex-twist",
391 "explore-filters",
392 "explore-line",
393 "explore-main",
394 "explore-name",
395 "explore-row",
396 "explorer",
397 "explorer-panel",
398 "explorer-toggle",
399 "fcard",
400 "fcard-acts",
401 "fcard-app",
402 "fcard-form",
403 "fcard-head",
404 "fcard-more",
405 "fcard-reason",
406 "fcard-reply",
407 "fcard-text",
408 "fcard-unblock",
409 "fcard-who",
410 "fcard-why",
411 "feed",
412 "feed-head",
413 "fg-s0",
414 "fg-s1",
415 "fg-s2",
416 "fg-s3",
417 "fg-s4",
418 "fg-s5",
419 "fg-s6",
420 "fg-s7",
421 "field",
422 "field-row",
423 "files-head",
424 "filters",
425 "finder",
426 "finder-note",
427 "flag-form",
428 "fleet-assume",
429 "fleet-axis",
430 "fleet-grid",
431 "fleet-legend",
432 "fleet-panel-name",
433 "fleet-pick",
434 "fleet-svg",
435 "fleet-swatch",
436 "fleet-table",
437 "fleet-ticks",
438 "fleet-window",
439 "fleet-windows",
440 "flow-bar",
441 "flow-out",
442 "following",
443 "following-actions",
444 "following-eyebrow",
445 "following-foot",
446 "following-head",
447 "following-sentence",
448 "foot",
449 "forges-sent",
450 "fork-bar",
451 "form-actions",
452 "front-admin",
453 "front-controls",
454 "front-crew",
455 "front-featured",
456 "front-featured-body",
457 "front-featured-look",
458 "front-issues",
459 "front-live",
460 "front-more",
461 "front-pick",
462 "front-pin-any",
463 "front-weights",
464 "frozen",
465 "frozen-form",
466 "frozen-sentence",
467 "grants",
468 "grants-foot",
469 "grow",
470 "handoff",
471 "handoff-links",
472 "headroom",
473 "held-bar",
474 "held-done_on_main",
475 "held-key",
476 "held-open_pull",
477 "held-parked_twice",
478 "held-planner",
479 "held-red_fix",
480 "held-run_cap",
481 "held-running",
482 "held-setting",
483 "held-stalled",
484 "held-startable",
485 "hero",
486 "hero-terminal",
487 "hex",
488 "hide-lg",
489 "hide-sm",
490 "hint",
491 "home-app",
492 "home-apps",
493 "home-apps-verbs",
494 "home-pick",
495 "home-rail",
496 "home-tabs",
497 "ico",
498 "ico-loader",
499 "input",
500 "issue-head",
501 "issue-meta",
502 "js-only",
503 "kv",
504 "label",
505 "limb-confirm",
506 "limb-rows",
507 "limb-why",
508 "lineage",
509 "lineage-children",
510 "lineage-here",
511 "list",
512 "live-note",
513 "live-pill",
514 "live-status",
515 "live-toggle",
516 "ln",
517 "ln-forge",
518 "ln-pulse",
519 "ln-say",
520 "ln-tool",
521 "ln-you",
522 "log-tail-row",
523 "look",
524 "look-attached",
525 "look-attached-phone",
526 "look-attachments",
527 "look-card",
528 "look-desktop",
529 "look-now",
530 "look-of",
531 "look-pair",
532 "look-phone",
533 "look-thumb",
534 "map-ask",
535 "map-autonomy",
536 "map-breath",
537 "map-canvas",
538 "map-card",
539 "map-chart",
540 "map-counts",
541 "map-dot",
542 "map-edge",
543 "map-edge-crit",
544 "map-edge-done",
545 "map-empty",
546 "map-flow",
547 "map-lane",
548 "map-lane-caret",
549 "map-lane-crew",
550 "map-lane-head",
551 "map-lane-name",
552 "map-lane-word",
553 "map-lanes",
554 "map-level",
555 "map-list",
556 "map-main",
557 "map-meta",
558 "map-node",
559 "map-pillars",
560 "map-rule",
561 "map-spend",
562 "map-spend-fill",
563 "map-spend-track",
564 "map-svg",
565 "map-title",
566 "map-tool",
567 "map-tools",
568 "map-who",
569 "map-who-i",
570 "map-who-n",
571 "map-word",
572 "map-wrap",
573 "mark",
574 "mb",
575 "menu",
576 "menu-item",
577 "menu-panel",
578 "menu-sep",
579 "menu-title",
580 "merge-box",
581 "merge-form",
582 "merge-headline",
583 "merge-sub",
584 "mirror-form",
585 "mirrored",
586 "ml",
587 "mono",
588 "ms-actions",
589 "ms-bar",
590 "ms-count",
591 "ms-figures",
592 "ms-head",
593 "ms-limb",
594 "ms-limbs",
595 "ms-more",
596 "ms-move",
597 "ms-name",
598 "ms-planner",
599 "ms-rows",
600 "ms-section",
601 "ms-state",
602 "ms-top",
603 "ms-trim",
604 "ms-weight",
605 "mt",
606 "muted",
607 "name-mark",
608 "narrow",
609 "narrow-wide",
610 "needs-you",
611 "next-crew",
612 "next-step",
613 "next-step-apps",
614 "next-step-main",
615 "next-step-mark",
616 "next-step-title",
617 "next-steps",
618 "no-hits",
619 "nonl",
620 "now",
621 "num",
622 "number",
623 "ok",
624 "origin-chip",
625 "origin-link",
626 "origin-mark",
627 "origin-name",
628 "origin-row",
629 "owner",
630 "page-head",
631 "pager",
632 "palette",
633 "palette-empty",
634 "palette-foot",
635 "palette-group",
636 "palette-heading",
637 "palette-input",
638 "palette-item",
639 "palette-list",
640 "passkey-note",
641 "path-head",
642 "pill",
643 "pinned-failures",
644 "pinned-slot",
645 "policy-line",
646 "preview",
647 "propose-line",
648 "prose",
649 "qf",
650 "quiet-details",
651 "rc",
652 "rc-body",
653 "rc-form",
654 "rc-head",
655 "rc-hidden",
656 "rc-outdated",
657 "rc-resolve",
658 "rc-resolved",
659 "rc-resolved-line",
660 "reduced",
661 "repo-actions",
662 "repo-audience",
663 "repo-desc",
664 "repo-head",
665 "repo-layout",
666 "repo-lineage",
667 "repo-origin",
668 "repo-title",
669 "repo-where",
670 "review-body",
671 "review-card",
672 "review-verdicts",
673 "reviews-chip",
674 "row",
675 "row-actions",
676 "row-lead",
677 "row-main",
678 "row-meta",
679 "row-title",
680 "row-trail",
681 "run-actions",
682 "run-jump",
683 "run-live",
684 "run-log",
685 "run-steer",
686 "run-summary",
687 "search",
688 "seg",
689 "sep",
690 "settings",
691 "settings-main",
692 "settings-nav",
693 "sha",
694 "shelf-desc",
695 "shelf-facts",
696 "shelf-meta",
697 "shell",
698 "shell-wide",
699 "skip",
700 "slash",
701 "small",
702 "spacer",
703 "spend-table",
704 "spend-totals",
705 "split",
706 "sprite",
707 "sr-only",
708 "stack",
709 "stack-tight",
710 "stage-detail",
711 "stage-dot",
712 "stages",
713 "step-state",
714 "steps",
715 "sticky-action",
716 "studio",
717 "studio-ask-actions",
718 "studio-crew",
719 "studio-head",
720 "studio-list",
721 "studio-live",
722 "studio-main",
723 "studio-preview",
724 "studio-preview-head",
725 "studio-rail",
726 "studio-tell",
727 "studio-who",
728 "studio-word",
729 "sub",
730 "swatch",
731 "sys-pair",
732 "ta-c",
733 "ta-r",
734 "table",
735 "table-scroll",
736 "tabs",
737 "tell-here",
738 "terminal-line",
739 "tests-bar",
740 "tests-words",
741 "theme-apply",
742 "theme-option",
743 "tick",
744 "timeline",
745 "tl-dot",
746 "tl-item",
747 "toast",
748 "toast-body",
749 "tok",
750 "tok-attribute",
751 "tok-comment",
752 "tok-constant",
753 "tok-decorator",
754 "tok-deleted",
755 "tok-emphasis",
756 "tok-function",
757 "tok-heading",
758 "tok-inserted",
759 "tok-keyword",
760 "tok-link",
761 "tok-meta",
762 "tok-number",
763 "tok-operator",
764 "tok-plain",
765 "tok-property",
766 "tok-punctuation",
767 "tok-regex",
768 "tok-string",
769 "tok-tag",
770 "tok-type",
771 "tok-variable",
772 "topbar",
773 "topbar-asks",
774 "topbar-search",
775 "topbar-tools",
776 "tree-row",
777 "tree-total",
778 "truncate",
779 "txt",
780 "verdict-stale",
781 "wall-cells",
782 "wd",
783 "where",
784 "where-block",
785 "where-dot",
786 "where-note",
787 "where-pin",
788 "where-place",
789 "where-sep",
790 "who",
791 "wordmark"
792 ]
793 }
docs/design/plan.md added +26
@@ -0,0 +1,26 @@
1 # Design plan (#2678)
2
3 The line is held by `bun scripts/design-lint.ts`: a pull may not add a colour
4 literal, an off-scale `font-size`, or a component class beyond
5 `docs/design/baseline.json`. Paying down lowers the baseline
6 (`bun scripts/design-lint.ts --update`, reviewed in the pull); it never rises
7 without a DESIGN.md §5 entry first.
8

The plan says each item is one issue, but none were filed. Create them via the API and link the dependencies (items 2 and 3 are already #2665 and #2666), so the plan lives in the map and not only in a markdown list.

9 Each item is one filed issue, small enough for one run; items 4–9 wait for the screenshot sweep (#2690) in the map.
10
11 1. **#2690 Screenshot sweep** — every top-level page, desktop + 360px, light + dark,
12 committed under `docs/design/shots/`; fills the gap in the audit.
13 2. **Home as KPIs** — #2665 (already filed).
14 3. **Boards selectors** — #2666 (already filed).
15 4. **#2691 `/system` on the system** — rebuild with `card`, `row`, `pill`, tokens only.
16 5. **#2692 Merge usage / spend / capacity / telemetry** into one "Usage" page with `tabs()`.
17 6. **#2693 Fold `/flow` into boards** as panels; drop the topbar Flow button.
18 7. **#2694 Fold resilience into backups.**
19 8. **#2695 `/map`: account for it** — what it shows that nothing else does; cut the
20 rest; move its 12 off-scale sizes and 3 colours onto tokens.
21 9. **#2696 Operator views** (`audit`, `waves`, `variants`, `looks`, `returns`,
22 `chain`): one line each on why it is a page; retire or turn into board panels.
23 10. **#2697 Tokenise `style/content.ts`** — 23 colours, 31 sizes; the largest debt.
24 11. **#2698 Tokenise `style/components.ts`, `studio/home.ts`, `pages/looks.ts`, `pages/site.ts`.**
25 12. **#2699 Top bar diet** — at most search, New, and the user menu on the left of the fold.
26 13. **#2700 Class rule against DESIGN.md §5** — the baseline holds 619 grandfathered classes, so today the check stops new ones, not old ones.
scripts/design-lint.ts added +139
@@ -0,0 +1,139 @@
1 /**
2 * The design check (#2678): a pull may not add a colour, a font size or a
3 * component class the design system (`docs/DESIGN.md`) does not already have.
4 *
5 * The system is not new; the pages drifted from it. So this is a ratchet, not
6 * a purge: `docs/design/baseline.json` records what the drift is today, per
7 * file, and a pull fails when it goes up. Paying a debt down is always allowed
8 * (and `bun scripts/design-lint.ts --update` writes the lower number down).
9 *
10 * colour a hex, rgb(), hsl() or named-colour literal, or a color-mix() that
11 * mixes no `var(--token)`, in src/web — a colour is a token
12 * size a font-size in px off the type scale (§3)
13 * class a class defined in a `/* css *\/` template that the baseline does
14 * not know — a new component must be named in DESIGN.md first
15 *
16 * Known gaps, so nobody mistakes the gate for more than it is: the class rule
17 * checks the baseline, not DESIGN.md §5 (619 classes are grandfathered until
18 * #2700); colours built in JS strings other than the forms above, `oklch()`,
19 * `lab()` and system colours are not seen; sizes in `rem`/`em` are not
20 * counted. Stricter patterns belong here, with a baseline update.
21 *
22 * The theme palettes (`src/design/themes.ts`) are where colours live and are
23 * not scanned. Plain data in, plain data out: `scan` takes files, `compare`
24 * takes two scans.
25 */
26
27 import { readdirSync, readFileSync, writeFileSync } from "node:fs";
28 import { join, relative } from "node:path";
29
30 /** The type scale in docs/DESIGN.md §3, in px. */
31 export const TYPE_SCALE: readonly number[] = [11.5, 12.5, 13, 14, 15, 16, 20];
32
33 const NAMED = [
34 "black", "white", "red", "green", "blue", "yellow", "orange", "purple", "pink",
35 "gray", "grey", "silver", "gold", "navy", "teal", "cyan", "magenta", "maroon",
36 "olive", "lime", "aqua", "fuchsia", "brown", "crimson", "coral", "indigo", "violet",
37 ];
38 const NAMED_RE = new RegExp(
39 `(?:color|background(?:-color)?|border(?:-[a-z]+)*|fill|stroke|outline(?:-color)?)\\s*:[^;{}]*\\b(?:${NAMED.join("|")})\\b`,
40 "gi",
41 );
42
43 export type Counts = { colour: number; size: number };
44 export type Scan = {
45 files: Record<string, Counts>;
46 classes: string[];
47 };
48
49 const SKIP = /(^|\/)(og\/vendor)\//;
50
51 export function sourceFiles(root: string): string[] {
52 const out: string[] = [];
53 const walk = (dir: string) => {
54 for (const e of readdirSync(dir, { withFileTypes: true })) {
55 const p = join(dir, e.name);
56 if (e.isDirectory()) walk(p);
57 else if (e.name.endsWith(".ts") && !SKIP.test(p)) out.push(p);
58 }
59 };
60 walk(join(root, "src/web"));
61 return out.sort();
62 }
63
64 /** The CSS inside `/* css *\/ \`…\`` templates — the only place a class is defined. */
65 function cssBlocks(text: string): string[] {
66 const out: string[] = [];
67 const re = /\/\* css \*\/\s*`((?:[^`\\]|\\.)*)`/g;
68 for (let m = re.exec(text); m; m = re.exec(text)) out.push(m[1]!);
69 return out;
70 }
71
72 export function countFile(text: string): Counts {
73 const mixes = (text.match(/color-mix\([^;{}]*\)/g) ?? []).filter((m) => !m.includes("var(")).length;
74 const colour =
75 (text.match(/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-zA-Z_-])/g) ?? []).length +
76 (text.match(/\b(?:rgba?|hsla?)\(/g) ?? []).length +
77 cssBlocks(text).reduce((n, css) => n + (css.match(NAMED_RE) ?? []).length, 0) +
78 mixes;
79 let size = 0;
80 for (const m of text.matchAll(/font-size:\s*([0-9.]+)px/g)) {
81 if (!TYPE_SCALE.includes(Number(m[1]))) size++;
82 }
83 return { colour, size };
84 }
85
86 export function classesIn(text: string): string[] {
87 const set = new Set<string>();
88 for (const css of cssBlocks(text)) {
89 const bare = css.replace(/\/\*[\s\S]*?\*\//g, "");
90 // Selectors only: what precedes a `{`, minus declarations.
91 for (const sel of bare.matchAll(/([^{};]+)\{/g)) {
92 for (const c of sel[1]!.matchAll(/(?:^|[\s,>+~(\]:)])\.([a-z][a-z0-9_-]*)/gi)) set.add(c[1]!);
93 }
94 }
95 return [...set];
96 }
97
98 export function scan(root: string): Scan {
99 const files: Record<string, Counts> = {};
100 const classes = new Set<string>();
101 for (const f of sourceFiles(root)) {
102 const text = readFileSync(f, "utf8");
103 const c = countFile(text);
104 if (c.colour || c.size) files[relative(root, f)] = c;
105 for (const k of classesIn(text)) classes.add(k);
106 }
107 return { files, classes: [...classes].sort() };
108 }
109
110 /** What a pull made worse, one line each. Empty means the check passes. */
111 export function compare(base: Scan, now: Scan): string[] {
112 const out: string[] = [];
113 for (const [file, c] of Object.entries(now.files)) {
114 const b = base.files[file] ?? { colour: 0, size: 0 };
115 if (c.colour > b.colour) out.push(`${file}: ${c.colour - b.colour} new colour literal(s) — use a token from themes.ts (var(--…))`);
116 if (c.size > b.size) out.push(`${file}: ${c.size - b.size} new off-scale font-size — the scale is ${TYPE_SCALE.join(", ")}px`);
117 }
118 const known = new Set(base.classes);
119 for (const k of now.classes) {
120 if (!known.has(k)) out.push(`.${k}: a component class the design system does not have — name it in docs/DESIGN.md §5, then run --update`);
121 }
122 return out;
123 }
124
125 const BASELINE = "docs/design/baseline.json";
126
127 if (import.meta.main) {
128 const root = process.cwd();
129 const now = scan(root);
130 if (process.argv.includes("--update")) {
131 writeFileSync(join(root, BASELINE), JSON.stringify(now, null, 2) + "\n");
132 console.log(`wrote ${BASELINE}`);
133 } else {
134 const problems = compare(JSON.parse(readFileSync(join(root, BASELINE), "utf8")), now);
135 for (const p of problems) console.error(p);
136 if (problems.length) process.exit(1);
137 console.log("design: no colour, size or component added outside the system");
138 }
139 }
test/design-lint.test.ts added +32
@@ -0,0 +1,32 @@
1 import { describe, expect, test } from "bun:test";
2 import { readFileSync } from "node:fs";
3 import { classesIn, compare, countFile, scan } from "../scripts/design-lint.ts";
4
5 describe("design check: nothing added outside the system (#2678)", () => {
6 test("the tree is no worse than its baseline", () => {
7 const base = JSON.parse(readFileSync("docs/design/baseline.json", "utf8"));
8 expect(compare(base, scan("."))).toEqual([]);
9 });
10
11 test("counts colour literals and off-scale sizes, not on-scale ones", () => {
12 expect(countFile("a{color:#fff;background:#a1b2c3;border:1px solid rgba(0,0,0,.1)}")).toEqual({ colour: 3, size: 0 });
13 expect(countFile("a{font-size: 13px} b{font-size: 14px} c{font-size: 13.5px} d{font-size: 72px}")).toEqual({ colour: 0, size: 2 });
14 expect(countFile("x = /* css */ `a{color: red; border: 1px solid navy; background: var(--card)}`")).toEqual({ colour: 2, size: 0 });
15 expect(countFile("a{background:color-mix(in srgb, red 10%, white)} b{background:color-mix(in srgb, var(--x) 12%, transparent)}").colour).toBe(1);
16 expect(countFile("a{color:var(--fg)} // issue #2678")).toEqual({ colour: 0, size: 0 });
17 });
18
19 test("finds the classes a css template defines, and only those", () => {
20 const text = "const x = a.ts; const css = /* css */ `.btn:hover, .card > .row-title { color: red }\n.new-thing { top: 0.5px }`; foo.bar";
21 expect(classesIn(text).sort()).toEqual(["btn", "card", "new-thing", "row-title"]);
22 });
23
24 test("fails a pull that adds a colour, a size or a component", () => {
25 const base = { files: { "src/web/x.ts": { colour: 1, size: 1 } }, classes: ["btn"] };
26 expect(compare(base, base)).toEqual([]);
27 expect(compare(base, { files: { "src/web/x.ts": { colour: 0, size: 0 } }, classes: ["btn"] })).toEqual([]);
28 expect(compare(base, { ...base, files: { "src/web/x.ts": { colour: 2, size: 1 } } })).toHaveLength(1);
29 expect(compare(base, { ...base, files: { "src/web/y.ts": { colour: 0, size: 1 } } })).toHaveLength(1);
30 expect(compare(base, { ...base, classes: ["btn", "gizmo"] })[0]).toContain(".gizmo");
31 });
32 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs