From 35ea3b3e1c71b1ac1b1f0d230bf4012b7f9c263d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 09:54:46 -0700 Subject: [PATCH 01/29] docs(specs): website statusbrew-inspired refactor design Design spec for refactoring apps/website to a modern, no-nonsense SaaS aesthetic inspired by Statusbrew while preserving the existing brand. Covers tokens, shared component primitives, homepage IA (12 sections), migration plan for /pilot-to-prod, /angular, /chat, /render, /solutions, /pricing, and 6-phase implementation phasing. Co-Authored-By: Claude Opus 4.7 --- ...5-12-website-statusbrew-refactor-design.md | 481 ++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-12-website-statusbrew-refactor-design.md diff --git a/docs/superpowers/specs/2026-05-12-website-statusbrew-refactor-design.md b/docs/superpowers/specs/2026-05-12-website-statusbrew-refactor-design.md new file mode 100644 index 000000000..868ce7239 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-website-statusbrew-refactor-design.md @@ -0,0 +1,481 @@ +# Website refactor: modern SaaS aesthetic on the existing brand + +## Summary + +Refactor the marketing site (`apps/website`) to a cleaner, more confident, product-led SaaS look inspired by Statusbrew, while preserving the existing brand identity (deep-blue `#004090`, Angular red accent, EB Garamond editorial heads, MIT/dev-tool positioning). Drop glassmorphism, gradient blobs, and ambient washes. Replace with near-white surfaces, layered product screenshots in browser frames, and editorial typography. + +This is a **developer-first** homepage refactor — the framework is the product. Pilot-to-Prod stays as a strong mid-page block but does not lead. Docs (`/docs/**`) are out of scope. + +## Goals + +1. Site feels like a modern, trustworthy dev-tool SaaS brand (Linear/Vercel/Resend territory) without losing the Angular Agent Framework's recognizable identity. +2. Product UI is prominent — layered static screenshots + one signature live demo. +3. Trust signals are honest — real proof (GitHub stars, npm installs, ecosystem listings), no borrowed customer logos. +4. Design system is reusable: same primitives drive every marketing page. + +## Non-goals + +- Docs styling (`/docs/**`) — different design language is appropriate for documentation. +- Dark mode (Phase 2, after light theme ships clean). +- Rebranding (logo, name, core positioning untouched). +- New illustrations or generic stock SaaS imagery. + +## Audience + +**Primary:** Angular developers and tech leads evaluating the framework. Decisions on signals/streaming/GenUI/MIT licensing. Conversion: `npm install` → docs → first integration. + +**Secondary:** Engineering leaders considering the Pilot-to-Prod program. Mid-page block addresses them; they are not the top-of-funnel. + +## Design system + +### Tokens — `apps/website/lib/design-tokens.ts` + +Full rewrite. Keep the file as single source of truth. New shape: + +```ts +export const tokens = { + colors: { + // Surfaces + canvas: '#fafbfc', // page background + surface: '#ffffff', // cards, frames, nav + surfaceTinted: '#f4f6fb', // alternating sections + surfaceDim: '#eef1f7', // pricing-card highlight, callouts + border: '#e6e8ee', // 1px hairlines + borderStrong: '#d2d6e0', // emphasized borders + + // Text + textPrimary: '#0f1729', + textSecondary: '#475067', + textMuted: '#6b7280', + textInverted: '#ffffff', + + // Brand — retained + accent: '#004090', + accentHover: '#003070', + accentLight: '#64C3FD', // used sparingly for highlights/markers + accentSurface: 'rgba(0, 64, 144, 0.06)', + accentBorder: 'rgba(0, 64, 144, 0.15)', + angularRed: '#DD0031', // Angular badges/marks only + }, + shadow: { + sm: '0 1px 2px rgba(15, 23, 41, 0.04), 0 1px 1px rgba(15, 23, 41, 0.03)', + md: '0 4px 12px rgba(15, 23, 41, 0.06), 0 2px 4px rgba(15, 23, 41, 0.04)', + lg: '0 12px 32px rgba(15, 23, 41, 0.08), 0 4px 8px rgba(15, 23, 41, 0.05)', + focus: '0 0 0 3px rgba(0, 64, 144, 0.25)', + }, + radius: { + sm: '6px', + md: '10px', + lg: '14px', + xl: '20px', + full: '999px', + }, + type: { + h1: { size: 'clamp(48px, 6vw, 72px)', line: 1.08, family: 'var(--font-garamond)' }, + h2: { size: 'clamp(36px, 4.5vw, 56px)', line: 1.12, family: 'var(--font-garamond)' }, + h3: { size: '28px', line: 1.25, family: 'var(--font-inter)', weight: 600 }, + eyebrow: { size: '12px', line: 1.4, family: 'var(--font-mono)', weight: 700, letterSpacing: '0.12em', transform: 'uppercase' }, + bodyLg: { size: '20px', line: 1.6, family: 'var(--font-inter)' }, + body: { size: '16px', line: 1.6, family: 'var(--font-inter)' }, + caption: { size: '14px', line: 1.5, family: 'var(--font-inter)' }, + mono: { family: 'var(--font-mono)' }, + }, + space: { + sectionY: 'clamp(64px, 8vw, 120px)', + sectionYTight: 'clamp(48px, 6vw, 80px)', + containerX: 'clamp(20px, 4vw, 40px)', + containerMax: '1200px', + }, +} as const; +``` + +**Removed:** `glass.*`, `gradient.*`, `glow.hero|demo|card|border|button` (replaced by `shadow.*` + `focus`). + +CSS custom properties in `apps/website/src/app/global.css` mirror this token set 1-to-1. + +### Primitives — `apps/website/src/components/ui/` + +New files. All server components unless marked. + +| File | Purpose | API sketch | +|---|---|---| +| `cn.ts` | `clsx` + `tailwind-merge` wrapper. | `cn(...classes: ClassValue[])` | +| `Container.tsx` | Max-width + responsive horizontal padding. | `` | +| `Section.tsx` | Vertical rhythm + optional surface variant + optional eyebrow/headline header. | `
` | +| `Button.tsx` | `cva` variants. | ` + ); +} +``` + +- [ ] **Step 2: Type-check** + +Run: `pnpm nx typecheck website` +Expected: no errors. Discriminated union on `href` should type-check cleanly. + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/src/components/ui/Button.tsx +git commit -m "feat(website): add Button primitive with primary/secondary/ghost variants" +``` + +--- + +## Task 17: Add `Card` primitive + +**Files:** +- Create: `apps/website/src/components/ui/Card.tsx` + +White card with border, rounded corners, subtle shadow. + +- [ ] **Step 1: Create the file** + +Create `apps/website/src/components/ui/Card.tsx`: + +```tsx +import type { ReactNode, HTMLAttributes } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +interface CardProps extends HTMLAttributes { + children: ReactNode; + /** If true, applies a subtle hover lift via CSS. */ + hoverable?: boolean; + /** Internal padding tier. */ + padding?: 'md' | 'lg'; + /** Override the surface color. */ + surface?: 'white' | 'tinted' | 'dim'; +} + +const SURFACE: Record, string> = { + white: tokens.surfaces.surface, + tinted: tokens.surfaces.surfaceTinted, + dim: tokens.surfaces.surfaceDim, +}; + +const PADDING: Record, string> = { + md: '20px', + lg: '28px', +}; + +export function Card({ + children, + hoverable = false, + padding = 'md', + surface = 'white', + className, + style, + ...rest +}: CardProps) { + return ( +
+ {children} +
+ ); +} +``` + +- [ ] **Step 2: Add the hover styles to global.css** + +Append to `apps/website/src/app/global.css` (at the bottom, after existing rules): + +```css +/* UI primitive — Card hover */ +[data-ui="card"][data-hoverable] { + cursor: default; +} +[data-ui="card"][data-hoverable]:hover { + box-shadow: var(--shadow-md); + border-color: var(--color-border-strong); + transform: translateY(-1px); +} +@media (prefers-reduced-motion: reduce) { + [data-ui="card"][data-hoverable]:hover { + transform: none; + } +} +``` + +- [ ] **Step 3: Type-check** + +Run: `pnpm nx typecheck website` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/src/components/ui/Card.tsx apps/website/src/app/global.css +git commit -m "feat(website): add Card primitive with hover variant" +``` + +--- + +## Task 18: Add `BrowserFrame` primitive + +**Files:** +- Create: `apps/website/src/components/ui/BrowserFrame.tsx` + +Mac-style window chrome around screenshots/iframes for the layered hero collage. + +- [ ] **Step 1: Create the file** + +Create `apps/website/src/components/ui/BrowserFrame.tsx`: + +```tsx +import type { ReactNode, HTMLAttributes } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +interface BrowserFrameProps extends HTMLAttributes { + children: ReactNode; + /** Faux URL shown in the address bar. */ + url?: string; + /** Degrees of rotation for collage stacking. */ + rotate?: number; + /** Elevation tier — defaults to `md`. */ + elevation?: 'sm' | 'md' | 'lg'; + /** Optional max-width override. */ + maxWidth?: number | string; +} + +const ELEVATION: Record, string> = { + sm: tokens.shadows.sm, + md: tokens.shadows.md, + lg: tokens.shadows.lg, +}; + +export function BrowserFrame({ + children, + url, + rotate = 0, + elevation = 'md', + maxWidth, + className, + style, + ...rest +}: BrowserFrameProps) { + return ( +
+ {/* Title bar */} +
+ {/* Traffic lights */} + + {/* URL pill */} + {url ? ( +
+ {url} +
+ ) : null} + {/* Right spacer to balance traffic lights */} + + + {/* Frame body */} +
+ {children} +
+
+ ); +} +``` + +- [ ] **Step 2: Type-check** + +Run: `pnpm nx typecheck website` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/src/components/ui/BrowserFrame.tsx +git commit -m "feat(website): add BrowserFrame primitive" +``` + +--- + +## Task 19: Add `LogoMark` primitive + +**Files:** +- Create: `apps/website/src/components/ui/LogoMark.tsx` + +The 🛩️ + "Angular Agent Framework" wordmark. Replaces ad-hoc logo treatments in `Nav`/`Footer` later. + +- [ ] **Step 1: Create the file** + +Create `apps/website/src/components/ui/LogoMark.tsx`: + +```tsx +import type { HTMLAttributes } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +interface LogoMarkProps extends HTMLAttributes { + size?: 'sm' | 'md'; + /** Hide the wordmark, show only the icon. */ + iconOnly?: boolean; +} + +const SIZE: Record, { icon: number; label: number }> = { + sm: { icon: 18, label: 14 }, + md: { icon: 22, label: 16 }, +}; + +export function LogoMark({ size = 'md', iconOnly = false, className, style, ...rest }: LogoMarkProps) { + const s = SIZE[size]; + return ( + + + {iconOnly ? null : Angular Agent Framework} + + ); +} +``` + +- [ ] **Step 2: Type-check** + +Run: `pnpm nx typecheck website` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/src/components/ui/LogoMark.tsx +git commit -m "feat(website): add LogoMark primitive" +``` + +--- + +## Task 20: Add `FAQ` primitive + +**Files:** +- Create: `apps/website/src/components/ui/FAQ.tsx` + +Native `
`-based accordion. Keyboard accessible by default. Animated open/close via CSS. + +- [ ] **Step 1: Create the file** + +Create `apps/website/src/components/ui/FAQ.tsx`: + +```tsx +import type { ReactNode } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +export interface FAQItem { + q: string; + a: ReactNode; +} + +interface FAQProps { + items: FAQItem[]; + className?: string; +} + +/** + * Native-details FAQ accordion. Keyboard accessible out of the box. + * Each item can be opened independently; no shared exclusivity. + */ +export function FAQ({ items, className }: FAQProps) { + return ( +
+ {items.map((item, i) => ( +
+ + {item.q} + + +
+ {item.a} +
+
+ ))} +
+ ); +} +``` + +- [ ] **Step 2: Add chevron rotation + summary marker-removal CSS** + +Append to `apps/website/src/app/global.css`: + +```css +/* UI primitive — FAQ */ +[data-ui="faq-item"] > summary::-webkit-details-marker { + display: none; +} +[data-ui="faq-item"][open] [data-ui="faq-chevron"] { + transform: rotate(180deg); +} +[data-ui="faq-item"] > summary:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); + border-radius: var(--radius-sm); +} +``` + +- [ ] **Step 3: Type-check** + +Run: `pnpm nx typecheck website` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/src/components/ui/FAQ.tsx apps/website/src/app/global.css +git commit -m "feat(website): add FAQ accordion primitive" +``` + +--- + +## Task 21: Create `_dev/primitives` showcase route + +**Files:** +- Create: `apps/website/src/app/_dev/primitives/page.tsx` + +A dev-only route that renders one of each primitive so we can both eyeball them in dev and assert them in Playwright. Will be deleted in a later phase. + +- [ ] **Step 1: Create the page** + +Create `apps/website/src/app/_dev/primitives/page.tsx`: + +```tsx +import { Container } from '../../../components/ui/Container'; +import { Section } from '../../../components/ui/Section'; +import { Eyebrow } from '../../../components/ui/Eyebrow'; +import { Pill } from '../../../components/ui/Pill'; +import { Button } from '../../../components/ui/Button'; +import { Card } from '../../../components/ui/Card'; +import { BrowserFrame } from '../../../components/ui/BrowserFrame'; +import { LogoMark } from '../../../components/ui/LogoMark'; +import { FAQ, type FAQItem } from '../../../components/ui/FAQ'; + +export const metadata = { title: 'UI primitives — dev only' }; + +const FAQ_ITEMS: FAQItem[] = [ + { q: 'Is this real?', a: 'Yes — this page is for verifying the primitives during refactor Phase 1.' }, + { q: 'Will it stay forever?', a: 'No, this route gets deleted once the marketing pages have migrated.' }, +]; + +export default function PrimitivesDevPage() { + return ( + <> +
+ +

