Fail the gate on a stylesheet feature the browser floor lacks, unless an @supports guards it #197
@@ -6,6 +6,13 @@ at boot. Entries start at 0.3.0.
|
||||
|
||||
## 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
|
||||
|
||||
Releases 0.4.1 to 0.4.20 were tagged in git but never published to Docker Hub or as semver tags in
|
||||
|
||||
@@ -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
|
||||
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. `npm test` fails on a
|
||||
stylesheet using a feature Chromium 87, Safari 15 or Firefox 115 lacks, unless an `@supports`
|
||||
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
|
||||
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+7
-3
@@ -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); }
|
||||
|
||||
|
||||
+107
-69
@@ -22,8 +22,6 @@
|
||||
selects).
|
||||
------------------------------------------------------------- */
|
||||
: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 ---- */
|
||||
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
|
||||
--l-border:#e5e5e8; --l-border-2:#d6d6da;
|
||||
@@ -71,9 +69,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 +97,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 +122,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 +155,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 +223,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 +284,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 +423,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 +433,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 +454,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 +476,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 +504,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 <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
|
||||
@@ -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 .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 <button popovertarget> and its [popover]
|
||||
@@ -506,17 +528,24 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.menu { display: inline-flex; }
|
||||
.menu-pop {
|
||||
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;
|
||||
max-height: 60vh; overflow-y: auto; overflow-wrap: anywhere;
|
||||
max-height: 60vh; overflow-y: auto;
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border-2); border-radius: var(--radius);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,.16);
|
||||
}
|
||||
.menu-pop.left { right: auto; left: anchor(left); }
|
||||
.menu-pop.up { top: auto; bottom: anchor(top); margin: 0 0 6px; }
|
||||
.menu-pop.up { 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
|
||||
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 —
|
||||
@@ -547,7 +576,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.menu-check { display: flex; align-items: center; gap: 9px; width: 100%;
|
||||
padding: 6px 8px; border-radius: 4px; font-size: var(--fz-sm); cursor: pointer; }
|
||||
.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-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 tr:hover td, .table tbody tr:hover th { background: var(--surface-2); }
|
||||
.table tbody tr:has(.row-select:checked) td,
|
||||
.table tbody tr:has(.row-select:checked) th { background: var(--accent-bg); }
|
||||
@supports selector(:has(a)) {
|
||||
.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 input { accent-color: var(--accent); width: 15px; height: 15px;
|
||||
margin: 0; vertical-align: middle; }
|
||||
.col-check input { width: 15px; height: 15px; margin: 0; vertical-align: middle; }
|
||||
@supports (accent-color: auto) {
|
||||
.col-check input { accent-color: var(--accent); }
|
||||
}
|
||||
.col-num { text-align: right; }
|
||||
.cell-strong { font-weight: 550; }
|
||||
.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); }
|
||||
/* 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. */
|
||||
.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 --------------------------------- */
|
||||
.pager {
|
||||
|
||||
@@ -1,16 +1,119 @@
|
||||
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 { test } from "node:test";
|
||||
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
|
||||
// var() that reads it is invalid at computed-value time: white text on no background.
|
||||
for (const file of readdirSync(cssDir).filter((f) => f.endsWith(".css"))) {
|
||||
test(`${file} declares no oklch() colour`, () => {
|
||||
assert.ok(!/oklch\(/i.test(readFileSync(join(cssDir, file), "utf8")));
|
||||
test("floorViolations flags a feature the floor lacks, with its line and the first version that has it", () => {
|
||||
assert.deepEqual(floorViolations(".a { color: red; }\n.b:has(.c) { color: red; }"), [
|
||||
"line 2: :has needs chrome 105, safari 15.4, firefox 121 (css.selectors.has)",
|
||||
]);
|
||||
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
|
||||
// branch there, so the fold may only exist where `:has()` works.
|
||||
test("styles.css folds .nav-children only inside @supports selector(:has(a))", () => {
|
||||
const open = stylesheet.indexOf("@supports selector(:has(a)) {");
|
||||
assert.ok(open >= 0, "no @supports selector(:has(a)) block");
|
||||
const open = stylesheet.lastIndexOf("@supports selector(:has(a)) {", stylesheet.indexOf(".nav-node > .nav-children"));
|
||||
assert.ok(open >= 0, "no @supports selector(:has(a)) block before the fold rule");
|
||||
const close = blockEnd(open);
|
||||
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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,13 +6,13 @@
|
||||
|
||||
`Bar = 1`
|
||||
|
||||
`Next ID = 44`
|
||||
`Next ID = 45`
|
||||
|
||||
## Items
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -27,11 +27,13 @@
|
||||
|
||||
## 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.
|
||||
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.
|
||||
`.segmented input` and `.chip input` are `opacity: 0`, and only `:has(input:checked)` and
|
||||
`:has(input:focus-visible)` rules mark the choice. On Firefox 115, Chromium 87–104 and Safari
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user