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).
This commit is contained in:
2026-09-24 05:25:55 +02:00
parent 7d480e2ad0
commit 86f467bd65
42 changed files with 1304 additions and 1036 deletions
+2 -2
View File
@@ -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 `<button className="px-3 py-2 …">` |
| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) | a hand-rolled `<button className="px-3 py-2 …">` |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
+14 -3
View File
@@ -111,7 +111,9 @@ The chosen rendering of each rule is in the lab under "Decided". The primitives
buttons 1.75rem / 2rem. A toolbar's search pill, dropdown trigger, `sm` button and small icon button are
the same 28, so the row lines up. Nothing goes under 24 (WCAG 2.5.8); touch grows it to 44 (A3).
- **Icon buttons (A2):** two sizes (28 / 32), r8: the smaller in rows and dense toolbars, the larger on its own. On a
picture, a round, dark translucent button, readable on any thumbnail. Round means "on media" only.
picture, a round, dark translucent button, readable on any thumbnail. Round means "on media" only: a text
button on a picture is a pill (the card's Resume in the accent, From the start dark translucent, C5), and
the one 48px glyph is the Play in the middle of a not-started card (C5).
- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`,
`Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse
sizes don't change. [HIG, WCAG 2.5.8]
@@ -125,8 +127,16 @@ The chosen rendering of each rule is in the lab under "Decided". The primitives
Also in force:
- An action button is `ui/Button` with a `SIZES` rung. A hand-rolled `<button className="px-3 …">` is a
violation. A text-less button must have an accessible name, and the type enforces it. [WCAG 4.1.2]
- **One primitive per button family** (E28 S3b): a labelled action is `Button` (a rung from the ladder,
plus `lg` 40 for a page's single hero call to action, and nothing else); a glyph is `IconButton` (a
required `label`); a card, row, tile or menu row whose content is the control is `Pressable`; a filter or
tag chip is the chip family; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme. A pressed
toggle is `pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A hand-rolled
`<button className="px-3 …">` is a violation. A text-less button must have an accessible name, and the
type enforces it. [WCAG 4.1.2]
- **Unavailable with a reason** stays focusable: `aria-disabled` plus the reason in the label or tooltip,
never native `disabled`, which drops it from the tab order with its explanation. [WCAG 2.1.1]
- A choice between a few values is `SegmentedToggle`; a long list or a user's own list is `FilterSelect`.
Never a native `<select>` (lint-enforced).
- A filter chip goes through `FilterChipGroup`.
@@ -303,3 +313,4 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
+11 -8
View File
@@ -9,6 +9,7 @@ import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
@@ -16,10 +17,12 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
export default function AddToPlaylist({
videoId,
className,
variant = "ghost",
size = "sm",
}: {
videoId: string;
className?: string;
variant?: "ghost" | "outline" | "overlay";
size?: "sm" | "md";
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -90,8 +93,10 @@ export default function AddToPlaylist({
return (
<>
<button
<IconButton
ref={pop.refs.setReference}
variant={variant}
size={size}
{...pop.getReferenceProps({
// The trigger sits on a feed card that opens the player on click, so the toggle must not
// reach it.
@@ -100,12 +105,10 @@ export default function AddToPlaylist({
e.stopPropagation();
},
})}
title={t("playlists.addToPlaylist")}
aria-label={t("playlists.addToPlaylist")}
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
label={t("playlists.addToPlaylist")}
>
<ListPlus className="w-4 h-4" />
</button>
<ListPlus />
</IconButton>
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2">
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
+88 -88
View File
@@ -29,6 +29,7 @@ import { formatDateTime, relativeTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { Section } from "./ui/form";
@@ -42,7 +43,7 @@ import { useAccountPersistedState } from "../lib/storage";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Pressable } from "./ui/Pressable";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -230,17 +231,17 @@ function UsersRoles({ me }: { me: Me }) {
const self = u.id === me.id;
return (
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
<button
<Pressable
onClick={() => setPanelFor(u)}
title={t("users.account.open")}
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
className="selectable cursor-pointer min-w-0 flex-1 rounded-lg -m-1 p-1"
>
<div className="text-sm truncate">
{displayName(u)}
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
</div>
<div className="text-2xs text-muted truncate">{u.email}</div>
</button>
</Pressable>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
@@ -283,36 +284,36 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.suspend.action")
}
>
<Button
variant={u.is_suspended ? "outline-warn" : "outline"}
size="icon-sm"
className="shrink-0"
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={
<IconButton
noTitle
label={
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
}
icon={
u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)
}
/>
size="sm"
variant={u.is_suspended ? "outline-warn" : "outline"}
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
>
{u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)}
</IconButton>
</Tooltip>
<Tooltip
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
>
<Button
<IconButton
noTitle
label={t("users.delete.action")}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={() => onDelete(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={t("users.delete.action")}
icon={<Trash2 className="w-4 h-4" />}
/>
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
{/* How long ago this account last signed in. LAST in the row on purpose: the badges and
the role button before it vary in width row to row, so only the row's own right
@@ -459,15 +460,16 @@ function PendingAccessRowView({
</div>
{!row.email_verified && (
<Tooltip hint={t("users.pendingAccess.resendHint")}>
<Button
<IconButton
noTitle
label={t("users.pendingAccess.resend")}
size="sm"
variant="outline"
size="icon-sm"
className="shrink-0"
onClick={onResend}
disabled={busy}
aria-label={t("users.pendingAccess.resend")}
icon={<Send className="w-4 h-4" />}
/>
>
<Send className="w-4 h-4" />
</IconButton>
</Tooltip>
)}
{row.can_activate && (
@@ -729,18 +731,15 @@ function AdminInvites() {
: t("settings.invites.inviteHint")
}
>
<FocusSafeButton
<IconButton
noTitle
onClick={() => onInvite(i)}
disabled={invite.isPending}
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
i.invited_at
? "text-muted/50 hover:text-fg"
: "text-muted hover:text-accent"
}`}
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
label={t("settings.invites.inviteLabel", { email: i.email })}
>
<Mails className="w-3.5 h-3.5" />
</FocusSafeButton>
{/* Already invited: the glyph fades, the action stays (a resend). */}
<Mails className={i.invited_at ? "opacity-50" : ""} />
</IconButton>
</Tooltip>
)}
{/* An entry with an account behind it stays read-only: deleting it would revoke that
@@ -748,26 +747,24 @@ function AdminInvites() {
{i.has_account ? (
// `disabled` would drop it from the tab order AND suppress the title, so the
// reason reached nobody — same call as the Playlists drag handle.
<button
type="button"
<IconButton
aria-disabled
onClick={(e) => e.preventDefault()}
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
label={t("settings.invites.deleteHasAccount")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<Trash2 />
</IconButton>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<FocusSafeButton
<IconButton
noTitle
variant="danger"
onClick={() => onDelete(i)}
disabled={remove.isPending}
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
label={t("settings.invites.deleteLabel", { email: i.email })}
>
<Trash2 className="w-3.5 h-3.5" />
</FocusSafeButton>
<Trash2 />
</IconButton>
</Tooltip>
)}
</div>
@@ -841,15 +838,16 @@ function AdminDemo() {
)}
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<Button
<IconButton
noTitle
label={t("settings.demo.remove")}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={() => onRemove(r)}
disabled={remove.isPending}
aria-label={t("settings.demo.remove")}
icon={<Trash2 className="w-4 h-4" />}
/>
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
</div>
))}
@@ -877,7 +875,11 @@ function AdminDemo() {
<div className="mt-4 pt-3 border-t border-border">
<Tooltip hint={t("settings.demo.resetHint")}>
<FocusSafeButton
<Button
variant="glass"
size="sm"
icon={<RotateCcw />}
loading={reset.isPending}
onClick={async () => {
if (
await confirm({
@@ -889,12 +891,9 @@ function AdminDemo() {
)
reset.mutate();
}}
disabled={reset.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
{t("settings.demo.reset")}
</FocusSafeButton>
</Button>
</Tooltip>
</div>
</Section>
@@ -928,26 +927,28 @@ function InviteRow({
)}
</div>
<Tooltip hint={t("settings.invites.approveHint")}>
<Button
<IconButton
noTitle
label={t("settings.invites.approve")}
size="sm"
variant="outline-accent"
size="icon-sm"
className="shrink-0"
onClick={onApprove}
disabled={busy}
aria-label={t("settings.invites.approve")}
icon={<Check className="w-4 h-4" />}
/>
>
<Check className="w-4 h-4" />
</IconButton>
</Tooltip>
<Tooltip hint={t("settings.invites.denyHint")}>
<Button
<IconButton
noTitle
label={t("settings.invites.deny")}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={onDeny}
disabled={busy}
aria-label={t("settings.invites.deny")}
icon={<X className="w-4 h-4" />}
/>
>
<X className="w-4 h-4" />
</IconButton>
</Tooltip>
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
exists for it the row is that account's approval gate, so it is managed from Users & roles
@@ -955,27 +956,26 @@ function InviteRow({
silently missing a button their other rows have. */}
{inv.has_account ? (
// aria-disabled, not `disabled`: the reason has to stay reachable by keyboard.
<button
type="button"
<IconButton
variant="outline"
aria-disabled
onClick={(e) => e.preventDefault()}
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
label={t("settings.invites.deleteHasAccount")}
>
<Trash2 className="w-4 h-4" />
</button>
<Trash2 />
</IconButton>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<Button
<IconButton
noTitle
label={t("settings.invites.deleteLabel", { email: inv.email })}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={onDelete}
disabled={busy}
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
icon={<Trash2 className="w-4 h-4" />}
/>
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
)}
</div>
+7 -6
View File
@@ -37,6 +37,7 @@ import { channelYouTubeUrl, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
@@ -436,14 +437,14 @@ export default function ChannelPage({
<ExternalLink className="w-4 h-4" />
</a>
{ch?.subscribed && (
<Button
<IconButton
label={t("channel.showInFeed")}
size="md"
variant="outline"
size="icon"
onClick={onShowInFeed}
title={t("channel.showInFeed")}
aria-label={t("channel.showInFeed")}
icon={<SlidersHorizontal className="w-4 h-4" />}
/>
>
<SlidersHorizontal className="w-4 h-4" />
</IconButton>
)}
{ch?.subscribed && !me.is_demo && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
+6 -8
View File
@@ -7,7 +7,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { IconButton } from "./ui/IconButton";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -278,16 +278,14 @@ function TagEditorMenu({
placeholder={t("channels.tags.createPlaceholder")}
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<FocusSafeButton
type="button"
<IconButton
variant="outline"
onClick={submit}
disabled={!draft.trim()}
title={t("channels.tags.create")}
aria-label={t("channels.tags.create")}
className="shrink-0 w-6 h-6 inline-flex items-center justify-center rounded-md border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-40 transition"
label={t("channels.tags.create")}
>
<Plus className="w-3.5 h-3.5" />
</FocusSafeButton>
<Plus />
</IconButton>
</div>
</div>
);
+7 -8
View File
@@ -6,7 +6,7 @@ import { Send } from "lucide-react";
import { api, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
@@ -223,16 +223,15 @@ export default function ChatThread({
placeholder={t("messages.writePlaceholder")}
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button
<IconButton
label={t("messages.send")}
size="md"
variant="primary"
size="icon"
className="shrink-0"
onClick={submit}
disabled={!draft.trim() || send.isPending}
aria-label={t("messages.send")}
title={t("messages.send")}
icon={<Send className="w-4 h-4" />}
/>
>
<Send className="w-4 h-4" />
</IconButton>
</div>
)}
</>
+14 -10
View File
@@ -8,6 +8,7 @@ import clsx from "clsx";
// clicked, so it never ships in the feed chunk for the majority who just browse.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
import { api, type Me } from "../lib/api";
import { IconButton } from "./ui/IconButton";
// Self-contained download affordance for a video card / the player. Hidden for the demo account
// (downloads spend server disk + need a real identity). Reflects the video's current state from
@@ -15,11 +16,13 @@ import { api, type Me } from "../lib/api";
export default function DownloadButton({
videoId,
title,
className,
variant = "ghost",
size = "sm",
}: {
videoId: string;
title?: string | null;
className?: string;
variant?: "ghost" | "outline" | "overlay";
size?: "sm" | "md";
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -47,20 +50,21 @@ export default function DownloadButton({
return (
<>
<button
<IconButton
variant={variant}
size={size}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(true);
}}
title={label}
className={clsx(
className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
(downloaded || inQueue) && "text-accent",
)}
label={label}
>
<Download className={clsx("w-4 h-4", inQueue && "animate-pulse")} />
</button>
{/* The state is in the glyph: downloaded or queued is the accent, a running one pulses. */}
<Download
className={clsx((downloaded || inQueue) && "text-accent", inQueue && "animate-pulse")}
/>
</IconButton>
{open && (
<Suspense fallback={null}>
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
+7 -7
View File
@@ -39,6 +39,7 @@ import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin";
@@ -199,15 +200,14 @@ export default function DownloadCenter() {
placeholder={t("downloads.page.addUrl")}
className={inputCls}
/>
<Button
variant="ghost"
size="sm"
iconOnly
icon={<ClipboardPaste className="w-4 h-4" />}
<IconButton
label={t("downloads.page.paste")}
size="sm"
variant="ghost"
onClick={() => pasteFromClipboard(true)}
className="shrink-0"
/>
>
<ClipboardPaste className="w-4 h-4" />
</IconButton>
<Button
variant="primary"
size="sm"
+32 -30
View File
@@ -50,8 +50,8 @@ import FilterSelect from "./ui/FilterSelect";
import ItemCount from "./ui/ItemCount";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import SegmentedToggle from "./ui/SegmentedToggle";
import { IconButton } from "./ui/IconButton";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -436,7 +436,10 @@ export default function Feed({
<div className="p-4">
<div className="pb-3 mb-1 border-b border-border">
<div className="flex items-center gap-2 flex-wrap">
<button
<Button
variant="ghost"
size="sm"
icon={<ArrowLeft />}
onClick={() => {
// Surface the just-searched videos in the feed you return to: switch the Source
// filter to "search" (your own search finds) and rank them by relevance.
@@ -444,11 +447,9 @@ export default function Feed({
onExitYtSearch();
dropFeedCaches();
}}
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
>
<ArrowLeft className="w-4 h-4" />
{t("feed.yt.back")}
</button>
</Button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Youtube className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-semibold truncate">
@@ -484,7 +485,10 @@ export default function Feed({
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
<Info className="w-3.5 h-3.5 shrink-0" />
<span>{t("feed.yt.ephemeralNote")}</span>
<button
<Button
variant="outline-danger"
size="xs"
icon={<Trash2 />}
onClick={() => {
const ids = ytItems.map((v) => v.id);
api
@@ -500,11 +504,9 @@ export default function Feed({
dropFeedCaches();
qc.removeQueries({ queryKey: qk.ytSearch() });
}}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
>
<Trash2 className="w-3 h-3" />
{t("feed.yt.clearNow")}
</button>
</Button>
</div>
)}
</div>
@@ -530,17 +532,17 @@ export default function Feed({
/>
{ytQuery.hasNextPage && (
<div className="text-center pt-4">
<FocusSafeButton
<Button
variant="outline"
onClick={() => ytQuery.fetchNextPage()}
disabled={ytQuery.isFetchingNextPage}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
loading={ytQuery.isFetchingNextPage}
>
{ytQuery.isFetchingNextPage
? t("feed.loadingMore")
: zeroQuota
? t("feed.yt.loadMoreFree")
: t("feed.yt.loadMore")}
</FocusSafeButton>
</Button>
</div>
)}
</>
@@ -594,12 +596,13 @@ export default function Feed({
<Button variant="primary" size="lg" onClick={onOpenWizard}>
{t("feed.setUp")}
</Button>
<button
<Button
variant="link-muted"
className="mx-auto mt-3 flex text-sm"
onClick={() => setFilters({ ...filters, scope: "all" })}
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
>
{t("feed.browseShared")}
</button>
</Button>
</>
)}
</div>
@@ -635,14 +638,13 @@ export default function Feed({
descLabel={t("feed.dirDesc")}
trailing={
sortKey === "shuffle" ? (
<button
<IconButton
variant="outline"
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")}
aria-label={t("sidebar.reshuffle")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
label={t("sidebar.reshuffle")}
>
<RefreshCw className="w-4 h-4" />
</button>
<RefreshCw />
</IconButton>
) : undefined
}
/>
@@ -757,23 +759,23 @@ export default function Feed({
{filters.channelIds.length > 0 && filters.scope === "my" && (
<div className="mt-4">
<p className="text-sm">{t("feed.channelScopeHint")}</p>
<button
<Button
className="mt-3"
icon={<Library />}
onClick={() => setFilters({ ...filters, scope: "all" })}
className="mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Library className="w-4 h-4" />
{t("feed.viewInLibrary")}
</button>
</Button>
</div>
)}
{filters.q.trim() && !isDemo && (
<button
<Button
className="mt-4"
icon={<Youtube />}
onClick={() => onYtSearch(filters.q.trim())}
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Youtube className="w-4 h-4" />
{t("feed.yt.searchFor", { query: filters.q.trim() })}
</button>
</Button>
)}
</div>
) : (
+13 -12
View File
@@ -21,6 +21,7 @@ import type { Page } from "../lib/urlState";
import { qk } from "../lib/queryKeys";
import { LEVEL_TAG, logClock } from "./logLineParts";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
@@ -519,17 +520,17 @@ function DraftFields({
{step.shot ? ` · ${t("recorder.composer.withShot")}` : ""}
</span>
{!readOnly && (
<Button
variant="ghost"
size="icon-sm"
iconOnly
icon={<X className="w-4 h-4" />}
<IconButton
label={t("recorder.composer.removeStep", { n: i + 1 })}
size="sm"
variant="ghost"
onClick={() => {
edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }));
addStep.current?.focus();
}}
/>
>
<X className="w-4 h-4" />
</IconButton>
)}
</li>
))}
@@ -678,18 +679,18 @@ function Attachments({
<span className="text-xs text-muted tabular-nums">{formatBytes(shot.size)}</span>
</div>
{!readOnly && (
<Button
variant="ghost"
size="icon-sm"
iconOnly
icon={<X className="w-4 h-4" />}
<IconButton
label={t("recorder.composer.removeShot", { n: i + 1 })}
size="sm"
variant="ghost"
loading={remove.isPending && remove.variables === shot.id}
onClick={() => {
remove.mutate(shot.id);
pick.current?.focus();
}}
/>
>
<X className="w-4 h-4" />
</IconButton>
)}
</div>
))}
+13 -10
View File
@@ -6,6 +6,7 @@ import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
@@ -221,15 +222,16 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
<IconButton
label={t("channel.back")}
size="sm"
variant="ghost"
size="icon-sm"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
label={t("channel.back")}
onClick={onBack}
icon={<ArrowLeft className="w-4 h-4" />}
/>
>
<ArrowLeft className="w-4 h-4" />
</IconButton>
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
</div>
<input
@@ -291,15 +293,16 @@ function PageThread({
return (
<div className="flex flex-col h-[65vh] min-h-80">
<div className="flex items-center gap-3 pb-3 border-b border-border">
<Button
<IconButton
label={t("channel.back")}
size="sm"
variant="ghost"
size="icon-sm"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
label={t("channel.back")}
onClick={onBack}
icon={<ArrowLeft className="w-4 h-4" />}
/>
>
<ArrowLeft className="w-4 h-4" />
</IconButton>
<Avatar
src={seedAvatar}
fallback={name}
+27 -26
View File
@@ -14,12 +14,13 @@ import type { Page } from "../lib/urlState";
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
import { useScrollFade } from "../lib/useScrollFade";
import { type LangCode } from "../i18n";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import AvatarImg from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -170,7 +171,7 @@ export default function NavSidebar({
return (
// The name rides in a right-side flyout on hover (rest-to-show), so the rail stays icon-only.
<Tooltip key={p} hint={label} side="right" always openDelay={350}>
<button
<Pressable
data-testid={`nav-${p}`}
onClick={() => setPage(p)}
aria-label={label}
@@ -191,7 +192,7 @@ export default function NavSidebar({
</span>
)}
</span>
</button>
</Pressable>
</Tooltip>
);
};
@@ -220,25 +221,25 @@ export default function NavSidebar({
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div className="flex flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
<Button
<IconButton
label={t("header.account.about")}
size="md"
variant="ghost"
size="icon"
onClick={onOpenAbout}
title={t("header.account.about")}
aria-label={t("header.account.about")}
icon={<Info className="w-5 h-5" />}
/>
<Button
>
<Info className="w-5 h-5" />
</IconButton>
<IconButton
label={t("header.account.signOut")}
size="md"
variant="ghost"
size="icon"
onClick={logout}
title={t("header.account.signOut")}
aria-label={t("header.account.signOut")}
icon={<LogOut className="w-5 h-5" />}
/>
>
<LogOut className="w-5 h-5" />
</IconButton>
</div>
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
<button
<Pressable
data-testid="nav-settings"
onClick={() => setPage("settings")}
aria-label={t("header.account.settings")}
@@ -246,11 +247,11 @@ export default function NavSidebar({
className={`${rowBase} justify-center px-0 is-current`}
>
<Settings className="w-4.5 h-4.5 shrink-0" />
</button>
</Pressable>
</Tooltip>
<div>
<button
<Pressable
ref={acctPop.refs.setReference}
{...acctPop.getReferenceProps()}
title={`${name} · ${roleLabel}`}
@@ -264,7 +265,7 @@ export default function NavSidebar({
className="w-5.5 h-5.5 rounded-full text-3xs"
/>
</span>
</button>
</Pressable>
<PopoverSurface
popover={acctPop}
@@ -305,7 +306,7 @@ export default function NavSidebar({
{otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
<button
<Pressable
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
@@ -316,18 +317,18 @@ export default function NavSidebar({
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-3xs shrink-0"
/>
<span className="min-w-0 text-left">
<span className="min-w-0">
<span className="block text-[0.8125rem] text-fg truncate">
{displayName(a)}
</span>
<span className="block text-2xs text-muted truncate">{a.email}</span>
</span>
</button>
</Pressable>
))}
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
<Pressable
onClick={() => {
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
@@ -339,14 +340,14 @@ export default function NavSidebar({
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
</Pressable>
<Pressable
onClick={logout}
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</button>
</Pressable>
</div>
</PopoverSurface>
</div>
+10 -25
View File
@@ -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({
</div>
<div className="flex items-center gap-1 shrink-0">
{!n.read && (
<Button
variant="ghost"
size="icon-sm"
onClick={onRead}
title={t("inbox.markRead")}
aria-label={t("inbox.markRead")}
icon={<Check className="w-4 h-4" />}
/>
<IconButton label={t("inbox.markRead")} size="sm" variant="ghost" onClick={onRead}>
<Check className="w-4 h-4" />
</IconButton>
)}
<Button
variant="ghost"
size="icon-sm"
onClick={onDismiss}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
icon={<X className="w-4 h-4" />}
/>
<IconButton label={t("inbox.dismiss")} size="sm" variant="ghost" onClick={onDismiss}>
<X className="w-4 h-4" />
</IconButton>
</div>
</div>
);
@@ -498,15 +489,9 @@ function ClientActivityRow({
)
)}
</div>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={onRemove}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
icon={<X className="w-4 h-4" />}
/>
<IconButton label={t("inbox.dismiss")} size="sm" variant="ghost" onClick={onRemove}>
<X className="w-4 h-4" />
</IconButton>
</div>
);
}
+37 -46
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react";
import { BookmarkPlus, Check, Play, Save, Star, Trash2, X } from "lucide-react";
import {
closestCenter,
DndContext,
@@ -14,13 +14,15 @@ import { CSS } from "@dnd-kit/utilities";
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { useSavedViews, asFilters } from "./savedViewsContext";
import { useActiveFilters } from "../lib/useActiveFilters";
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
import { useUndoable } from "../lib/useUndoable";
import UndoToolbar from "./ui/UndoToolbar";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
import { Pressable } from "./ui/Pressable";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
@@ -50,22 +52,13 @@ function ViewRow({
selected ? "bg-accent/15" : "hover:bg-card"
}`}
>
<button
{...attributes}
{...listeners}
aria-label={t("views.reorder")}
className="cursor-grab p-1.5 text-muted hover:text-fg"
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
<DragHandle label={t("views.reorder")} {...attributes} {...listeners} />
<Pressable
onClick={onSelect}
className={`flex-1 min-w-0 truncate py-1.5 text-left text-sm ${
selected ? "text-accent" : "text-fg"
}`}
className={`flex-1 min-w-0 truncate py-1.5 text-sm ${selected ? "text-accent" : "text-fg"}`}
>
{view.name}
</button>
</Pressable>
{view.is_default && <Star className="h-3 w-3 shrink-0 fill-current text-accent" />}
{active && <span className="mr-1 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />}
</div>
@@ -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"
/>
<Button
variant="primary"
size="sm"
iconOnly
icon={<BookmarkPlus className="h-4 w-4" />}
<IconButton
label={t("views.save")}
size="sm"
variant="primary"
onClick={onCreate}
disabled={!createName.trim()}
className="shrink-0"
/>
>
<BookmarkPlus className="h-4 w-4" />
</IconButton>
</div>
</div>
@@ -238,15 +230,15 @@ export default function PersonalViewsModal() {
onBlur={() => void onRename()}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
<FocusSafeButton
<IconButton
variant="outline"
size="md"
onClick={() => void onRename()}
disabled={!nameChanged}
title={t("views.rename")}
aria-label={t("views.rename")}
className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-30"
label={t("views.rename")}
>
<Check className="h-4 w-4" />
</FocusSafeButton>
<Check />
</IconButton>
</div>
<div>
<div className="mb-1.5 flex items-center justify-between gap-2">
@@ -294,7 +286,10 @@ export default function PersonalViewsModal() {
>
{t("views.apply")}
</Button>
<FocusSafeButton
<Button
variant="outline"
size="sm"
icon={<Save />}
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"
>
<Save className="h-3.5 w-3.5" />
{t("views.saveCurrent")}
</FocusSafeButton>
<button
</Button>
<Button
variant="outline"
size="sm"
pressed={selected.is_default}
onClick={() => void toggleDefault(selected)}
title={t("views.default")}
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition ${
selected.is_default
? "border-accent text-accent"
: "border-border text-muted hover:text-fg"
}`}
icon={<Star className={selected.is_default ? "fill-current" : ""} />}
>
<Star className={`h-3.5 w-3.5 ${selected.is_default ? "fill-current" : ""}`} />
{t("views.default")}
</button>
<button
</Button>
<Button
variant="danger-soft"
size="sm"
icon={<Trash2 />}
className="ml-auto"
onClick={async () => {
const next = views.find((v) => v.id !== selected.id);
// Only move the selection if the delete actually happened (remove awaits a confirm
// and resolves false on cancel) — otherwise cancelling would still switch the pane.
if (await remove(selected)) setSelectedId(next?.id ?? null);
}}
title={t("views.delete")}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-danger transition hover:border-danger"
>
<Trash2 className="h-3.5 w-3.5" />
{t("views.delete")}
</button>
</Button>
</div>
</div>
) : (
+60 -76
View File
@@ -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. */}
<div className="relative flex w-full max-w-4xl max-h-full">
{hasQueue && (
<FocusSafeButton
<Pressable
onClick={(e) => {
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"
>
<ChevronLeft className="h-8 w-8" />
</FocusSafeButton>
</Pressable>
)}
<div
ref={cardRef}
@@ -670,62 +672,46 @@ export default function PlayerModal({
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
/>
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
)}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
/>
)}
{!navigated && (
<button
<Button
variant="overlay"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
watched
? "bg-white/25 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? <CheckCheck className="h-4 w-4" /> : <Check className="h-4 w-4" />}
<span className="hidden md:inline">
{watched ? t("player.watched") : t("player.markWatched")}
</span>
</button>
</Button>
)}
<span className="h-4 w-px bg-white/20" />
<button
<IconButton
variant="overlay"
onClick={toggleTrueFullscreen}
title={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
aria-label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
>
{isFullscreen ? (
<Minimize className="h-4 w-4" />
) : (
<Maximize className="h-4 w-4" />
)}
</button>
<button
{isFullscreen ? <Minimize /> : <Maximize />}
</IconButton>
<Button
variant="overlay"
size="sm"
onClick={(e) => {
e.stopPropagation();
exitBigView();
setNativeControls(false);
focusModal();
}}
title={t("player.exitMaximize")}
aria-label={t("player.exitMaximize")}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-white/80 hover:bg-white/10 hover:text-white transition"
label={t("player.exitMaximize")}
icon={<Minimize2 />}
>
<Minimize2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
</button>
</Button>
</div>
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
@@ -802,14 +788,15 @@ export default function PlayerModal({
</span>
</h2>
{navigated && (
<button
<Button
variant="ghost"
size="sm"
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
icon={<ArrowLeft />}
>
<ArrowLeft className="w-4 h-4" />
{t("player.back")}
</button>
</Button>
)}
<PopoverSurface
popover={descPop}
@@ -837,15 +824,16 @@ export default function PlayerModal({
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</PopoverSurface>
<button
<Button
variant="ghost"
size="sm"
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
{t("player.close")}
<X className="w-4 h-4" />
</button>
<X />
</Button>
</div>
{/* 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 && (
<button
<IconButton
variant="outline"
size="md"
onClick={toggleFullscreen}
label={t("player.fullscreen")}
title={`${t("player.fullscreen")} · F`}
aria-label={t("player.fullscreen")}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
className="ml-auto"
>
<Maximize className="w-4 h-4" />
</button>
)}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
<Maximize />
</IconButton>
)}
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
variant="outline"
size="md"
/>
)}
{!navigated && onRemoveFromList && (
<button
<IconButton
variant="outline-danger"
size="md"
onClick={() => void removeFromList()}
title={t("playlists.removeItem")}
// Named with its object, like the row control: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
aria-label={t("playlists.removeItemOf", { title: active.title })}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-danger hover:bg-surface border border-border transition"
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
>
<Trash2 className="w-4 h-4" />
</button>
<Trash2 />
</IconButton>
)}
{!navigated && (
<button
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched
? "btn-primary shadow-xs"
: "text-muted hover:text-fg hover:bg-surface border border-border")
}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
{watched ? t("player.watched") : t("player.markWatched")}
</button>
</Button>
)}
</div>
</div>
</div>
{hasQueue && (
<FocusSafeButton
<Pressable
onClick={(e) => {
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"
>
<ChevronRight className="h-8 w-8" />
</FocusSafeButton>
</Pressable>
)}
</div>
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own
+11 -11
View File
@@ -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() {
</FocusSafeButton>
)}
{linked && detail.dirty && (
<Button
<IconButton
label={t("playlists.revert")}
size="sm"
variant="outline"
size="icon-sm"
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</Button>
</IconButton>
)}
{editable && (
<Button
<IconButton
label={t("playlists.delete")}
size="sm"
variant="outline-danger"
size="icon-sm"
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
icon={<Trash2 className="w-3.5 h-3.5" />}
/>
>
<Trash2 className="w-3.5 h-3.5" />
</IconButton>
)}
</div>
</div>
+42 -53
View File
@@ -15,7 +15,9 @@ import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
import { navItemAttrs } from "../lib/listNav";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -59,14 +61,14 @@ function Score({
}) {
const { t } = useTranslation();
return filterable && onFilterRating ? (
<button
<Pressable
onClick={() => onFilterRating(Math.floor(score))}
className="inline-flex items-center gap-1 hover:opacity-80"
className="inline-flex items-center gap-1 hover:underline"
title={t("plex.info.filterRating", { n: Math.floor(score) })}
>
<Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)}
</button>
</Pressable>
) : (
<span className="inline-flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current" />
@@ -257,13 +259,13 @@ function CrewPanel({
{crew.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
<button
<Pressable
onClick={() => onOpenPerson(c.name)}
title={t("plex.info.viewPerson", { name: c.name })}
className="truncate text-left text-sm font-medium hover:text-accent"
className="max-w-full truncate text-sm font-medium hover:text-accent"
>
{c.name}
</button>
</Pressable>
) : (
<div className="truncate text-sm font-medium">{c.name}</div>
)}
@@ -471,13 +473,14 @@ export default function PlexInfo({
}
/>
{overlay && onClose && (
<button
<IconButton
variant="overlay"
size="md"
onClick={onClose}
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
aria-label={t("plex.player.close")}
label={t("plex.player.close")}
>
<X className="w-5 h-5" />
</button>
<X />
</IconButton>
)}
</div>
@@ -592,57 +595,41 @@ export default function PlexInfo({
{!overlay && (
<div className="mt-4 flex flex-wrap items-center gap-2">
{onPlay && (
<button
onClick={onPlay}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90"
>
<Play className="w-5 h-5 fill-current" />
<Button onClick={onPlay} icon={<Play className="fill-current" />}>
{inProgress ? t("plex.resume") : t("plex.play")}
</button>
</Button>
)}
{detail.status === "watched" ? (
<button
onClick={() => setState("new")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
{t("plex.info.markUnwatched")}
</button>
</Button>
) : (
<button
onClick={() => setState("watched")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<Check className="w-4 h-4" />
<Button variant="glass" onClick={() => setState("watched")} icon={<Check />}>
{t("plex.info.markWatched")}
</button>
</Button>
)}
{inProgress && (
<button
onClick={() => setState("new")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<RotateCcw className="w-4 h-4" />
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
{t("plex.info.clearResume")}
</button>
</Button>
)}
{detail.kind === "movie" && (
<button
<Button
variant="glass"
onClick={() => setPlaylistOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
icon={<ListPlus />}
>
<ListPlus className="w-4 h-4" />
{t("plex.playlistAdd.manage")}
</button>
</Button>
)}
{isAdmin && detail.kind === "movie" && library && (
<button
<Button
variant="glass"
onClick={() => setEditorOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
icon={<FolderPlus />}
>
<FolderPlus className="w-4 h-4" />
{t("plex.collEditor.manage")}
</button>
</Button>
)}
</div>
)}
@@ -709,8 +696,9 @@ export default function PlexInfo({
</>
);
return clickable ? (
<button
<Pressable
key={i}
align="center"
{...navItemAttrs(i)}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
@@ -720,10 +708,10 @@ export default function PlexInfo({
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0 text-center"
className="group/cast w-20 shrink-0"
>
{inner}
</button>
</Pressable>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
@@ -747,27 +735,28 @@ export default function PlexInfo({
{col.title}
</h2>
{onFilter && (
<button
<Button
variant="link-muted"
className="text-xs"
onClick={() =>
onFilter({
collections: [col.id],
collectionTitles: { [col.id]: col.title },
})
}
className="shrink-0 text-xs text-muted hover:text-accent"
>
{t("plex.info.browseCollection")}
</button>
</Button>
)}
</div>
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
{col.items.map((m, i) => (
<FocusSafeButton
<Pressable
key={m.id}
{...navItemAttrs(i)}
onClick={() => onPlayItem?.(m.id)}
disabled={!onPlayItem}
className="group/col w-28 shrink-0 text-left"
className="group/col w-28 shrink-0"
>
<div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-surface">
<img
@@ -804,7 +793,7 @@ export default function PlexInfo({
{m.title}
</div>
{m.year ? <div className="text-2xs text-muted">{m.year}</div> : null}
</FocusSafeButton>
</Pressable>
))}
</Carousel>
</div>
+69 -79
View File
@@ -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<string, unknown>) {
const { t } = useTranslation();
return (
<button
{...props}
className="shrink-0 cursor-grab text-muted hover:text-fg active:cursor-grabbing"
aria-label={t("plex.playlist.reorderItem", { title })}
>
<GripVertical className="h-4 w-4" />
</button>
);
}
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"}`}
>
<DragHandle title={ep.title} {...attributes} {...listeners} />
<DragHandle
label={t("plex.playlist.reorderItem", { title: ep.title })}
{...attributes}
{...listeners}
/>
<span className="w-5 shrink-0 text-center text-2xs text-muted tabular-nums">
{ep.episode_number}
</span>
<button
<Pressable
onClick={() => ctx.onPlay(ep.id)}
className="group flex min-w-0 flex-1 items-center gap-2.5 text-left"
className="group flex min-w-0 flex-1 items-center gap-2.5"
>
<div
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
@@ -184,15 +174,15 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
</div>
</div>
<div className="min-w-0 truncate text-[0.8125rem]">{ep.title}</div>
</button>
<FocusSafeButton
</Pressable>
<IconButton
variant="danger"
onClick={() => 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 })}
>
<X className="h-4 w-4" />
</FocusSafeButton>
<X />
</IconButton>
</div>
);
}
@@ -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"
>
<DragHandle title={item.title} {...attributes} {...listeners} />
<button
<DragHandle
label={t("plex.playlist.reorderItem", { title: item.title })}
{...attributes}
{...listeners}
/>
<Pressable
onClick={() => ctx.onPlay(item.id)}
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
className="group flex min-w-0 flex-1 items-center gap-3"
>
<Poster src={item.thumb} className={dense ? "aspect-2/3 w-10" : "aspect-2/3 w-14"} />
<div className="min-w-0">
@@ -228,15 +222,15 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
<div className="pointer-events-none ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent/0 text-transparent transition group-hover:bg-accent/15 group-hover:text-accent">
<Play className="h-4 w-4" fill="currentColor" />
</div>
</button>
<FocusSafeButton
</Pressable>
<IconButton
variant="danger"
onClick={() => 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 })}
>
<X className="h-4 w-4" />
</FocusSafeButton>
<X />
</IconButton>
</div>
);
}
@@ -263,19 +257,22 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
<div ref={setNodeRef} style={style} className="glass-card overflow-hidden rounded-xl">
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
<div className="flex items-center gap-2.5 p-2">
<DragHandle title={block.title} {...attributes} {...listeners} />
<button
<DragHandle
label={t("plex.playlist.reorderItem", { title: block.title })}
{...attributes}
{...listeners}
/>
<IconButton
onClick={() => ctx.toggleShow(block.showKey)}
className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
aria-expanded={open}
aria-label={block.title}
label={block.title}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{open ? <ChevronDown /> : <ChevronRight />}
</IconButton>
{!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.
<button
<Pressable
onClick={() => ctx.toggleShow(block.showKey)}
className="flex shrink-0"
tabIndex={-1}
@@ -291,22 +288,22 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
style={{ marginLeft: i ? -14 : 0 }}
/>
))}
</button>
</Pressable>
)}
<button onClick={() => ctx.toggleShow(block.showKey)} className="min-w-0 flex-1 text-left">
<Pressable onClick={() => ctx.toggleShow(block.showKey)} className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{block.title}</div>
<div className="text-2xs text-muted">
{t("plex.playlist.episodes", { count: block.items.length })}
</div>
</button>
<FocusSafeButton
</Pressable>
<IconButton
variant="danger"
onClick={() => 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")}
>
<X className="h-4 w-4" />
</FocusSafeButton>
<X />
</IconButton>
</div>
{open && (
@@ -322,29 +319,26 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
<div key={s.key} className={dense ? "" : "mt-1.5 first:mt-0.5"}>
<div className="flex items-center gap-1.5 py-1 pl-1">
{dense && (
<button
<IconButton
onClick={() => ctx.toggleSeason(sKey)}
className="grid h-5 w-5 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
aria-expanded={sOpen}
label={seasonLabel(s)}
>
{sOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
{sOpen ? <ChevronDown /> : <ChevronRight />}
</IconButton>
)}
<span className="text-2xs font-medium uppercase tracking-wide text-muted">
{seasonLabel(s)} · {s.items.length}
</span>
<FocusSafeButton
<IconButton
variant="danger"
onClick={() => 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"
>
<X className="h-3.5 w-3.5" />
</FocusSafeButton>
<X />
</IconButton>
</div>
{sOpen && (
<div
@@ -556,32 +550,28 @@ export default function PlexPlaylistView({
onChange={setLayout}
label={t("plex.playlist.layoutLabel")}
/>
<button
<IconButton
variant="glass"
size="md"
onClick={() => {
setName(q.data?.title ?? "");
setRenaming(true);
}}
title={t("plex.playlist.rename")}
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-accent"
label={t("plex.playlist.rename")}
>
<Pencil className="h-4 w-4" />
</button>
<Pencil />
</IconButton>
{items.length > 0 && (
<button
<Button
onClick={() => onPlay(order[0]!, order)}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90"
icon={<Play className="fill-current" />}
>
<Play className="h-5 w-5 fill-current" />
{t("plex.playlist.playAll")}
</button>
</Button>
)}
<button
onClick={del}
title={t("plex.playlist.delete")}
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-danger"
>
<Trash2 className="h-4 w-4" />
</button>
<IconButton variant="glass" size="md" onClick={del} label={t("plex.playlist.delete")}>
<Trash2 />
</IconButton>
</div>
{q.isError && !q.data ? (
+29 -34
View File
@@ -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({
}`}
/>
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
<FocusSafeButton
onClick={save}
disabled={saving || !valid}
className="text-success hover:opacity-80 disabled:opacity-50"
aria-label={t("common.save")}
>
<Check className="w-3.5 h-3.5" />
</FocusSafeButton>
<button
onClick={close}
className="text-muted hover:text-fg"
aria-label={t("common.cancel")}
>
<X className="w-3.5 h-3.5" />
</button>
<IconButton onClick={save} disabled={saving || !valid} label={t("common.save")}>
<Check className="text-success" />
</IconButton>
<IconButton onClick={close} label={t("common.cancel")}>
<X />
</IconButton>
{!valid && (
<span id={rangeId} role="alert" className="text-2xs text-danger">
{t("scheduler.range", { min: 1, max: 1440 })}
@@ -223,14 +215,14 @@ function JobRow({
</span>
) : (
<Tooltip hint={t("scheduler.editInterval")}>
<button
<Pressable
ref={triggerRef}
onClick={() => setDraft(String(job.interval_minutes))}
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
>
· {t("scheduler.everyMin", { count: job.interval_minutes })}
<Pencil className="w-3 h-3 reveal transition" />
</button>
</Pressable>
</Tooltip>
)}
</div>
@@ -261,14 +253,14 @@ function JobRow({
)}
</div>
<Tooltip hint={t("scheduler.runNow")}>
<FocusSafeButton
<IconButton
onClick={onRun}
disabled={job.running || runDisabled}
aria-label={t("scheduler.runNow")}
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition disabled:opacity-30 disabled:hover:text-muted disabled:hover:bg-transparent"
label={t("scheduler.runNow")}
noTitle
>
<Play className="w-4 h-4" />
</FocusSafeButton>
<Play />
</IconButton>
</Tooltip>
</div>
);
@@ -516,17 +508,20 @@ export default function Scheduler() {
</div>
<div className="flex-1" />
<Tooltip hint={data.paused ? t("scheduler.runAllPausedHint") : t("scheduler.runAllHint")}>
<FocusSafeButton
<Button
variant="glass"
size="sm"
icon={<Play />}
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"
>
<Play className="w-4 h-4" />
{t("scheduler.runAll")}
</FocusSafeButton>
</Button>
</Tooltip>
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
<FocusSafeButton
<Button
variant="glass"
size="sm"
onClick={async () => {
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"
>
<Trash2 className="w-4 h-4" />
{t("scheduler.purgeDiscovery")}
</FocusSafeButton>
</Button>
</Tooltip>
<Tooltip hint={t("scheduler.pauseHint")}>
<FocusSafeButton
<Button
variant="glass"
size="sm"
icon={data.paused ? <Play /> : <Pause />}
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 ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
</FocusSafeButton>
</Button>
</Tooltip>
</div>
+23 -16
View File
@@ -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 }) {
<div className="grid grid-cols-4 gap-2">
{SCHEMES.map((s) => (
<Tooltip key={s.id} hint={s.name}>
<button
<Pressable
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
aria-label={s.name}
aria-pressed={theme.scheme === s.id}
@@ -293,7 +294,7 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
/>
</div>
)}
<button
<Button
onClick={() =>
notify({
level: "info",
@@ -302,10 +303,11 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
sound: true,
})
}
className="mt-2 text-sm text-accent hover:underline"
variant="link"
className="mt-2 text-sm"
>
{t("settings.notifications.sendTest")}
</button>
</Button>
</Section>
);
}
@@ -439,14 +441,15 @@ function SignInMethods({ me }: { me: Me }) {
{t("settings.account.googleLink.connected")}
</span>
) : (
<button
<Button
variant="glass"
size="sm"
onClick={() => {
window.location.href = withActiveAccount("/auth/link");
}}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{t("settings.account.googleLink.connect")}
</button>
</Button>
)}
</div>
)}
@@ -461,17 +464,19 @@ function SignInMethods({ me }: { me: Me }) {
: t("settings.account.password.unsetHint")}
</p>
</div>
<button
<Button
variant="glass"
size="sm"
aria-expanded={open}
onClick={() => {
setOpen((o) => !o);
setErr(null);
}}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{me.has_password
? t("settings.account.password.change")
: t("settings.account.password.set")}
</button>
</Button>
</div>
{open && (
<form onSubmit={submit} className="space-y-2 pt-1 pb-1">
@@ -598,13 +603,15 @@ function PlexWatchSync() {
? t("settings.plexSync.lastSync", { when: last })
: t("settings.plexSync.never")}
</p>
<button
<Button
variant="outline"
size="sm"
icon={<RefreshCw />}
loading={reimport.isPending}
onClick={() => reimport.mutate()}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-border hover:bg-card/60 transition shrink-0"
>
<RefreshCw className="w-4 h-4" />
{t("settings.plexSync.importNow")}
</button>
</Button>
</div>
)
)}
@@ -861,9 +868,9 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
}}
/>
</div>
<button onClick={onOpenWizard} className="mt-2 text-sm text-accent hover:underline">
<Button variant="link" className="mt-2 text-sm" onClick={onOpenWizard}>
{t("settings.account.walkMeThrough")}
</button>
</Button>
</Section>
) : null}
+19 -22
View File
@@ -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"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={copy}
aria-label={t("downloads.share.copy")}
title={t("downloads.share.copy")}
>
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</Button>
</IconButton>
{/* 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). */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
setNewPass("");
setEditPass((v) => !v);
}}
<IconButton
label={
link.has_password
? t("downloads.share.changePassword")
: t("downloads.share.setPassword")
}
size="sm"
variant="ghost"
onClick={() => {
setNewPass("");
setEditPass((v) => !v);
}}
// On the ICON, not the button: `clsx` does not merge Tailwind, so a colour className loses
// to the variant's own `text-muted`/`hover:text-fg` by stylesheet order and the state was
// simply invisible (E10 review round 2). The Copy button above already does it this way.
icon={<Lock className={`w-4 h-4 ${link.has_password ? "text-accent" : ""}`} />}
/>
<Button
>
<Lock className={`w-4 h-4 ${link.has_password ? "text-accent" : ""}`} />
</IconButton>
<IconButton
label={t("downloads.share.revoke")}
size="sm"
variant="ghost"
size="icon-sm"
onClick={async () => {
if (
await confirm({
@@ -192,9 +189,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
)
revoke.mutate();
}}
label={t("downloads.share.revoke")}
icon={<Trash2 className="w-4 h-4 text-danger" />}
/>
>
<Trash2 className="w-4 h-4 text-danger" />
</IconButton>
</div>
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
<span className="inline-flex items-center gap-1">
+76 -67
View File
@@ -26,6 +26,8 @@ import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format";
import { isPlainLeftClick } from "../lib/nav";
import ChannelNameLink from "./ChannelNameLink";
import { IconButton } from "./ui/IconButton";
import { Button } from "./ui/Button";
function Actions({
video,
@@ -54,56 +56,49 @@ function Actions({
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
// One button style for the row — AddToPlaylist and DownloadButton take it as a prop, so all six
// stay the same size instead of two of them keeping their own default.
// `dense` shrinks 28px buttons to 24 and halves the gap: six of them need 188px, but the small
// card's text column bottoms out around 160 (its 180px column less the padding), so at its
// narrowest the last button used to hang outside the card. 154 at dense.
const btn = clsx("rounded-md hover:bg-surface text-muted hover:text-fg", dense ? "p-1" : "p-1.5");
// Every button of the row is the IconButton `sm` rung (28), AddToPlaylist and DownloadButton
// included. `dense` halves the gap for the small card, whose text column bottoms out around 160px;
// six 28px buttons wrap there (the flex-wrap below) until the C1 action list (E28 S3e) moves the
// rare ones into the card's "…".
return (
// flex-wrap is the safety net, not the plan: the sizing above is what makes them fit. If a
// future button or a narrower column breaks that arithmetic, the row wraps to a second line
// rather than silently rendering a half-clipped control outside the card.
<div className={clsx("flex flex-wrap reveal transition", dense ? "gap-0.5" : "gap-1")}>
<button
<IconButton
onClick={act("watched")}
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
className={clsx(btn, video.status === "watched" && "text-accent")}
pressed={video.status === "watched"}
label={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
>
{video.status === "watched" ? (
<CheckCheck className="w-4 h-4" />
) : (
<Check className="w-4 h-4" />
)}
</button>
<button
{video.status === "watched" ? <CheckCheck /> : <Check />}
</IconButton>
<IconButton
onClick={toggleSave}
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className={clsx(btn, video.saved && "fill-current text-accent")}
pressed={!!video.saved}
label={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
>
<Bookmark className="w-4 h-4" />
</button>
<AddToPlaylist videoId={video.id} className={btn} />
<DownloadButton videoId={video.id} title={video.title} className={btn} />
<button
<Bookmark className={clsx(video.saved && "fill-current")} />
</IconButton>
<AddToPlaylist videoId={video.id} />
<DownloadButton videoId={video.id} title={video.title} />
<IconButton
onClick={act("hidden")}
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
className={clsx(btn, video.status === "hidden" && "text-accent")}
pressed={video.status === "hidden"}
label={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
>
{video.status === "hidden" ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
{video.status === "hidden" ? <Eye /> : <EyeOff />}
</IconButton>
{onResetState && resettable && (
<button
<IconButton
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onResetState(video.id);
}}
title={t("card.resetState")}
className={btn}
label={t("card.resetState")}
>
<RotateCcw className="w-4 h-4" />
</button>
<RotateCcw />
</IconButton>
)}
</div>
);
@@ -285,45 +280,59 @@ function Thumb({
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
{inProgress ? (
<>
<button
onClick={open(null)}
title={t("card.continueTitle")}
aria-label={t("card.continue")}
className={clsx(
"inline-flex items-center justify-center gap-1.5 font-semibold btn-primary shadow-lg transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
)}
>
<Play className="w-4 h-4 fill-current" />
{!small && t("card.continue")}
</button>
<button
onClick={open(0)}
title={t("card.restartTitle")}
aria-label={t("card.restart")}
className={clsx(
"inline-flex items-center justify-center gap-1.5 font-medium bg-white/15 text-white backdrop-blur-xs hover:bg-white/25 transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
)}
>
<RotateCcw className="w-4 h-4" />
{!small && t("card.restart")}
</button>
{small ? (
<IconButton
variant="primary"
size="md"
onMedia
onClick={open(null)}
label={t("card.continue")}
title={t("card.continueTitle")}
>
<Play className="fill-current" />
</IconButton>
) : (
<Button
size="sm"
onMedia
onClick={open(null)}
title={t("card.continueTitle")}
icon={<Play className="fill-current" />}
>
{t("card.continue")}
</Button>
)}
{small ? (
<IconButton
variant="media"
size="md"
onClick={open(0)}
label={t("card.restart")}
title={t("card.restartTitle")}
>
<RotateCcw />
</IconButton>
) : (
<Button
variant="media"
size="sm"
onClick={open(0)}
title={t("card.restartTitle")}
icon={<RotateCcw />}
>
{t("card.restart")}
</Button>
)}
</>
) : (
<button
<IconButton
variant="media"
size={small ? "md" : "lg"}
onClick={open(null)}
title={t("card.play")}
aria-label={t("card.play")}
className={clsx(
"inline-flex items-center justify-center rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition",
small ? "w-9 h-9" : "w-12 h-12",
)}
label={t("card.play")}
>
<Play
className={clsx("fill-current translate-x-px", small ? "w-4 h-4" : "w-5 h-5")}
/>
</button>
<Play className="translate-x-px fill-current" />
</IconButton>
)}
</div>
)}
+41 -50
View File
@@ -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 */}
<div className="flex items-center gap-3">
<button
onClick={playPause}
className="p-2 rounded-lg bg-surface hover:bg-card transition"
title={t("editor.playPause")}
>
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</button>
<IconButton variant="outline" size="md" onClick={playPause} label={t("editor.playPause")}>
{playing ? <Pause /> : <Play />}
</IconButton>
<div className="text-xs text-muted tabular-nums">
{tc(current)} / {tc(duration)}
</div>
<div className="flex-1" />
<button
<Button
variant="outline"
size="sm"
onClick={splitAtPlayhead}
className={clsx(btnCls, "bg-surface hover:bg-card inline-flex items-center gap-1.5")}
icon={<SplitSquareHorizontal />}
>
<SplitSquareHorizontal className="w-4 h-4" /> {t("editor.splitHere")}
</button>
{t("editor.splitHere")}
</Button>
</div>
{/* 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 */}
<button
<Pressable
aria-pressed={s.keep}
onPointerDown={(e) => {
e.stopPropagation();
toggleKeep(s.id);
@@ -511,7 +511,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
title={s.keep ? t("editor.drop") : t("editor.keep")}
>
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</button>
</Pressable>
</div>
))}
{/* draggable boundary markers */}
@@ -528,7 +528,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
setDrag({ type: "boundary", bi });
}}
/>
<button
<Pressable
onPointerDown={(e) => {
e.stopPropagation();
deleteBoundary(bi);
@@ -537,7 +537,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
title={t("editor.removeCut")}
>
<X className="w-3 h-3" />
</button>
</Pressable>
</div>
))}
{/* playhead */}
@@ -571,16 +571,15 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{sel && (
<div className="flex flex-wrap items-center gap-2 text-sm rounded-lg glass-card px-3 py-2">
<span className="font-medium">{t("editor.segment", { n: selIdx + 1 })}</span>
<button
<Button
variant="outline"
size="xs"
pressed={sel.keep}
onClick={() => toggleKeep(sel.id)}
className={clsx(
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium",
sel.keep ? "bg-accent/15 text-accent" : "bg-surface text-muted",
)}
icon={sel.keep ? <Eye /> : <EyeOff />}
>
{sel.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
{sel.keep ? t("editor.kept") : t("editor.dropped")}
</button>
</Button>
<div className="flex-1" />
<label className="flex items-center gap-1 text-muted">
{t("editor.in")}
@@ -618,19 +617,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted">{t("editor.output.label")}:</span>
{(["separate", "join"] as const).map((m) => (
<FocusSafeButton
<Button
key={m}
variant="outline"
size="sm"
pressed={output === m}
onClick={() => setOutput(m)}
disabled={m === "join" && kept.length < 2}
className={clsx(
"px-3 py-1.5 rounded-lg border transition disabled:opacity-40",
output === m
? "border-accent bg-accent/10 text-accent"
: "border-border text-muted hover:text-fg",
)}
>
{t(`editor.output.${m}`)}
</FocusSafeButton>
</Button>
))}
<div className="flex-1" />
<span className="text-xs text-muted">
@@ -640,35 +636,30 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{/* crop + precision */}
<div className="grid sm:grid-cols-2 gap-3">
<button
<Button
variant="outline"
pressed={cropOn}
onClick={() => setCropOn((v) => !v)}
className={clsx(
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition",
cropOn
? "border-accent text-accent bg-accent/10"
: "border-border text-muted hover:text-fg",
)}
icon={<Crop />}
className="justify-start"
>
<Crop className="w-4 h-4" /> {cropOn ? t("editor.cropOn") : t("editor.cropOff")}
</button>
{cropOn ? t("editor.cropOn") : t("editor.cropOff")}
</Button>
{!cropOn ? (
<div className="flex gap-2">
{(["accurate", "fast"] as const).map((m) => {
const on = (m === "accurate") === accurate;
return (
<button
<Button
key={m}
variant="outline"
pressed={on}
onClick={() => setAccurate(m === "accurate")}
className={clsx(
"flex-1 px-3 py-2 rounded-lg border text-left transition",
on ? "border-accent bg-accent/10" : "border-border hover:border-muted",
)}
title={t(`editor.${m}.hint`)}
className="flex-1"
>
<div className={clsx("text-sm font-medium", on ? "text-accent" : "text-fg")}>
{t(`editor.${m}.label`)}
</div>
</button>
{t(`editor.${m}.label`)}
</Button>
);
})}
</div>
+32 -41
View File
@@ -35,7 +35,8 @@ import { inputCls } from "./ui/form";
import { entryHash, entrySearch } from "../lib/entryUrl";
import type { VerifyEntry } from "../lib/useVerifyEntry";
import { MODE_URL, type Mode } from "./welcomeMode";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
@@ -232,8 +233,7 @@ function Preview({
);
}
return (
<button
type="button"
<Pressable
onClick={() => onOpen?.(src, label)}
aria-label={label}
className={`group relative glass-card rounded-2xl overflow-hidden block cursor-zoom-in ${className ?? ""}`}
@@ -248,7 +248,7 @@ function Preview({
<span className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 transition group-hover:bg-black/30 group-hover:opacity-100">
<Expand className="w-6 h-6 text-white drop-shadow-lg" />
</span>
</button>
</Pressable>
);
}
@@ -306,14 +306,15 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
aria-modal="true"
aria-label={label}
>
<button
type="button"
<IconButton
variant="media"
size="md"
onClick={onClose}
aria-label={t("common.close")}
className="absolute top-4 right-4 p-2 rounded-full glass-card glass-hover text-fg"
label={t("common.close")}
className="absolute top-4 right-4"
>
<X className="w-5 h-5" />
</button>
<X />
</IconButton>
<figure
className="flex max-h-full max-w-full flex-col items-center gap-3"
onClick={(e) => e.stopPropagation()}
@@ -644,15 +645,16 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
) : done ? (
<div className="mt-3">
<p className="text-sm text-fg/80 leading-relaxed">{done}</p>
<button
<Button
variant="link"
className="mt-4 text-sm"
onClick={() => {
setMode("signin");
setDone("");
}}
className="mt-4 text-sm text-accent hover:underline"
>
{t("welcome.auth.backToSignin")}
</button>
</Button>
</div>
) : (
<>
@@ -688,25 +690,18 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
{error}
</p>
)}
<FocusSafeButton
type="submit"
disabled={busy}
className="px-4 py-2.5 rounded-xl font-semibold btn-primary disabled:opacity-50 transition"
>
<Button type="submit" size="lg" disabled={busy}>
{busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])}
</FocusSafeButton>
</Button>
</form>
{/* A confirmation mail can be slow, filtered, or — before E9.5 S6 — never sent at all for a
repeat sign-up. Offered right under the form rather than below the mode switches: it is
the second thing a stuck person needs, after the password they just tried. */}
{mode === "signin" && (
<button
onClick={goResend}
className="mt-3 block w-full text-center text-xs text-muted hover:text-fg"
>
<Button variant="link-muted" fullWidth className="mt-3 text-xs" onClick={goResend}>
{t("welcome.auth.resendLink")}
</button>
</Button>
)}
{/* Mode switches */}
@@ -722,15 +717,15 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
>
{t("welcome.auth.createLink")}
</Button>
<button
<Button
variant="link-muted"
onClick={() => {
reset();
setMode("forgot");
}}
className="text-muted hover:text-fg"
>
{t("welcome.auth.forgotLink")}
</button>
</Button>
</>
) : (
<Button
@@ -761,12 +756,15 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
</a>
)}
{!showDemo ? (
<button
<Button
variant="outline"
size="lg"
fullWidth
className="mt-2"
onClick={() => setShowDemo(true)}
className="w-full mt-2 px-4 py-2.5 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
>
{t("welcome.auth.tryDemo")}
</button>
</Button>
) : (
<form onSubmit={onDemo} className="mt-2 flex flex-col gap-2">
<p className="text-2xs text-muted">{t("welcome.auth.demoHint")}</p>
@@ -778,13 +776,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
placeholder={t("welcome.auth.demoEmail")}
className={inputCls}
/>
<FocusSafeButton
type="submit"
disabled={demoBusy}
className="shrink-0 px-3 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent disabled:opacity-50 transition"
>
<Button type="submit" variant="outline" size="lg" disabled={demoBusy}>
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
</FocusSafeButton>
</Button>
</div>
{demoError && <p className="text-xs text-danger">{demoError}</p>}
</form>
@@ -858,12 +852,9 @@ function StatePanel({
{t("welcome.auth.backToSignin")}
</Button>
{cta && (
<button
onClick={cta.onClick}
className="shrink-0 px-3 py-2 rounded-xl text-xs font-semibold bg-card border border-border hover:border-accent transition"
>
<Button variant="outline" size="sm" onClick={cta.onClick}>
{cta.label}
</button>
</Button>
)}
</div>
</div>
+37 -41
View File
@@ -6,6 +6,8 @@ import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { navItemAttrs } from "../lib/listNav";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
// state). Rendered by the browse grid, the person page, and the series show/season views.
@@ -179,28 +181,26 @@ export function PlexPosterCard({
{/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */}
{isPlayable && (
<div className="reveal absolute right-1.5 top-1.5 z-10 flex gap-1">
<button
<IconButton
variant="media"
onClick={(e) => {
e.stopPropagation();
onInfo();
}}
title={t("plex.info.title")}
aria-label={t("plex.info.title")}
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
label={t("plex.info.title")}
>
<Info className="h-4 w-4 text-white" />
</button>
<button
<Info />
</IconButton>
<IconButton
variant="media"
onClick={(e) => {
e.stopPropagation();
onToggleWatched(card);
}}
title={watchedLabel}
aria-label={watchedLabel}
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
label={watchedLabel}
>
<WatchedIcon watched={card.status === "watched"} />
</button>
</IconButton>
</div>
)}
@@ -276,31 +276,31 @@ export function SeasonCard({
</div>
{/* Quick watched toggle (whole season), on hover. */}
{onToggleWatched && (
<button
<IconButton
variant="media"
onClick={(e) => {
e.stopPropagation();
onToggleWatched();
}}
title={watchedLabel}
aria-label={watchedLabel}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
label={watchedLabel}
className="absolute top-1.5 right-1.5 reveal"
>
<WatchedIcon watched={watched} />
</button>
</IconButton>
)}
{/* Add whole season to a playlist, on hover. */}
{onAdd && (
<button
<IconButton
variant="media"
onClick={(e) => {
e.stopPropagation();
onAdd();
}}
title={t("plex.playlist.addSeason")}
aria-label={t("plex.playlist.addSeason")}
className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
label={t("plex.playlist.addSeason")}
className="absolute bottom-1.5 right-1.5 reveal"
>
<ListPlus className="w-4 h-4 text-white" />
</button>
<ListPlus />
</IconButton>
)}
{watched && (
<span
@@ -383,8 +383,9 @@ export function CastStrip({
</>
);
return clickable ? (
<button
<Pressable
key={i}
align="center"
{...navItemAttrs(i)}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
@@ -394,10 +395,10 @@ export function CastStrip({
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0 text-center"
className="group/cast w-20 shrink-0"
>
{inner}
</button>
</Pressable>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
@@ -428,12 +429,12 @@ export function RelatedStrip({
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
{related.map((r, i) => (
<button
<Pressable
key={r.id}
{...navItemAttrs(i)}
{...menuProps?.(r)}
onClick={() => onOpen(r.id)}
className="group w-32.5 shrink-0 text-left"
className="group w-32.5 shrink-0"
>
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
<img
@@ -451,7 +452,7 @@ export function RelatedStrip({
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight min-h-[2lh]">
{r.title}
</div>
</button>
</Pressable>
))}
</Carousel>
</section>
@@ -506,17 +507,17 @@ export function EpisodeCard({
</div>
{/* Quick watched toggle (single episode), on hover. */}
{onToggleWatched && (
<button
<IconButton
variant="media"
onClick={(e) => {
e.stopPropagation();
onToggleWatched();
}}
title={watchedLabel}
aria-label={watchedLabel}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
label={watchedLabel}
className="absolute top-1.5 right-1.5 reveal"
>
<WatchedIcon watched={ep.status === "watched"} />
</button>
</IconButton>
)}
{ep.status === "watched" && (
<span
@@ -553,14 +554,9 @@ export function EpisodeCard({
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
</div>
{onAdd && (
<button
onClick={onAdd}
title={t("plex.playlist.addEpisode")}
aria-label={t("plex.playlist.addEpisode")}
className="shrink-0 rounded-sm p-1 text-muted transition hover:text-accent"
>
<ListPlus className="w-4 h-4" />
</button>
<IconButton onClick={onAdd} label={t("plex.playlist.addEpisode")}>
<ListPlus />
</IconButton>
)}
</div>
</div>
+29 -29
View File
@@ -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 <span className={className}>{children}</span>;
return (
<button
<Pressable
onClick={onClick}
title={title}
className={`${className ?? ""} cursor-pointer hover:text-accent transition`}
>
{children}
</button>
</Pressable>
);
}
@@ -84,14 +86,16 @@ export function DetailCustomizeMenu({
// any of them needs the platform to portal INTO a caller-named (scaled) container.
return (
<div className="relative shrink-0">
<button
<IconButton
ref={btnRef}
variant={overlay ? "overlay" : "ghost"}
size="md"
onClick={() => setOpen((v) => !v)}
title={t("plex.info.customize")}
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
aria-expanded={open}
label={t("plex.info.customize")}
>
<SlidersHorizontal className="w-4 h-4" />
</button>
<SlidersHorizontal />
</IconButton>
{open && (
<div
ref={menuRef}
@@ -135,9 +139,12 @@ export function PrefToggle({
onClick: () => void;
}) {
return (
<button
<Pressable
role="switch"
aria-checked={on}
layer
onClick={onClick}
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5"
>
<span>{label}</span>
<span
@@ -147,7 +154,7 @@ export function PrefToggle({
className={`absolute top-0.5 h-3 w-3 rounded-full transition-all ${on ? "left-3.5 bg-accent-fg" : "left-0.5 bg-white"}`}
/>
</span>
</button>
</Pressable>
);
}
@@ -156,13 +163,9 @@ export function PrefToggle({
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
return (
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
>
<ArrowLeft className="w-4 h-4" />
<Button variant="glass" size="sm" icon={<ArrowLeft />} onClick={onBack} className="mb-4">
{label}
</button>
</Button>
);
}
@@ -182,19 +185,16 @@ export function ActionBtn({
disabled?: boolean;
}) {
return (
<FocusSafeButton
<Button
variant={primary ? "primary" : "glass"}
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
primary ? "btn-primary shadow-xs" : "glass-card glass-hover"
}`}
icon={<Icon />}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex flex-col items-start leading-tight">
<span>{label}</span>
{sub && <span className="text-2xs font-normal opacity-70">{sub}</span>}
</span>
</FocusSafeButton>
{label}
{/* The resume point rides on the same line: the md rung is one line tall (B1). */}
{sub && <span className="opacity-75">· {sub}</span>}
</Button>
);
}
@@ -224,7 +224,7 @@ export function DetailHero({
return (
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
<FocusSafeButton
<Pressable
onClick={onPosterClick}
disabled={posterDisabled}
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
@@ -240,7 +240,7 @@ export function DetailHero({
<Play className="w-10 h-10 text-white" fill="currentColor" />
</div>
)}
</FocusSafeButton>
</Pressable>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
+8 -7
View File
@@ -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({
</button>
)}
{onDismiss && (
<Button
<IconButton
label={dismissTitle ?? t("common.close")}
size="sm"
variant="ghost"
size="icon-sm"
className="ml-auto"
onClick={onDismiss}
title={dismissTitle ?? t("common.close")}
icon={<X className="w-4 h-4" />}
/>
className="ml-auto"
>
<X className="w-4 h-4" />
</IconButton>
)}
</div>
);
+58 -28
View File
@@ -1,9 +1,9 @@
// @vitest-environment jsdom
//
// S5: the shared <Button> primitive. These lock the load-bearing contract the 120+ migrated call
// sites rely on: variant/size paint, icon-only accessibility (name must survive when the text is
// dropped), the loading state (disabled + spinner + aria-busy), and the type="button" default that
// stops an action button from submitting a surrounding form.
// S5: the shared <Button> primitive. These lock the load-bearing contract the migrated call sites rely
// on: variant/size paint (the E28 B1 ladder and B3 layers), the loading state (disabled + spinner +
// aria-busy), the pressed toggle, and the type="button" default that stops an action button from
// submitting a surrounding form. A glyph-only button is `IconButton` (its own test).
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, cleanup, fireEvent } from "@testing-library/react";
import { Button } from "./Button";
@@ -23,20 +23,50 @@ describe("Button primitive (S5)", () => {
</Button>,
);
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(
<Button iconOnly icon={<svg data-testid="ic" />} label="Block channel">
this text is dropped
it("puts every rung on an explicit height, so a toolbar row lines up (B1)", () => {
for (const [size, h] of [
["xs", "h-6"],
["sm", "h-7"],
["md", "h-8"],
] as const) {
const { unmount } = render(<Button size={size}>X</Button>);
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(<Button variant={variant}>X</Button>);
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(
<Button variant="outline" pressed={false}>
Default
</Button>,
);
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(
<Button variant="outline" pressed>
Default
</Button>,
);
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)", () => {
</Button>,
);
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(<Button>Tap</Button>);
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
unmount();
render(<Button variant="link">Read more</Button>);
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(<Button variant={variant}>Read more</Button>);
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 */}
<Button size="icon" icon={<span />} data-testid="bad-a" />
{/* @ts-expect-error iconOnly with no label/aria-label/title has no accessible name */}
<Button iconOnly icon={<span />} data-testid="bad-b" />
{/* @ts-expect-error an icon with no children and no size renders no text and no name */}
{/* @ts-expect-error an icon with no children renders no text and no name */}
<Button icon={<span />} data-testid="bad-c" />
<Button icon={<span />} label="an icon button named by label" />
<Button size="icon" icon={<span />} label="by label" />
<Button size="icon-sm" icon={<span />} aria-label="by aria-label" />
<Button iconOnly icon={<span />} title="by title" />
{/* @ts-expect-error the icon-only sizes moved to IconButton (E28 S3b) */}
<Button size="icon" label="gone" />
<Button icon={<span />} label="by label" />
<Button icon={<span />} aria-label="by aria-label" />
<Button icon={<span />} title="by title" />
<Button>plain text needs none</Button>
</>,
);
+77 -96
View File
@@ -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 <button>s styling themselves with
// ad-hoc utility strings over the CSS paint-recipes (.btn-primary/.btn-danger, index.css) + the btnCls
// constant. This centralises the geometry+paint so an "action button" (submit/cancel/subscribe/delete…)
// is one component with named variants. It deliberately does NOT cover the app's OTHER button families,
// which keep their own primitives: aria-pressed segmented pills (SegmentedToggle/SortControl), the
// Switch toggle, .chip removers, dropdown/popover trigger pills, and slider/overlay/card-click buttons.
// The ONE action-button primitive: a labelled action (Save, Cancel, Subscribe, Delete, "Show all").
// docs/DESIGN.md decides its look (A1 shape, B1 ladder, B3 state layers); this file is where that
// holds. The app's other button families have their own primitives beside it: `IconButton` (a glyph
// with no text), `Pressable` (a card, row or tile whose content is the control), `Chip` and
// `FilterChipGroup` (filters), `SegmentedToggle`, `DragHandle`. A hand-rolled `<button>` is none of
// them (E28 S3b).
//
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
// users. Adding one would double the ring.
@@ -19,71 +19,60 @@ type ButtonVariant =
| "danger" // solid danger — destructive confirm
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
| "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
| "outline-danger" // neutral at rest, danger on hover — the row-level Delete icon
| "outline-danger" // neutral at rest, danger on hover — a row-level Delete
| "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON
| "ghost" // borderless, hover-fill — tertiary/dismiss (dialog Cancel, close)
| "glass" // glass card — the Plex/overlay action buttons
| "ghost" // borderless — tertiary/dismiss (dialog Cancel, close)
| "glass" // glass card — the Plex/overlay action buttons (A1: glass is a variant, not a hand-built one)
| "overlay" // white text on the always-dark player chrome, whatever the theme
| "media" // dark translucent on a picture — "From the start" on a card (C5); always round
| "danger-soft" // bordered AND danger-coloured at rest — a destructive action that shouldn't shout
| "link"; // inline text link — "Show all", "Learn more", a resend-mail prompt inside a sentence
| "link" // inline accent text — "Show all", "Learn more", a resend-mail prompt inside a sentence
| "link-muted"; // inline quiet text that brightens on hover — "Clear", "Show less", "Skip"
type ButtonSize =
| "xs" // dense inline action
| "sm" // the btnCls geometry (px-3 py-1.5)
| "md" // default action button (px-3 py-2)
| "lg" // prominent CTA
| "icon" // square icon-only (w-9 h-9)
| "icon-sm" // square icon-only, dense (w-8 h-8) — toolbars and table rows
| "none"; // no padding/radius of its own — the `link` variant's default
| "xs" // 24 — inside a table row
| "sm" // 28 — toolbars and panel headers; lines up with the search pill and the dropdown trigger
| "md" // 32 — forms and dialog footers
| "lg" // 40 — a page's single hero call to action (Welcome, onboarding), nowhere else
| "none"; // no box of its own — the link variants' default
// B3: every hover and press is a LAYER (the `state-*` utilities in index.css), never an opacity dim or
// a `bg-card` fill, which vanish on a light card. The utilities already stand down on a disabled, busy
// or pressed button; the text-colour hovers here carry the same guard as a variant.
const ACT = "enabled:not-aria-disabled:";
const VARIANTS: Record<ButtonVariant, string> = {
// .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components).
primary: "font-medium btn-primary",
danger: "font-medium btn-danger",
// E10 S1b: the neutral variants were RE-AIMED at the paint the app actually uses. Counting the
// un-migrated call sites showed the majority hovering to `accent` (outline) and filling with `card`
// (ghost), while this primitive hovered to `fg` and filled with `surface` — so the 37 migrated sites
// and the ~100 hand-rolled ones had drifted into two different looks. That split IS the
// inconsistency this epic exists to remove; the primitive follows the majority, not the reverse.
outline:
"font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-accent enabled:not-aria-disabled:hover:border-accent",
"outline-accent":
"font-medium border border-accent/40 text-accent enabled:not-aria-disabled:hover:bg-accent/10",
primary: "font-medium state-filled",
danger: "font-medium state-danger",
outline: `font-medium border border-border text-muted state-layer ${ACT}hover:text-fg`,
"outline-accent": "font-medium border border-accent/40 text-accent state-layer",
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
// is whether the button should advertise the destruction before the pointer arrives.
"outline-danger":
"font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-danger enabled:not-aria-disabled:hover:border-danger/40",
"outline-warn":
"font-medium border border-warn/40 text-warn enabled:not-aria-disabled:hover:bg-warn/10",
ghost:
"font-medium text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:bg-card",
glass: "font-medium glass-card glass-hover enabled:not-aria-disabled:hover:text-accent",
"danger-soft":
"font-medium border border-danger/60 text-danger enabled:not-aria-disabled:hover:bg-danger/10 enabled:not-aria-disabled:hover:border-danger",
// No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would
// be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge
// the winner is decided by the generated stylesheet order, not by the class list order).
link: "font-semibold text-accent enabled:not-aria-disabled:hover:underline",
"outline-danger": `font-medium border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
"outline-warn": "font-medium border border-warn/40 text-warn state-layer",
ghost: `font-medium text-muted state-layer ${ACT}hover:text-fg`,
glass: `font-medium glass-card glass-hover ${ACT}hover:text-accent`,
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
overlay: `font-medium text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
media: `font-medium bg-black/60 text-white ${ACT}hover:bg-black/75`,
"danger-soft": `font-medium border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
link: `font-semibold text-accent ${ACT}hover:underline`,
"link-muted": `font-medium text-muted ${ACT}hover:text-fg`,
};
// Why every hover above is `enabled:not-aria-disabled:hover:` — neither natively disabled nor busy. A
// disabled button that still lights up on hover advertises a click that does nothing, and the ONE
// migrated call site that had guarded its own hover (TagManager's Add) silently lost the guard when it
// moved onto this primitive (E10 review round 1). `not-aria-disabled` is the `loading` half: a busy
// button stays natively enabled (see `loading` below) and must not light up either. The contract
// belongs here, not in ~160 call sites: BASE dims both, and the variant must not paint over it.
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
const PRESSED =
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline";
// The geometry ladder, and the whole of it — a call site picks a rung, it does not invent one. The
// rungs were derived from what the app actually used (counted during E10 S1b): `px-3 py-2 text-sm`
// (22 sites), `px-2 py-1 text-xs` (12), `px-3 py-1.5 text-sm` (11). Every value is rem-derived, so
// the Settings text-size slider (--font-scale, applied to the root font-size) scales the padding and
// the box with the label instead of leaving a shrunken button around bigger text.
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
// the Settings text-size slider (--font-scale) grows the box with the label. A1: r8 on every rung; a
// button on a picture is a pill instead (A2: round means "on media", and nothing else).
const SIZES: Record<ButtonSize, string> = {
xs: "px-2 py-1 text-xs rounded-md gap-1",
sm: "px-3 py-1.5 text-sm rounded-lg gap-1.5",
md: "px-3 py-2 text-sm rounded-lg gap-1.5",
lg: "px-4 py-2.5 text-base rounded-lg gap-2",
icon: "w-9 h-9 rounded-lg justify-center",
"icon-sm": "w-8 h-8 rounded-lg justify-center",
xs: "h-6 px-2 text-xs gap-1 whitespace-nowrap",
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
md: "h-8 px-3 text-sm gap-1.5 whitespace-nowrap",
lg: "h-10 px-4 text-base gap-2 whitespace-nowrap",
none: "gap-1.5",
};
@@ -91,42 +80,33 @@ const SIZES: Record<ButtonSize, string> = {
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
const BASE =
"inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed";
"inline-flex shrink-0 items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed [&>svg]:size-4 [&>svg]:shrink-0";
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
/** Defaults to "md", except for `variant="link"`, whose default is "none" (a link owns no padding). */
/** Defaults to "md", except for the link variants, whose default is "none" (a link owns no box). */
size?: ButtonSize;
/** Leading icon (an lucide element). Replaced by a spinner while `loading`. */
/** Leading icon (a lucide element; the primitive sizes it). Replaced by a spinner while `loading`. */
icon?: ReactNode;
/** Render only the icon: children are dropped and `label` supplies the accessible name. */
iconOnly?: boolean;
/** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */
/** Accessible name + tooltip, when the visible text alone is not enough. */
label?: string;
/** Busy: swap the icon for a spinner, mark aria-busy + aria-disabled, and swallow activation — but
* NOT native `disabled`, which would throw the focus out of the page (see the render below). */
loading?: boolean;
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
pressed?: boolean;
/** Sits on a picture (a thumbnail, a poster): the pill shape. Implied by the `media` variant. */
onMedia?: boolean;
fullWidth?: boolean;
}
// A button that renders NO TEXT has to get its accessible name from somewhere, and "required for
// `iconOnly`" was a sentence in a doc comment — so the S1 migration shipped two icon buttons that
// announce themselves as a bare "button" and every lane stayed green (E10 review round 1). The rule
// is a type now, in the file that owns the contract.
//
// Both spellings of a text-less button are covered: `iconOnly`, and the far commoner `size="icon"` /
// `"icon-sm"` (a square box that fits one glyph). Any of the three naming props satisfies it —
// `title` included, because the accname spec does fall back to it and ~16 call sites legitimately
// use it for the tooltip that IS the name.
// A button of this primitive always renders text, or it is an `IconButton`. `<Button icon={x} />`
// with no children would typecheck into a nameless button, so a text-less one must be named — and
// the named, text-less shape is what `IconButton` exists for.
type NamedSomehow = { label: string } | { "aria-label": string } | { title: string };
type TextlessButton = ({ iconOnly: true } | { size: "icon" | "icon-sm" }) & NamedSomehow;
// …and a button of any other size must actually HAVE text, or be named: `<Button icon={x} />` with
// no children and no size typechecked into a nameless button (E10 review).
type TextfulButton = { iconOnly?: false; size?: Exclude<ButtonSize, "icon" | "icon-sm"> } & (
| { children: ReactNode }
| NamedSomehow
);
export type ButtonProps = ButtonOwnProps & (TextlessButton | TextfulButton);
export type ButtonProps = ButtonOwnProps & ({ children: ReactNode } | NamedSomehow);
const isLink = (v: ButtonVariant) => v === "link" || v === "link-muted";
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button(
@@ -134,9 +114,10 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
variant = "primary",
size,
icon,
iconOnly = false,
label,
loading = false,
pressed,
onMedia = false,
fullWidth = false,
className,
children,
@@ -149,18 +130,15 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
},
ref,
) {
// Unavailable while busy (`loading`) or disabled — without dropping a focused button's focus:
// see FocusSafeButton, which owns that rule for every button in the app.
// A link must not inherit the action-button padding/radius, so its size default differs. Resolved
// here rather than by asking every call site to pass `size="none"` alongside `variant="link"` —
// a pairing everyone would eventually forget, which is how a "link" grows a button's box.
const resolvedSize: ButtonSize = size ?? (variant === "link" ? "none" : "md");
// The tablet comfort contract (docs/DESIGN.md) lives HERE, not in the call sites: on a coarse pointer every
// button-shaped control gets at least a 2.75rem box (see .touch-target in index.css). `link` is
// excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would
// wreck the paragraph it sits in rather than make anything easier to hit.
const touchTarget = variant !== "link";
const leading = loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon;
// A link must not inherit the action-button box, so its size default differs. Resolved here rather
// than by asking every call site to pass `size="none"` alongside a link variant — a pairing everyone
// would eventually forget, which is how a "link" grows a button's box.
const resolvedSize: ButtonSize = size ?? (isLink(variant) ? "none" : "md");
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A link is excluded on
// purpose — it is a run of text inside a sentence, and a 44px-tall inline link would wreck the
// paragraph it sits in rather than make anything easier to hit.
const touchTarget = !isLink(variant);
const leading = loading ? <Loader2 className="animate-spin" /> : icon;
return (
<FocusSafeButton
ref={ref}
@@ -171,11 +149,14 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
onFocus={onFocus}
onBlur={onBlur}
aria-label={label}
aria-pressed={pressed}
title={label}
className={clsx(
BASE,
VARIANTS[variant],
pressed !== undefined && PRESSED,
SIZES[resolvedSize],
resolvedSize !== "none" && (onMedia || variant === "media" ? "rounded-full" : "rounded-lg"),
touchTarget && "touch-target",
fullWidth && "w-full",
className,
@@ -183,7 +164,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
{...rest}
>
{leading}
{!iconOnly && children}
{children}
</FocusSafeButton>
);
});
+40
View File
@@ -0,0 +1,40 @@
import { forwardRef } from "react";
import type { ButtonHTMLAttributes } from "react";
import clsx from "clsx";
import { GripVertical } from "lucide-react";
// The grip a sortable row is dragged by. The caller spreads its sortable `attributes` and `listeners`
// onto it. `label` names the row it moves: a screen reader that hears a bare "reorder" on every row
// cannot tell which one it is on.
//
// Unavailable (under a sort or a filter, say) it stays focusable, keeps its tooltip and lets the label
// say why: native `disabled` would drop it from the tab order and suppress the tooltip, so the reason
// would reach nobody. The caller also leaves its `listeners` off, so nothing can start a drag.
export const DragHandle = forwardRef<
HTMLButtonElement,
Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "disabled"> & {
label: string;
unavailable?: boolean;
}
>(function DragHandle({ label, unavailable = false, className, onClick, ...rest }, ref) {
return (
<button
ref={ref}
type="button"
{...rest}
aria-label={label}
title={label}
aria-disabled={unavailable || undefined}
onClick={(e) => (unavailable ? e.preventDefault() : onClick?.(e))}
className={clsx(
"inline-grid size-7 shrink-0 touch-none place-items-center rounded-lg text-muted transition touch-target [&>svg]:size-4",
unavailable
? "cursor-not-allowed opacity-30"
: "cursor-grab state-layer hover:text-fg active:cursor-grabbing",
className,
)}
>
<GripVertical />
</button>
);
});
@@ -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 (
<button
ref={setNode}
{...rest}
data-focus-safe=""
disabled={!!disabled && !focused}
aria-disabled={soft || undefined}
aria-disabled={soft || callerSoft || undefined}
aria-busy={busy || undefined}
onClick={(e) => {
if (soft) {
@@ -0,0 +1,138 @@
// @vitest-environment jsdom
//
// E28 S3b: the glyph-only button (DESIGN.md A2) and the two other click primitives that sit beside
// `Button`: `Pressable` (a surface whose content is the control) and `DragHandle`.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import { DragHandle } from "./DragHandle";
afterEach(cleanup);
describe("IconButton", () => {
it("is named and tooltipped by its label, and sizes to the A2 rungs", () => {
const { rerender } = render(
<IconButton label="Delete">
<svg data-testid="ic" />
</IconButton>,
);
const btn = screen.getByRole("button", { name: "Delete" });
expect(btn.getAttribute("title")).toBe("Delete");
expect(btn.getAttribute("type")).toBe("button");
expect(btn.className).toContain("size-7");
expect(btn.className).toContain("touch-target");
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
rerender(
<IconButton label="Delete" size="md" noTitle>
<svg />
</IconButton>,
);
expect(btn.className).toContain("size-8");
expect(btn.hasAttribute("title")).toBe(false);
});
it("is round only on media (A2)", () => {
for (const variant of ["ghost", "outline", "danger", "glass", "overlay"] as const) {
const { unmount } = render(
<IconButton label="X" variant={variant}>
<svg />
</IconButton>,
);
expect(screen.getByRole("button", { name: "X" }).className).toContain("rounded-lg");
unmount();
}
render(
<IconButton label="Play" variant="media">
<svg />
</IconButton>,
);
expect(screen.getByRole("button", { name: "Play" }).className).toContain("rounded-full");
});
it("a toggle announces aria-pressed", () => {
render(
<IconButton label="Saved" pressed>
<svg />
</IconButton>,
);
expect(screen.getByRole("button", { name: "Saved" }).getAttribute("aria-pressed")).toBe("true");
});
it("loading swaps the glyph for a spinner and swallows the click", () => {
const onClick = vi.fn();
render(
<IconButton label="Sync" loading onClick={onClick}>
<svg data-testid="ic" />
</IconButton>,
);
const btn = screen.getByRole("button", { name: "Sync" });
expect(btn.getAttribute("aria-busy")).toBe("true");
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
it("keeps a caller's aria-disabled: unavailable, focusable, and still saying why", () => {
// The "an account is behind this address, delete the user instead" state: the reason lives in
// the label, so the control must stay in the tab order. FocusSafeButton used to overwrite it.
render(
<IconButton label="Delete the user instead" aria-disabled onClick={(e) => e.preventDefault()}>
<svg />
</IconButton>,
);
const btn = screen.getByRole("button", { name: "Delete the user instead" });
expect(btn.getAttribute("aria-disabled")).toBe("true");
expect(btn.hasAttribute("disabled")).toBe(false);
});
it("cannot be rendered without a name (type level)", () => {
render(
<>
{/* @ts-expect-error a glyph-only button with no label has no accessible name */}
<IconButton>
<svg />
</IconButton>
<IconButton label="named">
<svg />
</IconButton>
</>,
);
expect(screen.getByRole("button", { name: "named" })).toBeTruthy();
});
});
describe("Pressable", () => {
it("never submits a form, reads left-aligned, and paints a layer only on request", () => {
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
const { rerender } = render(
<form onSubmit={onSubmit}>
<Pressable>Row</Pressable>
</form>,
);
const row = screen.getByRole("button", { name: "Row" });
fireEvent.click(row);
expect(onSubmit).not.toHaveBeenCalled();
expect(row.className).toContain("text-left");
expect(row.className).not.toContain("state-layer");
rerender(
<form onSubmit={onSubmit}>
<Pressable layer>Row</Pressable>
</form>,
);
expect(row.className).toContain("state-layer");
});
});
describe("DragHandle", () => {
it("names the row it moves, and when unavailable stays focusable with its reason", () => {
const onClick = vi.fn();
render(<DragHandle label="Move Intro" unavailable onClick={onClick} />);
const h = screen.getByRole("button", { name: "Move Intro" });
expect(h.hasAttribute("disabled")).toBe(false);
expect(h.getAttribute("aria-disabled")).toBe("true");
expect(h.getAttribute("title")).toBe("Move Intro");
fireEvent.click(h);
expect(onClick).not.toHaveBeenCalled();
});
});
+109
View File
@@ -0,0 +1,109 @@
import { forwardRef } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import { Loader2 } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
// A button that is only a glyph (docs/DESIGN.md A2): two sizes, r8, and a name it cannot ship without
// (`label` is required: it is the accessible name and the tooltip). On a picture it is round and dark
// translucent (`media`), readable on any thumbnail; round means "on media" and nothing else. The glyph
// is sized here, so a row of them never mixes 14 and 16px icons.
type IconButtonVariant =
| "ghost" // the default: quiet glyph, a state layer on hover
| "primary" // solid accent — the one affirmative action of a compact form (Send, Save view)
| "outline" // bordered — an icon action that stands on its own in a form or a header
| "outline-accent" // bordered + accent — an affirmative row action (Approve)
| "outline-danger" // bordered, danger on hover — a row's destructive action beside bordered siblings
| "outline-warn" // bordered + warn — a reversible restriction that is currently ON (Suspended)
| "danger" // quiet at rest, danger on hover — a row's delete/remove
| "glass" // glass card — beside the glass Buttons on a Plex page
| "media" // round, dark translucent — on a thumbnail or a poster
| "overlay"; // white glyph on the always-dark player chrome, whatever the theme
type IconButtonSize =
| "sm" // 28 — rows and dense toolbars
| "md" // 32 — on its own
| "lg"; // 48 — only the Play in the middle of a picture (C5: a not-started card)
const ACT = "enabled:not-aria-disabled:";
const VARIANTS: Record<IconButtonVariant, string> = {
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
primary: "state-filled",
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
"outline-accent": "border border-accent/40 text-accent state-layer",
"outline-danger": `border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
"outline-warn": "border border-warn/40 text-warn state-layer",
danger: `text-muted state-layer ${ACT}hover:text-danger`,
glass: `glass-card glass-hover ${ACT}hover:text-accent`,
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
media: `bg-black/60 text-white ${ACT}hover:bg-black/75`,
overlay: `text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
};
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
const PRESSED = "aria-pressed:text-accent";
const SIZES: Record<IconButtonSize, string> = {
sm: "size-7 [&>svg]:size-4",
md: "size-8 [&>svg]:size-4",
lg: "size-12 [&>svg]:size-6",
};
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/** The accessible name and the tooltip. Required: the button renders no text. */
label: string;
/** The glyph (a lucide element); the primitive sizes it. */
children: ReactNode;
variant?: IconButtonVariant;
size?: IconButtonSize;
/** A toggle: sets aria-pressed and tints the glyph with the accent. */
pressed?: boolean;
/** Sits on a picture: round (A2). Implied by the `media` variant. */
onMedia?: boolean;
/** Busy: a spinner replaces the glyph, and the button is unavailable without losing the focus. */
loading?: boolean;
/** Leave the tooltip to a richer one the caller renders (a `Tooltip` around the button). */
noTitle?: boolean;
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{
label,
children,
variant = "ghost",
size = "sm",
pressed,
onMedia = false,
loading = false,
noTitle = false,
type,
className,
...rest
},
ref,
) {
return (
<FocusSafeButton
ref={ref}
type={type ?? "button"}
busy={loading}
aria-label={label}
aria-pressed={pressed}
title={noTitle ? undefined : label}
className={clsx(
"inline-grid shrink-0 place-items-center transition touch-target disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
VARIANTS[variant],
onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && PRESSED,
SIZES[size],
className,
)}
{...rest}
>
{loading ? <Loader2 className="animate-spin" /> : children}
</FocusSafeButton>
);
});
+4 -10
View File
@@ -6,7 +6,7 @@ import { useBackToClose } from "../../lib/history";
import { anyBlockingLayerAbove, pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
import { useFullscreenPortalHost } from "../../lib/fullscreen";
import { trapTab, useDialogChrome } from "./dialogFocus";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import Toaster from "./Toaster";
import { useOverlayOrder } from "./overlayOrder";
@@ -122,15 +122,9 @@ export default function Modal({
<h2 id={titleId} className="text-lg font-semibold leading-snug">
{title}
</h2>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={onClose}
title={t("common.close")}
aria-label={t("common.close")}
icon={<X className="w-4 h-4" />}
/>
<IconButton label={t("common.close")} size="sm" variant="ghost" onClick={onClose}>
<X className="w-4 h-4" />
</IconButton>
</div>
<div className="px-5 pb-5">{children}</div>
</div>
+35
View File
@@ -0,0 +1,35 @@
import { forwardRef } from "react";
import type { ButtonHTMLAttributes } from "react";
import clsx from "clsx";
import { FocusSafeButton } from "./FocusSafeButton";
// A click SURFACE: a button whose content is the control — a card, a list row, a poster tile, a menu
// row, a thumbnail that opens its video. It is not an action button (that is `Button`, with a label and
// a rung) and not a glyph (`IconButton`); it owns what every such surface needs and kept forgetting:
// `type="button"` (never a stray form submit), a left-aligned body, the focus-safe disabled state, and,
// on request, the B3 state layer for a row that has no hover of its own. `className` lays the surface
// out; its paint is the surface's own (a card's glass, a row's layer).
export const Pressable = forwardRef<
HTMLButtonElement,
ButtonHTMLAttributes<HTMLButtonElement> & {
/** Paint the B3 hover/pressed layer (a plain row or menu item; a card has its own hover). */
layer?: boolean;
/** A tile with a caption under a picture reads centred; everything else left. */
align?: "left" | "center";
/** Working: unavailable and announced as busy. */
busy?: boolean;
}
>(function Pressable({ type, layer = false, align = "left", className, ...rest }, ref) {
return (
<FocusSafeButton
ref={ref}
type={type ?? "button"}
className={clsx(
align === "center" ? "text-center" : "text-left",
layer && "state-layer",
className,
)}
{...rest}
/>
);
});
+7 -6
View File
@@ -39,7 +39,7 @@ domain-free.
| area | components |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `DragHandle` (one per button family, docs/DESIGN.md), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
@@ -51,8 +51,9 @@ are not a target — see [`docs/DESIGN.md`](../../../../docs/DESIGN.md) § Devic
change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the
~160 call sites, because a rule repeated in 160 places is a rule that is already broken somewhere:
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` are the rungs, derived from what
the app actually used. Pick a rung; do not hand-roll padding on a primitive.
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` and `IconButton`'s sizes are the
rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32). Pick a rung; do not hand-roll
padding on a primitive.
- **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale`
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
@@ -64,10 +65,10 @@ change at 1024x768, 820x1180 and a desktop size. That contract lives in these co
button is fine with a mouse and not with a thumb, so the box grows where there is no precise
pointer. It grows the real box rather than painting an invisible hit area: two expanded hit areas
on neighbouring icon buttons would overlap, and an overlap fires the _wrong_ button.
- `link` is excluded from the minimum: it is text inside a sentence.
- The link variants are excluded from the minimum: they are text inside a sentence.
Controls outside this directory (chips, list rows, the player's overlay buttons) are **not** covered
yet -- they are the next thing to bring under the contract, not evidence that there isn't one.
E28 S3b brings every button in the app onto these primitives (the chips and the player's overlay
buttons last), after which a raw `<button>` outside this directory is a lint error.
## Keeping it honest
+12 -13
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade";
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
@@ -102,16 +102,15 @@ export default function SidePanel({
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
Module actions live on their own row below so the title always has room. */}
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
<Button
<IconButton
label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
size="sm"
variant="ghost"
size="icon-sm"
className="shrink-0 -ml-1"
onClick={onToggleCollapse}
title={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
className="-ml-1"
>
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</Button>
</IconButton>
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{onTitleClick ? (
<button
@@ -136,14 +135,14 @@ export default function SidePanel({
</span>
)}
{editing && onReset && (
<Button
<IconButton
label={t("sidebar.resetDefaults")}
size="sm"
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={onReset}
title={t("sidebar.resetDefaults")}
icon={<RotateCcw className="w-4 h-4" />}
/>
>
<RotateCcw className="w-4 h-4" />
</IconButton>
)}
{onToggleEditing && (
<button
+30 -59
View File
@@ -12,7 +12,9 @@ import {
Trash2,
} from "lucide-react";
import { Contrast, Reference, Sample } from "./labKit";
import { DANGER, FILLED, H, LAYER, STRONG } from "./labTokens";
import { H, LAYER, STRONG } from "./labTokens";
import { Button } from "../components/ui/Button";
import { IconButton } from "../components/ui/IconButton";
// E28 S2 · Batch A — controls, DECIDED (owner, 2026-09-24: all six as recommended), rendered with the
// batch B decisions that refine them: B1's compact ladder and B3's state layers. Only the chosen
@@ -20,14 +22,7 @@ import { DANGER, FILLED, H, LAYER, STRONG } from "./labTokens";
type Tone = "primary" | "ghost" | "outline" | "glass" | "danger";
const TONE: Record<Tone, string> = {
primary: FILLED,
ghost: clsx("text-muted hover:text-fg", LAYER),
outline: clsx("border border-border text-muted hover:text-fg", LAYER),
glass: clsx("glass-card glass-hover hover:text-accent"),
danger: DANGER,
};
// The lab renders the shipped primitives (E28 S3b), so a reference can never drift from the app.
export function Btn({
tone,
size,
@@ -40,48 +35,24 @@ export function Btn({
children: ReactNode;
}) {
return (
<button
type="button"
className={clsx(
"inline-flex items-center justify-center rounded-lg font-medium transition",
TONE[tone],
H[size],
)}
>
{icon}
<Button variant={tone} size={size} icon={icon}>
{children}
</button>
</Button>
);
}
const ico = "w-4 h-4";
function IconBtn({ box, children }: { box: string; children: ReactNode }) {
return (
<button
type="button"
className={clsx(
"grid place-items-center rounded-lg text-muted hover:text-fg transition",
LAYER,
box,
)}
>
{children}
</button>
);
}
function OnMedia() {
const b = "grid h-8 w-8 place-items-center rounded-full bg-black/55 text-white hover:bg-black/75";
return (
<div className="relative aspect-video w-56 overflow-hidden rounded-lg bg-linear-to-br from-accent/50 to-card">
<span className="absolute bottom-1.5 right-1.5 flex gap-1.5">
<button type="button" className={b}>
<Play className={ico} />
</button>
<button type="button" className={b}>
<ListPlus className={ico} />
</button>
<IconButton label="Play" variant="media" size="md">
<Play />
</IconButton>
<IconButton label="Add to playlist" variant="media" size="md">
<ListPlus />
</IconButton>
</span>
</div>
);
@@ -178,9 +149,9 @@ export function BatchA() {
<Btn tone="glass" size="sm" icon={<RefreshCw className={ico} />}>
Sync now
</Btn>
<IconBtn box={H.iconSm}>
<MoreHorizontal className={ico} />
</IconBtn>
<IconButton label="More" size="sm">
<MoreHorizontal />
</IconButton>
</div>
</Sample>
<Sample label="Dialog footer · md">
@@ -215,26 +186,26 @@ export function BatchA() {
<div className="flex items-center justify-between rounded-lg bg-card/60 px-3 py-0.5 text-sm">
<span className="truncate">Techno Tim</span>
<span className="flex gap-1">
<IconBtn box={H.iconSm}>
<Pencil className={ico} />
</IconBtn>
<IconBtn box={H.iconSm}>
<RefreshCw className={ico} />
</IconBtn>
<IconBtn box={H.iconSm}>
<Trash2 className={ico} />
</IconBtn>
<IconButton label="Rename" size="sm">
<Pencil />
</IconButton>
<IconButton label="Refresh" size="sm">
<RefreshCw />
</IconButton>
<IconButton label="Delete" size="sm">
<Trash2 />
</IconButton>
</span>
</div>
</Sample>
<Sample label="On its own · 32">
<div className="flex gap-2">
<IconBtn box={H.icon}>
<Download className={ico} />
</IconBtn>
<IconBtn box={H.icon}>
<MoreHorizontal className={ico} />
</IconBtn>
<IconButton label="Download" size="md">
<Download />
</IconButton>
<IconButton label="More" size="md">
<MoreHorizontal />
</IconButton>
</div>
</Sample>
<Sample label="On a picture">
-1
View File
@@ -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";
+9 -7
View File
@@ -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));
}
}
+17
View File
@@ -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", () => {