+ UI primitives +

+ +

LogoMark

+
+ + + +
+ +

Eyebrow

+
+ Default muted + Accent + Angular +
+ +

Pill

+
+ MIT + LangGraph + Angular 20+ +
+ +

Button

+
+ + + + +
+ +

Card

+
+ + Standard +

A basic card with default padding.

+
+ + Hoverable +

Hover me — gentle lift + stronger shadow.

+
+ + Tinted, lg padding +

Used for emphasized callouts.

+
+
+ +

BrowserFrame

+
+ +
+ Placeholder content +
+
+
+
+
+ +
+ +

Section surface = tinted

+

This section uses the tinted surface variant.

+
+
+ +
+ +

FAQ

+ +
+
+ + ); +} +``` + +- [ ] **Step 2: Start the dev server and eyeball the page** + +Run: `pnpm nx dev website` (or `pnpm nx run website:dev` — check `apps/website/project.json` for the exact target). +Open: `http://localhost:/_dev/primitives` +Expected: each primitive renders without console errors. Note: the existing `Nav` and `Footer` still wrap the page since they're in the root layout; that's fine — Phase 2 will replace them. + +If anything looks broken, fix the relevant primitive before continuing. + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/src/app/_dev/primitives/page.tsx +git commit -m "feat(website): add _dev/primitives showcase route" +``` + +--- + +## Task 22: Add Playwright assertions for primitives + +**Files:** +- Create: `apps/website/e2e/primitives.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/website/e2e/primitives.spec.ts`: + +```typescript +import { test, expect } from '@playwright/test'; + +test.describe('UI primitives showcase', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/_dev/primitives'); + }); + + test('renders the page heading', async ({ page }) => { + await expect(page.locator('[data-testid="primitives-page-title"]')).toBeVisible(); + }); + + test('renders LogoMark', async ({ page }) => { + const logos = page.locator('[data-ui="logo-mark"]'); + await expect(logos).toHaveCount(3); + }); + + test('renders Eyebrow in all tones', async ({ page }) => { + await expect(page.locator('[data-ui="eyebrow"][data-tone="muted"]')).toBeVisible(); + await expect(page.locator('[data-ui="eyebrow"][data-tone="accent"]')).toBeVisible(); + await expect(page.locator('[data-ui="eyebrow"][data-tone="angular"]')).toBeVisible(); + }); + + test('renders Pill in all variants', async ({ page }) => { + await expect(page.locator('[data-ui="pill"][data-variant="neutral"]')).toBeVisible(); + await expect(page.locator('[data-ui="pill"][data-variant="accent"]')).toBeVisible(); + await expect(page.locator('[data-ui="pill"][data-variant="angular"]')).toBeVisible(); + }); + + test('renders Button variants', async ({ page }) => { + await expect(page.locator('[data-ui="button"][data-variant="primary"]').first()).toBeVisible(); + await expect(page.locator('[data-ui="button"][data-variant="secondary"]')).toBeVisible(); + await expect(page.locator('[data-ui="button"][data-variant="ghost"]')).toBeVisible(); + // Large primary renders as an with href + const linkButton = page.locator('a[data-ui="button"][data-size="lg"]'); + await expect(linkButton).toHaveAttribute('href', '/docs'); + }); + + test('renders Card variants including hoverable', async ({ page }) => { + const cards = page.locator('[data-ui="card"]'); + await expect(cards).toHaveCount(3); + await expect(page.locator('[data-ui="card"][data-hoverable]')).toHaveCount(1); + }); + + test('renders BrowserFrame with URL pill', async ({ page }) => { + const frame = page.locator('[data-ui="browser-frame"]'); + await expect(frame).toBeVisible(); + await expect(frame).toContainText('cockpit.cacheplane.ai'); + }); + + test('renders Section with tinted surface variant', async ({ page }) => { + await expect(page.locator('[data-ui="section"][data-surface="tinted"]')).toBeVisible(); + }); + + test('FAQ items toggle open and closed', async ({ page }) => { + const firstItem = page.locator('[data-ui="faq-item"]').first(); + await expect(firstItem).not.toHaveAttribute('open', ''); + await firstItem.locator('summary').click(); + await expect(firstItem).toHaveAttribute('open', ''); + await firstItem.locator('summary').click(); + await expect(firstItem).not.toHaveAttribute('open', ''); + }); + + test('FAQ summary is keyboard-focusable', async ({ page }) => { + const summary = page.locator('[data-ui="faq-item"]').first().locator('summary'); + await summary.focus(); + await expect(summary).toBeFocused(); + }); +}); +``` + +- [ ] **Step 2: Run the new tests** + +Run: `pnpm nx e2e website --grep "UI primitives showcase"` (or equivalent — check `apps/website/project.json` for the e2e target). + +Expected: all 9 tests in the suite pass. Existing `website.spec.ts` tests continue to pass — Phase 1 hasn't touched any visible content. + +If any primitive test fails, fix the primitive's `data-ui`/`data-variant` attributes to match the test selectors, then re-run. + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/e2e/primitives.spec.ts +git commit -m "test(website): add Playwright coverage for UI primitives" +``` + +--- + +## Task 23: Final verification — build + typecheck the workspace + +**Files:** none + +- [ ] **Step 1: Workspace build** + +Run: `pnpm nx run-many -t build -p design-tokens,website` +Expected: both projects build successfully. + +- [ ] **Step 2: Workspace typecheck** + +Run: `pnpm nx run-many -t typecheck -p design-tokens,website` (skip if typecheck is not a separate target — `nx build` covers it for these projects). +Expected: zero type errors. + +- [ ] **Step 3: Run all existing e2e tests to confirm nothing regressed** + +Run: `pnpm nx e2e website` +Expected: every existing test plus the new primitives suite passes. If any existing test fails, investigate — Phase 1 should not touch user-visible content, so a failure indicates a side-effect to fix before declaring the phase done. + +- [ ] **Step 4: Confirm bundle did not regress catastrophically** + +Run: `pnpm nx build website` and review the build output for the homepage route. Note the size; record it in the commit message for the closing commit so we have a baseline for Phase 6 deletion. (No specific size threshold — just a record.) + +- [ ] **Step 5: Closing commit** + +If no changes were needed in Step 4 (most likely), simply skip the commit; the verification step is a check, not a change. Otherwise: + +```bash +git add -A +git commit -m "chore(website): Phase 1 verification — tokens + primitives green" +``` + +--- + +## Summary + +After this plan executes: +- `@ngaf/design-tokens` exposes new `surfaces`, `shadows`, `radius`, `space` namespaces and an extended `colors` + `typography` (version bumped to 0.0.30). All existing tokens are untouched. +- The website has 9 new UI primitives under `src/components/ui/`, a `cn()` helper, and new Tailwind theme variables. None of them are wired into any production page yet. +- A `/_dev/primitives` route exists for inspection, with Playwright assertions guarding it. +- Total commits: ~22. No production-page behavior changes. + +Next phase (separate plan): Phase 2 — refactor `Nav`, `Footer`, and `AnnouncementToast` to use the new primitives and tokens. From 326b2deb70ace6030764a8b19a4e1ea07bc07175 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 12:33:20 -0700 Subject: [PATCH 03/29] feat(design-tokens): add surfaces/shadows/radius/space + type scale (v0.0.30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive-only changes for the website Statusbrew-aesthetic refactor (Phase 1): - new namespaces: surfaces, shadows, radius, space - colors: add accentHover, textInverted - typography: add h1/h2/h3/eyebrow/bodyLg/body/caption type scale - tokens.css: mirror everything as --ds-* custom properties - tokens aggregator + index.ts updated - tokens.spec.ts: vitest assertions for all additions No existing tokens modified. Version bumped 0.0.29 → 0.0.30 per repo patch-only policy. Co-Authored-By: Claude Opus 4.7 --- libs/design-tokens/package.json | 2 +- libs/design-tokens/src/index.ts | 4 + libs/design-tokens/src/lib/colors.ts | 4 + libs/design-tokens/src/lib/radius.ts | 16 +++ libs/design-tokens/src/lib/shadows.ts | 18 +++ libs/design-tokens/src/lib/space.ts | 17 +++ libs/design-tokens/src/lib/surfaces.ts | 23 ++++ libs/design-tokens/src/lib/tokens.css | 47 ++++++++ libs/design-tokens/src/lib/tokens.spec.ts | 135 +++++++++++++++++++++- libs/design-tokens/src/lib/tokens.ts | 8 ++ libs/design-tokens/src/lib/typography.ts | 47 +++++++- 11 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 libs/design-tokens/src/lib/radius.ts create mode 100644 libs/design-tokens/src/lib/shadows.ts create mode 100644 libs/design-tokens/src/lib/space.ts create mode 100644 libs/design-tokens/src/lib/surfaces.ts diff --git a/libs/design-tokens/package.json b/libs/design-tokens/package.json index fdfd1fb22..573db9afa 100644 --- a/libs/design-tokens/package.json +++ b/libs/design-tokens/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/design-tokens", - "version": "0.0.29", + "version": "0.0.30", "license": "MIT", "repository": { "type": "git", diff --git a/libs/design-tokens/src/index.ts b/libs/design-tokens/src/index.ts index 3829eb6cb..0666c9d61 100644 --- a/libs/design-tokens/src/index.ts +++ b/libs/design-tokens/src/index.ts @@ -3,4 +3,8 @@ export { glass, type Glass } from './lib/glass'; export { gradient, type Gradient } from './lib/gradients'; export { glow, type Glow } from './lib/glow'; export { typography, type Typography } from './lib/typography'; +export { surfaces, type Surfaces } from './lib/surfaces'; +export { shadows, type Shadows } from './lib/shadows'; +export { radius, type Radius } from './lib/radius'; +export { space, type Space } from './lib/space'; export { tokens, type Tokens } from './lib/tokens'; diff --git a/libs/design-tokens/src/lib/colors.ts b/libs/design-tokens/src/lib/colors.ts index 850b10478..74ecb1288 100644 --- a/libs/design-tokens/src/lib/colors.ts +++ b/libs/design-tokens/src/lib/colors.ts @@ -19,6 +19,10 @@ export const colors = Object.freeze({ accentBorderHover: 'rgba(0, 64, 144, 0.3)', /** Accent surface — very light tint for selected/active states */ accentSurface: 'rgba(0, 64, 144, 0.06)', + /** Hovered state of the primary accent */ + accentHover: '#003070', + /** Inverted text — for use on dark/accent backgrounds */ + textInverted: '#ffffff', /** Primary text — dark ink for headings and body */ textPrimary: '#1a1a2e', /** Secondary text — warm gray for descriptions */ diff --git a/libs/design-tokens/src/lib/radius.ts b/libs/design-tokens/src/lib/radius.ts new file mode 100644 index 000000000..b0d1fd843 --- /dev/null +++ b/libs/design-tokens/src/lib/radius.ts @@ -0,0 +1,16 @@ +/** + * Border radius scale. + * + * `sm` for compact controls (pills, small buttons), `md` for standard + * cards/buttons, `lg` for hero cards and frames, `xl` for prominent + * containers. `full` is for fully-rounded shapes (avatars, status dots). + */ +export const radius = Object.freeze({ + sm: '6px', + md: '10px', + lg: '14px', + xl: '20px', + full: '999px', +} as const); + +export type Radius = typeof radius; diff --git a/libs/design-tokens/src/lib/shadows.ts b/libs/design-tokens/src/lib/shadows.ts new file mode 100644 index 000000000..dd66d1a09 --- /dev/null +++ b/libs/design-tokens/src/lib/shadows.ts @@ -0,0 +1,18 @@ +/** + * Elevation shadows for the marketing surface. + * + * `sm`/`md`/`lg` form a three-step elevation scale. `focus` is the + * keyboard focus ring used on interactive primitives. + */ +export const shadows = Object.freeze({ + /** Subtle — default card */ + sm: '0 1px 2px rgba(15, 23, 41, 0.04), 0 1px 1px rgba(15, 23, 41, 0.03)', + /** Moderate — hovered card, dropdown */ + md: '0 4px 12px rgba(15, 23, 41, 0.06), 0 2px 4px rgba(15, 23, 41, 0.04)', + /** Strong — floating elements, hero collage */ + lg: '0 12px 32px rgba(15, 23, 41, 0.08), 0 4px 8px rgba(15, 23, 41, 0.05)', + /** Keyboard focus ring */ + focus: '0 0 0 3px rgba(0, 64, 144, 0.25)', +} as const); + +export type Shadows = typeof shadows; diff --git a/libs/design-tokens/src/lib/space.ts b/libs/design-tokens/src/lib/space.ts new file mode 100644 index 000000000..1bcecd5d0 --- /dev/null +++ b/libs/design-tokens/src/lib/space.ts @@ -0,0 +1,17 @@ +/** + * Section and container spacing scale. + * + * `sectionY` is the standard vertical breathing room around each + * marketing section (clamps between 64px mobile and 120px desktop). + * `sectionYTight` is for compact sections (proof strip, final CTA). + * `containerX` is the horizontal page padding. `containerMax` is the + * max content width before the page gutters take over. + */ +export const space = Object.freeze({ + sectionY: 'clamp(64px, 8vw, 120px)', + sectionYTight: 'clamp(48px, 6vw, 80px)', + containerX: 'clamp(20px, 4vw, 40px)', + containerMax: '1200px', +} as const); + +export type Space = typeof space; diff --git a/libs/design-tokens/src/lib/surfaces.ts b/libs/design-tokens/src/lib/surfaces.ts new file mode 100644 index 000000000..0c6f0df67 --- /dev/null +++ b/libs/design-tokens/src/lib/surfaces.ts @@ -0,0 +1,23 @@ +/** + * Surface tokens — backgrounds and borders for the marketing site. + * + * Replaces the older `bg`/`sidebarBg`/`glass` surfaces. Use these for any + * new component; legacy components may still consume `colors.bg` until + * the Phase 6 cleanup. + */ +export const surfaces = Object.freeze({ + /** Page background — near-white */ + canvas: '#fafbfc', + /** Pure white — cards, frames, nav */ + surface: '#ffffff', + /** Alternating section tint — slightly cool */ + surfaceTinted: '#f4f6fb', + /** Stronger tint — pricing-card highlight, callouts */ + surfaceDim: '#eef1f7', + /** Default 1px hairline */ + border: '#e6e8ee', + /** Emphasized border — focused inputs, hovered cards */ + borderStrong: '#d2d6e0', +} as const); + +export type Surfaces = typeof surfaces; diff --git a/libs/design-tokens/src/lib/tokens.css b/libs/design-tokens/src/lib/tokens.css index a0bf257a3..b1fc17fb8 100644 --- a/libs/design-tokens/src/lib/tokens.css +++ b/libs/design-tokens/src/lib/tokens.css @@ -46,4 +46,51 @@ --ds-font-serif: 'EB Garamond', Georgia, serif; --ds-font-sans: Inter, system-ui, sans-serif; --ds-font-mono: 'JetBrains Mono', monospace; + + /* Surfaces */ + --ds-canvas: #fafbfc; + --ds-surface: #ffffff; + --ds-surface-tinted: #f4f6fb; + --ds-surface-dim: #eef1f7; + --ds-border: #e6e8ee; + --ds-border-strong: #d2d6e0; + + /* Shadows */ + --ds-shadow-sm: 0 1px 2px rgba(15, 23, 41, 0.04), 0 1px 1px rgba(15, 23, 41, 0.03); + --ds-shadow-md: 0 4px 12px rgba(15, 23, 41, 0.06), 0 2px 4px rgba(15, 23, 41, 0.04); + --ds-shadow-lg: 0 12px 32px rgba(15, 23, 41, 0.08), 0 4px 8px rgba(15, 23, 41, 0.05); + --ds-shadow-focus: 0 0 0 3px rgba(0, 64, 144, 0.25); + + /* Radius */ + --ds-radius-sm: 6px; + --ds-radius-md: 10px; + --ds-radius-lg: 14px; + --ds-radius-xl: 20px; + --ds-radius-full: 999px; + + /* Space */ + --ds-section-y: clamp(64px, 8vw, 120px); + --ds-section-y-tight: clamp(48px, 6vw, 80px); + --ds-container-x: clamp(20px, 4vw, 40px); + --ds-container-max: 1200px; + + /* Colors — extensions */ + --ds-accent-hover: #003070; + --ds-text-inverted: #ffffff; + + /* Typography — type scale */ + --ds-h1-size: clamp(48px, 6vw, 72px); + --ds-h1-line: 1.08; + --ds-h2-size: clamp(36px, 4.5vw, 56px); + --ds-h2-line: 1.12; + --ds-h3-size: 28px; + --ds-h3-line: 1.25; + --ds-h3-weight: 600; + --ds-eyebrow-size: 12px; + --ds-eyebrow-line: 1.4; + --ds-eyebrow-weight: 700; + --ds-eyebrow-spacing: 0.12em; + --ds-body-lg-size: 20px; + --ds-body-size: 16px; + --ds-caption-size: 14px; } diff --git a/libs/design-tokens/src/lib/tokens.spec.ts b/libs/design-tokens/src/lib/tokens.spec.ts index 9f9255987..748038787 100644 --- a/libs/design-tokens/src/lib/tokens.spec.ts +++ b/libs/design-tokens/src/lib/tokens.spec.ts @@ -1,7 +1,18 @@ import { readFileSync } from 'fs'; import { resolve } from 'path'; import { describe, expect, it } from 'vitest'; -import { colors, glass, gradient, glow, typography, tokens } from '../index'; +import { + colors, + glass, + gradient, + glow, + typography, + tokens, + surfaces, + shadows, + radius, + space, +} from '../index'; describe('design-tokens', () => { it('exports all color tokens', () => { @@ -87,5 +98,127 @@ describe('design-tokens', () => { it('uses :root selector', () => { expect(css).toContain(':root'); }); + + it('defines surfaces tokens', () => { + expect(css).toContain('--ds-canvas:'); + expect(css).toContain('--ds-surface:'); + expect(css).toContain('--ds-surface-tinted:'); + expect(css).toContain('--ds-border:'); + expect(css).toContain('--ds-border-strong:'); + }); + + it('defines shadow tokens', () => { + expect(css).toContain('--ds-shadow-sm:'); + expect(css).toContain('--ds-shadow-md:'); + expect(css).toContain('--ds-shadow-lg:'); + expect(css).toContain('--ds-shadow-focus:'); + }); + + it('defines radius tokens', () => { + expect(css).toContain('--ds-radius-sm:'); + expect(css).toContain('--ds-radius-md:'); + expect(css).toContain('--ds-radius-lg:'); + expect(css).toContain('--ds-radius-full:'); + }); + + it('defines space tokens', () => { + expect(css).toContain('--ds-section-y:'); + expect(css).toContain('--ds-container-x:'); + expect(css).toContain('--ds-container-max:'); + }); + + it('defines extended color tokens', () => { + expect(css).toContain('--ds-accent-hover:'); + expect(css).toContain('--ds-text-inverted:'); + }); + + it('defines type-scale tokens', () => { + expect(css).toContain('--ds-h1-size:'); + expect(css).toContain('--ds-h2-size:'); + expect(css).toContain('--ds-h3-size:'); + expect(css).toContain('--ds-eyebrow-size:'); + }); + }); + + describe('surfaces tokens', () => { + it('exposes canvas, surface, surfaceTinted, surfaceDim, border, borderStrong', () => { + expect(surfaces.canvas).toBe('#fafbfc'); + expect(surfaces.surface).toBe('#ffffff'); + expect(surfaces.surfaceTinted).toBe('#f4f6fb'); + expect(surfaces.surfaceDim).toBe('#eef1f7'); + expect(surfaces.border).toBe('#e6e8ee'); + expect(surfaces.borderStrong).toBe('#d2d6e0'); + }); + }); + + describe('shadows tokens', () => { + it('exposes sm, md, lg, focus', () => { + expect(shadows.sm).toBe('0 1px 2px rgba(15, 23, 41, 0.04), 0 1px 1px rgba(15, 23, 41, 0.03)'); + expect(shadows.md).toBe('0 4px 12px rgba(15, 23, 41, 0.06), 0 2px 4px rgba(15, 23, 41, 0.04)'); + expect(shadows.lg).toBe('0 12px 32px rgba(15, 23, 41, 0.08), 0 4px 8px rgba(15, 23, 41, 0.05)'); + expect(shadows.focus).toBe('0 0 0 3px rgba(0, 64, 144, 0.25)'); + }); + }); + + describe('radius tokens', () => { + it('exposes sm, md, lg, xl, full', () => { + expect(radius.sm).toBe('6px'); + expect(radius.md).toBe('10px'); + expect(radius.lg).toBe('14px'); + expect(radius.xl).toBe('20px'); + expect(radius.full).toBe('999px'); + }); + }); + + describe('space tokens', () => { + it('exposes section + container scale', () => { + expect(space.sectionY).toBe('clamp(64px, 8vw, 120px)'); + expect(space.sectionYTight).toBe('clamp(48px, 6vw, 80px)'); + expect(space.containerX).toBe('clamp(20px, 4vw, 40px)'); + expect(space.containerMax).toBe('1200px'); + }); + }); + + describe('colors tokens — extensions', () => { + it('exposes accentHover and textInverted', () => { + expect(colors.accentHover).toBe('#003070'); + expect(colors.textInverted).toBe('#ffffff'); + }); + + it('keeps existing tokens unchanged (no breaking change)', () => { + expect(colors.accent).toBe('#004090'); + expect(colors.angularRed).toBe('#DD0031'); + expect(colors.textPrimary).toBe('#1a1a2e'); + }); + }); + + describe('typography tokens — type scale', () => { + it('exposes h1/h2/h3/eyebrow/bodyLg/body/caption scale', () => { + expect(typography.h1.size).toBe('clamp(48px, 6vw, 72px)'); + expect(typography.h1.family).toBe('var(--font-garamond)'); + expect(typography.h2.size).toBe('clamp(36px, 4.5vw, 56px)'); + expect(typography.h3.size).toBe('28px'); + expect(typography.h3.weight).toBe(600); + expect(typography.eyebrow.size).toBe('12px'); + expect(typography.eyebrow.transform).toBe('uppercase'); + expect(typography.bodyLg.size).toBe('20px'); + expect(typography.body.size).toBe('16px'); + expect(typography.caption.size).toBe('14px'); + }); + + it('keeps existing font-family tokens unchanged', () => { + expect(typography.fontSerif).toBe('"EB Garamond", Georgia, serif'); + expect(typography.fontSans).toBe('Inter, system-ui, sans-serif'); + expect(typography.fontMono).toBe('"JetBrains Mono", monospace'); + }); + }); + + describe('combined tokens — new namespaces', () => { + it('aggregator includes all new namespaces', () => { + expect(tokens.surfaces).toBe(surfaces); + expect(tokens.shadows).toBe(shadows); + expect(tokens.radius).toBe(radius); + expect(tokens.space).toBe(space); + }); }); }); diff --git a/libs/design-tokens/src/lib/tokens.ts b/libs/design-tokens/src/lib/tokens.ts index 6c847c451..d1a670cc3 100644 --- a/libs/design-tokens/src/lib/tokens.ts +++ b/libs/design-tokens/src/lib/tokens.ts @@ -3,6 +3,10 @@ import { glass } from './glass'; import { gradient } from './gradients'; import { glow } from './glow'; import { typography } from './typography'; +import { surfaces } from './surfaces'; +import { shadows } from './shadows'; +import { radius } from './radius'; +import { space } from './space'; /** * Combined design tokens object. @@ -15,6 +19,10 @@ export const tokens = { gradient, glow, typography, + surfaces, + shadows, + radius, + space, } as const; export type Tokens = typeof tokens; diff --git a/libs/design-tokens/src/lib/typography.ts b/libs/design-tokens/src/lib/typography.ts index 11e9f2b86..b550db8f7 100644 --- a/libs/design-tokens/src/lib/typography.ts +++ b/libs/design-tokens/src/lib/typography.ts @@ -1,9 +1,14 @@ /** - * Typography tokens — font families used across the design system. + * Typography tokens — font families and type scale used across the design system. * * - Serif (EB Garamond): Headlines, elegant emphasis * - Sans (Inter): Body text, UI elements * - Mono (JetBrains Mono): Code, labels, metadata + * + * The h1/h2/h3/eyebrow/bodyLg/body/caption objects are the type scale + * used by the marketing-site UI primitives. Each entry includes + * `size`, `line`, `family`, and (where relevant) `weight`, + * `letterSpacing`, `transform`. */ export const typography = { /** Serif font for headings */ @@ -12,6 +17,46 @@ export const typography = { fontSans: 'Inter, system-ui, sans-serif', /** Monospace font for code and labels */ fontMono: '"JetBrains Mono", monospace', + + h1: { + size: 'clamp(48px, 6vw, 72px)', + line: 1.08, + family: 'var(--font-garamond)', + }, + h2: { + size: 'clamp(36px, 4.5vw, 56px)', + line: 1.12, + family: 'var(--font-garamond)', + }, + h3: { + size: '28px', + line: 1.25, + family: 'var(--font-inter)', + weight: 600, + }, + eyebrow: { + size: '12px', + line: 1.4, + family: 'var(--font-mono)', + weight: 700, + letterSpacing: '0.12em', + transform: 'uppercase' as const, + }, + bodyLg: { + size: '20px', + line: 1.6, + family: 'var(--font-inter)', + }, + body: { + size: '16px', + line: 1.6, + family: 'var(--font-inter)', + }, + caption: { + size: '14px', + line: 1.5, + family: 'var(--font-inter)', + }, } as const; export type Typography = typeof typography; From b9027e4a4c279a71f613e27bba713895e79108d7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 12:36:08 -0700 Subject: [PATCH 04/29] feat(website): wire new tokens, add cn() helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/website/lib/design-tokens.ts: re-export from @ngaf/design-tokens so the website has one source of truth (was a stale partial duplicate) - src/app/global.css: extend Tailwind @theme with new surface/border colors, radii, and shadow variables (additive — no existing variables removed) - src/lib/cn.ts: clsx + tailwind-merge composition helper for the upcoming UI primitives No visible site changes — primitives that consume these tokens land in the next bundle. Co-Authored-By: Claude Opus 4.7 --- apps/website/lib/design-tokens.ts | 57 +++++++++++-------------------- apps/website/src/app/global.css | 23 +++++++++++++ apps/website/src/lib/cn.ts | 12 +++++++ 3 files changed, 54 insertions(+), 38 deletions(-) create mode 100644 apps/website/src/lib/cn.ts diff --git a/apps/website/lib/design-tokens.ts b/apps/website/lib/design-tokens.ts index 695e26e40..d1a49c6b3 100644 --- a/apps/website/lib/design-tokens.ts +++ b/apps/website/lib/design-tokens.ts @@ -1,39 +1,20 @@ -/** Single source of truth for all brand design tokens. - * CSS custom properties in globals.css must match these values exactly. +/** + * Website-local re-export of the shared design tokens. + * + * Kept as a barrel so existing imports of the form + * `import { tokens } from '../../lib/design-tokens'` keep working. + * New code should import directly from `@ngaf/design-tokens`. */ -export const tokens = { - colors: { - bg: '#f8f9fc', - accent: '#004090', - accentLight: '#64C3FD', - accentGlow: 'rgba(0, 64, 144, 0.2)', - accentBorder: 'rgba(0, 64, 144, 0.15)', - accentBorderHover: 'rgba(0, 64, 144, 0.3)', - accentSurface: 'rgba(0, 64, 144, 0.06)', - textPrimary: '#1a1a2e', - textSecondary: '#555770', - textMuted: '#8b8fa3', - sidebarBg: 'rgba(255, 255, 255, 0.45)', - angularRed: '#DD0031', - }, - glass: { - bg: 'rgba(255, 255, 255, 0.45)', - bgHover: 'rgba(255, 255, 255, 0.55)', - blur: '16px', - border: 'rgba(255, 255, 255, 0.6)', - shadow: '0 4px 24px rgba(0, 0, 0, 0.06)', - }, - gradient: { - warm: 'radial-gradient(circle, rgba(221, 0, 49, 0.18), transparent 70%)', - cool: 'radial-gradient(circle, rgba(0, 64, 144, 0.18), transparent 70%)', - coolLight: 'radial-gradient(circle, rgba(100, 195, 253, 0.15), transparent 70%)', - bgFlow: 'linear-gradient(135deg, #fef0f3 0%, #f4f0ff 45%, #eaf3ff 70%, #e6f4ff 100%)', - }, - glow: { - hero: '0 0 60px rgba(0, 64, 144, 0.15)', - demo: '0 0 30px rgba(0, 64, 144, 0.1)', - card: '0 0 24px rgba(0, 64, 144, 0.1)', - border: '0 0 12px rgba(0, 64, 144, 0.08)', - button: '0 0 16px rgba(0, 64, 144, 0.15)', - }, -} as const; +export { + tokens, + type Tokens, + colors, + glass, + gradient, + glow, + typography, + surfaces, + shadows, + radius, + space, +} from '@ngaf/design-tokens'; diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index ad8b77ecd..c6d72ddf6 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -17,6 +17,29 @@ --font-garamond: "EB Garamond", Georgia, serif; --font-inter: Inter, system-ui, sans-serif; --font-mono: "JetBrains Mono", monospace; + + /* New surfaces — Phase 1 of Statusbrew refactor */ + --color-canvas: #fafbfc; + --color-surface: #ffffff; + --color-surface-tinted: #f4f6fb; + --color-surface-dim: #eef1f7; + --color-border: #e6e8ee; + --color-border-strong: #d2d6e0; + --color-text-inverted: #ffffff; + --color-accent-hover: #003070; + + /* Radii */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --radius-full: 999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(15, 23, 41, 0.04), 0 1px 1px rgba(15, 23, 41, 0.03); + --shadow-md: 0 4px 12px rgba(15, 23, 41, 0.06), 0 2px 4px rgba(15, 23, 41, 0.04); + --shadow-lg: 0 12px 32px rgba(15, 23, 41, 0.08), 0 4px 8px rgba(15, 23, 41, 0.05); + --shadow-focus: 0 0 0 3px rgba(0, 64, 144, 0.25); } :root { diff --git a/apps/website/src/lib/cn.ts b/apps/website/src/lib/cn.ts new file mode 100644 index 000000000..b281f17e3 --- /dev/null +++ b/apps/website/src/lib/cn.ts @@ -0,0 +1,12 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** + * Merge class names with intelligent Tailwind conflict resolution. + * + * Usage: + * cn('px-4 py-2', isActive && 'bg-accent', className) + */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} From 2e910b1e9f086549892625b186ab1bb1ccce0b7a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 12:37:57 -0700 Subject: [PATCH 05/29] feat(website): add 9 UI primitives for the new design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Container — max-width wrapper with responsive padding - Section — vertical rhythm + surface variants (canvas/tinted/white) - Eyebrow — mono uppercase label with brand-tone variants - Pill — small rounded tag with neutral/accent/angular variants - Button — primary/secondary/ghost × md/lg; renders when href set - Card — white surface with hoverable lift via CSS - BrowserFrame — Mac-style chrome for screenshots and live demos - LogoMark — 🛩️ + wordmark - FAQ — native
accordion, kbd-accessible by default All server components except FAQ (which works without 'use client' because
is a native element). Styled via inline style props against @ngaf/design-tokens, matching the existing codebase pattern. global.css gets Card hover styles + FAQ chevron rotation + summary focus ring. No production page uses these yet — wiring lands in Bundle 6's /_dev/primitives showcase. Co-Authored-By: Claude Opus 4.7 --- apps/website/src/app/global.css | 28 ++++ .../src/components/ui/BrowserFrame.tsx | 101 ++++++++++++ apps/website/src/components/ui/Button.tsx | 154 ++++++++++++++++++ apps/website/src/components/ui/Card.tsx | 57 +++++++ apps/website/src/components/ui/Container.tsx | 37 +++++ apps/website/src/components/ui/Eyebrow.tsx | 47 ++++++ apps/website/src/components/ui/FAQ.tsx | 85 ++++++++++ apps/website/src/components/ui/LogoMark.tsx | 47 ++++++ apps/website/src/components/ui/Pill.tsx | 69 ++++++++ apps/website/src/components/ui/Section.tsx | 53 ++++++ 10 files changed, 678 insertions(+) create mode 100644 apps/website/src/components/ui/BrowserFrame.tsx create mode 100644 apps/website/src/components/ui/Button.tsx create mode 100644 apps/website/src/components/ui/Card.tsx create mode 100644 apps/website/src/components/ui/Container.tsx create mode 100644 apps/website/src/components/ui/Eyebrow.tsx create mode 100644 apps/website/src/components/ui/FAQ.tsx create mode 100644 apps/website/src/components/ui/LogoMark.tsx create mode 100644 apps/website/src/components/ui/Pill.tsx create mode 100644 apps/website/src/components/ui/Section.tsx diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index c6d72ddf6..7fa9a6ba4 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -173,3 +173,31 @@ html { .docs-prose th { text-align: left; padding: 0.5rem 0.75rem; font-family: var(--font-mono); font-size: 0.75rem; text-transform: uppercase; color: #8b8fa3; border-bottom: 1px solid rgba(0, 64, 144, 0.15); } .docs-prose td { padding: 0.5rem 0.75rem; border-bottom: 1px solid rgba(0, 64, 144, 0.08); color: #555770; } .docs-prose td code { font-size: 0.8em; } + +/* UI primitive — Card hover */ +[data-ui="card"][data-hoverable] { + cursor: default; +} +[data-ui="card"][data-hoverable]:hover { + box-shadow: var(--shadow-md); + border-color: var(--color-border-strong); + transform: translateY(-1px); +} +@media (prefers-reduced-motion: reduce) { + [data-ui="card"][data-hoverable]:hover { + transform: none; + } +} + +/* UI primitive — FAQ */ +[data-ui="faq-item"] > summary::-webkit-details-marker { + display: none; +} +[data-ui="faq-item"][open] [data-ui="faq-chevron"] { + transform: rotate(180deg); +} +[data-ui="faq-item"] > summary:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); + border-radius: var(--radius-sm); +} diff --git a/apps/website/src/components/ui/BrowserFrame.tsx b/apps/website/src/components/ui/BrowserFrame.tsx new file mode 100644 index 000000000..6e67a6e00 --- /dev/null +++ b/apps/website/src/components/ui/BrowserFrame.tsx @@ -0,0 +1,101 @@ +import type { ReactNode, HTMLAttributes } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +type Elevation = 'sm' | 'md' | 'lg'; + +interface BrowserFrameProps extends HTMLAttributes { + children: ReactNode; + /** Faux URL shown in the address bar. */ + url?: string; + /** Degrees of rotation for collage stacking. */ + rotate?: number; + /** Elevation tier — defaults to `md`. */ + elevation?: Elevation; + /** Optional max-width override. */ + maxWidth?: number | string; +} + +const ELEVATION: Record = { + sm: tokens.shadows.sm, + md: tokens.shadows.md, + lg: tokens.shadows.lg, +}; + +export function BrowserFrame({ + children, + url, + rotate = 0, + elevation = 'md', + maxWidth, + className, + style, + ...rest +}: BrowserFrameProps) { + return ( +
+ {/* Title bar */} +
+ {/* Traffic lights */} + + {/* URL pill */} + {url ? ( +
+ {url} +
+ ) : null} + {/* Right spacer to balance traffic lights */} + + + {/* Frame body */} +
+ {children} +
+
+ ); +} diff --git a/apps/website/src/components/ui/Button.tsx b/apps/website/src/components/ui/Button.tsx new file mode 100644 index 000000000..657449875 --- /dev/null +++ b/apps/website/src/components/ui/Button.tsx @@ -0,0 +1,154 @@ +import type { + ReactNode, + AnchorHTMLAttributes, + ButtonHTMLAttributes, + CSSProperties, +} from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +type Variant = 'primary' | 'secondary' | 'ghost'; +type Size = 'md' | 'lg'; + +interface CommonProps { + children: ReactNode; + variant?: Variant; + size?: Size; + /** Optional right-side icon — typically an arrow for ghost links. */ + trailingIcon?: ReactNode; +} + +type AnchorButtonProps = CommonProps & + Omit, 'href' | 'children'> & { + href: string; + }; + +type NativeButtonProps = CommonProps & + Omit, 'children'> & { + href?: undefined; + }; + +export type ButtonProps = AnchorButtonProps | NativeButtonProps; + +interface VariantStyle { + bg: string; + color: string; + border: string; +} + +const VARIANT_STYLES: Record = { + primary: { + bg: tokens.colors.accent, + color: tokens.colors.textInverted, + border: tokens.colors.accent, + }, + secondary: { + bg: tokens.surfaces.surface, + color: tokens.colors.textPrimary, + border: tokens.surfaces.borderStrong, + }, + ghost: { + bg: 'transparent', + color: tokens.colors.accent, + border: 'transparent', + }, +}; + +const SIZE_STYLES: Record = { + md: { padding: '0 16px', fontSize: 14, height: 40 }, + lg: { padding: '0 22px', fontSize: 16, height: 48 }, +}; + +export function Button(props: ButtonProps) { + const { + children, + variant = 'primary', + size = 'md', + trailingIcon, + className, + style, + } = props; + const v = VARIANT_STYLES[variant]; + const s = SIZE_STYLES[size]; + + const combinedStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + height: s.height, + padding: s.padding, + fontFamily: tokens.typography.fontSans, + fontSize: s.fontSize, + fontWeight: 600, + lineHeight: 1, + background: v.bg, + color: v.color, + border: `1px solid ${v.border}`, + borderRadius: tokens.radius.md, + boxShadow: variant === 'primary' ? tokens.shadows.sm : 'none', + cursor: 'pointer', + textDecoration: 'none', + transition: + 'background-color 120ms ease, color 120ms ease, border-color 120ms ease, box-shadow 120ms ease', + whiteSpace: 'nowrap', + ...style, + }; + + const content = ( + <> + {children} + {trailingIcon ? : null} + + ); + + if (typeof props.href === 'string') { + const { href, ...rest } = props as AnchorButtonProps; + const { + children: _c, + variant: _v, + size: _s, + trailingIcon: _t, + className: _cn, + style: _st, + ...anchorAttrs + } = rest as AnchorButtonProps; + return ( +
+ {content} + + ); + } + + const { + children: _c2, + variant: _v2, + size: _s2, + trailingIcon: _t2, + className: _cn2, + style: _st2, + href: _h, + ...buttonAttrs + } = props as NativeButtonProps; + return ( + + ); +} diff --git a/apps/website/src/components/ui/Card.tsx b/apps/website/src/components/ui/Card.tsx new file mode 100644 index 000000000..82886ef71 --- /dev/null +++ b/apps/website/src/components/ui/Card.tsx @@ -0,0 +1,57 @@ +import type { ReactNode, HTMLAttributes } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +type Surface = 'white' | 'tinted' | 'dim'; +type Padding = 'md' | 'lg'; + +interface CardProps extends HTMLAttributes { + children: ReactNode; + /** If true, applies a subtle hover lift via CSS. */ + hoverable?: boolean; + /** Internal padding tier. */ + padding?: Padding; + /** Override the surface color. */ + surface?: Surface; +} + +const SURFACE: Record = { + white: tokens.surfaces.surface, + tinted: tokens.surfaces.surfaceTinted, + dim: tokens.surfaces.surfaceDim, +}; + +const PADDING: Record = { + md: '20px', + lg: '28px', +}; + +export function Card({ + children, + hoverable = false, + padding = 'md', + surface = 'white', + className, + style, + ...rest +}: CardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/website/src/components/ui/Container.tsx b/apps/website/src/components/ui/Container.tsx new file mode 100644 index 000000000..8cc4c7996 --- /dev/null +++ b/apps/website/src/components/ui/Container.tsx @@ -0,0 +1,37 @@ +import type { ReactNode, HTMLAttributes } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +interface ContainerProps extends HTMLAttributes { + children: ReactNode; + /** Wider variant for full-width hero collages */ + size?: 'default' | 'wide'; +} + +export function Container({ + children, + size = 'default', + className, + style, + ...rest +}: ContainerProps) { + const maxWidth = size === 'wide' ? '1320px' : tokens.space.containerMax; + return ( +
+ {children} +
+ ); +} diff --git a/apps/website/src/components/ui/Eyebrow.tsx b/apps/website/src/components/ui/Eyebrow.tsx new file mode 100644 index 000000000..40a06172a --- /dev/null +++ b/apps/website/src/components/ui/Eyebrow.tsx @@ -0,0 +1,47 @@ +import type { ReactNode, HTMLAttributes } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +type Tone = 'muted' | 'accent' | 'angular'; + +interface EyebrowProps extends HTMLAttributes { + children: ReactNode; + /** Optional color override. Defaults to muted neutral. */ + tone?: Tone; +} + +const TONE_COLOR: Record = { + muted: tokens.colors.textMuted, + accent: tokens.colors.accent, + angular: tokens.colors.angularRed, +}; + +export function Eyebrow({ + children, + tone = 'muted', + className, + style, + ...rest +}: EyebrowProps) { + return ( +

+ {children} +

+ ); +} diff --git a/apps/website/src/components/ui/FAQ.tsx b/apps/website/src/components/ui/FAQ.tsx new file mode 100644 index 000000000..3242bc2a6 --- /dev/null +++ b/apps/website/src/components/ui/FAQ.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +export interface FAQItem { + q: string; + a: ReactNode; +} + +interface FAQProps { + items: FAQItem[]; + className?: string; +} + +/** + * Native-details FAQ accordion. Keyboard accessible out of the box. + * Each item can be opened independently; no shared exclusivity. + */ +export function FAQ({ items, className }: FAQProps) { + return ( +
+ {items.map((item, i) => ( +
+ + {item.q} + + +
+ {item.a} +
+
+ ))} +
+ ); +} diff --git a/apps/website/src/components/ui/LogoMark.tsx b/apps/website/src/components/ui/LogoMark.tsx new file mode 100644 index 000000000..02c46d7e8 --- /dev/null +++ b/apps/website/src/components/ui/LogoMark.tsx @@ -0,0 +1,47 @@ +import type { HTMLAttributes } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +type LogoSize = 'sm' | 'md'; + +interface LogoMarkProps extends HTMLAttributes { + size?: LogoSize; + /** Hide the wordmark, show only the icon. */ + iconOnly?: boolean; +} + +const SIZE: Record = { + sm: { icon: 18, label: 14 }, + md: { icon: 22, label: 16 }, +}; + +export function LogoMark({ + size = 'md', + iconOnly = false, + className, + style, + ...rest +}: LogoMarkProps) { + const s = SIZE[size]; + return ( + + + {iconOnly ? null : Angular Agent Framework} + + ); +} diff --git a/apps/website/src/components/ui/Pill.tsx b/apps/website/src/components/ui/Pill.tsx new file mode 100644 index 000000000..fee0cc3fe --- /dev/null +++ b/apps/website/src/components/ui/Pill.tsx @@ -0,0 +1,69 @@ +import type { ReactNode, HTMLAttributes } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +type PillVariant = 'neutral' | 'accent' | 'angular'; + +interface PillProps extends HTMLAttributes { + children: ReactNode; + variant?: PillVariant; +} + +interface VariantStyle { + bg: string; + border: string; + color: string; +} + +const VARIANT_STYLES: Record = { + neutral: { + bg: tokens.surfaces.surface, + border: tokens.surfaces.border, + color: tokens.colors.textSecondary, + }, + accent: { + bg: tokens.colors.accentSurface, + border: tokens.colors.accentBorder, + color: tokens.colors.accent, + }, + angular: { + bg: 'rgba(221, 0, 49, 0.06)', + border: 'rgba(221, 0, 49, 0.18)', + color: tokens.colors.angularRed, + }, +}; + +export function Pill({ + children, + variant = 'neutral', + className, + style, + ...rest +}: PillProps) { + const v = VARIANT_STYLES[variant]; + return ( + + {children} + + ); +} diff --git a/apps/website/src/components/ui/Section.tsx b/apps/website/src/components/ui/Section.tsx new file mode 100644 index 000000000..976565573 --- /dev/null +++ b/apps/website/src/components/ui/Section.tsx @@ -0,0 +1,53 @@ +import type { ReactNode, HTMLAttributes } from 'react'; +import { tokens } from '@ngaf/design-tokens'; +import { cn } from '../../lib/cn'; + +type Surface = 'canvas' | 'tinted' | 'white'; + +interface SectionProps extends Omit, 'id'> { + children: ReactNode; + /** Background surface for this section. Defaults to canvas (page bg). */ + surface?: Surface; + /** Use the tighter vertical rhythm (proof strip, final CTA). */ + tight?: boolean; + /** HTML element ID — useful for in-page anchors. */ + id?: string; + /** Optional aria-labelledby pointing at a heading inside the section. */ + ariaLabelledBy?: string; +} + +const SURFACE_BG: Record = { + canvas: tokens.surfaces.canvas, + tinted: tokens.surfaces.surfaceTinted, + white: tokens.surfaces.surface, +}; + +export function Section({ + children, + surface = 'canvas', + tight = false, + id, + ariaLabelledBy, + className, + style, + ...rest +}: SectionProps) { + return ( +
+ {children} +
+ ); +} From 42d9195d92b689885da87b84159abd6acc7b15d9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 12:40:57 -0700 Subject: [PATCH 06/29] feat(website): add /dev/primitives showcase + Playwright coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dev-only route that renders one of each UI primitive (LogoMark, Eyebrow, Pill, Button, Card, BrowserFrame, Section, FAQ) so the new design-system primitives are both eyeball-verifiable and asserted in CI. The route will be deleted in a later phase once the marketing pages have migrated. Note: route is /dev/primitives (not _dev/primitives) because Next.js App Router treats leading-underscore folders as private and unrouted. Playwright spec covers all 9 primitives plus FAQ open/close + keyboard focus. Verification (this commit): all 23 website e2e tests pass — 10 new primitives tests + 13 existing tests unchanged. Phase 1 complete with zero regressions to visible pages. Co-Authored-By: Claude Opus 4.7 --- apps/website/e2e/primitives.spec.ts | 68 +++++++++++ apps/website/src/app/dev/primitives/page.tsx | 122 +++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 apps/website/e2e/primitives.spec.ts create mode 100644 apps/website/src/app/dev/primitives/page.tsx diff --git a/apps/website/e2e/primitives.spec.ts b/apps/website/e2e/primitives.spec.ts new file mode 100644 index 000000000..5e7c39df4 --- /dev/null +++ b/apps/website/e2e/primitives.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; + +test.describe('UI primitives showcase', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/dev/primitives'); + }); + + test('renders the page heading', async ({ page }) => { + await expect(page.locator('[data-testid="primitives-page-title"]')).toBeVisible(); + }); + + test('renders LogoMark', async ({ page }) => { + const logos = page.locator('[data-ui="logo-mark"]'); + await expect(logos).toHaveCount(3); + }); + + test('renders Eyebrow in all tones', async ({ page }) => { + await expect(page.locator('[data-ui="eyebrow"][data-tone="muted"]').first()).toBeVisible(); + await expect(page.locator('[data-ui="eyebrow"][data-tone="accent"]')).toBeVisible(); + await expect(page.locator('[data-ui="eyebrow"][data-tone="angular"]')).toBeVisible(); + }); + + test('renders Pill in all variants', async ({ page }) => { + await expect(page.locator('[data-ui="pill"][data-variant="neutral"]')).toBeVisible(); + await expect(page.locator('[data-ui="pill"][data-variant="accent"]')).toBeVisible(); + await expect(page.locator('[data-ui="pill"][data-variant="angular"]')).toBeVisible(); + }); + + test('renders Button variants', async ({ page }) => { + await expect(page.locator('[data-ui="button"][data-variant="primary"]').first()).toBeVisible(); + await expect(page.locator('[data-ui="button"][data-variant="secondary"]')).toBeVisible(); + await expect(page.locator('[data-ui="button"][data-variant="ghost"]')).toBeVisible(); + // Large primary renders as an with href + const linkButton = page.locator('a[data-ui="button"][data-size="lg"]'); + await expect(linkButton).toHaveAttribute('href', '/docs'); + }); + + test('renders Card variants including hoverable', async ({ page }) => { + const cards = page.locator('[data-ui="card"]'); + await expect(cards).toHaveCount(3); + await expect(page.locator('[data-ui="card"][data-hoverable]')).toHaveCount(1); + }); + + test('renders BrowserFrame with URL pill', async ({ page }) => { + const frame = page.locator('[data-ui="browser-frame"]'); + await expect(frame).toBeVisible(); + await expect(frame).toContainText('cockpit.cacheplane.ai'); + }); + + test('renders Section with tinted surface variant', async ({ page }) => { + await expect(page.locator('[data-ui="section"][data-surface="tinted"]')).toBeVisible(); + }); + + test('FAQ items toggle open and closed', async ({ page }) => { + const firstItem = page.locator('[data-ui="faq-item"]').first(); + await expect(firstItem).not.toHaveAttribute('open', ''); + await firstItem.locator('summary').click(); + await expect(firstItem).toHaveAttribute('open', ''); + await firstItem.locator('summary').click(); + await expect(firstItem).not.toHaveAttribute('open', ''); + }); + + test('FAQ summary is keyboard-focusable', async ({ page }) => { + const summary = page.locator('[data-ui="faq-item"]').first().locator('summary'); + await summary.focus(); + await expect(summary).toBeFocused(); + }); +}); diff --git a/apps/website/src/app/dev/primitives/page.tsx b/apps/website/src/app/dev/primitives/page.tsx new file mode 100644 index 000000000..0bddebcfc --- /dev/null +++ b/apps/website/src/app/dev/primitives/page.tsx @@ -0,0 +1,122 @@ +import { Container } from '../../../components/ui/Container'; +import { Section } from '../../../components/ui/Section'; +import { Eyebrow } from '../../../components/ui/Eyebrow'; +import { Pill } from '../../../components/ui/Pill'; +import { Button } from '../../../components/ui/Button'; +import { Card } from '../../../components/ui/Card'; +import { BrowserFrame } from '../../../components/ui/BrowserFrame'; +import { LogoMark } from '../../../components/ui/LogoMark'; +import { FAQ, type FAQItem } from '../../../components/ui/FAQ'; + +export const metadata = { title: 'UI primitives — dev only' }; + +const FAQ_ITEMS: FAQItem[] = [ + { + q: 'Is this real?', + a: 'Yes — this page is for verifying the primitives during refactor Phase 1.', + }, + { + q: 'Will it stay forever?', + a: 'No, this route gets deleted once the marketing pages have migrated.', + }, +]; + +export default function PrimitivesDevPage() { + return ( + <> +
+ +

+ UI primitives +

+ +

LogoMark

+
+ + + +
+ +

Eyebrow

+
+ Default muted + Accent + Angular +
+ +

Pill

+
+ MIT + LangGraph + Angular 20+ +
+ +

Button

+
+ + + + +
+ +

Card

+
+ + Standard +

A basic card with default padding.

+
+ + Hoverable +

Hover me — gentle lift + stronger shadow.

+
+ + Tinted, lg padding +

Used for emphasized callouts.

+
+
+ +

BrowserFrame

+
+ +
+ Placeholder content +
+
+
+
+
+ +
+ +

Section surface = tinted

+

This section uses the tinted surface variant.

+
+
+ +
+ +

FAQ

+ +
+
+ + ); +} From 8528ace28f86e4ca6c9ffb6b75a6df1b0e4ac983 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 12 May 2026 12:43:01 -0700 Subject: [PATCH 07/29] chore(website): drop unused card-primitive className Reviewer flagged: Card.tsx applied a 'card-primitive' className but no CSS selector references it (hover styles target the data-ui attribute instead). Trivial dead code cleanup. Co-Authored-By: Claude Opus 4.7 --- apps/website/src/components/ui/Card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/website/src/components/ui/Card.tsx b/apps/website/src/components/ui/Card.tsx index 82886ef71..385008f45 100644 --- a/apps/website/src/components/ui/Card.tsx +++ b/apps/website/src/components/ui/Card.tsx @@ -39,7 +39,7 @@ export function Card({
Date: Tue, 12 May 2026 13:22:54 -0700 Subject: [PATCH 08/29] =?UTF-8?q?docs(plans):=20Phase=202=20=E2=80=94=20Na?= =?UTF-8?q?v=20+=20Footer=20+=20AnnouncementToast=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan to refactor the three site-wide shared components to use the Phase 1 primitives and tokens. Drops glassmorphism site-wide, removes framer-motion from the shell layer, swaps in LogoMark/Button/Eyebrow primitives. Preserves all analytics tracking and the deep mobile-docs nav. 4 tasks, ~3 commits. Co-Authored-By: Claude Opus 4.7 --- ...bsite-refactor-phase-2-nav-footer-toast.md | 555 ++++++++++++++++++ 1 file changed, 555 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-12-website-refactor-phase-2-nav-footer-toast.md diff --git a/docs/superpowers/plans/2026-05-12-website-refactor-phase-2-nav-footer-toast.md b/docs/superpowers/plans/2026-05-12-website-refactor-phase-2-nav-footer-toast.md new file mode 100644 index 000000000..d0452d40b --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-website-refactor-phase-2-nav-footer-toast.md @@ -0,0 +1,555 @@ +# Website refactor — Phase 2: Nav + Footer + AnnouncementToast Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Refactor the three site-wide shared components (`Nav`, `Footer`, `AnnouncementToast`) to the new Statusbrew-inspired aesthetic — white surfaces, clean borders, real shadows — using the Phase 1 primitives and tokens. Remove glassmorphism and `framer-motion` from this layer. + +**Architecture:** +- Drop `backdrop-filter` glass treatment site-wide on the shell. Switch to opaque/white backgrounds, hairline borders, and the new `shadow.*` tokens. +- Replace `framer-motion` imports in Footer + AnnouncementToast with native CSS transitions / `IntersectionObserver`. (framer-motion stays in landing-page components for now — Phase 4 deals with those.) +- Reuse Phase 1 primitives: `Container`, `LogoMark`, `Button`, `Eyebrow`, `Pill`. +- The Nav has a deep mobile menu with docs library/section navigation — preserve all that functionality exactly. Only restyle. +- Sticky nav gains a `border-bottom` on scroll instead of glass blur. + +**Tech Stack:** TypeScript, Next.js 16, React 19, `@ngaf/design-tokens` (Phase 1 additions), `apps/website/src/components/ui/*` primitives. + +**Out of scope:** +- Any change to landing-page components (HeroTwoCol, PositioningStrip, etc.) +- Docs page layout +- Removing the old `glass` / `gradient` / `glow` tokens (Phase 6) + +--- + +## File Structure + +**Modified:** +``` +apps/website/src/components/shared/ +├── Nav.tsx (refactor — drop glass, use new primitives) +├── Footer.tsx (refactor — drop glass + framer-motion) +└── AnnouncementToast.tsx (refactor — drop glass + framer-motion) +``` + +**No new files.** + +--- + +## Task 1: Refactor `Nav` + +**Files:** +- Modify: `apps/website/src/components/shared/Nav.tsx` + +### Goals + +- Remove the glass shell (`tokens.glass.*`, `backdropFilter`). +- White background, becomes sticky with `shadow-sm` + `border-bottom` after page scrolls past ~8px. +- Use `LogoMark` for the brand mark. +- Desktop "Get Started" CTA becomes a `Button variant="primary"`. +- Mobile overlay drops glass — solid white with `shadow-lg`. +- Preserve all existing analytics tracking calls verbatim. +- Preserve mobile docs library/section accordion behavior. +- Keep the existing `'use client'` directive (uses `useState`/`useEffect`/`usePathname`). + +### Steps + +- [ ] **Step 1: Read the current Nav.tsx** to confirm current behavior + +Run: `cat apps/website/src/components/shared/Nav.tsx | wc -l` +Expected: ~347 lines. This is a complex client component — read it carefully before editing. + +- [ ] **Step 2: Replace imports and top-of-file boilerplate** + +Change the imports block at the top of `Nav.tsx`: + +```typescript +'use client'; +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { tokens } from '@ngaf/design-tokens'; +import { docsConfig } from '../../lib/docs-config'; +import { trackCtaClick, trackExternalLinkClick } from '../../lib/analytics/client'; +import { LogoMark } from '../ui/LogoMark'; +import { Button } from '../ui/Button'; +``` + +Keep `GitHubIcon`, `MenuIcon`, `CloseIcon` helper functions unchanged. + +- [ ] **Step 3: Replace the desktop nav shell** + +Replace the outer `