Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions SHARED.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,55 @@ Stated explicitly, because "share everything" is its own failure:
lists paid model ids (BYOK — the user's key, the user's choice) while the same
id in kivvi's fallback was a bug. Centralize the **rule**; assert it
**locally**, where the app knows which is which.
- **Navigation chrome.** Measured 2026-08-29: ~13,300 lines of nav across 20
repos, and the experiment has already been run. `sitekit` is the one shared
renderer, it serves 2 of 20, and it shipped two defects — ~28px targets and
no focus style — to **both** consumers, unfixable downstream because a
consumer cannot patch markup it does not own. Its nav model (a flat
`{path, label}` list, no groups, no icons, no footer links, no mobile menu)
also cannot express what solon's mega-menu or reparaturbonus-zh's drawer
need, which is why every repo with real nav complexity reinvented its own
rather than adopt it. Centralizing the markup centralized the bug.
The navigation **contract** below is the shareable part.

---

## The navigation contract

Six rules, each one a defect found in the 2026-08-29 audit, each mechanically
checkable. This is the nav answer to "centralize the rule, assert it locally".

1. The active link of every nav surface carries `aria-current`.
2. Every toggle controlling a panel carries `aria-expanded`.
3. Every interactive nav element is at least 44×44px.
4. The label lives **inside** the control, never beside it.
5. Persisted UI state distinguishes `null` from empty.
6. Every internal href comes from a routes constant.

**Enforced in two places, because they cover disjoint surfaces.**
`scripts/ci/ui-defect-audit.mjs` checks 1, 3 and 4 by **rendering** each live
site — which is the only thing that spans Next apps, CSS modules, Tailwind and
wild-spirit's no-framework generator alike. But it renders **public entry pages
only**, so it structurally cannot see a sidebar behind a login. Authed surfaces
need a source-level check the repo runs in its own `verify`: orangecat's
`check:dead-labels` (rule 4) and fleetcrown's `check_paired` in
`check-design-system.sh` (rule 1) are the two working examples.

**Deliberately NOT on the ratchet.** The ratchet counts concerns that should
converge on ONE implementation, and nav is the opposite: every repo is supposed
to have its own nav config, so counting those files would score the correct
outcome as duplication and the number would have no meaning. The contract is
enforced by the gates above instead. (This revises the audit's own first
recommendation — writing the "do not centralize" section above is what showed
the two could not both be right.)

**Why a gate and not a convention.** The audit's sharpest finding was not that
teams do not know these rules — it is that aoz-housing, fleetcrown, vitareba
and evig each applied `aria-current` correctly on every nav surface **but one**.
Four teams, four stragglers. Hand-application always fails at the margin, and
the margin is invisible until someone renders it. Design tokens are the control
group: near-spotless fleet-wide, because they got a convention **plus a gate**
(`check:accent-ink`) rather than a shared component library.

---

Expand Down
148 changes: 148 additions & 0 deletions scripts/ci/test-ui-defect-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,71 @@
import { loadPlaywright, MEASURE } from "./ui-defect-audit.mjs";

