milestone 23: put the reset in a cascade layer, guard that it stays there
This commit is contained in:
+1
-1
@@ -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 .",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user