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 522 additions and 90 deletions
+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
`npm test` checks every stylesheet in `public/`, `examples/` and `plugins/` against Chromium 87,
Safari 15 and Firefox 115: a feature one of them lacks fails it unless an `@supports` around it
names that feature. Mount a plugin in `plugins/<id>/` to check its stylesheets. Pages render as
before in every browser.
### 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
+6 -3
View File
@@ -1059,9 +1059,12 @@ 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. `npm test` fails on a
`hsl()`, never `oklch()`: a custom property holding it passes unchecked, and the `var()` reading it stylesheet using a feature Chromium 87, Safari 15 or Firefox 115 lacks, unless an `@supports`
then renders transparent. around it names that feature. It checks every stylesheet in `public/`, `examples/` and `plugins/`,
so mount your plugin in `plugins/<id>/` and run it. A few constructs fail even when guarded, such as
`@layer`, nested rules and media range syntax, while a media feature passes unguarded, since a
browser without it skips the query; `src/ui/stylesheet-floor.ts` lists the rules.
**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); }
+107 -69
View File
@@ -22,8 +22,6 @@
selects). selects).
------------------------------------------------------------- */ ------------------------------------------------------------- */
:root { :root {
/* Hex, never oklch: below Chromium 111 `var(--accent)` is invalid at computed-value time and the
primary button loses its background. A fallback declaration cannot catch that. */
/* ---- raw LIGHT palette ---- */ /* ---- raw LIGHT palette ---- */
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc; --l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
--l-border:#e5e5e8; --l-border-2:#d6d6da; --l-border:#e5e5e8; --l-border-2:#d6d6da;
@@ -71,9 +69,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 +97,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 +122,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 +155,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 +223,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 +284,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 +423,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 +433,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 +454,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 +476,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 +504,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 +515,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 +528,24 @@ 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 (top: anchor(bottom)) {
.menu-pop { position-anchor: auto; top: anchor(bottom); right: anchor(right); }
.menu-pop.left { right: auto; left: anchor(left); }
.menu-pop.up { top: auto; bottom: anchor(top); }
}
@supports (position-try-fallbacks: flip-block, flip-inline) {
.menu-pop { position-try-fallbacks: flip-block, flip-inline; }
}
/* 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 +576,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 +621,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 +691,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 {
+112 -9
View File
@@ -1,16 +1,119 @@
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 type { SupportStatement } from "@mdn/browser-compat-data";
import { floorViolations, lacking } 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);
assert.deepEqual(floorViolations("@layer base { .a { color: red; } }"), [
"line 1: @layer needs chrome 99, safari 15.4 (css.at-rules.layer)",
]);
});
test("floorViolations flags media range and or syntax, which a floor engine drops with the whole query", () => {
assert.deepEqual(floorViolations("@media (width >= 600px) { .a { color: red; } }"), [
"line 1: media range syntax needs chrome 104, safari 16.4 (css.at-rules.media.range_syntax)",
]);
assert.deepEqual(floorViolations("@media (hover: hover) or (pointer: fine) { .a { color: red; } }"), [
"line 1: media or syntax needs chrome 104, safari 16.4 (css.at-rules.media.or_syntax)",
]);
});
test("floorViolations passes a media feature the floor lacks, since an engine without it reads the query as false", () => {
assert.deepEqual(floorViolations("@media (forced-colors: active) { .a { color: red; } }"), []);
});
test("floorViolations reports a bracket nothing closes, with its line", () => {
assert.deepEqual(floorViolations(".a { color: red; }\n.b { color: red;"), ["line 2: { is never closed"]);
assert.deepEqual(floorViolations(".a {\n width: calc(1px;\n}"), ["line 2: ( is never closed"]);
});
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);
assert.equal(floorViolations("@supports at-rule(@layer) { @layer base { .a { color: red; } } }").length, 1);
});
test("floorViolations takes as a guard only a part of the @supports condition that must hold", () => {
const has = ["line 1: :has needs chrome 105, safari 15.4, firefox 121 (css.selectors.has)"];
assert.deepEqual(floorViolations("@supports (display: grid) or selector(:has(a)) { .b:has(.c) { color: red; } }"), has);
assert.deepEqual(floorViolations("@supports selector(:has(a)) and ((display: grid) or (display: flex)) { .b:has(.c) { color: red; } }"), []);
assert.deepEqual(floorViolations("@supports (display: grid) and ((display: flex) or selector(:has(a))) { .b:has(.c) { color: red; } }"), has);
});
test("floorViolations passes what the floor has, vendor prefixes, font-family names, and syntax inside comments, strings and url()", () => {
const css = `/* .x:has(y) { min-height: 100dvh } */
@media (prefers-color-scheme: dark) and (orientation: portrait) { :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; font-family: Avenir, ui-sans-serif, sans-serif; }
summary::-webkit-details-marker { display: none; }
@-webkit-keyframes spin { to { opacity: 0; } }`;
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);
assert.deepEqual(floorViolations("@media (hovr: hover) { .a { color: red; } }"), ["line 1: (hovr) has no MDN compat entry"]);
});
const safari = (support: SupportStatement): string[] =>
lacking({ chrome: { version_added: "1" }, firefox: { version_added: "1" }, safari: support });
test("lacking counts only an unprefixed, unflagged, unrenamed support statement the floor still has", () => {
assert.deepEqual(safari({ version_added: "15" }), []);
assert.deepEqual(safari({ version_added: "15.4" }), ["safari 15.4"]);
assert.deepEqual(safari({ version_added: "≤16" }), ["safari 16"]);
assert.deepEqual(safari({ version_added: "preview" }), ["safari (none yet)"]);
assert.deepEqual(safari({ version_added: "3", prefix: "-webkit-" }), ["safari (none yet)"]);
assert.deepEqual(safari({ version_added: "3", flags: [{ type: "preference", name: "x" }] }), ["safari (none yet)"]);
assert.deepEqual(safari({ version_added: "3", alternative_name: "x" }), ["safari (none yet)"]);
assert.deepEqual(safari({ version_added: "3", version_removed: "15" }), ["safari (none yet)"]);
assert.deepEqual(safari({ version_added: "3", version_removed: "15.4" }), []);
assert.deepEqual(safari([{ version_added: "3", version_removed: "14" }, { version_added: "16" }]), ["safari 16"]);
});
const stylesheets = globSync(["public/**/*.css", "examples/**/*.css", "plugins/**/*.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 +134,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");
+249
View File
@@ -0,0 +1,249 @@
// floorViolations checks a stylesheet against FLOOR, the browser floor of README → "Two browser
// floors", using MDN's compat data. A feature one of its engines lacks passes only inside an
// `@supports` whose condition requires it; an at-rule or a nested rule the floor lacks fails even there.
// Checked: at-rules, media features, pseudo-classes and -elements, properties, and, in a property's
// value, its functions, its units and the keywords MDN lists for that property. A name MDN has no
// entry for fails. A media feature the floor lacks passes, since an engine without it reads the query
// as false; media range and `or` syntax fail. A partial implementation counts as support, since MDN
// marks edge gaps that way, such as `outline` ignoring `border-radius` before Chrome 94. 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.
import bcd from "@mdn/browser-compat-data" with { type: "json" };
import type { CompatStatement, Identifier, SimpleSupportStatement, SupportBlock } from "@mdn/browser-compat-data";
const FLOOR = [["chrome", "87"], ["safari", "15"], ["firefox", "115"]] as const;
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: [] };
const open = unclosed(sheet.css);
if (open !== undefined) return [`line ${lineAt(sheet.css, open)}: ${sheet.css[open]} is never closed`];
walk(sheet, 0, sheet.css.length, []);
return [...new Set(sheet.out)];
}
// The innermost bracket nothing closes: a browser reads the rest of the sheet as inside it.
function unclosed(css: string): number | undefined {
const open: { at: number; closer: string }[] = [];
for (let i = 0; i < css.length; i++) {
if (css[i] === "{" || css[i] === "(") open.push({ at: i, closer: css[i] === "{" ? "}" : ")" });
else if (css[i] === open.at(-1)?.closer) open.pop();
}
return open.at(-1)?.at;
}
const lineAt = (css: string, offset: number): number => css.slice(0, offset).split("\n").length;
// 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);
});
}
// The end of `text` when nothing closes the bracket. floorViolations refuses a sheet with one left
// open, so only an @supports condition cut at a `;` or `{` inside its parentheses gets there.
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;
}
return text.length;
}
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") mediaQuery(sheet, guards, condition, 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);
}
}
// Range and `or` syntax each have a floor spelling (`min-`/`max-`, a comma list), so a query using
// one was meant for every engine, and a floor engine drops it whole.
function mediaQuery(sheet: Sheet, guards: string[], condition: string, offset: number): void {
for (const m of condition.matchAll(/\(\s*(?:min-|max-)?([a-z][\w-]*)\s*[:)]/gi)) {
const path = `css.at-rules.media.${m[1]}`;
if (!entry(path)) check(sheet, guards, { label: `(${m[1]})`, path, guard: never, offset });
}
if (/[<>=]/.test(condition)) {
check(sheet, guards, { label: "media range syntax", path: "css.at-rules.media.range_syntax", guard: never, offset });
}
if (OR.test(condition)) {
check(sheet, guards, { label: "media or syntax", path: "css.at-rules.media.or_syntax", guard: never, offset });
}
}
// The parts of an @supports condition that must hold for its block to apply: neither a `not (…)`
// group nor an operand of an `or`, at any depth.
function positive(condition: string): string {
let top = "";
let kept = "";
let from = 0;
for (let open = condition.indexOf("("); open >= 0; open = condition.indexOf("(", from)) {
const close = closing(condition, open);
const head = condition.slice(from, open);
const inner = condition.slice(open + 1, close);
top += head;
if (NEGATED.test(head)) kept += head.replace(NEGATED, "");
else if (/[\w-]$/.test(head)) kept += `${head}(${inner})`; // a function such as selector()
else kept += `${head}(${positive(inner)})`;
from = close + 1;
}
return OR.test(top + condition.slice(from)) ? "" : kept + condition.slice(from);
}
const OR = /(?<![\w-])or\s/i;
const NEGATED = /(?<![\w-])not\s+[\w-]*$/i;
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)?.support ?? {}).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 = { css: bcd.css };
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 or the floor has it; returns true when either holds.
function check(sheet: Sheet, guards: string[], feature: Feature): boolean {
if (guards.some((condition) => feature.guard.test(condition))) return true;
const line = lineAt(sheet.css, feature.offset);
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.support);
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 first later release that has it, as "safari 15.4".
export function lacking(support: SupportBlock): string[] {
return FLOOR.flatMap(([browser, floor]) => {
const statements = [support[browser] ?? []].flat().filter((s) => !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) ?? []).filter((v) => newer(v, floor));
releases.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;
}
+8 -6
View File
@@ -6,13 +6,13 @@
`Bar = 1` `Bar = 1`
`Next ID = 44` `Next ID = 45`
## Items ## Items
| 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 | | 44 | 0.4.21 | | defect | **Show which filter segment or chip is selected and focused on a browser without `:has()`.** | 1 | 2 | 2 | 3 | 2 | 4 by 4 | 23.9 |
| 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,11 +27,13 @@
## Details ## Details
### 32. Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded. ### 44. Show which filter segment or chip is selected and focused on a browser without `:has()`.
Nothing checks README → "Two browser floors", and no E2E engine is old enough to show a break. `.segmented input` and `.chip input` are `opacity: 0`, and only `:has(input:checked)` and
Lint every stylesheet, the examples' included, against Chromium 87, Safari 15 and Firefox 115, and `:has(input:focus-visible)` rules mark the choice. On Firefox 115, Chromium 87–104 and Safari
allow a feature only inside an `@supports` that guards it. 15.0–15.3 a filter shows no selected state and no keyboard focus, which README → "Two browser
floors" forbids. Show the native inputs in `public/css/styles.css`'s `@supports not selector(:has(a))`
block.
### 13. Accept a verification code pasted with surrounding whitespace. ### 13. Accept a verification code pasted with surrounding whitespace.