Skip to content
Open
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
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

| | |
|---|---|
| **Last updated** | 2026-10-08 (W-05 resolved; 17 Wave issues seeded with seven new rows; issue format proofed against Drips' maintainer guide; the three pre-format issues triaged — app #16 re-emitted, app #15 and registry #7 closed as superseded; repo-side Changelog lines unioned back into canonical by `scripts/roadmap-merge`; that merge's ADR-link normalization anchored so quoted prose cannot be rewritten) |
| **Last updated** | 2026-10-17 (M3-17 app #39 accessible navigation & skip link; 175 tests green) |
| **Docs baseline** | v0.3, worldwide scope (`PRD.md`, `ARCHITECTURE.md`, `ARCHITECTURE_ESSENTIALS.md`, `AGENTS.md`, `CLAUDE.md`, `project_structure.md`) |
| **Current phase** | Phase 0 (Foundations) → starting Phase 1 (M0 Validate) |
| **Readiness** | See [§3](#3-progress-snapshot) |
Expand Down Expand Up @@ -535,6 +535,7 @@ Newest first. One entry per PR. Required for every contribution (see §2).

| Date | PR / ref | Repo | Rows touched | Summary |
|---|---|---|---|---|
| 2026-10-17 | `feat/accessible-nav-skip-link` (#46) | app | M3-17 (status unchanged, `IN PROGRESS`) | Closes #39. Accessible navigation pass: implemented skip-to-content link, semantic landmark tags, and focus styling. Added `components/a11y/SkipLink.tsx` rendered as the first focusable element in `app/layout.tsx` before the testnet banner and `SiteHeader`, targeting `<main id="main-content" tabIndex={-1}>` to allow keyboard and screen-reader users to bypass banner/nav and immediately move focus to page content. Updated `components/nav/SiteHeader.tsx` to restore focus to toggle button on `Escape` key disclosure dismiss and added `focus-visible:` classes on interactive controls. Added `:focus-visible` outline styles in `app/globals.css` with 2px solid `--accent` clearing WCAG 2.2 focus appearance (5.14:1 light, 6.84:1 dark >= 3:1). Added `messages/en.json` `common.skipToContent` key. Added `components/a11y/SkipLink.test.ts` (7 tests) verifying render ordering, landmark tagging, focus restoration, and CSS outline styles. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (175/175 passed across 16 files), and `pnpm build` all green. |
| 2026-10-08 | `chore/roadmap-copy-back` (#44) | app | no row changes | Copy-back run: `ROADMAP.md` replaced with the merged canonical (`.github` main as of #31) in repo-root link form, and §3 recomputed with `scripts/roadmap-progress`. This is the step `roadmap-merge`'s header requires after every merge and that was skipped between 2026-10-06 and 2026-10-08, which is how the seven copies reached 92 divergent statuses. Verified in this checkout: the file equals canonical byte for byte apart from this entry and the ADR link prefix, `W-05` reads `DONE`, the seven Wave rows are present, duplicate row-ID scan is 0. No row, status, priority, dependency or **Done when** was authored in this repo. |
| 2026-10-08 | `fix/roadmap-merge-link-anchor` | org | no row changes | Found while preparing the copy-back. `roadmap-merge`'s read normalization rewrote the docs-rooted ADR link form into the root-relative one on every copy so the two depths compare equal — correct for links, wrong for prose: the union entry above quotes both forms, so reading that line collapsed its own quote and emitted a Changelog entry contradicting itself, in a copy that no longer matched canonical line for line. Fixed by anchoring the replace on the numeric ADR filename that always follows the slash, so only real links are rewritten, and by re-wording the union entry to name the two forms as `docs/adr/` and `adr/` rather than spelling them as link text: **an entry that describes this rewrite must not itself be a string the rewrite can act on.** Verified: the anchored read leaves a quoted form byte-identical where the blanket replace corrupted it; a full `roadmap-merge` over all five copies against `b12d290` exits 0 with **0 lines** differing from canonical, which also confirms the union is complete; the one real link (`ADR-0026`) still normalizes both ways. No row, status, priority, dependency or **Done when** in this diff. |
| 2026-10-08 | `chore/roadmap-changelog-union` | org | no row changes | Second `scripts/roadmap-merge` run now that the Wave-format PRs have merged (`.github` #26 and #27, contracts #31, sdk #38, app #43, registry #18). Three-way against `b12d290`, the revision where all five copies were last identical. The merge's **entire** output is four lines: the repo-side `docs/wave-issue-format` Changelog rows that each repo wrote into its own copy and canonical never saw. No row ID, status, priority, dependency or **Done when** moved — the seven new rows went in byte-identically, and `W-05`'s `TODO` → `DONE` stayed on the `org` row in canonical as §12 requires, so the repo copies' deliberately-untouched `TODO` was not merged back over it. §3 verified unchanged by `roadmap-progress`: 211 rows, 75 `DONE`, 40%. **Not in this PR:** the copy-back. Each repo's `ROADMAP.md` must be rewritten from the merged canonical, with the ADR links rewritten for the repo-root location, so all five copies are byte-identical again — that invariant is what makes `--ancestor` meaningful next time, and it needs four repo PRs that can only be cut after this lands. |
Expand Down
7 changes: 7 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
}
}

/* Ensure keyboard focus indicators clear WCAG 2.2 focus appearance (>= 3:1 against adjacent colors).
* --accent (#1c7c68 in light mode, #35a085 in dark mode) achieves 5.14:1 and 6.84:1 contrast ratios. */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}

/* Mobile nav disclosure panel: enters from the header, per the animate skill's menu recipe. */
@keyframes menu-in {
from {
Expand Down
6 changes: 5 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Outfit } from "next/font/google";
import type { ReactNode } from "react";
import { SkipLink } from "@/components/a11y/SkipLink";
import { SiteHeader } from "@/components/nav/SiteHeader";
import { t } from "@/lib/i18n/messages";
import "./globals.css";
Expand All @@ -15,11 +16,14 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={outfit.variable} style={{ colorScheme: "light dark" }}>
<body className="bg-paper text-ink antialiased font-sans">
<SkipLink />
<div className="bg-ink px-4 py-2 text-center text-sm text-paper">
{t("common.testnetBanner")}
</div>
<SiteHeader />
{children}
<main id="main-content" tabIndex={-1} className="outline-none">
{children}
</main>
</body>
</html>
);
Expand Down
66 changes: 66 additions & 0 deletions components/a11y/SkipLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import en from "@/messages/en.json";

describe("accessible navigation and skip-to-content link (M3-17 / #39)", () => {
const layoutSource = readFileSync("app/layout.tsx", "utf8");
const skipLinkSource = readFileSync("components/a11y/SkipLink.tsx", "utf8");
const siteHeaderSource = readFileSync("components/nav/SiteHeader.tsx", "utf8");
const globalsCssSource = readFileSync("app/globals.css", "utf8");

it("registers skipToContent in common messages", () => {
expect(en.common).toHaveProperty("skipToContent");
expect(en.common.skipToContent).toBe("Skip to main content");
});

it("SkipLink component defaults to #main-content and has proper sr-only / focus styling", () => {
expect(skipLinkSource).toMatch(/href=\{`#\$\{targetId\}`\}/);
expect(skipLinkSource).toContain("sr-only");
expect(skipLinkSource).toContain("focus:not-sr-only");
expect(skipLinkSource).toContain('targetId = "main-content"');
expect(skipLinkSource).toContain('t("common.skipToContent")');
});

it("app/layout.tsx renders SkipLink as the first tab stop before the testnet banner and SiteHeader", () => {
const skipLinkPos = layoutSource.indexOf("<SkipLink");
const bannerPos = layoutSource.indexOf("common.testnetBanner");
const siteHeaderPos = layoutSource.indexOf("<SiteHeader");
const mainPos = layoutSource.indexOf("<main");

expect(skipLinkPos).toBeGreaterThan(-1);
expect(bannerPos).toBeGreaterThan(-1);
expect(siteHeaderPos).toBeGreaterThan(-1);
expect(mainPos).toBeGreaterThan(-1);

// SkipLink must come before the testnet banner
expect(skipLinkPos).toBeLessThan(bannerPos);
// SkipLink must come before the SiteHeader
expect(skipLinkPos).toBeLessThan(siteHeaderPos);
// SkipLink must precede main content
expect(skipLinkPos).toBeLessThan(mainPos);
});

it("app/layout.tsx wraps children in a semantic main landmark with id='main-content' and tabIndex={-1}", () => {
expect(layoutSource).toMatch(/<main[^>]*id=["']main-content["']/);
expect(layoutSource).toMatch(/<main[^>]*tabIndex=\{-1\}/);
});

it("SiteHeader closes disclosure menu on Escape and restores focus to toggle button", () => {
expect(siteHeaderSource).toContain('e.key === "Escape"');
expect(siteHeaderSource).toContain("toggleRef.current?.focus()");
expect(siteHeaderSource).toContain("ref={toggleRef}");
expect(siteHeaderSource).toContain("aria-expanded={open}");
expect(siteHeaderSource).toContain("aria-controls={menuId}");
});

it("SiteHeader interactive elements have explicit focus-visible classes", () => {
expect(siteHeaderSource).toContain("focus-visible:outline-accent");
expect(siteHeaderSource).toContain("focus-visible:outline-2");
});

it("app/globals.css includes :focus-visible rules with WCAG 2.2 compliant outline", () => {
expect(globalsCssSource).toContain(":focus-visible");
expect(globalsCssSource).toContain("outline: 2px solid var(--accent);");
expect(globalsCssSource).toContain("outline-offset: 2px;");
});
});
22 changes: 22 additions & 0 deletions components/a11y/SkipLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { t } from "@/lib/i18n/messages";

interface SkipLinkProps {
targetId?: string;
}

/**
* Accessible skip-to-content navigation link (AGENTS.md §8.3, Roadmap M3-17 / #39).
* Visually hidden until focused via keyboard Tab navigation. Placed as the first
* focusable element in the page layout so keyboard and screen-reader users can
* immediately jump past global banners and persistent header navigation to main content.
*/
export function SkipLink({ targetId = "main-content" }: SkipLinkProps) {
return (
<a
href={`#${targetId}`}
className="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-50 focus:rounded-lg focus:border focus:border-line focus:bg-paper focus:px-4 focus:py-2.5 focus:text-sm focus:font-semibold focus:text-ink focus:shadow-md focus:outline-2 focus:outline-offset-2 focus:outline-accent"
>
{t("common.skipToContent")}
</a>
);
}
26 changes: 17 additions & 9 deletions components/nav/SiteHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { usePathname } from "next/navigation";
import { useEffect, useId, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { t } from "@/lib/i18n/messages";

const LINKS = [
Expand All @@ -11,7 +11,7 @@ const LINKS = [
];

const navLinkClass = (active: boolean) =>
`text-sm font-medium transition-colors motion-reduce:transition-none ${
`rounded-md text-sm font-medium transition-colors motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
active ? "text-accent" : "text-ink/70 hover:text-accent"
}`;

Expand All @@ -24,6 +24,7 @@ const navLinkClass = (active: boolean) =>
export function SiteHeader() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const toggleRef = useRef<HTMLButtonElement>(null);
const menuId = useId();

// biome-ignore lint/correctness/useExhaustiveDependencies: closes the menu on route change
Expand All @@ -32,7 +33,10 @@ export function SiteHeader() {
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
if (e.key === "Escape") {
setOpen(false);
toggleRef.current?.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
Expand All @@ -41,7 +45,10 @@ export function SiteHeader() {
return (
<header className="relative border-b border-line">
<div className="mx-auto flex h-16 max-w-5xl items-center justify-between px-4">
<a href="/" className="flex items-center gap-2 font-semibold tracking-tight">
<a
href="/"
className="flex items-center gap-2 rounded-md font-semibold tracking-tight focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<svg viewBox="0 0 1024 1024" className="h-6 w-6 fill-accent" aria-hidden="true">
<mask id="header-lock-mask">
<rect width="1024" height="1024" fill="white" />
Expand Down Expand Up @@ -76,25 +83,26 @@ export function SiteHeader() {
<span className="h-4 w-px bg-line" aria-hidden="true" />
<a
href="https://kinlock-org.github.io"
className="rounded-full border border-line px-4 py-1.5 text-sm font-medium text-ink transition-colors motion-reduce:transition-none hover:border-accent hover:text-accent"
className="rounded-full border border-line px-4 py-1.5 text-sm font-medium text-ink transition-colors motion-reduce:transition-none hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
{t("nav.docs")}
</a>
<a
href="https://github.com/Kinlock-Org"
className="rounded-full bg-ink/5 px-4 py-1.5 text-sm font-medium text-ink transition-colors motion-reduce:transition-none hover:bg-ink/10"
className="rounded-full bg-ink/5 px-4 py-1.5 text-sm font-medium text-ink transition-colors motion-reduce:transition-none hover:bg-ink/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
{t("nav.github")}
</a>
</nav>

<button
ref={toggleRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={menuId}
aria-label={open ? t("nav.closeMenu") : t("nav.openMenu")}
className="flex h-10 w-10 items-center justify-center md:hidden"
className="flex h-10 w-10 items-center justify-center rounded-lg transition-colors motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent md:hidden"
>
<span className="relative block h-3.5 w-5" aria-hidden="true">
<span
Expand Down Expand Up @@ -135,13 +143,13 @@ export function SiteHeader() {
<div className="mt-2 flex flex-col gap-2 border-t border-line pt-3">
<a
href="https://kinlock-org.github.io"
className="rounded-full border border-line px-4 py-2 text-center text-sm font-medium text-ink"
className="rounded-full border border-line px-4 py-2 text-center text-sm font-medium text-ink transition-colors motion-reduce:transition-none hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
{t("nav.docs")}
</a>
<a
href="https://github.com/Kinlock-Org"
className="rounded-full bg-ink/5 px-4 py-2 text-center text-sm font-medium text-ink"
className="rounded-full bg-ink/5 px-4 py-2 text-center text-sm font-medium text-ink transition-colors motion-reduce:transition-none hover:bg-ink/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
{t("nav.github")}
</a>
Expand Down
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"tagline": "Money that can only reach the verified payee it was meant for."
},
"common": {
"skipToContent": "Skip to main content",
"testnetBanner": "Testnet only. No real money moves here.",
"notImplemented": "This page is not built yet.",
"indicative": "Indicative",
Expand Down
Loading