# 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 `wrn-*` 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 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(".wrn-pagination") as HTMLElement;
expect(root.getAttribute("role")).toBe("navigation");
const current = dom.querySelector('.wrn-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(".wrn-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 `.wrn-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 {
.wrn-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
max-width: 100%;
}
.wrn-pagination__summary {
margin: 0;
color: var(--wrn-color-text-muted);
font-size: 0.82rem;
}
.wrn-pagination__controls {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.wrn-pagination__pages {
display: flex;
align-items: center;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.wrn-pagination__page,
.wrn-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(--wrn-color-border);
border-radius: var(--wrn-radius-sm);
background: var(--wrn-color-surface);
color: var(--wrn-color-text);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.wrn-pagination__page:hover,
.wrn-pagination__step:hover {
background: var(--wrn-color-surface-soft);
}
.wrn-pagination__page[data-active="true"] {
border-color: var(--wrn-color-primary);
background: var(--wrn-color-primary);
color: var(--wrn-color-primary-contrast);
font-weight: 700;
}
.wrn-pagination__gap {
padding: 0 0.35rem;
color: var(--wrn-color-text-muted);
}
.wrn-pagination__compact {
margin: 0;
padding: 0 0.5rem;
color: var(--wrn-color-text-muted);
font-size: 0.85rem;
}
/* Below the small breakpoint the word labels crowd the arrows out. */
@media (max-width: 639px) {
.wrn-pagination {
justify-content: center;
}
.wrn-pagination__step-label {
display: none;
}
.wrn-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(".wrn-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(".wrn-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(".wrn-stepper") as HTMLElement;
expect(root.getAttribute("data-orientation")).toBe("vertical");
const items = [...dom.querySelectorAll(".wrn-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 `