feat(design): the page band (B4) — one h1 per page, section headings, skip to content, filters fold on a short window (E28 S3d4)

- 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 <h1> 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 <main>.
- 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.
This commit is contained in:
2026-09-26 00:39:01 +02:00
parent 69edeecc45
commit eddeca82d3
41 changed files with 669 additions and 319 deletions
+5 -1
View File
@@ -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 (
<div className="h-screen flex text-fg">
<div className="h-dvh flex text-fg">
<SkipLink label={t("nav.skipToContent")} />
<NavSidebar
me={me!}
onOpenAbout={() => setAboutOpen(true)}
@@ -428,6 +431,7 @@ export default function App() {
</PageShell>
) : (
<PageShell
title={t(pageTitleKey(activePage))}
scrollKey={normalScrollKey}
maxWidth={maxWidth}
header={<Header me={me!} onYtSearch={enterYtSearch} />}
+5 -4
View File
@@ -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({
<LoadingState />
) : (
<>
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
<SectionLabel level={3} className="mb-2">
{t("users.account.access")}
</div>
</SectionLabel>
<div className={`${SURFACE.card} p-3 space-y-2 mb-5`}>
<AccessRow
icon={Mail}
@@ -192,9 +193,9 @@ export default function AccountPanel({
)}
</div>
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
<SectionLabel level={3} className="mb-2">
{t("users.account.recovery")}
</div>
</SectionLabel>
<div className="space-y-2">
{a.can_activate && (
<Button
+121 -99
View File
@@ -32,6 +32,9 @@ import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
import { FilterFoldButton } from "./ui/FilterFold";
import { useFilterFold } from "../lib/useFilterFold";
import { useStickyTop } from "./ui/stickyTop";
import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { DismissibleChip } from "./ui/Chip";
@@ -77,6 +80,14 @@ export default function AdminLogs() {
logsParamsToState(new URLSearchParams(window.location.search)),
);
const [live, setLiveState] = useState(false);
// The toolbar sticks at the scroller's top (./ui/stickyTop), and folds its facet row when short.
useStickyTop();
const fold = useFilterFold();
const facetFilters =
state.sources.length +
(state.user ? 1 : 0) +
(state.event ? 1 : 0) +
(state.window !== DEFAULT_LOGS_STATE.window ? 1 : 0);
// Read by `getNextPageParam` when a page is fetched, not when it renders: while live, a refetch
// stops after the newest page, however many pages the cache holds — a "Load older" that lands
// after Live was switched on, or a filter set revisited with older pages still cached.
@@ -245,108 +256,119 @@ export default function AdminLogs() {
<div
data-testid="admin-logs"
className="overflow-hidden rounded-xl border border-border bg-bg"
// overflow-clip, not -hidden: a hidden box is a scroll container, and the toolbar would stick
// to it instead of to the page.
className="overflow-clip rounded-xl border border-border bg-bg"
>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label
className={clsx(
fieldClass({ size: "sm", pill: true }),
"flex min-w-[12rem] flex-1 items-center gap-1.5",
)}
>
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<InnerInput
type="search"
value={state.q}
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
placeholder={t("adminLogs.search")}
aria-label={t("adminLogs.search")}
{/* The console's toolbar is the page's band (B4, B9): it sticks at the top with the fade
under it while the lines scroll, so a filter is in reach at line 500. */}
<div className="sticky top-0 z-base bg-bg fade-below">
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label
className={clsx(
fieldClass({ size: "sm", pill: true }),
"flex min-w-[12rem] flex-1 items-center gap-1.5",
)}
>
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<InnerInput
type="search"
value={state.q}
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
placeholder={t("adminLogs.search")}
aria-label={t("adminLogs.search")}
/>
</label>
<FilterChipGroup
ariaLabel={t("adminLogs.levelLabel")}
value={state.group}
onChange={(group) => update({ group })}
options={GROUPS.map((g) => ({
value: g,
label: t(`settings.myLog.filters.${g}`),
count: groupCount(g),
}))}
/>
</label>
<FilterChipGroup
ariaLabel={t("adminLogs.levelLabel")}
value={state.group}
onChange={(group) => update({ group })}
options={GROUPS.map((g) => ({
value: g,
label: t(`settings.myLog.filters.${g}`),
count: groupCount(g),
}))}
/>
</div>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterSelect
label={t("adminLogs.source")}
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
selected={[state.sources[0] ?? ""]}
onChange={([source]) => update({ sources: source ? [source] : [] })}
testId="logs-source"
/>
<FilterSelect
label={t("adminLogs.account")}
options={facetOptions(facets.data?.users, state.user, accountLabel)}
selected={[state.user ?? ""]}
onChange={([user]) => update({ user: user || null })}
testId="logs-user"
/>
<FilterSelect
label={t("adminLogs.event")}
options={facetOptions(facets.data?.events, state.event, (v) =>
v === "none" ? t("adminLogs.noEvent") : v,
)}
selected={[state.event ?? ""]}
onChange={([event]) => update({ event: event || null })}
testId="logs-event"
/>
<FilterSelect
label={t("adminLogs.window")}
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
selected={[state.window]}
onChange={([window]) => update({ window: window as LogWindow })}
testId="logs-window"
/>
<span className="ml-auto flex gap-1.5">
<Button
variant="outline"
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
pressed={polling}
onClick={toggleLive}
>
{t("adminLogs.live")}
</Button>
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
// While a filter change loads, the rows on screen are the previous filter's.
disabled={rows.length === 0 || logs.isPlaceholderData}
onClick={() => void copy()}
>
{t("adminLogs.copy")}
</Button>
</span>
</div>
{(state.requestId || state.jobId !== null) && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
{state.requestId && (
<PivotChip
label={t("adminLogs.pivotRequest", { id: state.requestId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ requestId: null })}
/>
)}
{state.jobId !== null && (
<PivotChip
label={t("adminLogs.pivotJob", { id: state.jobId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ jobId: null })}
/>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
{!fold.folded && (
<>
<FilterSelect
label={t("adminLogs.source")}
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
selected={[state.sources[0] ?? ""]}
onChange={([source]) => update({ sources: source ? [source] : [] })}
testId="logs-source"
/>
<FilterSelect
label={t("adminLogs.account")}
options={facetOptions(facets.data?.users, state.user, accountLabel)}
selected={[state.user ?? ""]}
onChange={([user]) => update({ user: user || null })}
testId="logs-user"
/>
<FilterSelect
label={t("adminLogs.event")}
options={facetOptions(facets.data?.events, state.event, (v) =>
v === "none" ? t("adminLogs.noEvent") : v,
)}
selected={[state.event ?? ""]}
onChange={([event]) => update({ event: event || null })}
testId="logs-event"
/>
<FilterSelect
label={t("adminLogs.window")}
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
selected={[state.window]}
onChange={([window]) => update({ window: window as LogWindow })}
testId="logs-window"
/>
</>
)}
<span className="ml-auto flex gap-1.5">
<Button
variant="outline"
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
pressed={polling}
onClick={toggleLive}
>
{t("adminLogs.live")}
</Button>
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
// While a filter change loads, the rows on screen are the previous filter's.
disabled={rows.length === 0 || logs.isPlaceholderData}
onClick={() => void copy()}
>
{t("adminLogs.copy")}
</Button>
</span>
</div>
{(state.requestId || state.jobId !== null) && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
{state.requestId && (
<PivotChip
label={t("adminLogs.pivotRequest", { id: state.requestId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ requestId: null })}
/>
)}
{state.jobId !== null && (
<PivotChip
label={t("adminLogs.pivotJob", { id: state.jobId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ jobId: null })}
/>
)}
</div>
)}
</div>
{failure && (
<StateMessage
+2
View File
@@ -73,6 +73,8 @@ export default function AuditLog() {
rowKey={(r) => String(r.id)}
persistKey={LS.auditTable}
controlsPosition="top"
controlsInBand
controlsBandClassName="px-4 max-w-5xl mx-auto"
controlsLeading={
<Button
variant="outline-danger"
+18 -19
View File
@@ -202,28 +202,27 @@ export default function ChannelDiscovery({
return (
<>
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
{/* The table controls are the page's band (B4); only the rows scroll (sticky header). The intro
scrolls with them: in the band it held two lines of a short window for text read once. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-384 mx-auto flex items-start justify-between gap-4">
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && (
<div className="flex items-center gap-3 shrink-0">
<Pager {...pagerProps} />
<ColumnSortControl
columns={columns}
sort={sort}
onChange={setSort}
label={t("channels.sort.label")}
defaultLabel={t("channels.sort.default")}
ascLabel={t("channels.sort.asc")}
descLabel={t("channels.sort.desc")}
/>
</div>
)}
</div>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && (
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-end gap-3">
<Pager {...pagerProps} />
<ColumnSortControl
columns={columns}
sort={sort}
onChange={setSort}
label={t("channels.sort.label")}
defaultLabel={t("channels.sort.default")}
ascLabel={t("channels.sort.asc")}
descLabel={t("channels.sort.desc")}
/>
</div>
)}
</PageToolbar>
<div className="px-4 pb-4 max-w-384 mx-auto">
<p className="text-xs text-muted leading-relaxed pt-2">{t("channels.discovery.intro")}</p>
{query.isError && !query.data ? (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("common.loadError")}
+3 -2
View File
@@ -39,6 +39,7 @@ import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
// 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
@@ -379,9 +380,9 @@ export default function ChannelPage({
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 data-testid="channel-title" className="text-xl font-semibold truncate">
<PageHeading data-testid="channel-title" className="text-xl font-semibold truncate">
{name}
</h1>
</PageHeading>
{ch?.blocked ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")}
+4 -3
View File
@@ -187,14 +187,15 @@ export default function ConfigPanel() {
return (
<>
{/* Intro + tab bar are fixed chrome; the config fields scroll under them. */}
{/* The tab bar is the page's band (B4); the intro scrolls with the fields — in the band it
took three lines of a short window for text read once. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
<Tabs tabs={groupTabs} active={activeGroup ?? ""} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-24 pt-3 max-w-3xl w-full mx-auto">
<div className="px-4 pb-24 pt-1 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
{activeGroup && (
<div className={`${SURFACE.panel} p-4 mb-4`}>
<div className="divide-y divide-border/60">
+13 -11
View File
@@ -167,18 +167,20 @@ export default function DownloadCenter() {
return (
<>
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
{/* The tabs are the page's band (B4); the tab content scrolls under them. No visible title: the
nav rail and the tab title name the page, the shell's hidden h1 names it to a screen reader. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<Button variant="ghost" size="sm" icon={<Settings2 />} onClick={() => setManage(true)}>
{t("downloads.page.manage")}
</Button>
</div>
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
<div className="px-4 pt-2 pb-2 max-w-4xl w-full mx-auto flex flex-wrap items-center justify-between gap-2">
<Tabs tabs={tabs} active={tab} onChange={setTab} className="flex flex-wrap gap-1" />
<Button
variant="ghost"
size="sm"
icon={<Settings2 />}
onClick={() => setManage(true)}
className="shrink-0"
>
{t("downloads.page.manage")}
</Button>
</div>
</PageToolbar>
@@ -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({
<>
<div className="flex items-center gap-2 mb-3">
{icon}
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
<SectionLabel>{title}</SectionLabel>
</div>
<NavGrid className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((c, i) => (
+74 -55
View File
@@ -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). */}
<ItemCount count={summaryQuery.data?.count} />
{/* Watch state is single-select (always exactly one); content types are an independent
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
<FilterSelect
label={t("feed.showLabel")}
testId="feed-show"
options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
selected={[filters.show]}
onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
<FilterFoldButton
fold={fold}
label={t("sidebar.filters")}
count={channelScoped ? 0 : activeFilters.length}
/>
<FilterSelect
label={t("feed.contentLabel")}
testId="feed-content"
multiple
options={CONTENT.map((c) => ({ 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 && (
<FilterSelect
label={t("feed.source.label")}
testId="feed-source"
options={[
{ value: "organic", label: t("feed.source.organic") },
{ value: "all", label: t("feed.source.all") },
{ value: "search", label: t("feed.source.only") },
]}
selected={[filters.librarySource ?? "organic"]}
onChange={(next) =>
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. */}
<FilterSelect
label={t("feed.showLabel")}
testId="feed-show"
options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
selected={[filters.show]}
onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
/>
<FilterSelect
label={t("feed.contentLabel")}
testId="feed-content"
multiple
options={CONTENT.map((c) => ({ 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 && (
<FilterSelect
label={t("feed.source.label")}
testId="feed-source"
options={[
{ value: "organic", label: t("feed.source.organic") },
{ value: "all", label: t("feed.source.all") },
{ value: "search", label: t("feed.source.only") },
]}
selected={[filters.librarySource ?? "organic"]}
onChange={(next) =>
setFilters({
...filters,
librarySource: next[0] as FeedFilters["librarySource"],
})
}
/>
)}
</>
)}
{/* Folded, the band is one row: Sort + View join it on the right. */}
{fold.folded && (
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
)}
</div>
</div>
@@ -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. */}
<div className="flex flex-wrap items-center gap-2 pb-2">
{!channelScoped && (
<>
{/* The active personal view (if any) leads the applied-filter row. */}
<SelectedViewChip />
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
className="flex-1"
/>
</>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
{!fold.folded && (
<div className="flex flex-wrap items-center gap-2 pb-2">
{!channelScoped && (
<>
{/* The active personal view (if any) leads the applied-filter row. */}
<SelectedViewChip />
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
className="flex-1"
/>
</>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
)}
</div>
</PageToolbar>
{items.length === 0 ? (
+4 -5
View File
@@ -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 (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs uppercase tracking-wide text-muted">
{t("messages.conversations")}
</div>
<SectionLabel>{t("messages.conversations")}</SectionLabel>
{ready && (
<Button variant="primary" size="xs" onClick={onNew}>
<SquarePen className="w-4 h-4" />
@@ -234,7 +233,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
>
<ArrowLeft className="w-4 h-4" />
</IconButton>
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
<SectionLabel>{t("messages.newTitle")}</SectionLabel>
</div>
<Input
className="w-full"
@@ -294,7 +293,7 @@ function PageThread({
const ready = useKeyState() === "ready";
const name = seedName ?? "";
return (
<div className="flex flex-col h-[65vh] min-h-80">
<div className="flex flex-col h-[65dvh] min-h-80">
<div className="flex items-center gap-3 pb-3 border-b border-border">
<IconButton
label={t("channel.back")}
+28 -39
View File
@@ -46,6 +46,7 @@ import { useNavigationActions } from "./navigationContext";
import { useChannelsActions } from "./channelsContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
// The single notification center. "System" = durable, server-backed notifications (inbox
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
@@ -140,40 +141,32 @@ export default function NotificationsPanel() {
return (
<>
{/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<div className={`${SURFACE.panel} p-4 flex items-center gap-3`}>
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold">{t("inbox.title")}</div>
<div className="text-xs text-muted">{t("inbox.subtitle")}</div>
</div>
{hasAny && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="xs"
onClick={markAll}
disabled={!hasUnread || readAllMut.isPending}
icon={<CheckCheck className="w-4 h-4" />}
>
{t("inbox.markAllRead")}
</Button>
<Button
variant="ghost"
size="xs"
onClick={clearEverything}
disabled={clearMut.isPending}
icon={<Trash2 className="w-4 h-4" />}
>
{t("inbox.clearAll")}
</Button>
</div>
)}
{/* 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 && (
<PageToolbar>
<div className="px-4 pt-2 pb-1 max-w-3xl w-full mx-auto flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={markAll}
disabled={!hasUnread || readAllMut.isPending}
icon={<CheckCheck />}
>
{t("inbox.markAllRead")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearEverything}
disabled={clearMut.isPending}
icon={<Trash2 />}
>
{t("inbox.clearAll")}
</Button>
</div>
</div>
</PageToolbar>
</PageToolbar>
)}
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto space-y-4">
{q.isError && !hasAny ? (
@@ -191,9 +184,7 @@ export default function NotificationsPanel() {
<div className="space-y-4">
{serverItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionSystem")}
</div>
<SectionLabel className="mb-2">{t("inbox.sectionSystem")}</SectionLabel>
<div className="flex flex-col gap-2">
{serverItems.map((n) => (
<NotificationRow
@@ -211,9 +202,7 @@ export default function NotificationsPanel() {
)}
{clientItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionActivity")}
</div>
<SectionLabel className="mb-2">{t("inbox.sectionActivity")}</SectionLabel>
<div className="flex flex-col gap-2">
{clientItems.map((n) => (
<ClientActivityRow
+3 -2
View File
@@ -66,6 +66,7 @@ import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { IconLink, LinkButton } from "./ui/LinkButton";
import { SectionLabel } from "./ui/Headings";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -804,9 +805,9 @@ export default function PlayerModal({
popover={descPop}
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</div>
</SectionLabel>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
+30 -17
View File
@@ -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() {
<div className="px-4 pt-2 pb-1 max-w-400 mx-auto">
<div className="flex items-center gap-1.5">
<ItemCount count={browseQ.isLoading ? undefined : total} />
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={plexChips.length} />
{/* 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. */}
<FilterSelect
label={t("plex.filter.show")}
testId="plex-show"
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
selected={[show]}
onChange={(next) => setShow(next[0] ?? "all")}
/>
{!fold.folded && (
<FilterSelect
label={t("plex.filter.show")}
testId="plex-show"
options={SHOW_OPTS.map((s) => ({ 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 && (
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
)}
</div>
{/* 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. */}
<div className="mt-1 flex flex-wrap items-center gap-2">
{/* The active personal view (if any) leads the applied-filter row, like the feed. */}
<SelectedViewChip />
<ActiveFilterChips
filters={plexChips}
onClearAll={clearAllFilters}
className="flex-1"
/>
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
{!fold.folded && (
<div className="mt-1 flex flex-wrap items-center gap-2">
{/* The active personal view (if any) leads the applied-filter row, like the feed. */}
<SelectedViewChip />
<ActiveFilterChips
filters={plexChips}
onClearAll={clearAllFilters}
className="flex-1"
/>
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
)}
</div>
</PageToolbar>
@@ -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({
/>
) : (
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold leading-tight">{d.title}</h1>
<PageHeading className="text-2xl font-semibold leading-tight">
{d.title}
</PageHeading>
{readOnly && (
<Badge title={t("plex.collection.readOnly")}>
<Lock />
+9 -3
View File
@@ -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 (
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
@@ -452,9 +455,12 @@ export default function PlexInfo({
{/* Header: close (overlay) / customize */}
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0 flex-1">
<h1 className={overlay ? "text-lg font-bold text-white" : "text-2xl font-bold"}>
{detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title}
</h1>
{/* The page's own h1; over the running player it is the info dialog's title. */}
{overlay ? (
<h2 className="text-lg font-bold text-white">{infoTitle}</h2>
) : (
<PageHeading className="text-2xl font-bold">{infoTitle}</PageHeading>
)}
{detail.kind === "episode" && (
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
S{detail.season_number}·E{detail.episode_number} — {detail.title}
+4 -1
View File
@@ -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")}
/>
) : (
<h1 className="truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
<PageHeading className="truncate text-2xl font-bold">
{q.data?.title ?? " "}
</PageHeading>
)}
{items.length > 0 && (
<div className="mt-0.5 text-sm text-muted">
+4 -5
View File
@@ -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 }) {
<Modal title={t("downloads.profiles.title")} onClose={onClose} maxWidth="max-w-xl">
{/* Existing formats */}
<div className="space-y-1.5 mb-4">
<div className="text-xs uppercase tracking-wide text-muted">
{t("downloads.profiles.builtin")}
</div>
<SectionLabel level={3}>{t("downloads.profiles.builtin")}</SectionLabel>
{builtins.map((p) => (
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
@@ -118,9 +117,9 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
</div>
))}
{mine.length > 0 && (
<div className="text-xs uppercase tracking-wide text-muted pt-2">
<SectionLabel level={3} className="pt-2">
{t("downloads.profiles.yours")}
</div>
</SectionLabel>
)}
{mine.map((p) => (
<div key={p.id} className={`${SURFACE.card} flex items-center gap-2 text-sm px-3 py-1.5`}>
+5 -12
View File
@@ -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 */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("scheduler.jobsTitle")}
</div>
<SectionLabel className="mb-2">{t("scheduler.jobsTitle")}</SectionLabel>
<StatusLegend />
<div className="space-y-1.5">
{data.jobs.map((job) => (
@@ -600,9 +599,7 @@ export default function Scheduler() {
{/* Queue */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("scheduler.queueTitle")}
</div>
<SectionLabel className="mb-2">{t("scheduler.queueTitle")}</SectionLabel>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Stat
icon={Database}
@@ -639,9 +636,7 @@ export default function Scheduler() {
{/* Quota */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("scheduler.quotaTitle")}
</div>
<SectionLabel className="mb-2">{t("scheduler.quotaTitle")}</SectionLabel>
<div className={`${SURFACE.card} p-4`}>
<div className="flex items-center gap-2 text-sm">
<Gauge className="w-4 h-4 text-muted shrink-0" />
@@ -671,9 +666,7 @@ export default function Scheduler() {
{/* Maintenance settings */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("scheduler.maintenance.title")}
</div>
<SectionLabel className="mb-2">{t("scheduler.maintenance.title")}</SectionLabel>
<MaintenanceSettings
m={data.maintenance}
onSave={(batch) => batchMut.mutate(batch)}
+17 -10
View File
@@ -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 (
<div className="p-4 max-w-4xl mx-auto">
{/* Only admins have a second (System) tab; everyone else just sees the Overview. */}
<div className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
{/* 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 && (
<Tabs
active={tab}
onChange={setTab}
tabs={(["overview", "system"] as StatsTab[]).map((id) => ({
id,
label: t(`stats.tabs.${id}`),
}))}
/>
<PageToolbar>
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
<Tabs
active={tab}
onChange={setTab}
tabs={(["overview", "system"] as StatsTab[]).map((id) => ({
id,
label: t(`stats.tabs.${id}`),
}))}
className="flex flex-wrap gap-1 pb-2"
/>
</div>
</PageToolbar>
)}
{tab === "system" && isAdmin ? <SystemStats /> : <Overview me={me} />}
+4 -3
View File
@@ -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({
)}
</div>
<div className="min-w-0 flex-1">
<h1 className="text-xl font-semibold">{d.name}</h1>
<PageHeading className="text-xl font-semibold">{d.name}</PageHeading>
{d.known_for_department && (
<p className="text-xs font-medium text-accent">{d.known_for_department}</p>
)}
@@ -393,7 +394,7 @@ export function PlexShowView({
/>
}
>
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
<PageHeading className="text-2xl font-semibold leading-tight">{show.title}</PageHeading>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
{show.year != null && (
<Filterable
@@ -655,7 +656,7 @@ export function PlexSeasonView({
<Button variant="link-muted" onClick={onBack} className="text-sm">
{d.show.title}
</Button>
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
<PageHeading className="text-2xl font-semibold leading-tight">{se.title}</PageHeading>
<p className="text-sm text-muted mt-1">
{t("plex.series.episodeCount", { count: se.episode_count })}
</p>
@@ -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 (
<>
<FilterFoldButton fold={fold} label="Filters" count={2} />
{!fold.folded && <div data-testid="row">the filter row</div>}
</>
);
}
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(<Band />);
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(<Band />);
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();
});
});
+28
View File
@@ -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 (
<SelectTrigger
context="toolbar"
open={fold.open}
aria-expanded={fold.open}
icon={<SlidersHorizontal className="size-3.5 shrink-0 text-muted" />}
onClick={fold.toggle}
>
{count > 0 ? `${label} · ${count}` : label}
</SelectTrigger>
);
}
+30
View File
@@ -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 <h1 {...props} />;
}
/** 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 (
<Tag className={`text-xs uppercase tracking-wide text-muted ${className}`}>{children}</Tag>
);
}
+6 -1
View File
@@ -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.
<main
ref={setRef}
id={CONTENT_ID}
tabIndex={-1}
style={fade.style}
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable"
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable focus:outline-none"
>
<StickyTopProvider value={registerStickyTop}>{children}</StickyTopProvider>
</main>
@@ -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 }) => <div>{children}</div>,
@@ -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(<PageShell title="Downloads">content</PageShell>);
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(
<PageShell title="Plex">
<PageHeading>Knight Rider</PageHeading>
</PageShell>,
);
expect([...document.querySelectorAll("h1")].map((h) => h.textContent)).toEqual([
"Knight Rider",
]);
rerender(<PageShell title="Plex">grid</PageShell>);
expect([...document.querySelectorAll("h1")].map((h) => h.textContent)).toEqual(["Plex"]);
});
});
+27 -11
View File
@@ -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 <PageToolbar>. 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<HTMLDivElement> }>, {
ref: setHeaderEl,
@@ -133,17 +146,20 @@ export default function PageShell({
: header;
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
{measuredHeader}
<div
ref={setContentEl}
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
>
{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. */}
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
<PageScroller scrollKey={scrollKey}>{children}</PageScroller>
</div>
<PageHeadingProvider value={registerOwnHeading}>
{measuredHeader}
<div
ref={setContentEl}
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
>
{title && ownHeadings === 0 && <h1 className="sr-only">{title}</h1>}
{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. */}
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
<PageScroller scrollKey={scrollKey}>{children}</PageScroller>
</div>
</PageHeadingProvider>
</PageToolbarSlot.Provider>
);
}
+1 -1
View File
@@ -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)
@@ -0,0 +1,31 @@
// @vitest-environment jsdom
//
// E28 S3d4 (UX-4): "Skip to content" moves the focus to the page's <main> 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(
<>
<SkipLink label="Skip to content" />
<main id={CONTENT_ID} tabIndex={-1}>
page
</main>
</>,
);
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);
});
});
+23
View File
@@ -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 (
<LinkButton
href={`#${CONTENT_ID}`}
variant="primary"
size="md"
className="fixed left-3 top-3 z-tooltip -translate-y-[calc(100%+1rem)] focus:translate-y-0"
onClick={(e) => {
e.preventDefault();
document.getElementById(CONTENT_ID)?.focus();
}}
>
{label}
</LinkButton>
);
}
+4 -5
View File
@@ -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 (
<div className={card ? `${SURFACE.panel} p-4 mb-4` : "mb-6"}>
<div className={`text-xs uppercase tracking-wide text-muted ${card ? "mb-3" : "mb-2"}`}>
{title}
</div>
<SectionLabel className={card ? "mb-3" : "mb-2"}>{title}</SectionLabel>
{children}
</div>
);
+3
View File
@@ -0,0 +1,3 @@
// The id of the page's content region, `PageScroller`'s <main>: the target of `SkipLink`. One
// constant, so the link and its target cannot drift apart.
export const CONTENT_ID = "content";
+15
View File
@@ -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]);
}
@@ -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",
-2
View File
@@ -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",
+1
View File
@@ -1,5 +1,6 @@
{
"primary": "Primary navigation",
"skipToContent": "Skip to content",
"role": {
"admin": "Admin",
"user": "User",
@@ -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",
-2
View File
@@ -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",
+1
View File
@@ -1,5 +1,6 @@
{
"primary": "Fő navigáció",
"skipToContent": "Ugrás a tartalomra",
"role": {
"admin": "Admin",
"user": "Felhasználó",
@@ -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 `<h1>` 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 <h1>", () => {
const own = sources()
.filter(([name]) => !STANDALONE.has(name))
.filter(([, src]) => /<h1[\s>]/.test(src))
.map(([name]) => name);
expect(own).toEqual([]);
});
});
+23
View File
@@ -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) };
}