diff --git a/public/css/styles.css b/public/css/styles.css
index 7a14441..32cdef3 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -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;
@@ -540,15 +538,14 @@ span.nav-self { cursor: default; } /* static / non-clickable */
@supports (overflow-wrap: anywhere) {
.menu-pop { overflow-wrap: anywhere; }
}
-@supports (position-anchor: auto) and (position-try-fallbacks: flip-block, flip-inline) and (top: anchor(bottom)) {
- .menu-pop {
- position-anchor: auto;
- position-try-fallbacks: flip-block, flip-inline;
- top: anchor(bottom); right: anchor(right);
- }
+@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 —
diff --git a/src/ui/stylesheet-floor.test.ts b/src/ui/stylesheet-floor.test.ts
index d4f1aa1..df44fc4 100644
--- a/src/ui/stylesheet-floor.test.ts
+++ b/src/ui/stylesheet-floor.test.ts
@@ -3,7 +3,8 @@ import { globSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
-import { floorViolations } from "./stylesheet-floor.ts";
+import type { SupportStatement } from "@mdn/browser-compat-data";
+import { floorViolations, lacking } from "./stylesheet-floor.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cssDir = join(root, "public", "css");
@@ -22,6 +23,27 @@ test("floorViolations flags a feature the floor lacks, with its line and the fir
"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", () => {
@@ -37,16 +59,25 @@ test("floorViolations allows a feature inside an @supports that tests it, and on
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 passes what the floor has, a vendor prefix and a comment", () => {
+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) { :root { color-scheme: dark; } }
+ @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,"); transform: rotate(45deg); }
- .b { -webkit-font-smoothing: antialiased; -webkit-user-select: none; }
- summary::-webkit-details-marker { display: none; }`;
+ .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), []);
});
@@ -54,9 +85,26 @@ test("floorViolations refuses what MDN has no entry for, so a typo or a new feat
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 stylesheets = globSync(["public/**/*.css", "examples/**/*.css"], { cwd: root, exclude: ["**/node_modules/**"] });
+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"));
diff --git a/src/ui/stylesheet-floor.ts b/src/ui/stylesheet-floor.ts
index 03caaf5..59f2660 100644
--- a/src/ui/stylesheet-floor.ts
+++ b/src/ui/stylesheet-floor.ts
@@ -1,14 +1,18 @@
-// README → "Two browser floors", checked against MDN's compat data: a feature one of FLOOR's engines
-// lacks passes only inside an `@supports` whose condition names it. Checked: at-rules, media features,
-// pseudo-classes and -elements, properties, and in a value its functions, its units and the keywords
-// MDN lists for that property. A partial implementation counts as support. A vendor-prefixed name is
-// one engine's by design and is skipped, as are `font-family` names: an engine skips a family it lacks.
-// Not checked: the body of an at-rule other than @media and @supports, and syntax MDN has no entry for,
-// such as media range syntax.
+// 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 } from "@mdn/browser-compat-data";
+import type { CompatStatement, Identifier, SimpleSupportStatement, SupportBlock } from "@mdn/browser-compat-data";
-const FLOOR = { chrome: "87", safari: "15", firefox: "115" };
+const FLOOR = [["chrome", "87"], ["safari", "15"], ["firefox", "115"]] as const;
interface Feature {
label: string;
@@ -25,10 +29,24 @@ interface Sheet {
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 {
@@ -39,6 +57,8 @@ function blank(css: string): string {
});
}
+// 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;
@@ -46,7 +66,7 @@ function closing(text: string, open: number): number {
if (text[i] === opener) depth++;
if (text[i] === closer && --depth === 0) return i;
}
- throw new Error(`unbalanced ${opener} at offset ${open}`);
+ return text.length;
}
function walk(sheet: Sheet, start: number, end: number, guards: string[]): void {
@@ -70,11 +90,7 @@ function atRule(sheet: Sheet, prelude: string, offset: number, guards: string[],
if (name.startsWith("-")) return;
check(sheet, guards, { label: `@${name}`, path: `css.at-rules.${name}`, guard: never, offset });
const condition = prelude.slice(name.length + 1);
- if (name === "media") {
- for (const m of condition.matchAll(/\(\s*(?:min-|max-)?([a-z][\w-]*)\s*[:)]/gi)) {
- check(sheet, guards, { label: `(${m[1]})`, path: `css.at-rules.media.${m[1]}`, guard: never, offset });
- }
- }
+ if (name === "media") mediaQuery(sheet, guards, condition, offset);
if (name === "supports" && /(?= 0; at = rest.search(/(?=]/.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 });
}
- return rest;
}
+// 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 = /(? `css.types.${type}.${name}`);
const paths = [`css.properties.${property}.${name}`, `css.types.${name}`, ...types].filter((path) => entry(path));
- return paths.find((path) => lacking(entry(path)).length === 0) ?? paths[0];
+ return paths.find((path) => lacking(entry(path)?.support ?? {}).length === 0) ?? paths[0];
}
const VIEWPORT: Record = { d: "dynamic", s: "small", l: "large" };
@@ -159,36 +201,36 @@ function unitPath(unit: string): string | undefined {
function entry(path: string | undefined): CompatStatement | undefined {
if (!path) return undefined;
- let node: Identifier | undefined = bcd as unknown as Identifier;
+ 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; true when it is fine as written.
+// 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 = sheet.css.slice(0, feature.offset).split("\n").length;
+ 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);
+ 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 release that brought it, as "safari 15.4".
-function lacking(compat: CompatStatement | undefined): string[] {
- return Object.entries(FLOOR).flatMap(([browser, floor]) => {
- const support = compat?.support[browser as keyof typeof compat.support];
- const statements = [support ?? []].flat().filter((s: SimpleSupportStatement) => !s.prefix && !s.alternative_name && !s.flags);
+// 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) ?? []).sort((x, y) => (newer(x, y) ? 1 : -1));
+ 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)"}`];
});
}