Fail the gate on a stylesheet feature the browser floor lacks, unless an @supports guards it #197

Merged
lilleman merged 5 commits from item-32 into main 2026-10-31 12:17:50 +01:00
10 changed files with 424 additions and 89 deletions
Showing only changes of commit 7ff8140e07 - Show all commits
+7
View File
@@ -6,6 +6,13 @@ at boot. Entries start at 0.3.0.
## Unreleased ## Unreleased
### The tests fail on a stylesheet feature the browser floor lacks
Every stylesheet in the repo, the examples' included, is checked against Chromium 87, Safari 15 and
Firefox 115 using MDN's compat data; a feature one of them lacks passes only inside an `@supports`
that names it. The core stylesheets now guard each such feature, which those engines skip instead
of misreading. Pages render as before in current browsers.
### Every release reaches Docker Hub, and the Hub page has a short description ### Every release reaches Docker Hub, and the Hub page has a short description
Releases 0.4.1 to 0.4.20 were tagged in git but never published to Docker Hub or as semver tags in Releases 0.4.1 to 0.4.20 were tagged in git but never published to Docker Hub or as semver tags in
+5 -3
View File
@@ -1059,9 +1059,11 @@ Chromium 87, Safari 15 and Firefox 115, the engines in years-old TVs, kiosks and
features [Baseline Widely available](https://web-platform-dx.github.io/baseline/) (in every features [Baseline Widely available](https://web-platform-dx.github.io/baseline/) (in every
major engine for 30 months) may shape how a page looks; anything newer, like popovers or anchor major engine for 30 months) may shape how a page looks; anything newer, like popovers or anchor
positioning, only enhances. A feature the floor engines lack, such as `:has()`, may positioning, only enhances. A feature the floor engines lack, such as `:has()`, may
decorate a page but never decide whether something shows or can be read. A colour is hex, `rgb()` or decorate a page but never decide whether something shows or can be read. The tests hold every
`hsl()`, never `oklch()`: a custom property holding it passes unchecked, and the `var()` reading it stylesheet in `public/` and `examples/` to this floor, against MDN's compat data: a feature a floor
then renders transparent. engine lacks fails them unless it sits inside an `@supports` whose condition names it
(`src/ui/stylesheet-floor.ts` lists what it checks). A plugin stylesheet mounted from outside the
repo is not checked.
**A field whose options depend on another field is asked for in steps, one GET form each**, so every **A field whose options depend on another field is asked for in steps, one GET form each**, so every
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made. choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
+17
View File
@@ -209,3 +209,20 @@ Read most (opens and searches):
- `examples/plugins/admin/i18n/en-US.ts` 2 - `examples/plugins/admin/i18n/en-US.ts` 2
- `CHANGELOG.md` 1 - `CHANGELOG.md` 1
- `examples/plugins/admin/admin-groups.test.ts` 1 - `examples/plugins/admin/admin-groups.test.ts` 1
## Reading cost, chunk c90b25a50479, finished 2026-10-31T10:40:23Z
90935 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 9
- `todo.md` 5
- `AGENTS.md` 3
- `CHANGELOG.md` 1
- `package.json` 1
- `registry-cleanup/cleanup.ts` 1
- `release-tooling/dockerhub-overview.md.tmpl` 1
- `release-tooling/dockerhub-overview.test.ts` 1
- `release-tooling/dockerhub-overview.ts` 1
- `src/ci-gate.test.ts` 1
+8
View File
@@ -12,6 +12,7 @@
"postgres": "3.4.9" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@mdn/browser-compat-data": "8.1.7",
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "24.19.2", "@types/node": "24.19.2",
"acorn": "8.19.0", "acorn": "8.19.0",
@@ -30,6 +31,13 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@mdn/browser-compat-data": {
"version": "8.1.7",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-8.1.7.tgz",
"integrity": "sha512-TJ+rX530bFoE8678PuH2gjCSzwpBcaLlcElRhnnxFdkLHHT1clUAxtjImPFZDAlWQ005px9xaJ4Nd+aQp/rylQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/@types/ejs": { "node_modules/@types/ejs": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz",
+1
View File
@@ -22,6 +22,7 @@
"postgres": "3.4.9" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@mdn/browser-compat-data": "8.1.7",
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "24.19.2", "@types/node": "24.19.2",
"acorn": "8.19.0", "acorn": "8.19.0",
+7 -3
View File
@@ -1,9 +1,11 @@
.auth-stage { .auth-stage {
min-height: 100dvh;
display: grid; place-items: center; display: grid; place-items: center;
padding: 40px 20px 96px; padding: 40px 20px 96px;
background: var(--bg); background: var(--bg);
} }
@supports (min-height: 100dvh) {
.auth-stage { min-height: 100dvh; }
}
.auth { .auth {
width: 100%; max-width: 408px; width: 100%; max-width: 408px;
display: flex; flex-direction: column; gap: 22px; display: flex; flex-direction: column; gap: 22px;
@@ -103,8 +105,10 @@
.input:focus { outline: none; border-color: var(--accent); .input:focus { outline: none; border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-bg); } box-shadow: 0 0 0 2px var(--accent-bg); }
/* gentle validation feedback only after the user has interacted */ /* gentle validation feedback only after the user has interacted */
.input:user-invalid { border-color: var(--neg); } @supports selector(:user-invalid) {
.input:user-invalid:focus { box-shadow: 0 0 0 2px var(--neg-bg); } .input:user-invalid { border-color: var(--neg); }
.input:user-invalid:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
}
.field-hint { font-size: var(--fz-xs); color: var(--text-faint); } .field-hint { font-size: var(--fz-xs); color: var(--text-faint); }
+108 -67
View File
@@ -71,9 +71,24 @@
line-height: 1.45; line-height: 1.45;
} }
/* shared DARK mapping, applied to Auto-via-OS and forced-dark */ @supports selector(:has(a)) {
@media (prefers-color-scheme: dark) { /* shared DARK mapping, applied to Auto-via-OS and forced-dark */
:root:not(:has(#theme-light:checked)):not(:has(#theme-dark:checked)) { @media (prefers-color-scheme: dark) {
:root:not(:has(#theme-light:checked)):not(:has(#theme-dark:checked)) {
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
--border:var(--d-border); --border-2:var(--d-border-2);
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
--info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd);
color-scheme: dark;
}
}
/* forced DARK (overrides everything) */
html:has(#theme-dark:checked) {
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3); --bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
--border:var(--d-border); --border-2:var(--d-border-2); --border:var(--d-border); --border-2:var(--d-border-2);
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint); --text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
@@ -84,32 +99,19 @@
--info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd); --info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd);
color-scheme: dark; color-scheme: dark;
} }
}
/* forced DARK (overrides everything) */ /* forced LIGHT (overrides the OS-dark media query) */
html:has(#theme-dark:checked) { html:has(#theme-light:checked) {
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3); --bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
--border:var(--d-border); --border-2:var(--d-border-2); --border:var(--l-border); --border-2:var(--l-border-2);
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint); --text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent); --accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd); --pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd); --neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd); --warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
--info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd); --info:var(--l-info); --info-bg:var(--l-info-bg); --info-bd:var(--l-info-bd);
color-scheme: dark; color-scheme: light;
} }
/* forced LIGHT (overrides the OS-dark media query) */
html:has(#theme-light:checked) {
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
--border:var(--l-border); --border-2:var(--l-border-2);
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
--info:var(--l-info); --info-bg:var(--l-info-bg); --info-bd:var(--l-info-bd);
color-scheme: light;
} }
/* ---------- 2. RESET ---------------------------------------- */ /* ---------- 2. RESET ---------------------------------------- */
@@ -122,10 +124,12 @@ a { color: inherit; text-decoration: none; }
summary::-webkit-details-marker { display: none; } summary::-webkit-details-marker { display: none; }
summary { list-style: none; cursor: pointer; } summary { list-style: none; cursor: pointer; }
:focus-visible { @supports selector(:focus-visible) {
outline: 2px solid var(--focus); :focus-visible {
outline-offset: 1px; outline: 2px solid var(--focus);
border-radius: 3px; outline-offset: 1px;
border-radius: 3px;
}
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; } .sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; }
@@ -153,7 +157,9 @@ summary { list-style: none; cursor: pointer; }
.app { .app {
display: grid; display: grid;
grid-template-columns: var(--nav-w) minmax(0, 1fr); grid-template-columns: var(--nav-w) minmax(0, 1fr);
min-height: 100dvh; }
@supports (min-height: 100dvh) {
.app { min-height: 100dvh; }
} }
/* ---------- 4. SIDEBAR -------------------------------------- */ /* ---------- 4. SIDEBAR -------------------------------------- */
@@ -219,7 +225,10 @@ summary { list-style: none; cursor: pointer; }
min-height: 30px; padding: 4px 8px; min-height: 30px; padding: 4px 8px;
border-radius: var(--radius); border-radius: var(--radius);
color: var(--text-muted); font-size: var(--fz); color: var(--text-muted); font-size: var(--fz);
position: relative; user-select: none; position: relative;
}
@supports (user-select: none) {
.nav-self { user-select: none; }
} }
a.nav-self:hover { background: var(--surface-2); color: var(--text); } a.nav-self:hover { background: var(--surface-2); color: var(--text); }
.nav-self .ico { color: var(--text-faint); } .nav-self .ico { color: var(--text-faint); }
@@ -277,12 +286,14 @@ span.nav-self { cursor: default; } /* static / non-clickable */
} }
.theme-switch label:hover { color: var(--text); } .theme-switch label:hover { color: var(--text); }
.theme-switch input { position: absolute; opacity: 0; inset: 0; cursor: pointer; } .theme-switch input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.theme-switch label:has(input:checked) { @supports selector(:has(:focus-visible)) {
background: var(--surface); color: var(--text); .theme-switch label:has(input:checked) {
box-shadow: 0 1px 2px rgba(0,0,0,.06); font-weight: 550; background: var(--surface); color: var(--text);
} box-shadow: 0 1px 2px rgba(0,0,0,.06); font-weight: 550;
.theme-switch label:has(input:focus-visible) { }
outline: 2px solid var(--focus); outline-offset: 1px; .theme-switch label:has(input:focus-visible) {
outline: 2px solid var(--focus); outline-offset: 1px;
}
} }
/* Without :has() every branch is open and every theme rule is inert: neither control does anything. /* Without :has() every branch is open and every theme rule is inert: neither control does anything.
@@ -414,7 +425,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
position: relative; display: inline-flex; align-items: center; position: relative; display: inline-flex; align-items: center;
} }
.select select { .select select {
appearance: none; height: 30px; padding: 0 26px 0 9px; height: 30px; padding: 0 26px 0 9px;
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius); border: 1px solid var(--border-2); border-radius: var(--radius);
font: inherit; font-size: var(--fz-sm); cursor: pointer; font: inherit; font-size: var(--fz-sm); cursor: pointer;
@@ -424,8 +435,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
width: 7px; height: 7px; border-right: 1.5px solid var(--text-faint); width: 7px; height: 7px; border-right: 1.5px solid var(--text-faint);
border-bottom: 1.5px solid var(--text-faint); transform: translateY(-2px) rotate(45deg); border-bottom: 1.5px solid var(--text-faint); transform: translateY(-2px) rotate(45deg);
} }
.select select:focus-visible { border-color: var(--accent); outline: none; @supports (appearance: none) {
box-shadow: 0 0 0 2px var(--accent-bg); } .select select { appearance: none; }
}
@supports selector(:focus-visible) {
.select select:focus-visible { border-color: var(--accent); outline: none;
box-shadow: 0 0 0 2px var(--accent-bg); }
}
/* segmented toggle (radios) */ /* segmented toggle (radios) */
.segmented { .segmented {
@@ -440,11 +456,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
} }
.segmented label:hover { color: var(--text); } .segmented label:hover { color: var(--text); }
.segmented input { position: absolute; opacity: 0; inset: 0; cursor: pointer; } .segmented input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.segmented label:has(input:checked) { @supports selector(:has(:focus-visible)) {
background: var(--surface); color: var(--text); font-weight: 550; .segmented label:has(input:checked) {
box-shadow: 0 1px 2px rgba(0,0,0,.06); background: var(--surface); color: var(--text); font-weight: 550;
box-shadow: 0 1px 2px rgba(0,0,0,.06);
}
.segmented label:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
} }
.segmented label:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
.seg-count { font-size: var(--fz-xs); color: var(--text-faint); } .seg-count { font-size: var(--fz-xs); color: var(--text-faint); }
/* chips (multi-select checkboxes) */ /* chips (multi-select checkboxes) */
@@ -460,12 +478,14 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.chip input { position: absolute; opacity: 0; inset: 0; cursor: pointer; } .chip input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.chip .chip-dot { width: 7px; height: 7px; border-radius: 50%; .chip .chip-dot { width: 7px; height: 7px; border-radius: 50%;
background: var(--border-2); } background: var(--border-2); }
.chip:has(input:checked) { @supports selector(:has(:focus-visible)) {
background: var(--accent-bg); border-color: var(--accent-bd); .chip:has(input:checked) {
color: var(--text); font-weight: 550; background: var(--accent-bg); border-color: var(--accent-bd);
color: var(--text); font-weight: 550;
}
.chip:has(input:checked) .chip-dot { background: var(--accent); }
.chip:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
} }
.chip:has(input:checked) .chip-dot { background: var(--accent); }
.chip:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
/* date range */ /* date range */
.daterange { .daterange {
@@ -486,8 +506,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
display: inline-flex; align-items: center; gap: 7px; display: inline-flex; align-items: center; gap: 7px;
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer; font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer;
} }
.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent); .check input, .radio input { width: 15px; height: 15px; margin: 0; cursor: pointer; }
margin: 0; cursor: pointer; } @supports (accent-color: auto) {
.check input, .radio input { accent-color: var(--accent); }
}
.check:hover, .radio:hover { color: var(--text); } .check:hover, .radio:hover { color: var(--text); }
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the /* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
@@ -495,7 +517,9 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } .check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.check-group .check { align-items: baseline; } .check-group .check { align-items: baseline; }
.check-group .check input:disabled { cursor: default; } .check-group .check input:disabled { cursor: default; }
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; } @supports selector(:has(a)) {
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
}
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); } .check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover] /* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
@@ -506,17 +530,25 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.menu { display: inline-flex; } .menu { display: inline-flex; }
.menu-pop { .menu-pop {
position: absolute; inset: auto; margin: 6px 0 0; position: absolute; inset: auto; margin: 6px 0 0;
position-anchor: auto;
position-try-fallbacks: flip-block, flip-inline;
top: anchor(bottom); right: anchor(right);
min-width: 210px; max-width: min(320px, 92vw); padding: 6px; min-width: 210px; max-width: min(320px, 92vw); padding: 6px;
max-height: 60vh; overflow-y: auto; overflow-wrap: anywhere; max-height: 60vh; overflow-y: auto;
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius); border: 1px solid var(--border-2); border-radius: var(--radius);
box-shadow: 0 8px 28px rgba(0,0,0,.16); box-shadow: 0 8px 28px rgba(0,0,0,.16);
} }
.menu-pop.left { right: auto; left: anchor(left); } .menu-pop.up { margin: 0 0 6px; }
.menu-pop.up { top: auto; bottom: anchor(top); margin: 0 0 6px; } @supports (overflow-wrap: anywhere) {
.menu-pop { overflow-wrap: anywhere; }
}
@supports (position-anchor: auto) and (position-try-fallbacks: flip-block, flip-inline) and (top: anchor(bottom)) {
.menu-pop {
position-anchor: auto;
position-try-fallbacks: flip-block, flip-inline;
top: anchor(bottom); right: anchor(right);
}
.menu-pop.left { right: auto; left: anchor(left); }
.menu-pop.up { top: auto; bottom: anchor(top); }
}
/* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its /* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its
trigger rather than stranding Sign out and the language picker behind a button that does nothing. trigger rather than stranding Sign out and the language picker behind a button that does nothing.
The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes — The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes —
@@ -547,7 +579,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.menu-check { display: flex; align-items: center; gap: 9px; width: 100%; .menu-check { display: flex; align-items: center; gap: 9px; width: 100%;
padding: 6px 8px; border-radius: 4px; font-size: var(--fz-sm); cursor: pointer; } padding: 6px 8px; border-radius: 4px; font-size: var(--fz-sm); cursor: pointer; }
.menu-check:hover { background: var(--surface-2); } .menu-check:hover { background: var(--surface-2); }
.menu-check input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; } .menu-check input { width: 15px; height: 15px; margin: 0; }
@supports (accent-color: auto) {
.menu-check input { accent-color: var(--accent); }
}
/* active filter pills */ /* active filter pills */
.active-pills { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } .active-pills { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
@@ -589,11 +624,15 @@ table.table {
} }
.table tbody th { font-weight: inherit; text-align: left; } /* row-header cell, not bold/centered */ .table tbody th { font-weight: inherit; text-align: left; } /* row-header cell, not bold/centered */
.table tbody tr:hover td, .table tbody tr:hover th { background: var(--surface-2); } .table tbody tr:hover td, .table tbody tr:hover th { background: var(--surface-2); }
.table tbody tr:has(.row-select:checked) td, @supports selector(:has(a)) {
.table tbody tr:has(.row-select:checked) th { background: var(--accent-bg); } .table tbody tr:has(.row-select:checked) td,
.table tbody tr:has(.row-select:checked) th { background: var(--accent-bg); }
}
.col-check { width: 36px; text-align: center; padding: 0; } .col-check { width: 36px; text-align: center; padding: 0; }
.col-check input { accent-color: var(--accent); width: 15px; height: 15px; .col-check input { width: 15px; height: 15px; margin: 0; vertical-align: middle; }
margin: 0; vertical-align: middle; } @supports (accent-color: auto) {
.col-check input { accent-color: var(--accent); }
}
.col-num { text-align: right; } .col-num { text-align: right; }
.cell-strong { font-weight: 550; } .cell-strong { font-weight: 550; }
.cell-muted { color: var(--text-muted); } .cell-muted { color: var(--text-muted); }
@@ -655,7 +694,9 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
.kebab:hover { background: var(--surface-2); color: var(--text); } .kebab:hover { background: var(--surface-2); color: var(--text); }
/* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an /* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an
engine that doesn't know :popover-open would drop the hover style along with it. */ engine that doesn't know :popover-open would drop the hover style along with it. */
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); } @supports selector(.kebab:has(+ .menu-pop:popover-open)) {
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
}
/* ---------- 10. PAGINATION --------------------------------- */ /* ---------- 10. PAGINATION --------------------------------- */
.pager { .pager {
+64 -9
View File
@@ -1,16 +1,71 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs"; import { globSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { floorViolations } from "./stylesheet-floor.ts";
const cssDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public", "css"); const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cssDir = join(root, "public", "css");
// A custom property holding oklch() passes unchecked on an engine below Chromium 111, so the test("floorViolations flags a feature the floor lacks, with its line and the first version that has it", () => {
// var() that reads it is invalid at computed-value time: white text on no background. assert.deepEqual(floorViolations(".a { color: red; }\n.b:has(.c) { color: red; }"), [
for (const file of readdirSync(cssDir).filter((f) => f.endsWith(".css"))) { "line 2: :has needs chrome 105, safari 15.4, firefox 121 (css.selectors.has)",
test(`${file} declares no oklch() colour`, () => { ]);
assert.ok(!/oklch\(/i.test(readFileSync(join(cssDir, file), "utf8"))); assert.deepEqual(floorViolations(".a { min-height: 100dvh; }"), [
"line 1: dvh needs chrome 108, safari 15.4 (css.types.length.viewport_percentage_units_dynamic)",
]);
assert.deepEqual(floorViolations(".a { accent-color: red; }"), [
"line 1: accent-color needs chrome 93, safari 15.4 (css.properties.accent-color)",
]);
assert.deepEqual(floorViolations(".a { overflow-wrap: anywhere; }"), [
"line 1: overflow-wrap: anywhere needs safari 15.4 (css.properties.overflow-wrap.anywhere)",
]);
assert.equal(floorViolations(".a { & .b { color: red; } }").length, 1);
});
test("floorViolations checks a custom property's value, since the var() reading it fails unseen", () => {
assert.deepEqual(floorViolations(":root { --accent: oklch(0.5 0.1 250); }"), [
"line 1: oklch() needs chrome 111, safari 15.4 (css.types.color.oklch)",
]);
});
test("floorViolations allows a feature inside an @supports that tests it, and only then", () => {
assert.deepEqual(floorViolations("@supports selector(:has(a)) { .b:has(.c) { color: red; } }"), []);
assert.deepEqual(floorViolations("@media (min-width: 1px) { @supports (min-height: 100dvh) { .a { min-height: 100dvh; } } }"), []);
assert.equal(floorViolations("@supports not selector(:has(a)) { .b:has(.c) { color: red; } }").length, 1);
assert.equal(floorViolations("@supports (display: grid) and (not selector(:has(a))) { .b:has(.c) { color: red; } }").length, 1);
assert.equal(floorViolations("@supports (min-height: 100dvh) { .b:has(.c) { color: red; } }").length, 1);
assert.equal(floorViolations("@supports (position-anchor: auto) { .a { top: anchor(bottom); } }").length, 1);
});
test("floorViolations passes what the floor has, a vendor prefix and a comment", () => {
const css = `/* .x:has(y) { min-height: 100dvh } */
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
.a::before, .a:focus-within { content: "{ :has( }"; display: grid; gap: 4px; inset: 0;
grid-template-columns: repeat(3, minmax(0, 1fr)); width: min(86vw, 20rem); transition: .15s ease;
background: rgba(0,0,0,.4) url("data:image/svg+xml;utf8,<svg/>"); transform: rotate(45deg); }
.b { -webkit-font-smoothing: antialiased; -webkit-user-select: none; }
summary::-webkit-details-marker { display: none; }`;
assert.deepEqual(floorViolations(css), []);
});
test("floorViolations refuses what MDN has no entry for, so a typo or a new feature can't pass unchecked", () => {
assert.deepEqual(floorViolations(".a { colr: red; }"), ["line 1: colr has no MDN compat entry"]);
assert.equal(floorViolations(".a { width: 3zz; }").length, 1);
assert.equal(floorViolations(".a:hovr { color: red; }").length, 1);
});
const stylesheets = globSync(["public/**/*.css", "examples/**/*.css"], { cwd: root, exclude: ["**/node_modules/**"] });
test("the floor check finds the stylesheets", () => {
assert.ok(stylesheets.includes("public/css/styles.css"));
assert.ok(stylesheets.includes("examples/plugins/scheduling/public/scheduling.css"));
});
for (const file of stylesheets) {
test(`${file} uses nothing the browser floor lacks outside an @supports guarding it`, () => {
assert.deepEqual(floorViolations(readFileSync(join(root, file), "utf8")), []);
}); });
} }
@@ -31,8 +86,8 @@ function blockEnd(start: number): number {
// Chromium below 105 and Firefox below 121 have no `:has()`, and nothing else reveals a folded // Chromium below 105 and Firefox below 121 have no `:has()`, and nothing else reveals a folded
// branch there, so the fold may only exist where `:has()` works. // branch there, so the fold may only exist where `:has()` works.
test("styles.css folds .nav-children only inside @supports selector(:has(a))", () => { test("styles.css folds .nav-children only inside @supports selector(:has(a))", () => {
const open = stylesheet.indexOf("@supports selector(:has(a)) {"); const open = stylesheet.lastIndexOf("@supports selector(:has(a)) {", stylesheet.indexOf(".nav-node > .nav-children"));
assert.ok(open >= 0, "no @supports selector(:has(a)) block"); assert.ok(open >= 0, "no @supports selector(:has(a)) block before the fold rule");
const close = blockEnd(open); const close = blockEnd(open);
const outside = stylesheet.slice(0, open) + stylesheet.slice(close + 1); const outside = stylesheet.slice(0, open) + stylesheet.slice(close + 1);
assert.ok(!/\.nav-children\s*\{[^}]*display:\s*none/.test(outside), "a .nav-children rule hides it outside the guard"); assert.ok(!/\.nav-children\s*\{[^}]*display:\s*none/.test(outside), "a .nav-children rule hides it outside the guard");
+207
View File
@@ -0,0 +1,207 @@
// README → "Two browser floors", checked against MDN's compat data: a feature one of FLOOR's engines
// lacks passes only inside an `@supports` whose condition names it. Checked: at-rules, media features,
// pseudo-classes and -elements, properties, and in a value its functions, its units and the keywords
// MDN lists for that property. A partial implementation counts as support. A vendor-prefixed name is
// one engine's by design and is skipped, as are `font-family` names: an engine skips a family it lacks.
// Not checked: the body of an at-rule other than @media and @supports, and syntax MDN has no entry for,
// such as media range syntax.
import bcd from "@mdn/browser-compat-data" with { type: "json" };
import type { CompatStatement, Identifier, SimpleSupportStatement } from "@mdn/browser-compat-data";
const FLOOR = { chrome: "87", safari: "15", firefox: "115" };
interface Feature {
label: string;
path: string | undefined;
// Matches the feature in an `@supports` condition that tests for it.
guard: RegExp;
offset: number;
}
interface Sheet {
css: string;
out: string[];
}
export function floorViolations(css: string): string[] {
const sheet = { css: blank(css), out: [] };
walk(sheet, 0, sheet.css.length, []);
return [...new Set(sheet.out)];
}
// Comments, string contents and url() contents become spaces, so nothing inside one reads as syntax
// and every offset keeps its line.
function blank(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\/|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|url\([^)]*\)/g, (m) => {
if (m.startsWith("/*")) return m.replace(/[^\n]/g, " ");
const keep = m.startsWith("url(") ? 4 : 1;
return m.slice(0, keep) + " ".repeat(m.length - keep - 1) + m.slice(-1);
});
}
function closing(text: string, open: number): number {
const [opener, closer] = text[open] === "(" ? ["(", ")"] : ["{", "}"];
let depth = 0;
for (let i = open; i < text.length; i++) {
if (text[i] === opener) depth++;
if (text[i] === closer && --depth === 0) return i;
}
throw new Error(`unbalanced ${opener} at offset ${open}`);
}
function walk(sheet: Sheet, start: number, end: number, guards: string[]): void {
let i = start;
while (i < end) {
const stop = sheet.css.slice(i, end).search(/[{;]/);
if (stop < 0) return;
const at = i + stop;
const raw = sheet.css.slice(i, at);
const prelude = raw.trim();
const offset = i + raw.length - raw.trimStart().length;
const blockEnd = sheet.css[at] === "{" ? closing(sheet.css, at) : at;
if (prelude.startsWith("@")) atRule(sheet, prelude, offset, guards, [at + 1, blockEnd]);
else styleRule(sheet, prelude, offset, guards, [at + 1, blockEnd]);
i = blockEnd + 1;
}
}
function atRule(sheet: Sheet, prelude: string, offset: number, guards: string[], [from, to]: [number, number]): void {
const name = /^@([\w-]+)/.exec(prelude)?.[1]?.toLowerCase() ?? "";
if (name.startsWith("-")) return;
check(sheet, guards, { label: `@${name}`, path: `css.at-rules.${name}`, guard: never, offset });
const condition = prelude.slice(name.length + 1);
if (name === "media") {
for (const m of condition.matchAll(/\(\s*(?:min-|max-)?([a-z][\w-]*)\s*[:)]/gi)) {
check(sheet, guards, { label: `(${m[1]})`, path: `css.at-rules.media.${m[1]}`, guard: never, offset });
}
}
if (name === "supports" && /(?<![\w-])selector\(/i.test(condition)) {
check(sheet, guards, { label: "selector()", path: "css.at-rules.supports.selector", guard: never, offset });
}
if (name === "media" || name === "supports") {
walk(sheet, from, to, name === "supports" ? [...guards, positive(condition)] : guards);
}
}
// The parts of an @supports condition that hold inside it: a `not (…)` group holds only outside.
function positive(condition: string): string {
let rest = condition;
for (let at = rest.search(/(?<![\w-])not\s/i); at >= 0; at = rest.search(/(?<![\w-])not\s/i)) {
const open = rest.indexOf("(", at);
if (open < 0) return rest.slice(0, at);
rest = rest.slice(0, at) + rest.slice(closing(rest, open) + 1);
}
return rest;
}
function styleRule(sheet: Sheet, selector: string, offset: number, guards: string[], [from, to]: [number, number]): void {
for (const m of selector.matchAll(/::?([a-z][\w-]*)/gi)) {
const name = (m[1] ?? "").toLowerCase();
check(sheet, guards, { label: `:${name}`, path: `css.selectors.${name}`, guard: new RegExp(`:${name}(?![\\w-])`, "i"), offset: offset + (m.index ?? 0) });
}
const body = sheet.css.slice(from, to);
if (body.includes("{")) {
check(sheet, guards, { label: "a nested rule", path: "css.selectors.nesting", guard: never, offset: from });
return;
}
let at = from;
for (const declaration of body.split(";")) {
const colon = declaration.indexOf(":");
if (colon > 0) property(sheet, guards, declaration.slice(0, colon).trim().toLowerCase(), declaration.slice(colon + 1), at + declaration.search(/\S/));
at += declaration.length + 1;
}
}
function property(sheet: Sheet, guards: string[], name: string, value: string, offset: number): void {
if (name.startsWith("-") && !name.startsWith("--")) return;
const custom = name.startsWith("--");
const known = custom || check(sheet, guards, { label: name, path: `css.properties.${name}`, guard: word(name), offset });
for (const m of value.matchAll(/(?<![\w-])([a-z][\w-]*)\(/gi)) {
const fn = m[1] ?? "";
check(sheet, guards, { label: `${fn}()`, path: functionPath(name, fn), guard: new RegExp(`(?<![\\w-])${fn}\\(`, "i"), offset });
}
for (const m of value.replace(/#[\da-f]+/gi, "").matchAll(/(?<![\w.])\d*\.?\d+([a-z]+|%)/gi)) {
const unit = (m[1] ?? "").toLowerCase();
check(sheet, guards, { label: unit, path: unitPath(unit), guard: new RegExp(`\\d${unit}(?![\\w-])`, "i"), offset });
}
if (custom || !known || name === "font-family") return;
for (const m of value.matchAll(/(?<![\w-])([a-z][\w-]*)(?![\w(-])/gi)) {
const path = `css.properties.${name}.${(m[1] ?? "").toLowerCase()}`;
if (entry(path)) check(sheet, guards, { label: `${name}: ${m[1]}`, path, guard: word(m[1] ?? ""), offset });
}
}
const never = /(?!)/;
const word = (name: string): RegExp => new RegExp(`(?<![\\w-])${name}(?![\\w-])`, "i");
// A name two types share, like rect() in <shape> and <basic-shape>, passes where the floor has either.
function functionPath(property: string, fn: string): string | undefined {
const name = fn === "rgba" ? "rgb" : fn; // MDN files rgba() under rgb(), its alias
const types = Object.keys(bcd.css.types ?? {}).map((type) => `css.types.${type}.${name}`);
const paths = [`css.properties.${property}.${name}`, `css.types.${name}`, ...types].filter((path) => entry(path));
return paths.find((path) => lacking(entry(path)).length === 0) ?? paths[0];
}
const VIEWPORT: Record<string, string> = { d: "dynamic", s: "small", l: "large" };
const UNIT_TYPES: Record<string, string> = {
px: "css.types.length",
"%": "css.types.percentage",
fr: "css.types.flex",
s: "css.types.time",
ms: "css.types.time",
};
function unitPath(unit: string): string | undefined {
const viewport = /^([dsl])v(?:h|w|i|b|min|max)$/.exec(unit)?.[1];
if (viewport) return `css.types.length.viewport_percentage_units_${VIEWPORT[viewport]}`;
return UNIT_TYPES[unit] ?? ["length", "angle", "resolution"].map((type) => `css.types.${type}.${unit}`).find((path) => entry(path));
}
function entry(path: string | undefined): CompatStatement | undefined {
if (!path) return undefined;
let node: Identifier | undefined = bcd as unknown as Identifier;
for (const key of path.split(".")) node = node && Object.hasOwn(node, key) ? node[key] : undefined;
return node?.__compat;
}
// Reports the feature unless a guard names it; true when it is fine as written.
function check(sheet: Sheet, guards: string[], feature: Feature): boolean {
if (guards.some((condition) => feature.guard.test(condition))) return true;
const line = sheet.css.slice(0, feature.offset).split("\n").length;
const compat = entry(feature.path);
if (!compat) {
sheet.out.push(`line ${line}: ${feature.label} has no MDN compat entry`);
return false;
}
const missing = lacking(compat);
if (missing.length > 0) sheet.out.push(`line ${line}: ${feature.label} needs ${missing.join(", ")} (${feature.path})`);
return missing.length === 0;
}
// Each floor engine without the feature, with the release that brought it, as "safari 15.4".
function lacking(compat: CompatStatement | undefined): string[] {
return Object.entries(FLOOR).flatMap(([browser, floor]) => {
const support = compat?.support[browser as keyof typeof compat.support];
const statements = [support ?? []].flat().filter((s: SimpleSupportStatement) => !s.prefix && !s.alternative_name && !s.flags);
const has = (s: SimpleSupportStatement): boolean => {
const added = release(s.version_added);
return added !== undefined && !newer(added, floor) && !(s.version_removed && !newer(s.version_removed, floor));
};
if (statements.some(has)) return [];
const releases = statements.flatMap((s) => release(s.version_added) ?? []).sort((x, y) => (newer(x, y) ? 1 : -1));
return [`${browser} ${releases[0] ?? "(none yet)"}`];
});
}
// A shipped release number; "≤79" counts as 79, "preview" and false as none.
function release(version: string | false): string | undefined {
return typeof version === "string" && /^≤?\d/.test(version) ? version.replace("≤", "") : undefined;
}
function newer(version: string, than: string): boolean {
const [a, b] = [version, than].map((v) => v.split(".").map(Number));
for (let i = 0; i < Math.max(a?.length ?? 0, b?.length ?? 0); i++) {
if ((a?.[i] ?? 0) !== (b?.[i] ?? 0)) return (a?.[i] ?? 0) > (b?.[i] ?? 0);
}
return false;
}
-7
View File
@@ -12,7 +12,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|
| 32 | 0.4.21 | | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 4 | 2 | 2 | 6 | 7 | code health by 2 | 1.1 |
| 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 | | 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 |
| 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 | | 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 | | 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
@@ -27,12 +26,6 @@
## Details ## Details
### 32. Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.
Nothing checks README → "Two browser floors", and no E2E engine is old enough to show a break.
Lint every stylesheet, the examples' included, against Chromium 87, Safari 15 and Firefox 115, and
allow a feature only inside an `@supports` that guards it.
### 13. Accept a verification code pasted with surrounding whitespace. ### 13. Accept a verification code pasted with surrounding whitespace.
A pasted code fails the browser's `pattern` check. No zero-JS fix exists: `pattern` A pasted code fails the browser's `pattern` check. No zero-JS fix exists: `pattern`