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

Design: the class rule checks DESIGN.md section 5, not only the baseline #2707

Merged opened this

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

Merged

mason/forge:mason/2700-design-the-class-rule-checks-design-md-s → main 7 commits fork

  • Merged. mason/2700-design-the-class-rule-checks-design-md-s was merged into main as 63bc627.

Review

Sign in to review this change.
10 files changed +151 −714
README.md +8 −7
@@ -1214,19 +1214,20 @@ forge icon.
12141214
12151215 ```sh
12161216 bun test # protocol-level + adversarial suite
1217 bun scripts/design-lint.ts # no colour, off-scale font-size or component class beyond docs/design/baseline.json
1217 bun scripts/design-lint.ts # no colour or off-scale font-size beyond docs/design/baseline.json, no class docs/DESIGN.md §5 does not name
12181218 bun x tsc --noEmit # zero type errors is the standard
12191219 bun run check # exactly what the forge runs against every push
12201220 bun run check:record # the same, and rewrites scripts/test-times.json
12211221 ```
12221222
12231223 The design check (`test/design-lint.test.ts`, run in the gate) fails a pull that
1224 adds a colour literal, an off-scale `font-size` or a component class beyond
1225 `docs/design/baseline.json`; the message names the file. The way out is to use
1226 a token (`var(--…)`) or a named component from `docs/DESIGN.md`; when a change
1227 lowers the counts or adds a component to DESIGN.md §5, run
1228 `bun scripts/design-lint.ts --update` and commit the baseline. See
1229 `docs/design/plan.md`.
1224 adds a colour literal or an off-scale `font-size` beyond
1225 `docs/design/baseline.json`, or defines a class `docs/DESIGN.md` §5 does not
1226 name; the message names the file or the class. The way out is to use a token
1227 (`var(--…)`) or a component §5 names; a new component goes into the §5 table
1228 first. When a change lowers the counts, run `bun scripts/design-lint.ts
1229 --update` and commit the baseline; when it deletes the last rule for a class
1230 in the §5d register, strike the name there too. See `docs/design/plan.md`.
12301231
12311232 `bun run check` is the gate: the same command `forge.json` declares, so what
12321233 it says here is what the deploy will say. It type-checks once, then runs the
docs/DESIGN.md +32

Diff not shown — a line 2,326 characters wide (limit 2,000). +32

View file
docs/design/baseline.json +1 −630
@@ -16,638 +16,9 @@
1616 "colour": 2,
1717 "size": 0
1818 },
19 "src/web/studio/style.ts": {
20 "colour": 2,
21 "size": 1
22 },
2319 "src/web/style/components.ts": {
2420 "colour": 3,
2521 "size": 2
26 },
27 "src/web/style/content.ts": {
28 "colour": 19,
29 "size": 31
3022 }
31 },
32 "classes": [
33 "about",
34 "act",
35 "add",
36 "agent-brief",
37 "ahead",
38 "app-shelf",
39 "app-strip",
40 "app-tile",
41 "app-tile-body",
42 "app-tile-desc",
43 "app-tile-look",
44 "app-tile-name",
45 "app-url",
46 "ask-acts",
47 "ask-card",
48 "ask-group",
49 "ask-kind",
50 "ask-meta",
51 "ask-more-acts",
52 "ask-pick",
53 "ask-pick-hit",
54 "ask-short",
55 "ask-waiting",
56 "audit",
57 "audit-alarm",
58 "audit-answer",
59 "audit-app",
60 "audit-cells",
61 "audit-change",
62 "audit-h2",
63 "audit-problems",
64 "audit-said",
65 "audit-sum",
66 "audit-trail",
67 "audit-unread",
68 "audit-wide",
69 "avatar",
70 "backups-drill",
71 "backups-error",
72 "backups-now",
73 "backups-restore",
74 "bad",
75 "badge",
76 "bar",
77 "bare",
78 "behind",
79 "bform",
80 "bgrid",
81 "big",
82 "blob-view",
83 "bmanage",
84 "board",
85 "board-about",
86 "board-answer",
87 "board-app",
88 "board-app-mode",
89 "board-app-name",
90 "board-app-nums",
91 "board-app-week",
92 "board-asked",
93 "board-asks",
94 "board-bar",
95 "board-brand",
96 "board-card",
97 "board-clock",
98 "board-count",
99 "board-decision",
100 "board-decision-main",
101 "board-declined",
102 "board-declined-row",
103 "board-declined-says",
104 "board-dot",
105 "board-elapsed",
106 "board-fill",
107 "board-forge",
108 "board-grid",
109 "board-group",
110 "board-h",
111 "board-inferred",
112 "board-key",
113 "board-keys",
114 "board-kind",
115 "board-landed",
116 "board-landed-meta",
117 "board-landed-title",
118 "board-list",
119 "board-live",
120 "board-outcome",
121 "board-p",
122 "board-parked-row",
123 "board-pillar",
124 "board-pillars",
125 "board-pillars-h",
126 "board-quiet",
127 "board-region",
128 "board-run",
129 "board-run-meta",
130 "board-run-title",
131 "board-run-who",
132 "board-scope",
133 "board-spend",
134 "board-spend-detail",
135 "board-state",
136 "board-sub",
137 "board-sub-name",
138 "board-sub-scope",
139 "board-subs",
140 "board-thumb",
141 "board-top",
142 "board-when",
143 "board-window",
144 "board-window-name",
145 "board-windows",
146 "boards-result",
147 "bp-h2",
148 "bp-h3",
149 "bp-w2",
150 "bp-w3",
151 "branch-div",
152 "branch-row",
153 "break",
154 "btn",
155 "btn-icon",
156 "byline",
157 "byline-name",
158 "card",
159 "card-body",
160 "card-foot",
161 "card-head",
162 "cat",
163 "chart",
164 "chart-box",
165 "check",
166 "check-progress",
167 "check-steps",
168 "checks-line",
169 "claim-link-box",
170 "clone",
171 "clone-text",
172 "close-into",
173 "close-reason",
174 "code",
175 "code-hit",
176 "code-layout",
177 "code-line",
178 "code-main",
179 "code-split",
180 "color-field",
181 "comment",
182 "comment-body",
183 "comment-form",
184 "commit-body",
185 "commit-head",
186 "composer",
187 "composer-bar",
188 "connect-more",
189 "connect-path",
190 "count",
191 "counts",
192 "crumbs",
193 "dash",
194 "dc",
195 "dc-review",
196 "dcx",
197 "del",
198 "device-code",
199 "dh",
200 "dialog",
201 "dialog-body",
202 "dialog-foot",
203 "dialog-head",
204 "diff",
205 "diff-body",
206 "diff-file",
207 "diff-head",
208 "diff-note",
209 "diff-path",
210 "diff-stat",
211 "diff-stub",
212 "diff-summary",
213 "diff-table",
214 "diff-viewed",
215 "dl",
216 "dl-review",
217 "dn",
218 "dn-comment",
219 "domain-records",
220 "dot",
221 "edit-row",
222 "editor-commit",
223 "editor-gutter",
224 "editor-head",
225 "editor-hint",
226 "editor-mirror",
227 "editor-path",
228 "editor-preview",
229 "editor-stack",
230 "editor-tab",
231 "editor-target",
232 "editor-wrap",
233 "empty",
234 "empty-title",
235 "env-card",
236 "env-error",
237 "env-facts",
238 "error",
239 "error-note",
240 "everyday-ask",
241 "ex-dir",
242 "ex-empty",
243 "ex-file",
244 "ex-find",
245 "ex-head",
246 "ex-list",
247 "ex-more",
248 "ex-name",
249 "ex-ref",
250 "ex-twist",
251 "explore-filters",
252 "explore-line",
253 "explore-main",
254 "explore-name",
255 "explore-row",
256 "explorer",
257 "explorer-panel",
258 "explorer-toggle",
259 "fcard",
260 "fcard-acts",
261 "fcard-app",
262 "fcard-form",
263 "fcard-head",
264 "fcard-more",
265 "fcard-reason",
266 "fcard-reply",
267 "fcard-text",
268 "fcard-unblock",
269 "fcard-who",
270 "fcard-why",
271 "feed",
272 "feed-head",
273 "fg-s0",
274 "fg-s1",
275 "fg-s2",
276 "fg-s3",
277 "fg-s4",
278 "fg-s5",
279 "fg-s6",
280 "fg-s7",
281 "field",
282 "field-row",
283 "files-head",
284 "filters",
285 "finder",
286 "finder-note",
287 "flag-form",
288 "fleet-assume",
289 "fleet-axis",
290 "fleet-grid",
291 "fleet-legend",
292 "fleet-panel-name",
293 "fleet-pick",
294 "fleet-svg",
295 "fleet-swatch",
296 "fleet-table",
297 "fleet-ticks",
298 "fleet-window",
299 "fleet-windows",
300 "flow-bar",
301 "flow-out",
302 "following",
303 "following-actions",
304 "following-eyebrow",
305 "following-foot",
306 "following-head",
307 "following-sentence",
308 "foot",
309 "forges-sent",
310 "fork-bar",
311 "form-actions",
312 "front-admin",
313 "front-controls",
314 "front-crew",
315 "front-featured",
316 "front-featured-body",
317 "front-featured-look",
318 "front-issues",
319 "front-live",
320 "front-more",
321 "front-pick",
322 "front-pin-any",
323 "front-weights",
324 "frozen",
325 "frozen-form",
326 "frozen-sentence",
327 "grants",
328 "grants-foot",
329 "grow",
330 "handoff",
331 "handoff-links",
332 "headroom",
333 "held-bar",
334 "held-done_on_main",
335 "held-key",
336 "held-open_pull",
337 "held-parked_twice",
338 "held-planner",
339 "held-red_fix",
340 "held-run_cap",
341 "held-running",
342 "held-setting",
343 "held-stalled",
344 "held-startable",
345 "hero",
346 "hero-terminal",
347 "hex",
348 "hide-lg",
349 "hide-sm",
350 "hint",
351 "home-app",
352 "home-apps",
353 "home-apps-verbs",
354 "home-pick",
355 "home-rail",
356 "home-tabs",
357 "ico",
358 "ico-loader",
359 "input",
360 "issue-head",
361 "issue-meta",
362 "js-only",
363 "kv",
364 "label",
365 "limb-confirm",
366 "limb-rows",
367 "limb-why",
368 "lineage",
369 "lineage-children",
370 "lineage-here",
371 "list",
372 "live-note",
373 "live-pill",
374 "live-status",
375 "live-toggle",
376 "ln",
377 "ln-forge",
378 "ln-pulse",
379 "ln-say",
380 "ln-tool",
381 "ln-you",
382 "log-tail-row",
383 "look",
384 "look-attached",
385 "look-attached-phone",
386 "look-attachments",
387 "look-card",
388 "look-desktop",
389 "look-now",
390 "look-of",
391 "look-pair",
392 "look-phone",
393 "look-thumb",
394 "map-ask",
395 "map-autonomy",
396 "map-breath",
397 "map-canvas",
398 "map-card",
399 "map-chart",
400 "map-counts",
401 "map-dot",
402 "map-edge",
403 "map-edge-crit",
404 "map-edge-done",
405 "map-empty",
406 "map-flow",
407 "map-lane",
408 "map-lane-caret",
409 "map-lane-crew",
410 "map-lane-head",
411 "map-lane-name",
412 "map-lane-word",
413 "map-lanes",
414 "map-level",
415 "map-list",
416 "map-main",
417 "map-meta",
418 "map-node",
419 "map-pillars",
420 "map-rule",
421 "map-spend",
422 "map-spend-fill",
423 "map-spend-track",
424 "map-svg",
425 "map-title",
426 "map-tool",
427 "map-tools",
428 "map-who",
429 "map-who-i",
430 "map-who-n",
431 "map-word",
432 "map-wrap",
433 "mark",
434 "mb",
435 "menu",
436 "menu-item",
437 "menu-panel",
438 "menu-sep",
439 "menu-title",
440 "merge-box",
441 "merge-form",
442 "merge-headline",
443 "merge-sub",
444 "mirror-form",
445 "mirrored",
446 "ml",
447 "mono",
448 "ms-actions",
449 "ms-bar",
450 "ms-count",
451 "ms-figures",
452 "ms-head",
453 "ms-limb",
454 "ms-limbs",
455 "ms-more",
456 "ms-move",
457 "ms-name",
458 "ms-planner",
459 "ms-rows",
460 "ms-section",
461 "ms-state",
462 "ms-top",
463 "ms-trim",
464 "ms-weight",
465 "mt",
466 "muted",
467 "name-mark",
468 "narrow",
469 "narrow-wide",
470 "needs-you",
471 "next-crew",
472 "next-step",
473 "next-step-apps",
474 "next-step-main",
475 "next-step-mark",
476 "next-step-title",
477 "next-steps",
478 "no-hits",
479 "nonl",
480 "now",
481 "num",
482 "number",
483 "ok",
484 "origin-chip",
485 "origin-link",
486 "origin-mark",
487 "origin-name",
488 "origin-row",
489 "owner",
490 "page-head",
491 "pager",
492 "palette",
493 "palette-empty",
494 "palette-foot",
495 "palette-group",
496 "palette-heading",
497 "palette-input",
498 "palette-item",
499 "palette-list",
500 "passkey-note",
501 "path-head",
502 "pill",
503 "pinned-failures",
504 "pinned-slot",
505 "policy-line",
506 "preview",
507 "propose-line",
508 "prose",
509 "qf",
510 "quiet-details",
511 "rc",
512 "rc-body",
513 "rc-form",
514 "rc-head",
515 "rc-hidden",
516 "rc-outdated",
517 "rc-resolve",
518 "rc-resolved",
519 "rc-resolved-line",
520 "reduced",
521 "repo-actions",
522 "repo-audience",
523 "repo-desc",
524 "repo-head",
525 "repo-layout",
526 "repo-lineage",
527 "repo-origin",
528 "repo-title",
529 "repo-where",
530 "review-body",
531 "review-card",
532 "review-verdicts",
533 "reviews-chip",
534 "row",
535 "row-actions",
536 "row-lead",
537 "row-main",
538 "row-meta",
539 "row-title",
540 "row-trail",
541 "run-actions",
542 "run-jump",
543 "run-live",
544 "run-log",
545 "run-steer",
546 "run-summary",
547 "search",
548 "seg",
549 "sep",
550 "settings",
551 "settings-main",
552 "settings-nav",
553 "sha",
554 "shelf-desc",
555 "shelf-facts",
556 "shelf-meta",
557 "shell",
558 "shell-wide",
559 "skip",
560 "slash",
561 "small",
562 "spacer",
563 "spend-table",
564 "spend-totals",
565 "split",
566 "sprite",
567 "sr-only",
568 "stack",
569 "stack-tight",
570 "stage-detail",
571 "stage-dot",
572 "stages",
573 "step-state",
574 "steps",
575 "sticky-action",
576 "studio",
577 "studio-ask-actions",
578 "studio-crew",
579 "studio-head",
580 "studio-list",
581 "studio-live",
582 "studio-main",
583 "studio-preview",
584 "studio-preview-head",
585 "studio-rail",
586 "studio-tell",
587 "studio-who",
588 "studio-word",
589 "sub",
590 "swatch",
591 "sys-pair",
592 "ta-c",
593 "ta-r",
594 "table",
595 "table-scroll",
596 "tabs",
597 "tell-here",
598 "terminal-line",
599 "tests-bar",
600 "tests-words",
601 "theme-apply",
602 "theme-option",
603 "tick",
604 "timeline",
605 "tl-dot",
606 "tl-item",
607 "toast",
608 "toast-body",
609 "tok",
610 "tok-attribute",
611 "tok-comment",
612 "tok-constant",
613 "tok-decorator",
614 "tok-deleted",
615 "tok-emphasis",
616 "tok-function",
617 "tok-heading",
618 "tok-inserted",
619 "tok-keyword",
620 "tok-link",
621 "tok-meta",
622 "tok-number",
623 "tok-operator",
624 "tok-plain",
625 "tok-property",
626 "tok-punctuation",
627 "tok-regex",
628 "tok-string",
629 "tok-tag",
630 "tok-type",
631 "tok-variable",
632 "topbar",
633 "topbar-asks",
634 "topbar-search",
635 "topbar-tools",
636 "tree-row",
637 "tree-total",
638 "truncate",
639 "txt",
640 "verdict-stale",
641 "wall-cells",
642 "wd",
643 "where",
644 "where-block",
645 "where-dot",
646 "where-note",
647 "where-pin",
648 "where-place",
649 "where-sep",
650 "who",
651 "wordmark"
652 ]
23 }
65324 }
docs/design/plan.md +6 −5
@@ -1,10 +1,11 @@
11 # Design plan (#2678)
22
33 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.
4 literal or an off-scale `font-size` beyond `docs/design/baseline.json`, nor
5 define a class DESIGN.md §5 does not name. Paying down lowers the baseline
6 (`bun scripts/design-lint.ts --update`, reviewed in the pull) and strikes
7 retired classes from the §5d register; neither ever grows back without a §5
8 entry first.
89
910 Each item is one filed issue, small enough for one run; items 4–9 wait for the screenshot sweep (#2690) in the map.
1011
@@ -23,4 +24,4 @@ Each item is one filed issue, small enough for one run; items 4–9 wait for the
2324 10. **#2697 Tokenise `style/content.ts`** — 23 colours, 31 sizes; the largest debt.
2425 11. **#2698 Tokenise `style/components.ts`, `studio/home.ts`, `pages/looks.ts`, `pages/site.ts`.**
2526 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.
27 13. **#2700 Class rule against DESIGN.md §5** — done: 13 classes no page drew are gone, the other 505 page-local ones are registered in DESIGN.md §5d, and the check reads §5 rather than the baseline. Items 4–12 retire §5d entries as they redo each page.
scripts/design-lint.ts +59 −15
@@ -10,18 +10,19 @@
1010 * colour a hex, rgb(), hsl() or named-colour literal, or a color-mix() that
1111 * mixes no `var(--token)`, in src/web — a colour is a token
1212 * 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
13 * class a class defined in a `/* css *\/` template that DESIGN.md §5 does
14 * not name — a new component is named there first (#2700). §5d
15 * registers the page-local classes that predate the rule; a name
16 * it keeps after its last rule is gone fails too, so it only shrinks
1517 *
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()`,
18 * Known gaps, so nobody mistakes the gate for more than it is: colours built
19 * in JS strings other than the forms above, `oklch()`,
1920 * `lab()` and system colours are not seen; sizes in `rem`/`em` are not
2021 * counted. Stricter patterns belong here, with a baseline update.
2122 *
2223 * The theme palettes (`src/design/themes.ts`) are where colours live and are
2324 * not scanned. Plain data in, plain data out: `scan` takes files, `compare`
24 * takes two scans.
25 * takes the baseline, a scan and the text of DESIGN.md.
2526 */
2627
2728 import { readdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -41,10 +42,8 @@ const NAMED_RE = new RegExp(
4142 );
4243
4344 export type Counts = { colour: number; size: number };
44 export type Scan = {
45 files: Record<string, Counts>;
46 classes: string[];
47 };
45 export type Baseline = { files: Record<string, Counts> };
46 export type Scan = Baseline & { classes: string[] };
4847
4948 const SKIP = /(^|\/)(og\/vendor)\//;
5049
@@ -105,6 +104,44 @@ export function classesIn(text: string): string[] {
105104 return [...set];
106105 }
107106
107 /** The section of DESIGN.md from the heading starting `## <n>.` to the next `## `. */
108 function section(design: string, n: string): string {
109 const start = design.search(new RegExp(`^## ${n.replace(".", "\\.")}`, "m"));
110 if (start < 0) return "";
111 const end = design.slice(start + 1).search(/^## /m);
112 return end < 0 ? design.slice(start) : design.slice(start, start + 1 + end);
113 }
114
115 /** The classes a stretch of DESIGN.md names: a selector's `.name` chain inside
116 * a code span (which, as in Markdown, may wrap a line but not a paragraph),
117 * bare or after an element (`pre.run-log`). A dotted word that is not a
118 * selector — `ui.ts`, `codeberg.org`, `status.reviews` — names nothing. */
119 const SELECTOR = /(?<![\w/.$-])(?:(?:a|button|details|div|fieldset|form|input|label|li|ol|pre|section|select|span|table|textarea|ul)(?=\.))?((?:\.[a-z][a-z0-9_-]*)+)/gi;
120
121 function namedIn(text: string): string[] {
122 const set = new Set<string>();
123 for (const span of text.matchAll(/`((?:[^`\n]|\n(?!\n))+)`/g)) {
124 for (const chain of span[1]!.matchAll(SELECTOR)) {
125 for (const c of chain[1]!.split(".").slice(1)) set.add(c);
126 }
127 }
128 return [...set].sort();
129 }
130
131 /** Every class DESIGN.md §5 names, its subsections and the §5d register included. */
132 export function documentedClasses(design: string): string[] {
133 return namedIn(section(design, "5."));
134 }
135
136 /** The §5d register alone: the page-local classes that predate the rule. */
137 export function registeredClasses(design: string): string[] {
138 const s5 = section(design, "5.");
139 const start = s5.search(/^### 5d\./m);
140 if (start < 0) return [];
141 const end = s5.slice(start).search(/\n##+ /);
142 return namedIn(end < 0 ? s5.slice(start) : s5.slice(start, start + end));
143 }
144
108145 export function scan(root: string): Scan {
109146 const files: Record<string, Counts> = {};
110147 const classes = new Set<string>();
@@ -118,30 +155,37 @@ export function scan(root: string): Scan {
118155 }
119156
120157 /** What a pull made worse, one line each. Empty means the check passes. */
121 export function compare(base: Scan, now: Scan): string[] {
158 export function compare(base: Baseline, now: Scan, design: string): string[] {
122159 const out: string[] = [];
123160 for (const [file, c] of Object.entries(now.files)) {
124161 const b = base.files[file] ?? { colour: 0, size: 0 };
125162 if (c.colour > b.colour) out.push(`${file}: ${c.colour - b.colour} new colour literal(s) — use a token from themes.ts (var(--…))`);
126163 if (c.size > b.size) out.push(`${file}: ${c.size - b.size} new off-scale font-size — the scale is ${TYPE_SCALE.join(", ")}px`);
127164 }
128 const known = new Set(base.classes);
165 const named = new Set(documentedClasses(design));
129166 for (const k of now.classes) {
130 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`);
167 if (!named.has(k)) out.push(`.${k}: a component class the design system does not have — use one docs/DESIGN.md §5 names, or name it there first`);
168 }
169 const defined = new Set(now.classes);
170 for (const k of registeredClasses(design)) {
171 if (!defined.has(k)) out.push(`.${k}: no stylesheet defines it any more — strike it from docs/DESIGN.md §5d`);
131172 }
132173 return out;
133174 }
134175
135176 const BASELINE = "docs/design/baseline.json";
177 const DESIGN = "docs/DESIGN.md";
136178
137179 if (import.meta.main) {
138180 const root = process.cwd();
139181 const now = scan(root);
140182 if (process.argv.includes("--update")) {
141 writeFileSync(join(root, BASELINE), JSON.stringify(now, null, 2) + "\n");
183 const baseline: Baseline = { files: now.files };
184 writeFileSync(join(root, BASELINE), JSON.stringify(baseline, null, 2) + "\n");
142185 console.log(`wrote ${BASELINE}`);
143186 } else {
144 const problems = compare(JSON.parse(readFileSync(join(root, BASELINE), "utf8")), now);
187 const base: Baseline = JSON.parse(readFileSync(join(root, BASELINE), "utf8"));
188 const problems = compare(base, now, readFileSync(join(root, DESIGN), "utf8"));
145189 for (const p of problems) console.error(p);
146190 if (problems.length) process.exit(1);
147191 console.log("design: no colour, size or component added outside the system");
src/web/studio/style.ts +2 −41
@@ -9,28 +9,11 @@
99 * win from: nothing else in the sheet styles a `.studio-*` selector, and the
1010 * few rules that reach a shared class (`.badge`, `.row-lead`, `.env-facts`)
1111 * set properties those components do not.
12 *
13 * `shell` and `rail` hold rules nothing in this directory draws any more,
14 * and `rows` all but two of them. They are kept exactly as they were because
15 * this is a pure move — deleting a rule changes what is served, and that is
16 * its own issue.
1712 */
1813
1914 /** A part as written, without the newlines the template literal adds. */
2015 const part = (css: string): string => css.trim();
2116
22 /** The first studio's own two-column shell, and the preview box it drew. */
23 const shell = part(/* css */ `
24 .studio { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 22px; margin-top: 16px; }
25 @media (max-width: 860px) { .studio { grid-template-columns: 1fr; } }
26 .studio-main { display: grid; gap: 18px; }
27 .studio-preview { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; background: var(--muted); }
28 .studio-preview-head { display: flex; align-items: center; gap: 12px; }
29 .studio-live { font-family: var(--mono); font-size: 11.5px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); }
30 .studio-live[data-state="running"] { color: var(--merged); border-color: var(--merged); }
31 .studio-live[data-state="failed"] { color: var(--danger, #c33); }
32 `);
33
3417 /** The box a person tells the crew from (`app.ts`). */
3518 const tellBox = part(/* css */ `
3619 .studio-tell textarea { min-height: 72px; resize: vertical; }
@@ -59,21 +42,14 @@ const wordBadges = part(/* css */ `
5942 .badge[data-word="working"] svg { animation: spin 1.1s linear infinite; }
6043 `);
6144
62 /** The first studio's word rows, and the word itself where a row's meta
63 * line still carries one (`home.ts`). */
45 /** The word itself where a row's meta line still carries one, and the
46 * shelf row's meta line (`shelf.ts`). */
6447 const rows = part(/* css */ `
65 .studio-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
66 .studio-list li { display: grid; grid-template-columns: 18px 84px minmax(0, 1fr) auto; gap: 10px; align-items: baseline; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 14px; }
67 .studio-list li svg { vertical-align: -3px; }
68 .studio-who { font-weight: 600; }
6948 .studio-word { font-family: var(--mono); font-size: 11.5px; opacity: .8; white-space: nowrap; }
7049 .shelf-meta { display: flex; gap: 0 4px; min-width: 0; }
7150 .shelf-desc { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
7251 .shelf-facts { flex: 0 0 auto; max-width: 100%; }
7352 .row-meta .studio-word { font-family: inherit; font-size: inherit; opacity: 1; font-weight: 560; }
74 .studio-list li[data-word="working"] .studio-word { color: var(--accent); opacity: 1; }
75 .studio-list li[data-word="look"] .studio-word { color: var(--merged); opacity: 1; }
76 .studio-list li[data-word="stopped"] .studio-word { color: var(--danger, #c33); opacity: 1; }
7753 `);
7854
7955 /** The row of controls beside a list row: a stop, a change's buttons. */
@@ -81,16 +57,6 @@ const rowActions = part(/* css */ `
8157 .studio-ask-actions { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
8258 `);
8359
84 /** The first studio's rail, and the crew list it held. */
85 const rail = part(/* css */ `
86 .studio-rail { display: grid; gap: 18px; align-content: start; }
87 .studio-rail h3 { font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; opacity: .7; margin: 0 0 6px; font-weight: 600; }
88 .studio-crew { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; font-size: 13.5px; }
89 .studio-crew li { display: grid; grid-template-columns: 74px 1fr; gap: 8px; }
90 .studio-crew li b { font-weight: 600; }
91 .studio-crew li span { opacity: .85; }
92 `);
93
9460 /** The phone: one document, narrower. Each rule belongs to a component above
9561 * and only ever applies at 600px and under, so nothing changes on a desktop. */
9662 const phone = part(/* css */ `
@@ -105,10 +71,7 @@ const phone = part(/* css */ `
10571 .studio-tell .bar .btn, .studio-ask-actions .btn { min-height: 44px; }
10672 .fcard-acts .btn, .fcard-form .btn { min-height: 44px; }
10773 .studio-ask-actions { display: flex; flex-direction: column; align-items: stretch; white-space: normal; width: 100%; }
108 .studio-list li { grid-template-columns: 18px minmax(0, 1fr); row-gap: 2px; }
109 .studio-list li > :nth-child(n+3) { grid-column: 2; }
11074 .studio-word { white-space: normal; }
111 .studio .row-meta, .studio-list .row-meta { white-space: normal; overflow-wrap: anywhere; }
11275 .shelf-meta { flex-wrap: wrap; }
11376 .shelf-desc { flex: 1 1 100%; }
11477 .studio-tell textarea, .studio-tell .input { font-size: 16px; }
@@ -118,14 +81,12 @@ const phone = part(/* css */ `
11881 import { feedCss } from "./feed_style.ts";
11982
12083 export const studioCss = `@layer components {\n${[
121 shell,
12284 tellBox,
12385 appHead,
12486 justChanged,
12587 wordBadges,
12688 rows,
12789 rowActions,
128 rail,
12990 part(feedCss),
13091 phone,
13192 ].join("\n\n")}\n}`;
src/web/style/base.ts −2
@@ -116,8 +116,6 @@ h3 { font-size: 14px; font-weight: 600; line-height: 1.4; }
116116 }
117117 .wordmark:hover { color: var(--fg); }
118118 .wordmark:hover .mark { transform: rotate(135deg); }
119 .topbar-asks { display: inline-flex; flex: none; text-decoration: none; }
120 .topbar-asks:hover .pill { background: color-mix(in srgb, var(--accent) 24%, transparent); }
121119 .topbar .spacer { flex: 1; }
122120 .topbar-tools { display: flex; align-items: center; gap: 6px; flex: 0 1 auto; min-width: 0; }
123121 /* The page never scrolls sideways. A strip that must — the tabs, a code
src/web/style/content.ts −5
@@ -824,8 +824,6 @@ form.inline { display: inline; margin-left: auto; }
824824 .reviews-chip .ico { width: 14px; height: 14px; }
825825 .reviews-chip .ok { display: inline-flex; align-items: center; gap: 4px; color: var(--success); }
826826 .reviews-chip .bad { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); }
827 .files-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
828 .files-head h2 { margin-bottom: 0; }
829827
830828 /* ---- apps, runs, and the crew ---- */
831829 /* The strip a repo home wears when it runs: state, commit, URL, previews,
@@ -942,7 +940,6 @@ form.inline { display: inline; margin-left: auto; }
942940 .log-tail-row { padding: 0 14px 10px; }
943941 .run-log.log-tail { max-height: 8.5em; min-height: 0; margin: 6px 0 0; font-size: 12.5px; }
944942 .table .truncate { max-width: 34ch; display: inline-block; vertical-align: bottom; }
945 .agent-brief { white-space: pre-wrap; font-size: 13px; color: var(--muted-fg); max-height: 6.5em; overflow: hidden; }
946943
947944 /* ---- proposing a change ---- */
948945 /* The editor's commit block, when the commit is a contribution: the whole
@@ -1184,8 +1181,6 @@ textarea.editor:focus { outline: none; }
11841181 background: var(--card); min-height: 12rem;
11851182 }
11861183 .editor-commit { margin-top: 12px; }
1187 .editor-hint { margin: 6px 2px 0; font-size: 12.5px; color: var(--faint); }
1188 .editor-hint kbd { margin: 0 2px; }
11891184 .editor-target { border: 0; padding: 0; margin: 4px 0 0; }
11901185 .editor-target legend { font-weight: 600; margin-bottom: 6px; }
11911186 .editor-target .check { display: flex; }
test/design-lint.test.ts +42 −8
@@ -1,11 +1,25 @@
11 import { describe, expect, test } from "bun:test";
22 import { readFileSync } from "node:fs";
3 import { classesIn, compare, countFile, scan } from "../scripts/design-lint.ts";
3 import { classesIn, compare, countFile, documentedClasses, registeredClasses, scan } from "../scripts/design-lint.ts";
4
5 /** A DESIGN.md in miniature: the §5 table, a §5d register, and a §6 that names a class too. */
6 const DESIGN = [
7 "## 4. Space",
8 "`.spacing-only` is named before §5.",
9 "## 5. The component vocabulary",
10 "| **button** | `.btn` | in `src/web/ui.ts` |",
11 "| **run log** | `pre.run-log` (`.byline.origin-by`) | at `hello.localhost`, `status.reviews` |",
12 "### 5d. Page-local classes",
13 "- **`pages/x.ts`** — the x page: `.x-card` `.x-wrapped",
14 "` `.x-row`",
15 "## 6. Keyboard",
16 "`.keys-only` is named after §5.",
17 ].join("\n");
418
519 describe("design check: nothing added outside the system (#2678)", () => {
620 test("the tree is no worse than its baseline", () => {
721 const base = JSON.parse(readFileSync("docs/design/baseline.json", "utf8"));
8 expect(compare(base, scan("."))).toEqual([]);
22 expect(compare(base, scan("."), readFileSync("docs/DESIGN.md", "utf8"))).toEqual([]);
923 });
1024
1125 test("counts colour literals and off-scale sizes, not on-scale ones", () => {
@@ -28,12 +42,32 @@ describe("design check: nothing added outside the system (#2678)", () => {
2842 expect(classesIn(text).sort()).toEqual(["btn", "card", "new-thing", "row-title"]);
2943 });
3044
45 test("§5 names a class in a code span, as a selector, wrapped or not — and only §5 does (#2700)", () => {
46 expect(documentedClasses(DESIGN)).toEqual(["btn", "byline", "origin-by", "run-log", "x-card", "x-row", "x-wrapped"]);
47 expect(registeredClasses(DESIGN)).toEqual(["x-card", "x-row", "x-wrapped"]);
48 expect(documentedClasses("## 4. Space\n`.a`")).toEqual([]);
49 });
50
3151 test("fails a pull that adds a colour, a size or a component", () => {
32 const base = { files: { "src/web/x.ts": { colour: 1, size: 1 } }, classes: ["btn"] };
33 expect(compare(base, base)).toEqual([]);
34 expect(compare(base, { files: { "src/web/x.ts": { colour: 0, size: 0 } }, classes: ["btn"] })).toEqual([]);
35 expect(compare(base, { ...base, files: { "src/web/x.ts": { colour: 2, size: 1 } } })).toHaveLength(1);
36 expect(compare(base, { ...base, files: { "src/web/y.ts": { colour: 0, size: 1 } } })).toHaveLength(1);
37 expect(compare(base, { ...base, classes: ["btn", "gizmo"] })[0]).toContain(".gizmo");
52 const base = { files: { "src/web/x.ts": { colour: 1, size: 1 } } };
53 const now = { ...base, classes: ["btn", "run-log", "x-card", "x-row", "x-wrapped"] };
54 expect(compare(base, now, DESIGN)).toEqual([]);
55 expect(compare(base, { ...now, files: { "src/web/x.ts": { colour: 0, size: 0 } } }, DESIGN)).toEqual([]);
56 expect(compare(base, { ...now, files: { "src/web/x.ts": { colour: 2, size: 1 } } }, DESIGN)).toHaveLength(1);
57 expect(compare(base, { ...now, files: { "src/web/y.ts": { colour: 0, size: 1 } } }, DESIGN)).toHaveLength(1);
58 expect(compare(base, { ...now, classes: [...now.classes, "gizmo"] }, DESIGN)[0]).toContain(".gizmo");
59 // Named outside §5 is not named: §4 and §6 are not the vocabulary.
60 expect(compare(base, { ...now, classes: [...now.classes, "keys-only", "spacing-only"] }, DESIGN)).toHaveLength(2);
61 });
62
63 test("fails a register entry whose last rule is gone, so §5d only shrinks (#2700)", () => {
64 const base = { files: {} };
65 const problems = compare(base, { files: {}, classes: ["btn", "x-card", "x-wrapped"] }, DESIGN);
66 expect(problems).toEqual([".x-row: no stylesheet defines it any more — strike it from docs/DESIGN.md §5d"]);
67 });
68
69 test("every class the stylesheets define is one DESIGN.md §5 names (#2700)", () => {
70 const named = new Set(documentedClasses(readFileSync("docs/DESIGN.md", "utf8")));
71 expect(scan(".").classes.filter((k) => !named.has(k))).toEqual([]);
3872 });
3973 });
test/studio-phone.test.ts +1 −1
@@ -6,7 +6,7 @@ describe("the studio at 360px", () => {
66 const phone = studioCss.slice(studioCss.indexOf("@media (max-width: 600px) {\n .studio-head"));
77
88 test("every component the issue names has a phone rule", () => {
9 for (const sel of [".studio-head", ".studio-tell", ".fcard-head", ".studio-ask-actions", ".studio-list li", ".studio-word"]) {
9 for (const sel of [".studio-head", ".studio-tell", ".fcard-head", ".studio-ask-actions", ".studio-word"]) {
1010 expect(phone).toContain(sel);
1111 }
1212 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs