diff --git a/packages/viewer/e2e/keyboard.e2e.mjs b/packages/viewer/e2e/keyboard.e2e.mjs
index 2014c7c..33904d4 100644
--- a/packages/viewer/e2e/keyboard.e2e.mjs
+++ b/packages/viewer/e2e/keyboard.e2e.mjs
@@ -139,6 +139,38 @@ try {
skip("modal focus-trap (no clear-all in this environment)");
}
+ // --- Pages stepper: arrow-key nav + nested isolation -----------------------------------------
+ await push("pager", "component", JSON.stringify({
+ type: "Pages",
+ props: { pages: [
+ { title: "One", node: { type: "Pages", props: { pages: [
+ { title: "i1", node: { type: "Text", children: ["inner 1"] } },
+ { title: "i2", node: { type: "Text", children: ["inner 2"] } },
+ ] } } },
+ { title: "Two", node: { type: "Text", children: ["outer two"] } },
+ { title: "Three", node: { type: "Text", children: ["outer three"] } },
+ ] },
+ }));
+ await p.evaluate(() => { location.hash = "#kbd%2Fpager"; });
+ await p.waitForSelector(".tc-pages .tc-pages", { timeout: 8000 });
+ const bodyHas = (s) => p.evaluate((t) => (document.body.textContent || "").includes(t), s);
+
+ await p.evaluate(() => document.querySelector('.tc-pages > .tc-pages-bar [aria-label="next page"]')?.focus());
+ await p.keyboard.press("ArrowRight");
+ ok("ArrowRight steps the focused pager forward", await bodyHas("outer two"));
+ await p.keyboard.press("ArrowLeft");
+ ok("ArrowLeft steps it back", await bodyHas("inner 1"));
+
+ await p.evaluate(() => document.querySelector('.tc-pages .tc-pages [aria-label="next page"]')?.focus());
+ await p.keyboard.press("ArrowRight");
+ ok("nested pager arrows step the inner pager only", (await bodyHas("inner 2")) && !(await bodyHas("outer two")));
+
+ await p.evaluate(() => document.querySelector('.tc-pages > .tc-pages-bar [aria-label="next page"]')?.focus());
+ await p.keyboard.press("End");
+ ok("End jumps the pager to the last page", await bodyHas("outer three"));
+ await p.keyboard.press("Home");
+ ok("Home jumps back to the first page", await bodyHas("inner 1"));
+
// --- Regression: Escape with nothing open is harmless ---------------------------------------
await p.keyboard.press("Escape");
ok("Escape with no overlay does not throw", true);
diff --git a/packages/viewer/src/client/renderers/pages.tsx b/packages/viewer/src/client/renderers/pages.tsx
index b86c4c8..99a9e70 100644
--- a/packages/viewer/src/client/renderers/pages.tsx
+++ b/packages/viewer/src/client/renderers/pages.tsx
@@ -1,10 +1,11 @@
-import { Group, Pagination, Text } from "@mantine/core";
-import { useState } from "react";
+import { Button, Text } from "@mantine/core";
+import { createContext, useContext, useState } from "react";
+import { injectStyle } from "./inject-style.js";
import { resolve } from "./component-resolver.js";
/**
* `Pages` — top-level board pagination: one page rendered at a
- * time with a Pagination control above and below the content.
+ * time with a stepper control above and below the content.
* Off-page trees are never resolved, so their images are never
* fetched — a 400-node board pays only for the page in view.
*
@@ -16,38 +17,195 @@ import { resolve } from "./component-resolver.js";
* `pages` is a plain-data prop (the MapDetail.items convention),
* so the resolver leaves the trees raw and this component
* resolves only the active one via `resolve()`.
+ *
+ * The control is built for step-through use (algorithm debuggers
+ * arrow through stages, often with a Pages nested inside a page):
+ * labeled Prev/Next with end-disabled states, the current title
+ * shown prominently, arrow-key navigation while the bar has
+ * focus, and a progress affordance that scales — clickable dots
+ * up to 16 pages, a slim bar plus a jump-by-title select beyond.
+ * Nested pagers read a depth context and render smaller + inset
+ * so outer/inner controls are visually distinct.
*/
export interface PageEntry {
title?: string;
node?: unknown;
}
+/** Dot-per-page stops scaling past this; switch to progress bar + jump select. */
+const DOTS_MAX = 16;
+
+const PagesDepth = createContext(0);
+
+const CSS = `
+.tc-pages-bar { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
+.tc-pages-mid { flex: 1 1 0; min-width: 0; display: flex; align-items: baseline; gap: 8px; }
+.tc-pages-dots { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; margin: 4px 0 8px; }
+.tc-pages-dot { width: 8px; height: 8px; border-radius: 4px; border: none; padding: 0; cursor: pointer;
+ background: var(--mantine-color-default-border, #adb5bd);
+ transition: width 120ms ease, background-color 120ms ease; }
+.tc-pages-dot:hover { background: var(--mantine-color-dimmed, #868e96); }
+.tc-pages-dot[aria-current="page"] { width: 22px; background: var(--mantine-primary-color-filled, #228be6); }
+.tc-pages-dot:focus-visible { outline: 2px solid var(--mantine-primary-color-filled, #228be6); outline-offset: 2px; }
+.tc-pages-track { height: 4px; border-radius: 2px; margin: 4px 0 8px;
+ background: var(--mantine-color-default-border, #dee2e6); overflow: hidden; }
+.tc-pages-fill { height: 100%; border-radius: 2px; background: var(--mantine-primary-color-filled, #228be6);
+ transition: width 160ms ease; }
+.tc-pages-jump { flex: 0 1 auto; min-width: 0; font: inherit; font-weight: 600; font-size: 13px;
+ color: inherit; background: transparent; cursor: pointer;
+ border: 1px solid var(--mantine-color-default-border, #ced4da); border-radius: 6px; padding: 2px 6px; }
+.tc-pages-jump:focus-visible { outline: 2px solid var(--mantine-primary-color-filled, #228be6); outline-offset: 1px; }
+.tc-pages-page { animation: tc-pages-in 160ms ease-out; }
+@keyframes tc-pages-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
+/* nested pagers are subordinate: inset with a rule, tighter bars, smaller dots */
+.tc-pages .tc-pages { padding-left: 10px; border-left: 2px solid var(--mantine-color-default-border, #dee2e6); }
+.tc-pages .tc-pages .tc-pages-bar { margin: 6px 0; }
+.tc-pages .tc-pages .tc-pages-dot { width: 6px; height: 6px; }
+.tc-pages .tc-pages .tc-pages-dot[aria-current="page"] { width: 18px; }
+@media (prefers-reduced-motion: reduce) {
+ .tc-pages-page { animation: none; }
+ .tc-pages-dot, .tc-pages-fill { transition: none; }
+}
+`;
+
export function Pages(props: { pages?: PageEntry[] }) {
const pages = Array.isArray(props.pages) ? props.pages : [];
+ const depth = useContext(PagesDepth);
const [page, setPage] = useState(1);
if (pages.length === 0) return null;
- const idx = Math.min(Math.max(page, 1), pages.length) - 1;
+ injectStyle("tc-pages", CSS);
+
+ const total = pages.length;
+ const idx = Math.min(Math.max(page, 1), total) - 1; // clamp: live patches may shrink `pages`
const entry = pages[idx] ?? {};
- const title = String(entry.title ?? `Page ${idx + 1}`);
- const control = (where: string) => (
-