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

53 KiB
Raw Blame History

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:

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
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) {:

/*
 * 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:

setupAnchoredOverlays();
setupModalDialogs();
setupRovingFocus();
  • Step 4: Run the tests to verify they pass
bun test packages/csr/test/reactive.test.ts

Expected: PASS, all five new tests.

  • Step 5: Check the runtime size budget
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
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:

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
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:

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
bun test packages/csr

Expected: PASS, including the two new dialog tests and every pre-existing test.

  • Step 5: Commit
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:

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
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
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:

  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
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:

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
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
bun test packages/ui -t "stepper"

Expected: PASS, all three tests.

  • Step 5: Add the showcase profile
  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
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:

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
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
bun test packages/ui -t "nav "

Expected: PASS, all three tests.

  • Step 5: Add the showcase profile
  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
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

node scripts/generate-ui-component-reference.mjs
node examples/component-showcase/scripts/generate-showcase.mjs
  • Step 2: Regenerate the visual contract
bun run generate:ui-visual
  • Step 3: Format
bun run format
  • Step 4: Run the full production gate
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
git add -A
git commit -m "chore(ui): regenerate reference and showcase for navigation phase 1"
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.