milestone 23: put the reset in a cascade layer, guard that it stays there

This commit is contained in:
2026-08-12 23:06:26 +02:00
parent 65f76d4427
commit 9d5120cbad
3 changed files with 227 additions and 98 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "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", "typecheck": "tsc -b",
"lint": "oxlint src vite.config.ts", "lint": "oxlint src vite.config.ts",
"format": "prettier --write .", "format": "prettier --write .",
+81
View File
@@ -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`,
);
+145 -97
View File
@@ -9,116 +9,164 @@
* their font. Deleting this block does not restore browser defaults — it * their font. Deleting this block does not restore browser defaults — it
* silently changes the meaning of every size and spacing value in the app. * 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. * 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.
*/ */
*, @layer reset {
*::before, *,
*::after { *::before,
box-sizing: border-box; *::after {
margin: 0; box-sizing: border-box;
padding: 0; margin: 0;
} padding: 0;
}
html { html {
line-height: 1.5; line-height: 1.5;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
font-family: font-family:
system-ui, system-ui,
-apple-system, -apple-system,
"Segoe UI", "Segoe UI",
Roboto, Roboto,
"Helvetica Neue", "Helvetica Neue",
Arial, Arial,
sans-serif; sans-serif;
} }
/* Headings carry their scale from StyleX, not from the user agent. */ /* Headings carry their scale from StyleX, not from the user agent. */
h1, h1,
h2, h2,
h3, h3,
h4, h4,
h5, h5,
h6 { h6 {
font-size: inherit; font-size: inherit;
font-weight: inherit; font-weight: inherit;
} }
ul, ul,
ol, ol,
menu { menu {
list-style: none; list-style: none;
} }
/* Links opt into colour and underline; the shell's nav wants neither. */ /* Links opt into colour and underline; the shell's nav wants neither. */
a { a {
color: inherit; color: inherit;
text-decoration: inherit; text-decoration: inherit;
} }
code, code,
kbd, kbd,
samp, samp,
pre { pre {
font-size: 1em; font-size: 1em;
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
text-indent: 0; text-indent: 0;
border-color: inherit; border-color: inherit;
} }
/* /*
* Form controls: inherit type and colour, drop the user-agent chrome, and keep * Form controls: inherit type and colour, drop the user-agent chrome, and keep
* `appearance: button` so iOS Safari honours a button's border radius. * `appearance: button` so iOS Safari honours a button's border radius.
*/ */
button, button,
input, input,
select, select,
optgroup, optgroup,
textarea { textarea {
font: inherit; font: inherit;
letter-spacing: inherit; letter-spacing: inherit;
color: inherit; color: inherit;
background-color: transparent; background-color: transparent;
border: 0 solid; border: 0 solid;
border-radius: 0; border-radius: 0;
opacity: 1; opacity: 1;
} }
button, button,
input[type="button"], input[type="button"],
input[type="reset"], input[type="reset"],
input[type="submit"] { input[type="submit"] {
appearance: button; appearance: button;
} }
/* A checkbox keeps its native chrome; the rules above would erase it. */ /* A checkbox keeps its native chrome; the rules above would erase it. */
input[type="checkbox"], input[type="checkbox"],
input[type="radio"] { input[type="radio"] {
appearance: auto; appearance: auto;
} }
::placeholder { /*
opacity: 1; * WebKit lays a date/time input out from its own pseudo-elements, and an
color: color-mix(in oklab, currentcolor 50%, transparent); * 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. */ ::-webkit-datetime-edit {
svg, display: inline-flex;
img, }
video,
canvas {
display: block;
vertical-align: middle;
}
img, ::-webkit-datetime-edit-fields-wrapper {
video { padding: 0;
max-width: 100%; }
height: auto;
}
[hidden] { ::-webkit-datetime-edit,
display: none !important; ::-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;
}
} }