From 86f467bd65a9b4401f33d45ebc60f546a26dc0e8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 05:25:55 +0200 Subject: [PATCH] feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1) Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`, `media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held by theme.contract.test.ts. Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor, Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are 153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play). --- CLAUDE.md | 4 +- docs/DESIGN.md | 17 +- frontend/src/components/AddToPlaylist.tsx | 19 +- frontend/src/components/AdminUsers.tsx | 176 +++++++++--------- frontend/src/components/ChannelPage.tsx | 13 +- frontend/src/components/ChannelTags.tsx | 14 +- frontend/src/components/ChatThread.tsx | 15 +- frontend/src/components/DownloadButton.tsx | 24 ++- frontend/src/components/DownloadCenter.tsx | 14 +- frontend/src/components/Feed.tsx | 62 +++--- frontend/src/components/IssueComposer.tsx | 25 +-- frontend/src/components/Messages.tsx | 23 ++- frontend/src/components/NavSidebar.tsx | 53 +++--- .../src/components/NotificationsPanel.tsx | 35 +--- .../src/components/PersonalViewsModal.tsx | 83 ++++----- frontend/src/components/PlayerModal.tsx | 136 ++++++-------- frontend/src/components/Playlists.tsx | 22 +-- frontend/src/components/PlexInfo.tsx | 95 +++++----- frontend/src/components/PlexPlaylistView.tsx | 148 +++++++-------- frontend/src/components/Scheduler.tsx | 63 +++---- frontend/src/components/SettingsPanel.tsx | 39 ++-- frontend/src/components/ShareDialog.tsx | 41 ++-- frontend/src/components/VideoCard.tsx | 143 +++++++------- frontend/src/components/VideoEditor.tsx | 91 ++++----- frontend/src/components/Welcome.tsx | 73 ++++---- frontend/src/components/plexCards.tsx | 78 ++++---- frontend/src/components/plexDetailUi.tsx | 58 +++--- frontend/src/components/ui/Banner.tsx | 15 +- .../src/components/ui/Button.dom.test.tsx | 86 ++++++--- frontend/src/components/ui/Button.tsx | 173 ++++++++--------- frontend/src/components/ui/DragHandle.tsx | 40 ++++ .../src/components/ui/FocusSafeButton.tsx | 5 +- .../src/components/ui/IconButton.dom.test.tsx | 138 ++++++++++++++ frontend/src/components/ui/IconButton.tsx | 109 +++++++++++ frontend/src/components/ui/Modal.tsx | 14 +- frontend/src/components/ui/Pressable.tsx | 35 ++++ frontend/src/components/ui/README.md | 13 +- frontend/src/components/ui/SidePanel.tsx | 25 ++- frontend/src/design-lab/batchA.tsx | 89 +++------ frontend/src/design-lab/labTokens.ts | 1 - frontend/src/index.css | 16 +- frontend/src/lib/theme.contract.test.ts | 17 ++ 42 files changed, 1304 insertions(+), 1036 deletions(-) create mode 100644 frontend/src/components/ui/DragHandle.tsx create mode 100644 frontend/src/components/ui/IconButton.dom.test.tsx create mode 100644 frontend/src/components/ui/IconButton.tsx create mode 100644 frontend/src/components/ui/Pressable.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 846ffe0f..53e6a54f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` | | Request body | a Pydantic model | `payload: dict` | -| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself | +| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself | | Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` | | Admin gate import | `app.auth` | the `app.routes.admin` re-export | | Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) | @@ -92,7 +92,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory | Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals | | Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers | | FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks | -| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled ` + ); }; @@ -220,25 +221,25 @@ export default function NavSidebar({
-
- +
- + 0 && (
{otherAccounts.map((a) => ( - + ))}
)}
- - +
diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 2f828a21..52f7d3bf 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -38,6 +38,7 @@ import { } from "../lib/notifications"; import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; import { LEVEL_STYLE } from "./ui/toastLevels"; import { PageToolbar } from "./ui/PageShell"; import { LoadingState, StateMessage } from "./ui/QueryState"; @@ -346,23 +347,13 @@ function NotificationRow({
{!n.read && ( -
); @@ -498,15 +489,9 @@ function ClientActivityRow({ ) )} - - + {view.is_default && } {active && } @@ -214,16 +207,15 @@ export default function PersonalViewsModal() { placeholder={t("views.namePlaceholder")} className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30" /> - - } onClick={async () => { const saved = await saveCurrent(selected.id); // Re-seed the draft to what we just stored (the live filters) so the chip row @@ -305,36 +300,32 @@ export default function PersonalViewsModal() { title={ saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint") } - className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-40" > - {t("views.saveCurrent")} - - - + ) : ( diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index c3a3e8b0..ce6497ed 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -61,7 +61,9 @@ import { useYtVolume, } from "../lib/playerModalHooks"; import { useScrollFade } from "../lib/useScrollFade"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // Experiment (branch experiment/inline-player): play the video in-app via the // YouTube IFrame Player API (not a bare embed) so we can read playback position @@ -574,7 +576,7 @@ export default function PlayerModal({ where there's no room beside the card. */}
{hasQueue && ( - { e.stopPropagation(); goPrev(); @@ -585,7 +587,7 @@ export default function PlayerModal({ className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid" > - + )}
(a body-portaled node is painted underneath the element holding the lock, i.e. invisible). */} + {!navigated && } {!navigated && ( - + )} {!navigated && ( - - )} - {!navigated && ( - + )} - - +
)} {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */} @@ -802,14 +788,15 @@ export default function PlayerModal({ {navigated && ( - + )} {t("player.noDescription")}
)} - + + {/* Channel + meta on one line, with the watched toggle pushed to the right. @@ -952,60 +940,56 @@ export default function PlayerModal({ own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there the docked bar offers OUR true fullscreen. */} {!navigated && ( - - )} - {!navigated && ( - + + )} + {!navigated && } {!navigated && ( )} {!navigated && onRemoveFromList && ( - + + )} {!navigated && ( - + )} {hasQueue && ( - { e.stopPropagation(); goNext(); @@ -1016,7 +1000,7 @@ export default function PlayerModal({ className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid" > - + )} {/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 20f8f7d8..4d53dc01 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -49,6 +49,7 @@ import { formatRuntime } from "../lib/format"; import { applyView, sameOrder } from "../lib/playlistView"; const PlayerModal = lazy(() => import("./PlayerModal")); import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { useComfortPin, pinVideo } from "./comfortPin"; @@ -964,26 +965,25 @@ export default function Playlists() { )} {linked && detail.dirty && ( - + )} {editable && ( - + ) : ( @@ -257,13 +259,13 @@ function CrewPanel({ {crew.map((c, i) => (
{onOpenPerson ? ( - + ) : (
{c.name}
)} @@ -471,13 +473,14 @@ export default function PlexInfo({ } /> {overlay && onClose && ( - + + )}
@@ -592,57 +595,41 @@ export default function PlexInfo({ {!overlay && (
{onPlay && ( - + )} {detail.status === "watched" ? ( - + ) : ( - + )} {inProgress && ( - + )} {detail.kind === "movie" && ( - + )} {isAdmin && detail.kind === "movie" && library && ( - + )}
)} @@ -709,8 +696,9 @@ export default function PlexInfo({ ); return clickable ? ( - + ) : (
{inner} @@ -747,27 +735,28 @@ export default function PlexInfo({ {col.title} {onFilter && ( - + )}
{col.items.map((m, i) => ( - onPlayItem?.(m.id)} disabled={!onPlayItem} - className="group/col w-28 shrink-0 text-left" + className="group/col w-28 shrink-0" >
{m.year ?
{m.year}
: null} - + ))}
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index ee942539..e4ac92e9 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -16,7 +16,6 @@ import { ArrowLeft, ChevronDown, ChevronRight, - GripVertical, ListTree, Pencil, Play, @@ -32,7 +31,9 @@ import { useConfirm } from "./confirmContext"; import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; +import { DragHandle } from "./ui/DragHandle"; import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits"; import { sameListCollision, @@ -128,21 +129,6 @@ function Poster({ src, className }: { src: string; className: string }) { ); } -/** `title` names the row it moves: a screen reader heard the English word "reorder" on every - * row, with nothing to say which. */ -function DragHandle({ title, ...props }: { title: string } & Record) { - const { t } = useTranslation(); - return ( - - ); -} - function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ @@ -161,13 +147,17 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean style={style} className={`flex items-center gap-2.5 rounded-lg ${dense ? "px-1.5 py-1" : "glass-card p-1.5"}`} > - + {ep.episode_number} - - + ctx.onRemove([ep.id])} disabled={ctx.busy} - className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30" - aria-label={t("plex.playlist.removeItem", { title: ep.title })} + label={t("plex.playlist.removeItem", { title: ep.title })} > - - + + ); } @@ -215,10 +205,14 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) { style={style} className="glass-card flex items-center gap-3 rounded-xl p-2" > - - - + ctx.onRemove([item.id])} disabled={ctx.busy} - className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30" - aria-label={t("plex.playlist.removeItem", { title: item.title })} + label={t("plex.playlist.removeItem", { title: item.title })} > - - + + ); } @@ -263,19 +257,22 @@ function ShowGroup({ block, ctx }: { block: Extract; ct
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
- - + {open ? : } + {!dense && ( // A second, pointer-only target for the same toggle (its images are decorative): out of // the tab order, so the keyboard does not stop on a button with no name. - + )} - - + ctx.onRemove(block.items.map((i) => i.id))} disabled={ctx.busy} - title={t("plex.playlist.removeShow")} - className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30" + label={t("plex.playlist.removeShow")} > - - + +
{open && ( @@ -322,29 +319,26 @@ function ShowGroup({ block, ctx }: { block: Extract; ct
{dense && ( - + {sOpen ? : } + )} {seasonLabel(s)} · {s.items.length} - ctx.onRemove(s.items.map((i) => i.id))} disabled={ctx.busy} - title={t("plex.playlist.removeSeason")} - className="ml-1 rounded-sm p-0.5 text-muted hover:text-danger disabled:opacity-30" + label={t("plex.playlist.removeSeason")} + className="ml-1" > - - + +
{sOpen && (
- + + {items.length > 0 && ( - + )} - + + +
{q.isError && !q.data ? ( diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index bb3659f7..0a0c8a28 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -26,7 +26,8 @@ import { formatNumber, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./ui/Tooltip"; import { notifyActionError } from "../lib/actionErrors"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; const POLL_MS = 4000; // While any job is running, poll faster so short scheduled runs aren't missed between ticks. @@ -200,21 +201,12 @@ function JobRow({ }`} /> {t("scheduler.minutes")} - - - - + + + + + + {!valid && ( {t("scheduler.range", { min: 1, max: 1440 })} @@ -223,14 +215,14 @@ function JobRow({ ) : ( - + )}
@@ -261,14 +253,14 @@ function JobRow({ )}
- - - + + ); @@ -516,17 +508,20 @@ export default function Scheduler() {
- } onClick={() => runAllMut.mutate()} disabled={runAllMut.isPending || data.paused} - className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" > - {t("scheduler.runAll")} - + - { const ok = await confirm({ title: t("scheduler.purgeConfirmTitle"), @@ -537,21 +532,21 @@ export default function Scheduler() { if (ok) purgeMut.mutate(); }} disabled={purgeMut.isPending} - className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" > {t("scheduler.purgeDiscovery")} - + - : } onClick={() => pauseResume.mutate(data.paused)} disabled={pauseResume.isPending} - className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" > - {data.paused ? : } {data.paused ? t("scheduler.resume") : t("scheduler.pause")} - +
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index c69407f3..fff99313 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -24,6 +24,7 @@ import { START_ON_HOME } from "./navigationInit"; import { usePrefs } from "./prefsContext"; import Tabs from "./ui/Tabs"; import { useMe } from "../lib/useMe"; +import { Pressable } from "./ui/Pressable"; // The Settings page edits server-persisted preferences. Each change applies locally for instant // preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the @@ -147,7 +148,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
{SCHEMES.map((s) => ( -
)} - + ); } @@ -439,14 +441,15 @@ function SignInMethods({ me }: { me: Me }) { {t("settings.account.googleLink.connected")}
) : ( - + )} )} @@ -461,17 +464,19 @@ function SignInMethods({ me }: { me: Me }) { : t("settings.account.password.unsetHint")}

- + {open && (
@@ -598,13 +603,15 @@ function PlexWatchSync() { ? t("settings.plexSync.lastSync", { when: last }) : t("settings.plexSync.never")}

- + ) )} @@ -861,9 +868,9 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { }} /> - + ) : null} diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 0ea941bd..299b49ff 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -13,6 +13,7 @@ import { formatDate } from "../lib/format"; import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api"; import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; import { FocusSafeButton } from "./ui/FocusSafeButton"; const EXPIRY_DAYS = [null, 1, 7, 30] as const; @@ -148,39 +149,35 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } value={fullUrl} className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden" /> - + {/* All three on the same rung. Only Copy was migrated in S1, which left one 2rem button between two 1.75rem ones with a different radius and hover fill — and, on a coarse pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive Revoke stayed small (E10 review round 1). */} - - - - - + {video.status === "hidden" ? : } + {onResetState && resettable && ( - + + )} ); @@ -285,45 +280,59 @@ function Thumb({
{inProgress ? ( <> - - + {small ? ( + + + + ) : ( + + )} + {small ? ( + + + + ) : ( + + )} ) : ( - + + )}
)} diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index b9eddea7..1c48e5ec 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -8,11 +8,12 @@ import Modal from "./ui/Modal"; import { notify } from "../lib/notifications"; import { api, type DownloadJob, type EditSpec } from "../lib/api"; import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry"; -import { inputCls, btnCls } from "./ui/form"; +import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; import { failureReason, notifyActionError } from "../lib/actionErrors"; import { markReported } from "../lib/api/core"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // mm:ss.d timecode <-> seconds. function tc(s: number): string { @@ -431,23 +432,21 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos {/* transport */}
- + + {playing ? : } +
{tc(current)} / {tc(duration)}
- + {t("editor.splitHere")} +
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */} @@ -499,7 +498,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")} > {/* keep/drop toggle */} - +
))} {/* draggable boundary markers */} @@ -528,7 +528,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos setDrag({ type: "boundary", bi }); }} /> - + ))} {/* playhead */} @@ -571,16 +571,15 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos {sel && (
{t("editor.segment", { n: selIdx + 1 })} - +
{/* Quick watched toggle (whole season), on hover. */} {onToggleWatched && ( - + )} {/* Add whole season to a playlist, on hover. */} {onAdd && ( - + + )} {watched && ( ); return clickable ? ( - + ) : (
{inner} @@ -428,12 +429,12 @@ export function RelatedStrip({

{t("plex.series.related")}

{related.map((r, i) => ( - + ))} @@ -506,17 +507,17 @@ export function EpisodeCard({
{/* Quick watched toggle (single episode), on hover. */} {onToggleWatched && ( - + )} {ep.status === "watched" && ( {formatRuntime(ep.duration_seconds)}
{onAdd && ( - + + + )} diff --git a/frontend/src/components/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx index 1b0e9cb5..47faf675 100644 --- a/frontend/src/components/plexDetailUi.tsx +++ b/frontend/src/components/plexDetailUi.tsx @@ -9,7 +9,9 @@ import { type LucideIcon, } from "lucide-react"; import { useDismiss } from "../lib/useDismiss"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages, // so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two @@ -32,13 +34,13 @@ export function Filterable({ }) { if (!onClick) return {children}; return ( - + ); } @@ -84,14 +86,16 @@ export function DetailCustomizeMenu({ // any of them needs the platform to portal INTO a caller-named (scaled) container. return (
- + + {open && (
void; }) { return ( - + ); } @@ -156,13 +163,9 @@ export function PrefToggle({ export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) { return ( - + ); } @@ -182,19 +185,16 @@ export function ActionBtn({ disabled?: boolean; }) { return ( - } > - - - {label} - {sub && {sub}} - - + {label} + {/* The resume point rides on the same line: the md rung is one line tall (B1). */} + {sub && · {sub}} + ); } @@ -224,7 +224,7 @@ export function DetailHero({ return (
{customizeMenu}
-
)} - +
{children}
); diff --git a/frontend/src/components/ui/Banner.tsx b/frontend/src/components/ui/Banner.tsx index 4aa46974..faf180c9 100644 --- a/frontend/src/components/ui/Banner.tsx +++ b/frontend/src/components/ui/Banner.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { LucideIcon } from "lucide-react"; import { X } from "lucide-react"; -import { Button } from "./Button"; +import { IconButton } from "./IconButton"; type BannerTone = "accent" | "warning"; @@ -62,14 +62,15 @@ export default function Banner({ )} {onDismiss && ( -
); diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx index bea62648..ae1d4d60 100644 --- a/frontend/src/components/ui/Button.dom.test.tsx +++ b/frontend/src/components/ui/Button.dom.test.tsx @@ -1,9 +1,9 @@ // @vitest-environment jsdom // -// S5: the shared , ); const btn = screen.getByRole("button", { name: "Delete" }); - expect(btn.className).toContain("btn-danger"); - expect(btn.className).toContain("py-1.5"); + expect(btn.className).toContain("state-danger"); + expect(btn.className).toContain("h-7"); }); - it("exposes an accessible name for an icon-only button while dropping the text", () => { - render( - ); + const cls = screen.getByRole("button", { name: "X" }).className.split(/\s+/); + expect(cls).toContain(h); + expect(cls).toContain("rounded-lg"); + expect(cls.filter((c) => c.startsWith("py-"))).toEqual([]); + unmount(); + } + }); + + it("hovers with a state layer, never an opacity dim or a card fill (B3)", () => { + for (const variant of ["outline", "ghost", "outline-danger", "danger-soft"] as const) { + const { unmount } = render(); + const cls = screen.getByRole("button", { name: "X" }).className; + expect(cls).toContain("state-layer"); + expect(cls).not.toMatch(/hover:(bg-card|bg-surface|opacity)/); + unmount(); + } + }); + + it("a toggle announces aria-pressed and paints the pressed state", () => { + const { rerender } = render( + , ); - const btn = screen.getByRole("button", { name: "Block channel" }); - expect(btn.getAttribute("title")).toBe("Block channel"); - expect(btn.textContent).not.toContain("this text is dropped"); - expect(btn.querySelector("[data-testid=ic]")).not.toBeNull(); + const btn = screen.getByRole("button", { name: "Default" }); + expect(btn.getAttribute("aria-pressed")).toBe("false"); + expect(btn.className).toContain("aria-pressed:bg-accent"); + rerender( + , + ); + expect(btn.getAttribute("aria-pressed")).toBe("true"); }); it("loading marks aria-busy + aria-disabled, blocks clicks, and swaps the icon", () => { @@ -112,7 +142,7 @@ describe("Button primitive (S5)", () => { , ); btn = screen.getByRole("button", { name: "Show all" }); - expect(btn.className).toContain("px-3"); + expect(btn.className).toContain("px-2.5"); }); it("keeps one font-weight utility whatever the variant", () => { @@ -132,10 +162,13 @@ describe("Button primitive (S5)", () => { const { unmount } = render(); expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target"); unmount(); - render(); - expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain( - "touch-target", - ); + for (const variant of ["link", "link-muted"] as const) { + const { unmount: u } = render(); + expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain( + "touch-target", + ); + u(); + } }); it("fullWidth adds the w-full utility", () => { @@ -205,16 +238,13 @@ describe("Button accessible-name contract (type level)", () => { it("rejects a text-less button with no name, and accepts every named spelling", () => { render( <> - {/* @ts-expect-error size="icon" with no label/aria-label/title has no accessible name */} - , ); diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index a31ecc5a..4ea21558 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -4,12 +4,12 @@ import clsx from "clsx"; import { Loader2 } from "lucide-react"; import { FocusSafeButton } from "./FocusSafeButton"; -// The ONE action-button primitive. Before this there were 385 raw + ); +}); diff --git a/frontend/src/components/ui/FocusSafeButton.tsx b/frontend/src/components/ui/FocusSafeButton.tsx index 5e83d44a..2005e364 100644 --- a/frontend/src/components/ui/FocusSafeButton.tsx +++ b/frontend/src/components/ui/FocusSafeButton.tsx @@ -38,13 +38,16 @@ export const FocusSafeButton = forwardRef< const focused = focusEvent || (!!node.current && node.current.ownerDocument.activeElement === node.current); const soft = busy || (!!disabled && focused); + // A caller's own aria-disabled is the "unavailable, and here is why" state (the tooltip or label + // carries the reason, so the button stays focusable): it is kept, never overwritten by ours. + const callerSoft = rest["aria-disabled"] === true || rest["aria-disabled"] === "true"; return ( + {iconExpanded ?? {icon}} {onTitleClick ? ( + ); } const ico = "w-4 h-4"; -function IconBtn({ box, children }: { box: string; children: ReactNode }) { - return ( - - ); -} - function OnMedia() { - const b = "grid h-8 w-8 place-items-center rounded-full bg-black/55 text-white hover:bg-black/75"; return (
- - + + + + + +
); @@ -178,9 +149,9 @@ export function BatchA() { }> Sync now - - - + + + @@ -215,26 +186,26 @@ export function BatchA() {
Techno Tim - - - - - - - - - + + + + + + + + +
- - - - - - + + + + + +
diff --git a/frontend/src/design-lab/labTokens.ts b/frontend/src/design-lab/labTokens.ts index 6f22f927..719258a1 100644 --- a/frontend/src/design-lab/labTokens.ts +++ b/frontend/src/design-lab/labTokens.ts @@ -19,7 +19,6 @@ export const STRONG = "var(--edge-strong)"; /** B3 state layers (index.css utilities). */ export const LAYER = "state-layer"; export const FILLED = "state-filled"; -export const DANGER = "state-danger"; /** B9: the header + fade edge (index.css utilities; the host is positioned). */ export const FADE_BELOW = "fade-below"; diff --git a/frontend/src/index.css b/frontend/src/index.css index 189836a8..41fe76fa 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -64,12 +64,14 @@ /* ===== E28 tokens as utilities (docs/DESIGN.md) — the ONE source the ui/ primitives and the design lab both read, so a decided value is never restated as an arbitrary class. ===== */ -/* B3: a transparent control's hover / pressed layer. */ +/* B3: a transparent control's hover / pressed layer. The layer answers only a control that can act: + not a disabled one, not a busy one (FocusSafeButton's aria-disabled), and not a pressed toggle, whose + solid accent is its state and must not turn grey under the pointer. */ @utility state-layer { - &:hover { + &:hover:not(:disabled, [aria-disabled="true"], [aria-pressed="true"]) { background-color: var(--hover); } - &:active { + &:active:not(:disabled, [aria-disabled="true"], [aria-pressed="true"]) { background-color: var(--press); } } @@ -77,20 +79,20 @@ @utility state-filled { background-color: var(--accent); color: var(--accent-fg); - &:hover { + &:hover:not(:disabled, [aria-disabled="true"]) { background-color: color-mix(in srgb, var(--accent-fg) 14%, var(--accent)); } - &:active { + &:active:not(:disabled, [aria-disabled="true"]) { background-color: color-mix(in srgb, var(--accent-fg) 24%, var(--accent)); } } @utility state-danger { background-color: var(--danger); color: var(--danger-fg); - &:hover { + &:hover:not(:disabled, [aria-disabled="true"]) { background-color: color-mix(in srgb, var(--danger-fg) 14%, var(--danger)); } - &:active { + &:active:not(:disabled, [aria-disabled="true"]) { background-color: color-mix(in srgb, var(--danger-fg) 24%, var(--danger)); } } diff --git a/frontend/src/lib/theme.contract.test.ts b/frontend/src/lib/theme.contract.test.ts index 64bc24a5..93396a90 100644 --- a/frontend/src/lib/theme.contract.test.ts +++ b/frontend/src/lib/theme.contract.test.ts @@ -40,6 +40,23 @@ it("styles exactly the schemes the app offers", () => { expect(css).toMatch(/--edge-strong:\s*color-mix\(in srgb, var\(--fg\) 55%, var\(--bg\)\)/); }); +// B3: the state layers answer only a control that can act. A disabled or busy button that lights up +// advertises a click that does nothing, and a pressed toggle that greys under the pointer hides its +// own state; the primitives (Button, IconButton) rely on the utilities to hold this for them. +it("the state layers stand down on a disabled, busy or pressed control", () => { + const guard = /:not\(:disabled, \[aria-disabled="true"\]/; + for (const name of ["state-layer", "state-filled", "state-danger"]) { + const at = css.indexOf(`@utility ${name} {`); + const body = css.slice(at, css.indexOf("\n}", at)); + const states = [...body.matchAll(/&:(hover|active)([^{]*)\{/g)]; + expect(states.length, name).toBe(2); + for (const [, state, sel] of states) expect(sel, `${name} :${state}`).toMatch(guard); + } + expect(css.slice(css.indexOf("@utility state-layer {"))).toMatch( + /&:hover:not\([^)]*\[aria-pressed="true"\]\)/, + ); +}); + // The 4.5:1 above only holds for text that USES --accent-fg: a fixed white or black on a solid accent // fill passes on one scheme and fails on the next (white on the old Matrix lime was 1.5:1). it("text on a solid accent fill uses --accent-fg", () => {