feat(design): every button on its family, the chip primitive, the lint rule (E28 S3b2)

The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.

- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
  floor, 44 on touch) replace five chip geometries; FilterChipGroup
  renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
  round, overlay; 44 on touch); the YT modal bar is on the sm rung with
  pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
  new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
  add) announce checked/mixed; a one-letter tag chip was 21px wide;
  descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
  control-primitive files is a lint error (contract-tested, seen red).
This commit is contained in:
2026-09-24 23:03:23 +02:00
parent 86f467bd65
commit 81936e9f56
94 changed files with 1327 additions and 1093 deletions
+1 -1
View File
@@ -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 button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) | a hand-rolled `<button className="px-3 py-2 …">` |
| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) · `Chip` · `DragHandle` | a raw `<button>` outside `components/ui/` (lint-enforced) |
| 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>` |
+15 -6
View File
@@ -130,16 +130,24 @@ Also in force:
- **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]
tag chip is `Chip`; 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; the Plex
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` outside
`components/ui/` is a lint error. A text-less button must have an accessible name, and the type enforces
it. [WCAG 4.1.2]
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
`filter` is a selectable option, solid accent when `pressed` (also the "+N" and "More…" triggers, lit
while their popover is open); `token` is an applied narrowing, the accent tint, the whole chip removing
it; `tag` is a user's tag, the solid accent; `suggest` is a suggestion not yet taken (dashed accent);
`ghost` the quiet "add a tag" (dashed neutral). A chip whose label is not the control but carries its
own × is `DismissibleChip`: two sibling controls at most, never a button in a button. An overflowing chip
lane measures a hidden mirror painted with `chipClass`, so the mirror cannot drift from the chips.
- **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`.
- A single-select row of filter chips goes through `FilterChipGroup` (its options are `Chip`s).
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
[Nielsen #1]
@@ -313,4 +321,5 @@ 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 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 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 |
+46
View File
@@ -82,6 +82,48 @@ const focusSafeDisabled = {
};
},
};
// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
// drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton,
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
// files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a
// side panel, a toast) are built from the primitives like any feature component.
const BUTTON_PRIMITIVES = new Set([
"FocusSafeButton.tsx", // the focus-keeping <button> everything stands on
"Button.tsx",
"IconButton.tsx",
"Pressable.tsx",
"Chip.tsx",
"DragHandle.tsx",
"AnchoredPopover.tsx", // the menu row: floating-ui's roving focus reads its native `disabled`
"SegmentedToggle.tsx",
"Tabs.tsx",
"form.tsx", // Switch
"PriorityStepper.tsx",
"SortControl.tsx",
"FilterSelect.tsx", // the dropdown trigger (A5, S3c)
"Combobox.tsx", // the field's own chips and chevron (A4, S3c)
]);
const buttonPrimitive = {
meta: {
type: "problem",
messages: {
raw: "A raw <{{name}}> outside the control primitives: use ui/Button (a labelled action), IconButton (a glyph), Pressable (a card or row that is the control), Chip or DragHandle.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && BUTTON_PRIMITIVES.has(file)) return {};
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier") return;
const name = node.name.name;
if (name === "button" || name === "FocusSafeButton")
context.report({ node, messageId: "raw", data: { name } });
},
};
},
};
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
@@ -157,6 +199,7 @@ const localRules = {
"sortable-a11y": sortableA11y,
"jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"literal-a11y-text": literalA11yText,
},
};
@@ -211,6 +254,7 @@ export default tseslint.config(
"react-hooks/refs": "off",
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"local/button-primitive": "error",
"local/literal-a11y-text": "error",
"local/sortable-a11y": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
@@ -372,6 +416,8 @@ export default tseslint.config(
"no-restricted-globals": "off",
"no-restricted-properties": "off",
"local/literal-a11y-text": "off",
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
"local/button-primitive": "off",
},
},
prettier,
+28 -20
View File
@@ -13,7 +13,7 @@ import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
// The admin's answer to "why can this person not get in?", and the place every recovery action for
// one account lives (E12 · S5, design option C).
@@ -196,24 +196,28 @@ export default function AccountPanel({
</div>
<div className="space-y-2">
{a.can_activate && (
<FocusSafeButton
<Button
variant="glass"
fullWidth
icon={<Check />}
loading={activate.isPending}
onClick={() => activate.mutate()}
disabled={activate.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
className="border border-accent/40 text-accent"
>
<Check className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.activate")}</span>
</FocusSafeButton>
</Button>
)}
<FocusSafeButton
<Button
variant="glass"
fullWidth
icon={<Send />}
loading={resend.isPending}
onClick={() => resend.mutate()}
// The demo account's address is never confirmed, and the server refuses to mail it — as
// it refuses it a set-password link below (deep-review E11 round 3).
disabled={user.is_demo || !a.email_configured || a.email_verified || resend.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
disabled={user.is_demo || !a.email_configured || a.email_verified}
>
<Send className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.resend")}</span>
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
whole reason the link below exists, so naming it here is the explanation. */}
@@ -223,18 +227,21 @@ export default function AccountPanel({
{a.email_configured && a.email_verified && (
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
)}
</FocusSafeButton>
</Button>
<FocusSafeButton
<Button
variant="glass"
fullWidth
icon={<KeyRound />}
loading={issue.isPending}
disabled={user.is_demo}
onClick={() => issue.mutate()}
disabled={issue.isPending || user.is_demo}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
className="border border-accent/40 text-accent"
>
<KeyRound className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
</span>
</FocusSafeButton>
</Button>
</div>
{link && (
@@ -243,13 +250,14 @@ export default function AccountPanel({
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
{link.url}
</div>
<button
<Button
variant="glass"
icon={copied ? <Check /> : <Copy />}
onClick={copy}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
className="border border-accent/40 text-accent"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? t("users.account.copied") : t("users.account.copy")}
</button>
</Button>
</div>
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
</div>
+24 -26
View File
@@ -6,6 +6,9 @@ import { decideChipLayout } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
@@ -22,23 +25,19 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// the "fit some + N more" treatment used on wider lanes.
const NARROW_PX = 420;
const chipCls =
"chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
const { t } = useTranslation();
return (
<button
<Chip
variant="token"
size="md"
data-testid={testId}
data-filter-key={f.key}
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className={chipCls}
removeLabel={t("feed.chips.remove", { name: f.label })}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
</button>
</Chip>
);
}
@@ -69,18 +68,16 @@ function OverflowPopover({
const name = ariaLabel ?? label;
return (
<>
<button
type="button"
<Chip
variant="token"
size="md"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={name}
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
open ? "border-accent text-accent" : "hover:border-accent"
}`}
icon={icon ? <Filter className="size-3.5" aria-hidden="true" /> : undefined}
>
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
{label}
</button>
</Chip>
<PopoverSurface
popover={pop}
ariaLabel={name}
@@ -101,17 +98,17 @@ function OverflowPopover({
))}
</div>
<div className="mt-1 border-t border-border px-1 pt-1.5">
<button
type="button"
<Button
variant="link-muted"
data-testid="active-filter-clear-all"
onClick={() => {
onClearAll();
setOpen(false);
}}
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
className="text-xs underline underline-offset-2"
>
{t("sidebar.clearAll")}
</button>
</Button>
</div>
</PopoverSurface>
</>
@@ -191,13 +188,13 @@ export default function ActiveFilterChips({
ref={(el) => {
chipRefs.current[i] = el;
}}
className={chipCls}
className={chipClass("token", "md") + " pr-1.5"}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0" />
<X className="size-3" />
</span>
))}
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
<span ref={plusRef} className={chipClass("token", "md")}>
+{n}
</span>
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
@@ -230,13 +227,14 @@ export default function ActiveFilterChips({
/>
)}
{layout.mode === "all" && n > 1 && (
<button
<Button
variant="link-muted"
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
className="ml-0.5 text-sm underline underline-offset-2"
>
{t("sidebar.clearAll")}
</button>
</Button>
)}
</>
)}
+12 -20
View File
@@ -6,7 +6,7 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Copy, Radio, Search, X } from "lucide-react";
import { Copy, Radio, Search } from "lucide-react";
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard";
@@ -33,6 +33,7 @@ import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { DismissibleChip } from "./ui/Chip";
// Admin → Szervernapló: the server's stored log (`log_events`, E22.6). One list, newest first; a line
// opens in place to its full record, and its request id / job id narrow the list to that request or
@@ -421,18 +422,9 @@ function PivotChip({
onClear: () => void;
}) {
return (
<span className="chip py-0.5 pl-2.5 pr-1">
<span className="font-mono">{label}</span>
<button
type="button"
onClick={onClear}
aria-label={clearLabel}
title={clearLabel}
className="rounded-full p-0.5 hover:bg-accent/20"
>
<X className="h-3 w-3" aria-hidden />
</button>
</span>
<DismissibleChip onDismiss={onClear} dismissLabel={clearLabel} className="font-mono">
{label}
</DismissibleChip>
);
}
@@ -449,7 +441,7 @@ function RowDetail({
}) {
const { t } = useTranslation();
const pivotCls =
"font-mono text-accent underline decoration-dotted underline-offset-2 hover:decoration-solid";
"font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid";
return (
<div className="flex flex-col gap-2">
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-sans text-xs">
@@ -465,14 +457,14 @@ function RowDetail({
<>
<dt>{t("adminLogs.fields.request")}</dt>
<dd>
<button
type="button"
<Button
variant="link"
className={pivotCls}
title={t("adminLogs.showRequest")}
onClick={() => onRequest(row.request_id!)}
>
{row.request_id}
</button>
</Button>
</dd>
</>
)}
@@ -480,14 +472,14 @@ function RowDetail({
<>
<dt>{t("adminLogs.fields.job")}</dt>
<dd>
<button
type="button"
<Button
variant="link"
className={pivotCls}
title={t("adminLogs.showJob")}
onClick={() => onJob(row.job_id!)}
>
{row.job_id}
</button>
</Button>
</dd>
</>
)}
+7 -7
View File
@@ -23,7 +23,7 @@ import Pager from "./ui/Pager";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./confirmContext";
import { useChannelLayout } from "./channelsContext";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
@@ -108,16 +108,16 @@ export default function ChannelDiscovery({
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
}
>
<FocusSafeButton
// Busy blocks the click, not the focus (FocusSafeButton).
<Button
variant="glass"
size="xs"
icon={<UserPlus />}
onClick={() => onSubscribe(c)}
disabled={!canWrite}
busy={subscribe.isPending}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 aria-disabled:opacity-50 transition"
loading={subscribe.isPending}
>
<UserPlus className="w-3.5 h-3.5" />
{t("channels.discovery.subscribe")}
</FocusSafeButton>
</Button>
</Tooltip>
),
},
+4 -3
View File
@@ -3,6 +3,7 @@ import { ExternalLink } from "lucide-react";
import Avatar from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import { channelYouTubeUrl } from "../lib/format";
import { Pressable } from "./ui/Pressable";
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
// discovery list (and anywhere else that lists a channel). When `onView` is given the name
@@ -28,7 +29,7 @@ export default function ChannelLink({
<div className="flex items-center gap-2 min-w-0">
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
{onView ? (
<button
<Pressable
onClick={onView}
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
// card clicks this.
@@ -46,10 +47,10 @@ export default function ChannelLink({
}}
// title = the full name, so a truncated fluid-column name is still readable on hover.
title={name}
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
className="text-sm font-medium truncate hover:text-accent min-w-0"
>
{name}
</button>
</Pressable>
) : (
<span title={name} className="text-sm font-medium truncate min-w-0">
{name}
+25 -37
View File
@@ -38,7 +38,6 @@ 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
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -357,14 +356,15 @@ export default function ChannelPage({
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
/>
)}
<button
<Button
variant="media"
data-testid="channel-back"
icon={<ArrowLeft />}
onClick={closeChannel}
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-xs"
className="absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"
>
<ArrowLeft className="w-4 h-4" />
{t("channel.back")}
</button>
</Button>
</div>
<div className="px-4 sm:px-6">
@@ -489,39 +489,32 @@ export default function ChannelPage({
</Button>
)}
{!me.is_demo && (
<FocusSafeButton
<IconButton
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// light up on hover, as if a click would do something.
size="md"
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
onClick={() => block.mutate()}
busy={block.isPending}
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
ch?.blocked
? "border-danger text-danger"
: "border-border text-muted not-aria-disabled:hover:text-danger not-aria-disabled:hover:border-danger"
}`}
loading={block.isPending}
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
>
<Ban className="w-4 h-4" />
</FocusSafeButton>
<Ban />
</IconButton>
)}
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && !me.is_demo && (
<FocusSafeButton
<IconButton
size="md"
variant={ch.hidden ? "outline-accent" : "outline"}
onClick={() => toggleHidden.mutate()}
busy={toggleHidden.isPending}
loading={toggleHidden.isPending}
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
ch.hidden
? "border-accent text-accent"
: "border-border text-muted not-aria-disabled:hover:text-fg not-aria-disabled:hover:border-fg/40"
}`}
>
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</FocusSafeButton>
{ch.hidden ? <EyeOff /> : <Eye />}
</IconButton>
)}
{!me.is_demo &&
!ch?.blocked &&
@@ -585,20 +578,15 @@ export default function ChannelPage({
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<button
<Button
// Busy blocks the click, not the focus (see Block above).
onClick={() => !exploring && runExplore(nextToken)}
aria-disabled={exploring || undefined}
aria-busy={exploring || undefined}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border not-aria-disabled:hover:bg-surface aria-disabled:opacity-50"
variant="outline"
icon={<RefreshCw />}
loading={exploring}
onClick={() => runExplore(nextToken)}
>
{exploring ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
{t("channel.loadMore")}
</button>
</Button>
</div>
)}
</>
@@ -14,6 +14,7 @@ import { type MyStatus } from "../lib/api";
import { formatEta, formatNumber } from "../lib/format";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
@@ -77,15 +78,15 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
return (
<>
<button
type="button"
<Button
variant="glass"
size="sm"
icon={<BarChart3 aria-hidden="true" />}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted transition"
>
<BarChart3 className="h-4 w-4" aria-hidden="true" />
{t("channels.stats.label")}
</button>
</Button>
<PopoverSurface
popover={pop}
ariaLabel={t("channels.stats.label")}
+39 -57
View File
@@ -1,5 +1,5 @@
import { useRef, useState } from "react";
import { Check, Plus, Sparkles, X } from "lucide-react";
import { Check, Plus, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
@@ -8,6 +8,8 @@ import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { IconButton } from "./ui/IconButton";
import { Chip, DismissibleChip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
// 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:
@@ -25,20 +27,15 @@ interface TagChipProps {
title?: string;
}
// `block truncate` on the name, not the chip: the chip is inline-flex, where text-overflow cannot bite.
function TagChip({ tag, onClick, title }: TagChipProps) {
const cls =
"chip-solid shrink-0 text-2xs font-medium px-1.5 py-0.5 rounded-full max-w-36 truncate";
const name = <span className="block max-w-32 truncate">{tag.name}</span>;
return onClick ? (
<button
type="button"
onClick={onClick}
title={title}
className={`${cls} hover:opacity-80 transition`}
>
{tag.name}
</button>
<Chip variant="tag" size="xs" onClick={onClick} title={title}>
{name}
</Chip>
) : (
<span className={cls}>{tag.name}</span>
<span className={chipClass("tag", "xs")}>{name}</span>
);
}
@@ -63,33 +60,22 @@ function SuggestedChip({
const { t } = useTranslation();
const pct = Math.round(suggestion.confidence * 100);
return (
<span className="shrink-0 inline-flex items-center rounded-full border border-dashed border-accent/50 text-accent/90 text-2xs font-medium pl-1 pr-0.5 py-0.5 max-w-36">
<button
type="button"
onClick={onAccept}
title={t("channels.tags.suggestedHint", { pct })}
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition"
>
<Sparkles className="w-2.5 h-2.5 shrink-0" />
<span className="truncate">{suggestion.tag.name}</span>
</button>
<button
type="button"
onClick={onDismiss}
title={t("channels.tags.dismissSuggestion")}
aria-label={t("channels.tags.dismissSuggestion")}
className="shrink-0 ml-0.5 rounded-full text-accent/60 hover:text-danger transition"
>
<X className="w-2.5 h-2.5" />
</button>
</span>
<DismissibleChip
variant="suggest"
size="xs"
className="max-w-36 font-medium"
icon={<Sparkles />}
onClick={onAccept}
bodyTitle={t("channels.tags.suggestedHint", { pct })}
bodyLabel={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
onDismiss={onDismiss}
dismissLabel={t("channels.tags.dismissSuggestion")}
>
{suggestion.tag.name}
</DismissibleChip>
);
}
const GHOST_PILL =
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full border border-dashed border-accent/50 text-accent/90";
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
@@ -158,7 +144,7 @@ function SuggestedChipsLane({
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
</span>
))}
<span ref={pillRef} className={GHOST_PILL}>
<span ref={pillRef} className={chipClass("suggest", "xs")}>
+{suggestions.length}
</span>
</div>
@@ -173,15 +159,15 @@ function SuggestedChipsLane({
/>
))}
{hidden > 0 && (
<button
<Chip
variant="suggest"
size="xs"
ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${GHOST_PILL} hover:text-accent transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
</Chip>
)}
</div>
@@ -379,8 +365,8 @@ export function ChannelTagsControl({
placement: "bottom",
});
const pillClass =
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
const pillClass = chipClass("filter", "xs");
return (
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
@@ -394,21 +380,18 @@ export function ChannelTagsControl({
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */}
<button
<Chip
variant="ghost"
size="xs"
ref={editPop.refs.setReference}
type="button"
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
className={
bounded
? "shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
: "shrink-0 inline-flex items-center gap-1 h-6 pl-1.5 pr-2 rounded-full border border-dashed border-border text-2xs font-medium text-muted hover:text-accent hover:border-accent transition"
}
icon={<Plus className="size-3" />}
className={`font-medium ${bounded ? "w-6 justify-center px-0" : ""}`}
{...editPop.getReferenceProps()}
>
<Plus className="w-3 h-3 shrink-0" />
{!bounded && <span className="whitespace-nowrap">{t("channels.tags.add")}</span>}
</button>
{!bounded && t("channels.tags.add")}
</Chip>
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
@@ -457,15 +440,14 @@ export function ChannelTagsControl({
/>
))}
{hidden > 0 && (
<button
<Chip
size="xs"
ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
</Chip>
)}
</div>
</>
+4 -3
View File
@@ -524,12 +524,13 @@ export default function Channels() {
</Tooltip>
<TagFilterChips chips={tagFilterChips} className="flex-1" />
{tagFilter.length > 0 && (
<button
<Button
variant="link-muted"
onClick={() => setFilters({ ...filters, tags: [] })}
className="shrink-0 text-xs px-2 py-1 text-muted hover:text-accent transition"
className="text-xs px-2"
>
{t("datatable.clear")}
</button>
</Button>
)}
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
+12 -15
View File
@@ -16,6 +16,8 @@ import * as e2ee from "../lib/e2ee";
import Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./ui/Overlay";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
@@ -82,9 +84,9 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
/>
)}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
<button
<Pressable
onClick={() => toggleMinimize(chat.partnerId)}
className="flex items-center gap-2 min-w-0 flex-1 text-left"
className="flex items-center gap-2 min-w-0 flex-1"
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
<Avatar
@@ -93,21 +95,16 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
className="w-7 h-7 rounded-full shrink-0 text-xs"
/>
<span className="font-semibold text-sm truncate">{chat.name}</span>
</button>
<button
</Pressable>
<IconButton
onClick={() => toggleMinimize(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<button
onClick={() => closeChat(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={t("messages.close")}
>
<X className="w-4 h-4" />
</button>
{chat.minimized ? <ChevronUp /> : <ChevronDown />}
</IconButton>
<IconButton onClick={() => closeChat(chat.partnerId)} label={t("messages.close")}>
<X />
</IconButton>
</div>
{!chat.minimized && (
<div className="flex flex-col h-96">
+24 -22
View File
@@ -17,7 +17,7 @@ import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS, useAccountPersistedState } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
@@ -218,14 +218,15 @@ export default function ConfigPanel() {
with no error anywhere — say so next to the fields that fix it. */}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Send />}
loading={testEmail.isPending}
disabled={dirty}
onClick={() => testEmail.mutate()}
disabled={testEmail.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
{t("config.testEmail")}
</FocusSafeButton>
</Button>
</Tooltip>
</div>
)}
@@ -233,14 +234,15 @@ export default function ConfigPanel() {
{activeGroup === "issues" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.issuesTestHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Plug />}
loading={testIssues.isPending}
disabled={dirty}
onClick={() => testIssues.mutate()}
disabled={testIssues.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testIssues.isPending ? "animate-pulse" : ""}`} />
{t("config.issuesTest")}
</FocusSafeButton>
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
</div>
@@ -249,14 +251,15 @@ export default function ConfigPanel() {
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Plug />}
loading={testPlex.isPending}
disabled={dirty}
onClick={() => testPlex.mutate()}
disabled={testPlex.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
{t("config.plexTest")}
</FocusSafeButton>
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
{plexResult && (
@@ -404,15 +407,14 @@ function Field({
)}
{showReset && !secretDisabled && (
<Tooltip hint={t("config.resetHint")}>
<button
<Button
variant={item.secret && pendingSecretReset ? "link" : "link-muted"}
icon={<RotateCcw className="size-3.5!" />}
onClick={onResetField}
className={`inline-flex items-center gap-1 text-xs transition ${
item.secret && pendingSecretReset ? "text-accent" : "text-muted hover:text-fg"
}`}
className="text-xs gap-1"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("config.reset")}
</button>
</Button>
</Tooltip>
)}
</div>
+9 -8
View File
@@ -5,6 +5,8 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { type FeedFilters } from "../lib/api";
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
import { Button } from "./ui/Button";
import { Pressable } from "./ui/Pressable";
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
@@ -96,8 +98,7 @@ export default function DateFilterSelect({
return (
<>
<button
type="button"
<Pressable
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
@@ -106,9 +107,9 @@ export default function DateFilterSelect({
}`}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="flex-1 truncate text-left text-fg">{summary}</span>
<span className="flex-1 truncate text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
</Pressable>
<PopoverSurface
popover={pop}
@@ -179,13 +180,13 @@ export default function DateFilterSelect({
/>
</label>
{(filters.dateFrom || filters.dateTo) && (
<button
type="button"
<Button
variant="link-muted"
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
className="text-2xs text-muted hover:text-accent self-end"
className="text-2xs self-end"
>
{t("sidebar.clearDates")}
</button>
</Button>
)}
</div>
)}
+3 -6
View File
@@ -175,12 +175,9 @@ export default function DownloadCenter() {
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button
onClick={() => setManage(true)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
</button>
<Button variant="ghost" size="sm" icon={<Settings2 />} onClick={() => setManage(true)}>
{t("downloads.page.manage")}
</Button>
</div>
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
+2 -6
View File
@@ -77,13 +77,9 @@ export default function DownloadDialog({
onChange={([v]) => v && setProfileId(Number(v))}
options={profiles.map((p) => ({ value: String(p.id), label: p.name }))}
/>
<button
type="button"
onClick={() => setManage(true)}
className="text-xs text-accent hover:underline mt-1 mb-4"
>
<Button variant="link" onClick={() => setManage(true)} className="text-xs mt-1 mb-4">
{t("downloads.dialog.manage")}
</button>
</Button>
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
<input
+3 -5
View File
@@ -3,6 +3,7 @@ import i18n from "../i18n";
import { notify } from "../lib/notifications";
import { errorAttrs, log } from "../lib/log";
import { reloadingForNewBuild } from "../lib/staleBuild";
import { Button } from "./ui/Button";
interface Props {
children: ReactNode;
@@ -46,12 +47,9 @@ export default class ErrorBoundary extends Component<Props, State> {
{i18n.t("errors.boundary.title")}
</div>
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
<button
onClick={() => location.reload()}
className="text-accent hover:underline text-sm font-semibold"
>
<Button variant="link" onClick={() => location.reload()} className="text-sm">
{i18n.t("errors.boundary.reload")}
</button>
</Button>
</div>
</div>
);
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
import ChannelLink from "./ChannelLink";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { Button } from "./ui/Button";
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
@@ -58,15 +59,9 @@ export default function ExcludedChannelList({
onView={onView ? () => onView(c) : undefined}
/>
</div>
<button
onClick={() => onAction(c.id)}
title={actionLabel}
aria-label={actionLabel}
className="shrink-0 inline-flex items-center gap-1 rounded-full border border-border px-2 py-1 text-xs text-muted transition hover:border-accent hover:text-accent"
>
{actionIcon}
<Button variant="outline" size="xs" icon={actionIcon} onClick={() => onAction(c.id)}>
{actionLabel}
</button>
</Button>
</div>
))}
</NavGrid>
+6 -7
View File
@@ -42,6 +42,7 @@ import { PlexPosterCard } from "./plexCards";
import { useNavigationActions } from "./navigationContext";
import { usePlexActions } from "./plexContext";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
// The Home surface (E24) — "Mit nézzek most?".
//
@@ -244,8 +245,7 @@ function ShelfTile({
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
: t("home.shelf.itemCount", { count: count ?? 0 });
return (
<button
type="button"
<Pressable
onClick={() => card.next && onPlay(card)}
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
@@ -257,7 +257,7 @@ function ShelfTile({
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
// alive to all three input paths.
aria-disabled={!card.next}
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent"
className="block w-full rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent"
>
<span className="block aspect-video bg-card overflow-hidden">
{card.thumb ? (
@@ -268,7 +268,7 @@ function ShelfTile({
<span className="block text-xs font-medium truncate">{card.title}</span>
<span className="block text-2xs text-muted">{countLabel}</span>
</span>
</button>
</Pressable>
);
}
@@ -307,8 +307,7 @@ function Hero({
{...pinMenu.triggerProps(card)}
className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]"
>
<button
type="button"
<Pressable
onClick={() => onPlay(card)}
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
@@ -331,7 +330,7 @@ function Hero({
/>
</span>
) : null}
</button>
</Pressable>
<div className="flex min-w-0 flex-col">
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
+2 -2
View File
@@ -172,9 +172,9 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
{configured ? t("messages.unlock") : t("messages.setUp")}
</Button>
{configured && (
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
<Button variant="link-muted" onClick={toReset} className="text-xs">
{t("messages.forgotPass")}
</button>
</Button>
)}
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
</div>
+6 -4
View File
@@ -3,6 +3,7 @@ import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
@@ -42,15 +43,16 @@ export default function LanguageSwitcher({
return (
<>
<button
<Pressable
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
title={current.label}
aria-label={current.label}
layer
className={
rail
? "relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
: "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
? "relative p-2 rounded-lg text-muted hover:text-fg transition"
: "flex h-7 items-center gap-1.5 px-2.5 rounded-lg text-sm text-muted hover:text-fg transition"
}
>
<Globe className={rail ? "w-5 h-5" : "w-4 h-4"} />
@@ -63,7 +65,7 @@ export default function LanguageSwitcher({
) : (
<span className="text-xs font-semibold uppercase">{current.code}</span>
)}
</button>
</Pressable>
<PopoverSurface
popover={pop}
+5 -4
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import type { Level } from "../lib/log";
import { navItemAttrs } from "../lib/listNav";
import { LEVEL_TAG, logClock } from "./logLineParts";
import { Pressable } from "./ui/Pressable";
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens
@@ -39,13 +40,13 @@ export default function LogLine({
className="border-b border-border/50 last:border-b-0"
{...navItemAttrs(navIndex, `${event} ${msg}`)}
>
<button
type="button"
<Pressable
layer={!open}
data-nav-open
aria-expanded={open}
onClick={onToggle}
title={title}
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 text-left hover:bg-card ${
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 ${
open ? "bg-accent/10" : ""
}`}
>
@@ -55,7 +56,7 @@ export default function LogLine({
{/* A phone has no room for a fourth column: the message takes a line of its own under the
code, full width — sharing the code's line left it a few characters. */}
<span className="col-span-3 truncate sm:col-span-1">{msg}</span>
</button>
</Pressable>
{open && <div className="bg-accent/5 px-3 py-2 text-muted">{children}</div>}
</div>
);
+19 -18
View File
@@ -19,6 +19,7 @@ import { inputCls } from "./ui/form";
import ChatThread from "./ChatThread";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
type View =
| "list"
@@ -158,10 +159,10 @@ function ConversationList({
{...navItemAttrs(i, c.partner.name)}
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
>
<button
<Pressable
data-nav-open=""
onClick={() => onOpen(c)}
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
className="flex items-center gap-3 p-2.5 min-w-0 flex-1"
>
<Avatar
src={c.partner.avatar_url}
@@ -192,16 +193,16 @@ function ConversationList({
)}
</div>
</div>
</button>
</Pressable>
{!isSystem && ready && (
<button
<IconButton
size="md"
onClick={() => openChat(c.partner)}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
label={t("messages.popOut")}
className="mr-1.5 reveal"
>
<ExternalLink className="w-4 h-4" />
</button>
<ExternalLink />
</IconButton>
)}
</div>
);
@@ -253,10 +254,11 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
) : (
<div className="flex flex-col gap-1">
{users.map((u) => (
<button
<Pressable
key={u.id}
layer
onClick={() => onPick(u)}
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
className="flex items-center gap-3 p-2.5 rounded-xl"
>
<Avatar
src={u.avatar_url}
@@ -264,7 +266,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
className="w-9 h-9 rounded-full shrink-0 text-sm"
/>
<span className="truncate font-medium">{u.name}</span>
</button>
</Pressable>
))}
</div>
)}
@@ -317,14 +319,13 @@ function PageThread({
)}
<div className="flex-1" />
{!isSystem && ready && (
<button
<IconButton
size="md"
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition"
label={t("messages.popOut")}
>
<ExternalLink className="w-4 h-4" />
</button>
<ExternalLink />
</IconButton>
)}
</div>
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
+24 -24
View File
@@ -11,6 +11,7 @@ import Toaster from "./ui/Toaster";
import { useOverlayOrder } from "./ui/overlayOrder";
import { useLayer } from "../lib/layerStack";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
import { IconButton } from "./ui/IconButton";
// A first-login wizard that walks the user through granting YouTube access one scope at a
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
@@ -172,13 +173,14 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
aria-labelledby={titleId}
className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]"
>
<button
<IconButton
size="md"
onClick={close}
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
aria-label={t("onboarding.close")}
className="absolute top-3 right-3"
label={t("onboarding.close")}
>
<X className="w-4 h-4" />
</button>
<X />
</IconButton>
{step === "read" && (
<>
@@ -196,12 +198,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
</p>
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("read")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
>
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
</button>
<Button size="lg" icon={<Youtube />} onClick={() => beginGrant("read")}>
{t("onboarding.read.connect")}
</Button>
<Button variant="ghost" size="md" onClick={close}>
{t("onboarding.read.skip")}
</Button>
@@ -254,21 +253,21 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
)}
</p>
{importSubs.isError && (
<button
<Button
variant="outline"
icon={<RefreshCw />}
loading={importSubs.isPending}
onClick={() => importSubs.mutate()}
className="mb-3 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
className="mb-3"
>
<RefreshCw className="w-4 h-4" /> {t("onboarding.write.retryImport")}
</button>
{t("onboarding.write.retryImport")}
</Button>
)}
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("write")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
>
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
</button>
<Button size="lg" icon={<ShieldCheck />} onClick={() => beginGrant("write")}>
{t("onboarding.write.enableUnsubscribe")}
</Button>
<Button
variant="ghost"
size="md"
@@ -294,15 +293,16 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
{t("onboarding.done.body")}
</p>
<button
<Button
size="lg"
fullWidth
onClick={() => {
endOnboarding(false);
onClose();
}}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition w-full"
>
{t("onboarding.done.done")}
</button>
</Button>
</>
)}
+5 -6
View File
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { Command } from "lucide-react";
import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap";
import { Pressable } from "./ui/Pressable";
// The visible way into the command palette (and, from there, the shortcut sheet). Ctrl/Cmd+K and
// `h` had no entry point anywhere but a palette row — the one in-app pointer to the cheat sheet sat
@@ -30,15 +31,14 @@ export function PaletteKeycap() {
const { t } = useTranslation();
const cap = useCap();
return (
<button
type="button"
<Pressable
onClick={openPalette}
aria-label={`${t("palette.open")} (${cap})`}
title={`${t("palette.open")} (${cap})`}
className="shrink-0 rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden"
>
{cap}
</button>
</Pressable>
);
}
@@ -47,8 +47,7 @@ export function PaletteLauncher() {
const { t } = useTranslation();
const cap = useCap();
return (
<button
type="button"
<Pressable
onClick={openPalette}
title={t("palette.open")}
className="pointer-events-auto glass-menu flex h-11 items-center gap-2 rounded-2xl px-3 text-sm text-muted transition hover:text-fg"
@@ -58,6 +57,6 @@ export function PaletteLauncher() {
<span className="rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs @max-[640px]/appmain:hidden">
{cap}
</span>
</button>
</Pressable>
);
}
+7 -12
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookmarkPlus, Check, Play, Save, Star, Trash2, X } from "lucide-react";
import { BookmarkPlus, Check, Play, Save, Star, Trash2 } from "lucide-react";
import {
closestCenter,
DndContext,
@@ -23,6 +23,7 @@ import UndoToolbar from "./ui/UndoToolbar";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
import { Pressable } from "./ui/Pressable";
import { DismissibleChip } from "./ui/Chip";
// 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
@@ -259,20 +260,14 @@ export default function PersonalViewsModal() {
) : (
<div className="flex flex-wrap gap-1.5">
{captured.map((f) => (
<span
<DismissibleChip
key={f.key}
className="group inline-flex items-center gap-1 rounded-full bg-accent/10 py-0.5 pl-2 pr-1 text-2xs text-accent"
size="xs"
onDismiss={f.remove}
dismissLabel={t("feed.chips.remove", { name: f.label })}
>
{f.label}
<button
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className="rounded-full p-0.5 opacity-60 transition hover:bg-accent/20 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</span>
</DismissibleChip>
))}
</div>
)}
+3 -3
View File
@@ -46,7 +46,7 @@ import {
formatViews,
relativeTime,
} from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { renderDescription } from "./descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { useBackToClose } from "../lib/history";
@@ -748,9 +748,9 @@ export default function PlayerModal({
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium state-filled touch-target"
>
<ExternalLink className="w-4 h-4" />
<ExternalLink className="size-4" />
{t("player.openOnYoutube")}
</a>
</div>
@@ -4,6 +4,7 @@ import { Info } from "lucide-react";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
// The playlists module shows fewer lists than youtube.com does, and without a word about it that
// reads as Siftlode being broken (the owner's ask, 2026-09-03: say it is YouTube's API, up front).
@@ -29,14 +30,9 @@ export default function PlaylistLimitsHint() {
>
<Info className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>{t("playlists.limits.hint")}</span>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
className="text-accent hover:underline"
>
<Button variant="link" ref={pop.refs.setReference} {...pop.getReferenceProps()}>
{t("playlists.limits.why")}
</button>
</Button>
<PopoverSurface
popover={pop}
ariaLabel={t("playlists.limits.title")}
+24 -48
View File
@@ -27,11 +27,9 @@ import {
ExternalLink,
FolderInput,
History,
GripVertical,
ListPlus,
Pencil,
Play,
RefreshCw,
RotateCcw,
Trash2,
Upload,
@@ -68,8 +66,8 @@ import { ColumnHead } from "./ui/tableHeader";
import { alignClass } from "./ui/tableAlign";
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
@@ -146,21 +144,12 @@ function Row({
// The one cell the column defs can't render: the handle needs this row's sortable
// attributes/listeners. Disabled (not hidden) under a view, so the affordance stays
// put and can explain itself instead of silently doing nothing.
<button
<DragHandle
{...attributes}
{...(dragDisabled ? {} : listeners)}
// `disabled` would drop it from the tab order AND suppress the tooltip, so the
// reason reached nobody. aria-disabled keeps it focusable and the name carries the why.
aria-disabled={dragDisabled || undefined}
onClick={(e) => dragDisabled && e.preventDefault()}
title={dragDisabled ? dragHint : t("playlists.dragHandle")}
aria-label={
dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })
}
className={`text-muted ${dragDisabled ? "opacity-30 cursor-not-allowed" : "hover:text-fg cursor-grab active:cursor-grabbing"}`}
>
<GripVertical className="w-4 h-4" />
</button>
unavailable={dragDisabled}
label={dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })}
/>
) : (
col.render(video)
)}
@@ -896,17 +885,15 @@ export default function Playlists() {
<>
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
<IconButton
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
label={t("playlists.rename")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<Pencil />
</IconButton>
)}
{detail.yt_playlist_id && (
<a
@@ -932,37 +919,25 @@ export default function Playlists() {
{t("playlists.unsynced")}
</span>
)}
<FocusSafeButton
<Button
size="sm"
onClick={() => shown.length && openPlayer(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
icon={<Play className="fill-current" />}
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</FocusSafeButton>
{t("playlists.playAll")}
</Button>
{editable && canWrite && (
<FocusSafeButton
<IconButton
variant={detail.dirty ? "outline-accent" : "outline"}
onClick={pushToYoutube}
disabled={pushing}
title={
loading={pushing}
label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</FocusSafeButton>
{linked ? <Youtube /> : <Upload />}
</IconButton>
)}
{linked && detail.dirty && (
<IconButton
@@ -1009,12 +984,13 @@ export default function Playlists() {
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
<Button
variant="link-muted"
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
className="text-xs underline underline-offset-2"
>
{t("playlists.clearView")}
</button>
</Button>
)}
{canSaveOrder && (
<Button
+16 -16
View File
@@ -23,6 +23,8 @@ import { useMe } from "../lib/useMe";
import { playlistName } from "../lib/playlistName";
import { usePlaylistActions } from "./usePlaylistActions";
import PlaylistLimitsHint from "./PlaylistLimitsHint";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
type PlSort = {
key: "custom" | "name" | "count" | "duration" | "recent";
@@ -255,14 +257,14 @@ export default function PlaylistsRail({
next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir })
}
trailing={
<button
<IconButton
variant="outline"
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
title={t("playlists.dirtyFirst")}
aria-pressed={plSort.dirtyFirst}
className="pill shrink-0 rounded-lg border p-1.5"
label={t("playlists.dirtyFirst")}
pressed={plSort.dirtyFirst}
>
<Pin className="h-4 w-4" />
</button>
<Pin />
</IconButton>
}
/>
)}
@@ -292,13 +294,12 @@ export default function PlaylistsRail({
<>
{playlists.length === 0 &&
(listQuery.isError && !listQuery.data ? (
<button
type="button"
<Pressable
onClick={() => listQuery.refetch()}
className="text-xs text-danger px-1 py-1 text-left hover:underline"
className="text-xs text-danger px-1 py-1 hover:underline"
>
{t("common.loadError")} — {t("common.retry")}
</button>
</Pressable>
) : listQuery.isLoading ? (
<div className="text-xs text-muted px-1 py-1">{t("common.loading")}</div>
) : (
@@ -342,7 +343,7 @@ export default function PlaylistsRail({
/>
</div>
) : (
<button
<Pressable
key={pl.id}
ref={(el) => {
if (pl.id === selectedId) selectedRef.current = el;
@@ -354,10 +355,9 @@ export default function PlaylistsRail({
}}
onClick={() => setSelectedId(pl.id)}
{...railMenu.triggerProps(pl)}
className={`${ROW_BASE} text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
layer={pl.id !== selectedId}
className={`${ROW_BASE} ${
pl.id === selectedId ? "bg-accent/15 border-accent" : "border-transparent"
}`}
>
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0">
@@ -380,7 +380,7 @@ export default function PlaylistsRail({
` · ${formatRuntime(pl.total_duration_seconds)}`}
</div>
</div>
</button>
</Pressable>
),
)}
</div>
+13 -14
View File
@@ -43,6 +43,9 @@ import { useContextMenu } from "./ui/useContextMenu";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
import { dispatch, useKeyScope } from "../lib/keymap";
import NavGrid from "./ui/NavGrid";
import { Chip } from "./ui/Chip";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -553,10 +556,10 @@ export default function PlexBrowse() {
className="glass-card flex items-center gap-1 rounded-xl py-1.5 pl-1.5 pr-2.5"
>
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
<button
<Pressable
onClick={() => onOpenPerson(p.name)}
title={t("plex.info.viewPerson", { name: p.name })}
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg text-left"
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg"
>
{p.photo ? (
<img
@@ -578,15 +581,14 @@ export default function PlexBrowse() {
{t("plex.people.count", { count: p.count })}
</div>
</div>
</button>
<button
</Pressable>
<IconButton
onClick={() => removeActor(p.name)}
title={t("plex.people.remove")}
aria-label={t("plex.people.remove")}
className="ml-1 p-1 rounded-full text-muted hover:text-fg hover:bg-card transition"
label={t("plex.people.remove")}
className="ml-1"
>
<X className="w-4 h-4" />
</button>
<X />
</IconButton>
</div>
))}
</div>
@@ -604,12 +606,9 @@ export default function PlexBrowse() {
// query — say so and offer a one-click escape, instead of a bare "No matches".
<div className="flex flex-wrap items-center gap-3 text-sm text-muted">
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
<button
onClick={() => setFilters(EMPTY_PLEX_FILTERS)}
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
>
<Chip onClick={() => setFilters(EMPTY_PLEX_FILTERS)} className="font-medium">
{t("plex.filter.clearAll")}
</button>
</Chip>
</div>
) : (
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
@@ -9,7 +9,7 @@ import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Pressable } from "./ui/Pressable";
// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE
// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to
@@ -191,10 +191,12 @@ export default function PlexCollectionEditor({
key={c.id}
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
>
<FocusSafeButton
<Pressable
role="checkbox"
aria-checked={inIt}
onClick={() => toggle(c.id)}
disabled={!!busy}
className={`inline-flex min-w-0 flex-1 items-center gap-2 text-left disabled:opacity-50 ${
className={`inline-flex min-w-0 flex-1 items-center gap-2 disabled:opacity-50 ${
inIt ? "text-accent" : ""
}`}
>
@@ -209,14 +211,15 @@ export default function PlexCollectionEditor({
<span className="shrink-0 text-xs text-muted">
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
</span>
</FocusSafeButton>
<FocusSafeButton
</Pressable>
<Button
variant="outline-danger"
size="xs"
onClick={() => removeCollection(c.id, c.title)}
disabled={!!busy}
className="shrink-0 rounded-sm px-1.5 py-0.5 text-xs text-muted hover:text-danger disabled:opacity-50"
>
{t("plex.collEditor.delete")}
</FocusSafeButton>
</Button>
</div>
);
})
@@ -243,13 +246,14 @@ export default function PlexCollectionEditor({
<span className="shrink-0 text-xs text-muted">
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
</span>
<FocusSafeButton
<Button
variant="glass"
size="xs"
onClick={() => markEditable(c.id)}
disabled={!!busy}
className="glass-card glass-hover shrink-0 rounded-sm px-2 py-0.5 text-xs disabled:opacity-50"
>
{t("plex.collEditor.makeEditable")}
</FocusSafeButton>
</Button>
</div>
))}
</div>
+21 -21
View File
@@ -21,7 +21,8 @@ import PlexLibraryPicker from "./PlexLibraryPicker";
import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
@@ -312,12 +313,14 @@ export default function PlexCollectionView({
>
{d.summary}
</p>
<button
<Button
variant="link"
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
className="mt-1 text-xs font-medium text-accent hover:underline"
className="mt-1 text-xs"
>
{expanded ? t("plex.collection.less") : t("plex.collection.more")}
</button>
</Button>
</div>
)}
@@ -412,15 +415,15 @@ export default function PlexCollectionView({
menuProps={pinMenu.triggerProps}
/>
{canEdit && (
<FocusSafeButton
<IconButton
variant="media"
onClick={() => doRemove(c)}
disabled={busy}
title={t("plex.collection.removeItem")}
aria-label={t("plex.collection.removeItem")}
className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50"
label={t("plex.collection.removeItem")}
className="absolute right-1.5 top-1.5 z-base"
>
<X className="h-3.5 w-3.5" />
</FocusSafeButton>
<X />
</IconButton>
)}
</div>
))}
@@ -475,13 +478,9 @@ function AddItemPanel({
aria-label={t("plex.collection.addSearch")}
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
onClick={onClose}
aria-label={t("common.close")}
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
>
<X className="h-4 w-4" />
</button>
<IconButton size="md" onClick={onClose} label={t("common.close")}>
<X />
</IconButton>
</div>
<PlexLibraryPicker
scope={scope}
@@ -490,14 +489,15 @@ function AddItemPanel({
renderAction={(c) => {
const inColl = existing.has(c.id) || added.has(c.id);
return (
<FocusSafeButton
<Button
variant="glass"
size="xs"
icon={inColl ? <Check /> : <Plus />}
onClick={() => add(c)}
disabled={inColl}
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
>
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
</FocusSafeButton>
</Button>
);
}}
/>
+6 -7
View File
@@ -14,6 +14,7 @@ import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
import { IconButton } from "./ui/IconButton";
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
@@ -213,15 +214,13 @@ export default function PlexDiscover({
ariaLabel={t("plex.discover.surprise")}
cards={surprise}
action={
<button
type="button"
<IconButton
onClick={() => surpriseQ.refetch()}
aria-label={t("plex.discover.reshuffle")}
title={t("plex.discover.reshuffle")}
className="rounded-full p-1 text-muted transition hover:text-accent"
loading={surpriseQ.isFetching}
label={t("plex.discover.reshuffle")}
>
<RotateCcw className={`h-3.5 w-3.5 ${surpriseQ.isFetching ? "animate-spin" : ""}`} />
</button>
<RotateCcw />
</IconButton>
}
{...railProps}
/>
+39 -27
View File
@@ -82,6 +82,9 @@ import {
anyBlockingLayerAbove,
escapeHandled,
} from "../lib/layerStack";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -1231,13 +1234,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}`}
>
<div className="flex items-center gap-3 text-white">
<button
<IconButton
variant="overlay"
size="player"
onMedia
onClick={handleBack}
className="p-1.5 rounded-lg hover:bg-white/15"
title={t("plex.player.back")}
label={t("plex.player.back")}
>
<ArrowLeft className="w-5 h-5" />
</button>
<ArrowLeft />
</IconButton>
<div className="min-w-0">
<div className="font-semibold truncate">
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title}
@@ -1270,9 +1275,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Skip intro / credits (with the auto-skip countdown fill, if armed) */}
{activeMarker && (
<button
<Button
variant="media"
onClick={() => doSkip(activeMarker)}
className={`absolute right-6 bottom-28 overflow-hidden rounded-lg bg-white/90 px-4 py-2 text-sm font-medium text-black transition hover:bg-white ${
className={`absolute right-6 bottom-28 overflow-hidden ${
uiVisible ? "opacity-100" : "opacity-80"
}`}
>
@@ -1295,7 +1301,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
? t("plex.player.skipIntro")
: t("plex.player.skipCredits")}
</span>
</button>
</Button>
)}
{/* Bottom controls */}
@@ -1476,14 +1482,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */}
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
<div className="relative">
<button
<IconButton
variant="overlay"
size="player"
onMedia
ref={tracksTriggerRef}
onClick={() => setTracksOpen((o) => !o)}
aria-label={t("plex.player.tracks")}
className={`p-1 hover:text-accent ${tracksOpen ? "text-accent" : ""}`}
aria-expanded={tracksOpen}
label={t("plex.player.tracks")}
>
<Languages className="w-5 h-5" />
</button>
<Languages />
</IconButton>
{tracksOpen && (
<div
ref={setTracksNode}
@@ -1496,42 +1505,42 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<>
<PanelHead>{t("plex.player.audio")}</PanelHead>
{detail.audio_streams.map((a) => (
<button
<Pressable
key={a.ord}
onClick={() => changeAudio(a.ord)}
aria-pressed={(audioOrd ?? 0) === a.ord}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
{a.label}
</button>
</Pressable>
))}
</>
)}
{textSubs.length > 0 && (
<>
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<button
<Pressable
onClick={() => changeSubtitle(null)}
aria-pressed={subOrd == null}
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
className={`block w-full rounded px-2 py-1 hover:bg-white/10 ${
subOrd == null ? "text-accent" : "text-white"
}`}
>
{t("plex.player.subOff")}
</button>
</Pressable>
{textSubs.map((s) => (
<button
<Pressable
key={s.ord}
onClick={() => changeSubtitle(s.ord)}
aria-pressed={subOrd === s.ord}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
subOrd === s.ord ? "text-accent" : "text-white"
}`}
>
{s.label}
</button>
</Pressable>
))}
</>
)}
@@ -1543,14 +1552,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
{detail && (
<div className="relative">
<button
<IconButton
variant="overlay"
size="player"
onMedia
ref={menuTriggerRef}
onClick={() => setMenuOpen((o) => !o)}
aria-label={t("plex.player.settings")}
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
aria-expanded={menuOpen}
label={t("plex.player.settings")}
>
<Settings className="w-5 h-5" />
</button>
<Settings />
</IconButton>
{menuOpen && (
<PlexPlayerSettings
menuRef={menuRef}
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Slider, Toggle } from "./plexPlayerControls";
import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
import { Pressable } from "./ui/Pressable";
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
@@ -44,15 +45,17 @@ export function PlexPlayerSettings(props: {
>
<div className="mb-1.5 flex gap-1">
{settingsTabs.map((tab) => (
<button
<Pressable
key={tab.key}
align="center"
aria-pressed={activeTab === tab.key}
onClick={() => setSettingsTab(tab.key)}
className={`flex-1 whitespace-nowrap rounded px-2 py-1 text-2xs ${
activeTab === tab.key ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
}`}
>
{tab.label}
</button>
</Pressable>
))}
</div>
@@ -253,8 +256,10 @@ export function PlexPlayerSettings(props: {
{prefs.clockDate && (
<div className="flex gap-1 px-1 py-1">
{(["hu", "en"] as const).map((st) => (
<button
<Pressable
key={st}
align="center"
aria-pressed={prefs.clockDateStyle === st}
onClick={() => patchPrefs({ clockDateStyle: st })}
className={`flex-1 rounded px-2 py-1 text-2xs ${
prefs.clockDateStyle === st
@@ -263,7 +268,7 @@ export function PlexPlayerSettings(props: {
}`}
>
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
</button>
</Pressable>
))}
</div>
)}
+6 -4
View File
@@ -8,7 +8,7 @@ import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Pressable } from "./ui/Pressable";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
@@ -146,11 +146,13 @@ export default function PlexPlaylistAdd({
const all = n >= size;
const partial = n > 0 && n < size;
return (
<FocusSafeButton
<Pressable
key={p.id}
role="checkbox"
aria-checked={all ? true : partial ? "mixed" : false}
onClick={() => toggle(p)}
disabled={busy}
className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm disabled:opacity-50 ${
className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm disabled:opacity-50 ${
all ? "text-accent" : ""
}`}
>
@@ -175,7 +177,7 @@ export default function PlexPlaylistAdd({
{t("plex.playlistAdd.count", { count: p.item_count })}
</span>
)}
</FocusSafeButton>
</Pressable>
);
})
)}
+4 -4
View File
@@ -9,6 +9,7 @@ import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
import { Pressable } from "./ui/Pressable";
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
@@ -144,17 +145,16 @@ export default function PlexSearchBox() {
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
)}
{suggestions.map((c, i) => (
<button
<Pressable
key={c.id}
id={`${listId}-opt-${i}`}
data-idx={i}
type="button"
role="option"
aria-selected={hi === i}
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
onMouseEnter={() => setHi(i)}
onClick={() => pick(i)}
className={`flex w-full items-center gap-3 rounded-xl p-1.5 text-left transition ${
className={`flex w-full items-center gap-3 rounded-xl p-1.5 transition ${
hi === i ? "bg-border/60" : "hover:bg-border/40"
}`}
>
@@ -173,7 +173,7 @@ export default function PlexSearchBox() {
) : (
<Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
)}
</button>
</Pressable>
))}
</div>
) : null
+9 -11
View File
@@ -4,7 +4,7 @@ import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
import { useTranslation } from "react-i18next";
import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { type PanelLayout } from "../lib/panelLayout";
@@ -20,6 +20,7 @@ import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./savedViewsContext";
import { usePlex, usePlexActions } from "./plexContext";
import { useMe } from "../lib/useMe";
import { DismissibleChip } from "./ui/Chip";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
@@ -498,16 +499,13 @@ function CollectionPicker({
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
const { t } = useTranslation();
return (
<span className="chip px-2 py-1 text-xs">
<span className="truncate max-w-36">{label}</span>
<button
onClick={onRemove}
className="hover:opacity-70"
aria-label={t("common.removeNamed", { name: label })}
>
<X className="w-3 h-3" />
</button>
</span>
<DismissibleChip
onDismiss={onRemove}
dismissLabel={t("common.removeNamed", { name: label })}
className="max-w-40"
>
{label}
</DismissibleChip>
);
}
+4 -7
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import type Hls from "hls.js";
import { X } from "lucide-react";
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
import { IconButton } from "./ui/IconButton";
// "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5 <video> + hls.js state once a second
// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session
@@ -133,13 +134,9 @@ export function PlexStatsOverlay({
<span className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("plex.player.stats.title")}
</span>
<button
onClick={onClose}
aria-label={t("plex.player.close")}
className="rounded p-0.5 text-white/60 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
<IconButton variant="overlay" onClick={onClose} label={t("plex.player.close")}>
<X />
</IconButton>
</div>
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen
reader announce the changing values continuously and drown out real player announcements.
+4 -4
View File
@@ -1,6 +1,7 @@
import { type ReactNode } from "react";
import { hueOf, hueTileGradient } from "../lib/hueTile";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
@@ -26,12 +27,11 @@ export default function PosterTile({
const posters = (art ?? []).slice(0, 2);
const band = hueTileGradient(hueOf(title));
return (
<button
type="button"
<Pressable
onClick={onClick}
title={title}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl transition ${className ?? ""}`}
>
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
<div
@@ -60,6 +60,6 @@ export default function PosterTile({
<div className="truncate text-sm font-semibold text-white drop-shadow">{title}</div>
{subtitle != null && <div className="truncate text-2xs text-white/80">{subtitle}</div>}
</div>
</button>
</Pressable>
);
}
+9 -12
View File
@@ -10,6 +10,7 @@ import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect";
import { IconButton } from "./ui/IconButton";
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
@@ -129,20 +130,16 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card"
>
<span className="flex-1 truncate">{p.name}</span>
<button
onClick={() => startEdit(p)}
title={t("downloads.actions.rename")}
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
<IconButton onClick={() => startEdit(p)} label={t("downloads.actions.rename")}>
<Pencil />
</IconButton>
<IconButton
variant="danger"
onClick={() => onDelete(p)}
title={t("downloads.profiles.delete")}
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-danger"
label={t("downloads.profiles.delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<Trash2 />
</IconButton>
</div>
))}
</div>
+22 -9
View File
@@ -4,6 +4,8 @@ import { Bookmark, Check, Settings2 } from "lucide-react";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useSavedViews } from "./savedViewsContext";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
@@ -26,15 +28,26 @@ export default function SavedViewsPopover({
const label = ariaLabel ?? t("views.saved");
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={label}
className={className ?? "shrink-0 flex text-accent transition hover:text-fg"}
>
{trigger}
</button>
{/* A caller-shaped trigger (the toolbar's view chip) is a surface; the default one a glyph. */}
{className ? (
<Pressable
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={label}
className={className}
>
{trigger}
</Pressable>
) : (
<IconButton
variant="ghost-accent"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
label={label}
>
{trigger}
</IconButton>
)}
<PopoverSurface
popover={pop}
ariaLabel={label}
+8 -12
View File
@@ -8,8 +8,9 @@ import {
import { useTranslation } from "react-i18next";
import { Search, X } from "lucide-react";
import { useSearchFocusKey } from "../lib/keymap";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { PaletteKeycap } from "./PaletteLauncher";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
// The floating search pill in the top header. One component for every module that searches
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
@@ -92,28 +93,23 @@ export default function SearchBar({
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
/>
{value && (
<button
onClick={() => onChange("")}
title={t("header.clearSearch")}
aria-label={t("header.clearSearch")}
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
>
<X className="w-4 h-4" />
</button>
<IconButton onClick={() => onChange("")} label={t("header.clearSearch")}>
<X />
</IconButton>
)}
<PaletteKeycap />
{onGo && (
<FocusSafeButton
<Button
size="sm"
onClick={fire}
disabled={goDisabled}
title={goTitle ?? goLabel ?? t("header.goTip")}
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
>
{goIcon}
<span className={goIcon ? "hidden md:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</FocusSafeButton>
</Button>
)}
</div>
{below}
+9 -14
View File
@@ -6,7 +6,6 @@ import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { Button } from "./ui/Button";
import { inputCls } from "./ui/form";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
// setup token comes from the URL the app printed to the container logs (?token=…); every step
@@ -280,27 +279,23 @@ export default function SetupWizard() {
)}
<div className="flex items-center justify-between mt-6">
<FocusSafeButton
<Button
variant="glass"
icon={<ChevronLeft />}
onClick={() => go(-1)}
disabled={stepIdx === 0 || busy}
className="glass-card glass-hover inline-flex items-center gap-1 px-3 py-2 rounded-xl text-sm disabled:opacity-40 transition"
>
<ChevronLeft className="w-4 h-4" />
{t("setup.nav.back")}
</FocusSafeButton>
<FocusSafeButton
</Button>
<Button
onClick={next}
disabled={busy}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm btn-primary font-medium shadow-md shadow-accent/20 disabled:opacity-50 transition"
loading={busy}
icon={step === "finish" ? <Check /> : undefined}
className="shadow-md shadow-accent/20"
>
{busy ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : step === "finish" ? (
<Check className="w-4 h-4" />
) : null}
{step === "finish" ? t("setup.nav.finish") : t("setup.nav.next")}
{step !== "finish" && !busy && <ChevronRight className="w-4 h-4" />}
</FocusSafeButton>
</Button>
</div>
</div>
</div>
+19 -20
View File
@@ -3,7 +3,6 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import clsx from "clsx";
import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext";
import { notify } from "../lib/notifications";
@@ -14,7 +13,7 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../l
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Pressable } from "./ui/Pressable";
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) -----------------------------------------------------
@@ -72,15 +71,16 @@ function UserShare({ job }: { job: DownloadJob }) {
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<FocusSafeButton
<Pressable
key={u.id}
layer
onClick={() => share.mutate(u.email)}
disabled={share.isPending}
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
className="w-full px-3 py-2 text-sm flex flex-col"
>
<span className="truncate">{u.display_name || u.email}</span>
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
</FocusSafeButton>
</Pressable>
))}
</div>
)}
@@ -227,15 +227,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
)}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<FocusSafeButton
<Button
size="xs"
onClick={() => savePass.mutate(newPass.trim())}
disabled={savePass.isPending || !newPass.trim()}
className="px-2 py-1 rounded-md text-xs font-semibold btn-primary disabled:opacity-40 transition"
loading={savePass.isPending}
disabled={!newPass.trim()}
>
{t("downloads.share.savePassword")}
</FocusSafeButton>
</Button>
{link.has_password && (
<FocusSafeButton
<Button
variant="outline-danger"
size="xs"
onClick={async () => {
if (
await confirm({
@@ -248,10 +251,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
savePass.mutate("");
}}
disabled={savePass.isPending}
className="px-2 py-1 rounded-md text-xs text-muted hover:text-danger transition"
>
{t("downloads.share.removePassword")}
</FocusSafeButton>
</Button>
)}
</div>
)}
@@ -336,20 +338,17 @@ function LinkShare({ job }: { job: DownloadJob }) {
<div className="flex items-center gap-2 text-sm">
<span className="text-muted">{t("downloads.share.expiry")}:</span>
{EXPIRY_DAYS.map((d) => (
<button
<Button
key={String(d)}
variant="outline"
size="xs"
pressed={expiryDays === d}
onClick={() => setExpiryDays(d)}
className={clsx(
"px-2 py-1 rounded-md border text-xs transition",
expiryDays === d
? "border-accent bg-accent/10 text-accent"
: "border-border text-muted hover:text-fg",
)}
>
{d === null
? t("downloads.share.expiryNever")
: t("downloads.share.days", { n: d })}
</button>
</Button>
))}
</div>
<input
+33 -28
View File
@@ -16,7 +16,8 @@ import Tabs from "./ui/Tabs";
import { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe";
import { notifyActionError } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
import { Pressable } from "./ui/Pressable";
const RANGES = [7, 30, 90] as const;
type StatsTab = "overview" | "system";
@@ -175,26 +176,28 @@ function Overview({ me }: { me: Me }) {
{!me.is_demo && (
<Section title={t("stats.sync.actions")}>
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
<FocusSafeButton
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
{t("stats.sync.syncSubscriptions")}
</FocusSafeButton>
</Tooltip>
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
<FocusSafeButton
onClick={() => deepAll.mutate()}
disabled={deepAll.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
>
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
{t("stats.sync.backfillEverything")}
</FocusSafeButton>
</Tooltip>
<div className="flex flex-wrap gap-2">
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
<Button
variant="glass"
icon={<RefreshCw />}
loading={syncSubs.isPending}
onClick={() => syncSubs.mutate()}
>
{t("stats.sync.syncSubscriptions")}
</Button>
</Tooltip>
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
<Button
variant="glass"
icon={<History />}
loading={deepAll.isPending}
onClick={() => deepAll.mutate()}
>
{t("stats.sync.backfillEverything")}
</Button>
</Tooltip>
</div>
</Section>
)}
</>
@@ -227,15 +230,16 @@ function SystemStats() {
{s && (
<Section title={t("stats.sync.admin")}>
<Tooltip hint={t("stats.sync.adminPauseHint")}>
<button
<Button
variant="glass"
icon={s.paused ? <Play /> : <Pause />}
loading={pauseResume.isPending}
onClick={() => pauseResume.mutate(s.paused)}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
>
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{s.paused
? t("stats.sync.resumeBackgroundSync")
: t("stats.sync.pauseBackgroundSync")}
</button>
</Button>
</Tooltip>
</Section>
)}
@@ -321,9 +325,10 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const isSystem = row.user_id === null;
return (
<div className="glass-card rounded-xl p-3">
<button
<Pressable
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-3 text-left"
aria-expanded={open}
className="w-full flex items-center gap-3"
>
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
{isSystem ? t("stats.system") : row.email}
@@ -331,7 +336,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
<span className="text-sm font-semibold tabular-nums shrink-0">
{formatNumber(row.total)}
</span>
</button>
</Pressable>
{/* Proportion bar */}
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
<div
+15 -13
View File
@@ -8,7 +8,9 @@ import { notifyActionError } from "../lib/actionErrors";
import { formatViews } from "../lib/format";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
@@ -98,14 +100,13 @@ export default function SyncStatus({
);
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
<FocusSafeButton
<IconButton
onClick={() => toggle.mutate()}
disabled={toggle.isPending}
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
className="p-1 rounded-md hover:bg-card hover:text-fg transition"
loading={toggle.isPending}
label={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
>
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
</FocusSafeButton>
{data.paused ? <Play /> : <Pause />}
</IconButton>
);
// Chip: the headline count rides in the header; the state, the history link and pause live in a
@@ -117,7 +118,7 @@ export default function SyncStatus({
)} ${t("header.sync.total")}`;
return (
<div className="pointer-events-auto shrink-0">
<button
<Pressable
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// Short label only: the popover already explains the numbers, and the long one overflowed
@@ -134,7 +135,7 @@ export default function SyncStatus({
{needsAttention && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
)}
</button>
</Pressable>
<PopoverSurface
popover={pop}
@@ -161,17 +162,18 @@ export default function SyncStatus({
)}
{notFull > 0 && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<button
<Button
variant="link-muted"
icon={<History className="size-3.5!" />}
onClick={() => {
setOpen(false);
onGoToFullHistory();
}}
title={t("header.sync.fullHistoryTooltip")}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
className="gap-1 underline decoration-dotted decoration-muted/40 underline-offset-4"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
</Button>
{!showMain && pauseBtn}
</div>
)}
+11 -21
View File
@@ -2,7 +2,8 @@ import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { filterChipClass } from "./ui/FilterChipGroup";
import { Chip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
@@ -20,16 +21,11 @@ export interface FilterChip {
onToggle: () => void;
}
function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) {
function TagChip({ chip }: { chip: FilterChip }) {
return (
<button
type="button"
onClick={chip.onToggle}
aria-pressed={chip.active}
className={`${filterChipClass}${extra ? ` ${extra}` : ""}`}
>
<Chip onClick={chip.onToggle} pressed={chip.active}>
{chip.label}
</button>
</Chip>
);
}
@@ -103,28 +99,22 @@ export default function TagFilterChips({
}}
className="inline-flex"
>
<span className={filterChipClass}>{c.label}</span>
<span className={chipClass()}>{c.label}</span>
</span>
))}
<span ref={pillRef} className={filterChipClass}>
<span ref={pillRef} className={chipClass()}>
+{chips.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => (
<Chip key={c.key} chip={c} extra="shrink-0" />
<TagChip key={c.key} chip={c} />
))}
{hidden > 0 && (
<button
ref={pop.refs.setReference}
type="button"
aria-label={moreLabel}
className={`${filterChipClass} shrink-0 ${open ? "border-accent text-accent" : ""}`}
{...pop.getReferenceProps()}
>
<Chip ref={pop.refs.setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
+{hidden}
</button>
</Chip>
)}
</div>
@@ -134,7 +124,7 @@ export default function TagFilterChips({
className="glass-menu flex max-w-[22rem] flex-wrap items-center gap-1.5 rounded-xl p-2 shadow-lg animate-[popIn_0.16s_ease]"
>
{chips.slice(visible).map((c) => (
<Chip key={c.key} chip={c} />
<TagChip key={c.key} chip={c} />
))}
</PopoverSurface>
</div>
+9 -11
View File
@@ -12,6 +12,8 @@ import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the
@@ -67,14 +69,9 @@ function TagRow({
>
{t("tagManager.channels", { count: tag.channel_count })}
</span>
<button
onClick={onDelete}
title={t("tagManager.delete")}
aria-label={t("tagManager.delete")}
className="shrink-0 p-1.5 rounded-md text-muted hover:text-danger transition"
>
<Trash2 className="w-4 h-4" />
</button>
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
<Trash2 />
</IconButton>
<PopoverSurface
popover={pop}
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
@@ -89,13 +86,14 @@ function TagRow({
className="max-h-72 overflow-y-auto no-scrollbar"
>
{channels.map((ch) => (
<button
<Pressable
key={ch.id}
layer
onClick={() => onPickChannel(ch.title)}
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
className="w-full text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg truncate"
>
{ch.title}
</button>
</Pressable>
))}
</div>
</PopoverSurface>
+16 -13
View File
@@ -5,6 +5,8 @@ import { Play, X } from "lucide-react";
import { api } from "../lib/api";
import { plexLang } from "../i18n";
import { qk } from "../lib/queryKeys";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
// Up-next / end-card (E6 S4). Shown at episode end when a next item exists and the pref is on, INSTEAD
// of the old instant jump — so binge stays under the viewer's control (D6-A). Counts down ~10s then
@@ -84,10 +86,7 @@ export function UpNextCard({
<div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
<div className="mt-auto flex items-center gap-2 pt-2.5">
<button
onClick={onAdvance}
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg hover:brightness-110"
>
<Button size="sm" onMedia onClick={onAdvance} className="text-xs font-semibold">
<span className="relative grid h-5.5 w-5.5 flex-none place-items-center">
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
<circle
@@ -114,22 +113,26 @@ export function UpNextCard({
</span>
{t("plex.player.upNext.playNow")}
<span className="tabular-nums">{secs}</span>
</button>
<button
</Button>
<Button
variant="overlay"
size="sm"
onMedia
onClick={onCancel}
className="rounded-full border border-white/25 px-3 py-1.5 text-xs text-white/85 hover:bg-white/10"
className="border border-white/25 text-xs"
>
{t("plex.player.upNext.cancel")}
</button>
</Button>
</div>
</div>
<button
<IconButton
variant="overlay"
onClick={onCancel}
aria-label={t("plex.player.upNext.cancel")}
className="absolute right-2 top-2 rounded p-0.5 text-white/50 hover:bg-white/10 hover:text-white"
label={t("plex.player.upNext.cancel")}
className="absolute right-1 top-1"
>
<X className="h-4 w-4" />
</button>
<X />
</IconButton>
</div>
);
}
+4 -4
View File
@@ -14,7 +14,7 @@ import { navigateTo } from "../lib/nav";
import { formatBytes } from "../lib/format";
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
import { inputCls } from "./ui/form";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Pressable } from "./ui/Pressable";
// Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The
@@ -102,8 +102,8 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
<FocusSafeButton
type="button"
<Pressable
align="center"
onClick={pick}
onDragEnter={(e) => {
e.preventDefault();
@@ -158,7 +158,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
</span>
</>
)}
</FocusSafeButton>
</Pressable>
<input
ref={inputRef}
type="file"
+3 -7
View File
@@ -109,11 +109,7 @@ function Actions({
// overlay for the thumbnailed views, and inline beside the actions for the one-line row.
const INFO_REVEAL =
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
const INFO_OVERLAY = clsx(
INFO_REVEAL,
"absolute bottom-1.5 left-1.5 z-10 rounded-md bg-black/70 p-1 text-white hover:bg-black/85",
);
const INFO_INLINE = clsx(INFO_REVEAL, "rounded-md p-1.5 text-muted hover:bg-surface hover:text-fg");
const INFO_OVERLAY = clsx(INFO_REVEAL, "absolute bottom-1.5 left-1.5 z-10");
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
@@ -339,7 +335,7 @@ function Thumb({
{/* Hover infobox trigger (E8·S3), docked bottom-left so it clears the centered play overlay, the
corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the
rest of the hover chrome, dropped on touch. */}
<VideoInfoCard video={video} className={INFO_OVERLAY} />
<VideoInfoCard video={video} onMedia className={INFO_OVERLAY} />
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
@@ -559,7 +555,7 @@ function VideoCard({
budget). ROW_COL.actions already counts its width. */}
<div className="shrink-0 flex items-center gap-1">
<div className="w-48">{actions}</div>
<VideoInfoCard video={video} className={INFO_INLINE} />
<VideoInfoCard video={video} className={INFO_REVEAL} />
</div>
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
+11 -8
View File
@@ -9,6 +9,7 @@ import { linkify } from "../lib/linkify";
import Avatar from "./ui/Avatar";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
// The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped)
// title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a
@@ -31,11 +32,14 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// so the trigger deliberately lives OUTSIDE it.
export default function VideoInfoCard({
video,
onMedia = false,
className,
}: {
video: Video;
/** Trigger-button classes (placement + hover-reveal + skin). The reveal/touch-suppression rules
* live here so each host can match its surface (overlay vs inline). */
/** The trigger sits on the thumbnail: the round, dark translucent IconButton (A2). */
onMedia?: boolean;
/** Trigger placement + hover-reveal. The reveal/touch-suppression rules live with the host, which
* knows its surface (overlay vs inline); the skin is the IconButton's. */
className?: string;
}) {
const { t, i18n } = useTranslation();
@@ -90,8 +94,8 @@ export default function VideoInfoCard({
return (
<>
<button
type="button"
<IconButton
variant={onMedia ? "media" : "ghost"}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must
@@ -100,12 +104,11 @@ export default function VideoInfoCard({
e.preventDefault();
e.stopPropagation();
}}
aria-label={t("card.info")}
title={t("card.info")}
label={t("card.info")}
className={className}
>
<Info className="w-4 h-4" />
</button>
<Info />
</IconButton>
<PopoverSurface
popover={pop}
ariaLabel={t("card.info")}
+3 -2
View File
@@ -3,6 +3,7 @@ import { Check, ChevronDown, type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
export interface ViewOption<T extends string> {
id: T;
@@ -59,7 +60,7 @@ export default function ViewSwitcher<T extends string>({
return (
<>
<button
<Pressable
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// The aria-label carries the mode's name at every size — the visible text below is a
@@ -75,7 +76,7 @@ export default function ViewSwitcher<T extends string>({
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
</Pressable>
<PopoverSurface
popover={pop}
ariaLabel={label}
+8 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { formatBytes } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { LS, readJSON, writeJSON } from "../lib/storage";
@@ -385,14 +386,15 @@ export default function WatchPage() {
<div className="flex shrink-0 items-center gap-2">
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
Button, whose variants differ only in colour. */}
<button
type="button"
aria-pressed={repeat}
<Chip
size="md"
pressed={repeat}
icon={<Repeat className="size-3.5" aria-hidden />}
onClick={toggleRepeat}
className="pill inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium"
className="font-medium"
>
<Repeat className="w-4 h-4" aria-hidden /> {T.repeat}
</button>
{T.repeat}
</Chip>
{meta.allow_download && (
<a
href={meta.file_url}
+11 -22
View File
@@ -6,7 +6,8 @@ import { qk } from "../lib/queryKeys";
import { api, type FeedFilters, type YearCount } from "../lib/api";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { filterChipClass } from "./ui/FilterChipGroup";
import { Chip } from "./ui/Chip";
import { Button } from "./ui/Button";
import CountBadge from "./ui/CountBadge";
import { useDebounced } from "../lib/useDebounced";
@@ -111,31 +112,23 @@ export default function YearFilter({
{quick.map((y) => {
const on = selected === y.year;
return (
<button
key={y.year}
type="button"
aria-pressed={on}
onClick={() => pick(on ? undefined : y.year)}
className={filterChipClass}
>
<Chip key={y.year} pressed={on} onClick={() => pick(on ? undefined : y.year)}>
<span className="tabular-nums">{y.year}</span>
{y.count > 0 && <CountBadge count={y.count} active={on} />}
</button>
</Chip>
);
})}
{years.length > quick.length && (
<>
<button
type="button"
<Chip
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={t("sidebar.year.moreLabel")}
className={filterChipClass}
>
<span>{t("sidebar.year.more")}</span>
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
</button>
</Chip>
<PopoverSurface
popover={pop}
@@ -151,19 +144,15 @@ export default function YearFilter({
["sortCount", true],
] as const
).map(([key, v]) => (
<button
<Button
key={key}
type="button"
aria-pressed={sortByCount === v}
variant="outline"
size="xs"
pressed={sortByCount === v}
onClick={() => setSortByCount(v)}
className={`text-2xs rounded-md border px-2 py-0.5 transition ${
sortByCount === v
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
}`}
>
{t("sidebar.year." + key)}
</button>
</Button>
))}
</div>
</div>
+18 -22
View File
@@ -4,7 +4,7 @@ import type { ManagedChannel } from "../lib/api";
import { formatNumber } from "../lib/format";
import Tooltip from "./ui/Tooltip";
import { isFullySynced } from "./channelSync";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { IconButton } from "./ui/IconButton";
// The Channels-manager cells that are components in their own right (they read i18n), split out of
// channelColumns so that file holds only the column factory and its predicates (Fast Refresh).
@@ -67,39 +67,35 @@ export function ActionsCell({
return (
<div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}>
<FocusSafeButton
<IconButton
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled}
className={`shrink-0 transition ${
requested
? "text-accent"
: enabled
? "text-muted hover:text-fg"
: "text-muted/40 cursor-default"
}`}
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
variant={requested ? "ghost-accent" : "ghost"}
label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
noTitle
>
<History className="w-4 h-4" />
</FocusSafeButton>
<History />
</IconButton>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button
<IconButton
onClick={onHide}
className="text-muted hover:text-fg shrink-0"
aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
noTitle
>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
{c.hidden ? <EyeOff /> : <Eye />}
</IconButton>
</Tooltip>
{canWrite && (
<Tooltip hint={t("channels.row.unsubscribeHint")}>
<button
<IconButton
variant="danger"
onClick={onUnsubscribe}
className="text-muted hover:text-danger shrink-0"
aria-label={t("channels.row.unsubscribeOnYoutube")}
label={t("channels.row.unsubscribeOnYoutube")}
noTitle
>
<UserMinus className="w-4 h-4" />
</button>
<UserMinus />
</IconButton>
</Tooltip>
)}
</div>
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import type { TFunction } from "i18next";
import { Pressable } from "./ui/Pressable";
// Turn a video description into clickable nodes:
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
@@ -78,7 +79,7 @@ export function renderDescription(
if (yt) {
const sameVideo = yt.id === opts.currentId;
out.push(
<button
<Pressable
key={key++}
onClick={() =>
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
@@ -87,7 +88,7 @@ export function renderDescription(
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
>
{href}
</button>,
</Pressable>,
);
} else {
out.push(
@@ -133,13 +134,13 @@ export function renderDescription(
// alternative — the timestamp group — is the one that fired.
const ts = m[3]!;
out.push(
<button
<Pressable
key={key++}
onClick={() => opts.onSeek(tsToSeconds(ts))}
className="text-accent hover:underline"
>
{ts}
</button>,
</Pressable>,
);
}
last = m.index + m[0].length;
+10 -8
View File
@@ -11,6 +11,8 @@ import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { StatusPill } from "./downloadRow";
import { jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
// list. Split out of DownloadCenter (self-contained, admin-only surface).
@@ -165,18 +167,19 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<button
<Pressable
key={u.id}
layer
onClick={() => {
onPick({ id: u.id, email: u.email });
setQ("");
setOpen(false);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
className="w-full px-3 py-2 text-sm flex flex-col"
>
<span className="truncate">{u.display_name || u.email}</span>
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
</button>
</Pressable>
))}
</div>
)}
@@ -225,13 +228,12 @@ export function AdminSystem() {
>
<span className="flex-1 truncate">{u.email}</span>
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
<button
<IconButton
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
title={t("downloads.admin.editQuota")}
label={t("downloads.admin.editQuota")}
>
<Settings2 className="w-4 h-4" />
</button>
<Settings2 />
</IconButton>
</div>
))}
{s.per_user.length === 0 && (
+8 -9
View File
@@ -95,12 +95,9 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
</div>
))}
</div>
<button
onClick={addLink}
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
>
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
</button>
<Button variant="link-muted" icon={<Plus />} onClick={addLink} className="mt-2 text-sm">
{t("downloads.edit.addLink")}
</Button>
</div>
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
<div className="flex justify-end">
@@ -148,12 +145,14 @@ export function ErrorModal({
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
{job.error && (
<div>
<button
<Button
variant="link-muted"
aria-expanded={showRaw}
onClick={() => setShowRaw((v) => !v)}
className="text-xs text-muted hover:text-fg transition"
className="text-xs"
>
{t("downloads.errors.fail.detailsToggle")}
</button>
</Button>
{showRaw && (
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
{job.error}
+16 -24
View File
@@ -18,6 +18,8 @@ import {
import { navItemAttrs } from "../lib/listNav";
import { failCopyKeys } from "../lib/downloadFailure";
import { jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
@@ -136,17 +138,14 @@ export function IconBtn({
children: ReactNode;
}) {
return (
<button
<IconButton
variant={danger ? "danger" : "ghost"}
onClick={onClick}
data-nav-open={open ? "" : undefined}
title={title}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted transition",
danger ? "hover:text-danger" : "hover:text-fg",
)}
label={title}
>
{children}
</button>
</IconButton>
);
}
@@ -182,14 +181,9 @@ function SourceRef({ url }: { url: string }) {
>
{displayUrl(url)}
</a>
<button
onClick={copy}
aria-label={t("downloads.source.copy")}
title={t("downloads.source.copy")}
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
>
<Copy className="w-3.5 h-3.5" />
</button>
<IconButton onClick={copy} label={t("downloads.source.copy")}>
<Copy />
</IconButton>
</div>
);
}
@@ -267,14 +261,14 @@ export function DownloadRow({
</span>
) : null}
{job.status === "error" ? (
<button
<Pressable
onClick={() => onShowError?.(job)}
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
className="text-danger inline-flex items-center gap-1 truncate"
title={t("downloads.errors.fail.title")}
>
· {t(failCopyKeys(job, "short"))}
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
</button>
</Pressable>
) : null}
</div>
{job.source_url && <SourceRef url={job.source_url} />}
@@ -306,12 +300,10 @@ export function DownloadRow({
</div>
)}
</div>
{/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared
IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps
them on the title's line; the text column shrinks instead. */}
<div className="flex items-start gap-1 shrink-0 [&_button]:p-2 [&_svg]:size-4.5">
{children}
</div>
{/* Row action buttons: the md rung here (the row stands alone), the sm rung in the modals, one
IconBtn for both. `shrink-0` + no wrap keeps them on the title's line; the text column
shrinks instead. */}
<div className="flex items-start gap-1 shrink-0 [&_button]:size-8">{children}</div>
</div>
);
}
+31 -38
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
import type { AutoMode, LoopMode } from "../lib/playerModes";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
@@ -35,76 +35,69 @@ export function PlayerTransportControls({
}) {
const { t } = useTranslation();
const docked = variant === "docked";
const navBtn = docked
? "inline-flex items-center gap-1 text-white/80 enabled:not-aria-disabled:hover:text-white disabled:opacity-30 transition"
: "inline-flex items-center gap-1.5 text-muted enabled:not-aria-disabled:hover:text-fg disabled:opacity-40 transition";
// The docked bar sits on the video (always dark): the overlay variant; the modal's is a page surface.
const chrome = docked ? "overlay" : "ghost";
const navLabel = docked ? "hidden sm:inline" : "";
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
const toggle = (on: boolean) =>
docked
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
}`
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`;
const toggleLabel = docked ? "hidden md:inline" : "";
return (
<>
<FocusSafeButton
<Button
variant={chrome}
size="sm"
icon={<SkipBack />}
onClick={onPrev}
disabled={index === 0}
label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
className={navBtn}
>
<SkipBack className="h-4 w-4" />
<span className={navLabel}>{t("player.previous")}</span>
</FocusSafeButton>
</Button>
<span className={counter}>
{index + 1} / {queueLength}
</span>
<FocusSafeButton
<Button
variant={chrome}
size="sm"
onClick={onNext}
disabled={index === queueLength - 1}
label={t("player.next")}
title={`${t("player.next")} · Shift+→`}
className={navBtn}
>
<span className={navLabel}>{t("player.next")}</span>
<SkipForward className="h-4 w-4" />
</FocusSafeButton>
<SkipForward />
</Button>
<span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
<Button
variant={chrome}
size="sm"
pressed={autoMode !== "off"}
icon={
autoMode === "prev" ? <SkipBack /> : autoMode === "random" ? <Shuffle /> : <SkipForward />
}
onClick={onCycleAuto}
label={`${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`}
title={t("player.autoAdvance.hint")}
className={toggle(autoMode !== "off")}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</button>
<button
</Button>
<Button
variant={chrome}
size="sm"
pressed={loopMode !== "off"}
icon={loopMode === "one" ? <Repeat1 /> : <Repeat />}
onClick={onCycleLoop}
label={`${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`}
title={t("player.loop.hint")}
className={toggle(loopMode !== "off")}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</button>
</Button>
</>
);
}
+12 -10
View File
@@ -3,6 +3,8 @@ import type { TFunction } from "i18next";
import type { Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import type { Column } from "../lib/tableColumns";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
//
@@ -56,7 +58,7 @@ export function playlistColumns({
cardPrimary: true,
render: (v) => (
<div className="flex items-center gap-2.5 min-w-0">
<button
<Pressable
onClick={() => onPlay(v)}
aria-label={`${t("card.play")} — ${v.title}`}
className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group"
@@ -67,15 +69,15 @@ export function playlistColumns({
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition">
<Play className="w-4 h-4 text-white fill-current" />
</span>
</button>
<button
</Pressable>
<Pressable
onClick={() => onPlay(v)}
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
data-nav-open=""
className="min-w-0 flex-1 text-left"
className="min-w-0 flex-1"
>
<span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
</button>
</Pressable>
</div>
),
},
@@ -129,14 +131,14 @@ export function playlistColumns({
width: "2.5rem",
nowrap: true,
render: (v) => (
<button
<IconButton
variant="danger"
onClick={() => onRemove(v)}
label={t("playlists.removeItemOf", { title: v.title })}
title={t("playlists.removeItem")}
aria-label={t("playlists.removeItemOf", { title: v.title })}
className="text-muted hover:text-fg p-1"
>
<X className="w-4 h-4" />
</button>
<X />
</IconButton>
),
});
}
+13 -19
View File
@@ -12,7 +12,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import PosterTile from "./PosterTile";
import PlexLibraryPicker from "./PlexLibraryPicker";
import NavGrid from "./ui/NavGrid";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime).
@@ -109,13 +110,9 @@ function NewCollectionPanel({
<div className="glass-card mb-6 rounded-2xl p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
<button
onClick={onClose}
aria-label={t("common.close")}
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
>
<X className="h-4 w-4" />
</button>
<IconButton size="md" onClick={onClose} label={t("common.close")}>
<X />
</IconButton>
</div>
<input
autoFocus
@@ -146,14 +143,15 @@ function NewCollectionPanel({
term={term}
enabled={nameOk}
renderAction={(c) => (
<FocusSafeButton
<Button
variant="glass"
size="xs"
icon={<Check />}
onClick={() => create(c)}
disabled={busy}
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
loading={busy}
>
<Check className="h-3.5 w-3.5" />
{t("plex.collection.createWith")}
</FocusSafeButton>
</Button>
)}
/>
</div>
@@ -190,13 +188,9 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
className="glass-card w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{isAdmin && !creating && (
<button
onClick={() => setCreating(true)}
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm"
>
<Plus className="h-4 w-4" />
<Button variant="glass" size="sm" icon={<Plus />} onClick={() => setCreating(true)}>
{t("plex.collection.newCollection")}
</button>
</Button>
)}
</div>
{creating && (
+14 -10
View File
@@ -1,7 +1,7 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { RotateCcw } from "lucide-react";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { IconButton } from "./ui/IconButton";
// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native
// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
@@ -82,13 +82,14 @@ export function Slider({
{suffix ?? ""}
</span>
{onReset && (
<button
<IconButton
variant="overlay"
onClick={onReset}
className="text-white/40 hover:text-white"
aria-label={t("common.resetNamed", { name: label })}
label={t("common.resetNamed", { name: label })}
className="-my-1.5"
>
<RotateCcw className="h-3 w-3" />
</button>
<RotateCcw className="size-3.5!" />
</IconButton>
)}
</span>
</div>
@@ -146,14 +147,17 @@ export function Ctrl({
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return (
<div className="relative flex group/ctrl">
<FocusSafeButton
<IconButton
variant="overlay"
size="player"
onMedia
noTitle
onClick={onClick}
disabled={disabled}
aria-label={label}
className="p-1 hover:text-accent disabled:opacity-30"
label={label}
>
{children}
</FocusSafeButton>
</IconButton>
<span
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-2xs text-white opacity-0 transition group-hover/ctrl:opacity-100`}
>
+8 -12
View File
@@ -27,6 +27,8 @@ import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
import { Chip } from "./ui/Chip";
import { Pressable } from "./ui/Pressable";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
@@ -222,12 +224,9 @@ export function PlexPersonView({
</p>
)}
</div>
<button
onClick={() => onFilter({ actors: [d.name] })}
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
>
<Chip onClick={() => onFilter({ actors: [d.name] })} className="font-medium">
{t("plex.person.filterBy", { name: d.name })}
</button>
</Chip>
</div>
</div>
{d.biography && (
@@ -428,7 +427,7 @@ export function PlexShowView({
if (!badge) return null;
const { score } = badge;
return (
<button
<Pressable
onClick={
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
}
@@ -437,7 +436,7 @@ export function PlexShowView({
{/* decorative rating gold — intentionally NOT a status token */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{score.toFixed(1)}
</button>
</Pressable>
);
})()}
</div>
@@ -645,12 +644,9 @@ export function PlexSeasonView({
/>
}
>
<button
onClick={onBack}
className="text-sm text-muted hover:text-accent transition text-left"
>
<Button variant="link-muted" onClick={onBack} className="text-sm">
{d.show.title}
</button>
</Button>
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
<p className="text-sm text-muted mt-1">
{t("plex.series.episodeCount", { count: se.episode_count })}
+24 -13
View File
@@ -2,6 +2,8 @@ import { Fragment, useState, type ReactNode } from "react";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
@@ -209,19 +211,28 @@ export default function ActionMenu({
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={ariaLabel}
title={ariaLabel}
className={
className ??
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
}
>
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
</button>
{/* A caller-shaped trigger (a labelled pill) is a surface; the default "…" is a glyph. */}
{className ? (
<Pressable
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={ariaLabel}
title={ariaLabel}
className={className}
>
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
</Pressable>
) : (
<IconButton
variant="glass"
size="md"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
label={ariaLabel}
>
{trigger ?? <MoreHorizontal aria-hidden="true" />}
</IconButton>
)}
<PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
<MenuItemList items={items} onClose={() => setOpen(false)} />
</PopoverSurface>
+4 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Pressable } from "./Pressable";
// A floating "back to top" button that fades in once the current page's scroll container is
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a
@@ -50,7 +51,8 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
// The accessible name comes from a visually-hidden child instead, which those selectors can't match.
return createPortal(
<button
<Pressable
align="center"
onClick={toTop}
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
@@ -58,7 +60,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
>
<ArrowUp className="w-7 h-7" aria-hidden="true" />
<span className="sr-only">{t("common.backToTop")}</span>
</button>,
</Pressable>,
document.body,
);
}
+10 -7
View File
@@ -2,20 +2,21 @@ 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";
const TONES: Record<BannerTone, { bar: string; icon: string; action: string }> = {
const TONES: Record<BannerTone, { bar: string; icon: string; action: "primary" | "warn" }> = {
accent: {
bar: "bg-accent/15 border-accent/30",
icon: "text-accent",
action: "btn-primary",
action: "primary",
},
warning: {
bar: "bg-warn/15 border-warn/30",
icon: "text-warn",
action: "btn-warn",
action: "warn",
},
};
@@ -54,12 +55,14 @@ export default function Banner({
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
<span className="min-w-0">{children}</span>
{action && (
<button
<Button
variant={c.action}
size="sm"
onClick={action.onClick}
className={`ml-1 px-2.5 py-1 rounded-lg font-semibold transition ${c.action}`}
className="ml-1 font-semibold"
>
{action.label}
</button>
</Button>
)}
{onDismiss && (
<IconButton
@@ -69,7 +72,7 @@ export default function Banner({
onClick={onDismiss}
className="ml-auto"
>
<X className="w-4 h-4" />
<X />
</IconButton>
)}
</div>
+2
View File
@@ -17,6 +17,7 @@ import { FocusSafeButton } from "./FocusSafeButton";
type ButtonVariant =
| "primary" // solid accent — the main affirmative action
| "danger" // solid danger — destructive confirm
| "warn" // solid warn — the action of a warning bar (reload for a new version)
| "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 — a row-level Delete
@@ -43,6 +44,7 @@ const ACT = "enabled:not-aria-disabled:";
const VARIANTS: Record<ButtonVariant, string> = {
primary: "font-medium state-filled",
danger: "font-medium state-danger",
warn: "font-medium state-warn",
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
+19 -18
View File
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { useScrollEdges } from "../../lib/useScrollEdges";
import { useListNav } from "../../lib/listNav";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
@@ -95,10 +97,10 @@ export function Carousel({
// Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover`
// removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap.
// Round, dark translucent: they sit over the pictures (A2).
const arrowBtn =
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 backdrop-blur-xs border border-white/15 shadow-lg " +
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100";
return (
<div className={clsx("group/car relative", className)}>
@@ -106,27 +108,25 @@ export function Carousel({
<div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && (
<button
onClick={onSeeAll}
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
>
<Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
{seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" />
</button>
</Button>
)}
</div>
)}
<div className="relative">
{left && (
<button
type="button"
<IconButton
variant="media"
size="md"
onClick={() => page(-1)}
aria-label={t("common.scrollLeft")}
label={t("common.scrollLeft")}
className={clsx(arrowBtn, "left-1")}
>
<ChevronLeft className="h-5 w-5" />
</button>
<ChevronLeft />
</IconButton>
)}
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
@@ -140,14 +140,15 @@ export function Carousel({
{children}
</div>
{right && (
<button
type="button"
<IconButton
variant="media"
size="md"
onClick={() => page(1)}
aria-label={t("common.scrollRight")}
label={t("common.scrollRight")}
className={clsx(arrowBtn, "right-1")}
>
<ChevronRight className="h-5 w-5" />
</button>
<ChevronRight />
</IconButton>
)}
</div>
</div>
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
//
// E28 S3b: the chip family (docs/DESIGN.md "One primitive per button family"). One geometry for a
// filter, an applied narrowing, a tag and a suggestion, where five files had five.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Chip, DismissibleChip } from "./Chip";
import { chipClass } from "./chipStyles";
afterEach(cleanup);
describe("Chip", () => {
it("is a pill on the 24px floor that never submits a form, and a toggle announces its state", () => {
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
render(
<form onSubmit={onSubmit}>
<Chip pressed={false}>2024</Chip>
</form>,
);
const chip = screen.getByRole("button", { name: "2024" });
expect(chip.className).toContain("rounded-full");
expect(chip.className).toContain("h-6");
expect(chip.className).toContain("touch-target");
expect(chip.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(chip);
expect(onSubmit).not.toHaveBeenCalled();
});
it("a removing chip is named by the removal and shows the ×", () => {
const onClick = vi.fn();
render(
<Chip variant="token" size="md" removeLabel="Remove Music" onClick={onClick}>
Music
</Chip>,
);
const chip = screen.getByRole("button", { name: "Remove Music" });
expect(chip.getAttribute("title")).toBe("Remove Music");
expect(chip.className).toContain("h-7");
expect(chip.querySelector("svg")).not.toBeNull();
fireEvent.click(chip);
expect(onClick).toHaveBeenCalledOnce();
});
it("the measuring mirror lays out exactly like the chip it measures", () => {
render(
<Chip variant="tag" size="xs">
tag
</Chip>,
);
const cls = screen.getByRole("button", { name: "tag" }).className;
for (const c of chipClass("tag", "xs").split(" ")) expect(cls).toContain(c);
});
});
describe("DismissibleChip", () => {
it("is two sibling controls at most, never a button in a button", () => {
const onAccept = vi.fn();
const onDismiss = vi.fn();
const { container } = render(
<DismissibleChip
variant="suggest"
onClick={onAccept}
bodyLabel="Add the tag Music"
onDismiss={onDismiss}
dismissLabel="Not now"
>
Music
</DismissibleChip>,
);
expect(container.querySelector("button button")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Add the tag Music" }));
fireEvent.click(screen.getByRole("button", { name: "Not now" }));
expect(onAccept).toHaveBeenCalledOnce();
expect(onDismiss).toHaveBeenCalledOnce();
});
it("with no body action the label is text, and only the × is a control", () => {
render(
<DismissibleChip onDismiss={() => {}} dismissLabel="Remove Year: 2024">
Year: 2024
</DismissibleChip>,
);
expect(screen.getAllByRole("button")).toHaveLength(1);
expect(screen.getByText("Year: 2024").closest("button")).toBeNull();
});
});
+125
View File
@@ -0,0 +1,125 @@
import { forwardRef } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import { X } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
import { chipClass, type ChipSize, type ChipVariant } from "./chipStyles";
// The chip family (docs/DESIGN.md "One primitive per button family"): a small rounded-full token that
// is a filter, an applied narrowing, a tag, or a suggestion. It was hand-painted in five files with
// five geometries; this is the one. `FilterChipGroup` renders a single-select row of these.
//
// filter — a selectable option (a year, a tag filter, a content type). Toggles with `pressed`: the
// `.pill` recipe in index.css paints the solid accent when on. Also the "+N" overflow and
// "More…" triggers, which light up while their popover is open (aria-expanded).
// token — an applied narrowing, tinted accent (the `.chip` recipe): it exists because it is on.
// tag — a user's tag, the solid accent (`.chip-solid`). Dense: it sits in table rows.
// suggest — dashed accent: a suggestion not yet taken.
// ghost — dashed neutral: the "add a tag" trigger, quiet in every table row until reached for.
//
// Nothing goes under 24px (B1, WCAG 2.5.8) and a coarse pointer grows every chip to 44 (A3).
type ChipProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ChipVariant;
size?: ChipSize;
/** A filter chip that toggles: aria-pressed, painted solid when on. */
pressed?: boolean;
/** Leading glyph (a lucide element). */
icon?: ReactNode;
/** The whole chip removes what it stands for: a trailing × and the removal as its name. */
removeLabel?: string;
};
/** A chip that is itself the control (a toggle, a removal, an accept, a "+N" trigger). */
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
{
variant = "filter",
size = "sm",
pressed,
icon,
removeLabel,
type,
className,
children,
...rest
},
ref,
) {
return (
<FocusSafeButton
ref={ref}
type={type ?? "button"}
aria-pressed={pressed}
aria-label={removeLabel}
title={removeLabel}
className={clsx(chipClass(variant, size), removeLabel && "group pr-1.5", className)}
{...rest}
>
{icon}
{children}
{removeLabel && (
<X className="size-3 opacity-60 group-hover:opacity-100" aria-hidden="true" />
)}
</FocusSafeButton>
);
});
/** A chip whose label is not the control but which carries its own trailing × (a captured filter in
* a saved view, a suggestion with a dismiss). Two sibling controls at most, never a button in a
* button: `onClick` makes the body one (an accept), `onDismiss` the ×. */
export function DismissibleChip({
variant = "token",
size = "sm",
icon,
children,
onClick,
bodyLabel,
bodyTitle,
onDismiss,
dismissLabel,
className,
}: {
variant?: Exclude<ChipVariant, "filter">;
size?: ChipSize;
icon?: ReactNode;
children: ReactNode;
onClick?: () => void;
bodyLabel?: string;
bodyTitle?: string;
onDismiss: () => void;
dismissLabel: string;
className?: string;
}) {
const body = (
<>
{icon}
<span className="truncate">{children}</span>
</>
);
return (
<span className={clsx(chipClass(variant, size), "pr-0.5 min-w-0", className)}>
{onClick ? (
<FocusSafeButton
type="button"
onClick={onClick}
aria-label={bodyLabel}
title={bodyTitle}
className="inline-flex min-w-0 items-center gap-1 self-stretch [&>svg]:size-3 [&>svg]:shrink-0"
>
{body}
</FocusSafeButton>
) : (
<span className="inline-flex min-w-0 items-center gap-1 [&>svg]:size-3">{body}</span>
)}
<FocusSafeButton
type="button"
onClick={onDismiss}
aria-label={dismissLabel}
title={dismissLabel}
className="-my-px grid size-6 shrink-0 place-items-center rounded-full opacity-60 transition hover:opacity-100 hover:bg-accent/20 touch-target"
>
<X className="size-3" aria-hidden="true" />
</FocusSafeButton>
</span>
);
}
@@ -4,6 +4,7 @@ import { Search } from "lucide-react";
import Modal from "./Modal";
import { closeTopOverlayThenRun } from "../../lib/history";
import { pageStep, pageTarget } from "../../lib/listPaging";
import { Pressable } from "./Pressable";
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
@@ -243,14 +244,13 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
{g.label}
</div>
{g.items.map((item) => (
<button
<Pressable
key={item.id}
id={`${listId}-${item.id}`}
ref={item.id === activeId ? activeRef : undefined}
data-cmd-id={item.id}
role="option"
aria-selected={item.id === activeId}
type="button"
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
// these rows. Leaving them tabbable put DOM focus on a row while
// aria-activedescendant named a different one — two competing "current" options —
@@ -268,14 +268,14 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
if (e.movementX === 0 && e.movementY === 0) return;
setPicked(item.id);
}}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm ${
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
}`}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
</button>
</Pressable>
))}
</div>
))}
@@ -4,7 +4,7 @@
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import FilterChipGroup from "./FilterChipGroup";
const OPTS = [
{ value: "all", label: "All", testId: "chip-all" },
@@ -56,8 +56,12 @@ describe("FilterChipGroup", () => {
expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy();
});
it("exports the canonical chip class for multi-select siblings to reuse", () => {
expect(filterChipClass).toContain("rounded-full");
expect(filterChipClass).toContain("pill");
it("renders its options as Chips: the pill, on the 24px rung", () => {
render(<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync" />);
const chip = screen.getByTestId("chip-all");
expect(chip.className).toContain("rounded-full");
expect(chip.className).toContain("pill");
expect(chip.className).toContain("h-6");
expect(chip.getAttribute("type")).toBe("button");
});
});
+6 -14
View File
@@ -1,13 +1,6 @@
import { type ReactNode } from "react";
import CountBadge from "./CountBadge";
// Canonical filter-chip geometry — the rounded-full "Filters bar" look used app-wide for a selectable
// filter chip. Exported so single-select groups (this component) AND standalone/multi-select filter
// chips (e.g. the feed content-type toggles) render identically from ONE source of truth. The active
// paint comes entirely from the `.pill[aria-pressed="true"]` recipe (index.css) — carry no
// bg/text/border-colour utilities here or a utility class would beat the pressed fill.
export const filterChipClass =
"pill inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px";
import { Chip } from "./Chip";
export interface FilterChipOption<T extends string> {
value: T;
@@ -19,7 +12,7 @@ export interface FilterChipOption<T extends string> {
}
// A single-select row of filter chips (exactly one active), radio-like. For a multi-select chip row,
// render plain buttons with `filterChipClass` instead — same look, different (independent) semantics.
// render `Chip`s with `pressed` instead — same look, different (independent) semantics.
export default function FilterChipGroup<T extends string>({
options,
value,
@@ -39,18 +32,17 @@ export default function FilterChipGroup<T extends string>({
{options.map((o) => {
const on = value === o.value;
return (
<button
<Chip
key={o.value}
data-testid={o.testId}
onClick={() => onChange(o.value)}
title={o.title}
aria-pressed={on}
className={filterChipClass}
pressed={on}
icon={o.icon}
>
{o.icon}
<span>{o.label}</span>
{o.count != null && o.count > 0 && <CountBadge count={o.count} active={on} />}
</button>
</Chip>
);
})}
</div>
+8 -2
View File
@@ -11,12 +11,14 @@ import { FocusSafeButton } from "./FocusSafeButton";
type IconButtonVariant =
| "ghost" // the default: quiet glyph, a state layer on hover
| "ghost-accent" // an accent glyph: the action it stands for is already in effect (queued, requested)
| "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
| "danger-soft" // bordered AND danger at rest — a destructive restriction that is ON (Blocked)
| "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
@@ -24,17 +26,20 @@ type IconButtonVariant =
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)
| "lg" // 48 — only the Play in the middle of a picture (C5: a not-started card)
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
const ACT = "enabled:not-aria-disabled:";
const VARIANTS: Record<IconButtonVariant, string> = {
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
"ghost-accent": "text-accent state-layer",
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`,
"danger-soft": `border border-danger/60 text-danger state-layer ${ACT}hover:border-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).
@@ -50,6 +55,7 @@ const SIZES: Record<IconButtonSize, string> = {
sm: "size-7 [&>svg]:size-4",
md: "size-8 [&>svg]:size-4",
lg: "size-12 [&>svg]:size-6",
player: "size-9 [&>svg]:size-5",
};
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
@@ -94,7 +100,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
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",
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent 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,
+10 -8
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { hasPagerContent, pageCount } from "./pagerContent";
import { FocusSafeButton } from "./FocusSafeButton";
import SegmentedToggle from "./SegmentedToggle";
import { Button } from "./Button";
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
@@ -66,14 +66,15 @@ export default function Pager({
<div className="flex items-center gap-3 flex-wrap">
{totalPages > 1 && (
<div className="flex items-center gap-2">
<FocusSafeButton
<Button
variant="glass"
size="sm"
icon={<ChevronLeft />}
onClick={() => goToPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
>
<ChevronLeft className="w-4 h-4" />
{t("datatable.pager.prev")}
</FocusSafeButton>
</Button>
<span className="text-muted flex items-center gap-1.5">
{t("datatable.pager.pageLabel")}
<input
@@ -89,14 +90,15 @@ export default function Pager({
/>
{t("datatable.pager.ofTotal", { total: totalPages })}
</span>
<FocusSafeButton
<Button
variant="glass"
size="sm"
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 1}
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
>
{t("datatable.pager.next")}
<ChevronRight className="w-4 h-4" />
</FocusSafeButton>
</Button>
</div>
)}
{total > 0 && (
+22 -21
View File
@@ -1,8 +1,11 @@
import { type ReactNode, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react";
import { ChevronDown, Eye, EyeOff, Pencil } from "lucide-react";
import { CSS } from "@dnd-kit/utilities";
import { useSortableItem } from "./sortableA11y";
import { DragHandle } from "./DragHandle";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
@@ -46,7 +49,7 @@ export default function PanelGroup({
const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
const titleCls =
"flex-1 min-w-0 flex items-center gap-1 text-left select-none text-sm uppercase tracking-wide";
"flex-1 min-w-0 flex items-center gap-1 select-none text-sm uppercase tracking-wide";
return (
<div
@@ -58,55 +61,53 @@ export default function PanelGroup({
>
<div className="flex items-center gap-1.5 px-3 py-2">
{editing && (
<button
<DragHandle
{...attributes}
{...listeners}
title={t("sidebar.dragToReorder")}
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
>
<GripVertical className="w-4 h-4" />
</button>
label={t("sidebar.dragToReorder")}
className="-ml-1"
/>
)}
{collapsible && !editing ? (
<button
<Pressable
onClick={onToggleCollapse}
aria-expanded={!collapsed}
className={`${titleCls} text-muted hover:text-fg transition`}
>
<span className="truncate">{title}</span>
</button>
</Pressable>
) : titleIsTrigger ? (
<button
<Pressable
onClick={onTitleClick}
title={titleActionLabel}
className={`${titleCls} group text-muted hover:text-accent transition`}
>
<span className="truncate">{title}</span>
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
</button>
</Pressable>
) : (
<span className={`${titleCls} text-muted`}>
<span className="truncate">{title}</span>
</span>
)}
{editing ? (
<button
<IconButton
onClick={onToggleHidden}
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
className="text-muted hover:text-fg"
label={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
>
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
{hidden ? <EyeOff /> : <Eye />}
</IconButton>
) : (
collapsible && (
<button
<IconButton
onClick={onToggleCollapse}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
aria-expanded={!collapsed}
label={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
>
<ChevronDown
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
/>
</button>
</IconButton>
)
)}
</div>
+4 -4
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`, `IconButton`, `Pressable`, `DragHandle` (one per button family, docs/DESIGN.md), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `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,9 +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` 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.
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes
are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
and player rungs). 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
+11 -12
View File
@@ -4,6 +4,7 @@ import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade";
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
// 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
@@ -72,7 +73,7 @@ export default function SidePanel({
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
<button
<Pressable
{...peek.hoverHandlers}
onClick={onToggleCollapse}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
@@ -92,7 +93,7 @@ export default function SidePanel({
>
{title}
</span>
</button>
</Pressable>
{expanded && (
<aside
@@ -113,14 +114,14 @@ export default function SidePanel({
</IconButton>
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{onTitleClick ? (
<button
<Pressable
onClick={onTitleClick}
title={titleActionLabel}
aria-label={titleActionLabel}
className="flex-1 min-w-0 truncate text-left text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
>
{title}
</button>
</Pressable>
) : (
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
{title}
@@ -145,15 +146,13 @@ export default function SidePanel({
</IconButton>
)}
{onToggleEditing && (
<button
<IconButton
variant={editing ? "ghost-accent" : "ghost"}
onClick={onToggleEditing}
title={editing ? t("sidebar.done") : t("sidebar.customize")}
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
editing ? "text-accent" : "text-muted hover:text-fg"
}`}
label={editing ? t("sidebar.done") : t("sidebar.customize")}
>
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
</button>
{editing ? <Check /> : <Pencil />}
</IconButton>
)}
</div>
+11 -8
View File
@@ -6,6 +6,8 @@ import { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder";
import { LEVEL_STYLE } from "./toastLevels";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
// Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking
// context (its `@container` implies layout containment) — so anything portalled to <body> at the
@@ -170,24 +172,25 @@ export default function Toaster({
)}
</div>
{toast.action && (
<button
<Button
variant="link"
onClick={() => {
toast.action!.onClick();
dismiss(toast.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
className="mt-1 text-sm"
>
{toast.action.label}
</button>
</Button>
)}
</div>
<button
<IconButton
onClick={() => dismiss(toast.id)}
className="shrink-0 text-muted hover:text-fg"
title={t("notifications.dismiss")}
label={t("notifications.dismiss")}
className="-m-1.5"
>
<X className="w-4 h-4" />
</button>
<X />
</IconButton>
{toast.duration && (
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
<CountdownBar key={toast.ts} since={toast.ts} duration={toast.duration} bar={bar} />
+7 -21
View File
@@ -2,7 +2,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Redo2, Undo2 } from "lucide-react";
import { dispatch, useKeyScope } from "../../lib/keymap";
import { FocusSafeButton } from "./FocusSafeButton";
import { IconButton } from "./IconButton";
// Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z
// (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any
@@ -44,28 +44,14 @@ export default function UndoToolbar({
return () => window.removeEventListener("keydown", onKey);
}, [canUndo, canRedo, onUndo, onRedo]);
const btn =
"p-1.5 rounded-lg border border-border text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:border-accent disabled:opacity-30 transition";
return (
<div className="inline-flex items-center gap-1">
<FocusSafeButton
onClick={onUndo}
disabled={!canUndo}
title={t("common.undo")}
aria-label={t("common.undo")}
className={btn}
>
<Undo2 className="w-4 h-4" />
</FocusSafeButton>
<FocusSafeButton
onClick={onRedo}
disabled={!canRedo}
title={t("common.redo")}
aria-label={t("common.redo")}
className={btn}
>
<Redo2 className="w-4 h-4" />
</FocusSafeButton>
<IconButton variant="outline" onClick={onUndo} disabled={!canUndo} label={t("common.undo")}>
<Undo2 />
</IconButton>
<IconButton variant="outline" onClick={onRedo} disabled={!canRedo} label={t("common.redo")}>
<Redo2 />
</IconButton>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import clsx from "clsx";
// The chip family's geometry and paint, apart from the component (`Chip.tsx`) so the hidden measuring
// mirror of an overflowing chip lane can lay out exactly like the chips it measures.
export type ChipVariant = "filter" | "token" | "tag" | "suggest" | "ghost";
export type ChipSize =
| "md" // 28, text-sm — the applied-filter row above the feed
| "sm" // 24, text-xs — a filter row
| "xs"; // 24, text-2xs, tight — a tag in a table row
const ACT = "enabled:not-aria-disabled:";
const PAINT: Record<ChipVariant, string> = {
// `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed
// fill, so the open-popover state is the only one spelled out.
filter: "pill border shadow-xs aria-expanded:border-accent aria-expanded:text-accent",
token: `chip ${ACT}hover:border-accent ${ACT}hover:bg-accent/20 aria-expanded:border-accent`,
// B3: a filled control's hover mixes its own text colour into the fill, never an opacity dim.
tag: `chip-solid font-medium ${ACT}hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))]`,
suggest: `border border-dashed border-accent/50 text-accent/90 ${ACT}hover:text-accent ${ACT}hover:border-accent`,
ghost: `border border-dashed border-border text-muted ${ACT}hover:text-accent ${ACT}hover:border-accent`,
};
const SIZES: Record<ChipSize, string> = {
md: "h-7 px-2.5 text-sm gap-1",
sm: "h-6 px-2.5 text-xs gap-1.5",
xs: "h-6 px-1.5 text-2xs gap-1",
};
const BASE =
"inline-flex min-w-6 shrink-0 items-center justify-center rounded-full whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0";
/** The chip's geometry + paint as a class string: for the hidden measuring mirror of an overflowing
* chip lane, which must lay out exactly like the chips it measures but must not be buttons. */
export function chipClass(variant: ChipVariant = "filter", size: ChipSize = "sm") {
return clsx(BASE, PAINT[variant], SIZES[size]);
}
-3
View File
@@ -14,9 +14,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
export const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40";
/** The standard text button style (used verbatim by ShareDialog + VideoEditor). */
export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
/** Pill on/off switch — the bare control; compose it with your own label/row. Pass `label` for
* the accessible name (the visible row text) since the control itself has no inner text. */
export function Switch({
+16 -13
View File
@@ -8,6 +8,9 @@ import { type SortState } from "../../lib/columnSort";
import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
import { alignClass } from "./tableAlign";
import { useStickyTop } from "./stickyTop";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
// The column-header machinery — the sortable header row with its filter funnel + popover — lifted
// OUT of DataTable so a non-DataTable body can render under the same headers. The column contract
@@ -75,17 +78,16 @@ function FilterFunnel<T>({
return (
<>
<button
<IconButton
variant={filterOn ? "ghost-accent" : "ghost"}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={t("datatable.filter")}
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
className={`-m-1.5 p-1.5 transition ${
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
}`}
label={t("datatable.filter")}
// The icon alone was a 14x14 hit target, the smallest in the app; the sm rung is 28.
className="-my-1.5"
>
<ListFilter className="w-3.5 h-3.5" />
</button>
<ListFilter className="size-3.5!" />
</IconButton>
<PopoverSurface
popover={pop}
ariaLabel={col.header}
@@ -167,12 +169,13 @@ function FilterFunnel<T>({
</FilterOptionList>
)}
{filterOn && (
<button
<Button
variant="link-muted"
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
className="mt-1.5 w-full text-xs"
>
{t("datatable.clear")}
</button>
</Button>
)}
</PopoverSurface>
</>
@@ -229,7 +232,7 @@ export function ColumnHead<T>({
focusable no-op button, which put unlabeled dead stops in the tab order —
two of them per playlist table, whose grip and actions headers are empty. */}
{col.sortable ? (
<button
<Pressable
onClick={() => onToggleSort(col.key)}
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
>
@@ -241,7 +244,7 @@ export function ColumnHead<T>({
) : (
<ArrowDown className="w-3 h-3" />
))}
</button>
</Pressable>
) : (
<span>{col.header}</span>
)}
+7 -27
View File
@@ -1,9 +1,9 @@
import { useLayoutEffect, useState } from "react";
import clsx from "clsx";
import { SCHEMES, type Scheme } from "../lib/theme";
import { BatchA } from "./batchA";
import { BatchB } from "./batchB";
import { BatchC } from "./batchC";
import { Chip } from "../components/ui/Chip";
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
// OUTSIDE <App>, so it owns the theme attributes on <html> itself, through the same data-theme /
@@ -23,12 +23,6 @@ function applyLook(l: Look) {
el.style.setProperty("--font-scale", String(l.fontScale));
}
const chip = (on: boolean) =>
clsx(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition",
on ? "border-accent bg-accent text-accent-fg" : "border-border text-muted hover:text-fg",
);
export default function DesignLab() {
const [look, setLook] = useState<Look>(START);
useLayoutEffect(() => {
@@ -47,36 +41,22 @@ export default function DesignLab() {
<strong className="text-sm">Design lab</strong>
<span className="flex gap-1">
{(["dark", "light"] as const).map((m) => (
<button
key={m}
type="button"
className={chip(look.mode === m)}
onClick={() => set({ mode: m })}
>
<Chip key={m} pressed={look.mode === m} onClick={() => set({ mode: m })}>
{m}
</button>
</Chip>
))}
</span>
<span className="flex flex-wrap gap-1">
{SCHEMES.map((s) => (
<button
key={s.id}
type="button"
className={chip(look.scheme === s.id)}
onClick={() => set({ scheme: s.id })}
>
<Chip key={s.id} pressed={look.scheme === s.id} onClick={() => set({ scheme: s.id })}>
<span className="h-2.5 w-2.5 rounded-full" style={{ background: s.swatch }} />
{s.name}
</button>
</Chip>
))}
</span>
<button
type="button"
className={chip(look.backdrop)}
onClick={() => set({ backdrop: !look.backdrop })}
>
<Chip pressed={look.backdrop} onClick={() => set({ backdrop: !look.backdrop })}>
backdrop
</button>
</Chip>
<label className="flex items-center gap-2 text-xs text-muted">
text {look.fontScale.toFixed(2)}
<input
+6 -9
View File
@@ -24,6 +24,7 @@ import { Btn } from "./batchA";
import { hexContrast, hueGap } from "../lib/colorMath";
import { Reference, Sample } from "./labKit";
import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, FILLED, H, LAYER } from "./labTokens";
import { Chip } from "../components/ui/Chip";
// E28 S2 · Batch B — surfaces, chrome and colour, DECIDED (owner, 2026-09-24): every recommendation,
// except B7, where the owner combined the soft tint + edge bar with optional labels. B1 (density) and
@@ -124,8 +125,8 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
{s.name} <span className="font-normal text-muted">· {mode}</span>
</span>
<div className="flex flex-wrap gap-1.5">
<span className="btn-primary rounded-lg px-2.5 py-1 text-xs font-medium">Save</span>
<span className="btn-danger rounded-lg px-2.5 py-1 text-xs font-medium">Delete</span>
<span className="state-filled rounded-lg px-2.5 py-1 text-xs font-medium">Save</span>
<span className="state-danger rounded-lg px-2.5 py-1 text-xs font-medium">Delete</span>
</div>
<div className="flex flex-wrap gap-1.5 text-2xs">
<span className="chip px-2 py-0.5">Filter</span>
@@ -252,13 +253,9 @@ function WatchPage() {
<div className="text-xs text-muted">Techno Tim</div>
</div>
<span className="flex gap-1.5">
<button
type="button"
className={clsx("pill inline-flex items-center rounded-full border font-medium", H.md)}
aria-pressed
>
<Repeat className="h-3.5 w-3.5" /> Repeat
</button>
<Chip size="md" pressed icon={<Repeat className="size-3.5" />} className="font-medium">
Repeat
</Chip>
<Btn tone="glass" size="md" icon={<Download className={ico} />}>
Download
</Btn>
@@ -98,7 +98,6 @@
"removeItem": "Remove",
"removeItemOf": "Remove “{{title}}” from the playlist",
"confirmRemove": "Remove “{{title}}” from this playlist?",
"dragHandle": "Drag to reorder",
"dragHandleOf": "Reorder “{{title}}”",
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.",
"dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
@@ -98,7 +98,6 @@
"removeItem": "Eltávolítás",
"removeItemOf": "„{{title}}” eltávolítása a listáról",
"confirmRemove": "Eltávolítod a(z) „{{title}}” videót erről a listáról?",
"dragHandle": "Húzd az átrendezéshez",
"dragHandleOf": "„{{title}}” átrendezése",
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.",
"dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.",
+10 -24
View File
@@ -96,6 +96,16 @@
background-color: color-mix(in srgb, var(--danger-fg) 24%, var(--danger));
}
}
@utility state-warn {
background-color: var(--warn);
color: var(--warn-fg);
&:hover:not(:disabled, [aria-disabled="true"]) {
background-color: color-mix(in srgb, var(--warn-fg) 14%, var(--warn));
}
&:active:not(:disabled, [aria-disabled="true"]) {
background-color: color-mix(in srgb, var(--warn-fg) 24%, var(--warn));
}
}
/* A4: a control's ≥ 3:1 edge. */
@utility edge-strong {
border-color: var(--edge-strong);
@@ -618,30 +628,6 @@ body {
color: var(--accent-fg);
border-color: var(--accent);
}
/* .btn-{tone} — solid ACTION-button paint (the "primary/danger/warn action", distinct from the
selection recipes above which are "selected among options"). Each sets its tone fill + contrasting
fg; all share ONE standard hover-dim. Like .chip-solid this owns paint (+ that single hover
behaviour), NOT geometry — a site keeps its own padding/radius/font/gap/disabled:opacity/shadow.
Migrated sites MUST drop `bg-{tone} text-{tone}-fg hover:opacity-90` so these @layer-components
rules aren't beaten by those utilities. No focus-visible — @layer base already rings every button. */
.btn-primary {
background-color: var(--accent);
color: var(--accent-fg);
}
.btn-danger {
background-color: var(--danger);
color: var(--danger-fg);
}
.btn-warn {
background-color: var(--warn);
color: var(--warn-fg);
}
.btn-primary:hover:not(:disabled):not([aria-disabled="true"]),
.btn-danger:hover:not(:disabled):not([aria-disabled="true"]),
.btn-warn:hover:not(:disabled):not([aria-disabled="true"]) {
opacity: 0.9;
}
/* ===== E10 S1c · the minimum-target half of the tablet comfort contract (docs/DESIGN.md) =====
A 32px icon button is fine with a mouse (a precise pointer plus hover to confirm the aim) and
not fine with a thumb. So the minimum grows ONLY where there is no precise pointer, which on
@@ -0,0 +1,48 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
// `local/button-primitive` (eslint.config.js): one primitive per button family (docs/DESIGN.md, E28
// S3b). 268 hand-rolled buttons were swept onto ui/Button, IconButton, Pressable, Chip and
// DragHandle; this pins that the rule keeping a new one out actually fires, and where it stands down.
const RULE = "local/button-primitive";
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
}
describe("a hand-rolled button", () => {
it("is refused in a feature component, the lab and lib, raw or focus-safe", async () => {
const raw = 'export const A = () => <button className="px-3">x</button>;\n';
expect(await hits(raw)).toBe(1);
expect(await hits(raw, "src/design-lab/__probe.tsx")).toBe(1);
expect(await hits(raw, "src/lib/__probe.tsx")).toBe(1);
// …and in a ui/ COMPOSITE: a pager or a side panel is built from the primitives too.
expect(await hits(raw, "src/components/ui/Pager.tsx")).toBe(1);
expect(
await hits(
'import { FocusSafeButton } from "./ui/FocusSafeButton";\nexport const B = () => <FocusSafeButton>x</FocusSafeButton>;\n',
),
).toBe(1);
});
it("is allowed only in a control primitive, and the primitives pass everywhere", async () => {
for (const file of ["IconButton.tsx", "SegmentedToggle.tsx", "AnchoredPopover.tsx"])
expect(
await hits("export const C = () => <button>x</button>;\n", `src/components/ui/${file}`),
).toBe(0);
// Named like a primitive but outside ui/: still refused.
expect(
await hits("export const E = () => <button>x</button>;\n", "src/components/Button.tsx"),
).toBe(1);
expect(
await hits(
'import { Button } from "./ui/Button";\nexport const D = () => <Button>x</Button>;\n',
),
).toBe(0);
});
});