From 7ff8140e07ff2cec3857cfb1a566eb880f378677 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 31 Oct 2026 11:52:17 +0100 Subject: [PATCH] Fail the gate on a stylesheet feature the browser floor lacks, unless an @supports guards it --- CHANGELOG.md | 7 ++ README.md | 8 +- docs/reading-cost.md | 17 +++ package-lock.json | 8 ++ package.json | 1 + public/css/auth.css | 10 +- public/css/styles.css | 175 ++++++++++++++++----------- src/ui/stylesheet-floor.test.ts | 73 +++++++++-- src/ui/stylesheet-floor.ts | 207 ++++++++++++++++++++++++++++++++ todo.md | 7 -- 10 files changed, 424 insertions(+), 89 deletions(-) create mode 100644 src/ui/stylesheet-floor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0372f2e..f1b3512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ at boot. Entries start at 0.3.0. ## 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 Releases 0.4.1 to 0.4.20 were tagged in git but never published to Docker Hub or as semver tags in diff --git a/README.md b/README.md index c05f5e6..2c1b130 100644 --- a/README.md +++ b/README.md @@ -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 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 -decorate a page but never decide whether something shows or can be read. A colour is hex, `rgb()` or -`hsl()`, never `oklch()`: a custom property holding it passes unchecked, and the `var()` reading it -then renders transparent. +decorate a page but never decide whether something shows or can be read. The tests hold every +stylesheet in `public/` and `examples/` to this floor, against MDN's compat data: a feature a floor +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 choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made. diff --git a/docs/reading-cost.md b/docs/reading-cost.md index e8ce3f5..853a87c 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -209,3 +209,20 @@ Read most (opens and searches): - `examples/plugins/admin/i18n/en-US.ts` 2 - `CHANGELOG.md` 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 diff --git a/package-lock.json b/package-lock.json index 4ecde74..ecfb573 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "postgres": "3.4.9" }, "devDependencies": { + "@mdn/browser-compat-data": "8.1.7", "@types/ejs": "3.1.5", "@types/node": "24.19.2", "acorn": "8.19.0", @@ -30,6 +31,13 @@ "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": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", diff --git a/package.json b/package.json index 7771026..20213c4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "postgres": "3.4.9" }, "devDependencies": { + "@mdn/browser-compat-data": "8.1.7", "@types/ejs": "3.1.5", "@types/node": "24.19.2", "acorn": "8.19.0", diff --git a/public/css/auth.css b/public/css/auth.css index b8fbe20..45fda1d 100644 --- a/public/css/auth.css +++ b/public/css/auth.css @@ -1,9 +1,11 @@ .auth-stage { - min-height: 100dvh; display: grid; place-items: center; padding: 40px 20px 96px; background: var(--bg); } +@supports (min-height: 100dvh) { + .auth-stage { min-height: 100dvh; } +} .auth { width: 100%; max-width: 408px; display: flex; flex-direction: column; gap: 22px; @@ -103,8 +105,10 @@ .input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-bg); } /* gentle validation feedback only after the user has interacted */ -.input:user-invalid { border-color: var(--neg); } -.input:user-invalid:focus { box-shadow: 0 0 0 2px var(--neg-bg); } +@supports selector(:user-invalid) { + .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); } diff --git a/public/css/styles.css b/public/css/styles.css index 92add70..7a14441 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -71,9 +71,24 @@ line-height: 1.45; } -/* shared DARK mapping, applied to Auto-via-OS and forced-dark */ -@media (prefers-color-scheme: dark) { - :root:not(:has(#theme-light:checked)):not(:has(#theme-dark:checked)) { +@supports selector(:has(a)) { + /* shared DARK mapping, applied to Auto-via-OS and forced-dark */ + @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); --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); @@ -84,32 +99,19 @@ --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); - --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 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; + /* 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 ---------------------------------------- */ @@ -122,10 +124,12 @@ a { color: inherit; text-decoration: none; } summary::-webkit-details-marker { display: none; } summary { list-style: none; cursor: pointer; } -:focus-visible { - outline: 2px solid var(--focus); - outline-offset: 1px; - border-radius: 3px; +@supports selector(:focus-visible) { + :focus-visible { + outline: 2px solid var(--focus); + outline-offset: 1px; + border-radius: 3px; + } } @media (prefers-reduced-motion: no-preference) { .sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; } @@ -153,7 +157,9 @@ summary { list-style: none; cursor: pointer; } .app { display: grid; grid-template-columns: var(--nav-w) minmax(0, 1fr); - min-height: 100dvh; +} +@supports (min-height: 100dvh) { + .app { min-height: 100dvh; } } /* ---------- 4. SIDEBAR -------------------------------------- */ @@ -219,7 +225,10 @@ summary { list-style: none; cursor: pointer; } min-height: 30px; padding: 4px 8px; border-radius: var(--radius); 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); } .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 input { position: absolute; opacity: 0; inset: 0; cursor: pointer; } -.theme-switch label:has(input:checked) { - 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; +@supports selector(:has(:focus-visible)) { + .theme-switch label:has(input:checked) { + 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; + } } /* 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; } .select select { - appearance: none; height: 30px; padding: 0 26px 0 9px; + height: 30px; padding: 0 26px 0 9px; background: var(--surface); color: var(--text); border: 1px solid var(--border-2); border-radius: var(--radius); 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); border-bottom: 1.5px solid var(--text-faint); transform: translateY(-2px) rotate(45deg); } -.select select:focus-visible { border-color: var(--accent); outline: none; - box-shadow: 0 0 0 2px var(--accent-bg); } +@supports (appearance: none) { + .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 { @@ -440,11 +456,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */ } .segmented label:hover { color: var(--text); } .segmented input { position: absolute; opacity: 0; inset: 0; cursor: pointer; } -.segmented label:has(input:checked) { - background: var(--surface); color: var(--text); font-weight: 550; - box-shadow: 0 1px 2px rgba(0,0,0,.06); +@supports selector(:has(:focus-visible)) { + .segmented label:has(input:checked) { + 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); } /* 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 .chip-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-2); } -.chip:has(input:checked) { - background: var(--accent-bg); border-color: var(--accent-bd); - color: var(--text); font-weight: 550; +@supports selector(:has(:focus-visible)) { + .chip:has(input:checked) { + 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 */ .daterange { @@ -486,8 +506,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */ display: inline-flex; align-items: center; gap: 7px; font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer; } -.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent); - margin: 0; cursor: pointer; } +.check input, .radio input { width: 15px; height: 15px; margin: 0; cursor: pointer; } +@supports (accent-color: auto) { + .check input, .radio input { accent-color: var(--accent); } +} .check:hover, .radio:hover { color: var(--text); } /* A stacked group of .check rows in a
— 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 @@ -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 .check { align-items: baseline; } .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); } /* popover menu (language picker, profile, row kebab) — a