From 1833f594cb0c2d9580fe34bbddfe0e7384b8d09d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 25 Sep 2026 01:16:20 +0200 Subject: [PATCH] =?UTF-8?q?fix(design):=20an=20audit=20of=20the=20S3d=20cl?= =?UTF-8?q?aims=20=E2=80=94=20the=20float=20shadow,=20filter=20hints,=20on?= =?UTF-8?q?e=20action=20size=20per=20row=20(E28=20S3d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A glass role's shadow is one token (shadow-float); the header search's drop-shadow utility had replaced its top highlight, and the debug pill's shadow-lg did the same. The surface-role lint rule now refuses a shadow size beside a role. - Plex: every value that filters carries a hint (genre, certificate, studio had none), and the facts panel's year and certificate filter like the header's. filterKeyword -> filterValue. - Downloads: the row resized its buttons with a [&_button] override that the save link escaped (28px beside 32px); the size is the primitive's md rung now. --- docs/DESIGN.md | 3 +- frontend/eslint.config.js | 29 +++++++++++++++ frontend/src/components/DebugRecorder.tsx | 2 +- frontend/src/components/DownloadCenter.tsx | 2 +- frontend/src/components/PlexInfo.dom.test.tsx | 20 +++++++++++ frontend/src/components/PlexInfo.tsx | 35 ++++++++++++++++--- .../src/components/downloadRow.dom.test.tsx | 14 ++++++++ frontend/src/components/downloadRow.tsx | 3 +- frontend/src/components/plexDetailUi.tsx | 15 +++++--- frontend/src/components/plexViews.tsx | 25 ++++++++----- frontend/src/components/ui/fieldStyles.ts | 5 +-- frontend/src/i18n/locales/en/plex.json | 2 +- frontend/src/i18n/locales/hu/plex.json | 2 +- frontend/src/index.css | 10 ++++-- frontend/src/lib/surfaceRole.contract.test.ts | 22 ++++++++++++ 15 files changed, 160 insertions(+), 29 deletions(-) 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() { +
{children}
); } diff --git a/frontend/src/components/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx index 1fe3ff73..b75f3343 100644 --- a/frontend/src/components/plexDetailUi.tsx +++ b/frontend/src/components/plexDetailUi.tsx @@ -22,8 +22,9 @@ import { Chip } from "./ui/Chip"; // customize menu that toggles them. The prefs hook, backdrop helpers and epLabel live in plexDetail.ts. // A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared -// by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). The optional -// `title` gives a hover hint (e.g. "Filter by 1998"); callers that don't need it just omit it. +// by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). A filtering value +// always says so on hover: `title` is a caller's specific hint ("Show titles from 1998"), else the +// generic "Filter by …" from the value's own text. export function Filterable({ onClick, title, @@ -41,9 +42,15 @@ export function Filterable({ onDark?: boolean; children: ReactNode; }) { + const { t } = useTranslation(); + const hint = + title ?? + (onClick && (typeof children === "string" || typeof children === "number") + ? t("plex.info.filterValue", { name: children }) + : undefined); if (tag) return onClick ? ( - + {children} ) : ( @@ -53,7 +60,7 @@ export function Filterable({ return ( {children} diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 9d080aa0..7942b32f 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -397,6 +397,7 @@ export function PlexShowView({
{show.year != null && ( onFilter({ yearMin: show.year, yearMax: show.year }) @@ -407,15 +408,18 @@ export function PlexShowView({ )} {show.content_rating && ( - onFilter({ contentRatings: [show.content_rating!] }) - : undefined - } - > - · {show.content_rating} - + + ·{" "} + onFilter({ contentRatings: [show.content_rating!] }) + : undefined + } + > + {show.content_rating} + + )} {show.season_count != null && ( · {t("plex.seasons", { count: show.season_count })} @@ -432,6 +436,9 @@ export function PlexShowView({ onClick={ onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined } + title={ + onFilter ? t("plex.info.filterRating", { n: Math.floor(score) }) : undefined + } className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`} > {/* decorative rating gold — intentionally NOT a status token */} diff --git a/frontend/src/components/ui/fieldStyles.ts b/frontend/src/components/ui/fieldStyles.ts index 1c80e27d..5877ca73 100644 --- a/frontend/src/components/ui/fieldStyles.ts +++ b/frontend/src/components/ui/fieldStyles.ts @@ -40,7 +40,8 @@ const BASE = // `floating`: the field hovers over the scrolling page (the header search), so it keeps the glass-menu // surface under its edge instead of a see-through one (A2: what floats over content is glass). The -// shadow is a utility so the focus ring's box-shadow composes with it instead of dropping it. +// shadow is the `shadow-float` utility (highlight + drop, the menu's own) so the focus ring's box-shadow +// composes with it instead of dropping it. export function fieldClass({ size = "md", pill = false, @@ -51,6 +52,6 @@ export function fieldClass({ BASE, SIZES[size], pill ? "rounded-full" : "rounded-lg", - floating ? "glass-menu shadow-[0_18px_44px_-16px_rgba(0,0,0,0.6)]" : "bg-transparent", + floating ? "glass-menu shadow-float" : "bg-transparent", ); } diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index eec8da42..6b51bb30 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -267,7 +267,7 @@ "audience": "Audience", "keywords": "Keywords", "crew": "Crew", - "filterKeyword": "Filter by \"{{name}}\"", + "filterValue": "Filter by \"{{name}}\"", "facts": { "title": "Details", "release": "Released", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index d1e8420c..158fd1da 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -267,7 +267,7 @@ "audience": "Közönség", "keywords": "Kulcsszavak", "crew": "Stáb", - "filterKeyword": "Szűrés erre: „{{name}}”", + "filterValue": "Szűrés erre: „{{name}}”", "facts": { "title": "Részletek", "release": "Megjelenés", diff --git a/frontend/src/index.css b/frontend/src/index.css index aa4ea31f..f8d22495 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -18,6 +18,12 @@ makes the v4 opacity modifier (bg-accent/40 → color-mix(… var(--accent) 40% …)) pick up the active scheme's color. Same pattern as shadcn's v4 theme setup. */ @theme inline { + /* A floating glass surface's shadow: the top specular highlight + the drop. One token so a field that + floats (the header search, `shadow-float`) keeps the highlight a menu has — a bare drop-shadow + utility would replace both. */ + --shadow-float: + inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent), + 0 18px 44px -16px rgba(0, 0, 0, 0.6); --color-bg: var(--bg); --color-surface: var(--surface); --color-card: var(--card); @@ -499,9 +505,7 @@ body { #fff var(--glass-edge), color-mix(in srgb, var(--border) 80%, transparent) ); - box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent), - 0 18px 44px -16px rgba(0, 0, 0, 0.6); + box-shadow: var(--shadow-float); } /* R9: standard interactive-row recipe. The hover tint menu/list rows already use, PLUS the diff --git a/frontend/src/lib/surfaceRole.contract.test.ts b/frontend/src/lib/surfaceRole.contract.test.ts index 6f6412c1..1dc339a9 100644 --- a/frontend/src/lib/surfaceRole.contract.test.ts +++ b/frontend/src/lib/surfaceRole.contract.test.ts @@ -42,3 +42,25 @@ describe("a bare glass tier", () => { ).toBe(0); }); }); + +// A role brings its own shadow, whose inset top highlight a `shadow-*` size would replace wholesale. +describe("a shadow size beside a role", () => { + const SUR = 'import clsx from "clsx";\nimport { SURFACE } from "./ui/surfaces";\n'; + const tpl = (cls: string) => + `${SUR}export const A = () =>
;\n`; + const call = (role: string, cls: string) => + `${SUR}export const B = () =>
;\n`; + + it("is refused in a template and in clsx, variants included", async () => { + expect(await hits(tpl("h-12 shadow-lg"))).toBe(1); + expect(await hits(call("menu", "hover:shadow-2xl"))).toBe(1); + }); + + it("passes a shadow colour, shadow-float, and a shadow with no role beside it", async () => { + expect(await hits(tpl("shadow-black/20"))).toBe(0); + expect(await hits(call("popover", "shadow-float"))).toBe(0); + expect(await hits('export const E = () =>
;\n')).toBe( + 0, + ); + }); +});