From eddeca82d3f04f6ea54da1ffffb5ff1cfdefb81a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 26 Sep 2026 00:39:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(design):=20the=20page=20band=20(B4)=20?= =?UTF-8?q?=E2=80=94=20one=20h1=20per=20page,=20section=20headings,=20skip?= =?UTF-8?q?=20to=20content,=20filters=20fold=20on=20a=20short=20window=20(?= =?UTF-8?q?E28=20S3d4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PageShell names the page with a visually hidden h1; a view with its own title renders it as PageHeading and the hidden one steps aside (contract: no hand

inside the shell). - Section captions are SectionLabel headings (h2, h3 in a dialog), ui/form Section included. - "Skip to content" is the first Tab stop and focuses the scroller's
. - Below 600px of height the band's filter row folds into "Filters · n" (Feed, Plex, server log), the same row shown in place. - The band holds controls only: Downloads and Notifications lose their title cards, the Config and channel-discovery intros scroll with the content, the Stats tabs and the audit pager join the band, and the server log's toolbar sticks with the fade under it. --- frontend/src/App.tsx | 6 +- frontend/src/components/AccountPanel.tsx | 9 +- frontend/src/components/AdminLogs.tsx | 220 ++++++++++-------- frontend/src/components/AuditLog.tsx | 2 + frontend/src/components/ChannelDiscovery.tsx | 37 ++- frontend/src/components/ChannelPage.tsx | 5 +- frontend/src/components/ConfigPanel.tsx | 7 +- frontend/src/components/DownloadCenter.tsx | 24 +- .../src/components/ExcludedChannelList.tsx | 3 +- frontend/src/components/Feed.tsx | 129 +++++----- frontend/src/components/Messages.tsx | 9 +- .../src/components/NotificationsPanel.tsx | 67 +++--- frontend/src/components/PlayerModal.tsx | 5 +- frontend/src/components/PlexBrowse.tsx | 47 ++-- .../src/components/PlexCollectionView.tsx | 5 +- frontend/src/components/PlexInfo.tsx | 12 +- frontend/src/components/PlexPlaylistView.tsx | 5 +- frontend/src/components/ProfileEditor.tsx | 9 +- frontend/src/components/Scheduler.tsx | 17 +- frontend/src/components/Stats.tsx | 27 ++- frontend/src/components/plexViews.tsx | 7 +- .../src/components/ui/FilterFold.dom.test.tsx | 52 +++++ frontend/src/components/ui/FilterFold.tsx | 28 +++ frontend/src/components/ui/Headings.tsx | 30 +++ frontend/src/components/ui/PageScroller.tsx | 7 +- .../src/components/ui/PageShell.dom.test.tsx | 26 +++ frontend/src/components/ui/PageShell.tsx | 38 ++- frontend/src/components/ui/README.md | 2 +- .../src/components/ui/SkipLink.dom.test.tsx | 31 +++ frontend/src/components/ui/SkipLink.tsx | 23 ++ frontend/src/components/ui/form.tsx | 9 +- frontend/src/components/ui/landmarks.ts | 3 + frontend/src/components/ui/pageHeading.ts | 15 ++ frontend/src/i18n/locales/en/downloads.json | 2 - frontend/src/i18n/locales/en/inbox.json | 2 - frontend/src/i18n/locales/en/nav.json | 1 + frontend/src/i18n/locales/hu/downloads.json | 2 - frontend/src/i18n/locales/hu/inbox.json | 2 - frontend/src/i18n/locales/hu/nav.json | 1 + frontend/src/lib/headings.contract.test.ts | 39 ++++ frontend/src/lib/useFilterFold.ts | 23 ++ 41 files changed, 669 insertions(+), 319 deletions(-) create mode 100644 frontend/src/components/ui/FilterFold.dom.test.tsx create mode 100644 frontend/src/components/ui/FilterFold.tsx create mode 100644 frontend/src/components/ui/Headings.tsx create mode 100644 frontend/src/components/ui/SkipLink.dom.test.tsx create mode 100644 frontend/src/components/ui/SkipLink.tsx create mode 100644 frontend/src/components/ui/landmarks.ts create mode 100644 frontend/src/components/ui/pageHeading.ts create mode 100644 frontend/src/lib/headings.contract.test.ts create mode 100644 frontend/src/lib/useFilterFold.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f3ac1af..71ff6d69 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,8 @@ import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import BackToTop from "./components/ui/BackToTop"; import PageShell from "./components/ui/PageShell"; +import { SkipLink } from "./components/ui/SkipLink"; +import { pageTitleKey } from "./lib/pageMeta"; import ChatDock from "./components/ChatDock"; import DebugRecorder from "./components/DebugRecorder"; import Toaster from "./components/ui/Toaster"; @@ -367,7 +369,8 @@ export default function App() { const railDef = channelView ? undefined : PAGE_RAIL[activePage]; return ( -
+
+ setAboutOpen(true)} @@ -428,6 +431,7 @@ export default function App() { ) : ( } diff --git a/frontend/src/components/AccountPanel.tsx b/frontend/src/components/AccountPanel.tsx index 296800b0..c1bb1a58 100644 --- a/frontend/src/components/AccountPanel.tsx +++ b/frontend/src/components/AccountPanel.tsx @@ -15,6 +15,7 @@ import { invalidateUserAccess } from "../lib/adminUsers"; import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin"; import { Button } from "./ui/Button"; import { SURFACE } from "./ui/surfaces"; +import { SectionLabel } from "./ui/Headings"; // 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). @@ -115,9 +116,9 @@ export default function AccountPanel({ ) : ( <> -
+ {t("users.account.access")} -
+
-
+ {t("users.account.recovery")} -
+
{a.can_activate && ( - - -
- - {(state.requestId || state.jobId !== null) && ( -
- {state.requestId && ( - update({ requestId: null })} - /> - )} - {state.jobId !== null && ( - update({ jobId: null })} - /> - )}
- )} + +
+ + {!fold.folded && ( + <> + v)} + selected={[state.sources[0] ?? ""]} + onChange={([source]) => update({ sources: source ? [source] : [] })} + testId="logs-source" + /> + update({ user: user || null })} + testId="logs-user" + /> + + v === "none" ? t("adminLogs.noEvent") : v, + )} + selected={[state.event ?? ""]} + onChange={([event]) => update({ event: event || null })} + testId="logs-event" + /> + ({ value: w, label: t(`adminLogs.windows.${w}`) }))} + selected={[state.window]} + onChange={([window]) => update({ window: window as LogWindow })} + testId="logs-window" + /> + + )} + + + + +
+ + {(state.requestId || state.jobId !== null) && ( +
+ {state.requestId && ( + update({ requestId: null })} + /> + )} + {state.jobId !== null && ( + update({ jobId: null })} + /> + )} +
+ )} +
{failure && ( String(r.id)} persistKey={LS.auditTable} controlsPosition="top" + controlsInBand + controlsBandClassName="px-4 max-w-5xl mx-auto" controlsLeading={ -
-

{t("downloads.page.subtitle")}

- - +
+ +
diff --git a/frontend/src/components/ExcludedChannelList.tsx b/frontend/src/components/ExcludedChannelList.tsx index bc28e7dd..2a075d8e 100644 --- a/frontend/src/components/ExcludedChannelList.tsx +++ b/frontend/src/components/ExcludedChannelList.tsx @@ -4,6 +4,7 @@ import NavGrid from "./ui/NavGrid"; import { navItemAttrs } from "../lib/listNav"; import { Button } from "./ui/Button"; import { SURFACE } from "./ui/surfaces"; +import { SectionLabel } from "./ui/Headings"; // 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 @@ -42,7 +43,7 @@ export default function ExcludedChannelList({ <>
{icon} - {title} + {title}
{channels.map((c, i) => ( diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index c1e94f26..815f4e20 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -48,6 +48,8 @@ import { useWizard } from "./wizardContext"; import SortControl from "./ui/SortControl"; import FilterSelect from "./ui/FilterSelect"; import ItemCount from "./ui/ItemCount"; +import { FilterFoldButton } from "./ui/FilterFold"; +import { useFilterFold } from "../lib/useFilterFold"; import ViewSwitcher from "./ViewSwitcher"; import VirtualFeed from "./VirtualFeed"; import SegmentedToggle from "./ui/SegmentedToggle"; @@ -136,6 +138,8 @@ export default function Feed({ const { openWizard: onOpenWizard } = useWizard(); // Same list the panel's badge counts — see lib/useActiveFilters. const activeFilters = useActiveFilters(filters, setFilters); + // B4: on a short window the filter row folds into one "Filters · n" button (ui/FilterFold). + const fold = useFilterFold(); const sortKeys = channelScoped ? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority") : SORT_KEYS; @@ -670,48 +674,61 @@ export default function Feed({ {/* The item count is the fixed leading element — a fixed-width pill so a changing number never shoves the controls beside it (see ItemCount). */} - {/* Watch state is single-select (always exactly one); content types are an independent - multi-select. Both compress to one labeled dropdown pill via FilterSelect. */} - ({ value: id, label: t("sidebar.show." + id) }))} - selected={[filters.show]} - onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })} + - ({ value: c.key, label: t(c.label) }))} - selected={CONTENT.filter((c) => filters[c.key]).map((c) => c.key)} - onChange={(next) => { - const patch = { ...filters }; - for (const c of CONTENT) patch[c.key] = next.includes(c.key); - setFilters(patch); - }} - /> - {/* The Source filter (organic / include-search / search-only) is a global-catalog concept — - it selects how videos ENTERED the library. On a channel-scoped view we already show all - of this one channel's videos (librarySource is pinned to "all"), so the selector would - only offer a near-empty, confusing slice — hide it here. */} - {!channelScoped && ( - - setFilters({ - ...filters, - librarySource: next[0] as FeedFilters["librarySource"], - }) - } - /> + {!fold.folded && ( + <> + {/* Watch state is single-select (always exactly one); content types are an + independent multi-select. Both compress to one labeled dropdown pill via FilterSelect. */} + ({ value: id, label: t("sidebar.show." + id) }))} + selected={[filters.show]} + onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })} + /> + ({ value: c.key, label: t(c.label) }))} + selected={CONTENT.filter((c) => filters[c.key]).map((c) => c.key)} + onChange={(next) => { + const patch = { ...filters }; + for (const c of CONTENT) patch[c.key] = next.includes(c.key); + setFilters(patch); + }} + /> + {/* The Source filter (organic / include-search / search-only) is a global-catalog + concept — it selects how videos ENTERED the library. On a channel-scoped view we + already show all of this one channel's videos (librarySource is pinned to "all"), so + the selector would only offer a near-empty, confusing slice — hide it here. */} + {!channelScoped && ( + + setFilters({ + ...filters, + librarySource: next[0] as FeedFilters["librarySource"], + }) + } + /> + )} + + )} + {/* Folded, the band is one row: Sort + View join it on the right. */} + {fold.folded && ( +
{sortView}
)}
@@ -734,20 +751,22 @@ export default function Feed({ applied"), but still get the right-pinned Sort/View. */} {/* Wraps on a phone: the chips and Sort + View did not fit one line, and the row pushed the page 21px wide (prod UAT 2026-09-19). Sort + View then take a line of their own. */} -
- {!channelScoped && ( - <> - {/* The active personal view (if any) leads the applied-filter row. */} - - setFilters(clearedFilters(filters))} - className="flex-1" - /> - - )} -
{sortView}
-
+ {!fold.folded && ( +
+ {!channelScoped && ( + <> + {/* The active personal view (if any) leads the applied-filter row. */} + + setFilters(clearedFilters(filters))} + className="flex-1" + /> + + )} +
{sortView}
+
+ )} {items.length === 0 ? ( diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 4bcb16f6..506bf09a 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -21,6 +21,7 @@ import { navItemAttrs } from "../lib/listNav"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; +import { SectionLabel } from "./ui/Headings"; type View = | "list" @@ -129,9 +130,7 @@ function ConversationList({ return (
-
- {t("messages.conversations")} -
+ {t("messages.conversations")} {ready && (
+
- {/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */} - -
-
- -
-
{t("inbox.title")}
-
{t("inbox.subtitle")}
-
- {hasAny && ( -
- - -
- )} + {/* The inbox band (B4): its two actions, fixed while the list scrolls. No title card: the nav + rail and the tab title name the page, and the shell's hidden h1 names it to a screen reader. */} + {hasAny && ( + +
+ +
-
- + + )}
{q.isError && !hasAny ? ( @@ -191,9 +184,7 @@ export default function NotificationsPanel() {
{serverItems.length > 0 && (
-
- {t("inbox.sectionSystem")} -
+ {t("inbox.sectionSystem")}
{serverItems.map((n) => ( 0 && (
-
- {t("inbox.sectionActivity")} -
+ {t("inbox.sectionActivity")}
{clientItems.map((n) => ( -
+ {t("player.description")} -
+ {detail.isLoading ? (
{t("player.loading")}
) : detail.data?.description ? ( diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index c19f830f..a4042f89 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -33,6 +33,8 @@ import { PageToolbar } from "./ui/PageShell"; import ActiveFilterChips from "./ActiveFilterChips"; import SelectedViewChip from "./SelectedViewChip"; import ItemCount from "./ui/ItemCount"; +import { FilterFoldButton } from "./ui/FilterFold"; +import { useFilterFold } from "../lib/useFilterFold"; import { StateMessage } from "./ui/QueryState"; import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters"; import { usePlex, usePlexActions } from "./plexContext"; @@ -104,6 +106,8 @@ export default function PlexBrowse() { // Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per // narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort. const plexChips = usePlexActiveFilters(); + // B4: on a short window the filter row folds into one "Filters · n" button (ui/FilterFold). + const fold = useFilterFold(); const clearAllFilters = usePlexClearFilters(); // The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only // key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort. @@ -523,29 +527,38 @@ export default function PlexBrowse() {
+ {/* Watch state — a top-level toolbar pill next to the count (moved out of the rail), the same idiom as the feed's Watch-state FilterSelect. */} - ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))} - selected={[show]} - onChange={(next) => setShow(next[0] ?? "all")} - /> + {!fold.folded && ( + ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))} + selected={[show]} + onChange={(next) => setShow(next[0] ?? "all")} + /> + )} + {/* Folded, the band is one row: Sort + the view toggle join it on the right. */} + {fold.folded && ( +
{sortView}
+ )}
{/* The controls row sits directly above the grid: the applied-filter chips flow from the left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort + the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */} -
- {/* The active personal view (if any) leads the applied-filter row, like the feed. */} - - -
{sortView}
-
+ {!fold.folded && ( +
+ {/* The active personal view (if any) leads the applied-filter row, like the feed. */} + + +
{sortView}
+
+ )}
diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index db764cd2..8a237d43 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -27,6 +27,7 @@ import { Input } from "./ui/Input"; import { useReturnFocus } from "./ui/useReturnFocus"; import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; +import { PageHeading } from "./ui/Headings"; // 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, @@ -294,7 +295,9 @@ export default function PlexCollectionView({ /> ) : (
-

{d.title}

+ + {d.title} + {readOnly && ( diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 6b36a780..b2586b2f 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -21,6 +21,7 @@ import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; import { IconLink } from "./ui/LinkButton"; +import { PageHeading } from "./ui/Headings"; // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from @@ -443,6 +444,8 @@ export default function PlexInfo({ !!tmdb?.production_countries?.length; const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0; const hasCrew = (tmdb?.crew?.length ?? 0) > 0; + const infoTitle = + detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title; return (
@@ -452,9 +455,12 @@ export default function PlexInfo({ {/* Header: close (overlay) / customize */}
-

- {detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title} -

+ {/* The page's own h1; over the running player it is the info dialog's title. */} + {overlay ? ( +

{infoTitle}

+ ) : ( + {infoTitle} + )} {detail.kind === "episode" && (

S{detail.season_number}·E{detail.episode_number} — {detail.title} diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index fcbb8557..f7597d73 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -44,6 +44,7 @@ import { import { Input } from "./ui/Input"; import { useReturnFocus } from "./ui/useReturnFocus"; import { SURFACE } from "./ui/surfaces"; +import { PageHeading } from "./ui/Headings"; // A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the // same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat @@ -551,7 +552,9 @@ export default function PlexPlaylistView({ aria-label={t("plex.playlist.rename")} /> ) : ( -

{q.data?.title ?? " "}

+ + {q.data?.title ?? " "} + )} {items.length > 0 && (
diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index e37de0ae..56662536 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -13,6 +13,7 @@ import { IconButton } from "./ui/IconButton"; import { CheckRow } from "./ui/NativeControls"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; +import { SectionLabel } from "./ui/Headings"; const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360]; @@ -108,9 +109,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) { {/* Existing formats */}
-
- {t("downloads.profiles.builtin")} -
+ {t("downloads.profiles.builtin")} {builtins.map((p) => (
@@ -118,9 +117,9 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
))} {mine.length > 0 && ( -
+ {t("downloads.profiles.yours")} -
+ )} {mine.map((p) => (
diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 534e1692..840e982d 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -30,6 +30,7 @@ import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; +import { SectionLabel } from "./ui/Headings"; const POLL_MS = 4000; // While any job is running, poll faster so short scheduled runs aren't missed between ticks. @@ -579,9 +580,7 @@ export default function Scheduler() { {/* Jobs */}
-
- {t("scheduler.jobsTitle")} -
+ {t("scheduler.jobsTitle")}
{data.jobs.map((job) => ( @@ -600,9 +599,7 @@ export default function Scheduler() { {/* Queue */}
-
- {t("scheduler.queueTitle")} -
+ {t("scheduler.queueTitle")}
-
- {t("scheduler.quotaTitle")} -
+ {t("scheduler.quotaTitle")}
@@ -671,9 +666,7 @@ export default function Scheduler() { {/* Maintenance settings */}
-
- {t("scheduler.maintenance.title")} -
+ {t("scheduler.maintenance.title")} batchMut.mutate(batch)} diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 0cc9c0f9..dc2db163 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -13,6 +13,7 @@ import Tooltip from "./ui/Tooltip"; import { LoadingState, StateMessage } from "./ui/QueryState"; import FilterChipGroup from "./ui/FilterChipGroup"; import Tabs from "./ui/Tabs"; +import { PageToolbar } from "./ui/PageShell"; import { Section, SettingRow } from "./ui/form"; import { useMe } from "../lib/useMe"; import { notifyActionError } from "../lib/actionErrors"; @@ -35,17 +36,23 @@ export default function Stats() { const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview"; return ( -
- {/* Only admins have a second (System) tab; everyone else just sees the Overview. */} +
+ {/* Only admins have a second (System) tab; everyone else just sees the Overview. The tabs are + the page's band (B4), so they stay in reach on the long System tab. */} {isAdmin && ( - ({ - id, - label: t(`stats.tabs.${id}`), - }))} - /> + +
+ ({ + id, + label: t(`stats.tabs.${id}`), + }))} + className="flex flex-wrap gap-1 pb-2" + /> +
+
)} {tab === "system" && isAdmin ? : } diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 044b0824..8808141b 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -30,6 +30,7 @@ import NavGrid from "./ui/NavGrid"; import { Chip } from "./ui/Chip"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; +import { PageHeading } from "./ui/Headings"; // 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, @@ -199,7 +200,7 @@ export function PlexPersonView({ )}
-

{d.name}

+ {d.name} {d.known_for_department && (

{d.known_for_department}

)} @@ -393,7 +394,7 @@ export function PlexShowView({ /> } > -

{show.title}

+ {show.title}
{show.year != null && ( {d.show.title} -

{se.title}

+ {se.title}

{t("plex.series.episodeCount", { count: se.episode_count })}

diff --git a/frontend/src/components/ui/FilterFold.dom.test.tsx b/frontend/src/components/ui/FilterFold.dom.test.tsx new file mode 100644 index 00000000..6b513a14 --- /dev/null +++ b/frontend/src/components/ui/FilterFold.dom.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +// +// E28 S3d4 (B4): below ~600px of height a band's filter row folds into one "Filters · n" button that +// shows it again in place; on a tall window there is no button and the row is always there. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { FilterFoldButton } from "./FilterFold"; +import { useFilterFold } from "../../lib/useFilterFold"; + +function Band() { + const fold = useFilterFold(); + return ( + <> + + {!fold.folded &&
the filter row
} + + ); +} + +function windowHeight(short: boolean) { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(max-height: 600px)" && short, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("the band's filter fold", () => { + it("is not there on a tall window: the row always shows", () => { + windowHeight(false); + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByTestId("row")).toBeTruthy(); + }); + + it("folds the row into 'Filters · n' on a short window, and unfolds it in place", () => { + windowHeight(true); + render(); + const button = screen.getByRole("button", { name: "Filters · 2" }); + expect(button.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByTestId("row")).toBeNull(); + fireEvent.click(button); + expect(button.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByTestId("row")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/ui/FilterFold.tsx b/frontend/src/components/ui/FilterFold.tsx new file mode 100644 index 00000000..82e61f0c --- /dev/null +++ b/frontend/src/components/ui/FilterFold.tsx @@ -0,0 +1,28 @@ +import { SlidersHorizontal } from "lucide-react"; +import type { FilterFold } from "../../lib/useFilterFold"; +import { SelectTrigger } from "./SelectTrigger"; + +/** B4: the "Filters · n" button that folds a page band's filter row on a short window (see + * lib/useFilterFold). Rendered only while the window is short. `count` = active filters. */ +export function FilterFoldButton({ + fold, + label, + count, +}: { + fold: FilterFold; + label: string; + count: number; +}) { + if (!fold.short) return null; + return ( + } + onClick={fold.toggle} + > + {count > 0 ? `${label} · ${count}` : label} + + ); +} diff --git a/frontend/src/components/ui/Headings.tsx b/frontend/src/components/ui/Headings.tsx new file mode 100644 index 00000000..d671e7b3 --- /dev/null +++ b/frontend/src/components/ui/Headings.tsx @@ -0,0 +1,30 @@ +import type { ComponentPropsWithoutRef, ReactNode } from "react"; +import { useOwnPageHeading } from "./pageHeading"; + +// The page's heading outline (B4, UX-15): one `h1` per page, then a heading per section. A screen +// reader user moves by headings, so a section caption drawn as a styled `div` hides the page's +// structure from exactly the people who navigate by it. + +/** A view's own visible title (a channel, a Plex show, a playlist): the page's `h1`, in place of the + * shell's hidden page name. */ +export function PageHeading(props: ComponentPropsWithoutRef<"h1">) { + useOwnPageHeading(); + return

; +} + +/** A section's caption: the small uppercase muted label, as a heading. `level` 3 inside a dialog or + * a panel whose own title is the `h2`. */ +export function SectionLabel({ + level = 2, + className = "", + children, +}: { + level?: 2 | 3; + className?: string; + children: ReactNode; +}) { + const Tag = level === 2 ? "h2" : "h3"; + return ( + {children} + ); +} diff --git a/frontend/src/components/ui/PageScroller.tsx b/frontend/src/components/ui/PageScroller.tsx index 0a98c9b7..29a0d5a4 100644 --- a/frontend/src/components/ui/PageScroller.tsx +++ b/frontend/src/components/ui/PageScroller.tsx @@ -2,6 +2,7 @@ import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from " import { useScrollFade } from "../../lib/useScrollFade"; import { useFocusKeeper } from "../../lib/useFocusKeeper"; import { StickyTopProvider } from "./stickyTop"; +import { CONTENT_ID } from "./landmarks"; import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history"; // Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the @@ -156,10 +157,14 @@ export default function PageScroller({ }, [resolve]); return ( + // `id` + `tabIndex={-1}`: the skip link's target (ui/SkipLink) takes the focus, so the next Tab + // lands on the page's first control.
{children}
diff --git a/frontend/src/components/ui/PageShell.dom.test.tsx b/frontend/src/components/ui/PageShell.dom.test.tsx index 40bb35e4..e076c068 100644 --- a/frontend/src/components/ui/PageShell.dom.test.tsx +++ b/frontend/src/components/ui/PageShell.dom.test.tsx @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render, cleanup } from "@testing-library/react"; import PageShell from "./PageShell"; +import { PageHeading } from "./Headings"; vi.mock("./PageScroller", () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -137,3 +138,28 @@ describe("PageShell --rail-pinned-w / --content-mw", () => { expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem"); }); }); + +// E28 S3d4 (B4): every page has one h1 — the page's name, visually hidden, unless a view shows its +// own title (a channel, a Plex show), which is then the page's h1 instead. +describe("PageShell page heading", () => { + it("names the page with a visually hidden h1", () => { + render(content); + const h1s = document.querySelectorAll("h1"); + expect(h1s).toHaveLength(1); + expect(h1s[0]!.textContent).toBe("Downloads"); + expect(h1s[0]!.className).toContain("sr-only"); + }); + + it("steps aside while a view renders its own PageHeading, and returns when it goes", () => { + const { rerender } = render( + + Knight Rider + , + ); + expect([...document.querySelectorAll("h1")].map((h) => h.textContent)).toEqual([ + "Knight Rider", + ]); + rerender(grid); + expect([...document.querySelectorAll("h1")].map((h) => h.textContent)).toEqual(["Plex"]); + }); +}); diff --git a/frontend/src/components/ui/PageShell.tsx b/frontend/src/components/ui/PageShell.tsx index 7ec51cec..50172a84 100644 --- a/frontend/src/components/ui/PageShell.tsx +++ b/frontend/src/components/ui/PageShell.tsx @@ -1,6 +1,7 @@ import { cloneElement, isValidElement, + useCallback, useContext, useLayoutEffect, useState, @@ -11,6 +12,7 @@ import { import { createPortal } from "react-dom"; import PageScroller from "./PageScroller"; import { PageToolbarSlot } from "./pageToolbarSlot"; +import { PageHeadingProvider } from "./pageHeading"; // A module renders its per-module fixed band (filter chips / sort / counts) through this. The // content lands in the shell's toolbar band — ABOVE the scroller, so it stays put while the content @@ -32,6 +34,9 @@ export function PageToolbar({ children }: { children: ReactNode }) { // - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads // its top by that height so nothing hides under it. Omit it (channel page) → no pad // and no fixed top band, so the module's own banner can scroll from the very top. +// - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are +// the visible identity, and a screen reader still lands on a named page. A view that +// shows its own title renders it through `PageHeading`, and this one steps aside. // - `banners` — in-flow, fixed notices (demo/version) above the scroll region. // - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a // module via . Empty ⇒ zero height, so pages without one lose nothing. @@ -41,12 +46,14 @@ export function PageToolbar({ children }: { children: ReactNode }) { // The shell renders a fragment so it drops straight into App's `relative` content column — the // floating header keeps resolving its `absolute` position against that same ancestor. export default function PageShell({ + title, header, banners, children, scrollKey, maxWidth, }: { + title?: string; header?: ReactNode; banners?: ReactNode; children: ReactNode; @@ -126,6 +133,12 @@ export default function PageShell({ ro.observe(headerEl); return () => ro.disconnect(); }, [headerEl, contentEl]); + // How many views below render their own page `h1` right now (see ./pageHeading). + const [ownHeadings, setOwnHeadings] = useState(0); + const registerOwnHeading = useCallback(() => { + setOwnHeadings((n) => n + 1); + return () => setOwnHeadings((n) => n - 1); + }, []); const measuredHeader = isValidElement(header) ? cloneElement(header as ReactElement<{ ref?: Ref }>, { ref: setHeaderEl, @@ -133,17 +146,20 @@ export default function PageShell({ : header; return ( - {measuredHeader} -
- {banners} - {/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the - scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */} -
- {children} -
+ + {measuredHeader} +
+ {title && ownHeadings === 0 &&

{title}

} + {banners} + {/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the + scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */} +
+ {children} +
+ ); } diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 2400560d..d653c662 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -44,7 +44,7 @@ domain-free. B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` | | data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` | -| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` | +| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` | | state & messaging | `QueryState`, `Banner`, `InlineNotice` | ## The size contract (E10 S1c) diff --git a/frontend/src/components/ui/SkipLink.dom.test.tsx b/frontend/src/components/ui/SkipLink.dom.test.tsx new file mode 100644 index 00000000..5e6e5e25 --- /dev/null +++ b/frontend/src/components/ui/SkipLink.dom.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +// +// E28 S3d4 (UX-4): "Skip to content" moves the focus to the page's
itself. Following its +// `#content` fragment would push a history entry, and the app reads every popstate as a page change. +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { SkipLink } from "./SkipLink"; +import { CONTENT_ID } from "./landmarks"; + +afterEach(cleanup); + +describe("SkipLink", () => { + it("focuses the content region without touching the address", () => { + render( + <> + +
+ page +
+ , + ); + const before = window.location.href; + const link = screen.getByRole("link", { name: "Skip to content" }); + // Off screen until focused, then a full-size button (sr-only / not-sr-only zeroed its padding). + expect(link.className).toContain("focus:translate-y-0"); + expect(link.className).not.toMatch(/sr-only/); + fireEvent.click(link); + expect(document.activeElement?.id).toBe(CONTENT_ID); + expect(window.location.href).toBe(before); + }); +}); diff --git a/frontend/src/components/ui/SkipLink.tsx b/frontend/src/components/ui/SkipLink.tsx new file mode 100644 index 00000000..0ca0d051 --- /dev/null +++ b/frontend/src/components/ui/SkipLink.tsx @@ -0,0 +1,23 @@ +import { LinkButton } from "./LinkButton"; +import { CONTENT_ID } from "./landmarks"; + +// "Skip to content" (UX-4, WCAG 2.4.1): the first Tab stop of the app, off screen until it has the +// focus. Without it the first feed card was the 102nd Tab stop, behind the rail, the filter panel and +// the header. It moves the focus itself rather than following its `#content` fragment: a fragment +// change is a history entry, and the app's navigation reads every popstate as a page change. +export function SkipLink({ label }: { label: string }) { + return ( + { + e.preventDefault(); + document.getElementById(CONTENT_ID)?.focus(); + }} + > + {label} + + ); +} diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx index 8286df29..1ff23784 100644 --- a/frontend/src/components/ui/form.tsx +++ b/frontend/src/components/ui/form.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import Tooltip from "./Tooltip"; import { FocusSafeButton } from "./FocusSafeButton"; import { SURFACE } from "./surfaces"; +import { SectionLabel } from "./Headings"; // Shared form/settings primitives, factored out of the many panels that each re-declared // them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged. @@ -50,8 +51,8 @@ export function Switch({ ); } -/** A titled settings block. Default = a plain block with an uppercase caption; `card` wraps it - * in a glass card (the admin pages' style). */ +/** A titled settings block. Default = a plain block with an uppercase caption (the section's `h2`); + * `card` wraps it in a glass card (the admin pages' style). */ export function Section({ title, card, @@ -63,9 +64,7 @@ export function Section({ }) { return (
-
- {title} -
+ {title} {children}
); diff --git a/frontend/src/components/ui/landmarks.ts b/frontend/src/components/ui/landmarks.ts new file mode 100644 index 00000000..17a71afc --- /dev/null +++ b/frontend/src/components/ui/landmarks.ts @@ -0,0 +1,3 @@ +// The id of the page's content region, `PageScroller`'s
: the target of `SkipLink`. One +// constant, so the link and its target cannot drift apart. +export const CONTENT_ID = "content"; diff --git a/frontend/src/components/ui/pageHeading.ts b/frontend/src/components/ui/pageHeading.ts new file mode 100644 index 00000000..cde5badf --- /dev/null +++ b/frontend/src/components/ui/pageHeading.ts @@ -0,0 +1,15 @@ +import { createContext, useContext, useLayoutEffect } from "react"; + +// B4 / UX-15: every page has exactly one `h1`. The shell renders the page's name as a visually hidden +// one; a view that shows its OWN title (a channel, a Plex show, a playlist) renders that as the page's +// `h1` through `PageHeading`, which says so here, and the shell drops its hidden one while it is +// mounted. The same shape as ./stickyTop: the element announces itself, no page opts out by hand. +const PageHeadingContext = createContext<(() => () => void) | null>(null); + +export const PageHeadingProvider = PageHeadingContext.Provider; + +/** Called by `PageHeading`: while mounted, the shell's hidden page name steps aside. */ +export function useOwnPageHeading(): void { + const register = useContext(PageHeadingContext); + useLayoutEffect(() => register?.(), [register]); +} diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 4389fd06..86dfd086 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -16,8 +16,6 @@ "manage": "Manage formats" }, "page": { - "title": "Downloads", - "subtitle": "Download videos to the server, then save them to your device.", "addUrl": "Paste a video link (YouTube, Facebook, …) or a YouTube video id…", "add": "Add", "paste": "Paste link from clipboard", diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json index bb16c997..faa05324 100644 --- a/frontend/src/i18n/locales/en/inbox.json +++ b/frontend/src/i18n/locales/en/inbox.json @@ -1,7 +1,5 @@ { "navLabel": "Notifications", - "title": "Notifications", - "subtitle": "Updates from your library and the system.", "empty": "You're all caught up.", "markAllRead": "Mark all read", "clearAll": "Clear all", diff --git a/frontend/src/i18n/locales/en/nav.json b/frontend/src/i18n/locales/en/nav.json index 6a0751f5..3b63fa9e 100644 --- a/frontend/src/i18n/locales/en/nav.json +++ b/frontend/src/i18n/locales/en/nav.json @@ -1,5 +1,6 @@ { "primary": "Primary navigation", + "skipToContent": "Skip to content", "role": { "admin": "Admin", "user": "User", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 53f354ea..71941bda 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -16,8 +16,6 @@ "manage": "Formátumok kezelése" }, "page": { - "title": "Letöltések", - "subtitle": "Töltsd le a videókat a szerverre, majd mentsd a saját gépedre.", "addUrl": "Illessz be egy videólinket (YouTube, Facebook, …) vagy YouTube-videóazonosítót…", "add": "Hozzáad", "paste": "Link beillesztése a vágólapról", diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json index f8b434d1..5c0d3ffb 100644 --- a/frontend/src/i18n/locales/hu/inbox.json +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -1,7 +1,5 @@ { "navLabel": "Értesítések", - "title": "Értesítések", - "subtitle": "Frissítések a könyvtáradból és a rendszertől.", "empty": "Nincs új értesítés.", "markAllRead": "Összes olvasott", "clearAll": "Összes törlése", diff --git a/frontend/src/i18n/locales/hu/nav.json b/frontend/src/i18n/locales/hu/nav.json index 620046e6..41090a5b 100644 --- a/frontend/src/i18n/locales/hu/nav.json +++ b/frontend/src/i18n/locales/hu/nav.json @@ -1,5 +1,6 @@ { "primary": "Fő navigáció", + "skipToContent": "Ugrás a tartalomra", "role": { "admin": "Admin", "user": "Felhasználó", diff --git a/frontend/src/lib/headings.contract.test.ts b/frontend/src/lib/headings.contract.test.ts new file mode 100644 index 00000000..18f2f7ce --- /dev/null +++ b/frontend/src/lib/headings.contract.test.ts @@ -0,0 +1,39 @@ +import { readdirSync, readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +// E28 S3d4 (B4, UX-15). One `h1` per page: the shell's hidden page name, or a view's own visible +// title through `PageHeading`, which makes the shell's step aside. A hand-written `

` inside the +// shell would be a second one, beside the page name the shell already put there. +function sources(): Array<[string, string]> { + const walk = (u: URL, prefix: string): Array<[string, string]> => + readdirSync(u, { withFileTypes: true }).flatMap((e) => { + if (e.isDirectory()) return walk(new URL(`${e.name}/`, u), `${prefix}${e.name}/`); + if (!/\.tsx$/.test(e.name) || e.name.includes(".test.")) return []; + return [ + [`${prefix}${e.name}`, readFileSync(new URL(e.name, u), "utf-8")] as [string, string], + ]; + }); + return walk(new URL("../", import.meta.url), ""); +} + +// Pages that render OUTSIDE the app shell own their h1 (no shell, no hidden page name). +const STANDALONE = new Set([ + "components/Welcome.tsx", + "components/WatchPage.tsx", + "components/legal/LegalLayout.tsx", + "design-lab/DesignLab.tsx", + // The shell's hidden h1 and the primitive itself. + "components/ui/PageShell.tsx", + "components/ui/Headings.tsx", +]); + +describe("the page heading", () => { + it("is written as PageHeading inside the shell, never as a hand

", () => { + const own = sources() + .filter(([name]) => !STANDALONE.has(name)) + .filter(([, src]) => /]/.test(src)) + .map(([name]) => name); + expect(own).toEqual([]); + }); +}); diff --git a/frontend/src/lib/useFilterFold.ts b/frontend/src/lib/useFilterFold.ts new file mode 100644 index 00000000..d2d0a7af --- /dev/null +++ b/frontend/src/lib/useFilterFold.ts @@ -0,0 +1,23 @@ +import { useState } from "react"; +import { useMediaQuery } from "./useMediaQuery"; + +// B4: below ~600px of height (a laptop with the browser's bars, a tablet with its keyboard up) the +// page band's filter row folds into one "Filters · n" button (ui/FilterFold), so the band leaves the +// content its room. The row is the same controls either way; the button only shows or hides it, in +// place, so a filter is never out of reach and no control is duplicated into a second surface. +const SHORT_WINDOW = "(max-height: 600px)"; + +export type FilterFold = { + /** The window is short: the fold button shows, and the row folds. */ + short: boolean; + /** The filter row is hidden right now (short, and not unfolded). */ + folded: boolean; + open: boolean; + toggle: () => void; +}; + +export function useFilterFold(): FilterFold { + const short = useMediaQuery(SHORT_WINDOW, false); + const [open, setOpen] = useState(false); + return { short, folded: short && !open, open, toggle: () => setOpen((o) => !o) }; +}