diff --git a/web/package.json b/web/package.json index 542385a..aeb405d 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ }, "scripts": { "dev": "vite", - "build": "vite build && node scripts/stamp-dist.mjs", + "build": "vite build && node scripts/assert-css-layers.mjs && node scripts/stamp-dist.mjs", "typecheck": "tsc -b", "lint": "oxlint src vite.config.ts", "format": "prettier --write .", diff --git a/web/scripts/assert-css-layers.mjs b/web/scripts/assert-css-layers.mjs new file mode 100644 index 0000000..c88574e --- /dev/null +++ b/web/scripts/assert-css-layers.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Every rule in the built stylesheet must sit inside a cascade layer +// (milestone 23). Unlayered author CSS outranks every layer whatever its +// selector says, so a single unlayered rule silently beats the StyleX atomic +// rules it was written to sit under. That failure renders wrong and passes +// every other gate: no test asserts computed style, and the bundler is happy. +// This check runs from web/ as part of `npm run build`. + +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets"); + +const sheets = readdirSync(distDir).filter((name) => name.endsWith(".css")); +if (sheets.length === 0) { + console.error("assert-css-layers: no stylesheet in dist/assets — did the build emit one?"); + process.exit(1); +} + +// At-rules that describe a resource or a name rather than styling an element. +// They carry no cascade priority against a layer, so being outside one is +// correct, and StyleX emits `@property` for its custom properties. +const unlayerable = String.raw`@(?:layer|property|keyframes|font-face|counter-style|charset|import)`; + +/** Strip comments, then every balanced block and statement the rule above allows. */ +function outsideLayers(css) { + let rest = css.replace(/\/\*[\s\S]*?\*\//g, ""); + for (;;) { + const at = rest.search(new RegExp(`${unlayerable}[^{;]*\\{`)); + if (at === -1) break; + let depth = 0; + let end = rest.indexOf("{", at); + for (let i = end; i < rest.length; i += 1) { + if (rest[i] === "{") depth += 1; + else if (rest[i] === "}") { + depth -= 1; + if (depth === 0) { + end = i; + break; + } + } + } + rest = rest.slice(0, at) + rest.slice(end + 1); + } + return rest.replace(new RegExp(`${unlayerable}[^;{}]*;`, "g"), ""); +} + +/** + * A rule that only sets custom properties styles nothing on its own — StyleX + * emits its token `:root` block that way, ahead of its layers, and a variable + * is consumed through `var()` rather than competing with a layered rule. + */ +function stylesSomething(body) { + return body + .split(";") + .map((declaration) => declaration.trim()) + .some((declaration) => declaration.length > 0 && !declaration.startsWith("--")); +} + +let failed = false; +for (const sheet of sheets) { + const leftover = outsideLayers(readFileSync(join(distDir, sheet), "utf8")); + for (const [, selector, body] of leftover.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + if (!stylesSomething(body)) continue; + console.error( + `assert-css-layers: ${sheet} styles elements outside every @layer:\n` + + ` ${selector.trim().slice(0, 80)} { ${body.trim().slice(0, 60)} … }`, + ); + failed = true; + break; + } +} + +if (failed) { + console.error("Wrap it in a layer declared before StyleX's, as web/src/styles.css does."); + process.exit(1); +} +console.log( + `every rule in ${sheets.length === 1 ? "the stylesheet" : `${sheets.length} stylesheets`} sits inside a cascade layer`, +); diff --git a/web/src/styles.css b/web/src/styles.css index 11cd86f..e5bc682 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -9,116 +9,164 @@ * their font. Deleting this block does not restore browser defaults — it * silently changes the meaning of every size and spacing value in the app. * Rules are limited to what this app renders; it is not a general reset. + + * The reset lives in its own cascade layer, declared here before StyleX emits + * its own. Layer order is priority order, and unlayered author CSS outranks + * every layer: leaving these rules unlayered silently beat every StyleX rule in + * the app, whatever the selector said. */ -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} +@layer reset { + *, + *::before, + *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } -html { - line-height: 1.5; - -webkit-text-size-adjust: 100%; - font-family: - system-ui, - -apple-system, - "Segoe UI", - Roboto, - "Helvetica Neue", - Arial, - sans-serif; -} + html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + font-family: + system-ui, + -apple-system, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + sans-serif; + } -/* Headings carry their scale from StyleX, not from the user agent. */ -h1, -h2, -h3, -h4, -h5, -h6 { - font-size: inherit; - font-weight: inherit; -} + /* Headings carry their scale from StyleX, not from the user agent. */ + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: inherit; + font-weight: inherit; + } -ul, -ol, -menu { - list-style: none; -} + ul, + ol, + menu { + list-style: none; + } -/* Links opt into colour and underline; the shell's nav wants neither. */ -a { - color: inherit; - text-decoration: inherit; -} + /* Links opt into colour and underline; the shell's nav wants neither. */ + a { + color: inherit; + text-decoration: inherit; + } -code, -kbd, -samp, -pre { - font-size: 1em; -} + code, + kbd, + samp, + pre { + font-size: 1em; + } -table { - border-collapse: collapse; - text-indent: 0; - border-color: inherit; -} + table { + border-collapse: collapse; + text-indent: 0; + border-color: inherit; + } -/* - * Form controls: inherit type and colour, drop the user-agent chrome, and keep - * `appearance: button` so iOS Safari honours a button's border radius. - */ -button, -input, -select, -optgroup, -textarea { - font: inherit; - letter-spacing: inherit; - color: inherit; - background-color: transparent; - border: 0 solid; - border-radius: 0; - opacity: 1; -} + /* + * Form controls: inherit type and colour, drop the user-agent chrome, and keep + * `appearance: button` so iOS Safari honours a button's border radius. + */ + button, + input, + select, + optgroup, + textarea { + font: inherit; + letter-spacing: inherit; + color: inherit; + background-color: transparent; + border: 0 solid; + border-radius: 0; + opacity: 1; + } -button, -input[type="button"], -input[type="reset"], -input[type="submit"] { - appearance: button; -} + button, + input[type="button"], + input[type="reset"], + input[type="submit"] { + appearance: button; + } -/* A checkbox keeps its native chrome; the rules above would erase it. */ -input[type="checkbox"], -input[type="radio"] { - appearance: auto; -} + /* A checkbox keeps its native chrome; the rules above would erase it. */ + input[type="checkbox"], + input[type="radio"] { + appearance: auto; + } -::placeholder { - opacity: 1; - color: color-mix(in oklab, currentcolor 50%, transparent); -} + /* + * WebKit lays a date/time input out from its own pseudo-elements, and an + * empty one comes out shorter than a filled one without these. The query log + * filters are two `datetime-local` inputs sitting in a row of controls, so + * the height has to hold whether or not a value is set. + */ + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } -/* An inline SVG leaves a baseline gap under a full-width chart. */ -svg, -img, -video, -canvas { - display: block; - vertical-align: middle; -} + ::-webkit-datetime-edit { + display: inline-flex; + } -img, -video { - max-width: 100%; - height: auto; -} + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } -[hidden] { - display: none !important; + ::-webkit-datetime-edit, + ::-webkit-datetime-edit-year-field, + ::-webkit-datetime-edit-month-field, + ::-webkit-datetime-edit-day-field, + ::-webkit-datetime-edit-hour-field, + ::-webkit-datetime-edit-minute-field, + ::-webkit-datetime-edit-second-field, + ::-webkit-datetime-edit-millisecond-field, + ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + + ::placeholder { + opacity: 1; + } + + /* + * Safari below 16.4 resolves `color-mix` against the wrong colour here and + * renders the placeholder invisible. The guard admits every engine that + * supports either a non-WebKit feature or one Safari only gained afterwards. + */ + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + + /* An inline SVG leaves a baseline gap under a full-width chart. */ + svg, + img, + video, + canvas { + display: block; + vertical-align: middle; + } + + img, + video { + max-width: 100%; + height: auto; + } + + [hidden] { + display: none !important; + } }