From 932b54663a389e1526262055f99a6e3c9906d6ed Mon Sep 17 00:00:00 2001 From: gbangbolaoluwagbemiga Date: Tue, 25 Aug 2026 20:33:59 +0100 Subject: [PATCH] fix: semantic palette + light mode, mobile nav, site-map single source, motion tokens (#58 #59 #62 #70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #62 — design-system: rename literals to semantic tokens (--surface, --surface-raised, --text-primary, --text-muted, --border, --ring, --accent, --accent-2); add color-scheme: dark light; define full light palette under @media (prefers-color-scheme: light) — only token values, no component rules. WCAG AA verified in both themes (--text-primary 16–17:1, --text-muted 7:1+). Resolves #59 — components: extract SiteNav from layout.tsx into components/site-nav.tsx; nav links sourced from lib/site-map.ts. Desktop: flex link list. Mobile (≤767px): hamburger disclosure panel with focus trap, Escape/route-change close, focus-return to trigger, and aria-current="page" on the active route. Closes #58 — components: move route data into lib/site-map.ts (typed Route + RouteStatus, NAV_LINKS derived export); rewrite components/expected-pages.tsx to render from that source; add scripts/generate-site-map-md.ts generator and docs/SITE_MAP.md; add site-map-check.yml CI workflow that fails on stale generated markdown; correct all statuses to reflect reality (all routes exist). Closes #70 — design-system: add CSS motion tokens (--motion-duration-fast/base/ slow, --motion-easing-standard/enter/exit, --motion-transition-base) with @media (prefers-reduced-motion: reduce) override that collapses all durations to 0.01ms and cancels animation-iteration-count; apply to cta-secondary transition and site-nav transitions. --- .github/workflows/site-map-check.yml | 40 +++++ app/globals.css | 166 ++++++++++++++---- app/layout.tsx | 18 +- components/expected-pages.tsx | 49 ++++-- components/site-nav.tsx | 248 +++++++++++++++++++++++++++ docs/SITE_MAP.md | 14 ++ lib/site-map.ts | 94 ++++++++++ package.json | 3 +- scripts/generate-site-map-md.ts | 37 ++++ 9 files changed, 606 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/site-map-check.yml create mode 100644 components/site-nav.tsx create mode 100644 docs/SITE_MAP.md create mode 100644 lib/site-map.ts create mode 100644 scripts/generate-site-map-md.ts diff --git a/.github/workflows/site-map-check.yml b/.github/workflows/site-map-check.yml new file mode 100644 index 0000000..3512774 --- /dev/null +++ b/.github/workflows/site-map-check.yml @@ -0,0 +1,40 @@ +name: Site-map freshness + +# Fails if docs/SITE_MAP.md has not been regenerated after a change to +# lib/site-map.ts, preventing the route table from drifting (#58). + +on: + pull_request: + paths: + - "lib/site-map.ts" + - "docs/SITE_MAP.md" + - "scripts/generate-site-map-md.ts" + - ".github/workflows/site-map-check.yml" + push: + branches: [main] + paths: + - "lib/site-map.ts" + - "docs/SITE_MAP.md" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - name: Regenerate docs/SITE_MAP.md + run: npm run gen:site-map + + - name: Fail if SITE_MAP.md is stale + run: | + if ! git diff --exit-code docs/SITE_MAP.md; then + echo "::error::docs/SITE_MAP.md is stale — run 'npm run gen:site-map' and commit the result." + exit 1 + fi diff --git a/app/globals.css b/app/globals.css index 2374fe8..6ab4600 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,37 +1,110 @@ +/* + * Design-system tokens + * + * #62 — semantic palette with both dark (default) and light themes, + * color-scheme declared so browser UI matches the page. + * #70 — motion system that respects prefers-reduced-motion. + * + * Rule: never redefine component rules inside a media/theme block — only + * token values change. Components always consume tokens. + */ + +/* ── Dark palette (default) ─────────────────────────────────────────────── */ :root { - --bg: #070b14; - --surface: #0f1727; - --text: #eaf2ff; - --muted: #9bb1d3; - --accent: #59c2ff; + color-scheme: dark light; + + /* Surfaces */ + --surface: #070b14; /* page background */ + --surface-raised: #0f1727; /* cards, elevated panels */ + + /* Text — verified WCAG AA: + --text-primary on --surface: #eaf2ff / #070b14 → 17.3:1 ✓ + --text-muted on --surface-raised: #9bb1d3 / #0f1727 → 7.1:1 ✓ */ + --text-primary: #eaf2ff; + --text-muted: #9bb1d3; + + /* Accent */ + --accent: #59c2ff; --accent-2: #7cf9c4; - --ring: color-mix(in srgb, #59c2ff 35%, transparent); + + /* Border / focus ring */ + --border: color-mix(in srgb, var(--accent) 22%, transparent); + --ring: color-mix(in srgb, var(--accent) 35%, transparent); + + /* ── Motion tokens (#70) ─────────────────────────────────────────────── */ + --motion-duration-fast: 150ms; + --motion-duration-base: 250ms; + --motion-duration-slow: 400ms; + --motion-easing-standard: cubic-bezier(0.4, 0, 0.2, 1); + --motion-easing-enter: cubic-bezier(0.0, 0, 0.2, 1); + --motion-easing-exit: cubic-bezier(0.4, 0, 1.0, 1); + --motion-transition-base: var(--motion-duration-base) var(--motion-easing-standard); } +/* ── Light palette (#62) ───────────────────────────────────────────────── + Only token values are redefined here — no component rules. + Verified WCAG AA: + --text-primary on --surface: #0a1628 / #f5f8ff → 16.1:1 ✓ + --text-muted on --surface-raised: #3d567a / #ffffff → 7.3:1 ✓ */ +@media (prefers-color-scheme: light) { + :root { + --surface: #f5f8ff; + --surface-raised: #ffffff; + --text-primary: #0a1628; + --text-muted: #3d567a; + --accent: #0077cc; + --accent-2: #00905e; + --border: color-mix(in srgb, var(--accent) 30%, transparent); + --ring: color-mix(in srgb, var(--accent) 40%, transparent); + } +} + +/* ── Reduced-motion override (#70) ──────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + :root { + --motion-duration-fast: 0.01ms; + --motion-duration-base: 0.01ms; + --motion-duration-slow: 0.01ms; + } + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ── Base reset ──────────────────────────────────────────────────────────── */ * { box-sizing: border-box; } body { margin: 0; - background: radial-gradient(circle at 20% 0%, color-mix(in srgb, #59c2ff 18%, #070b14) 0%, var(--bg) 42%); - color: var(--text); + background: radial-gradient( + circle at 20% 0%, + color-mix(in srgb, var(--accent) 18%, var(--surface)) 0%, + var(--surface) 42% + ); + color: var(--text-primary); font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; line-height: 1.6; } a { color: inherit; text-decoration: none; } +/* ── Layout ──────────────────────────────────────────────────────────────── */ .container { max-width: 1080px; margin: 0 auto; padding: 0 20px; } +/* ── Nav ─────────────────────────────────────────────────────────────────── */ .nav { position: sticky; top: 0; backdrop-filter: blur(10px); - background: color-mix(in srgb, var(--bg) 78%, transparent); - border-bottom: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); + background: color-mix(in srgb, var(--surface) 78%, transparent); + border-bottom: 1px solid var(--border); z-index: 20; } @@ -47,14 +120,16 @@ a { color: inherit; text-decoration: none; } letter-spacing: 0.2px; } +/* Desktop link list — hidden at mobile by site-nav.tsx */ .links { display: flex; flex-wrap: wrap; gap: 14px; - color: var(--muted); + color: var(--text-muted); font-size: 0.92rem; } +/* ── Hero ────────────────────────────────────────────────────────────────── */ .hero { padding: 56px 0 28px; } @@ -65,10 +140,11 @@ a { color: inherit; text-decoration: none; } } .hero p { - color: var(--muted); + color: var(--text-muted); max-width: 720px; } +/* ── Grid / Card ─────────────────────────────────────────────────────────── */ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); @@ -77,15 +153,20 @@ a { color: inherit; text-decoration: none; } } .card { - background: linear-gradient(180deg, color-mix(in srgb, var(--surface) 88%, var(--bg)) 0%, var(--surface) 100%); - border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-raised) 88%, var(--surface)) 0%, + var(--surface-raised) 100% + ); + border: 1px solid var(--border); border-radius: 14px; padding: 18px; } .card h3 { margin: 0 0 8px; } -.card p { margin: 0; color: var(--muted); } +.card p { margin: 0; color: var(--text-muted); } +/* ── Section ─────────────────────────────────────────────────────────────── */ .section { padding: 24px 0 40px; } @@ -94,9 +175,10 @@ a { color: inherit; text-decoration: none; } .list { padding-left: 18px; - color: var(--muted); + color: var(--text-muted); } +/* ── Tag ─────────────────────────────────────────────────────────────────── */ .tag { display: inline-block; padding: 6px 10px; @@ -110,6 +192,7 @@ a { color: inherit; text-decoration: none; } letter-spacing: 0.06em; } +/* ── CTA ─────────────────────────────────────────────────────────────────── */ .cta { display: inline-block; margin-top: 12px; @@ -120,8 +203,9 @@ a { color: inherit; text-decoration: none; } font-weight: 700; } +/* ── Site-map table ──────────────────────────────────────────────────────── */ .site-map { - border-top: 1px solid color-mix(in srgb, var(--accent) 15%, transparent); + border-top: 1px solid var(--border); padding-top: 36px; } @@ -131,34 +215,35 @@ a { color: inherit; text-decoration: none; } font-size: 0.92rem; } -.site-map th, .site-map td { +.site-map th, +.site-map td { text-align: left; padding: 10px 12px; - border-bottom: 1px solid color-mix(in srgb, var(--muted) 25%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--text-muted) 25%, transparent); } .site-map th { - color: var(--muted); + color: var(--text-muted); font-weight: 600; } -/* landing-enhancements-v2 */ - - -/* === landing enhancements (generated) === */ +/* ── Landing hero ─────────────────────────────────────────────────────────── */ .landing-hero { position: relative; overflow: hidden; padding: 40px 0 48px; } + .landing-hero-inner { position: relative; z-index: 2; max-width: 720px; } + .landing-logo { width: clamp(76px, 14vw, 128px); height: auto; margin-bottom: 22px; filter: drop-shadow(0 14px 42px color-mix(in srgb, var(--accent) 38%, transparent)); } -.nav-logo { width: 38px; height: 38px; flex-shrink: 0; display: block; } + +.nav-logo { width: 38px; height: 38px; flex-shrink: 0; display: block; } .brand-with-logo { display: flex; align-items: center; @@ -175,13 +260,15 @@ a { color: inherit; text-decoration: none; } font-weight: 800; letter-spacing: -0.03em; } + .landing-lead { font-size: 1.07rem; max-width: 560px; margin: 0 0 26px; - color: var(--muted); + color: var(--text-muted); line-height: 1.65; } + .landing-cta-row { display: flex; flex-wrap: wrap; @@ -190,6 +277,7 @@ a { color: inherit; text-decoration: none; } margin-bottom: 28px; } .landing-cta-row .cta { margin-top: 0; } + .cta-secondary { display: inline-block; padding: 11px 18px; @@ -197,12 +285,15 @@ a { color: inherit; text-decoration: none; } border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); color: var(--accent); font-weight: 600; - transition: background 0.15s ease, border-color 0.15s ease; + transition: + background var(--motion-duration-fast) var(--motion-easing-standard), + border-color var(--motion-duration-fast) var(--motion-easing-standard); } .cta-secondary:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); border-color: color-mix(in srgb, var(--accent) 65%, transparent); } + .landing-stats { display: flex; flex-wrap: wrap; @@ -211,14 +302,15 @@ a { color: inherit; text-decoration: none; } padding: 0; margin: 0; font-size: 0.82rem; - color: var(--muted); + color: var(--text-muted); } .landing-stats li { padding: 8px 14px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 11%, transparent); - border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); + border: 1px solid var(--border); } + .landing-orbs { position: absolute; inset: 0; @@ -247,6 +339,7 @@ a { color: inherit; text-decoration: none; } bottom: -15%; left: -8%; } + .landing-pillars { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); @@ -258,10 +351,10 @@ a { color: inherit; text-decoration: none; } border-radius: 16px; background: linear-gradient( 165deg, - color-mix(in srgb, var(--surface) 92%, var(--bg)) 0%, - var(--surface) 100% + color-mix(in srgb, var(--surface-raised) 92%, var(--surface)) 0%, + var(--surface-raised) 100% ); - border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); + border: 1px solid var(--border); } .landing-pillar-icon { font-size: 1.35rem; @@ -275,18 +368,19 @@ a { color: inherit; text-decoration: none; } } .landing-pillar p { margin: 0; - color: var(--muted); + color: var(--text-muted); font-size: 0.93rem; line-height: 1.55; } + .landing-trust { text-align: center; padding: 20px 22px; margin-bottom: 36px; font-size: 0.88rem; - color: var(--muted); - border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--text-muted); + border: 1px solid var(--border); border-radius: 14px; - background: color-mix(in srgb, var(--surface) 65%, transparent); + background: color-mix(in srgb, var(--surface-raised) 65%, transparent); letter-spacing: 0.04em; } diff --git a/app/layout.tsx b/app/layout.tsx index 9deb9d7..0d5aa05 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; +import { SiteNav } from "@/components/site-nav"; import { resolveSiteUrl } from "@/lib/site-url"; import "./globals.css"; @@ -35,16 +36,6 @@ export const metadata: Metadata = { }, }; -const nav = [ - ["Product", "/product"], - ["Contracts", "/contracts"], - ["Operators", "/operators"], - ["Compliance", "/compliance"], - ["Roadmap", "/roadmap"], - ["Contributors", "/contributors"], - ["Docs", "/docs"], -] as const; - export default function RootLayout({ children }: { children: React.ReactNode }) { return ( @@ -62,11 +53,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) /> ModelTrace - + {/* Nav extracted into SiteNav (#59) — responsive with mobile menu */} +
{children}
diff --git a/components/expected-pages.tsx b/components/expected-pages.tsx index c666f8c..5386790 100644 --- a/components/expected-pages.tsx +++ b/components/expected-pages.tsx @@ -1,11 +1,37 @@ +/** + * ExpectedPages — renders the site-map route table from the single typed + * source in `lib/site-map.ts` (#58). Status badges are derived from + * `Route.status`; no hand-maintained duplicates. + * + * The markdown equivalent (`docs/SITE_MAP.md`) is generated by + * `scripts/generate-site-map-md.ts` and verified in CI. + */ + +import { ROUTES, type RouteStatus } from "@/lib/site-map"; + +const STATUS_LABEL: Record = { + live: "Live", + scaffold: "Scaffold", + planned: "Planned", +}; + +const STATUS_STYLE: Record = { + live: { color: "var(--accent-2)", fontWeight: 600 }, + scaffold: { color: "var(--accent)", fontWeight: 600 }, + planned: { color: "var(--text-muted)" }, +}; + export function ExpectedPages() { return (
Site map

Expected pages (delivery backlog)

-

- This table is the contract between product and engineering. Routes marked scaffold ship as - placeholders; planned routes are tracked for sprint planning. +

+ This table is generated from{" "} + lib/site-map.ts — the single source of truth between + product and engineering. Update the source and run{" "} + npm run gen:site-map to keep{" "} + docs/SITE_MAP.md in sync.

@@ -17,14 +43,15 @@ export function ExpectedPages() { - - - - - - - - + {ROUTES.map((route) => ( + + + + + + ))}
/Marketing hub + site mapScaffold
/productPersonas, pricing hooks, integration storyPlanned
/contractsSoroban modules and interaction flowsPlanned
/operatorsDashboard preview for AI gatewaysPlanned
/complianceAudit exports and policy packsPlanned
/roadmapMilestones vs grantsScaffold
/contributorsGood first issues and guild rolesPlanned
/docsTechnical reference hubScaffold
{route.path}{route.purpose} + {STATUS_LABEL[route.status]} +
diff --git a/components/site-nav.tsx b/components/site-nav.tsx new file mode 100644 index 0000000..0169dde --- /dev/null +++ b/components/site-nav.tsx @@ -0,0 +1,248 @@ +"use client"; + +/** + * SiteNav — responsive top-level navigation (#59). + * + * Desktop: inline link list. + * Mobile (≤ 767px): hamburger button opens a disclosure panel that is: + * - keyboard accessible (focus trapped while open) + * - closed on Escape, outside click, or route change + * - focus returned to the trigger on close + * + * Current route is marked with `aria-current="page"` and a visible highlight. + * Link data comes from `lib/site-map.ts` — the single source (#58). + */ + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { NAV_LINKS } from "@/lib/site-map"; + +const FOCUSABLE = + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +function getFocusable(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll(FOCUSABLE)); +} + +export function SiteNav() { + const pathname = usePathname(); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const menuRef = useRef(null); + + // Close on route change + useEffect(() => { + setOpen(false); + }, [pathname]); + + // Close on Escape; trap focus inside while open + useEffect(() => { + if (!open) return; + + const menu = menuRef.current; + if (!menu) return; + + // Shift focus into the first item when the panel opens + const focusable = getFocusable(menu); + focusable[0]?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + triggerRef.current?.focus(); + return; + } + if (e.key !== "Tab") return; + + const items = getFocusable(menuRef.current!); + if (items.length === 0) return; + + const first = items[0]; + const last = items[items.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open]); + + // Close on outside click + const handleOutside = useCallback((e: MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + triggerRef.current && + !triggerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }, []); + + useEffect(() => { + if (!open) return; + document.addEventListener("mousedown", handleOutside); + return () => document.removeEventListener("mousedown", handleOutside); + }, [open, handleOutside]); + + const toggle = () => { + if (open) { + setOpen(false); + triggerRef.current?.focus(); + } else { + setOpen(true); + } + }; + + return ( + + ); +} diff --git a/docs/SITE_MAP.md b/docs/SITE_MAP.md new file mode 100644 index 0000000..28ca709 --- /dev/null +++ b/docs/SITE_MAP.md @@ -0,0 +1,14 @@ +# Site Map + + +| Route | Purpose | Status | +|-------|---------|--------| +| `/` | Marketing hub + site map | 🏗 Scaffold | +| `/product` | Personas, pricing hooks, integration story | 🏗 Scaffold | +| `/contracts` | Soroban modules and interaction flows | 🏗 Scaffold | +| `/operators` | Dashboard preview for AI gateways | 🏗 Scaffold | +| `/compliance` | Audit exports and policy packs | 🏗 Scaffold | +| `/roadmap` | Milestones vs grants | 🏗 Scaffold | +| `/contributors` | Good first issues and guild roles | 🏗 Scaffold | +| `/docs` | Technical reference hub | 🏗 Scaffold | diff --git a/lib/site-map.ts b/lib/site-map.ts new file mode 100644 index 0000000..32c14d7 --- /dev/null +++ b/lib/site-map.ts @@ -0,0 +1,94 @@ +/** + * Site map — single typed source of route data (#58). + * + * This module is the sole owner of: + * - the route table rendered by `components/expected-pages.tsx` + * - the nav links used by `components/site-nav.tsx` + * - `docs/SITE_MAP.md` (generated by `scripts/generate-site-map-md.ts`) + * + * To add or change a route: + * 1. Edit the `ROUTES` array below. + * 2. Run `npm run gen:site-map` to regenerate `docs/SITE_MAP.md`. + * 3. Commit both files — CI will fail if they diverge. + */ + +export type RouteStatus = "live" | "scaffold" | "planned"; + +export interface Route { + /** URL path, e.g. "/product" */ + path: string; + /** Human-readable purpose shown in the site-map table */ + purpose: string; + /** Current delivery status */ + status: RouteStatus; + /** Whether to show this route in the top-level navigation */ + showInNav?: boolean; + /** Short label used in the nav (defaults to the last path segment, capitalised) */ + navLabel?: string; +} + +export const ROUTES: Route[] = [ + { + path: "/", + purpose: "Marketing hub + site map", + status: "scaffold", + showInNav: false, + }, + { + path: "/product", + purpose: "Personas, pricing hooks, integration story", + status: "scaffold", + showInNav: true, + navLabel: "Product", + }, + { + path: "/contracts", + purpose: "Soroban modules and interaction flows", + status: "scaffold", + showInNav: true, + navLabel: "Contracts", + }, + { + path: "/operators", + purpose: "Dashboard preview for AI gateways", + status: "scaffold", + showInNav: true, + navLabel: "Operators", + }, + { + path: "/compliance", + purpose: "Audit exports and policy packs", + status: "scaffold", + showInNav: true, + navLabel: "Compliance", + }, + { + path: "/roadmap", + purpose: "Milestones vs grants", + status: "scaffold", + showInNav: true, + navLabel: "Roadmap", + }, + { + path: "/contributors", + purpose: "Good first issues and guild roles", + status: "scaffold", + showInNav: true, + navLabel: "Contributors", + }, + { + path: "/docs", + purpose: "Technical reference hub", + status: "scaffold", + showInNav: true, + navLabel: "Docs", + }, +]; + +/** Nav links derived from the route table — the only source used by SiteNav. */ +export const NAV_LINKS: { label: string; href: string }[] = ROUTES.filter( + (r) => r.showInNav, +).map((r) => ({ + label: r.navLabel ?? r.path.slice(1).replace(/^./, (c) => c.toUpperCase()), + href: r.path, +})); diff --git a/package.json b/package.json index a6ca758..7b3a133 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui" + "test:e2e:ui": "playwright test --ui", + "gen:site-map": "node --loader ts-node/esm scripts/generate-site-map-md.ts" }, "dependencies": { "next": "^15.1.3", diff --git a/scripts/generate-site-map-md.ts b/scripts/generate-site-map-md.ts new file mode 100644 index 0000000..339b87c --- /dev/null +++ b/scripts/generate-site-map-md.ts @@ -0,0 +1,37 @@ +/** + * generate-site-map-md.ts + * + * Generates `docs/SITE_MAP.md` from `lib/site-map.ts` (#58). + * Run via: npm run gen:site-map + * + * CI runs this and fails if the committed file differs from the output, + * preventing the table from drifting out of sync with the route list. + */ + +import { writeFileSync } from "fs"; +import { join } from "path"; +import { ROUTES } from "../lib/site-map"; + +const STATUS_LABEL: Record = { + live: "✅ Live", + scaffold: "🏗 Scaffold", + planned: "📋 Planned", +}; + +const rows = ROUTES.map( + (r) => + `| \`${r.path}\` | ${r.purpose} | ${STATUS_LABEL[r.status] ?? r.status} |`, +).join("\n"); + +const md = `# Site Map + + +| Route | Purpose | Status | +|-------|---------|--------| +${rows} +`; + +const outPath = join(import.meta.dirname, "..", "docs", "SITE_MAP.md"); +writeFileSync(outPath, md, "utf8"); +console.log("✓ docs/SITE_MAP.md written");