feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user