const FIXTURES = {
// ── navigation fixtures ───────────────────────────────────────────────────

// The orangecat sidebar as it shipped: the section title is an <h3> in a
// <div>, and the ONLY thing carrying the click is the chevron beside it.
// Visitors aimed at the word "Fund" and nothing happened.
navDeadLabel: `
<div style="background:#0d0d0d;color:#e6e6e6;font:14px sans-serif;padding:16px;width:280px">
<nav aria-label="Main">
<div style="display:flex;align-items:center;justify-content:space-between;padding:0 12px">
<h3 style="font-size:12px;text-transform:uppercase;margin:0">Fund</h3>
<button style="width:44px;height:44px;background:none;border:0;color:#aaa">
<svg aria-hidden="true" width="16" height="16"></svg>
</button>
</div>
</nav>
</div>`,

// The same row done correctly: the label lives INSIDE the control, so the
// whole row is the target. This must stay silent.
navLabelInsideControl: `
<div style="background:#0d0d0d;color:#e6e6e6;font:14px sans-serif;padding:16px;width:280px">
<nav aria-label="Main">
<button aria-expanded="true" style="display:flex;align-items:center;justify-content:space-between;width:100%;min-height:44px;padding:0 12px;background:none;border:0;color:#e6e6e6">
<h3 style="font-size:12px;text-transform:uppercase;margin:0">Fund</h3>
<svg aria-hidden="true" width="16" height="16"></svg>
</button>
</nav>
</div>`,

// A nav containing a link to the page we are on, with nothing announcing it.
navUnmarkedCurrent: `
<div style="background:#fff;color:#111;font:14px sans-serif;padding:16px">
<nav aria-label="Primary">
<a href="/here" style="display:inline-flex;min-width:44px;min-height:44px;align-items:center;padding:0 12px">Here</a>
<a href="/elsewhere" style="display:inline-flex;min-width:44px;min-height:44px;align-items:center;padding:0 12px">Elsewhere</a>
</nav>
</div>`,

// The same nav, announcing correctly.
navMarkedCurrent: `
<div style="background:#fff;color:#111;font:14px sans-serif;padding:16px">
<nav aria-label="Primary">
<a href="/here" aria-current="page" style="display:inline-flex;min-width:44px;min-height:44px;align-items:center;padding:0 12px">Here</a>
<a href="/elsewhere" style="display:inline-flex;min-width:44px;min-height:44px;align-items:center;padding:0 12px">Elsewhere</a>
</nav>
</div>`,

// A nav of outbound links only — it legitimately contains no link to the
// current page, so "no aria-current" is CORRECT here and must stay silent.
navNoSelfLink: `
<div style="background:#fff;color:#111;font:14px sans-serif;padding:16px">
<nav aria-label="Elsewhere">
<a href="/a" style="display:inline-flex;min-width:44px;min-height:44px;align-items:center;padding:0 12px">A</a>
<a href="/b" style="display:inline-flex;min-width:44px;min-height:44px;align-items:center;padding:0 12px">B</a>
</nav>
</div>`,

// Targets below the fleet's 44px floor.
navSmallTargets: `
<div style="background:#fff;color:#111;font:14px sans-serif;padding:16px">
<nav aria-label="Compact">
<a href="/x" aria-current="page" style="display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center">X</a>
<a href="/y" style="display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center">Y</a>
</nav>
</div>`,
// The original fleetcrown fleet card: an icon INLINE at the head of two of
// the four rows, shoving only those lines sideways by its own width, and a
// wrapped hint whose second line falls back to the container edge.
Expand Down Expand Up @@ -154,6 +219,26 @@ async function main() {
return page.evaluate(MEASURE);
};

// The nav detectors compare each link's pathname against location.pathname,
// which setContent alone cannot exercise: an about:blank page has no path to
// match. Serve the fixture from a routed URL so "the link to the page you are
// on" is a real condition rather than one the test can never reach.
const measureAt = async (html, path) => {
const url = `https://fixture.test${path}`;
await page.route("https://fixture.test/**", (route) =>
route.fulfill({
status: 200,
contentType: "text/html",
body: `<!doctype html><meta charset="utf-8">${html}`,
}),
);
await page.goto(url, { waitUntil: "load" });
await page.waitForTimeout(120);
const r = await page.evaluate(MEASURE);
await page.unroute("https://fixture.test/**");
return r;
};

let passed = 0;
const check = async (label, fn) => {
await fn();
Expand Down Expand Up @@ -250,6 +335,69 @@ async function main() {
);
});


await check("catches a nav label that is not the control (the orangecat sidebar bug)", async () => {
const r = await measure(FIXTURES.navDeadLabel);
// innerText is the RENDERED text, so `text-transform: uppercase` reports
// "FUND" and not the "Fund" in the source. Compare case-insensitively —
// the detector is right and a case-sensitive assertion would be the bug.
assert(
r.navDeadLabels.length === 1 &&
r.navDeadLabels[0].label.toLowerCase() === "fund",
`the stranded "Fund" heading must be reported, got ${JSON.stringify(r.navDeadLabels)}`,
);
});

await check("stays silent when the label lives inside the control", async () => {
const r = await measure(FIXTURES.navLabelInsideControl);
assert(
r.navDeadLabels.length === 0,
`a full-row button is correct markup, got ${JSON.stringify(r.navDeadLabels)}`,
);
});

await check("catches a current page that no link announces", async () => {
const r = await measureAt(FIXTURES.navUnmarkedCurrent, "/here");
assert(
r.navMissingCurrent.length === 1 && r.navMissingCurrent[0].href === "/here",
`the unmarked self-link must be reported, got ${JSON.stringify(r.navMissingCurrent)}`,
);
});

await check("stays silent when the current page IS announced", async () => {
const r = await measureAt(FIXTURES.navMarkedCurrent, "/here");
assert(
r.navMissingCurrent.length === 0,
`aria-current is present, got ${JSON.stringify(r.navMissingCurrent)}`,
);
});

await check("does NOT demand aria-current from a nav with no link to this page", async () => {
// A footer of outbound links has no current page to mark. Flagging it would
// make the detector fire on every site that has one.
const r = await measureAt(FIXTURES.navNoSelfLink, "/somewhere-else");
assert(
r.navMissingCurrent.length === 0,
`no self-link means nothing to announce, got ${JSON.stringify(r.navMissingCurrent)}`,
);
});

await check("catches nav targets below the 44px floor", async () => {
const r = await measureAt(FIXTURES.navSmallTargets, "/x");
assert(
r.navSmallTargets.length === 2,
`both 32px targets must be reported, got ${JSON.stringify(r.navSmallTargets)}`,
);
});

await check("stays silent on nav targets that meet the floor", async () => {
const r = await measureAt(FIXTURES.navMarkedCurrent, "/here");
assert(
r.navSmallTargets.length === 0,
`44px targets are fine, got ${JSON.stringify(r.navSmallTargets)}`,
);
});

await browser.close();
console.log(`\n${passed}/${passed} ui-defect-audit self-tests passed`);
}
Expand Down
Loading