From 86f467bd65a9b4401f33d45ebc60f546a26dc0e8 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 24 Sep 2026 05:25:55 +0200
Subject: [PATCH] feat(design): the button families on the decided ladder,
first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a
pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`,
`media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for
the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and
DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held
by theme.contract.test.ts.
Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player
modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor,
Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are
153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is
why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and
the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
---
CLAUDE.md | 4 +-
docs/DESIGN.md | 17 +-
frontend/src/components/AddToPlaylist.tsx | 19 +-
frontend/src/components/AdminUsers.tsx | 176 +++++++++---------
frontend/src/components/ChannelPage.tsx | 13 +-
frontend/src/components/ChannelTags.tsx | 14 +-
frontend/src/components/ChatThread.tsx | 15 +-
frontend/src/components/DownloadButton.tsx | 24 ++-
frontend/src/components/DownloadCenter.tsx | 14 +-
frontend/src/components/Feed.tsx | 62 +++---
frontend/src/components/IssueComposer.tsx | 25 +--
frontend/src/components/Messages.tsx | 23 ++-
frontend/src/components/NavSidebar.tsx | 53 +++---
.../src/components/NotificationsPanel.tsx | 35 +---
.../src/components/PersonalViewsModal.tsx | 83 ++++-----
frontend/src/components/PlayerModal.tsx | 136 ++++++--------
frontend/src/components/Playlists.tsx | 22 +--
frontend/src/components/PlexInfo.tsx | 95 +++++-----
frontend/src/components/PlexPlaylistView.tsx | 148 +++++++--------
frontend/src/components/Scheduler.tsx | 63 +++----
frontend/src/components/SettingsPanel.tsx | 39 ++--
frontend/src/components/ShareDialog.tsx | 41 ++--
frontend/src/components/VideoCard.tsx | 143 +++++++-------
frontend/src/components/VideoEditor.tsx | 91 ++++-----
frontend/src/components/Welcome.tsx | 73 ++++----
frontend/src/components/plexCards.tsx | 78 ++++----
frontend/src/components/plexDetailUi.tsx | 58 +++---
frontend/src/components/ui/Banner.tsx | 15 +-
.../src/components/ui/Button.dom.test.tsx | 86 ++++++---
frontend/src/components/ui/Button.tsx | 173 ++++++++---------
frontend/src/components/ui/DragHandle.tsx | 40 ++++
.../src/components/ui/FocusSafeButton.tsx | 5 +-
.../src/components/ui/IconButton.dom.test.tsx | 138 ++++++++++++++
frontend/src/components/ui/IconButton.tsx | 109 +++++++++++
frontend/src/components/ui/Modal.tsx | 14 +-
frontend/src/components/ui/Pressable.tsx | 35 ++++
frontend/src/components/ui/README.md | 13 +-
frontend/src/components/ui/SidePanel.tsx | 25 ++-
frontend/src/design-lab/batchA.tsx | 89 +++------
frontend/src/design-lab/labTokens.ts | 1 -
frontend/src/index.css | 16 +-
frontend/src/lib/theme.contract.test.ts | 17 ++
42 files changed, 1304 insertions(+), 1036 deletions(-)
create mode 100644 frontend/src/components/ui/DragHandle.tsx
create mode 100644 frontend/src/components/ui/IconButton.dom.test.tsx
create mode 100644 frontend/src/components/ui/IconButton.tsx
create mode 100644 frontend/src/components/ui/Pressable.tsx
diff --git a/CLAUDE.md b/CLAUDE.md
index 846ffe0f..53e6a54f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -81,7 +81,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
-| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
+| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
@@ -92,7 +92,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
-| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `
- : }
onClick={() => pauseResume.mutate(data.paused)}
disabled={pauseResume.isPending}
- className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
- {data.paused ? : }
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
-
+
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index c69407f3..fff99313 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -24,6 +24,7 @@ import { START_ON_HOME } from "./navigationInit";
import { usePrefs } from "./prefsContext";
import Tabs from "./ui/Tabs";
import { useMe } from "../lib/useMe";
+import { Pressable } from "./ui/Pressable";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
@@ -147,7 +148,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
{SCHEMES.map((s) => (
- setTheme({ ...theme, scheme: s.id as Scheme })}
aria-label={s.name}
aria-pressed={theme.scheme === s.id}
@@ -293,7 +294,7 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
/>
)}
-
notify({
level: "info",
@@ -302,10 +303,11 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
sound: true,
})
}
- className="mt-2 text-sm text-accent hover:underline"
+ variant="link"
+ className="mt-2 text-sm"
>
{t("settings.notifications.sendTest")}
-
+
);
}
@@ -439,14 +441,15 @@ function SignInMethods({ me }: { me: Me }) {
{t("settings.account.googleLink.connected")}
) : (
- {
window.location.href = withActiveAccount("/auth/link");
}}
- className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{t("settings.account.googleLink.connect")}
-
+
)}
)}
@@ -461,17 +464,19 @@ function SignInMethods({ me }: { me: Me }) {
: t("settings.account.password.unsetHint")}
- {
setOpen((o) => !o);
setErr(null);
}}
- className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{me.has_password
? t("settings.account.password.change")
: t("settings.account.password.set")}
-
+
{open && (