fix(ui): E31 S2 — the tab names the object, the channel header wraps, the player fits a short window

- The browser tab names the place and the object: PageHeading reports the
  view's own title (lib/documentTitle), so a channel, playlist or any Plex
  view reads "<object> · <module> · Siftlode" however it was opened; the
  Feed is named too; a season adds its show (tabTitle).
- The channel page's tab + action row wraps and lets the tag lane fold into
  "+N": at 1024 it ran past the edge and the page scrolled sideways.
- The YouTube player card's width is capped from the window height less the
  chrome measured under the video: at 133% zoom (685px) it cut its channel row.
- Plex certificate words (Not Rated, Approved, Passed) are named through the
  vocabulary on the info badge, show page, collection, filter and chips.
- DESIGN.md: the zoom target is 125% and 133%, not 200% (decision log).
This commit is contained in:
2026-10-01 21:17:20 +02:00
parent 2543d44860
commit ec22eefd31
20 changed files with 265 additions and 46 deletions
+14 -10
View File
@@ -414,7 +414,9 @@ export default function ChannelPage({
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
corner: there are only ever two tabs, so there's room, and it keeps the things you click
within a few pixels of each other instead of across the page. */}
<div className="flex items-center gap-2 mt-3 border-b border-border pb-2">
{/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and
the whole page scrolled sideways (E31 S1). The tag lane takes what is left and folds into "+N". */}
<div className="flex flex-wrap items-center gap-2 mt-3 border-b border-border pb-2">
<Tabs
idBase={panel.idBase}
className="flex gap-1"
@@ -425,7 +427,7 @@ export default function ChannelPage({
{ id: "about", label: t("channel.tabAbout") },
]}
/>
<div className="ml-2 flex items-center gap-2">
<div className="ml-2 flex min-w-0 flex-1 items-center gap-2">
<IconLink
href={ytUrl}
target="_blank"
@@ -542,14 +544,16 @@ export default function ChannelPage({
otherwise), and personal tags are for your subscriptions — so only offer it on a
channel you actually follow. */}
{ch?.subscribed && !me.is_demo && (
<ChannelTagsControl
userTags={userTags}
attachedIds={ch.tag_ids}
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(channelId, name)}
// The header row has free width — show tags inline instead of collapsing to "+N".
bounded={false}
/>
<div className="min-w-0 flex-1">
<ChannelTagsControl
userTags={userTags}
attachedIds={ch.tag_ids}
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(channelId, name)}
// The header row's leftover width: tags inline, "+N" only when they do not fit.
bounded={false}
/>
</div>
)}
</div>
</div>
@@ -10,7 +10,7 @@
// then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as
// bookkeeping.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import { renderHook, render as rtlRender, act, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
@@ -28,6 +28,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { useBackToClose } from "../lib/history";
import { PageHeading } from "./ui/Headings";
import { qk } from "../lib/queryKeys";
@@ -88,6 +89,41 @@ describe("NavigationProvider · the tab title", () => {
expect(document.title).not.toBe("Siftlode");
expect(document.title.endsWith(" · Siftlode")).toBe(true);
});
// E31 S1: the Feed's tab read a bare "Siftlode" — the one page that named no place.
it("names the Feed like every other page", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
render();
await settle();
expect(document.title).toBe("header.account.feed · Siftlode");
});
// E31 S1: a channel opened from a link was "Channels · Siftlode", the same channel opened in the
// app was named; a Plex title, person or collection only "Plex". The object is the view's heading.
it("puts the open view's own heading before the place, and drops it when the view closes", async () => {
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
function View({ name }: { name: string | null }) {
return name === null ? null : <PageHeading>{name}</PageHeading>;
}
const view = rtlRender(<View name="" />, { wrapper });
await settle();
expect(document.title).toBe("plex.navLabel · Siftlode"); // no title yet: the place alone
view.rerender(<View name="Nicolas Cage" />);
await settle();
expect(document.title).toBe("Nicolas Cage · plex.navLabel · Siftlode");
view.rerender(<View name={null} />);
await settle();
expect(document.title).toBe("plex.navLabel · Siftlode");
});
it("takes a heading's tabTitle over its text", async () => {
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
rtlRender(<PageHeading tabTitle="Voyager · Season 1">Season 1</PageHeading>, { wrapper });
await settle();
expect(document.title).toBe("Voyager · Season 1 · plex.navLabel · Siftlode");
});
});
describe("NavigationProvider · setPage's same-page guard", () => {
+9 -12
View File
@@ -4,6 +4,7 @@ import { useMe } from "../lib/useMe";
import { urlWithParams, type Page } from "../lib/urlState";
import { LOGS_KEYS } from "../lib/logsUrlState";
import { pageTitleKey } from "../lib/pageMeta";
import { useDocumentObjectTitle } from "../lib/documentTitle";
import { setAccountRaw } from "../lib/storage";
import { setNavigator } from "../lib/nav";
import {
@@ -164,21 +165,17 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
if (page !== "feed") setYtSearch(null);
}
// Reflect the current module in the browser tab title. Feed = brand only; an open channel page
// shows the channel name. Signed out, the page on screen is the public landing whatever `page`
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
// "<object> · <module> · Siftlode". The object is the open view's own heading (a channel, a Plex
// title, a playlist — `PageHeading` reports it), so a channel opened from a link is named the same
// as one opened in the app. Signed out, the page on screen is the public landing whatever `page`
// says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19).
const signedIn = useMe() != null;
const objectTitle = useDocumentObjectTitle();
useEffect(() => {
const brand = "Siftlode";
const label = !signedIn
? null
: channelView
? channelView.name || t("header.channelManager")
: page === "feed"
? null
: t(pageTitleKey(page));
document.title = label ? `${label} · ${brand}` : brand;
}, [signedIn, page, channelView, i18n.language, t]);
const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : [];
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
}, [signedIn, page, objectTitle, i18n.language, t]);
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
// entry: every later page change writes its own entry through setPage.
@@ -443,3 +443,28 @@ describe("PlayerModal — the side arrows", () => {
expect(arrow.parentElement!.className).toMatch(/sm:px-14/);
});
});
// E31 S1: at 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
// row. The card's width is capped from the window height less what sits under the video, measured.
describe("PlayerModal — fits the window's height", () => {
it("caps the card's width by the chrome measured under the video", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.classList.contains("player-stage") ? 470 : 0;
});
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.querySelector(":scope > .player-stage") ? 630 : 0;
});
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(row.style.maxWidth).toBe("min(63rem, calc((100dvh - 3rem - 160px) * 16 / 9 + 7rem))");
});
});
+36 -2
View File
@@ -1,4 +1,12 @@
import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from "react";
import {
useCallback,
useEffect,
useEffectEvent,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./navigationContext";
@@ -181,6 +189,25 @@ export default function PlayerModal({
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
// dep lists too.
const focusModal = useCallback(() => cardRef.current?.focus(), []);
// The card fits the window, video AND what sits under it (the queue strip, title, channel row): the
// video is 16:9 at the card's width, so on a short window (133% zoom on a 1080p screen: 685px) a
// full-width card ran past the bottom and cut its channel row (E31 S1). `below` is that chrome's
// height, measured (it wraps with the title and the text size), and caps the card's width below.
const [below, setBelow] = useState(0);
useLayoutEffect(() => {
const card = cardRef.current;
const stage = stageRef.current;
if (!card || !stage) return;
const measure = () => {
// A maximised stage is fixed to the viewport, out of the card's flow: keep the last value.
if (getComputedStyle(stage).position === "fixed") return;
setBelow(card.scrollHeight - stage.offsetHeight);
};
measure();
const ro = new ResizeObserver(measure);
for (const child of card.children) ro.observe(child);
return () => ro.disconnect();
}, []);
// The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back
// to whatever opened the player, and the page lock is the one counted lock. It opens focused on the
// CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one
@@ -579,7 +606,14 @@ export default function PlayerModal({
it and a portrait tablet lost it). stopPropagation so a click steps instead of closing.
Hidden on narrow screens where there's no room beside the card. */}
<div
className={`relative flex w-full max-h-full ${hasQueue ? "max-w-[63rem] sm:px-14" : "max-w-4xl"}`}
className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`}
// The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the
// chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem).
style={{
maxWidth: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${
hasQueue ? " + 7rem" : ""
}))`,
}}
>
{hasQueue && (
<Pressable
+4 -1
View File
@@ -63,6 +63,7 @@ import { CheckRow } from "./ui/NativeControls";
import { useReturnFocus } from "./ui/useReturnFocus";
import { MoreButton } from "./ui/MoreButton";
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
import { PageHeading } from "./ui/Headings";
import { useCopyItems } from "./copyItems";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
@@ -875,7 +876,9 @@ export default function Playlists() {
aria-label={t("playlists.rename")}
/>
) : (
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
<PageHeading className="text-lg font-semibold truncate">
{plName(detail)}
</PageHeading>
)}
<div className="ml-auto flex gap-1.5 items-center shrink-0">
{linked && detail.dirty && (
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { vocab } from "../lib/vocab";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
import { qk } from "../lib/queryKeys";
@@ -262,7 +263,7 @@ export default function PlexCollectionView({
const meta = [
d?.year_min != null &&
(d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`),
d?.content_rating,
d?.content_rating && vocab(t, "certificate", d.content_rating),
d && t("plex.browse.items", { count: d.child_count ?? items.length }),
formatRuntime(d?.total_runtime_s, lang),
].filter(Boolean) as string[];
@@ -15,8 +15,13 @@ vi.mock("react-i18next", async (importOriginal) => {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
// A certificate code ("R") is no catalog word: it falls back to itself, as in the real catalog.
t: (key: string, opts?: Record<string, unknown>) =>
opts?.name ? `${key}:${opts.name}` : key,
opts?.name
? `${key}:${opts.name}`
: key.startsWith("vocab.certificate.") && typeof opts?.defaultValue === "string"
? opts.defaultValue
: key,
}),
};
});
+2 -2
View File
@@ -187,7 +187,7 @@ function FactsPanel({
label: t("plex.info.facts.cert"),
node: (
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
{cert}
{vocab(t, "certificate", cert)}
</Filterable>
),
});
@@ -589,7 +589,7 @@ export default function PlexInfo({
: undefined
}
>
{detail.content_rating}
{vocab(t, "certificate", detail.content_rating)}
</Filterable>
)}
{detail.duration_seconds ? (
+5 -1
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
import { useTranslation } from "react-i18next";
import { vocab } from "../lib/vocab";
import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
@@ -247,7 +248,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
body: (
<Combobox
label={t("plex.filter.contentRating")}
options={facets.content_ratings}
options={facets.content_ratings.map((o) => ({
...o,
label: vocab(t, "certificate", o.value),
}))}
selected={filters.contentRatings}
onChange={(next) => patch({ contentRatings: next })}
/>
+5 -2
View File
@@ -583,7 +583,7 @@ export function PlexShowView({
: undefined
}
>
{show.content_rating}
{vocab(t, "certificate", show.content_rating)}
</Filterable>
)}
{show.season_count != null && (
@@ -838,7 +838,10 @@ export function PlexSeasonView({
<Button variant="link-muted" selectable onClick={onBack} className="text-sm">
{d.show.title}
</Button>
<PageHeading className="text-2xl font-semibold leading-tight">
<PageHeading
className="text-2xl font-semibold leading-tight"
tabTitle={`${d.show.title} · ${seasonTitle(se, t)}`}
>
{seasonTitle(se, t)}
</PageHeading>
<p className="text-sm text-muted mt-1">
+11 -4
View File
@@ -1,4 +1,5 @@
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { type ComponentPropsWithoutRef, type ReactNode, useRef } from "react";
import { useNameTabAfter } from "../../lib/documentTitle";
import { useOwnPageHeading } from "./pageHeading";
// The page's heading outline (B4, UX-15): one `h1` per page, then a heading per section. A screen
@@ -6,10 +7,16 @@ import { useOwnPageHeading } from "./pageHeading";
// 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">) {
* shell's hidden page name, and the object the browser tab names (`lib/documentTitle`). `tabTitle`
* only when the heading alone does not say what it is (a season: "Season 1" → "Voyager · Season 1"). */
export function PageHeading({
tabTitle,
...props
}: ComponentPropsWithoutRef<"h1"> & { tabTitle?: string }) {
useOwnPageHeading();
return <h1 {...props} />;
const ref = useRef<HTMLHeadingElement>(null);
useNameTabAfter(ref, tabTitle);
return <h1 ref={ref} {...props} />;
}
/** A section's caption: the small uppercase muted label, as a heading. `level` 3 inside a dialog or
@@ -23,6 +23,7 @@ const plexState = {
filters: {
...EMPTY_PLEX_FILTERS,
genres: ["Action"],
contentRatings: ["Not Rated"],
// "A" was picked in the sidebar (its title came along); "B" arrived from a shared LINK, which
// carries ids only — the case that used to render a raw rating_key.
collections: ["A", "B"],
@@ -64,6 +65,13 @@ describe("usePlexActiveFilters", () => {
expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true);
});
// E31 S1: the chip read Plex's English word ("Not Rated") under a Hungarian UI.
it("names a certificate chip through the vocabulary, keeping Plex's value as the key", () => {
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
const chip = result.current.find((c) => c.key === "contentRating:Not Rated");
expect(chip?.label).toBe("vocab.certificate.NotRated");
});
it("labels a link-supplied collection with its resolved title, not the bare id", () => {
// Seed the resolution the hook would fetch, so this asserts the WIRING (hook -> builder -> label)
// rather than the network.
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import { vocabI18nKey } from "../lib/vocab";
import { type ActiveFilter } from "../lib/useActiveFilters";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
@@ -53,7 +54,9 @@ export function buildPlexActiveFilters(
for (const r of filters.contentRatings) {
out.push({
key: `contentRating:${r}`,
label: r,
label: vocabI18nKey("certificate", r)
? t(vocabI18nKey("certificate", r), { defaultValue: r })
: r,
remove: () => patch({ contentRatings: filters.contentRatings.filter((x) => x !== r) }),
});
}
+6
View File
@@ -165,5 +165,11 @@
"mono": "Mono",
"audioN": "Audio {{n}}",
"subtitleN": "Subtitle {{n}}"
},
"certificate": {
"NotRated": "Not Rated",
"Unrated": "Unrated",
"Approved": "Approved",
"Passed": "Passed"
}
}
+6
View File
@@ -165,5 +165,11 @@
"mono": "monó",
"audioN": "{{n}}. hangsáv",
"subtitleN": "{{n}}. felirat"
},
"certificate": {
"NotRated": "Nincs besorolva",
"Unrated": "Besorolatlan",
"Approved": "Jóváhagyva",
"Passed": "Engedélyezve"
}
}
+58
View File
@@ -0,0 +1,58 @@
import { type RefObject, useLayoutEffect, useRef, useSyncExternalStore } from "react";
// The browser tab names the place AND the object in it (DESIGN § Composition and the reader, "Where am
// I"): "Nicolas Cage · Plex · Siftlode". The place is the module (NavigationProvider); the object is the
// view's own visible title, so it is read from the element that already shows it (`PageHeading`), not
// handed over a second time by every view — a view with a title on screen names the tab by having it.
// The latest mounted title wins (a view stacked over another); an empty one (still loading) is skipped.
type Entry = { text: string };
let entries: Entry[] = [];
const subs = new Set<() => void>();
function emit(): void {
for (const cb of subs) cb();
}
function snapshot(): string | null {
let found: string | null = null;
for (const e of entries) if (e.text) found = e.text;
return found;
}
/** The object the screen shows, for the tab title; null when the view has no title of its own. */
export function useDocumentObjectTitle(): string | null {
return useSyncExternalStore(
(cb) => {
subs.add(cb);
return () => subs.delete(cb);
},
snapshot,
snapshot,
);
}
/** Names the tab after what `el` shows (its text), or after `override` when given (a season page's
* heading is "Season 1"; the tab wants the show too). Re-read after every render, so a title that
* arrives with its data, or changes in place, follows. */
export function useNameTabAfter(el: RefObject<HTMLElement | null>, override?: string): void {
const entry = useRef<Entry | null>(null);
useLayoutEffect(() => {
const e: Entry = { text: "" };
entry.current = e;
entries = [...entries, e];
return () => {
entries = entries.filter((x) => x !== e);
entry.current = null;
emit();
};
}, []);
// No dependency list: the text is the DOM's, read after each render of the heading.
useLayoutEffect(() => {
const e = entry.current;
const text = (override ?? el.current?.textContent ?? "").replace(/\s+/g, " ").trim();
if (!e || e.text === text) return;
e.text = text;
emit();
});
}
+9
View File
@@ -18,6 +18,10 @@ describe("a vocabulary word", () => {
["autoTag", "Hungarian", "Magyar"],
["profile", "Video only (MP4)", "Csak videó (MP4)"],
["topic", "Lifestyle_(sociology)", "Életmód"],
// E31 S1: Plex's certificate words came through in English on the info badge and the filter.
["certificate", "Not Rated", "Nincs besorolva"],
["certificate", "Approved", "Jóváhagyva"],
["certificate", "Passed", "Engedélyezve"],
] as const)("%s %j reads %j in Hungarian", (group, word, shown) => {
expect(vocab(hu, group, word)).toBe(shown);
});
@@ -27,6 +31,11 @@ describe("a vocabulary word", () => {
expect(vocab(en, "topic", "Pop_music", "Pop music")).toBe("Pop music");
});
it("a certificate code reads the same in every language", () => {
expect(vocab(hu, "certificate", "PG-13")).toBe("PG-13");
expect(vocab(hu, "certificate", "TV-MA")).toBe("TV-MA");
});
it("an unknown word is shown, not hidden or keyed", () => {
expect(vocab(hu, "crewJob", "Gaffer")).toBe("Gaffer");
expect(vocab(hu, "topic", "Knitting_(craft)", "Knitting (craft)")).toBe("Knitting (craft)");
+12 -4
View File
@@ -2,23 +2,31 @@ import type { TFunction } from "i18next";
import type { PlexTrackNaming } from "./api";
// E30: the closed vocabularies the server stores in ENGLISH — a TMDB crew job or department, an
// auto-tag, a built-in download profile, a YouTube topic — named in the reader's language. The data
// auto-tag, a built-in download profile, a YouTube topic, a Plex certificate's word ("Not Rated"; a
// code such as "PG-13" reads the same everywhere and falls through as stored) — named in the reader's language. The data
// stays language-neutral (it is filtered and matched on); only the NAME is translated, here, from the
// `vocab` locale area. A word the area does not hold shows as stored, so a new server value is never
// hidden — and the backend's own lists are held key-for-key to the area by backendIds.contract.
type Group = "crewJob" | "department" | "autoTag" | "profile" | "topic";
type Group = "crewJob" | "department" | "autoTag" | "profile" | "topic" | "certificate";
/** A vocabulary word's key: its letters and digits ("Health & Fitness" → "HealthFitness"). */
export function vocabKey(word: string): string {
return word.replace(/[^A-Za-z0-9]/g, "");
}
/** The i18n key of one word of `group` ("" when the word has no letters or digits) — for a caller
* whose `t` is not i18next's own (a pure builder handed a narrow `t`). */
export function vocabI18nKey(group: Group, word: string): string {
const key = vocabKey(word);
return key ? `vocab.${group}.${key}` : "";
}
/** One stored English word of `group`, in the reader's language (`fallback`, by default the word
* itself, when the area does not hold it). */
export function vocab(t: TFunction, group: Group, word: string, fallback = word): string {
const key = vocabKey(word);
return key ? t(`vocab.${group}.${key}`, { defaultValue: fallback }) : fallback;
const key = vocabI18nKey(group, word);
return key ? t(key, { defaultValue: fallback }) : fallback;
}
/** A download profile's name: a built-in one is seeded under an English name and named here; a user's