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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11916-a-release-whose-tree-covers-the-page-is → main 1 commit fork 2 threads · 2 unresolved
4 files
changed +167
−82
scripts/design-lint.ts +4 −81
| @@ -62,6 +62,10 @@ | ||
| 62 | 62 | import { readdirSync, readFileSync, writeFileSync } from "node:fs"; |
| 63 | 63 | import { join, relative } from "node:path"; |
| 64 | 64 | 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"; | |
| 65 | 69 | |
| 66 | 70 | /** The type scale in docs/DESIGN.md §3, in px. */ |
| 67 | 71 | 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> }; | ||
| 91 | 95 | export type Finding = { line: number; kind: "colour" | "size"; text: string; use: string }; |
| 92 | 96 | export type Scan = Baseline & { classes: string[]; found?: Record<string, Finding[]> }; |
| 93 | 97 | |
| 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 | ||
| 109 | 98 | /** The CSS inside `/* css *\/ \`…\`` templates — the only place a class is defined. */ |
| 110 | 99 | function cssBlocks(text: string): string[] { |
| 111 | 100 | return cssBlocksAt(text).map((b) => b.css); |
| 112 | 101 | } |
| 113 | 102 | |
| 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 | ||
| 122 | 103 | /** |
| 123 | 104 | * The text without its comments, so an issue reference (`#599`) is not read |
| 124 | 105 | * as a three-digit hex. A line comment starts a line or follows whitespace, |
| @@ -574,64 +555,6 @@ export function scanStranger(root: string): string[] { | ||
| 574 | 555 | 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 | 556 | } |
| 576 | 557 | |
| 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 | ||
| 635 | 558 | const BASELINE = "docs/design/baseline.json"; |
| 636 | 559 | const DESIGN = "docs/DESIGN.md"; |
| 637 | 560 | 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 | ||
src/release/checkout.ts +17 −1
| @@ -13,6 +13,7 @@ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, utime | ||
| 13 | 13 | import { join } from "node:path"; |
| 14 | 14 | import { exportTree } from "../apps/checkout.ts"; |
| 15 | 15 | import { childEnv } from "../apps/process.ts"; |
| 16 | import { scanOverlay } from "../design/overlay.ts"; | |
| 16 | 17 | import { isSafeOid } from "../git/guard.ts"; |
| 17 | 18 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 18 | 19 | |
| @@ -87,10 +88,25 @@ export function pruneReleases(dataDir: string, keep: number, current: string): s | ||
| 87 | 88 | |
| 88 | 89 | export type Verdict = { ok: boolean; line: string }; |
| 89 | 90 | |
| 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. */ | |
| 91 | 105 | export async function preflightRelease(dir: string, dataDir: string): Promise<Verdict> { |
| 92 | 106 | const entry = join(dir, "src", "main.ts"); |
| 93 | 107 | 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; | |
| 94 | 110 | const proc = Bun.spawn([process.execPath, entry, "--preflight"], { |
| 95 | 111 | cwd: dir, |
| 96 | 112 | 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 | }); |
new above
offline
Trailing blank line at end of file; trim it.