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
+6 -4
View File
@@ -39,8 +39,8 @@ runs this check before it is called done (E28 S3g):
4. **Seen.** A screenshot of every changed state, thin edges at 1:1; the tablet widths (1024 × 768, a
narrow split screen) when the layout can reflow. Phones are the E29 epic's, not this check's.
5. **Judged as a whole** (E30 S6). For each screenshot, one written line on its composition: where the empty
space is, what hugs one side, what the eye lands on first. Then the screen at 200% browser zoom and at
the largest text size. Both are held to [Composition and the reader](#composition-and-the-reader--decided-e30-s6).
space is, what hugs one side, what the eye lands on first. Then the screen at 125% and 133% browser
zoom and at the largest text size. All are held to [Composition and the reader](#composition-and-the-reader--decided-e30-s6).
A rule that holds is not the same as a screen that works: the person page passed every rule above with
half its panel empty.
@@ -465,8 +465,9 @@ of a full-width panel and nothing on the right.
(TMDB, YouTube) is fetched ahead of time by a job, not on the click, so a page never waits on it.
Late content keeps its place (B8). [Nielsen #1, RAIL]
- **Zoom and text size are first-class.** The owner reads with the browser zoomed and a large text size.
Every screen holds at 200% browser zoom on a desktop window and at the largest Settings text size at
1024 wide: nothing clipped or overlapping, no sideways page scroll, every control reachable and at
Every screen holds at 125% and 133% browser zoom on a 1080p desktop window (what the owner uses: the
layout of a 1536 × 729 and a 1444 × 685 viewport) and at the largest Settings text size at 1024 wide:
nothing clipped or overlapping, no sideways page scroll, every control reachable and at
least its rung, text wrapping rather than cut. Measured, not assumed, on every UI change. [WCAG 1.4.4,
1.4.10, 1.4.12]
@@ -688,3 +689,4 @@ of a full-width panel and nothing on the right.
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
+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