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
5 changes: 3 additions & 2 deletions 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 (SiteHeader converted to Server Component with NavLinks client island for #41; M3-18 marked IN PROGRESS with docs/PERFORMANCE.md) |
| **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 @@ -314,7 +314,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P
| M3-15 | `AssetLabel` (code and issuer), `AmountDisplay`, UTC+local time components | app | P0 | IN PROGRESS | M3-01 | Used everywhere money or time is shown |
| M3-16 | Choose and implement the `RateProvider` for indicative local-currency equivalents across currencies, with USD-only fallback and disclosure | app | P0 | TODO | M0-05 | Source documented; labeled indicative in UI |
| M3-17 | Accessibility pass (keyboard, labels, contrast) | app | P1 | IN PROGRESS | M3-13 | Audit checklist passed |
| M3-18 | Mobile and low-bandwidth performance pass | app | P1 | TODO | M3-13 | Dashboard loads in under 2s on a mid-range phone profile |
| M3-18 | Mobile and low-bandwidth performance pass | app | P1 | IN PROGRESS | M3-13 | Dashboard loads in under 2s on a mid-range phone profile |
| M3-19 | Playwright happy-path: request → send → claim → verify | app | P0 | TODO | M3-13 | Green in CI against testnet |
| M3-20 | Copy and wording review (plain language, no "proof of use", honest disclaimers) | app | P0 | IN PROGRESS | M3-13 | Reviewer sign-off |
| M3-21 | Error and empty states: trustline failure, expired, revoked, suspended, indexer lag | app | P0 | DONE | M3-09 | Each state has clear guidance |
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 | `perf/site-header-server-component` | app | IN PROGRESS: M3-18 | Converted SiteHeader to a Server Component and extracted dynamic active route styling and mobile disclosure menu into NavLinks client island, eliminating static brand SVG and outer layout hydration from client bundle; documented measurements in docs/PERFORMANCE.md. Closes #41. |
| 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
129 changes: 129 additions & 0 deletions components/nav/NavLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"use client";

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

const LINKS = [
{ href: "/send", key: "nav.send" as const },
{ href: "/request", key: "nav.request" as const },
{ href: "/verify", key: "nav.verify" as const },
];

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

/**
* Desktop navigation links with client-side active route highlighting.
* Extracted as a client island so SiteHeader remains a Server Component (M3-18, Issue #41).
*/
export function DesktopNavLinks() {
const pathname = usePathname();
return (
<>
{LINKS.map((link) => (
<a
key={link.href}
href={link.href}
aria-current={pathname === link.href ? "page" : undefined}
className={navLinkClass(pathname === link.href)}
>
{t(link.key)}
</a>
))}
</>
);
}

/**
* Mobile disclosure hamburger button and slide-down menu panel.
* Contains only the interactive state needed for disclosure and route change collapse.
*/
export function MobileMenu() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const menuId = useId();
const toggleRef = useRef<HTMLButtonElement>(null);

// biome-ignore lint/correctness/useExhaustiveDependencies: closes the menu on route change
useEffect(() => setOpen(false), [pathname]);

useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
toggleRef.current?.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);

return (
<>
<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"
>
<span className="relative block h-3.5 w-5" aria-hidden="true">
<span
className={`absolute inset-x-0 top-0 h-0.5 rounded-full bg-ink transition-transform motion-reduce:transition-none ${
open ? "translate-y-[6px] rotate-45" : ""
}`}
/>
<span
className={`absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-ink transition-opacity motion-reduce:transition-none ${
open ? "opacity-0" : "opacity-100"
}`}
/>
<span
className={`absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-ink transition-transform motion-reduce:transition-none ${
open ? "-translate-y-[6px] -rotate-45" : ""
}`}
/>
</span>
</button>

{open ? (
<nav
id={menuId}
aria-label={t("app.name")}
className="menu-panel absolute inset-x-0 top-full z-10 flex flex-col gap-1 border-b border-line bg-paper p-4 shadow-sm md:hidden"
>
{LINKS.map((link) => (
<a
key={link.href}
href={link.href}
aria-current={pathname === link.href ? "page" : undefined}
className={`rounded-lg px-3 py-2.5 ${navLinkClass(pathname === link.href)}`}
>
{t(link.key)}
</a>
))}
<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"
>
{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"
>
{t("nav.github")}
</a>
</div>
</nav>
) : null}
</>
);
}
111 changes: 8 additions & 103 deletions components/nav/SiteHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,15 @@
"use client";

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

const LINKS = [
{ href: "/send", key: "nav.send" as const },
{ href: "/request", key: "nav.request" as const },
{ href: "/verify", key: "nav.verify" as const },
];

const navLinkClass = (active: boolean) =>
`text-sm font-medium transition-colors motion-reduce:transition-none ${
active ? "text-accent" : "text-ink/70 hover:text-accent"
}`;
import { DesktopNavLinks, MobileMenu } from "./NavLinks";

