diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 2350d0d1..4a85a08a 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -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 |
diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx
index a15c2ca3..8df8bd86 100644
--- a/frontend/src/components/ChannelPage.tsx
+++ b/frontend/src/components/ChannelPage.tsx
@@ -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. */}
-
+ {/* 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". */}
+
-
+
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}
- />
+
+ 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}
+ />
+
)}
diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx
index dc33daad..764709d6 100644
--- a/frontend/src/components/NavigationProvider.dom.test.tsx
+++ b/frontend/src/components/NavigationProvider.dom.test.tsx
@@ -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 : {name};
+ }
+ const view = rtlRender(, { wrapper });
+ await settle();
+ expect(document.title).toBe("plex.navLabel · Siftlode"); // no title yet: the place alone
+
+ view.rerender();
+ await settle();
+ expect(document.title).toBe("Nicolas Cage · plex.navLabel · Siftlode");
+
+ view.rerender();
+ 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(Season 1, { wrapper });
+ await settle();
+ expect(document.title).toBe("Voyager · Season 1 · plex.navLabel · Siftlode");
+ });
});
describe("NavigationProvider · setPage's same-page guard", () => {
diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx
index 9aea5a63..c17cfc51 100644
--- a/frontend/src/components/NavigationProvider.tsx
+++ b/frontend/src/components/NavigationProvider.tsx
@@ -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"):
+ // "