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", () => {