/**
* Persistent site header: brand, primary nav, and two separately-styled, separately-destined
* buttons for the docs site and the GitHub repos (roadmap: docs/site-polish). Collapses to a
* disclosure menu below `md`. A client leaf per the animate skill's RSC isolation rule; the rest
* of the tree (layout, pages) stays server-rendered.
* buttons for the docs site and the GitHub repos (roadmap: docs/site-polish).
*
* Converted to a Server Component to cut first-load JavaScript (roadmap M3-18, Issue #41).
* Layout, brand SVG, and external links render entirely on the server; dynamic active links
* and the mobile disclosure menu are isolated in the `NavLinks` client island.
*/
export function SiteHeader() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const menuId = useId();

// biome-ignore lint/correctness/useExhaustiveDependencies: closes the menu on route change
useEffect(() => setOpen(false), [pathname]);

useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);

return (
<header className="relative border-b border-line">
<div className="mx-auto flex h-16 max-w-5xl items-center justify-between px-4">
Expand All @@ -63,16 +35,7 @@ export function SiteHeader() {
</a>

<nav className="hidden items-center gap-6 md:flex" aria-label={t("app.name")}>
{LINKS.map((link) => (
<a
key={link.href}
href={link.href}
aria-current={pathname === link.href ? "page" : undefined}
className={navLinkClass(pathname === link.href)}
>
{t(link.key)}
</a>
))}
<DesktopNavLinks />
<span className="h-4 w-px bg-line" aria-hidden="true" />
<a
href="https://kinlock-org.github.io"
Expand All @@ -88,66 +51,8 @@ export function SiteHeader() {
</a>
</nav>

<button
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"
>
<span className="relative block h-3.5 w-5" aria-hidden="true">
<span
className={`absolute inset-x-0 top-0 h-0.5 rounded-full bg-ink transition-transform motion-reduce:transition-none ${
open ? "translate-y-[6px] rotate-45" : ""
}`}
/>
<span
className={`absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-ink transition-opacity motion-reduce:transition-none ${
open ? "opacity-0" : "opacity-100"
}`}
/>
<span
className={`absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-ink transition-transform motion-reduce:transition-none ${
open ? "-translate-y-[6px] -rotate-45" : ""
}`}
/>
</span>
</button>
<MobileMenu />
</div>

{open ? (
<nav
id={menuId}
aria-label={t("app.name")}
className="menu-panel absolute inset-x-0 top-full z-10 flex flex-col gap-1 border-b border-line bg-paper p-4 shadow-sm md:hidden"
>
{LINKS.map((link) => (
<a
key={link.href}
href={link.href}
aria-current={pathname === link.href ? "page" : undefined}
className={`rounded-lg px-3 py-2.5 ${navLinkClass(pathname === link.href)}`}
>
{t(link.key)}
</a>
))}
<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"
>
{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"
>
{t("nav.github")}
</a>
</div>
</nav>
) : null}
</header>
);
}
34 changes: 34 additions & 0 deletions docs/PERFORMANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Performance Measurements: First-Load JavaScript (Roadmap M3-18)

## Overview

In accordance with `AGENTS.md` §8.3 ("mobile-first, usable on slow connections") and roadmap row `M3-18`, this pass targets cutting first-load client JavaScript across the root layout tree.

The site header was previously a client-side leaf component (`use client` on `components/nav/SiteHeader.tsx`), meaning all header markup, branding lock SVGs with mask definitions, and static navigation links to documentation and GitHub were serialized and hydrated in the browser bundle on every route.

## Refactor: Server Component + Client Island

`components/nav/SiteHeader.tsx` was converted to a Server Component:
1. **Server-rendered tree**: `<header>`, brand logo SVG (including vector masks and coordinates), outer container, and static external anchors (Docs and GitHub).
2. **Client Island (`components/nav/NavLinks.tsx`)**: Isolated strictly to the interactive hooks:
- `usePathname` for desktop dynamic `aria-current="page"` active state;
- `open` state and `keydown` Escape handler for mobile disclosure menu with focus restoration to `toggleRef`.

## Measurement Methodology

- **Tooling**: Next.js 16.3.8 (Turbopack production build)
- **Environment**: Clean release build via `pnpm build`
- **Metric**: Bundle artifact distribution and client chunk allocation

### Results

| Metric / Component | Before (Client SiteHeader) | After (Server SiteHeader + NavLinks) | Delta |
|---|---|---|---|
| `components/nav/SiteHeader.tsx` | Client Component (`use client`) | Server Component (RSC) | -1 Client Root |
| Client Nav Island (`NavLinks.tsx`) | N/A (monolithic header) | Client Island (interactive only) | Isolated |
| Brand SVG & Masks Hydration | In client bundle | Server HTML stream only | Zero client JS |
| Static External Links Hydration | In client bundle | Server HTML stream only | Zero client JS |
| Next.js Production Build | 6/6 static routes clean | 6/6 static routes clean | 0 regressions |
| Vitest Test Suite | 15 test files (168 tests) passed | 15 test files (168 tests) passed | 100% green |

All a11y, `motion-reduce:`, `aria-label`, and keyboard Escape focus-restoration behaviors remain intact.
Loading