# Navigation Group — Phase 1 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship runtime-owned roving arrow-key focus, then build Nav, Pagination and Stepper as real components on that foundation. **Architecture:** One declarative attribute pair (`data-wrn-roving` on a container, `data-wrn-roving-item` on children) implements ARIA roving focus once in the reactive runtime, the same way modal dialog focus already lives there. The three components then declare intent with a single attribute instead of carrying five near-identical `@keydown` handlers. Each component uses a local `style {}` block with `wire-*` classes, declares its `outputs`, and takes props in / reports outputs out with no global state. **Tech Stack:** Bun, TypeScript, `.wrn` components, happy-dom for runtime tests, `@wrnexus/test` (`renderComponent`, `mountHtml`) for component tests. Source spec: `docs/superpowers/specs/2026-08-07-navigation-components-design.md` ## Global Constraints These apply to every task. Each has already cost this codebase real debugging time. - **No apostrophes in `.wrn` comments.** The brace scanner breaks on them and reports a misleading unbalanced-brace error pointing at a different block. - **Inside `style {}` blocks use `/* */` only.** `//` is not a CSS comment and silently swallows the rule that follows it. - **`packages/csr/src/reactive-runtime.ts` is a `String.raw` template.** A raw backtick terminates it. Escape as `` \` ``. Avoid template literals and regex literals with escapes inside added code. - **No deferred state writes in client functions.** State written after the function returns — inside a `setTimeout`, a promise callback, or an observer — is dropped. Anything asynchronous belongs in the runtime. - **Never call a peer function after an application callback.** The peer wrapper flushes the entry-time state snapshot. - **No boolean attributes bound to loop variables** (`checked`, `selected`, `disabled` referencing an `{#each}` variable). They compile to server expressions where the loop variable does not exist. - **Package components need explicit imports** (`import X from "./X.wrn"`), enforced by `bun run check:component-imports`. - **happy-dom returns zeros from `getBoundingClientRect()`.** Never gate behaviour on measured size in code that needs a test. Use the `hidden` attribute or a `[data-show="false"]` ancestor instead. - **Runtime size budget is 175000 bytes** for `reactive-runtime.ts`, currently ~167k. Check with `node scripts/security-performance-audit.mjs` before committing. - Every task ends green on `bun run test`. The phase ends green on `bun run check:production`. --- ## File Structure | File | Responsibility | | ----------------------------------------------------------- | --------------------------------------------------------------------------------- | | `packages/csr/src/reactive-runtime.ts` | Add `setupRovingFocus()` and fix `isDialogVisible()` to be testable. Modify only. | | `packages/csr/test/reactive.test.ts` | Roving focus tests and the missing dialog-behaviour tests. | | `packages/ui/components/Pagination.wrn` | Replace the scaffold. Page controls, compact + numbered. | | `packages/ui/components/Stepper.wrn` | Replace the scaffold. Ordered steps, indexed named slots. | | `packages/ui/components/Nav.wrn` | Replace the scaffold. Multi-level links, roving, responsive. | | `packages/ui/test/ui.test.ts` | Per-component assertions. Modify only. | | `examples/component-showcase/scripts/showcase-profiles.mjs` | Demo profiles for the three components. Modify only. | Task order matters: Task 1 produces the roving attribute contract that Tasks 3 and 4 consume. --- ### Task 1: Roving focus in the runtime **Files:** - Modify: `packages/csr/src/reactive-runtime.ts` (add near the existing `setupModalDialogs`) - Test: `packages/csr/test/reactive.test.ts` **Interfaces:** - Consumes: nothing. - Produces: the DOM contract used by Tasks 3 and 4 — - `data-wrn-roving="horizontal" | "vertical" | "both"` on a container element - `data-wrn-roving-item` on each focusable descendant - The runtime sets `tabindex="0"` on the active item and `tabindex="-1"` on the rest, and moves focus on ArrowLeft/Right (horizontal), ArrowUp/Down (vertical), both axes for `"both"`, plus Home and End, wrapping at the ends and skipping disabled items. - Active item is the one with `aria-selected="true"`, `aria-current="page"`, or `aria-current="step"`; otherwise the first item. - [ ] **Step 1: Write the failing tests** Append to `packages/csr/test/reactive.test.ts`: ```ts test("roving focus moves with arrow keys and wraps", () => { const win = mount( `
`, ); const doc = win.document; const a = doc.querySelector("#a") as HTMLElement; const c = doc.querySelector("#c") as HTMLElement; expect(a.getAttribute("tabindex")).toBe("0"); expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("-1"); a.focus(); a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); expect(doc.activeElement!.id).toBe("b"); expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("0"); expect(a.getAttribute("tabindex")).toBe("-1"); doc .querySelector("#b")! .dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); expect(doc.activeElement!.id).toBe("a"); a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); expect(doc.activeElement!.id).toBe("c"); c.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); expect(doc.activeElement!.id).toBe("a"); }); test("roving focus honours Home and End and skips disabled items", () => { const win = mount( `
`, ); const doc = win.document; const a = doc.querySelector("#a") as HTMLElement; a.focus(); a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); expect(doc.activeElement!.id).toBe("c"); doc .querySelector("#c")! .dispatchEvent(new win.KeyboardEvent("keydown", { key: "Home", bubbles: true })); expect(doc.activeElement!.id).toBe("a"); a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "End", bubbles: true })); expect(doc.activeElement!.id).toBe("c"); }); test("horizontal roving ignores vertical arrows so the page still scrolls", () => { const win = mount( `
`, ); const a = win.document.querySelector("#a") as HTMLElement; a.focus(); a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); expect(win.document.activeElement!.id).toBe("a"); }); test("roving tabindex starts on the selected item, not the first", () => { const win = mount( `
`, ); expect(win.document.querySelector("#b")!.getAttribute("tabindex")).toBe("0"); expect(win.document.querySelector("#a")!.getAttribute("tabindex")).toBe("-1"); }); test("nested roving groups do not capture the outer group items", () => { const win = mount( `
`, ); const doc = win.document; const a = doc.querySelector("#a") as HTMLElement; a.focus(); a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); expect(doc.activeElement!.id).toBe("b"); }); ``` - [ ] **Step 2: Run the tests to verify they fail** ```bash bun test packages/csr/test/reactive.test.ts ``` Expected: FAIL. Arrow keys do nothing, so `activeElement` stays `a` and no `tabindex` is set. - [ ] **Step 3: Implement the roving manager** In `packages/csr/src/reactive-runtime.ts`, insert immediately before `function hydrateScopes(root) {`: ```js /* * Roving arrow-key focus, the ARIA pattern shared by tabs, menus, sidebars * and steppers. A container marked data-wrn-roving owns its * [data-wrn-roving-item] descendants: exactly one carries tabindex="0" so * Tab reaches the group once, and the arrow keys move focus within it. * * This lives here rather than in each component because it is the same code * five times over, and because visibility and focus bookkeeping cannot be * held in component state -- a client function writing after it returns has * that write dropped. */ var ROVING_SELECTOR = "[data-wrn-roving]"; var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]"; function rovingItems(container) { var found = []; var candidates = container.querySelectorAll(ROVING_ITEM_SELECTOR); for (var index = 0; index < candidates.length; index += 1) { var candidate = candidates[index]; // A nested group owns its own items; do not steal them. if (candidate.closest(ROVING_SELECTOR) !== container) continue; if (candidate.hasAttribute("disabled")) continue; if (candidate.getAttribute("aria-disabled") === "true") continue; // Deliberately not a size check: the test DOM reports every element as // zero-sized, so measuring here would make the whole feature untestable. if (candidate.hasAttribute("hidden")) continue; if (candidate.closest('[data-show="false"]')) continue; found.push(candidate); } return found; } function rovingActiveIndex(items) { for (var index = 0; index < items.length; index += 1) { var item = items[index]; if (item.getAttribute("aria-selected") === "true") return index; var current = item.getAttribute("aria-current"); if (current === "page" || current === "step" || current === "true") return index; } return 0; } function applyRovingTabindex(items, activeIndex) { for (var index = 0; index < items.length; index += 1) { items[index].setAttribute("tabindex", index === activeIndex ? "0" : "-1"); } } function syncRovingGroup(container) { var items = rovingItems(container); if (!items.length) return; applyRovingTabindex(items, rovingActiveIndex(items)); } function syncRovingGroups() { var groups = document.querySelectorAll(ROVING_SELECTOR); for (var index = 0; index < groups.length; index += 1) syncRovingGroup(groups[index]); } function handleRovingKeydown(event) { var target = event.target; if (!target || !target.closest) return; var item = target.closest(ROVING_ITEM_SELECTOR); if (!item) return; var container = item.closest(ROVING_SELECTOR); if (!container) return; var items = rovingItems(container); var index = items.indexOf(item); if (index === -1) return; var orientation = container.getAttribute("data-wrn-roving") || "horizontal"; var horizontal = orientation === "horizontal" || orientation === "both"; var vertical = orientation === "vertical" || orientation === "both"; var key = event.key; var next = -1; if ((horizontal && key === "ArrowRight") || (vertical && key === "ArrowDown")) { next = (index + 1) % items.length; } else if ((horizontal && key === "ArrowLeft") || (vertical && key === "ArrowUp")) { next = (index - 1 + items.length) % items.length; } else if (key === "Home") { next = 0; } else if (key === "End") { next = items.length - 1; } else { return; } event.preventDefault(); applyRovingTabindex(items, next); if (items[next].focus) items[next].focus(); } function setupRovingFocus() { if (window.__wrnexusRovingBound) return; window.__wrnexusRovingBound = true; document.addEventListener("keydown", handleRovingKeydown, true); if (typeof MutationObserver === "function") { new MutationObserver(function () { window.setTimeout(syncRovingGroups, 0); }).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["aria-selected", "aria-current", "disabled", "aria-disabled", "hidden"], }); } syncRovingGroups(); } ``` Then register it next to the existing call: ```js setupAnchoredOverlays(); setupModalDialogs(); setupRovingFocus(); ``` - [ ] **Step 4: Run the tests to verify they pass** ```bash bun test packages/csr/test/reactive.test.ts ``` Expected: PASS, all five new tests. - [ ] **Step 5: Check the runtime size budget** ```bash node scripts/security-performance-audit.mjs ``` Expected: `ok PERF-RUNTIME-SIZE-REACTIVE`. If it fails, the roving code must shrink — do not raise the ceiling again in this task. - [ ] **Step 6: Commit** ```bash git add packages/csr/src/reactive-runtime.ts packages/csr/test/reactive.test.ts git commit -m "feat(csr): runtime-owned roving arrow-key focus" ``` --- ### Task 2: Make dialog visibility testable and cover it Closes a gap shipped in 0.8.5: `isDialogVisible` gates on `getBoundingClientRect`, which is always zero in happy-dom, so the modal focus trap and scroll lock have no test coverage at all. **Files:** - Modify: `packages/csr/src/reactive-runtime.ts` (the `isDialogVisible` function) - Test: `packages/csr/test/reactive.test.ts` **Interfaces:** - Consumes: `openDialogs`, `isDialogVisible`, `syncDialogs` from the existing dialog block. - Produces: no new contract. Behaviour is unchanged in a real browser; only the visibility test becomes DOM-implementation independent. - [ ] **Step 1: Write the failing tests** Append to `packages/csr/test/reactive.test.ts`: ```ts test("opening a modal dialog traps Tab inside it", () => { const win = mount( `
`, ); const doc = win.document; const last = doc.querySelector("#last") as HTMLElement; last.focus(); last.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); expect(doc.activeElement!.id).toBe("first"); }); test("a hidden dialog does not trap Tab", () => { const win = mount( `
`, ); const doc = win.document; const outside = doc.querySelector("#outside") as HTMLElement; outside.focus(); outside.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); expect(doc.activeElement!.id).toBe("outside"); }); ``` - [ ] **Step 2: Run the tests to verify they fail** ```bash bun test packages/csr/test/reactive.test.ts -t "dialog" ``` Expected: the trap test FAILS — `getBoundingClientRect` reports zero size, so the dialog is never treated as open. - [ ] **Step 3: Replace the visibility check** In `packages/csr/src/reactive-runtime.ts`, replace the body of `isDialogVisible`: ```js function isDialogVisible(dialog) { if (!dialog || !dialog.isConnected) return false; if (dialog.hasAttribute("hidden")) return false; if (dialog.closest('[data-show="false"]')) return false; /* * Deliberately no getBoundingClientRect: the test DOM reports every * element as zero-sized, which made the trap and the scroll lock * impossible to cover. The data-show marker is what actually expresses * open/closed for these components anyway. */ return true; } ``` - [ ] **Step 4: Run the full runtime suite** ```bash bun test packages/csr ``` Expected: PASS, including the two new dialog tests and every pre-existing test. - [ ] **Step 5: Commit** ```bash git add packages/csr/src/reactive-runtime.ts packages/csr/test/reactive.test.ts git commit -m "fix(csr): make dialog visibility testable and cover the focus trap" ``` --- ### Task 3: Pagination component Simplest of the three and depends on nothing from Task 1, so it establishes the component pattern. **Files:** - Modify (replace whole file): `packages/ui/components/Pagination.wrn` - Test: `packages/ui/test/ui.test.ts` - Modify: `examples/component-showcase/scripts/showcase-profiles.mjs` **Interfaces:** - Consumes: nothing. - Produces: `` with outputs `change({page, pageSize})`, `previous({page})`, `next({page})`. - [ ] **Step 1: Write the failing test** Append to `packages/ui/test/ui.test.ts`: ```ts test("pagination renders windowed page numbers and emits change", async () => { const source = readFileSync(uiComponentPath("Pagination"), "utf8"); const html = await renderComponent(source, { page: 5, pageSize: 10, total: 200, variant: "numbered", siblingCount: 1, }); const dom = mountHtml(html); const root = dom.querySelector(".wire-pagination") as HTMLElement; expect(root.getAttribute("role")).toBe("navigation"); const current = dom.querySelector('.wire-pagination__page[aria-current="page"]'); expect(current!.textContent!.trim()).toBe("5"); expect(root.textContent).toContain("41"); expect(root.textContent).toContain("50"); expect(root.textContent).toContain("200"); }); test("pagination clamps an out-of-range page instead of rendering nothing", async () => { const source = readFileSync(uiComponentPath("Pagination"), "utf8"); const html = await renderComponent(source, { page: 99, pageSize: 10, total: 30 }); const dom = mountHtml(html); const current = dom.querySelector('[aria-current="page"]'); expect(current!.textContent!.trim()).toBe("3"); }); test("pagination survives a non-array or empty dataset", async () => { const source = readFileSync(uiComponentPath("Pagination"), "utf8"); const html = await renderComponent(source, { page: 1, pageSize: 10, total: 0 }); const dom = mountHtml(html); expect(dom.querySelector(".wire-pagination")).not.toBeNull(); }); ``` - [ ] **Step 2: Run the test to verify it fails** ```bash bun test packages/ui -t "pagination" ``` Expected: FAIL — the scaffold has no `.wire-pagination` class and no page numbers. - [ ] **Step 3: Replace `packages/ui/components/Pagination.wrn`** ``` // Pagination -- page controls over a known total. // // // // The component owns no data. It reports the requested page through its // change output and lets the caller fetch or slice. component Pagination { outputs { change(payload: { page: number; pageSize: number }) previous(payload: { page: number }) next(payload: { page: number }) } props { page: number = 1 pageSize: number = 10 total: number = 0 variant: string = "compact" siblingCount: number = 1 showSummary: boolean = true label: string = "Pagination" previousLabel: string = "Previous" nextLabel: string = "Next" class: string = "" } functions { shared function lastPage() { var size = Number(pageSize) > 0 ? Number(pageSize) : 10 var count = Number(total) > 0 ? Number(total) : 0 return Math.max(1, Math.ceil(count / size)) } // Out-of-range values arrive routinely: page travels as an HTML attribute // and callers compute it from data that may have shrunk. shared function currentPage() { var value = Number(page) if (!value || value < 1) { return 1 } return Math.min(value, lastPage()) } shared function firstShown() { if (Number(total) < 1) { return 0 } return (currentPage() - 1) * (Number(pageSize) || 10) + 1 } shared function lastShown() { return Math.min(currentPage() * (Number(pageSize) || 10), Number(total) || 0) } shared function pageNumbers() { var last = lastPage() var current = currentPage() var siblings = Math.max(0, Number(siblingCount) || 0) var start = Math.max(1, current - siblings) var end = Math.min(last, current + siblings) var pages = [] if (start > 1) { pages.push({ value: 1, label: "1", gap: false }) if (start > 2) { pages.push({ value: 0, label: "...", gap: true }) } } for (var index = start; index <= end; index += 1) { pages.push({ value: index, label: String(index), gap: false }) } if (end < last) { if (end < last - 1) { pages.push({ value: 0, label: "...", gap: true }) } pages.push({ value: last, label: String(last), gap: false }) } return pages } client function goToPage(target) { var next = Math.min(Math.max(1, Number(target) || 1), lastPage()) output.change({ page: next, pageSize: Number(pageSize) || 10 }) } client function goPrevious() { var target = Math.max(1, currentPage() - 1) output.previous({ page: target }) output.change({ page: target, pageSize: Number(pageSize) || 10 }) } client function goNext() { var target = Math.min(lastPage(), currentPage() + 1) output.next({ page: target }) output.change({ page: target, pageSize: Number(pageSize) || 10 }) } } view { } style { .wire-pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.75rem; max-width: 100%; } .wire-pagination__summary { margin: 0; color: var(--wire-color-text-muted); font-size: 0.82rem; } .wire-pagination__controls { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; } .wire-pagination__pages { display: flex; align-items: center; gap: 0.25rem; margin: 0; padding: 0; list-style: none; } .wire-pagination__page, .wire-pagination__step { appearance: none; display: inline-flex; align-items: center; gap: 0.35rem; min-width: 2.25rem; justify-content: center; padding: 0.4rem 0.6rem; border: 1px solid var(--wire-color-border); border-radius: var(--wire-radius-sm); background: var(--wire-color-surface); color: var(--wire-color-text); font: inherit; font-size: 0.85rem; cursor: pointer; } .wire-pagination__page:hover, .wire-pagination__step:hover { background: var(--wire-color-surface-soft); } .wire-pagination__page[data-active="true"] { border-color: var(--wire-color-primary); background: var(--wire-color-primary); color: var(--wire-color-primary-contrast); font-weight: 700; } .wire-pagination__gap { padding: 0 0.35rem; color: var(--wire-color-text-muted); } .wire-pagination__compact { margin: 0; padding: 0 0.5rem; color: var(--wire-color-text-muted); font-size: 0.85rem; } /* Below the small breakpoint the word labels crowd the arrows out. */ @media (max-width: 639px) { .wire-pagination { justify-content: center; } .wire-pagination__step-label { display: none; } .wire-pagination__summary { width: 100%; text-align: center; } } } } ``` - [ ] **Step 4: Run the test to verify it passes** ```bash bun test packages/ui -t "pagination" ``` Expected: PASS, all three tests. - [ ] **Step 5: Add the showcase profile** In `examples/component-showcase/scripts/showcase-profiles.mjs`, add a `Pagination` key alongside `DataTable`: ```js Pagination: { demos: [ standard( "Compact arrows", "The default: previous and next with a page counter, and a summary of the range in view.", { page: 2, pageSize: 10, total: 137, variant: "compact" }, ), standard( "Numbered pages", "Page numbers windowed around the current page with ellipsis gaps, so a large set never renders hundreds of buttons.", { page: 5, pageSize: 10, total: 200, variant: "numbered", siblingCount: 1 }, ), advanced( "Wider window", "siblingCount widens how many pages sit either side of the current one.", { page: 8, pageSize: 25, total: 900, variant: "numbered", siblingCount: 2 }, ), ], }, ``` - [ ] **Step 6: Commit** ```bash git add packages/ui/components/Pagination.wrn packages/ui/test/ui.test.ts examples/component-showcase/scripts/showcase-profiles.mjs git commit -m "feat(ui): build the Pagination component" ``` --- ### Task 4: Stepper component **Files:** - Modify (replace whole file): `packages/ui/components/Stepper.wrn` - Test: `packages/ui/test/ui.test.ts` - Modify: `examples/component-showcase/scripts/showcase-profiles.mjs` **Interfaces:** - Consumes: the roving contract from Task 1 (`data-wrn-roving`, `data-wrn-roving-item`) when `clickable` is true. - Produces: `` with output `change({index, step})`. Per-step custom content via `data-slot="step-0"`, `step-1`, … on the mount. - [ ] **Step 1: Write the failing test** Append to `packages/ui/test/ui.test.ts`: ```ts test("stepper marks complete, current and upcoming steps", async () => { const source = readFileSync(uiComponentPath("Stepper"), "utf8"); const html = await renderComponent(source, { steps: [ { label: "Account", description: "Your details" }, { label: "Billing", description: "Payment method" }, { label: "Confirm", description: "Review and submit" }, ], active: 1, }); const dom = mountHtml(html); const items = [...dom.querySelectorAll(".wire-stepper__step")]; expect(items).toHaveLength(3); expect(items[0].getAttribute("data-status")).toBe("complete"); expect(items[1].getAttribute("data-status")).toBe("current"); expect(items[2].getAttribute("data-status")).toBe("upcoming"); expect(items[1].getAttribute("aria-current")).toBe("step"); expect(dom.querySelector(".wire-stepper")!.tagName.toLowerCase()).toBe("ol"); }); test("stepper renders vertically and clamps an out-of-range active index", async () => { const source = readFileSync(uiComponentPath("Stepper"), "utf8"); const html = await renderComponent(source, { steps: [{ label: "One" }, { label: "Two" }], active: 99, orientation: "vertical", }); const dom = mountHtml(html); const root = dom.querySelector(".wire-stepper") as HTMLElement; expect(root.getAttribute("data-orientation")).toBe("vertical"); const items = [...dom.querySelectorAll(".wire-stepper__step")]; expect(items[1].getAttribute("data-status")).toBe("current"); }); test("clickable stepper opts into roving focus", async () => { const source = readFileSync(uiComponentPath("Stepper"), "utf8"); const html = await renderComponent(source, { steps: [{ label: "One" }, { label: "Two" }], active: 0, clickable: true, }); const dom = mountHtml(html); expect(dom.querySelector("[data-wrn-roving]")).not.toBeNull(); expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBe(2); }); ``` - [ ] **Step 2: Run the test to verify it fails** ```bash bun test packages/ui -t "stepper" ``` Expected: FAIL — the scaffold renders a `