Files
WRNexusJS/docs/superpowers/plans/2026-08-07-navigation-phase-1.md
T
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

1666 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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(
`<div data-wrn-roving="horizontal">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b">B</button>
<button data-wrn-roving-item id="c">C</button>
</div>`,
);
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(
`<div data-wrn-roving="vertical">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b" disabled>B</button>
<button data-wrn-roving-item id="c">C</button>
</div>`,
);
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(
`<div data-wrn-roving="horizontal">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b">B</button>
</div>`,
);
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(
`<div data-wrn-roving="horizontal">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b" aria-selected="true">B</button>
</div>`,
);
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(
`<div data-wrn-roving="horizontal" id="outer">
<button data-wrn-roving-item id="a">A</button>
<div data-wrn-roving="vertical" id="inner">
<button data-wrn-roving-item id="x">X</button>
<button data-wrn-roving-item id="y">Y</button>
</div>
<button data-wrn-roving-item id="b">B</button>
</div>`,
);
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(
`<div>
<button id="outside">Outside</button>
<div data-show="true">
<section role="dialog" aria-modal="true" tabindex="-1">
<button id="first">First</button>
<button id="last">Last</button>
</section>
</div>
</div>`,
);
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(
`<div>
<button id="outside">Outside</button>
<div data-show="false">
<section role="dialog" aria-modal="true" tabindex="-1">
<button id="first">First</button>
</section>
</div>
</div>`,
);
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: `<Pagination page={n} pageSize={n} total={n} variant="compact"|"numbered" siblingCount={n} />` 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.
//
// <Pagination page={2} pageSize={10} total={137} variant="numbered" />
//
// 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 {
<nav
{...attrs}
data-ui-component="Pagination"
class='wrn-pagination {class}'
data-variant='{variant}'
role="navigation"
aria-label='{label}'
>
<p class="wrn-pagination__summary" data-show="showSummary">
{firstShown()} to {lastShown()} of {total}
</p>
<div class="wrn-pagination__controls">
<button
type="button"
class="wrn-pagination__step"
aria-label='{previousLabel}'
@click='goPrevious()'
>
<span class="wrn-pagination__step-icon" aria-hidden="true">&#8249;</span>
<span class="wrn-pagination__step-label">{previousLabel}</span>
</button>
<ol class="wrn-pagination__pages" data-show="variant === 'numbered'">
{#each pageNumbers() as entry}
<li>
<span class="wrn-pagination__gap" data-show="entry.gap">{entry.label}</span>
<button
type="button"
class="wrn-pagination__page"
data-show="!entry.gap"
data-active='{entry.value === currentPage()}'
aria-current='{entry.value === currentPage() ? "page" : "false"}'
@click='goToPage(entry.value)'
>
{entry.label}
</button>
</li>
{/each}
</ol>
<p class="wrn-pagination__compact" data-show="variant !== 'numbered'">
{currentPage()} / {lastPage()}
</p>
<button
type="button"
class="wrn-pagination__step"
aria-label='{nextLabel}'
@click='goNext()'
>
<span class="wrn-pagination__step-label">{nextLabel}</span>
<span class="wrn-pagination__step-icon" aria-hidden="true">&#8250;</span>
</button>
</div>
<slot />
</nav>
}
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: `<Stepper steps={[...]} active={n} orientation="horizontal"|"vertical" clickable={bool} />` 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 `<nav>` of anchors with no `.wrn-stepper__step`.
- [ ] **Step 3: Replace `packages/ui/components/Stepper.wrn`**
```
// Stepper -- ordered progress through a sequence.
//
// <Stepper steps='[{"label":"Account"},{"label":"Billing"}]' active={1} />
//
// Each step can be authored by hand instead of using the built-in body, by
// passing a slot named for its index:
//
// <Stepper steps={steps} active={1}>
// <div data-slot="step-1">…anything…</div>
// </Stepper>
component Stepper {
outputs {
change(payload: { index: number; step: object })
}
props {
steps: unknown[] = []
active: number = 0
orientation: string = "horizontal"
clickable: boolean = false
label: string = "Progress"
class: string = ""
}
functions {
shared function stepList() {
return Array.isArray(steps) ? steps : []
}
shared function activeIndex() {
var count = stepList().length
if (count < 1) {
return 0
}
var value = Number(active)
if (!value || value < 0) {
return 0
}
return Math.min(value, count - 1)
}
shared function statusFor(index) {
if (index < activeIndex()) {
return "complete"
}
if (index === activeIndex()) {
return "current"
}
return "upcoming"
}
client function selectStep(index, step) {
if (!clickable) {
return
}
output.change({ index: index, step: step })
}
}
view {
<ol
{...attrs}
data-ui-component="Stepper"
class='wrn-stepper {class}'
data-orientation='{orientation}'
data-clickable='{clickable}'
data-wrn-roving='{clickable ? (orientation === "vertical" ? "vertical" : "horizontal") : ""}'
aria-label='{label}'
>
{#each stepList() as step, index}
<li
class="wrn-stepper__step"
data-status='{statusFor(index)}'
aria-current='{statusFor(index) === "current" ? "step" : "false"}'
>
<button
type="button"
class="wrn-stepper__button"
data-wrn-roving-item='{clickable}'
@click='selectStep(index, step)'
>
<span class="wrn-stepper__marker" aria-hidden="true">
<span class='wrn-stepper__icon {step.icon}' data-show="step.icon"></span>
<span class="wrn-stepper__number" data-show="!step.icon">{index + 1}</span>
</span>
<span class="wrn-stepper__body">
<span class="wrn-stepper__label">{step.label}</span>
<span class="wrn-stepper__description" data-show="step.description">
{step.description}
</span>
</span>
</button>
<span class="wrn-stepper__custom">
<slot name="step-{index}"></slot>
</span>
</li>
{/each}
<slot />
</ol>
}
style {
.wrn-stepper {
display: flex;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
max-width: 100%;
}
.wrn-stepper[data-orientation="vertical"] {
flex-direction: column;
}
.wrn-stepper__step {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
gap: 0.35rem;
}
.wrn-stepper__button {
appearance: none;
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.5rem;
border: 0;
border-radius: var(--wrn-radius-sm);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: default;
}
.wrn-stepper[data-clickable="true"] .wrn-stepper__button {
cursor: pointer;
}
.wrn-stepper[data-clickable="true"] .wrn-stepper__button:hover {
background: var(--wrn-color-surface-soft);
}
.wrn-stepper__marker {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 2rem;
height: 2rem;
border: 1px solid var(--wrn-color-border);
border-radius: 999px;
background: var(--wrn-color-surface);
font-size: 0.85rem;
font-weight: 700;
}
.wrn-stepper__step[data-status="complete"] .wrn-stepper__marker {
border-color: var(--wrn-color-primary);
background: var(--wrn-color-primary);
color: var(--wrn-color-primary-contrast);
}
.wrn-stepper__step[data-status="current"] .wrn-stepper__marker {
border-color: var(--wrn-color-primary);
color: var(--wrn-color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-primary) 22%, transparent);
}
.wrn-stepper__step[data-status="upcoming"] .wrn-stepper__marker {
color: var(--wrn-color-text-muted);
}
.wrn-stepper__body {
display: flex;
flex-direction: column;
min-width: 0;
}
.wrn-stepper__label {
font-size: 0.9rem;
font-weight: 600;
}
.wrn-stepper__description {
color: var(--wrn-color-text-muted);
font-size: 0.78rem;
}
.wrn-stepper__step[data-status="upcoming"] .wrn-stepper__label {
color: var(--wrn-color-text-muted);
}
/* A horizontal stepper cannot stay side by side on a phone. */
@media (max-width: 639px) {
.wrn-stepper {
flex-direction: column;
}
}
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
```bash
bun test packages/ui -t "stepper"
```
Expected: PASS, all three tests.
- [ ] **Step 5: Add the showcase profile**
```js
Stepper: {
demos: [
standard(
"Horizontal progress",
"Steps before the active one read as complete, the active one is highlighted, and the rest are muted.",
{
steps: [
{ label: "Account", description: "Your details" },
{ label: "Billing", description: "Payment method" },
{ label: "Confirm", description: "Review and submit" },
],
active: 1,
},
),
standard(
"Vertical with icons",
"Vertical orientation suits a sidebar or a narrow column. Any step may carry an iconify class instead of its number.",
{
steps: [
{ label: "Cloned", icon: "icon-[lucide--git-branch]" },
{ label: "Built", icon: "icon-[lucide--hammer]" },
{ label: "Deployed", icon: "icon-[lucide--rocket]" },
],
active: 2,
orientation: "vertical",
},
),
advanced(
"Clickable steps",
"With clickable the steps become buttons, emit a change output, and take arrow-key roving focus.",
{
steps: [{ label: "One" }, { label: "Two" }, { label: "Three" }],
active: 0,
clickable: true,
},
),
],
},
```
- [ ] **Step 6: Commit**
```bash
git add packages/ui/components/Stepper.wrn packages/ui/test/ui.test.ts examples/component-showcase/scripts/showcase-profiles.mjs
git commit -m "feat(ui): build the Stepper component"
```
---
### Task 5: Nav component
The largest of the three: multi-level links to a fixed depth of 3, roving focus, animated disclosure arrows, and a mobile toggle.
**Files:**
- Modify (replace whole file): `packages/ui/components/Nav.wrn`
- Test: `packages/ui/test/ui.test.ts`
- Modify: `examples/component-showcase/scripts/showcase-profiles.mjs`
**Interfaces:**
- Consumes: the roving contract from Task 1.
- Produces: `<Nav items={[...]} active="value" orientation="horizontal"|"vertical" />` with output `select({item, value})`. Item shape: `{label, href, value, icon, badge, disabled, items}` where a nested `items` array creates a submenu, honoured to 3 levels.
- [ ] **Step 1: Write the failing test**
Append to `packages/ui/test/ui.test.ts`:
```ts
test("nav renders links, marks the active one, and shows icons and badges", async () => {
const source = readFileSync(uiComponentPath("Nav"), "utf8");
const html = await renderComponent(source, {
items: [
{ label: "Home", href: "/", value: "home", icon: "icon-[lucide--house]" },
{ label: "Inbox", href: "/inbox", value: "inbox", badge: "9" },
{ label: "Archive", href: "/archive", value: "archive", disabled: true },
],
active: "inbox",
});
const dom = mountHtml(html);
expect(dom.querySelector(".wrn-nav")!.getAttribute("role")).toBe("navigation");
const current = dom.querySelector('[aria-current="page"]');
expect(current!.textContent).toContain("Inbox");
expect(dom.querySelector(".wrn-nav__badge")!.textContent!.trim()).toBe("9");
expect(dom.querySelector(".wrn-nav__icon")).not.toBeNull();
expect(dom.querySelector('[aria-disabled="true"]')).not.toBeNull();
});
test("nav renders a submenu with a disclosure arrow and opts into roving focus", async () => {
const source = readFileSync(uiComponentPath("Nav"), "utf8");
const html = await renderComponent(source, {
items: [
{
label: "Products",
value: "products",
items: [
{ label: "Overview", href: "/p", value: "p-overview" },
{
label: "More",
value: "p-more",
items: [{ label: "Deep", href: "/d", value: "deep" }],
},
],
},
],
active: "p-overview",
});
const dom = mountHtml(html);
expect(dom.querySelector("[data-wrn-roving]")).not.toBeNull();
expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBeGreaterThan(0);
expect(dom.querySelector(".wrn-nav__arrow")).not.toBeNull();
expect(dom.querySelector(".wrn-nav__submenu")).not.toBeNull();
// Third level is rendered, and nothing deeper is attempted.
expect(dom.querySelector(".wrn-nav__submenu--level3")).not.toBeNull();
expect(dom.body.innerHTML).toContain("Deep");
});
test("nav renders empty rather than throwing when items is not an array", async () => {
const source = readFileSync(uiComponentPath("Nav"), "utf8");
const html = await renderComponent(source, { items: "not-an-array", active: "" });
const dom = mountHtml(html);
expect(dom.querySelector(".wrn-nav")).not.toBeNull();
expect(dom.querySelectorAll(".wrn-nav__link").length).toBe(0);
});
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
bun test packages/ui -t "nav "
```
Expected: FAIL — the scaffold renders bare anchors with no `.wrn-nav__link`, no submenu and no roving attributes.
- [ ] **Step 3: Replace `packages/ui/components/Nav.wrn`**
```
// Nav -- a navigation link list, flat or with submenus.
//
// <Nav items='[{"label":"Home","href":"/","value":"home"}]' active="home" />
//
// An item with a nested items array becomes a submenu. Depth is capped at
// three levels: this template language has no component recursion, so each
// level is written out, and three covers any realistic navigation.
component Nav {
outputs {
select(payload: { item: object; value: string })
}
props {
items: unknown[] = []
active: string = ""
orientation: string = "horizontal"
label: string = "Main"
collapsible: boolean = true
toggleLabel: string = "Menu"
class: string = ""
}
state expanded = false
functions {
shared function itemList() {
return Array.isArray(items) ? items : []
}
shared function childrenOf(item) {
return Array.isArray(item.items) ? item.items : []
}
shared function isActive(item) {
return Boolean(item.value) && item.value === active
}
client function choose(item) {
if (item.disabled) {
return
}
output.select({ item: item, value: item.value || "" })
}
client function toggleMenu() {
expanded = !expanded
}
}
view {
<nav
{...attrs}
data-ui-component="Nav"
class='wrn-nav {class}'
data-orientation='{orientation}'
data-expanded='{expanded}'
role="navigation"
aria-label='{label}'
>
<button
type="button"
class="wrn-nav__toggle"
data-show="collapsible"
aria-expanded='{expanded}'
aria-label='{toggleLabel}'
@click='toggleMenu()'
>
<span class="wrn-nav__toggle-bar" aria-hidden="true"></span>
<span class="wrn-nav__toggle-text">{toggleLabel}</span>
</button>
<ul
class="wrn-nav__list"
data-wrn-roving='{orientation === "vertical" ? "vertical" : "horizontal"}'
>
{#each itemList() as item}
<li class="wrn-nav__item" data-has-children='{childrenOf(item).length > 0}'>
<a
class="wrn-nav__link"
href='{item.href || "#"}'
data-wrn-roving-item="true"
data-active='{isActive(item)}'
aria-current='{isActive(item) ? "page" : "false"}'
aria-disabled='{item.disabled ? "true" : "false"}'
@click='choose(item)'
>
<span class='wrn-nav__icon {item.icon}' data-show="item.icon" aria-hidden="true"></span>
<span class="wrn-nav__label">{item.label}</span>
<span class="wrn-nav__badge" data-show="item.badge">{item.badge}</span>
<span
class="wrn-nav__arrow"
data-show="childrenOf(item).length > 0"
aria-hidden="true"
>&#8250;</span>
</a>
<ul class="wrn-nav__submenu" data-show="childrenOf(item).length > 0">
{#each childrenOf(item) as child}
<li class="wrn-nav__item" data-has-children='{childrenOf(child).length > 0}'>
<a
class="wrn-nav__link"
href='{child.href || "#"}'
data-active='{isActive(child)}'
aria-current='{isActive(child) ? "page" : "false"}'
aria-disabled='{child.disabled ? "true" : "false"}'
@click='choose(child)'
>
<span class='wrn-nav__icon {child.icon}' data-show="child.icon" aria-hidden="true"></span>
<span class="wrn-nav__label">{child.label}</span>
<span class="wrn-nav__badge" data-show="child.badge">{child.badge}</span>
<span
class="wrn-nav__arrow"
data-show="childrenOf(child).length > 0"
aria-hidden="true"
>&#8250;</span>
</a>
<ul
class="wrn-nav__submenu wrn-nav__submenu--level3"
data-show="childrenOf(child).length > 0"
>
{#each childrenOf(child) as leaf}
<li class="wrn-nav__item">
<a
class="wrn-nav__link"
href='{leaf.href || "#"}'
data-active='{isActive(leaf)}'
aria-current='{isActive(leaf) ? "page" : "false"}'
aria-disabled='{leaf.disabled ? "true" : "false"}'
@click='choose(leaf)'
>
<span class='wrn-nav__icon {leaf.icon}' data-show="leaf.icon" aria-hidden="true"></span>
<span class="wrn-nav__label">{leaf.label}</span>
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
<slot />
</nav>
}
style {
.wrn-nav {
max-width: 100%;
}
.wrn-nav__list {
display: flex;
align-items: center;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.wrn-nav[data-orientation="vertical"] .wrn-nav__list {
flex-direction: column;
align-items: stretch;
}
.wrn-nav__item {
position: relative;
}
.wrn-nav__link {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.7rem;
border-radius: var(--wrn-radius-sm);
color: var(--wrn-color-text-muted);
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
}
.wrn-nav__link:hover {
background: var(--wrn-color-surface-soft);
color: var(--wrn-color-text);
}
.wrn-nav__link[data-active="true"] {
background: var(--wrn-color-primary-soft);
color: var(--wrn-color-primary);
}
.wrn-nav__link[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.wrn-nav__badge {
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--wrn-color-primary-soft);
color: var(--wrn-color-primary);
font-size: 0.72rem;
}
.wrn-nav__arrow {
display: inline-block;
transition: transform 160ms ease;
}
.wrn-nav__item:hover > .wrn-nav__link > .wrn-nav__arrow,
.wrn-nav__item:focus-within > .wrn-nav__link > .wrn-nav__arrow {
transform: rotate(90deg);
}
.wrn-nav__submenu {
position: absolute;
z-index: 30;
top: 100%;
left: 0;
min-width: 12rem;
margin: 0;
padding: 0.35rem;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-md);
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-1);
list-style: none;
opacity: 0;
visibility: hidden;
transition: opacity 160ms ease;
}
.wrn-nav__item:hover > .wrn-nav__submenu,
.wrn-nav__item:focus-within > .wrn-nav__submenu {
opacity: 1;
visibility: visible;
}
.wrn-nav__submenu--level3 {
top: 0;
left: 100%;
}
.wrn-nav__toggle {
display: none;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.7rem;
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.9rem;
cursor: pointer;
}
.wrn-nav__toggle-bar {
width: 1rem;
height: 2px;
background: currentColor;
box-shadow: 0 -5px 0 currentColor, 0 5px 0 currentColor;
}
/*
* On a phone the bar becomes a disclosure: submenus stop being floating
* overlays and stack inline, because a hover-opened overlay is
* unreachable on touch.
*/
@media (max-width: 767px) {
.wrn-nav__toggle {
display: inline-flex;
}
.wrn-nav__list {
flex-direction: column;
align-items: stretch;
}
.wrn-nav[data-expanded="false"] .wrn-nav__list {
display: none;
}
.wrn-nav__submenu,
.wrn-nav__submenu--level3 {
position: static;
opacity: 1;
visibility: visible;
border: 0;
box-shadow: none;
padding-left: 1rem;
}
}
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
```bash
bun test packages/ui -t "nav "
```
Expected: PASS, all three tests.
- [ ] **Step 5: Add the showcase profile**
```js
Nav: {
demos: [
standard(
"Horizontal links",
"A flat link bar. The active item is marked with aria-current, and arrow keys move between items.",
{
items: [
{ label: "Home", href: "/", value: "home", icon: "icon-[lucide--house]" },
{ label: "Inbox", href: "/inbox", value: "inbox", badge: "9" },
{ label: "Reports", href: "/reports", value: "reports" },
{ label: "Archive", href: "/archive", value: "archive", disabled: true },
],
active: "inbox",
},
),
advanced(
"Nested submenus",
"An item carrying its own items array opens a submenu on hover or focus, to a maximum of three levels.",
{
items: [
{ label: "Home", href: "/", value: "home" },
{
label: "Products",
value: "products",
items: [
{ label: "Overview", href: "/p", value: "p-overview" },
{
label: "Platform",
value: "p-platform",
items: [
{ label: "Runtime", href: "/p/runtime", value: "runtime" },
{ label: "Compiler", href: "/p/compiler", value: "compiler" },
],
},
],
},
],
active: "p-overview",
},
),
standard(
"Vertical rail",
"Vertical orientation switches the arrow keys to up and down.",
{
items: [
{ label: "Dashboard", href: "/", value: "dash", icon: "icon-[lucide--gauge]" },
{ label: "Team", href: "/team", value: "team", icon: "icon-[lucide--users]" },
{ label: "Settings", href: "/settings", value: "settings", icon: "icon-[lucide--settings]" },
],
active: "team",
orientation: "vertical",
collapsible: false,
},
),
],
},
```
- [ ] **Step 6: Commit**
```bash
git add packages/ui/components/Nav.wrn packages/ui/test/ui.test.ts examples/component-showcase/scripts/showcase-profiles.mjs
git commit -m "feat(ui): build the Nav component with submenus and roving focus"
```
---
### Task 6: Regenerate artifacts and close the phase
**Files:**
- Modify: `packages/ui/component-reference.json`, `packages/ui/component-catalog.json`, `packages/ui/COMPONENTS.md` (generated)
- Modify: `examples/component-showcase/**` (generated)
- Modify: `docs/ui-visual-contract-0.8.json` (generated)
**Interfaces:**
- Consumes: the three finished components from Tasks 35.
- Produces: nothing new; brings generated artifacts back in step.
- [ ] **Step 1: Regenerate the reference and showcase**
```bash
node scripts/generate-ui-component-reference.mjs
```
```bash
node examples/component-showcase/scripts/generate-showcase.mjs
```
- [ ] **Step 2: Regenerate the visual contract**
```bash
bun run generate:ui-visual
```
- [ ] **Step 3: Format**
```bash
bun run format
```
- [ ] **Step 4: Run the full production gate**
```bash
bun run check:production
```
Expected: PASS. If `PERF-RUNTIME-SIZE-REACTIVE` fails, shrink the roving code rather than raising the ceiling.
- [ ] **Step 5: Commit and push**
```bash
git add -A
git commit -m "chore(ui): regenerate reference and showcase for navigation phase 1"
```
```bash
git push
```
---
## Self-Review
**Spec coverage for phase 1.** Roving focus in the runtime — Task 1. Nav multi-level to depth 3, icons, badges, animated arrows, responsive collapse, `aria-current` — Task 5. Pagination standalone with compact and numbered variants and windowed numbers — Task 3. Stepper horizontal/vertical with indexed named slots and `aria-current="step"` — Task 4. Local `style {}` blocks with `wrn-*` classes — Tasks 3, 4, 5. Error handling for non-array `items` and out-of-range `active`/`page` — covered by a test in each of Tasks 3, 4, 5. Showcase profiles and regeneration — Tasks 36. Runtime budget check — Tasks 1 and 6.
Out of phase 1 by design, carried to later plans: Tabs, Sidebar, MegaMenu (phase 2); Navbar, Breadcrumb, Scrollspy, responsive sweep (phase 3).
**Added beyond the spec.** Task 2 fixes the untestable `isDialogVisible` shipped in 0.8.5. It belongs here because the roving code faces the identical happy-dom trap, and fixing both under one rule keeps them consistent.
**Type consistency.** `data-wrn-roving` / `data-wrn-roving-item` are spelled identically in Task 1 and consumed identically in Tasks 4 and 5. `pageNumbers()` returns `{value, label, gap}` in Task 3 and the view reads exactly those three fields. `statusFor(index)` returns `"complete" | "current" | "upcoming"` in Task 4 and the tests and CSS assert those three values. `childrenOf(item)` returns an array at all three Nav levels.