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 | | Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers | | Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks | | 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 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 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>` | | 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, - **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 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 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 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. A pressed `Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex
toggle is `pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A hand-rolled control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
`<button className="px-3 …">` is a violation. A text-less button must have an accessible name, and the `pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` outside
type enforces it. [WCAG 4.1.2] `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, - **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] 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`. - 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). 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. - A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
[Nielsen #1] [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 | 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 | 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 | 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 | | 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 // 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 // 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 — // 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, "sortable-a11y": sortableA11y,
"jsx-comment-text": jsxCommentText, "jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled, "focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"literal-a11y-text": literalA11yText, "literal-a11y-text": literalA11yText,
}, },
}; };
@@ -211,6 +254,7 @@ export default tseslint.config(
"react-hooks/refs": "off", "react-hooks/refs": "off",
"local/jsx-comment-text": "error", "local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error", "local/focus-safe-disabled": "error",
"local/button-primitive": "error",
"local/literal-a11y-text": "error", "local/literal-a11y-text": "error",
"local/sortable-a11y": "error", "local/sortable-a11y": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
@@ -372,6 +416,8 @@ export default tseslint.config(
"no-restricted-globals": "off", "no-restricted-globals": "off",
"no-restricted-properties": "off", "no-restricted-properties": "off",
"local/literal-a11y-text": "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, prettier,
+28 -20
View File
@@ -13,7 +13,7 @@ import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format"; import { formatDateTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers"; import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin"; 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 // 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). // one account lives (E12 · S5, design option C).
@@ -196,24 +196,28 @@ export default function AccountPanel({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{a.can_activate && ( {a.can_activate && (
<FocusSafeButton <Button
variant="glass"
fullWidth
icon={<Check />}
loading={activate.isPending}
onClick={() => activate.mutate()} onClick={() => activate.mutate()}
disabled={activate.isPending} className="border border-accent/40 text-accent"
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"
> >
<Check className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.activate")}</span> <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()} onClick={() => resend.mutate()}
// The demo account's address is never confirmed, and the server refuses to mail it — as // 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). // 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} disabled={user.is_demo || !a.email_configured || a.email_verified}
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"
> >
<Send className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.resend")}</span> <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 {/* 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. */} 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 && ( {a.email_configured && a.email_verified && (
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span> <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()} onClick={() => issue.mutate()}
disabled={issue.isPending || user.is_demo} className="border border-accent/40 text-accent"
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"
> >
<KeyRound className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left"> <span className="flex-1 text-left">
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")} {link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
</span> </span>
</FocusSafeButton> </Button>
</div> </div>
{link && ( {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"> <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} {link.url}
</div> </div>
<button <Button
variant="glass"
icon={copied ? <Check /> : <Copy />}
onClick={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")} {copied ? t("users.account.copied") : t("users.account.copy")}
</button> </Button>
</div> </div>
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p> <p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
</div> </div>
+24 -26
View File
@@ -6,6 +6,9 @@ import { decideChipLayout } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit"; import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; 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 // 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 // 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. // the "fit some + N more" treatment used on wider lanes.
const NARROW_PX = 420; 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 }) { function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<button <Chip
variant="token"
size="md"
data-testid={testId} data-testid={testId}
data-filter-key={f.key} data-filter-key={f.key}
onClick={f.remove} onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })} removeLabel={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className={chipCls}
> >
<span className="max-w-56 truncate">{f.label}</span> <span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" /> </Chip>
</button>
); );
} }
@@ -69,18 +68,16 @@ function OverflowPopover({
const name = ariaLabel ?? label; const name = ariaLabel ?? label;
return ( return (
<> <>
<button <Chip
type="button" variant="token"
size="md"
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
aria-label={name} aria-label={name}
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${ icon={icon ? <Filter className="size-3.5" aria-hidden="true" /> : undefined}
open ? "border-accent text-accent" : "hover:border-accent"
}`}
> >
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
{label} {label}
</button> </Chip>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
ariaLabel={name} ariaLabel={name}
@@ -101,17 +98,17 @@ function OverflowPopover({
))} ))}
</div> </div>
<div className="mt-1 border-t border-border px-1 pt-1.5"> <div className="mt-1 border-t border-border px-1 pt-1.5">
<button <Button
type="button" variant="link-muted"
data-testid="active-filter-clear-all" data-testid="active-filter-clear-all"
onClick={() => { onClick={() => {
onClearAll(); onClearAll();
setOpen(false); 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")} {t("sidebar.clearAll")}
</button> </Button>
</div> </div>
</PopoverSurface> </PopoverSurface>
</> </>
@@ -191,13 +188,13 @@ export default function ActiveFilterChips({
ref={(el) => { ref={(el) => {
chipRefs.current[i] = el; chipRefs.current[i] = el;
}} }}
className={chipCls} className={chipClass("token", "md") + " pr-1.5"}
> >
<span className="max-w-56 truncate">{f.label}</span> <span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0" /> <X className="size-3" />
</span> </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} +{n}
</span> </span>
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2"> <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 && ( {layout.mode === "all" && n > 1 && (
<button <Button
variant="link-muted"
data-testid="active-filter-clear-all" data-testid="active-filter-clear-all"
onClick={onClearAll} 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")} {t("sidebar.clearAll")}
</button> </Button>
)} )}
</> </>
)} )}
+12 -20
View File
@@ -6,7 +6,7 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } 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 { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard"; import { copyText } from "../lib/clipboard";
@@ -33,6 +33,7 @@ import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect"; import FilterSelect from "./ui/FilterSelect";
import { Section } from "./ui/form"; import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState"; 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 // 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 // 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; onClear: () => void;
}) { }) {
return ( return (
<span className="chip py-0.5 pl-2.5 pr-1"> <DismissibleChip onDismiss={onClear} dismissLabel={clearLabel} className="font-mono">
<span className="font-mono">{label}</span> {label}
<button </DismissibleChip>
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>
); );
} }
@@ -449,7 +441,7 @@ function RowDetail({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const pivotCls = 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 ( return (
<div className="flex flex-col gap-2"> <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"> <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> <dt>{t("adminLogs.fields.request")}</dt>
<dd> <dd>
<button <Button
type="button" variant="link"
className={pivotCls} className={pivotCls}
title={t("adminLogs.showRequest")} title={t("adminLogs.showRequest")}
onClick={() => onRequest(row.request_id!)} onClick={() => onRequest(row.request_id!)}
> >
{row.request_id} {row.request_id}
</button> </Button>
</dd> </dd>
</> </>
)} )}
@@ -480,14 +472,14 @@ function RowDetail({
<> <>
<dt>{t("adminLogs.fields.job")}</dt> <dt>{t("adminLogs.fields.job")}</dt>
<dd> <dd>
<button <Button
type="button" variant="link"
className={pivotCls} className={pivotCls}
title={t("adminLogs.showJob")} title={t("adminLogs.showJob")}
onClick={() => onJob(row.job_id!)} onClick={() => onJob(row.job_id!)}
> >
{row.job_id} {row.job_id}
</button> </Button>
</dd> </dd>
</> </>
)} )}
+7 -7
View File
@@ -23,7 +23,7 @@ import Pager from "./ui/Pager";
import { PageToolbar } from "./ui/PageShell"; import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./confirmContext"; import { useConfirm } from "./confirmContext";
import { useChannelLayout } from "./channelsContext"; 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 // 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 // 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") canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
} }
> >
<FocusSafeButton <Button
// Busy blocks the click, not the focus (FocusSafeButton). variant="glass"
size="xs"
icon={<UserPlus />}
onClick={() => onSubscribe(c)} onClick={() => onSubscribe(c)}
disabled={!canWrite} disabled={!canWrite}
busy={subscribe.isPending} loading={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"
> >
<UserPlus className="w-3.5 h-3.5" />
{t("channels.discovery.subscribe")} {t("channels.discovery.subscribe")}
</FocusSafeButton> </Button>
</Tooltip> </Tooltip>
), ),
}, },
+4 -3
View File
@@ -3,6 +3,7 @@ import { ExternalLink } from "lucide-react";
import Avatar from "./ui/Avatar"; import Avatar from "./ui/Avatar";
import Tooltip from "./ui/Tooltip"; import Tooltip from "./ui/Tooltip";
import { channelYouTubeUrl } from "../lib/format"; import { channelYouTubeUrl } from "../lib/format";
import { Pressable } from "./ui/Pressable";
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the // 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 // 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"> <div className="flex items-center gap-2 min-w-0">
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" /> <Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
{onView ? ( {onView ? (
<button <Pressable
onClick={onView} onClick={onView}
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or // The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
// card clicks this. // 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 = the full name, so a truncated fluid-column name is still readable on hover.
title={name} 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} {name}
</button> </Pressable>
) : ( ) : (
<span title={name} className="text-sm font-medium truncate min-w-0"> <span title={name} className="text-sm font-medium truncate min-w-0">
{name} {name}
+25 -37
View File
@@ -38,7 +38,6 @@ import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions"; import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the // 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 // 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" }} style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
/> />
)} )}
<button <Button
variant="media"
data-testid="channel-back" data-testid="channel-back"
icon={<ArrowLeft />}
onClick={closeChannel} 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")} {t("channel.back")}
</button> </Button>
</div> </div>
<div className="px-4 sm:px-6"> <div className="px-4 sm:px-6">
@@ -489,39 +489,32 @@ export default function ChannelPage({
</Button> </Button>
)} )}
{!me.is_demo && ( {!me.is_demo && (
<FocusSafeButton <IconButton
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle // Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// light up on hover, as if a click would do something. // light up on hover, as if a click would do something.
size="md"
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
onClick={() => block.mutate()} onClick={() => block.mutate()}
busy={block.isPending} loading={block.isPending}
title={ch?.blocked ? t("channel.unblock") : t("channel.block")} label={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"
}`}
> >
<Ban className="w-4 h-4" /> <Ban />
</FocusSafeButton> </IconButton>
)} )}
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel {/* 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 (mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
from the feed. EyeOff = currently hidden (click to unhide). */} from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && !me.is_demo && ( {ch?.subscribed && !me.is_demo && (
<FocusSafeButton <IconButton
size="md"
variant={ch.hidden ? "outline-accent" : "outline"}
onClick={() => toggleHidden.mutate()} 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")} 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" />} {ch.hidden ? <EyeOff /> : <Eye />}
</FocusSafeButton> </IconButton>
)} )}
{!me.is_demo && {!me.is_demo &&
!ch?.blocked && !ch?.blocked &&
@@ -585,20 +578,15 @@ export default function ChannelPage({
/> />
{!ch?.subscribed && nextToken && ( {!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6"> <div className="flex justify-center pb-6">
<button <Button
// Busy blocks the click, not the focus (see Block above). // Busy blocks the click, not the focus (see Block above).
onClick={() => !exploring && runExplore(nextToken)} variant="outline"
aria-disabled={exploring || undefined} icon={<RefreshCw />}
aria-busy={exploring || undefined} loading={exploring}
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" onClick={() => runExplore(nextToken)}
> >
{exploring ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
{t("channel.loadMore")} {t("channel.loadMore")}
</button> </Button>
</div> </div>
)} )}
</> </>
@@ -14,6 +14,7 @@ import { type MyStatus } from "../lib/api";
import { formatEta, formatNumber } from "../lib/format"; import { formatEta, formatNumber } from "../lib/format";
import { PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; 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 // 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 // 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 ( return (
<> <>
<button <Button
type="button" variant="glass"
size="sm"
icon={<BarChart3 aria-hidden="true" />}
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...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")} {t("channels.stats.label")}
</button> </Button>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
ariaLabel={t("channels.stats.label")} ariaLabel={t("channels.stats.label")}
+37 -55
View File
@@ -1,5 +1,5 @@
import { useRef, useState } from "react"; 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 { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api"; import { type Tag } from "../lib/api";
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover"; import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
@@ -8,6 +8,8 @@ import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow"; import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit"; import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { IconButton } from "./ui/IconButton"; 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 // 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: // 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; 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) { function TagChip({ tag, onClick, title }: TagChipProps) {
const cls = const name = <span className="block max-w-32 truncate">{tag.name}</span>;
"chip-solid shrink-0 text-2xs font-medium px-1.5 py-0.5 rounded-full max-w-36 truncate";
return onClick ? ( return onClick ? (
<button <Chip variant="tag" size="xs" onClick={onClick} title={title}>
type="button" {name}
onClick={onClick} </Chip>
title={title}
className={`${cls} hover:opacity-80 transition`}
>
{tag.name}
</button>
) : ( ) : (
<span className={cls}>{tag.name}</span> <span className={chipClass("tag", "xs")}>{name}</span>
); );
} }
@@ -63,33 +60,22 @@ function SuggestedChip({
const { t } = useTranslation(); const { t } = useTranslation();
const pct = Math.round(suggestion.confidence * 100); const pct = Math.round(suggestion.confidence * 100);
return ( 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"> <DismissibleChip
<button variant="suggest"
type="button" size="xs"
className="max-w-36 font-medium"
icon={<Sparkles />}
onClick={onAccept} onClick={onAccept}
title={t("channels.tags.suggestedHint", { pct })} bodyTitle={t("channels.tags.suggestedHint", { pct })}
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })} bodyLabel={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition" onDismiss={onDismiss}
dismissLabel={t("channels.tags.dismissSuggestion")}
> >
<Sparkles className="w-2.5 h-2.5 shrink-0" /> {suggestion.tag.name}
<span className="truncate">{suggestion.tag.name}</span> </DismissibleChip>
</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>
); );
} }
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 /** 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 * 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 * 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={() => {}} /> <SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
</span> </span>
))} ))}
<span ref={pillRef} className={GHOST_PILL}> <span ref={pillRef} className={chipClass("suggest", "xs")}>
+{suggestions.length} +{suggestions.length}
</span> </span>
</div> </div>
@@ -173,15 +159,15 @@ function SuggestedChipsLane({
/> />
))} ))}
{hidden > 0 && ( {hidden > 0 && (
<button <Chip
variant="suggest"
size="xs"
ref={overflowPop.refs.setReference} ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })} aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${GHOST_PILL} hover:text-accent transition`}
{...overflowPop.getReferenceProps()} {...overflowPop.getReferenceProps()}
> >
+{hidden} +{hidden}
</button> </Chip>
)} )}
</div> </div>
@@ -379,8 +365,8 @@ export function ChannelTagsControl({
placement: "bottom", placement: "bottom",
}); });
const pillClass = // The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-card text-muted border border-border"; const pillClass = chipClass("filter", "xs");
return ( return (
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a // 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 {/* 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- (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). */} 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} ref={editPop.refs.setReference}
type="button"
title={t("channels.row.editTags")} title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")} aria-label={t("channels.row.editTags")}
className={ icon={<Plus className="size-3" />}
bounded className={`font-medium ${bounded ? "w-6 justify-center px-0" : ""}`}
? "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"
}
{...editPop.getReferenceProps()} {...editPop.getReferenceProps()}
> >
<Plus className="w-3 h-3 shrink-0" /> {!bounded && t("channels.tags.add")}
{!bounded && <span className="whitespace-nowrap">{t("channels.tags.add")}</span>} </Chip>
</button>
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the {/* 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 attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
@@ -457,15 +440,14 @@ export function ChannelTagsControl({
/> />
))} ))}
{hidden > 0 && ( {hidden > 0 && (
<button <Chip
size="xs"
ref={overflowPop.refs.setReference} ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })} aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()} {...overflowPop.getReferenceProps()}
> >
+{hidden} +{hidden}
</button> </Chip>
)} )}
</div> </div>
</> </>
+4 -3
View File
@@ -524,12 +524,13 @@ export default function Channels() {
</Tooltip> </Tooltip>
<TagFilterChips chips={tagFilterChips} className="flex-1" /> <TagFilterChips chips={tagFilterChips} className="flex-1" />
{tagFilter.length > 0 && ( {tagFilter.length > 0 && (
<button <Button
variant="link-muted"
onClick={() => setFilters({ ...filters, tags: [] })} 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")} {t("datatable.clear")}
</button> </Button>
)} )}
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a {/* 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 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 Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread"; import ChatThread from "./ChatThread";
import Overlay from "./ui/Overlay"; 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 // 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 // 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"> <div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
<button <Pressable
onClick={() => toggleMinimize(chat.partnerId)} 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")} title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
> >
<Avatar <Avatar
@@ -93,21 +95,16 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
className="w-7 h-7 rounded-full shrink-0 text-xs" className="w-7 h-7 rounded-full shrink-0 text-xs"
/> />
<span className="font-semibold text-sm truncate">{chat.name}</span> <span className="font-semibold text-sm truncate">{chat.name}</span>
</button> </Pressable>
<button <IconButton
onClick={() => toggleMinimize(chat.partnerId)} onClick={() => toggleMinimize(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition" label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
> >
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} {chat.minimized ? <ChevronUp /> : <ChevronDown />}
</button> </IconButton>
<button <IconButton onClick={() => closeChat(chat.partnerId)} label={t("messages.close")}>
onClick={() => closeChat(chat.partnerId)} <X />
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition" </IconButton>
aria-label={t("messages.close")}
>
<X className="w-4 h-4" />
</button>
</div> </div>
{!chat.minimized && ( {!chat.minimized && (
<div className="flex flex-col h-96"> <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 { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS, useAccountPersistedState } from "../lib/storage"; import { LS, useAccountPersistedState } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors"; 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 // 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 // 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. */} with no error anywhere — say so next to the fields that fix it. */}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>} {!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}> <Tooltip hint={t("config.testEmailHint")}>
<FocusSafeButton <Button
variant="glass"
icon={<Send />}
loading={testEmail.isPending}
disabled={dirty}
onClick={() => testEmail.mutate()} 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")} {t("config.testEmail")}
</FocusSafeButton> </Button>
</Tooltip> </Tooltip>
</div> </div>
)} )}
@@ -233,14 +234,15 @@ export default function ConfigPanel() {
{activeGroup === "issues" && ( {activeGroup === "issues" && (
<div className="mt-3 pt-3 border-t border-border/60"> <div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.issuesTestHint")}> <Tooltip hint={t("config.issuesTestHint")}>
<FocusSafeButton <Button
variant="glass"
icon={<Plug />}
loading={testIssues.isPending}
disabled={dirty}
onClick={() => testIssues.mutate()} 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")} {t("config.issuesTest")}
</FocusSafeButton> </Button>
</Tooltip> </Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>} {dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
</div> </div>
@@ -249,14 +251,15 @@ export default function ConfigPanel() {
{activeGroup === "plex" && ( {activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60"> <div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}> <Tooltip hint={t("config.plexTestHint")}>
<FocusSafeButton <Button
variant="glass"
icon={<Plug />}
loading={testPlex.isPending}
disabled={dirty}
onClick={() => testPlex.mutate()} 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")} {t("config.plexTest")}
</FocusSafeButton> </Button>
</Tooltip> </Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>} {dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
{plexResult && ( {plexResult && (
@@ -404,15 +407,14 @@ function Field({
)} )}
{showReset && !secretDisabled && ( {showReset && !secretDisabled && (
<Tooltip hint={t("config.resetHint")}> <Tooltip hint={t("config.resetHint")}>
<button <Button
variant={item.secret && pendingSecretReset ? "link" : "link-muted"}
icon={<RotateCcw className="size-3.5!" />}
onClick={onResetField} onClick={onResetField}
className={`inline-flex items-center gap-1 text-xs transition ${ className="text-xs gap-1"
item.secret && pendingSecretReset ? "text-accent" : "text-muted hover:text-fg"
}`}
> >
<RotateCcw className="w-3.5 h-3.5" />
{t("config.reset")} {t("config.reset")}
</button> </Button>
</Tooltip> </Tooltip>
)} )}
</div> </div>
+9 -8
View File
@@ -5,6 +5,8 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { type FeedFilters } from "../lib/api"; import { type FeedFilters } from "../lib/api";
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter"; 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, // 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 // 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 ( return (
<> <>
<button <Pressable
type="button"
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`} 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" /> <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" /> <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button> </Pressable>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
@@ -179,13 +180,13 @@ export default function DateFilterSelect({
/> />
</label> </label>
{(filters.dateFrom || filters.dateTo) && ( {(filters.dateFrom || filters.dateTo) && (
<button <Button
type="button" variant="link-muted"
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })} 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")} {t("sidebar.clearDates")}
</button> </Button>
)} )}
</div> </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="px-4 pt-2 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1"> <div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1> <h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button <Button variant="ghost" size="sm" icon={<Settings2 />} onClick={() => setManage(true)}>
onClick={() => setManage(true)} {t("downloads.page.manage")}
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" </Button>
>
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
</button>
</div> </div>
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p> <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))} onChange={([v]) => v && setProfileId(Number(v))}
options={profiles.map((p) => ({ value: String(p.id), label: p.name }))} options={profiles.map((p) => ({ value: String(p.id), label: p.name }))}
/> />
<button <Button variant="link" onClick={() => setManage(true)} className="text-xs mt-1 mb-4">
type="button"
onClick={() => setManage(true)}
className="text-xs text-accent hover:underline mt-1 mb-4"
>
{t("downloads.dialog.manage")} {t("downloads.dialog.manage")}
</button> </Button>
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label> <label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
<input <input
+3 -5
View File
@@ -3,6 +3,7 @@ import i18n from "../i18n";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { errorAttrs, log } from "../lib/log"; import { errorAttrs, log } from "../lib/log";
import { reloadingForNewBuild } from "../lib/staleBuild"; import { reloadingForNewBuild } from "../lib/staleBuild";
import { Button } from "./ui/Button";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -46,12 +47,9 @@ export default class ErrorBoundary extends Component<Props, State> {
{i18n.t("errors.boundary.title")} {i18n.t("errors.boundary.title")}
</div> </div>
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div> <div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
<button <Button variant="link" onClick={() => location.reload()} className="text-sm">
onClick={() => location.reload()}
className="text-accent hover:underline text-sm font-semibold"
>
{i18n.t("errors.boundary.reload")} {i18n.t("errors.boundary.reload")}
</button> </Button>
</div> </div>
</div> </div>
); );
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
import ChannelLink from "./ChannelLink"; import ChannelLink from "./ChannelLink";
import NavGrid from "./ui/NavGrid"; import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { Button } from "./ui/Button";
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked. // 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 // 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} onView={onView ? () => onView(c) : undefined}
/> />
</div> </div>
<button <Button variant="outline" size="xs" icon={actionIcon} onClick={() => onAction(c.id)}>
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}
{actionLabel} {actionLabel}
</button> </Button>
</div> </div>
))} ))}
</NavGrid> </NavGrid>
+6 -7
View File
@@ -42,6 +42,7 @@ import { PlexPosterCard } from "./plexCards";
import { useNavigationActions } from "./navigationContext"; import { useNavigationActions } from "./navigationContext";
import { usePlexActions } from "./plexContext"; import { usePlexActions } from "./plexContext";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
// The Home surface (E24) — "Mit nézzek most?". // The Home surface (E24) — "Mit nézzek most?".
// //
@@ -244,8 +245,7 @@ function ShelfTile({
? t("home.shelf.unwatchedCount", { count: count ?? 0 }) ? t("home.shelf.unwatchedCount", { count: count ?? 0 })
: t("home.shelf.itemCount", { count: count ?? 0 }); : t("home.shelf.itemCount", { count: count ?? 0 });
return ( return (
<button <Pressable
type="button"
onClick={() => card.next && onPlay(card)} onClick={() => card.next && onPlay(card)}
{...menuProps?.(card)} {...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex))} {...(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), // Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
// alive to all three input paths. // alive to all three input paths.
aria-disabled={!card.next} 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"> <span className="block aspect-video bg-card overflow-hidden">
{card.thumb ? ( {card.thumb ? (
@@ -268,7 +268,7 @@ function ShelfTile({
<span className="block text-xs font-medium truncate">{card.title}</span> <span className="block text-xs font-medium truncate">{card.title}</span>
<span className="block text-2xs text-muted">{countLabel}</span> <span className="block text-2xs text-muted">{countLabel}</span>
</span> </span>
</button> </Pressable>
); );
} }
@@ -307,8 +307,7 @@ function Hero({
{...pinMenu.triggerProps(card)} {...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]" className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]"
> >
<button <Pressable
type="button"
onClick={() => onPlay(card)} onClick={() => onPlay(card)}
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-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" card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
@@ -331,7 +330,7 @@ function Hero({
/> />
</span> </span>
) : null} ) : null}
</button> </Pressable>
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-col">
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p> <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")} {configured ? t("messages.unlock") : t("messages.setUp")}
</Button> </Button>
{configured && ( {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")} {t("messages.forgotPass")}
</button> </Button>
)} )}
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>} {!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
</div> </div>
+6 -4
View File
@@ -3,6 +3,7 @@ import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n"; import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
// Compact language picker (globe + current code). Presentational: the parent decides what // Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in). // changing the language does (set it; persist server-side when signed in).
@@ -42,15 +43,16 @@ export default function LanguageSwitcher({
return ( return (
<> <>
<button <Pressable
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
title={current.label} title={current.label}
aria-label={current.label} aria-label={current.label}
layer
className={ className={
rail rail
? "relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition" ? "relative p-2 rounded-lg text-muted hover:text-fg 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" : "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"} /> <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> <span className="text-xs font-semibold uppercase">{current.code}</span>
)} )}
</button> </Pressable>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
+5 -4
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import type { Level } from "../lib/log"; import type { Level } from "../lib/log";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { LEVEL_TAG, logClock } from "./logLineParts"; 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 // 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 // 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" className="border-b border-border/50 last:border-b-0"
{...navItemAttrs(navIndex, `${event} ${msg}`)} {...navItemAttrs(navIndex, `${event} ${msg}`)}
> >
<button <Pressable
type="button" layer={!open}
data-nav-open data-nav-open
aria-expanded={open} aria-expanded={open}
onClick={onToggle} onClick={onToggle}
title={title} 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" : "" 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 {/* 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. */} 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> <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>} {open && <div className="bg-accent/5 px-3 py-2 text-muted">{children}</div>}
</div> </div>
); );
+19 -18
View File
@@ -19,6 +19,7 @@ import { inputCls } from "./ui/form";
import ChatThread from "./ChatThread"; import ChatThread from "./ChatThread";
import NavGrid from "./ui/NavGrid"; import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
type View = type View =
| "list" | "list"
@@ -158,10 +159,10 @@ function ConversationList({
{...navItemAttrs(i, c.partner.name)} {...navItemAttrs(i, c.partner.name)}
className="group flex items-center gap-1 rounded-xl hover:bg-card transition" className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
> >
<button <Pressable
data-nav-open="" data-nav-open=""
onClick={() => onOpen(c)} 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 <Avatar
src={c.partner.avatar_url} src={c.partner.avatar_url}
@@ -192,16 +193,16 @@ function ConversationList({
)} )}
</div> </div>
</div> </div>
</button> </Pressable>
{!isSystem && ready && ( {!isSystem && ready && (
<button <IconButton
size="md"
onClick={() => openChat(c.partner)} onClick={() => openChat(c.partner)}
title={t("messages.popOut")} label={t("messages.popOut")}
aria-label={t("messages.popOut")} className="mr-1.5 reveal"
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
> >
<ExternalLink className="w-4 h-4" /> <ExternalLink />
</button> </IconButton>
)} )}
</div> </div>
); );
@@ -253,10 +254,11 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{users.map((u) => ( {users.map((u) => (
<button <Pressable
key={u.id} key={u.id}
layer
onClick={() => onPick(u)} 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 <Avatar
src={u.avatar_url} 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" className="w-9 h-9 rounded-full shrink-0 text-sm"
/> />
<span className="truncate font-medium">{u.name}</span> <span className="truncate font-medium">{u.name}</span>
</button> </Pressable>
))} ))}
</div> </div>
)} )}
@@ -317,14 +319,13 @@ function PageThread({
)} )}
<div className="flex-1" /> <div className="flex-1" />
{!isSystem && ready && ( {!isSystem && ready && (
<button <IconButton
size="md"
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })} onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
title={t("messages.popOut")} label={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition"
> >
<ExternalLink className="w-4 h-4" /> <ExternalLink />
</button> </IconButton>
)} )}
</div> </div>
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} /> <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 { useOverlayOrder } from "./ui/overlayOrder";
import { useLayer } from "../lib/layerStack"; import { useLayer } from "../lib/layerStack";
import { trapTab, useDialogChrome } from "./ui/dialogFocus"; 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 // 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 // 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} 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)]" 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} onClick={close}
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition" className="absolute top-3 right-3"
aria-label={t("onboarding.close")} label={t("onboarding.close")}
> >
<X className="w-4 h-4" /> <X />
</button> </IconButton>
{step === "read" && ( {step === "read" && (
<> <>
@@ -196,12 +198,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
</p> </p>
<ConsentHeadsUp /> <ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2"> <div className="mt-5 flex flex-col gap-2">
<button <Button size="lg" icon={<Youtube />} onClick={() => beginGrant("read")}>
onClick={() => beginGrant("read")} {t("onboarding.read.connect")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition" </Button>
>
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
</button>
<Button variant="ghost" size="md" onClick={close}> <Button variant="ghost" size="md" onClick={close}>
{t("onboarding.read.skip")} {t("onboarding.read.skip")}
</Button> </Button>
@@ -254,21 +253,21 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
)} )}
</p> </p>
{importSubs.isError && ( {importSubs.isError && (
<button <Button
variant="outline"
icon={<RefreshCw />}
loading={importSubs.isPending}
onClick={() => importSubs.mutate()} 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")} {t("onboarding.write.retryImport")}
</button> </Button>
)} )}
<ConsentHeadsUp /> <ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2"> <div className="mt-5 flex flex-col gap-2">
<button <Button size="lg" icon={<ShieldCheck />} onClick={() => beginGrant("write")}>
onClick={() => beginGrant("write")} {t("onboarding.write.enableUnsubscribe")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition" </Button>
>
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
</button>
<Button <Button
variant="ghost" variant="ghost"
size="md" 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"> <p className="text-sm text-muted leading-relaxed mt-2 mb-5">
{t("onboarding.done.body")} {t("onboarding.done.body")}
</p> </p>
<button <Button
size="lg"
fullWidth
onClick={() => { onClick={() => {
endOnboarding(false); endOnboarding(false);
onClose(); 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")} {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 { Command } from "lucide-react";
import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap"; 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 // 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 // `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 { t } = useTranslation();
const cap = useCap(); const cap = useCap();
return ( return (
<button <Pressable
type="button"
onClick={openPalette} onClick={openPalette}
aria-label={`${t("palette.open")} (${cap})`} aria-label={`${t("palette.open")} (${cap})`}
title={`${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" 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} {cap}
</button> </Pressable>
); );
} }
@@ -47,8 +47,7 @@ export function PaletteLauncher() {
const { t } = useTranslation(); const { t } = useTranslation();
const cap = useCap(); const cap = useCap();
return ( return (
<button <Pressable
type="button"
onClick={openPalette} onClick={openPalette}
title={t("palette.open")} 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" 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"> <span className="rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs @max-[640px]/appmain:hidden">
{cap} {cap}
</span> </span>
</button> </Pressable>
); );
} }
+7 -12
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { import {
closestCenter, closestCenter,
DndContext, DndContext,
@@ -23,6 +23,7 @@ import UndoToolbar from "./ui/UndoToolbar";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle"; import { DragHandle } from "./ui/DragHandle";
import { Pressable } from "./ui/Pressable"; 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 // 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 // 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"> <div className="flex flex-wrap gap-1.5">
{captured.map((f) => ( {captured.map((f) => (
<span <DismissibleChip
key={f.key} 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} {f.label}
<button </DismissibleChip>
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>
))} ))}
</div> </div>
)} )}
+3 -3
View File
@@ -46,7 +46,7 @@ import {
formatViews, formatViews,
relativeTime, relativeTime,
} from "../lib/format"; } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks"; import { renderDescription } from "./descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen"; import { fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { useBackToClose } from "../lib/history"; import { useBackToClose } from "../lib/history";
@@ -748,9 +748,9 @@ export default function PlayerModal({
href={`https://www.youtube.com/watch?v=${currentVideoId}`} href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank" target="_blank"
rel="noreferrer" 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")} {t("player.openOnYoutube")}
</a> </a>
</div> </div>
@@ -4,6 +4,7 @@ import { Info } from "lucide-react";
import { PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; 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 // 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). // 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" /> <Info className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>{t("playlists.limits.hint")}</span> <span>{t("playlists.limits.hint")}</span>
<button <Button variant="link" ref={pop.refs.setReference} {...pop.getReferenceProps()}>
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
className="text-accent hover:underline"
>
{t("playlists.limits.why")} {t("playlists.limits.why")}
</button> </Button>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
ariaLabel={t("playlists.limits.title")} ariaLabel={t("playlists.limits.title")}
+24 -48
View File
@@ -27,11 +27,9 @@ import {
ExternalLink, ExternalLink,
FolderInput, FolderInput,
History, History,
GripVertical,
ListPlus, ListPlus,
Pencil, Pencil,
Play, Play,
RefreshCw,
RotateCcw, RotateCcw,
Trash2, Trash2,
Upload, Upload,
@@ -68,8 +66,8 @@ import { ColumnHead } from "./ui/tableHeader";
import { alignClass } from "./ui/tableAlign"; import { alignClass } from "./ui/tableAlign";
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns"; import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
import { GRIP_KEY, playlistColumns } from "./playlistColumns"; import { GRIP_KEY, playlistColumns } from "./playlistColumns";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; 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 // 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. // 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 // 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 // attributes/listeners. Disabled (not hidden) under a view, so the affordance stays
// put and can explain itself instead of silently doing nothing. // put and can explain itself instead of silently doing nothing.
<button <DragHandle
{...attributes} {...attributes}
{...(dragDisabled ? {} : listeners)} {...(dragDisabled ? {} : listeners)}
// `disabled` would drop it from the tab order AND suppress the tooltip, so the unavailable={dragDisabled}
// reason reached nobody. aria-disabled keeps it focusable and the name carries the why. label={dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })}
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>
) : ( ) : (
col.render(video) col.render(video)
)} )}
@@ -896,17 +885,15 @@ export default function Playlists() {
<> <>
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2> <h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && ( {editable && (
<button <IconButton
onClick={() => { onClick={() => {
setRenameValue(detail.name); setRenameValue(detail.name);
setRenaming(true); setRenaming(true);
}} }}
title={t("playlists.rename")} label={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil />
</button> </IconButton>
)} )}
{detail.yt_playlist_id && ( {detail.yt_playlist_id && (
<a <a
@@ -932,37 +919,25 @@ export default function Playlists() {
{t("playlists.unsynced")} {t("playlists.unsynced")}
</span> </span>
)} )}
<FocusSafeButton <Button
size="sm"
onClick={() => shown.length && openPlayer(shown[0]!.id)} onClick={() => shown.length && openPlayer(shown[0]!.id)}
disabled={!shown.length} 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")} {t("playlists.playAll")}
</FocusSafeButton> </Button>
{editable && canWrite && ( {editable && canWrite && (
<FocusSafeButton <IconButton
variant={detail.dirty ? "outline-accent" : "outline"}
onClick={pushToYoutube} onClick={pushToYoutube}
disabled={pushing} loading={pushing}
title={ label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube") 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 ? ( {linked ? <Youtube /> : <Upload />}
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> </IconButton>
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</FocusSafeButton>
)} )}
{linked && detail.dirty && ( {linked && detail.dirty && (
<IconButton <IconButton
@@ -1009,12 +984,13 @@ export default function Playlists() {
{t("playlists.groupByChannel")} {t("playlists.groupByChannel")}
</label> </label>
{viewActive && ( {viewActive && (
<button <Button
variant="link-muted"
onClick={clearView} 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")} {t("playlists.clearView")}
</button> </Button>
)} )}
{canSaveOrder && ( {canSaveOrder && (
<Button <Button
+16 -16
View File
@@ -23,6 +23,8 @@ import { useMe } from "../lib/useMe";
import { playlistName } from "../lib/playlistName"; import { playlistName } from "../lib/playlistName";
import { usePlaylistActions } from "./usePlaylistActions"; import { usePlaylistActions } from "./usePlaylistActions";
import PlaylistLimitsHint from "./PlaylistLimitsHint"; import PlaylistLimitsHint from "./PlaylistLimitsHint";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
type PlSort = { type PlSort = {
key: "custom" | "name" | "count" | "duration" | "recent"; 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 }) next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir })
} }
trailing={ trailing={
<button <IconButton
variant="outline"
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })} onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
title={t("playlists.dirtyFirst")} label={t("playlists.dirtyFirst")}
aria-pressed={plSort.dirtyFirst} pressed={plSort.dirtyFirst}
className="pill shrink-0 rounded-lg border p-1.5"
> >
<Pin className="h-4 w-4" /> <Pin />
</button> </IconButton>
} }
/> />
)} )}
@@ -292,13 +294,12 @@ export default function PlaylistsRail({
<> <>
{playlists.length === 0 && {playlists.length === 0 &&
(listQuery.isError && !listQuery.data ? ( (listQuery.isError && !listQuery.data ? (
<button <Pressable
type="button"
onClick={() => listQuery.refetch()} 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")} {t("common.loadError")} — {t("common.retry")}
</button> </Pressable>
) : listQuery.isLoading ? ( ) : listQuery.isLoading ? (
<div className="text-xs text-muted px-1 py-1">{t("common.loading")}</div> <div className="text-xs text-muted px-1 py-1">{t("common.loading")}</div>
) : ( ) : (
@@ -342,7 +343,7 @@ export default function PlaylistsRail({
/> />
</div> </div>
) : ( ) : (
<button <Pressable
key={pl.id} key={pl.id}
ref={(el) => { ref={(el) => {
if (pl.id === selectedId) selectedRef.current = el; if (pl.id === selectedId) selectedRef.current = el;
@@ -354,10 +355,9 @@ export default function PlaylistsRail({
}} }}
onClick={() => setSelectedId(pl.id)} onClick={() => setSelectedId(pl.id)}
{...railMenu.triggerProps(pl)} {...railMenu.triggerProps(pl)}
className={`${ROW_BASE} text-left ${ layer={pl.id !== selectedId}
pl.id === selectedId className={`${ROW_BASE} ${
? "bg-accent/15 border-accent" pl.id === selectedId ? "bg-accent/15 border-accent" : "border-transparent"
: "border-transparent hover:bg-card/60"
}`} }`}
> >
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0"> <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)}`} ` · ${formatRuntime(pl.total_duration_seconds)}`}
</div> </div>
</div> </div>
</button> </Pressable>
), ),
)} )}
</div> </div>
+13 -14
View File
@@ -43,6 +43,9 @@ import { useContextMenu } from "./ui/useContextMenu";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews"; import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
import { dispatch, useKeyScope } from "../lib/keymap"; import { dispatch, useKeyScope } from "../lib/keymap";
import NavGrid from "./ui/NavGrid"; 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. // Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer")); 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" 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. */} {/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
<button <Pressable
onClick={() => onOpenPerson(p.name)} onClick={() => onOpenPerson(p.name)}
title={t("plex.info.viewPerson", { name: 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 ? ( {p.photo ? (
<img <img
@@ -578,15 +581,14 @@ export default function PlexBrowse() {
{t("plex.people.count", { count: p.count })} {t("plex.people.count", { count: p.count })}
</div> </div>
</div> </div>
</button> </Pressable>
<button <IconButton
onClick={() => removeActor(p.name)} onClick={() => removeActor(p.name)}
title={t("plex.people.remove")} label={t("plex.people.remove")}
aria-label={t("plex.people.remove")} className="ml-1"
className="ml-1 p-1 rounded-full text-muted hover:text-fg hover:bg-card transition"
> >
<X className="w-4 h-4" /> <X />
</button> </IconButton>
</div> </div>
))} ))}
</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". // 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"> <div className="flex flex-wrap items-center gap-3 text-sm text-muted">
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span> <span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
<button <Chip onClick={() => setFilters(EMPTY_PLEX_FILTERS)} className="font-medium">
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"
>
{t("plex.filter.clearAll")} {t("plex.filter.clearAll")}
</button> </Chip>
</div> </div>
) : ( ) : (
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p> <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 { Button } from "./ui/Button";
import Modal from "./ui/Modal"; import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext"; 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 // 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 // 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} key={c.id}
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm" 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)} onClick={() => toggle(c.id)}
disabled={!!busy} 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" : "" inIt ? "text-accent" : ""
}`} }`}
> >
@@ -209,14 +211,15 @@ export default function PlexCollectionEditor({
<span className="shrink-0 text-xs text-muted"> <span className="shrink-0 text-xs text-muted">
{t("plex.collEditor.count", { count: c.child_count ?? 0 })} {t("plex.collEditor.count", { count: c.child_count ?? 0 })}
</span> </span>
</FocusSafeButton> </Pressable>
<FocusSafeButton <Button
variant="outline-danger"
size="xs"
onClick={() => removeCollection(c.id, c.title)} onClick={() => removeCollection(c.id, c.title)}
disabled={!!busy} 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")} {t("plex.collEditor.delete")}
</FocusSafeButton> </Button>
</div> </div>
); );
}) })
@@ -243,13 +246,14 @@ export default function PlexCollectionEditor({
<span className="shrink-0 text-xs text-muted"> <span className="shrink-0 text-xs text-muted">
{t("plex.collEditor.count", { count: c.child_count ?? 0 })} {t("plex.collEditor.count", { count: c.child_count ?? 0 })}
</span> </span>
<FocusSafeButton <Button
variant="glass"
size="xs"
onClick={() => markEditable(c.id)} onClick={() => markEditable(c.id)}
disabled={!!busy} 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")} {t("plex.collEditor.makeEditable")}
</FocusSafeButton> </Button>
</div> </div>
))} ))}
</div> </div>
+21 -21
View File
@@ -21,7 +21,8 @@ import PlexLibraryPicker from "./PlexLibraryPicker";
import { toggleWatchState } from "../lib/plexWatch"; import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid"; 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 // 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, // 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} {d.summary}
</p> </p>
<button <Button
variant="link"
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)} 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")} {expanded ? t("plex.collection.less") : t("plex.collection.more")}
</button> </Button>
</div> </div>
)} )}
@@ -412,15 +415,15 @@ export default function PlexCollectionView({
menuProps={pinMenu.triggerProps} menuProps={pinMenu.triggerProps}
/> />
{canEdit && ( {canEdit && (
<FocusSafeButton <IconButton
variant="media"
onClick={() => doRemove(c)} onClick={() => doRemove(c)}
disabled={busy} disabled={busy}
title={t("plex.collection.removeItem")} label={t("plex.collection.removeItem")}
aria-label={t("plex.collection.removeItem")} className="absolute right-1.5 top-1.5 z-base"
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"
> >
<X className="h-3.5 w-3.5" /> <X />
</FocusSafeButton> </IconButton>
)} )}
</div> </div>
))} ))}
@@ -475,13 +478,9 @@ function AddItemPanel({
aria-label={t("plex.collection.addSearch")} 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" 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 <IconButton size="md" onClick={onClose} label={t("common.close")}>
onClick={onClose} <X />
aria-label={t("common.close")} </IconButton>
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
>
<X className="h-4 w-4" />
</button>
</div> </div>
<PlexLibraryPicker <PlexLibraryPicker
scope={scope} scope={scope}
@@ -490,14 +489,15 @@ function AddItemPanel({
renderAction={(c) => { renderAction={(c) => {
const inColl = existing.has(c.id) || added.has(c.id); const inColl = existing.has(c.id) || added.has(c.id);
return ( return (
<FocusSafeButton <Button
variant="glass"
size="xs"
icon={inColl ? <Check /> : <Plus />}
onClick={() => add(c)} onClick={() => add(c)}
disabled={inColl} 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")} {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 { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid"; 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 // 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 // /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")} ariaLabel={t("plex.discover.surprise")}
cards={surprise} cards={surprise}
action={ action={
<button <IconButton
type="button"
onClick={() => surpriseQ.refetch()} onClick={() => surpriseQ.refetch()}
aria-label={t("plex.discover.reshuffle")} loading={surpriseQ.isFetching}
title={t("plex.discover.reshuffle")} label={t("plex.discover.reshuffle")}
className="rounded-full p-1 text-muted transition hover:text-accent"
> >
<RotateCcw className={`h-3.5 w-3.5 ${surpriseQ.isFetching ? "animate-spin" : ""}`} /> <RotateCcw />
</button> </IconButton>
} }
{...railProps} {...railProps}
/> />
+39 -27
View File
@@ -82,6 +82,9 @@ import {
anyBlockingLayerAbove, anyBlockingLayerAbove,
escapeHandled, escapeHandled,
} from "../lib/layerStack"; } 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. // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo")); 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"> <div className="flex items-center gap-3 text-white">
<button <IconButton
variant="overlay"
size="player"
onMedia
onClick={handleBack} onClick={handleBack}
className="p-1.5 rounded-lg hover:bg-white/15" label={t("plex.player.back")}
title={t("plex.player.back")}
> >
<ArrowLeft className="w-5 h-5" /> <ArrowLeft />
</button> </IconButton>
<div className="min-w-0"> <div className="min-w-0">
<div className="font-semibold truncate"> <div className="font-semibold truncate">
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title} {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) */} {/* Skip intro / credits (with the auto-skip countdown fill, if armed) */}
{activeMarker && ( {activeMarker && (
<button <Button
variant="media"
onClick={() => doSkip(activeMarker)} 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" uiVisible ? "opacity-100" : "opacity-80"
}`} }`}
> >
@@ -1295,7 +1301,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
? t("plex.player.skipIntro") ? t("plex.player.skipIntro")
: t("plex.player.skipCredits")} : t("plex.player.skipCredits")}
</span> </span>
</button> </Button>
)} )}
{/* Bottom controls */} {/* 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. */} {/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */}
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && ( {detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
<div className="relative"> <div className="relative">
<button <IconButton
variant="overlay"
size="player"
onMedia
ref={tracksTriggerRef} ref={tracksTriggerRef}
onClick={() => setTracksOpen((o) => !o)} onClick={() => setTracksOpen((o) => !o)}
aria-label={t("plex.player.tracks")} aria-expanded={tracksOpen}
className={`p-1 hover:text-accent ${tracksOpen ? "text-accent" : ""}`} label={t("plex.player.tracks")}
> >
<Languages className="w-5 h-5" /> <Languages />
</button> </IconButton>
{tracksOpen && ( {tracksOpen && (
<div <div
ref={setTracksNode} ref={setTracksNode}
@@ -1496,42 +1505,42 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<> <>
<PanelHead>{t("plex.player.audio")}</PanelHead> <PanelHead>{t("plex.player.audio")}</PanelHead>
{detail.audio_streams.map((a) => ( {detail.audio_streams.map((a) => (
<button <Pressable
key={a.ord} key={a.ord}
onClick={() => changeAudio(a.ord)} onClick={() => changeAudio(a.ord)}
aria-pressed={(audioOrd ?? 0) === 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" (audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`} }`}
> >
{a.label} {a.label}
</button> </Pressable>
))} ))}
</> </>
)} )}
{textSubs.length > 0 && ( {textSubs.length > 0 && (
<> <>
<PanelHead>{t("plex.player.subtitles")}</PanelHead> <PanelHead>{t("plex.player.subtitles")}</PanelHead>
<button <Pressable
onClick={() => changeSubtitle(null)} onClick={() => changeSubtitle(null)}
aria-pressed={subOrd == 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" subOrd == null ? "text-accent" : "text-white"
}`} }`}
> >
{t("plex.player.subOff")} {t("plex.player.subOff")}
</button> </Pressable>
{textSubs.map((s) => ( {textSubs.map((s) => (
<button <Pressable
key={s.ord} key={s.ord}
onClick={() => changeSubtitle(s.ord)} onClick={() => changeSubtitle(s.ord)}
aria-pressed={subOrd === 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" subOrd === s.ord ? "text-accent" : "text-white"
}`} }`}
> >
{s.label} {s.label}
</button> </Pressable>
))} ))}
</> </>
)} )}
@@ -1543,14 +1552,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */} {/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
{detail && ( {detail && (
<div className="relative"> <div className="relative">
<button <IconButton
variant="overlay"
size="player"
onMedia
ref={menuTriggerRef} ref={menuTriggerRef}
onClick={() => setMenuOpen((o) => !o)} onClick={() => setMenuOpen((o) => !o)}
aria-label={t("plex.player.settings")} aria-expanded={menuOpen}
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`} label={t("plex.player.settings")}
> >
<Settings className="w-5 h-5" /> <Settings />
</button> </IconButton>
{menuOpen && ( {menuOpen && (
<PlexPlayerSettings <PlexPlayerSettings
menuRef={menuRef} menuRef={menuRef}
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Slider, Toggle } from "./plexPlayerControls"; import { Slider, Toggle } from "./plexPlayerControls";
import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs"; import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
import { Pressable } from "./ui/Pressable";
type SettingsTab = "sync" | "playback" | "subtitle" | "clock"; type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
@@ -44,15 +45,17 @@ export function PlexPlayerSettings(props: {
> >
<div className="mb-1.5 flex gap-1"> <div className="mb-1.5 flex gap-1">
{settingsTabs.map((tab) => ( {settingsTabs.map((tab) => (
<button <Pressable
key={tab.key} key={tab.key}
align="center"
aria-pressed={activeTab === tab.key}
onClick={() => setSettingsTab(tab.key)} onClick={() => setSettingsTab(tab.key)}
className={`flex-1 whitespace-nowrap rounded px-2 py-1 text-2xs ${ 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" activeTab === tab.key ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
}`} }`}
> >
{tab.label} {tab.label}
</button> </Pressable>
))} ))}
</div> </div>
@@ -253,8 +256,10 @@ export function PlexPlayerSettings(props: {
{prefs.clockDate && ( {prefs.clockDate && (
<div className="flex gap-1 px-1 py-1"> <div className="flex gap-1 px-1 py-1">
{(["hu", "en"] as const).map((st) => ( {(["hu", "en"] as const).map((st) => (
<button <Pressable
key={st} key={st}
align="center"
aria-pressed={prefs.clockDateStyle === st}
onClick={() => patchPrefs({ clockDateStyle: st })} onClick={() => patchPrefs({ clockDateStyle: st })}
className={`flex-1 rounded px-2 py-1 text-2xs ${ className={`flex-1 rounded px-2 py-1 text-2xs ${
prefs.clockDateStyle === st prefs.clockDateStyle === st
@@ -263,7 +268,7 @@ export function PlexPlayerSettings(props: {
}`} }`}
> >
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date} {clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
</button> </Pressable>
))} ))}
</div> </div>
)} )}
+6 -4
View File
@@ -8,7 +8,7 @@ import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import Modal from "./ui/Modal"; 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 // 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. // 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 all = n >= size;
const partial = n > 0 && n < size; const partial = n > 0 && n < size;
return ( return (
<FocusSafeButton <Pressable
key={p.id} key={p.id}
role="checkbox"
aria-checked={all ? true : partial ? "mixed" : false}
onClick={() => toggle(p)} onClick={() => toggle(p)}
disabled={busy} 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" : "" all ? "text-accent" : ""
}`} }`}
> >
@@ -175,7 +177,7 @@ export default function PlexPlaylistAdd({
{t("plex.playlistAdd.count", { count: p.item_count })} {t("plex.playlistAdd.count", { count: p.item_count })}
</span> </span>
)} )}
</FocusSafeButton> </Pressable>
); );
}) })
)} )}
+4 -4
View File
@@ -9,6 +9,7 @@ import { api } from "../lib/api";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch"; 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 // 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 // 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> <div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
)} )}
{suggestions.map((c, i) => ( {suggestions.map((c, i) => (
<button <Pressable
key={c.id} key={c.id}
id={`${listId}-opt-${i}`} id={`${listId}-opt-${i}`}
data-idx={i} data-idx={i}
type="button"
role="option" role="option"
aria-selected={hi === i} aria-selected={hi === i}
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
onMouseEnter={() => setHi(i)} onMouseEnter={() => setHi(i)}
onClick={() => pick(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" 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" /> <Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
)} )}
</button> </Pressable>
))} ))}
</div> </div>
) : null ) : null
+8 -10
View File
@@ -4,7 +4,7 @@ import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { plexLang } from "../i18n"; import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query"; 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 { api, plexFilterCount, type PlexFilters } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { type PanelLayout } from "../lib/panelLayout"; import { type PanelLayout } from "../lib/panelLayout";
@@ -20,6 +20,7 @@ import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./savedViewsContext"; import { useSavedViews } from "./savedViewsContext";
import { usePlex, usePlexActions } from "./plexContext"; import { usePlex, usePlexActions } from "./plexContext";
import { useMe } from "../lib/useMe"; 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 // 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 // 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 }) { function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<span className="chip px-2 py-1 text-xs"> <DismissibleChip
<span className="truncate max-w-36">{label}</span> onDismiss={onRemove}
<button dismissLabel={t("common.removeNamed", { name: label })}
onClick={onRemove} className="max-w-40"
className="hover:opacity-70"
aria-label={t("common.removeNamed", { name: label })}
> >
<X className="w-3 h-3" /> {label}
</button> </DismissibleChip>
</span>
); );
} }
+4 -7
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import type Hls from "hls.js"; import type Hls from "hls.js";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats"; 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 // "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 // 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"> <span className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("plex.player.stats.title")} {t("plex.player.stats.title")}
</span> </span>
<button <IconButton variant="overlay" onClick={onClose} label={t("plex.player.close")}>
onClick={onClose} <X />
aria-label={t("plex.player.close")} </IconButton>
className="rounded p-0.5 text-white/60 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div> </div>
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen {/* 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. 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 { type ReactNode } from "react";
import { hueOf, hueTileGradient } from "../lib/hueTile"; import { hueOf, hueTileGradient } from "../lib/hueTile";
import { navItemAttrs } from "../lib/listNav"; 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}), // 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 // 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 posters = (art ?? []).slice(0, 2);
const band = hueTileGradient(hueOf(title)); const band = hueTileGradient(hueOf(title));
return ( return (
<button <Pressable
type="button"
onClick={onClick} onClick={onClick}
title={title} title={title}
{...(navIndex !== undefined && navItemAttrs(navIndex))} {...(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. */} {/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
<div <div
@@ -60,6 +60,6 @@ export default function PosterTile({
<div className="truncate text-sm font-semibold text-white drop-shadow">{title}</div> <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>} {subtitle != null && <div className="truncate text-2xs text-white/80">{subtitle}</div>}
</div> </div>
</button> </Pressable>
); );
} }
+9 -12
View File
@@ -10,6 +10,7 @@ import { inputCls } from "./ui/form";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import SegmentedToggle from "./ui/SegmentedToggle"; import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect"; import FilterSelect from "./ui/FilterSelect";
import { IconButton } from "./ui/IconButton";
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360]; 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" 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> <span className="flex-1 truncate">{p.name}</span>
<button <IconButton onClick={() => startEdit(p)} label={t("downloads.actions.rename")}>
onClick={() => startEdit(p)} <Pencil />
title={t("downloads.actions.rename")} </IconButton>
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg" <IconButton
> variant="danger"
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onDelete(p)} onClick={() => onDelete(p)}
title={t("downloads.profiles.delete")} label={t("downloads.profiles.delete")}
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-danger"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 />
</button> </IconButton>
</div> </div>
))} ))}
</div> </div>
+17 -4
View File
@@ -4,6 +4,8 @@ import { Bookmark, Check, Settings2 } from "lucide-react";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useSavedViews } from "./savedViewsContext"; 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 // 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 // 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"); const label = ariaLabel ?? t("views.saved");
return ( return (
<> <>
<button {/* A caller-shaped trigger (the toolbar's view chip) is a surface; the default one a glyph. */}
type="button" {className ? (
<Pressable
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
aria-label={label} aria-label={label}
className={className ?? "shrink-0 flex text-accent transition hover:text-fg"} className={className}
> >
{trigger} {trigger}
</button> </Pressable>
) : (
<IconButton
variant="ghost-accent"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
label={label}
>
{trigger}
</IconButton>
)}
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
ariaLabel={label} ariaLabel={label}
+8 -12
View File
@@ -8,8 +8,9 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import { useSearchFocusKey } from "../lib/keymap"; import { useSearchFocusKey } from "../lib/keymap";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { PaletteKeycap } from "./PaletteLauncher"; 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 // 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 // (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" className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
/> />
{value && ( {value && (
<button <IconButton onClick={() => onChange("")} label={t("header.clearSearch")}>
onClick={() => onChange("")} <X />
title={t("header.clearSearch")} </IconButton>
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>
)} )}
<PaletteKeycap /> <PaletteKeycap />
{onGo && ( {onGo && (
<FocusSafeButton <Button
size="sm"
onClick={fire} onClick={fire}
disabled={goDisabled} disabled={goDisabled}
title={goTitle ?? goLabel ?? t("header.goTip")} 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} {goIcon}
<span className={goIcon ? "hidden md:inline" : undefined}> <span className={goIcon ? "hidden md:inline" : undefined}>
{goLabel ?? t("header.go")} {goLabel ?? t("header.go")}
</span> </span>
</FocusSafeButton> </Button>
)} )}
</div> </div>
{below} {below}
+9 -14
View File
@@ -6,7 +6,6 @@ import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { inputCls } from "./ui/form"; 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 // 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 // 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"> <div className="flex items-center justify-between mt-6">
<FocusSafeButton <Button
variant="glass"
icon={<ChevronLeft />}
onClick={() => go(-1)} onClick={() => go(-1)}
disabled={stepIdx === 0 || busy} 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")} {t("setup.nav.back")}
</FocusSafeButton> </Button>
<FocusSafeButton <Button
onClick={next} onClick={next}
disabled={busy} loading={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" 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" ? t("setup.nav.finish") : t("setup.nav.next")}
{step !== "finish" && !busy && <ChevronRight className="w-4 h-4" />} {step !== "finish" && !busy && <ChevronRight className="w-4 h-4" />}
</FocusSafeButton> </Button>
</div> </div>
</div> </div>
</div> </div>
+19 -20
View File
@@ -3,7 +3,6 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import clsx from "clsx";
import Modal from "./ui/Modal"; import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext"; import { useConfirm } from "./confirmContext";
import { notify } from "../lib/notifications"; 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 { inputCls } from "./ui/form";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
import { FocusSafeButton } from "./ui/FocusSafeButton"; import { Pressable } from "./ui/Pressable";
const EXPIRY_DAYS = [null, 1, 7, 30] as const; const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) ----------------------------------------------------- // --- share with a registered user (ACL) -----------------------------------------------------
@@ -72,15 +71,16 @@ function UserShare({ job }: { job: DownloadJob }) {
{open && filtered.length > 0 && ( {open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden"> <div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => ( {filtered.map((u) => (
<FocusSafeButton <Pressable
key={u.id} key={u.id}
layer
onClick={() => share.mutate(u.email)} onClick={() => share.mutate(u.email)}
disabled={share.isPending} 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> <span className="truncate">{u.display_name || u.email}</span>
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>} {u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
</FocusSafeButton> </Pressable>
))} ))}
</div> </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" 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())} onClick={() => savePass.mutate(newPass.trim())}
disabled={savePass.isPending || !newPass.trim()} loading={savePass.isPending}
className="px-2 py-1 rounded-md text-xs font-semibold btn-primary disabled:opacity-40 transition" disabled={!newPass.trim()}
> >
{t("downloads.share.savePassword")} {t("downloads.share.savePassword")}
</FocusSafeButton> </Button>
{link.has_password && ( {link.has_password && (
<FocusSafeButton <Button
variant="outline-danger"
size="xs"
onClick={async () => { onClick={async () => {
if ( if (
await confirm({ await confirm({
@@ -248,10 +251,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
savePass.mutate(""); savePass.mutate("");
}} }}
disabled={savePass.isPending} disabled={savePass.isPending}
className="px-2 py-1 rounded-md text-xs text-muted hover:text-danger transition"
> >
{t("downloads.share.removePassword")} {t("downloads.share.removePassword")}
</FocusSafeButton> </Button>
)} )}
</div> </div>
)} )}
@@ -336,20 +338,17 @@ function LinkShare({ job }: { job: DownloadJob }) {
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="text-muted">{t("downloads.share.expiry")}:</span> <span className="text-muted">{t("downloads.share.expiry")}:</span>
{EXPIRY_DAYS.map((d) => ( {EXPIRY_DAYS.map((d) => (
<button <Button
key={String(d)} key={String(d)}
variant="outline"
size="xs"
pressed={expiryDays === d}
onClick={() => setExpiryDays(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 {d === null
? t("downloads.share.expiryNever") ? t("downloads.share.expiryNever")
: t("downloads.share.days", { n: d })} : t("downloads.share.days", { n: d })}
</button> </Button>
))} ))}
</div> </div>
<input <input
+23 -18
View File
@@ -16,7 +16,8 @@ import Tabs from "./ui/Tabs";
import { Section, SettingRow } from "./ui/form"; import { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
import { notifyActionError } from "../lib/actionErrors"; 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; const RANGES = [7, 30, 90] as const;
type StatsTab = "overview" | "system"; type StatsTab = "overview" | "system";
@@ -175,26 +176,28 @@ function Overview({ me }: { me: Me }) {
{!me.is_demo && ( {!me.is_demo && (
<Section title={t("stats.sync.actions")}> <Section title={t("stats.sync.actions")}>
<div className="flex flex-wrap gap-2">
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}> <Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
<FocusSafeButton <Button
variant="glass"
icon={<RefreshCw />}
loading={syncSubs.isPending}
onClick={() => syncSubs.mutate()} 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")} {t("stats.sync.syncSubscriptions")}
</FocusSafeButton> </Button>
</Tooltip> </Tooltip>
<Tooltip hint={t("stats.sync.backfillEverythingHint")}> <Tooltip hint={t("stats.sync.backfillEverythingHint")}>
<FocusSafeButton <Button
variant="glass"
icon={<History />}
loading={deepAll.isPending}
onClick={() => deepAll.mutate()} 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")} {t("stats.sync.backfillEverything")}
</FocusSafeButton> </Button>
</Tooltip> </Tooltip>
</div>
</Section> </Section>
)} )}
</> </>
@@ -227,15 +230,16 @@ function SystemStats() {
{s && ( {s && (
<Section title={t("stats.sync.admin")}> <Section title={t("stats.sync.admin")}>
<Tooltip hint={t("stats.sync.adminPauseHint")}> <Tooltip hint={t("stats.sync.adminPauseHint")}>
<button <Button
variant="glass"
icon={s.paused ? <Play /> : <Pause />}
loading={pauseResume.isPending}
onClick={() => pauseResume.mutate(s.paused)} 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 {s.paused
? t("stats.sync.resumeBackgroundSync") ? t("stats.sync.resumeBackgroundSync")
: t("stats.sync.pauseBackgroundSync")} : t("stats.sync.pauseBackgroundSync")}
</button> </Button>
</Tooltip> </Tooltip>
</Section> </Section>
)} )}
@@ -321,9 +325,10 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const isSystem = row.user_id === null; const isSystem = row.user_id === null;
return ( return (
<div className="glass-card rounded-xl p-3"> <div className="glass-card rounded-xl p-3">
<button <Pressable
onClick={() => setOpen((o) => !o)} 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" : ""}`}> <span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
{isSystem ? t("stats.system") : row.email} {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"> <span className="text-sm font-semibold tabular-nums shrink-0">
{formatNumber(row.total)} {formatNumber(row.total)}
</span> </span>
</button> </Pressable>
{/* Proportion bar */} {/* Proportion bar */}
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden"> <div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
<div <div
+15 -13
View File
@@ -8,7 +8,9 @@ import { notifyActionError } from "../lib/actionErrors";
import { formatViews } from "../lib/format"; import { formatViews } from "../lib/format";
import { PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; 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* // 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 // 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) && ( const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
<FocusSafeButton <IconButton
onClick={() => toggle.mutate()} onClick={() => toggle.mutate()}
disabled={toggle.isPending} loading={toggle.isPending}
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")} label={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
className="p-1 rounded-md hover:bg-card hover:text-fg transition"
> >
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />} {data.paused ? <Play /> : <Pause />}
</FocusSafeButton> </IconButton>
); );
// Chip: the headline count rides in the header; the state, the history link and pause live in a // 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")}`; )} ${t("header.sync.total")}`;
return ( return (
<div className="pointer-events-auto shrink-0"> <div className="pointer-events-auto shrink-0">
<button <Pressable
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
// Short label only: the popover already explains the numbers, and the long one overflowed // Short label only: the popover already explains the numbers, and the long one overflowed
@@ -134,7 +135,7 @@ export default function SyncStatus({
{needsAttention && ( {needsAttention && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" /> <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
)} )}
</button> </Pressable>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
@@ -161,17 +162,18 @@ export default function SyncStatus({
)} )}
{notFull > 0 && ( {notFull > 0 && (
<div className="flex items-center justify-between gap-1.5 pt-2"> <div className="flex items-center justify-between gap-1.5 pt-2">
<button <Button
variant="link-muted"
icon={<History className="size-3.5!" />}
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
onGoToFullHistory(); onGoToFullHistory();
}} }}
title={t("header.sync.fullHistoryTooltip")} 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 })} {t("header.sync.withoutFullHistory", { count: notFull })}
</button> </Button>
{!showMain && pauseBtn} {!showMain && pauseBtn}
</div> </div>
)} )}
+11 -21
View File
@@ -2,7 +2,8 @@ import { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../lib/chipOverflow"; import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit"; 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 { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
@@ -20,16 +21,11 @@ export interface FilterChip {
onToggle: () => void; onToggle: () => void;
} }
function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) { function TagChip({ chip }: { chip: FilterChip }) {
return ( return (
<button <Chip onClick={chip.onToggle} pressed={chip.active}>
type="button"
onClick={chip.onToggle}
aria-pressed={chip.active}
className={`${filterChipClass}${extra ? ` ${extra}` : ""}`}
>
{chip.label} {chip.label}
</button> </Chip>
); );
} }
@@ -103,28 +99,22 @@ export default function TagFilterChips({
}} }}
className="inline-flex" className="inline-flex"
> >
<span className={filterChipClass}>{c.label}</span> <span className={chipClass()}>{c.label}</span>
</span> </span>
))} ))}
<span ref={pillRef} className={filterChipClass}> <span ref={pillRef} className={chipClass()}>
+{chips.length} +{chips.length}
</span> </span>
</div> </div>
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden"> <div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => ( {chips.slice(0, visible).map((c) => (
<Chip key={c.key} chip={c} extra="shrink-0" /> <TagChip key={c.key} chip={c} />
))} ))}
{hidden > 0 && ( {hidden > 0 && (
<button <Chip ref={pop.refs.setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
ref={pop.refs.setReference}
type="button"
aria-label={moreLabel}
className={`${filterChipClass} shrink-0 ${open ? "border-accent text-accent" : ""}`}
{...pop.getReferenceProps()}
>
+{hidden} +{hidden}
</button> </Chip>
)} )}
</div> </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]" 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) => ( {chips.slice(visible).map((c) => (
<Chip key={c.key} chip={c} /> <TagChip key={c.key} chip={c} />
))} ))}
</PopoverSurface> </PopoverSurface>
</div> </div>
+9 -11
View File
@@ -12,6 +12,8 @@ import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext"; import { useConfirm } from "./confirmContext";
import { PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; 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 // 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 // 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 })} {t("tagManager.channels", { count: tag.channel_count })}
</span> </span>
<button <IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
onClick={onDelete} <Trash2 />
title={t("tagManager.delete")} </IconButton>
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>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]" 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" className="max-h-72 overflow-y-auto no-scrollbar"
> >
{channels.map((ch) => ( {channels.map((ch) => (
<button <Pressable
key={ch.id} key={ch.id}
layer
onClick={() => onPickChannel(ch.title)} 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} {ch.title}
</button> </Pressable>
))} ))}
</div> </div>
</PopoverSurface> </PopoverSurface>
+16 -13
View File
@@ -5,6 +5,8 @@ import { Play, X } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { plexLang } from "../i18n"; import { plexLang } from "../i18n";
import { qk } from "../lib/queryKeys"; 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 // 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 // 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> <div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
{sub && <div className="truncate text-xs text-white/60">{sub}</div>} {sub && <div className="truncate text-xs text-white/60">{sub}</div>}
<div className="mt-auto flex items-center gap-2 pt-2.5"> <div className="mt-auto flex items-center gap-2 pt-2.5">
<button <Button size="sm" onMedia onClick={onAdvance} className="text-xs font-semibold">
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"
>
<span className="relative grid h-5.5 w-5.5 flex-none place-items-center"> <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"> <svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
<circle <circle
@@ -114,22 +113,26 @@ export function UpNextCard({
</span> </span>
{t("plex.player.upNext.playNow")} {t("plex.player.upNext.playNow")}
<span className="tabular-nums">{secs}</span> <span className="tabular-nums">{secs}</span>
</button> </Button>
<button <Button
variant="overlay"
size="sm"
onMedia
onClick={onCancel} 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")} {t("plex.player.upNext.cancel")}
</button> </Button>
</div> </div>
</div> </div>
<button <IconButton
variant="overlay"
onClick={onCancel} onClick={onCancel}
aria-label={t("plex.player.upNext.cancel")} 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" className="absolute right-1 top-1"
> >
<X className="h-4 w-4" /> <X />
</button> </IconButton>
</div> </div>
); );
} }
+4 -4
View File
@@ -14,7 +14,7 @@ import { navigateTo } from "../lib/nav";
import { formatBytes } from "../lib/format"; import { formatBytes } from "../lib/format";
import { DISPLAY_NAME_MAX } from "../lib/api/downloads"; import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
import { inputCls } from "./ui/form"; 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 // 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 // (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> <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. */} {/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
<FocusSafeButton <Pressable
type="button" align="center"
onClick={pick} onClick={pick}
onDragEnter={(e) => { onDragEnter={(e) => {
e.preventDefault(); e.preventDefault();
@@ -158,7 +158,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
</span> </span>
</> </>
)} )}
</FocusSafeButton> </Pressable>
<input <input
ref={inputRef} ref={inputRef}
type="file" 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. // overlay for the thumbnailed views, and inline beside the actions for the one-line row.
const INFO_REVEAL = const INFO_REVEAL =
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity"; "reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
const INFO_OVERLAY = clsx( const INFO_OVERLAY = clsx(INFO_REVEAL, "absolute bottom-1.5 left-1.5 z-10");
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");
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click // 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. // 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 {/* 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 corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the
rest of the hover chrome, dropped on touch. */} 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). */} {/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && ( {pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40"> <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. */} budget). ROW_COL.actions already counts its width. */}
<div className="shrink-0 flex items-center gap-1"> <div className="shrink-0 flex items-center gap-1">
<div className="w-48">{actions}</div> <div className="w-48">{actions}</div>
<VideoInfoCard video={video} className={INFO_INLINE} /> <VideoInfoCard video={video} className={INFO_REVEAL} />
</div> </div>
{/* Everything else is shrink-0, so the title is the only thing that can give — which means {/* 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 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 Avatar from "./ui/Avatar";
import { PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; 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) // 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 // 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. // so the trigger deliberately lives OUTSIDE it.
export default function VideoInfoCard({ export default function VideoInfoCard({
video, video,
onMedia = false,
className, className,
}: { }: {
video: Video; video: Video;
/** Trigger-button classes (placement + hover-reveal + skin). The reveal/touch-suppression rules /** The trigger sits on the thumbnail: the round, dark translucent IconButton (A2). */
* live here so each host can match its surface (overlay vs inline). */ 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; className?: string;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -90,8 +94,8 @@ export default function VideoInfoCard({
return ( return (
<> <>
<button <IconButton
type="button" variant={onMedia ? "media" : "ghost"}
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must // 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
}} }}
aria-label={t("card.info")} label={t("card.info")}
title={t("card.info")}
className={className} className={className}
> >
<Info className="w-4 h-4" /> <Info />
</button> </IconButton>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
ariaLabel={t("card.info")} 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 clsx from "clsx";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
export interface ViewOption<T extends string> { export interface ViewOption<T extends string> {
id: T; id: T;
@@ -59,7 +60,7 @@ export default function ViewSwitcher<T extends string>({
return ( return (
<> <>
<button <Pressable
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
// The aria-label carries the mode's name at every size — the visible text below is a // 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 <ChevronDown
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`} className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`}
/> />
</button> </Pressable>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
ariaLabel={label} ariaLabel={label}
+8 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react"; import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { formatBytes } from "../lib/format"; import { formatBytes } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay"; import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { LS, readJSON, writeJSON } from "../lib/storage"; import { LS, readJSON, writeJSON } from "../lib/storage";
@@ -385,14 +386,15 @@ export default function WatchPage() {
<div className="flex shrink-0 items-center gap-2"> <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 {/* 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, whose variants differ only in colour. */}
<button <Chip
type="button" size="md"
aria-pressed={repeat} pressed={repeat}
icon={<Repeat className="size-3.5" aria-hidden />}
onClick={toggleRepeat} 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} {T.repeat}
</button> </Chip>
{meta.allow_download && ( {meta.allow_download && (
<a <a
href={meta.file_url} 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 { api, type FeedFilters, type YearCount } from "../lib/api";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; 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 CountBadge from "./ui/CountBadge";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
@@ -111,31 +112,23 @@ export default function YearFilter({
{quick.map((y) => { {quick.map((y) => {
const on = selected === y.year; const on = selected === y.year;
return ( return (
<button <Chip key={y.year} pressed={on} onClick={() => pick(on ? undefined : y.year)}>
key={y.year}
type="button"
aria-pressed={on}
onClick={() => pick(on ? undefined : y.year)}
className={filterChipClass}
>
<span className="tabular-nums">{y.year}</span> <span className="tabular-nums">{y.year}</span>
{y.count > 0 && <CountBadge count={y.count} active={on} />} {y.count > 0 && <CountBadge count={y.count} active={on} />}
</button> </Chip>
); );
})} })}
{years.length > quick.length && ( {years.length > quick.length && (
<> <>
<button <Chip
type="button"
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
aria-label={t("sidebar.year.moreLabel")} aria-label={t("sidebar.year.moreLabel")}
className={filterChipClass}
> >
<span>{t("sidebar.year.more")}</span> <span>{t("sidebar.year.more")}</span>
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" /> <ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
</button> </Chip>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
@@ -151,19 +144,15 @@ export default function YearFilter({
["sortCount", true], ["sortCount", true],
] as const ] as const
).map(([key, v]) => ( ).map(([key, v]) => (
<button <Button
key={key} key={key}
type="button" variant="outline"
aria-pressed={sortByCount === v} size="xs"
pressed={sortByCount === v}
onClick={() => setSortByCount(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)} {t("sidebar.year." + key)}
</button> </Button>
))} ))}
</div> </div>
</div> </div>
+18 -22
View File
@@ -4,7 +4,7 @@ import type { ManagedChannel } from "../lib/api";
import { formatNumber } from "../lib/format"; import { formatNumber } from "../lib/format";
import Tooltip from "./ui/Tooltip"; import Tooltip from "./ui/Tooltip";
import { isFullySynced } from "./channelSync"; 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 // 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). // channelColumns so that file holds only the column factory and its predicates (Fast Refresh).
@@ -67,39 +67,35 @@ export function ActionsCell({
return ( return (
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}> <Tooltip hint={backfillHint}>
<FocusSafeButton <IconButton
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep} onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled} disabled={!enabled}
className={`shrink-0 transition ${ variant={requested ? "ghost-accent" : "ghost"}
requested label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
? "text-accent" noTitle
: enabled
? "text-muted hover:text-fg"
: "text-muted/40 cursor-default"
}`}
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
> >
<History className="w-4 h-4" /> <History />
</FocusSafeButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}> <Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button <IconButton
onClick={onHide} onClick={onHide}
className="text-muted hover:text-fg shrink-0" label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
aria-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" />} {c.hidden ? <EyeOff /> : <Eye />}
</button> </IconButton>
</Tooltip> </Tooltip>
{canWrite && ( {canWrite && (
<Tooltip hint={t("channels.row.unsubscribeHint")}> <Tooltip hint={t("channels.row.unsubscribeHint")}>
<button <IconButton
variant="danger"
onClick={onUnsubscribe} onClick={onUnsubscribe}
className="text-muted hover:text-danger shrink-0" label={t("channels.row.unsubscribeOnYoutube")}
aria-label={t("channels.row.unsubscribeOnYoutube")} noTitle
> >
<UserMinus className="w-4 h-4" /> <UserMinus />
</button> </IconButton>
</Tooltip> </Tooltip>
)} )}
</div> </div>
@@ -1,5 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { Pressable } from "./ui/Pressable";
// Turn a video description into clickable nodes: // Turn a video description into clickable nodes:
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player // - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
@@ -78,7 +79,7 @@ export function renderDescription(
if (yt) { if (yt) {
const sameVideo = yt.id === opts.currentId; const sameVideo = yt.id === opts.currentId;
out.push( out.push(
<button <Pressable
key={key++} key={key++}
onClick={() => onClick={() =>
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start) 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")} title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
> >
{href} {href}
</button>, </Pressable>,
); );
} else { } else {
out.push( out.push(
@@ -133,13 +134,13 @@ export function renderDescription(
// alternative — the timestamp group — is the one that fired. // alternative — the timestamp group — is the one that fired.
const ts = m[3]!; const ts = m[3]!;
out.push( out.push(
<button <Pressable
key={key++} key={key++}
onClick={() => opts.onSeek(tsToSeconds(ts))} onClick={() => opts.onSeek(tsToSeconds(ts))}
className="text-accent hover:underline" className="text-accent hover:underline"
> >
{ts} {ts}
</button>, </Pressable>,
); );
} }
last = m.index + m[0].length; 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 { Button } from "./ui/Button";
import { StatusPill } from "./downloadRow"; import { StatusPill } from "./downloadRow";
import { jobTitle } from "./downloadTitle"; 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 // 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). // 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 && ( {open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden"> <div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => ( {filtered.map((u) => (
<button <Pressable
key={u.id} key={u.id}
layer
onClick={() => { onClick={() => {
onPick({ id: u.id, email: u.email }); onPick({ id: u.id, email: u.email });
setQ(""); setQ("");
setOpen(false); 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> <span className="truncate">{u.display_name || u.email}</span>
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>} {u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
</button> </Pressable>
))} ))}
</div> </div>
)} )}
@@ -225,13 +228,12 @@ export function AdminSystem() {
> >
<span className="flex-1 truncate">{u.email}</span> <span className="flex-1 truncate">{u.email}</span>
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span> <span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
<button <IconButton
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })} onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg" label={t("downloads.admin.editQuota")}
title={t("downloads.admin.editQuota")}
> >
<Settings2 className="w-4 h-4" /> <Settings2 />
</button> </IconButton>
</div> </div>
))} ))}
{s.per_user.length === 0 && ( {s.per_user.length === 0 && (
+8 -9
View File
@@ -95,12 +95,9 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
</div> </div>
))} ))}
</div> </div>
<button <Button variant="link-muted" icon={<Plus />} onClick={addLink} className="mt-2 text-sm">
onClick={addLink} {t("downloads.edit.addLink")}
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition" </Button>
>
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
</button>
</div> </div>
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p> <p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
<div className="flex justify-end"> <div className="flex justify-end">
@@ -148,12 +145,14 @@ export function ErrorModal({
<p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p> <p className="text-sm text-muted">{t(failCopyKeys(job, "body"))}</p>
{job.error && ( {job.error && (
<div> <div>
<button <Button
variant="link-muted"
aria-expanded={showRaw}
onClick={() => setShowRaw((v) => !v)} onClick={() => setShowRaw((v) => !v)}
className="text-xs text-muted hover:text-fg transition" className="text-xs"
> >
{t("downloads.errors.fail.detailsToggle")} {t("downloads.errors.fail.detailsToggle")}
</button> </Button>
{showRaw && ( {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"> <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} {job.error}
+16 -24
View File
@@ -18,6 +18,8 @@ import {
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { failCopyKeys } from "../lib/downloadFailure"; import { failCopyKeys } from "../lib/downloadFailure";
import { jobTitle } from "./downloadTitle"; 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), // 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, // split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
@@ -136,17 +138,14 @@ export function IconBtn({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<button <IconButton
variant={danger ? "danger" : "ghost"}
onClick={onClick} onClick={onClick}
data-nav-open={open ? "" : undefined} data-nav-open={open ? "" : undefined}
title={title} label={title}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted transition",
danger ? "hover:text-danger" : "hover:text-fg",
)}
> >
{children} {children}
</button> </IconButton>
); );
} }
@@ -182,14 +181,9 @@ function SourceRef({ url }: { url: string }) {
> >
{displayUrl(url)} {displayUrl(url)}
</a> </a>
<button <IconButton onClick={copy} label={t("downloads.source.copy")}>
onClick={copy} <Copy />
aria-label={t("downloads.source.copy")} </IconButton>
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>
</div> </div>
); );
} }
@@ -267,14 +261,14 @@ export function DownloadRow({
</span> </span>
) : null} ) : null}
{job.status === "error" ? ( {job.status === "error" ? (
<button <Pressable
onClick={() => onShowError?.(job)} 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")} title={t("downloads.errors.fail.title")}
> >
· {t(failCopyKeys(job, "short"))} · {t(failCopyKeys(job, "short"))}
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span> <span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
</button> </Pressable>
) : null} ) : null}
</div> </div>
{job.source_url && <SourceRef url={job.source_url} />} {job.source_url && <SourceRef url={job.source_url} />}
@@ -306,12 +300,10 @@ export function DownloadRow({
</div> </div>
)} )}
</div> </div>
{/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared {/* Row action buttons: the md rung here (the row stands alone), the sm rung in the modals, one
IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps IconBtn for both. `shrink-0` + no wrap keeps them on the title's line; the text column
them on the title's line; the text column shrinks instead. */} shrinks instead. */}
<div className="flex items-start gap-1 shrink-0 [&_button]:p-2 [&_svg]:size-4.5"> <div className="flex items-start gap-1 shrink-0 [&_button]:size-8">{children}</div>
{children}
</div>
</div> </div>
); );
} }
+31 -38
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react"; import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
import type { AutoMode, LoopMode } from "../lib/playerModes"; 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 — // 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. // 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 { t } = useTranslation();
const docked = variant === "docked"; const docked = variant === "docked";
const navBtn = docked // The docked bar sits on the video (always dark): the overlay variant; the modal's is a page surface.
? "inline-flex items-center gap-1 text-white/80 enabled:not-aria-disabled:hover:text-white disabled:opacity-30 transition" const chrome = docked ? "overlay" : "ghost";
: "inline-flex items-center gap-1.5 text-muted enabled:not-aria-disabled:hover:text-fg disabled:opacity-40 transition";
const navLabel = docked ? "hidden sm:inline" : ""; const navLabel = docked ? "hidden sm:inline" : "";
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums"; 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 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" : ""; const toggleLabel = docked ? "hidden md:inline" : "";
return ( return (
<> <>
<FocusSafeButton <Button
variant={chrome}
size="sm"
icon={<SkipBack />}
onClick={onPrev} onClick={onPrev}
disabled={index === 0} disabled={index === 0}
label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`} title={`${t("player.previous")} · Shift+←`}
className={navBtn}
> >
<SkipBack className="h-4 w-4" />
<span className={navLabel}>{t("player.previous")}</span> <span className={navLabel}>{t("player.previous")}</span>
</FocusSafeButton> </Button>
<span className={counter}> <span className={counter}>
{index + 1} / {queueLength} {index + 1} / {queueLength}
</span> </span>
<FocusSafeButton <Button
variant={chrome}
size="sm"
onClick={onNext} onClick={onNext}
disabled={index === queueLength - 1} disabled={index === queueLength - 1}
label={t("player.next")}
title={`${t("player.next")} · Shift+→`} title={`${t("player.next")} · Shift+→`}
className={navBtn}
> >
<span className={navLabel}>{t("player.next")}</span> <span className={navLabel}>{t("player.next")}</span>
<SkipForward className="h-4 w-4" /> <SkipForward />
</FocusSafeButton> </Button>
<span className={divider} /> <span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */} {/* 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} onClick={onCycleAuto}
label={`${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`}
title={t("player.autoAdvance.hint")} 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}> <span className={toggleLabel}>
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)} {t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span> </span>
</button> </Button>
<button <Button
variant={chrome}
size="sm"
pressed={loopMode !== "off"}
icon={loopMode === "one" ? <Repeat1 /> : <Repeat />}
onClick={onCycleLoop} onClick={onCycleLoop}
label={`${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`}
title={t("player.loop.hint")} 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}> <span className={toggleLabel}>
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)} {t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span> </span>
</button> </Button>
</> </>
); );
} }
+12 -10
View File
@@ -3,6 +3,8 @@ import type { TFunction } from "i18next";
import type { Video } from "../lib/api"; import type { Video } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import type { Column } from "../lib/tableColumns"; 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`. // Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
// //
@@ -56,7 +58,7 @@ export function playlistColumns({
cardPrimary: true, cardPrimary: true,
render: (v) => ( render: (v) => (
<div className="flex items-center gap-2.5 min-w-0"> <div className="flex items-center gap-2.5 min-w-0">
<button <Pressable
onClick={() => onPlay(v)} onClick={() => onPlay(v)}
aria-label={`${t("card.play")} — ${v.title}`} aria-label={`${t("card.play")} — ${v.title}`}
className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group" 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"> <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" /> <Play className="w-4 h-4 text-white fill-current" />
</span> </span>
</button> </Pressable>
<button <Pressable
onClick={() => onPlay(v)} onClick={() => onPlay(v)}
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it. // The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
data-nav-open="" 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> <span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
</button> </Pressable>
</div> </div>
), ),
}, },
@@ -129,14 +131,14 @@ export function playlistColumns({
width: "2.5rem", width: "2.5rem",
nowrap: true, nowrap: true,
render: (v) => ( render: (v) => (
<button <IconButton
variant="danger"
onClick={() => onRemove(v)} onClick={() => onRemove(v)}
label={t("playlists.removeItemOf", { title: v.title })}
title={t("playlists.removeItem")} 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" /> <X />
</button> </IconButton>
), ),
}); });
} }
+13 -19
View File
@@ -12,7 +12,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import PosterTile from "./PosterTile"; import PosterTile from "./PosterTile";
import PlexLibraryPicker from "./PlexLibraryPicker"; import PlexLibraryPicker from "./PlexLibraryPicker";
import NavGrid from "./ui/NavGrid"; 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 // A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime). // collections have no movie runtime).
@@ -109,13 +110,9 @@ function NewCollectionPanel({
<div className="glass-card mb-6 rounded-2xl p-4"> <div className="glass-card mb-6 rounded-2xl p-4">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3> <h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
<button <IconButton size="md" onClick={onClose} label={t("common.close")}>
onClick={onClose} <X />
aria-label={t("common.close")} </IconButton>
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
>
<X className="h-4 w-4" />
</button>
</div> </div>
<input <input
autoFocus autoFocus
@@ -146,14 +143,15 @@ function NewCollectionPanel({
term={term} term={term}
enabled={nameOk} enabled={nameOk}
renderAction={(c) => ( renderAction={(c) => (
<FocusSafeButton <Button
variant="glass"
size="xs"
icon={<Check />}
onClick={() => create(c)} onClick={() => create(c)}
disabled={busy} loading={busy}
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
> >
<Check className="h-3.5 w-3.5" />
{t("plex.collection.createWith")} {t("plex.collection.createWith")}
</FocusSafeButton> </Button>
)} )}
/> />
</div> </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" 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 && ( {isAdmin && !creating && (
<button <Button variant="glass" size="sm" icon={<Plus />} onClick={() => setCreating(true)}>
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" />
{t("plex.collection.newCollection")} {t("plex.collection.newCollection")}
</button> </Button>
)} )}
</div> </div>
{creating && ( {creating && (
+14 -10
View File
@@ -1,7 +1,7 @@
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { RotateCcw } from "lucide-react"; 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 // ±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 // muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
@@ -82,13 +82,14 @@ export function Slider({
{suffix ?? ""} {suffix ?? ""}
</span> </span>
{onReset && ( {onReset && (
<button <IconButton
variant="overlay"
onClick={onReset} onClick={onReset}
className="text-white/40 hover:text-white" label={t("common.resetNamed", { name: label })}
aria-label={t("common.resetNamed", { name: label })} className="-my-1.5"
> >
<RotateCcw className="h-3 w-3" /> <RotateCcw className="size-3.5!" />
</button> </IconButton>
)} )}
</span> </span>
</div> </div>
@@ -146,14 +147,17 @@ export function Ctrl({
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2"; align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return ( return (
<div className="relative flex group/ctrl"> <div className="relative flex group/ctrl">
<FocusSafeButton <IconButton
variant="overlay"
size="player"
onMedia
noTitle
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
aria-label={label} label={label}
className="p-1 hover:text-accent disabled:opacity-30"
> >
{children} {children}
</FocusSafeButton> </IconButton>
<span <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`} 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 PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid"; 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 // 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, // shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
@@ -222,12 +224,9 @@ export function PlexPersonView({
</p> </p>
)} )}
</div> </div>
<button <Chip onClick={() => onFilter({ actors: [d.name] })} className="font-medium">
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"
>
{t("plex.person.filterBy", { name: d.name })} {t("plex.person.filterBy", { name: d.name })}
</button> </Chip>
</div> </div>
</div> </div>
{d.biography && ( {d.biography && (
@@ -428,7 +427,7 @@ export function PlexShowView({
if (!badge) return null; if (!badge) return null;
const { score } = badge; const { score } = badge;
return ( return (
<button <Pressable
onClick={ onClick={
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
} }
@@ -437,7 +436,7 @@ export function PlexShowView({
{/* decorative rating gold — intentionally NOT a status token */} {/* decorative rating gold — intentionally NOT a status token */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" /> · <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{score.toFixed(1)} {score.toFixed(1)}
</button> </Pressable>
); );
})()} })()}
</div> </div>
@@ -645,12 +644,9 @@ export function PlexSeasonView({
/> />
} }
> >
<button <Button variant="link-muted" onClick={onBack} className="text-sm">
onClick={onBack}
className="text-sm text-muted hover:text-accent transition text-left"
>
{d.show.title} {d.show.title}
</button> </Button>
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1> <h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
<p className="text-sm text-muted mt-1"> <p className="text-sm text-muted mt-1">
{t("plex.series.episodeCount", { count: se.episode_count })} {t("plex.series.episodeCount", { count: se.episode_count })}
+18 -7
View File
@@ -2,6 +2,8 @@ import { Fragment, useState, type ReactNode } from "react";
import { ChevronRight, MoreHorizontal } from "lucide-react"; import { ChevronRight, MoreHorizontal } from "lucide-react";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks"; 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. // 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 // 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" }); const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
return ( return (
<> <>
<button {/* A caller-shaped trigger (a labelled pill) is a surface; the default "…" is a glyph. */}
type="button" {className ? (
<Pressable
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
aria-label={ariaLabel} aria-label={ariaLabel}
title={ariaLabel} title={ariaLabel}
className={ className={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" />} {trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
</button> </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}> <PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
<MenuItemList items={items} onClose={() => setOpen(false)} /> <MenuItemList items={items} onClose={() => setOpen(false)} />
</PopoverSurface> </PopoverSurface>
+4 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { ArrowUp } from "lucide-react"; import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next"; 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 // 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 // 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"]. // 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. // The accessible name comes from a visually-hidden child instead, which those selectors can't match.
return createPortal( return createPortal(
<button <Pressable
align="center"
onClick={toTop} 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 ${ 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" 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" /> <ArrowUp className="w-7 h-7" aria-hidden="true" />
<span className="sr-only">{t("common.backToTop")}</span> <span className="sr-only">{t("common.backToTop")}</span>
</button>, </Pressable>,
document.body, document.body,
); );
} }
+10 -7
View File
@@ -2,20 +2,21 @@ import type { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { Button } from "./Button";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
type BannerTone = "accent" | "warning"; 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: { accent: {
bar: "bg-accent/15 border-accent/30", bar: "bg-accent/15 border-accent/30",
icon: "text-accent", icon: "text-accent",
action: "btn-primary", action: "primary",
}, },
warning: { warning: {
bar: "bg-warn/15 border-warn/30", bar: "bg-warn/15 border-warn/30",
icon: "text-warn", 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}`} /> <Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
<span className="min-w-0">{children}</span> <span className="min-w-0">{children}</span>
{action && ( {action && (
<button <Button
variant={c.action}
size="sm"
onClick={action.onClick} 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} {action.label}
</button> </Button>
)} )}
{onDismiss && ( {onDismiss && (
<IconButton <IconButton
@@ -69,7 +72,7 @@ export default function Banner({
onClick={onDismiss} onClick={onDismiss}
className="ml-auto" className="ml-auto"
> >
<X className="w-4 h-4" /> <X />
</IconButton> </IconButton>
)} )}
</div> </div>
+2
View File
@@ -17,6 +17,7 @@ import { FocusSafeButton } from "./FocusSafeButton";
type ButtonVariant = type ButtonVariant =
| "primary" // solid accent — the main affirmative action | "primary" // solid accent — the main affirmative action
| "danger" // solid danger — destructive confirm | "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" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
| "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve) | "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
| "outline-danger" // neutral at rest, danger on hover — a row-level Delete | "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> = { const VARIANTS: Record<ButtonVariant, string> = {
primary: "font-medium state-filled", primary: "font-medium state-filled",
danger: "font-medium state-danger", 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: `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", "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 // 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 clsx from "clsx";
import { useScrollEdges } from "../../lib/useScrollEdges"; import { useScrollEdges } from "../../lib/useScrollEdges";
import { useListNav } from "../../lib/listNav"; 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 // 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 // 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` // 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. // 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 = const arrowBtn =
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " + "reveal-hover absolute top-1/2 -translate-y-1/2 z-10 backdrop-blur-xs border border-white/15 shadow-lg " +
"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";
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
return ( return (
<div className={clsx("group/car relative", className)}> <div className={clsx("group/car relative", className)}>
@@ -106,27 +108,25 @@ export function Carousel({
<div className="mb-3 flex items-baseline gap-2"> <div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2> <h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && ( {onSeeAll && (
<button <Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
onClick={onSeeAll}
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
>
{seeAllLabel ?? t("common.seeAll")} {seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" /> <ChevronRight className="h-3.5 w-3.5" />
</button> </Button>
)} )}
</div> </div>
)} )}
<div className="relative"> <div className="relative">
{left && ( {left && (
<button <IconButton
type="button" variant="media"
size="md"
onClick={() => page(-1)} onClick={() => page(-1)}
aria-label={t("common.scrollLeft")} label={t("common.scrollLeft")}
className={clsx(arrowBtn, "left-1")} className={clsx(arrowBtn, "left-1")}
> >
<ChevronLeft className="h-5 w-5" /> <ChevronLeft />
</button> </IconButton>
)} )}
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a {/* 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. */} scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
@@ -140,14 +140,15 @@ export function Carousel({
{children} {children}
</div> </div>
{right && ( {right && (
<button <IconButton
type="button" variant="media"
size="md"
onClick={() => page(1)} onClick={() => page(1)}
aria-label={t("common.scrollRight")} label={t("common.scrollRight")}
className={clsx(arrowBtn, "right-1")} className={clsx(arrowBtn, "right-1")}
> >
<ChevronRight className="h-5 w-5" /> <ChevronRight />
</button> </IconButton>
)} )}
</div> </div>
</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 Modal from "./Modal";
import { closeTopOverlayThenRun } from "../../lib/history"; import { closeTopOverlayThenRun } from "../../lib/history";
import { pageStep, pageTarget } from "../../lib/listPaging"; 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 // 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 // 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} {g.label}
</div> </div>
{g.items.map((item) => ( {g.items.map((item) => (
<button <Pressable
key={item.id} key={item.id}
id={`${listId}-${item.id}`} id={`${listId}-${item.id}`}
ref={item.id === activeId ? activeRef : undefined} ref={item.id === activeId ? activeRef : undefined}
data-cmd-id={item.id} data-cmd-id={item.id}
role="option" role="option"
aria-selected={item.id === activeId} aria-selected={item.id === activeId}
type="button"
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over // 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 // these rows. Leaving them tabbable put DOM focus on a row while
// aria-activedescendant named a different one — two competing "current" options — // 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; if (e.movementX === 0 && e.movementY === 0) return;
setPicked(item.id); 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.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
}`} }`}
> >
{item.icon && <span className="shrink-0">{item.icon}</span>} {item.icon && <span className="shrink-0">{item.icon}</span>}
<span className="min-w-0 flex-1 truncate">{item.label}</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>} {item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
</button> </Pressable>
))} ))}
</div> </div>
))} ))}
@@ -4,7 +4,7 @@
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract. // paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract.
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; import FilterChipGroup from "./FilterChipGroup";
const OPTS = [ const OPTS = [
{ value: "all", label: "All", testId: "chip-all" }, { value: "all", label: "All", testId: "chip-all" },
@@ -56,8 +56,12 @@ describe("FilterChipGroup", () => {
expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy(); expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy();
}); });
it("exports the canonical chip class for multi-select siblings to reuse", () => { it("renders its options as Chips: the pill, on the 24px rung", () => {
expect(filterChipClass).toContain("rounded-full"); render(<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync" />);
expect(filterChipClass).toContain("pill"); 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 { type ReactNode } from "react";
import CountBadge from "./CountBadge"; import CountBadge from "./CountBadge";
import { Chip } from "./Chip";
// 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";
export interface FilterChipOption<T extends string> { export interface FilterChipOption<T extends string> {
value: T; 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, // 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>({ export default function FilterChipGroup<T extends string>({
options, options,
value, value,
@@ -39,18 +32,17 @@ export default function FilterChipGroup<T extends string>({
{options.map((o) => { {options.map((o) => {
const on = value === o.value; const on = value === o.value;
return ( return (
<button <Chip
key={o.value} key={o.value}
data-testid={o.testId} data-testid={o.testId}
onClick={() => onChange(o.value)} onClick={() => onChange(o.value)}
title={o.title} title={o.title}
aria-pressed={on} pressed={on}
className={filterChipClass} icon={o.icon}
> >
{o.icon}
<span>{o.label}</span> <span>{o.label}</span>
{o.count != null && o.count > 0 && <CountBadge count={o.count} active={on} />} {o.count != null && o.count > 0 && <CountBadge count={o.count} active={on} />}
</button> </Chip>
); );
})} })}
</div> </div>
+8 -2
View File
@@ -11,12 +11,14 @@ import { FocusSafeButton } from "./FocusSafeButton";
type IconButtonVariant = type IconButtonVariant =
| "ghost" // the default: quiet glyph, a state layer on hover | "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) | "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" // 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-accent" // bordered + accent — an affirmative row action (Approve)
| "outline-danger" // bordered, danger on hover — a row's destructive action beside bordered siblings | "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) | "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" // 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 | "glass" // glass card — beside the glass Buttons on a Plex page
| "media" // round, dark translucent — on a thumbnail or a poster | "media" // round, dark translucent — on a thumbnail or a poster
| "overlay"; // white glyph on the always-dark player chrome, whatever the theme | "overlay"; // white glyph on the always-dark player chrome, whatever the theme
@@ -24,17 +26,20 @@ type IconButtonVariant =
type IconButtonSize = type IconButtonSize =
| "sm" // 28 — rows and dense toolbars | "sm" // 28 — rows and dense toolbars
| "md" // 32 — on its own | "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 ACT = "enabled:not-aria-disabled:";
const VARIANTS: Record<IconButtonVariant, string> = { const VARIANTS: Record<IconButtonVariant, string> = {
ghost: `text-muted state-layer ${ACT}hover:text-fg`, ghost: `text-muted state-layer ${ACT}hover:text-fg`,
"ghost-accent": "text-accent state-layer",
primary: "state-filled", primary: "state-filled",
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`, outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
"outline-accent": "border border-accent/40 text-accent state-layer", "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-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", "outline-warn": "border border-warn/40 text-warn state-layer",
danger: `text-muted state-layer ${ACT}hover:text-danger`, 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`, 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 // 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). // 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", sm: "size-7 [&>svg]:size-4",
md: "size-8 [&>svg]:size-4", md: "size-8 [&>svg]:size-4",
lg: "size-12 [&>svg]:size-6", lg: "size-12 [&>svg]:size-6",
player: "size-9 [&>svg]:size-5",
}; };
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> { export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
@@ -94,7 +100,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
aria-pressed={pressed} aria-pressed={pressed}
title={noTitle ? undefined : label} title={noTitle ? undefined : label}
className={clsx( 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], VARIANTS[variant],
onMedia || variant === "media" ? "rounded-full" : "rounded-lg", onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && PRESSED, pressed !== undefined && PRESSED,
+10 -8
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { hasPagerContent, pageCount } from "./pagerContent"; import { hasPagerContent, pageCount } from "./pagerContent";
import { FocusSafeButton } from "./FocusSafeButton";
import SegmentedToggle from "./SegmentedToggle"; 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 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 + // (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"> <div className="flex items-center gap-3 flex-wrap">
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FocusSafeButton <Button
variant="glass"
size="sm"
icon={<ChevronLeft />}
onClick={() => goToPage(Math.max(0, safePage - 1))} onClick={() => goToPage(Math.max(0, safePage - 1))}
disabled={safePage === 0} 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")} {t("datatable.pager.prev")}
</FocusSafeButton> </Button>
<span className="text-muted flex items-center gap-1.5"> <span className="text-muted flex items-center gap-1.5">
{t("datatable.pager.pageLabel")} {t("datatable.pager.pageLabel")}
<input <input
@@ -89,14 +90,15 @@ export default function Pager({
/> />
{t("datatable.pager.ofTotal", { total: totalPages })} {t("datatable.pager.ofTotal", { total: totalPages })}
</span> </span>
<FocusSafeButton <Button
variant="glass"
size="sm"
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))} onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 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")} {t("datatable.pager.next")}
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</FocusSafeButton> </Button>
</div> </div>
)} )}
{total > 0 && ( {total > 0 && (
+22 -21
View File
@@ -1,8 +1,11 @@
import { type ReactNode, type CSSProperties } from "react"; import { type ReactNode, type CSSProperties } from "react";
import { useTranslation } from "react-i18next"; 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 { CSS } from "@dnd-kit/utilities";
import { useSortableItem } from "./sortableA11y"; 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 // 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 // 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 titleIsTrigger = !editing && !collapsible && !!onTitleClick;
const titleCls = 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 ( return (
<div <div
@@ -58,55 +61,53 @@ export default function PanelGroup({
> >
<div className="flex items-center gap-1.5 px-3 py-2"> <div className="flex items-center gap-1.5 px-3 py-2">
{editing && ( {editing && (
<button <DragHandle
{...attributes} {...attributes}
{...listeners} {...listeners}
title={t("sidebar.dragToReorder")} label={t("sidebar.dragToReorder")}
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none" className="-ml-1"
> />
<GripVertical className="w-4 h-4" />
</button>
)} )}
{collapsible && !editing ? ( {collapsible && !editing ? (
<button <Pressable
onClick={onToggleCollapse} onClick={onToggleCollapse}
aria-expanded={!collapsed}
className={`${titleCls} text-muted hover:text-fg transition`} className={`${titleCls} text-muted hover:text-fg transition`}
> >
<span className="truncate">{title}</span> <span className="truncate">{title}</span>
</button> </Pressable>
) : titleIsTrigger ? ( ) : titleIsTrigger ? (
<button <Pressable
onClick={onTitleClick} onClick={onTitleClick}
title={titleActionLabel} title={titleActionLabel}
className={`${titleCls} group text-muted hover:text-accent transition`} className={`${titleCls} group text-muted hover:text-accent transition`}
> >
<span className="truncate">{title}</span> <span className="truncate">{title}</span>
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" /> <Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
</button> </Pressable>
) : ( ) : (
<span className={`${titleCls} text-muted`}> <span className={`${titleCls} text-muted`}>
<span className="truncate">{title}</span> <span className="truncate">{title}</span>
</span> </span>
)} )}
{editing ? ( {editing ? (
<button <IconButton
onClick={onToggleHidden} onClick={onToggleHidden}
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")} label={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
className="text-muted hover:text-fg"
> >
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} {hidden ? <EyeOff /> : <Eye />}
</button> </IconButton>
) : ( ) : (
collapsible && ( collapsible && (
<button <IconButton
onClick={onToggleCollapse} onClick={onToggleCollapse}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")} aria-expanded={!collapsed}
className="text-muted hover:text-fg" label={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
> >
<ChevronDown <ChevronDown
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`} className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
/> />
</button> </IconButton>
) )
)} )}
</div> </div>
+4 -4
View File
@@ -39,7 +39,7 @@ domain-free.
| area | components | | 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` | | 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` | | 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` | | page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` | | 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 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: ~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 - **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes
rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32). Pick a rung; do not hand-roll are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
padding on a primitive. 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` - **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 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 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 { useScrollFade } from "../../lib/useScrollFade";
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin"; import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
import { IconButton } from "./IconButton"; 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 // 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 // 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 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. 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. */} `opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
<button <Pressable
{...peek.hoverHandlers} {...peek.hoverHandlers}
onClick={onToggleCollapse} onClick={onToggleCollapse}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")} aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
@@ -92,7 +93,7 @@ export default function SidePanel({
> >
{title} {title}
</span> </span>
</button> </Pressable>
{expanded && ( {expanded && (
<aside <aside
@@ -113,14 +114,14 @@ export default function SidePanel({
</IconButton> </IconButton>
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>} {iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{onTitleClick ? ( {onTitleClick ? (
<button <Pressable
onClick={onTitleClick} onClick={onTitleClick}
title={titleActionLabel} title={titleActionLabel}
aria-label={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} {title}
</button> </Pressable>
) : ( ) : (
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]"> <span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
{title} {title}
@@ -145,15 +146,13 @@ export default function SidePanel({
</IconButton> </IconButton>
)} )}
{onToggleEditing && ( {onToggleEditing && (
<button <IconButton
variant={editing ? "ghost-accent" : "ghost"}
onClick={onToggleEditing} onClick={onToggleEditing}
title={editing ? t("sidebar.done") : t("sidebar.customize")} label={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"
}`}
> >
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />} {editing ? <Check /> : <Pencil />}
</button> </IconButton>
)} )}
</div> </div>
+11 -8
View File
@@ -6,6 +6,8 @@ import { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock"; import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder"; import { useOverlayOrder } from "./overlayOrder";
import { LEVEL_STYLE } from "./toastLevels"; 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 // 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 // context (its `@container` implies layout containment) — so anything portalled to <body> at the
@@ -170,24 +172,25 @@ export default function Toaster({
)} )}
</div> </div>
{toast.action && ( {toast.action && (
<button <Button
variant="link"
onClick={() => { onClick={() => {
toast.action!.onClick(); toast.action!.onClick();
dismiss(toast.id); dismiss(toast.id);
}} }}
className="mt-1 text-accent text-sm font-semibold hover:underline" className="mt-1 text-sm"
> >
{toast.action.label} {toast.action.label}
</button> </Button>
)} )}
</div> </div>
<button <IconButton
onClick={() => dismiss(toast.id)} onClick={() => dismiss(toast.id)}
className="shrink-0 text-muted hover:text-fg" label={t("notifications.dismiss")}
title={t("notifications.dismiss")} className="-m-1.5"
> >
<X className="w-4 h-4" /> <X />
</button> </IconButton>
{toast.duration && ( {toast.duration && (
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too. // 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} /> <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 { useTranslation } from "react-i18next";
import { Redo2, Undo2 } from "lucide-react"; import { Redo2, Undo2 } from "lucide-react";
import { dispatch, useKeyScope } from "../../lib/keymap"; 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 // 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 // (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); return () => window.removeEventListener("keydown", onKey);
}, [canUndo, canRedo, onUndo, onRedo]); }, [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 ( return (
<div className="inline-flex items-center gap-1"> <div className="inline-flex items-center gap-1">
<FocusSafeButton <IconButton variant="outline" onClick={onUndo} disabled={!canUndo} label={t("common.undo")}>
onClick={onUndo} <Undo2 />
disabled={!canUndo} </IconButton>
title={t("common.undo")} <IconButton variant="outline" onClick={onRedo} disabled={!canRedo} label={t("common.redo")}>
aria-label={t("common.undo")} <Redo2 />
className={btn} </IconButton>
>
<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>
</div> </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 = 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"; "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 /** 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. */ * the accessible name (the visible row text) since the control itself has no inner text. */
export function Switch({ 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 { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
import { alignClass } from "./tableAlign"; import { alignClass } from "./tableAlign";
import { useStickyTop } from "./stickyTop"; 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 // 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 // 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 ( return (
<> <>
<button <IconButton
variant={filterOn ? "ghost-accent" : "ghost"}
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
aria-label={t("datatable.filter")} label={t("datatable.filter")}
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app. // The icon alone was a 14x14 hit target, the smallest in the app; the sm rung is 28.
className={`-m-1.5 p-1.5 transition ${ className="-my-1.5"
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
}`}
> >
<ListFilter className="w-3.5 h-3.5" /> <ListFilter className="size-3.5!" />
</button> </IconButton>
<PopoverSurface <PopoverSurface
popover={pop} popover={pop}
ariaLabel={col.header} ariaLabel={col.header}
@@ -167,12 +169,13 @@ function FilterFunnel<T>({
</FilterOptionList> </FilterOptionList>
)} )}
{filterOn && ( {filterOn && (
<button <Button
variant="link-muted"
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")} 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")} {t("datatable.clear")}
</button> </Button>
)} )}
</PopoverSurface> </PopoverSurface>
</> </>
@@ -229,7 +232,7 @@ export function ColumnHead<T>({
focusable no-op button, which put unlabeled dead stops in the tab order — 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. */} two of them per playlist table, whose grip and actions headers are empty. */}
{col.sortable ? ( {col.sortable ? (
<button <Pressable
onClick={() => onToggleSort(col.key)} onClick={() => onToggleSort(col.key)}
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer" 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" /> <ArrowDown className="w-3 h-3" />
))} ))}
</button> </Pressable>
) : ( ) : (
<span>{col.header}</span> <span>{col.header}</span>
)} )}
+7 -27
View File
@@ -1,9 +1,9 @@
import { useLayoutEffect, useState } from "react"; import { useLayoutEffect, useState } from "react";
import clsx from "clsx";
import { SCHEMES, type Scheme } from "../lib/theme"; import { SCHEMES, type Scheme } from "../lib/theme";
import { BatchA } from "./batchA"; import { BatchA } from "./batchA";
import { BatchB } from "./batchB"; import { BatchB } from "./batchB";
import { BatchC } from "./batchC"; 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 // 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 / // 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)); 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() { export default function DesignLab() {
const [look, setLook] = useState<Look>(START); const [look, setLook] = useState<Look>(START);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -47,36 +41,22 @@ export default function DesignLab() {
<strong className="text-sm">Design lab</strong> <strong className="text-sm">Design lab</strong>
<span className="flex gap-1"> <span className="flex gap-1">
{(["dark", "light"] as const).map((m) => ( {(["dark", "light"] as const).map((m) => (
<button <Chip key={m} pressed={look.mode === m} onClick={() => set({ mode: m })}>
key={m}
type="button"
className={chip(look.mode === m)}
onClick={() => set({ mode: m })}
>
{m} {m}
</button> </Chip>
))} ))}
</span> </span>
<span className="flex flex-wrap gap-1"> <span className="flex flex-wrap gap-1">
{SCHEMES.map((s) => ( {SCHEMES.map((s) => (
<button <Chip key={s.id} pressed={look.scheme === s.id} onClick={() => set({ scheme: s.id })}>
key={s.id}
type="button"
className={chip(look.scheme === s.id)}
onClick={() => set({ scheme: s.id })}
>
<span className="h-2.5 w-2.5 rounded-full" style={{ background: s.swatch }} /> <span className="h-2.5 w-2.5 rounded-full" style={{ background: s.swatch }} />
{s.name} {s.name}
</button> </Chip>
))} ))}
</span> </span>
<button <Chip pressed={look.backdrop} onClick={() => set({ backdrop: !look.backdrop })}>
type="button"
className={chip(look.backdrop)}
onClick={() => set({ backdrop: !look.backdrop })}
>
backdrop backdrop
</button> </Chip>
<label className="flex items-center gap-2 text-xs text-muted"> <label className="flex items-center gap-2 text-xs text-muted">
text {look.fontScale.toFixed(2)} text {look.fontScale.toFixed(2)}
<input <input
+6 -9
View File
@@ -24,6 +24,7 @@ import { Btn } from "./batchA";
import { hexContrast, hueGap } from "../lib/colorMath"; import { hexContrast, hueGap } from "../lib/colorMath";
import { Reference, Sample } from "./labKit"; import { Reference, Sample } from "./labKit";
import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, FILLED, H, LAYER } from "./labTokens"; 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, // 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 // 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> {s.name} <span className="font-normal text-muted">· {mode}</span>
</span> </span>
<div className="flex flex-wrap gap-1.5"> <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="state-filled 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-danger rounded-lg px-2.5 py-1 text-xs font-medium">Delete</span>
</div> </div>
<div className="flex flex-wrap gap-1.5 text-2xs"> <div className="flex flex-wrap gap-1.5 text-2xs">
<span className="chip px-2 py-0.5">Filter</span> <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 className="text-xs text-muted">Techno Tim</div>
</div> </div>
<span className="flex gap-1.5"> <span className="flex gap-1.5">
<button <Chip size="md" pressed icon={<Repeat className="size-3.5" />} className="font-medium">
type="button" Repeat
className={clsx("pill inline-flex items-center rounded-full border font-medium", H.md)} </Chip>
aria-pressed
>
<Repeat className="h-3.5 w-3.5" /> Repeat
</button>
<Btn tone="glass" size="md" icon={<Download className={ico} />}> <Btn tone="glass" size="md" icon={<Download className={ico} />}>
Download Download
</Btn> </Btn>
@@ -98,7 +98,6 @@
"removeItem": "Remove", "removeItem": "Remove",
"removeItemOf": "Remove “{{title}}” from the playlist", "removeItemOf": "Remove “{{title}}” from the playlist",
"confirmRemove": "Remove “{{title}}” from this playlist?", "confirmRemove": "Remove “{{title}}” from this playlist?",
"dragHandle": "Drag to reorder",
"dragHandleOf": "Reorder “{{title}}”", "dragHandleOf": "Reorder “{{title}}”",
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.", "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.", "dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
@@ -98,7 +98,6 @@
"removeItem": "Eltávolítás", "removeItem": "Eltávolítás",
"removeItemOf": "„{{title}}” eltávolítása a listáról", "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?", "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", "dragHandleOf": "„{{title}}” átrendezése",
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.", "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.", "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)); 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. */ /* A4: a control's ≥ 3:1 edge. */
@utility edge-strong { @utility edge-strong {
border-color: var(--edge-strong); border-color: var(--edge-strong);
@@ -618,30 +628,6 @@ body {
color: var(--accent-fg); color: var(--accent-fg);
border-color: var(--accent); 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) ===== /* ===== 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 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 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);
});
});