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

A release whose tree covers the page is refused before the swap, and the tick names the rule #11921

Merged opened this

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

Merged

scout/forge:scout/11916-a-release-whose-tree-covers-the-page-is → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/11916-a-release-whose-tree-covers-the-page-is was merged into main as 9640133.

Review

Sign in to review this change.
4 files changed +167 −82
scripts/design-lint.ts +4 −81
@@ -62,6 +62,10 @@
6262 import { readdirSync, readFileSync, writeFileSync } from "node:fs";
6363 import { join, relative } from "node:path";
6464 import { STRANGER_NEVER_SEES } from "../src/design/first_five_minutes.ts";
65 import { cssBlocksAt, scanOverlay, sourceFiles } from "../src/design/overlay.ts";
66
67 // The covered-page rule lives in src/design/ so the release preflight runs it too (#11916).
68 export { matchesClosedOverlay, overlay, overlayClassesIn, type OverlayFinding, scanOverlay, sourceFiles } from "../src/design/overlay.ts";
6569
6670 /** The type scale in docs/DESIGN.md §3, in px. */
6771 export const TYPE_SCALE: readonly number[] = [11.5, 12.5, 13, 14, 15, 16, 20];
@@ -91,34 +95,11 @@ export type Baseline = { files: Record<string, Counts> };
9195 export type Finding = { line: number; kind: "colour" | "size"; text: string; use: string };
9296 export type Scan = Baseline & { classes: string[]; found?: Record<string, Finding[]> };
9397
94 const SKIP = /(^|\/)(og\/vendor|web\/vendor)\//;
95
96 export function sourceFiles(root: string): string[] {
97 const out: string[] = [];
98 const walk = (dir: string) => {
99 for (const e of readdirSync(dir, { withFileTypes: true })) {
100 const p = join(dir, e.name);
101 if (e.isDirectory()) walk(p);
102 else if (e.name.endsWith(".ts") && !SKIP.test(p)) out.push(p);
103 }
104 };
105 walk(join(root, "src/web"));
106 return out.sort();
107 }
108
10998 /** The CSS inside `/* css *\/ \`…\`` templates — the only place a class is defined. */
11099 function cssBlocks(text: string): string[] {
111100 return cssBlocksAt(text).map((b) => b.css);
112101 }
113102
114 /** The same blocks with the offset in `text` where each one's css starts. */
115 function cssBlocksAt(text: string): { css: string; at: number }[] {
116 const out: { css: string; at: number }[] = [];
117 const re = /\/\* css \*\/\s*`((?:[^`\\]|\\.)*)`/g;
118 for (let m = re.exec(text); m; m = re.exec(text)) out.push({ css: m[1]!, at: m.index + m[0].length - 1 - m[1]!.length });
119 return out;
120 }
121
122103 /**
123104 * The text without its comments, so an issue reference (`#599`) is not read
124105 * as a three-digit hex. A line comment starts a line or follows whitespace,
@@ -574,64 +555,6 @@ export function scanStranger(root: string): string[] {
574555 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`));
575556 }
576557
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"];
586 const OPEN_STATE = /\[open\]|:popover-open|:modal|:not\(\s*\[hidden\]\s*\)|:not\(\s*:popover-open\s*\)/;
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
635558 const BASELINE = "docs/design/baseline.json";
636559 const DESIGN = "docs/DESIGN.md";
637560 const SECTIONS = "docs/design/sections";
src/design/overlay.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * The covered-page rule (#11831), shared by the design lint
3 * (`scripts/design-lint.ts`) and the release preflight (#11916), which runs
4 * it over a candidate tree before the swap: a page covered by something
5 * closed never ships, whether it came through a pull or straight to a release.
6 */
7
8 import { readdirSync, readFileSync } from "node:fs";
9 import { join, relative } from "node:path";
10
11 const SKIP = /(^|\/)(og\/vendor|web\/vendor)\//;
12
13 export function sourceFiles(root: string): string[] {
14 const out: string[] = [];
15 const walk = (dir: string) => {
16 for (const e of readdirSync(dir, { withFileTypes: true })) {
17 const p = join(dir, e.name);
18 if (e.isDirectory()) walk(p);
19 else if (e.name.endsWith(".ts") && !SKIP.test(p)) out.push(p);
20 }
21 };
22 walk(join(root, "src/web"));
23 return out.sort();
24 }
25
26 /** Every `/* css *\/ \`…\`` template in `text`, with the offset where each one's css starts. */
27 export function cssBlocksAt(text: string): { css: string; at: number }[] {
28 const out: { css: string; at: number }[] = [];
29 const re = /\/\* css \*\/\s*`((?:[^`\\]|\\.)*)`/g;
30 for (let m = re.exec(text); m; m = re.exec(text)) out.push({ css: m[1]!, at: m.index + m[0].length - 1 - m[1]!.length });
31 return out;
32 }
33
34 /**
35 * `overlay` (#11831): a rule that sets `display` (other than `none`), `visibility: visible`, or `opacity` above 0
36 * with `position: fixed` on a selector that can match a closed `<dialog>`, a `[popover]` or a `[hidden]` element
37 * draws it over the page: the browser hides a closed one with `dialog:not([open]) { display: none }`, and a bare
38 * selector's `display` beats that. 0.7.549 put a black panel over every phone page this way. The selector must
39 * itself require the open state. Read across every media query in every `/* css *\/` template.
40 */
41 export type OverlayFinding = { line: number; selector: string; decl: string };
42 const OVERLAY_CLASSES = ["dialog", "palette", "sheet", "drawer", "toast", "popover", "modal", "overlay", "backdrop", "finder"];
43 const OPEN_STATE = /\[open\]|:popover-open|:modal|:not\(\s*\[hidden\]\s*\)|:not\(\s*:popover-open\s*\)/;
44
45 /** Whether the selector's last compound can be a closed overlay and nothing in it asks for the open state. */
46 export function matchesClosedOverlay(selector: string, extra: readonly string[] = []): boolean {
47 const last = selector.trim().split(/[\s>+~]+/).filter(Boolean).pop() ?? "";
48 if (OPEN_STATE.test(last)) return false; // the last compound only: `.x[open] .toast` can still be closed
49 if (/^dialog\b|\[popover\b|\[hidden\b/.test(last)) return true;
50 const classes = [...last.matchAll(/\.([\w-]+)/g)].map((m) => m[1]!);
51 return classes.some((c) => OVERLAY_CLASSES.includes(c) || extra.includes(c));
52 }
53
54 /** Every class a `<dialog>` or `[popover]` element carries in the source: an overlay is found by its markup, not only by name. */
55 export function overlayClassesIn(text: string): string[] {
56 const out = new Set<string>();
57 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);
58 return [...out];
59 }
60
61 export function overlay(text: string, extra: readonly string[] = []): OverlayFinding[] {
62 const out: OverlayFinding[] = [];
63 for (const { css, at } of cssBlocksAt(text)) {
64 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
65 // Every `{ declarations }` with no nested `{`; an at-rule's own braces never hold declarations directly.
66 for (const m of clean.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
67 const head = m[1]!.trim().split(/[;}]/).pop()!.trim();
68 if (!head || head.startsWith("@")) continue;
69 const body = m[2]!;
70 const decls = [...body.matchAll(/([a-z-]+)\s*:\s*([^;]+)/g)].map((d) => [d[1]!, d[2]!.trim().replace(/\s*!important$/, "")] as const);
71 const get = (k: string) => decls.find((d) => d[0] === k)?.[1];
72 const display = get("display");
73 let bad = "";
74 if (display !== undefined && display !== "none") bad = `display: ${display}`;
75 else if (get("visibility") === "visible") bad = "visibility: visible";
76 else if (get("position") === "fixed" && get("opacity") !== undefined && Number(get("opacity")) > 0) bad = `opacity: ${get("opacity")} with position: fixed`;
77 if (!bad) continue;
78 const line = text.slice(0, at + m.index!).split("\n").length;
79 for (const sel of head.split(",").map((x) => x.trim())) if (matchesClosedOverlay(sel, extra)) out.push({ line, selector: sel, decl: bad });
80 }
81 }
82 return out;
83 }
84
85 export function scanOverlay(root: string): string[] {
86 const files = sourceFiles(root);
87 const extra = [...new Set(files.flatMap((f) => overlayClassesIn(readFileSync(f, "utf8"))))];
88 return files.flatMap((f) =>
89 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)`));
90 }
91

Trailing blank line at end of file; trim it.

src/release/checkout.ts +17 −1
@@ -13,6 +13,7 @@ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, utime
1313 import { join } from "node:path";
1414 import { exportTree } from "../apps/checkout.ts";
1515 import { childEnv } from "../apps/process.ts";
16 import { scanOverlay } from "../design/overlay.ts";
1617 import { isSafeOid } from "../git/guard.ts";
1718 import { err, ok, type Result } from "../kernel/result.ts";
1819
@@ -87,10 +88,25 @@ export function pruneReleases(dataDir: string, keep: number, current: string): s
8788
8889 export type Verdict = { ok: boolean; line: string };
8990
90 /** Run the release's own `--preflight` against this forge's data directory. */
91 /**
92 * The covered-page rule (#11831) over the candidate's own `src/web`, in this
93 * process (#11916): a tree whose stylesheet draws a closed overlay over every
94 * page passes every status probe, so it is refused here, before the swap.
95 */
96 export function coveredPage(dir: string): Verdict {
97 if (!existsSync(join(dir, "src", "web"))) return { ok: true, line: "no src/web" };
98 const found = scanOverlay(dir);
99 if (found.length === 0) return { ok: true, line: "no page covered" };
100 const more = found.length > 1 ? ` (and ${found.length - 1} more)` : "";

Next: found[0].split(" →") parses the lint's human message. Export a function that returns {file,line,selector,decl} and format here.

101 return { ok: false, line: `preflight refused: covered-page rule (#11831): ${found[0]!.split(" →")[0]}${more}`.slice(0, 300) };
102 }
103
104 /** Refuse a tree that covers the page, then run the release's own `--preflight` against this forge's data directory. */
91105 export async function preflightRelease(dir: string, dataDir: string): Promise<Verdict> {
92106 const entry = join(dir, "src", "main.ts");
93107 if (!existsSync(entry)) return { ok: false, line: "preflight failed: the tree has no src/main.ts" };
108 const covered = coveredPage(dir);
109 if (!covered.ok) return covered;
94110 const proc = Bun.spawn([process.execPath, entry, "--preflight"], {
95111 cwd: dir,
96112 env: childEnv({ FORGE_DATA: dataDir, FORGE_APP_URL: "" }),
test/release-preflight-covered.test.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * A release whose tree covers the page is refused before the swap (#11916):
3 * `preflightRelease` runs the covered-page rule (#11831) over the candidate's
4 * `src/web` in process, names the rule and the file, and never starts the
5 * tree's own `--preflight`. Today's tree passes.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { coveredPage, preflightRelease } from "../src/release/checkout.ts";
13
14 let root: string;
15 beforeAll(() => {
16 root = mkdtempSync(join(tmpdir(), "preflight-covered-"));
17 });
18 afterAll(() => rmSync(root, { recursive: true, force: true }));
19
20 /** A stand-in tree whose `--preflight` passes and leaves a mark that it ran. */
21 function tree(name: string, css: string): { dir: string; ran: string } {
22 const dir = join(root, name);
23 const ran = join(dir, "ran");
24 mkdirSync(join(dir, "src", "web", "style"), { recursive: true });
25 writeFileSync(
26 join(dir, "src", "main.ts"),
27 `require("node:fs").writeFileSync(${JSON.stringify(ran)}, "1"); console.log("preflight ok: stand-in");\n`,
28 );
29 writeFileSync(join(dir, "src", "web", "style", "layer.ts"), `export const layer = /* css */ \`\n${css}\n\`;\n`);
30 return { dir, ran };
31 }
32
33 describe("preflight: the covered-page rule (#11916)", () => {
34 test("an always-on full-screen fixed layer is refused with the rule and the file, before the tree runs", async () => {
35 const { dir, ran } = tree("covered", "@media (max-width: 719px) {\n .sheet { display: block; position: fixed; inset: 0; }\n}");
36 const v = await preflightRelease(dir, join(root, "data"));
37 expect(v.ok).toBe(false);
38 expect(v.line).toContain("covered-page rule");
39 expect(v.line).toContain("src/web/style/layer.ts:");
40 expect(v.line).toContain(".sheet");
41 expect(v.line.split("\n")).toHaveLength(1);
42 expect(existsSync(ran)).toBe(false);
43 });
44
45 test("the same layer keyed on the open state passes and the tree's own preflight runs", async () => {
46 const { dir, ran } = tree("open", ".sheet[open] { display: block; position: fixed; inset: 0; }");
47 const v = await preflightRelease(dir, join(root, "data"));
48 expect(v).toEqual({ ok: true, line: "preflight ok: stand-in" });
49 expect(existsSync(ran)).toBe(true);
50 });
51
52 test("today's tree is not covered", () => {
53 expect(coveredPage(join(import.meta.dir, ".."))).toEqual({ ok: true, line: "no page covered" });
54 });
55 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs