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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11831-the-design-system-cannot-ship-a-page-cov → main 3 commits fork 3 threads · 3 unresolved
4 files
changed +105
−9
docs/DESIGN.md +3
| @@ -11,6 +11,9 @@ | ||
| 11 | 11 | 7. Rows over boxes: a list is rows, a fact is a `dl` row, not a card. |
| 12 | 12 | 8. Plain words, as in `docs/WRITING.md`. |
| 13 | 13 | |
| 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 }`. | |
| 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 | ||
| 14 | 17 | **The first five minutes.** Later work is judged against this one journey, a stranger's from a link to a change live: |
| 15 | 18 | 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. |
| 16 | 19 |
scripts/design-lint.ts +59 −1
| @@ -574,6 +574,64 @@ export function scanStranger(root: string): string[] { | ||
| 574 | 574 | 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`)); |
| 575 | 575 | } |
| 576 | 576 | |
| 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 | ||
| 577 | 635 | const BASELINE = "docs/design/baseline.json"; |
| 578 | 636 | const DESIGN = "docs/DESIGN.md"; |
| 579 | 637 | const SECTIONS = "docs/design/sections"; |
| @@ -590,7 +648,7 @@ if (import.meta.main) { | ||
| 590 | 648 | } else { |
| 591 | 649 | const base: Baseline = JSON.parse(readFileSync(join(root, BASELINE), "utf8")); |
| 592 | 650 | 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)]; | |
| 594 | 652 | for (const p of problems) console.error(p); |
| 595 | 653 | if (problems.length) process.exit(1); |
| 596 | 654 | 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); } | ||
| 790 | 790 | box-shadow: var(--shadow-2); |
| 791 | 791 | } |
| 792 | 792 | .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; } | |
| 794 | 796 | .dialog-head { display: flex; align-items: center; gap: 8px; padding: 14px 16px 0; } |
| 795 | 797 | .dialog-head h2 { flex: 1; } |
| 796 | 798 | .dialog-body { padding: 12px 16px; } |
| @@ -874,12 +876,7 @@ html:has(.palette[open]) { overflow: hidden; } | ||
| 874 | 876 | height: calc(var(--vv-h, 100dvh) - env(safe-area-inset-top, 0px)); |
| 875 | 877 | max-height: calc(var(--vv-h, 100dvh) - env(safe-area-inset-top, 0px)); |
| 876 | 878 | border-radius: 0 0 var(--radius-lg) var(--radius-lg); border-top: 0; border-bottom: 1px solid var(--border); |
| 877 | flex-direction: column; | |
| 878 | 879 | } |
| 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; } | |
| 883 | 880 | .palette .palette-input { flex: none; font-size: 16px; } |
| 884 | 881 | .palette-list { flex: 1; min-height: 0; height: auto; } |
| 885 | 882 | .palette-item { min-height: 44px; } |
| @@ -889,7 +886,7 @@ html:has(.palette[open]) { overflow: hidden; } | ||
| 889 | 886 | } |
| 890 | 887 | |
| 891 | 888 | /* ---- toast / flash ---- */ |
| 892 | .toast { | |
| 889 | .toast:not([hidden]) { | |
| 893 | 890 | display: flex; align-items: flex-start; gap: 8px; |
| 894 | 891 | margin: 16px 0 0; padding: 10px 12px; |
| 895 | 892 | border: 1px solid var(--border); border-radius: var(--radius); |
test/design-lint.test.ts +39 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { describe, expect, test } from "bun:test"; |
| 2 | 2 | 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"; | |
| 4 | 4 | |
| 5 | 5 | /** A DESIGN.md in miniature: the §5 table, a §5d register, and a §6 that names a class too. */ |
| 6 | 6 | const DESIGN = [ |
| @@ -310,3 +310,41 @@ describe("the stranger's words (#11458)", () => { | ||
| 310 | 310 | expect(scanStranger(process.cwd())).toEqual([]); |
| 311 | 311 | }); |
| 312 | 312 | }); |
| 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 | }); |
new above
offline
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.