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

The design system cannot ship a page covered by something closed: a lint and… #11832

Merged opened this in answer to

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

Merged

scout/forge:scout/11831-the-design-system-cannot-ship-a-page-cov → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/11831-the-design-system-cannot-ship-a-page-cov was merged into main as 9fe1625.

Review

Sign in to review this change.
4 files changed +105 −9
docs/DESIGN.md +3
@@ -11,6 +11,9 @@
1111 7. Rows over boxes: a list is rows, a fact is a `dl` row, not a card.
1212 8. Plain words, as in `docs/WRITING.md`.
1313
14 **Overlays are drawn only when open (#11831).** One rule keyed on the open state (`.dialog[open]`, `.toast:not([hidden])`) gives every overlay its layout; a component adds only what is its own, never a bare `display` on its selector, which beats the browser's `dialog:not([open]) { display: none }`.
outdated

Principle 1: the paragraph says the rule twice (what, then why, then what enforces). It describes a shared rule that does not exist in the code yet. Add the rule, or say only what is enforced.

15 Enforced: `overlay` in `scripts/design-lint.ts` names the selector, judging its last compound, for the known overlay names and every class a `<dialog>` or `[popover]` carries.
16
1417 **The first five minutes.** Later work is judged against this one journey, a stranger's from a link to a change live:
1518 1. They follow a link. 2. They name the forge. 3. They choose a username and password. 4. They say what to build. 5. They see the crew working.
1619
scripts/design-lint.ts +59 −1
@@ -574,6 +574,64 @@ export function scanStranger(root: string): string[] {
574574 return STRANGER_FILES.flatMap((f) => strangerWords(readFileSync(join(root, f), "utf8")).map((w) => `${f}:${w.line} "${w.word}" in "${w.text}" → a stranger is never shown this word (docs/DESIGN.md § The first five minutes): say it in plain words`));
575575 }
576576
577 /**
578 * `overlay` (#11831): a rule that sets `display` (other than `none`), `visibility: visible`, or `opacity` above 0
579 * with `position: fixed` on a selector that can match a closed `<dialog>`, a `[popover]` or a `[hidden]` element
580 * draws it over the page: the browser hides a closed one with `dialog:not([open]) { display: none }`, and a bare
581 * selector's `display` beats that. 0.7.549 put a black panel over every phone page this way. The selector must
582 * itself require the open state. Read across every media query in every `/* css *\/` template.
583 */
584 export type OverlayFinding = { line: number; selector: string; decl: string };
585 const OVERLAY_CLASSES = ["dialog", "palette", "sheet", "drawer", "toast", "popover", "modal", "overlay", "backdrop", "finder"];

A fixed class list is the weak point: a closed overlay named anything else is not caught. See item 2.

586 const OPEN_STATE = /\[open\]|:popover-open|:modal|:not\(\s*\[hidden\]\s*\)|:not\(\s*:popover-open\s*\)/;

OPEN_STATE should be tested on the last compound only (item 3).

587
588 /** Whether the selector's last compound can be a closed overlay and nothing in it asks for the open state. */
589 export function matchesClosedOverlay(selector: string, extra: readonly string[] = []): boolean {
590 const last = selector.trim().split(/[\s>+~]+/).filter(Boolean).pop() ?? "";
591 if (OPEN_STATE.test(last)) return false; // the last compound only: `.x[open] .toast` can still be closed
592 if (/^dialog\b|\[popover\b|\[hidden\b/.test(last)) return true;
593 const classes = [...last.matchAll(/\.([\w-]+)/g)].map((m) => m[1]!);
594 return classes.some((c) => OVERLAY_CLASSES.includes(c) || extra.includes(c));
595 }
596
597 /** Every class a `<dialog>` or `[popover]` element carries in the source: an overlay is found by its markup, not only by name. */
598 export function overlayClassesIn(text: string): string[] {
599 const out = new Set<string>();
600 for (const m of text.matchAll(/<dialog\b[^>]*?\bclass="([^"$]*)"|<[a-z]+\b[^>]*?\bpopover\b[^>]*?\bclass="([^"$]*)"/g)) for (const c of (m[1] ?? m[2] ?? "").split(/\s+/)) if (c) out.add(c);
601 return [...out];
602 }
603
604 export function overlay(text: string, extra: readonly string[] = []): OverlayFinding[] {
605 const out: OverlayFinding[] = [];
606 for (const { css, at } of cssBlocksAt(text)) {
607 const clean = css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/\\`/g, " `").replace(/\$\{[^{}]*\}/g, (m) => " ".repeat(m.length)); // a `${x}` is blanked, so it cannot break the scan
608 // Every `{ declarations }` with no nested `{`; an at-rule's own braces never hold declarations directly.
609 for (const m of clean.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
610 const head = m[1]!.trim().split(/[;}]/).pop()!.trim();
611 if (!head || head.startsWith("@")) continue;
612 const body = m[2]!;
613 const decls = [...body.matchAll(/([a-z-]+)\s*:\s*([^;]+)/g)].map((d) => [d[1]!, d[2]!.trim().replace(/\s*!important$/, "")] as const);
614 const get = (k: string) => decls.find((d) => d[0] === k)?.[1];
615 const display = get("display");
616 let bad = "";
617 if (display !== undefined && display !== "none") bad = `display: ${display}`;
618 else if (get("visibility") === "visible") bad = "visibility: visible";
619 else if (get("position") === "fixed" && get("opacity") !== undefined && Number(get("opacity")) > 0) bad = `opacity: ${get("opacity")} with position: fixed`;
620 if (!bad) continue;
621 const line = text.slice(0, at + m.index!).split("\n").length;
622 for (const sel of head.split(",").map((x) => x.trim())) if (matchesClosedOverlay(sel, extra)) out.push({ line, selector: sel, decl: bad });
623 }
624 }
625 return out;
626 }
627
628 export function scanOverlay(root: string): string[] {
629 const files = sourceFiles(root);
630 const extra = [...new Set(files.flatMap((f) => overlayClassesIn(readFileSync(f, "utf8"))))];
631 return files.flatMap((f) =>
632 overlay(readFileSync(f, "utf8"), extra).map((o) => `${relative(root, f)}:${o.line} ${o.selector} { ${o.decl} } → this selector can match a closed dialog, popover or [hidden] element and would draw it over the page: write ${o.selector}[open] (or :popover-open, :not([hidden])), or move the layout into the shared open-state rule (docs/DESIGN.md, overlays)`));
633 }
634
577635 const BASELINE = "docs/design/baseline.json";
578636 const DESIGN = "docs/DESIGN.md";
579637 const SECTIONS = "docs/design/sections";
@@ -590,7 +648,7 @@ if (import.meta.main) {
590648 } else {
591649 const base: Baseline = JSON.parse(readFileSync(join(root, BASELINE), "utf8"));
592650 const debt: QuietDebt = JSON.parse(readFileSync(join(root, QUIET_DEBT), "utf8"));
593 const problems = [...compare(base, now, designText(root), areaFiles(root)), ...compareQuiet(debt, scanQuiet(root)), ...scanBanner(root), ...scanCards(root), ...scanStranger(root)];
651 const problems = [...compare(base, now, designText(root), areaFiles(root)), ...compareQuiet(debt, scanQuiet(root)), ...scanBanner(root), ...scanOverlay(root), ...scanCards(root), ...scanStranger(root)];
594652 for (const p of problems) console.error(p);
595653 if (problems.length) process.exit(1);
596654 console.log("design: no colour, size or component added outside the system");
src/web/style/components.ts +4 −7
@@ -790,7 +790,9 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
790790 box-shadow: var(--shadow-2);
791791 }
792792 .dialog::backdrop { background: color-mix(in srgb, var(--bg) 62%, #0008); backdrop-filter: blur(3px); }
793 .dialog[open] { animation: pop 140ms var(--ease) both; }
793 /* The one open-state rule (#11831): an overlay is laid out only while open, and every overlay
794 (dialog, palette) gets its layout here; a component adds only what is its own. */
795 .dialog[open] { display: flex; flex-direction: column; animation: pop 140ms var(--ease) both; }
794796 .dialog-head { display: flex; align-items: center; gap: 8px; padding: 14px 16px 0; }
795797 .dialog-head h2 { flex: 1; }
796798 .dialog-body { padding: 12px 16px; }
@@ -874,12 +876,7 @@ html:has(.palette[open]) { overflow: hidden; }
874876 height: calc(var(--vv-h, 100dvh) - env(safe-area-inset-top, 0px));
875877 max-height: calc(var(--vv-h, 100dvh) - env(safe-area-inset-top, 0px));
876878 border-radius: 0 0 var(--radius-lg) var(--radius-lg); border-top: 0; border-bottom: 1px solid var(--border);
877 flex-direction: column;
878879 }
879 /* Only an open palette is laid out. \`display\` on the bare selector showed the
880 closed dialog over every page on a phone (2026-11-12). */
881 .palette:not([open]) { display: none; }
882 .palette[open] { display: flex; }
883880 .palette .palette-input { flex: none; font-size: 16px; }
884881 .palette-list { flex: 1; min-height: 0; height: auto; }
885882 .palette-item { min-height: 44px; }
@@ -889,7 +886,7 @@ html:has(.palette[open]) { overflow: hidden; }
889886 }
890887
891888 /* ---- toast / flash ---- */
892 .toast {
889 .toast:not([hidden]) {
893890 display: flex; align-items: flex-start; gap: 8px;
894891 margin: 16px 0 0; padding: 10px 12px;
895892 border: 1px solid var(--border); border-radius: var(--radius);
test/design-lint.test.ts +39 −1
@@ -1,6 +1,6 @@
11 import { describe, expect, test } from "bun:test";
22 import { readdirSync, readFileSync } from "node:fs";
3 import { areaFiles, classesIn, compare, compareQuiet, quiet, scanQuiet, scanCards, scanStranger, strangerWords, STRANGER_FILES, countFile, designText, documentedClasses, locate, loneSentenceCards, registeredClasses, scan, sectionLinks, webCss } from "../scripts/design-lint.ts";
3 import { areaFiles, classesIn, compare, compareQuiet, quiet, scanQuiet, scanCards, scanStranger, strangerWords, STRANGER_FILES, countFile, designText, documentedClasses, locate, loneSentenceCards, registeredClasses, overlay, overlayClassesIn, scanOverlay, scan, sectionLinks, webCss } from "../scripts/design-lint.ts";
44
55 /** A DESIGN.md in miniature: the §5 table, a §5d register, and a §6 that names a class too. */
66 const DESIGN = [
@@ -310,3 +310,41 @@ describe("the stranger's words (#11458)", () => {
310310 expect(scanStranger(process.cwd())).toEqual([]);
311311 });
312312 });
313
314 describe("overlay: nothing closed is drawn (#11831)", () => {
315 const css = (body: string) => `export const c = /* css */ \`${body}\`;`;
316 test("bare .palette display inside a media query is named", () => {
317 const f = overlay(css("@media (max-width: 719px) {\n .palette { display: flex; }\n}"));
318 expect(f.map((x) => x.selector)).toEqual([".palette"]);
319 expect(f[0]!.decl).toBe("display: flex");
320 });
321 test("selectors that require the open state, and display none, pass", () => {
322 expect(overlay(css(".palette[open] { display: flex; } .palette:not([open]) { display: none; } dialog:modal { display: grid } .x:not([hidden]) { display: flex }"))).toEqual([]);
323 expect(overlay(css(".palette-item[hidden] { display: none; }"))).toEqual([]);
324 });
325 test("dialog, [popover] and [hidden] with display, visibility or fixed opacity are named", () => {
326 const f = overlay(css("dialog { display: block } [popover] { visibility: visible } .row[hidden] { display: flex } .sheet { position: fixed; opacity: 1 }"));
327 expect(f.length).toBe(4);
328 });
329 test("ordinary components are left alone", () => {
330 expect(overlay(css(".dialog-foot { display: flex } .card { display: flex }"))).toEqual([]);
331 });
332 test("an unlisted class is caught once a <dialog> or [popover] carries it", () => {
333 const html = '<dialog class="dialog zorp" id="z"></dialog><div popover class="flyout"></div>';
334 const extra = overlayClassesIn(html);
335 expect(extra.sort()).toEqual(["dialog", "flyout", "zorp"]);
336 expect(overlay(css(".zorp { display: flex } .flyout { display: grid }"), extra).length).toBe(2);
337 expect(overlay(css(".zorp { display: flex }"))).toEqual([]);
338 });
339 test("the open state must be on the last compound, not an ancestor", () => {
340 expect(overlay(css(".x[open] .toast { display: flex }")).map((x) => x.selector)).toEqual([".x[open] .toast"]);
341 expect(overlay(css(".toast:not([hidden]) { display: flex } .x .palette[open] { display: flex }"))).toEqual([]);
342 });
343 test("an interpolated ${...} is blanked and does not hide the rule after it", () => {
344 const f = overlay(css(".a { width: ${1 + 1}px; } .palette { display: flex }"));
345 expect(f.map((x) => x.selector)).toEqual([".palette"]);
346 });
347 test("today's stylesheets pass", () => {
348 expect(scanOverlay(process.cwd())).toEqual([]);
349 });
350 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs