feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1)

This commit is contained in:
2026-09-25 00:34:35 +02:00
parent db10bfbbca
commit c47c193ad1
89 changed files with 818 additions and 478 deletions
+45
View File
@@ -159,6 +159,48 @@ const inputPrimitive = {
};
},
};
// B5 (docs/DESIGN.md; E28 S3d): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
// ~140 sites had paired the three glass tiers with five radii and three menu paddings. A class string
// (two or more tokens, so `variant="glass"` passes) holding a bare glass tier is refused outside the
// role table and the control primitives that paint a glass BUTTON face.
const SURFACE_OWNERS = new Set([
"surfaces.ts",
"buttonStyles.ts", // the `glass` button face
"SelectTrigger.tsx",
"fieldStyles.ts", // the header search floats over the page
]);
const GLASS_TIER = /^glass(-card|-menu)?$/;
const surfaceRole = {
meta: {
type: "problem",
messages: {
raw: "A bare `{{tier}}` class: use a role from ui/surfaces.ts (SURFACE.panel / drawer / card / menu / popover / tooltip), which brings the radius too.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && SURFACE_OWNERS.has(file)) return {};
if (path.includes("/src/lib/")) return {}; // no classes live there, only prose ("a glass tuner")
const check = (node, text) => {
if (typeof text !== "string") return;
const words = text.split(/\s+/).filter(Boolean);
// A lone word is a value (`variant="glass"`); a class string has company, or is a template
// fragment that a space joins to its neighbour.
if (words.length < 2 && text === text.trim()) return;
const tier = words.find((w) => GLASS_TIER.test(w));
if (tier) context.report({ node, messageId: "raw", data: { tier } });
};
return {
Literal(node) {
check(node, node.value);
},
TemplateElement(node) {
check(node, node.value.cooked);
},
};
},
};
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
@@ -236,6 +278,7 @@ const localRules = {
"focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"input-primitive": inputPrimitive,
"surface-role": surfaceRole,
"literal-a11y-text": literalA11yText,
},
};
@@ -292,6 +335,7 @@ export default tseslint.config(
"local/focus-safe-disabled": "error",
"local/button-primitive": "error",
"local/input-primitive": "error",
"local/surface-role": "error",
"local/literal-a11y-text": "error",
"local/sortable-a11y": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
@@ -456,6 +500,7 @@ export default tseslint.config(
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
"local/button-primitive": "off",
"local/input-primitive": "off",
"local/surface-role": "off",
},
},
prettier,