diff --git a/ROADMAP.md b/ROADMAP.md
index a4bc565..1149e90 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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) |
@@ -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 |
@@ -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. |
diff --git a/components/nav/NavLinks.tsx b/components/nav/NavLinks.tsx
new file mode 100644
index 0000000..16dd6a2
--- /dev/null
+++ b/components/nav/NavLinks.tsx
@@ -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) => (
+
+ {t(link.key)}
+
+ ))}
+ >
+ );
+}
+
+/**
+ * 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(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 (
+ <>
+
+
+ {open ? (
+
+ ) : null}
+ >
+ );
+}
diff --git a/components/nav/SiteHeader.tsx b/components/nav/SiteHeader.tsx
index 1f6f524..64ff7bb 100644
--- a/components/nav/SiteHeader.tsx
+++ b/components/nav/SiteHeader.tsx
@@ -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 (
@@ -63,16 +35,7 @@ export function SiteHeader() {
-
+
-
- {open ? (
-
- ) : null}
);
}
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
new file mode 100644
index 0000000..e921b48
--- /dev/null
+++ b/docs/PERFORMANCE.md
@@ -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**: ``, 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.