diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 53c6dd92..872db160 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -174,7 +174,8 @@ Also in force: violation. A call site names the ROLE (`SURFACE.panel` / `drawer` / `card` / `menu` / `popover` / `tooltip`, `ui/surfaces.ts`), which brings the tier AND the radius; a page section is a panel, and what floats over undimmed content (a menu, a hover card, a status popover, a toast, a tooltip) is `.glass-menu`. A bare glass - class is a lint error. + class is a lint error. A role's depth is its own shadow (the top highlight + the drop); a `shadow-*` size beside + a role would replace both and is a lint error too — a field that floats takes `shadow-float`, the same shadow. - **Static vs pressable (owner, S3d):** a label that does nothing (a topic, a keyword, a status, a count) is a flat, tonal `Badge` with no edge; everything that does something has an edge (a chip, a trigger, a field). A genre that filters the library is a chip; the same genre where it can't is a badge. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index ee87115d..d8296925 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -170,11 +170,20 @@ const SURFACE_OWNERS = new Set([ "fieldStyles.ts", // the header search floats over the page ]); const GLASS_TIER = /^glass(-card|-menu)?$/; +// A glass surface's depth is its own box-shadow, whose inset top highlight a `shadow-*` utility would +// replace wholesale (the header search and the debug pill lost theirs). A shadow COLOUR only sets a +// variable, and `shadow-float` IS the surface's shadow, so both pass. +const SHADOW_SIZE = /^shadow-(xs|sm|md|lg|xl|2xl|\[.+\]|\(.+\))$/; +const isShadowSize = (w) => SHADOW_SIZE.test(w.slice(w.lastIndexOf(":") + 1)); +const isSurfaceRole = (n) => + n?.type === "MemberExpression" && n.object.type === "Identifier" && n.object.name === "SURFACE"; 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.", + shadow: + "`{{shadow}}` on a glass surface replaces its own shadow and drops the top highlight; the role's depth is the intended one.", }, }, create(context) { @@ -191,6 +200,20 @@ const surfaceRole = { const tier = words.find((w) => GLASS_TIER.test(w)); if (tier) context.report({ node, messageId: "raw", data: { tier } }); }; + // A role plus a shadow size in one class list: `${SURFACE.x} shadow-lg` or clsx(SURFACE.x, "shadow-lg"). + const shadowBeside = (node, parts) => { + if (!parts.some(isSurfaceRole)) return; + for (const part of parts) { + const text = + part.type === "TemplateElement" + ? part.value.cooked + : part.type === "Literal" && typeof part.value === "string" + ? part.value + : ""; + const shadow = text.split(/\s+/).find(isShadowSize); + if (shadow) context.report({ node, messageId: "shadow", data: { shadow } }); + } + }; return { Literal(node) { check(node, node.value); @@ -198,6 +221,12 @@ const surfaceRole = { TemplateElement(node) { check(node, node.value.cooked); }, + TemplateLiteral(node) { + shadowBeside(node, [...node.quasis, ...node.expressions]); + }, + CallExpression(node) { + shadowBeside(node, node.arguments); + }, }; }, }; diff --git a/frontend/src/components/DebugRecorder.tsx b/frontend/src/components/DebugRecorder.tsx index e7584c8c..14ea0335 100644 --- a/frontend/src/components/DebugRecorder.tsx +++ b/frontend/src/components/DebugRecorder.tsx @@ -153,7 +153,7 @@ function RecorderPill() {