Web Brand Parity Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Bring the Flow web portal’s marketing surfaces and user auth landing pages to brand parity with the Flow Flutter mobile app, without regressing the admin / vendor / moderator dashboards that share components/ui/*.

Architecture: Additive-only token strategy (new --brand-radius-*, --surface-warm-*, --text-*, --shadow-* tokens) plus a tone: 'neutral' | 'brand' prop on 6 shared primitives gated by a ToneProvider React context. A net-new components/marketing/* tree composes the primitives into marketing layout + dark-band imagery + warm sections. Dashboards stay neutral by default; only marketing + the 2 user auth landing pages opt in to tone="brand".

Tech Stack: Next.js 15 (App Router), React 19, Tailwind v4 (@theme inline), TypeScript, next/font/google (Inter), Playwright (@playwright/test visual regression + @axe-core/playwright a11y), Vitest + React Testing Library, plaiceholder (blur placeholders), Lighthouse CI.

Spec: flow_docs/docs/roadmap/specs/2026-04-08-web-brand-parity-design.md — v3.

Repository layout (three independent git repos, siblings under C:/Users/elia-/Documents/flowproject/):

RepoBranch basePurposeWhat this plan commits here
flow_backend/developWeb (Next.js) + dashboard + Supabase edge functions + migrationsFeature branch feature/web-brand-parity-v1 — all code changes
flow_mobile/developFlutter mobile appNot touched by this plan
flow_docs/mainDocusaurus docs site, source of truth for specs / plans / changelogEdit branch edit/web-brand-parity-docs — spec + plan + changelog + audit artifacts

Branches this plan creates:

  • flow_backend: feature/web-brand-parity-v1 (off develop) — will be PR’d back to develop in Phase 9
  • flow_docs: edit/web-brand-parity-docs (off main) — merged to main as docs land (Phase 0, 1.5, 2.7, 5.10, 6.1, 8.2)

Every git step names which repo it runs in. Never assume cd; each task’s commit step is explicit about the working directory.

Path for cross-repo references in scripts: the mobile theme lives at ../flow_mobile/lib/core/theme/app_theme.dart relative to flow_backend/. The token parity script must resolve paths relative to flow_backend/’s worktree, then climb one level for the mobile file. Absolute paths are OK for local dev but MUST be made portable before CI — prefer resolve(__dirname, '../../../flow_mobile/...') where __dirname = flow_backend/scripts/.


File Structure Overview

Files this plan creates or modifies. Each row is a locked-in responsibility.

Created files

Paths are absolute-from-repo-root. The flow_backend/ prefix means the file lives in the flow_backend repo; flow_docs/ means the flow_docs repo. There’s no monorepo — each prefix is a separate git worktree.

PathResponsibility
flow_backend/web/components/ui/tone-context.tsxReact context + ToneProvider + useTone() hook. Zero business logic.
flow_backend/web/components/marketing/MarketingShell.tsxTop-level wrapper every marketing page imports. Mounts <ToneProvider tone="brand">, reuses existing <ScrollRevealProvider>, sets warm canvas background on a top-level <div> (not body), renders <SvgDuotoneFilter /> once, wraps <Nav> + <main> + <Footer>.
flow_backend/web/components/marketing/AuthLanding.tsxWrapper for /auth/confirmed and /auth/reset-password. Centered <Card tone="brand">, subtle coral radial glow (CSS only).
flow_backend/web/components/marketing/Nav.tsxWarm canvas nav bar. Logo, 5 links, primary CTA, mobile sheet.
flow_backend/web/components/marketing/Footer.tsxDark band footer. 3 cols, social icons, 18+ disclaimer.
flow_backend/web/components/marketing/DarkBand.tsxDark-moment workhorse. Props imagery, intensity, rounded. Absolute <Image> + gradient overlay + filter: url(#brand-duotone) + onError fallback to abstract.
flow_backend/web/components/marketing/WarmSection.tsxWarm-page section wrapper. Max-width, responsive padding, auto .reveal on children.
flow_backend/web/components/marketing/Hero.tsxLanding hero composition (DarkBand + H1 + sub + 2 CTAs + trust strip).
flow_backend/web/components/marketing/SvgDuotoneFilter.tsxHidden <svg> mounting the brand-duotone filter once per marketing tree.
flow_backend/web/public/brand/hero/hero-01.webpAI-generated hero image slot 1 (theatrical).
flow_backend/web/public/brand/hero/hero-02.webpSlot 2 (editorial — vinyl close-up).
flow_backend/web/public/brand/hero/hero-03.webpSlot 3 (editorial — empty dancefloor).
flow_backend/web/public/brand/hero/hero-04.webpSpare (abstract/bar).
flow_backend/web/public/brand/hero/blur.jsonBuild-time blur placeholders keyed by filename.
flow_backend/web/scripts/gen-blur-placeholders.tsReads public/brand/hero/*.webp, writes blur.json. One-shot.
flow_backend/web/scripts/check-token-parity.tsParses flow_mobile/lib/core/theme/app_theme.dart + flow_backend/web/app/globals.css, diffs tokens, respects allow-list.
flow_backend/web/scripts/web-only-tokens.txtAllow-list of web-only tokens with one justification per line.
flow_backend/web/app/error.tsxMarketing error boundary (warm canvas, brand Card, coral CTA back to /).
flow_backend/web/app/not-found.tsxMarketing 404 (same shell as error).
flow_backend/web/__tests__/ui/tone-context.test.tsxVitest tests on ToneProvider / useTone.
flow_backend/web/__tests__/ui/button.brand.test.tsxBrand-tone component tests for Button.
flow_backend/web/__tests__/ui/card.brand.test.tsxCard brand-tone tests.
flow_backend/web/__tests__/ui/input.brand.test.tsxInput brand-tone tests.
flow_backend/web/__tests__/ui/textarea.brand.test.tsxTextarea brand-tone tests.
flow_backend/web/__tests__/ui/select.brand.test.tsxSelect brand-tone tests.
flow_backend/web/__tests__/ui/modal.brand.test.tsxModal brand-tone tests.
flow_backend/web/tests/visual/marketing.spec.tsPlaywright visual regression for 8 marketing/auth pages at 3 viewports.
flow_backend/web/tests/visual/dashboard-regression.spec.tsPlaywright visual regression for 10 dashboard routes (pixel-stability guard).
flow_backend/web/tests/a11y/marketing.spec.ts@axe-core/playwright runs on every in-scope page.
flow_backend/web/lighthouserc.jsonLighthouse CI config, landing-only budget.
flow_docs/docs/design/2026-04-08-marketing-copy-audit.mdRubric audit table artifact.
flow_docs/docs/project/changelog/2026-04-08-web-brand-parity.mdChangelog entry (what + why).

Modified files

PathChange
flow_backend/web/app/globals.cssAdd new tokens under :root + expose via @theme inline; change --foreground value; remove --font-display entry; remove .font-display utility rule.
flow_backend/web/app/layout.tsxReplace Syne + DM Sans loading with next/font/google Inter (weights 400-900), wire --font-sans.
flow_backend/web/components/ui/button.tsxAdd tone?: Tone, brand variant classes, JSDoc.
flow_backend/web/components/ui/card.tsxAdd tone, brand classes.
flow_backend/web/components/ui/input.tsxAdd tone, brand classes.
flow_backend/web/components/ui/textarea.tsxAdd tone, brand classes.
flow_backend/web/components/ui/select.tsxAdd tone, brand classes.
flow_backend/web/components/ui/modal.tsxAdd tone, brand classes.
flow_backend/web/app/page.tsxFull rebuild with <MarketingShell> + <Hero> + warm sections. Copy rewrite.
flow_backend/web/app/about/page.tsx<MarketingShell> + copy tighten.
flow_backend/web/app/services/page.tsxLayout rebuild + heaviest copy rewrite.
flow_backend/web/app/contact/page.tsxForm with brand primitives + success/error microcopy.
flow_backend/web/app/privacy/page.tsxLayout only; legal copy untouched.
flow_backend/web/app/upgrade/page.tsxLayout rebuild + copy pass.
flow_backend/web/app/auth/confirmed/page.tsx<AuthLanding> wrapper + status copy.
flow_backend/web/app/auth/reset-password/page.tsx<AuthLanding> wrapper + single-field form + error map.
flow_backend/web/package.jsonAdd plaiceholder, @axe-core/playwright, @lhci/cli deps + scripts gen:blur, check:tokens, test:visual, test:a11y, lhci.
flow_backend/web/playwright.config.tsEnsure Linux-baseline projects for visual tests; add test-results ignore.
.claude/brand-voice-guidelines.md§8.219 rewrite per spec §1.2.

Explicitly untouched

  • flow_backend/web/components/ui/empty-state.tsx, loader.tsx, ImageUploadCrop.tsx, table.tsx — dashboard-only primitives.
  • flow_backend/web/app/admin/**, vendor/**, moderator/**, api/**, auth/callback/**, admin/login/** — out of scope.
  • flow_mobile/**, Supabase schema, email templates.

Cross-cutting standards (apply to every task)

  • Commits: Conventional Commits. Scope is web/tokens, web/ui, web/marketing, web/pages, web/tests, web/docs, web/scripts. One logical change per commit.
  • TDD discipline: For every primitive and context change, write the failing test first. Verify it fails with the expected message. Then write minimal code. Re-run. Commit once green.
  • Run tests from flow_backend/web/ unless otherwise noted. Vitest: npm test -- --run <path>. Playwright: npx playwright test <path>. Both are already installed.
  • Never commit Playwright snapshots from a Windows local run. Baselines are generated on ubuntu-latest via --update-snapshots in CI only. Local runs use --update-snapshots for comparison, not for committing.
  • Never change a dashboard token value except --foreground. Every other change is additive. If a refactor tempts you to flip an existing variable, STOP and escalate.
  • Italian copy only. No English fallbacks, no next-intl. Top-of-file const COPY = { ... } per marketing page.
  • Every primitive modification keeps the neutral code-path byte-identical to HEAD. Use if (t === 'brand') branches; the else branch returns the pre-existing JSX unchanged.

Chunk 1: Phase 0 — Pre-flight audit and safety nets

Phase 0 is the blocker. Nothing in Phase 1+ runs until Phase 0 is green.

Task 0.0: Set up docs edit branch in flow_docs

Repo: flow_docs/ (separate repo from code).

Files: the spec + plan files already exist as untracked files at flow_docs/docs/roadmap/specs/2026-04-08-web-brand-parity-design.md and flow_docs/docs/roadmap/plans/2026-04-08-web-brand-parity.md.

  • Step 1: Create the docs edit branch
cd "<flowproject>/flow_docs" && git checkout main && git status

Expected: clean (except the two untracked files).

git checkout -b edit/web-brand-parity-docs
  • Step 2: Commit the spec and plan as-is
git add docs/roadmap/specs/2026-04-08-web-brand-parity-design.md docs/roadmap/plans/2026-04-08-web-brand-parity.md
git commit -m "docs(roadmap): add web brand parity design spec v3 + implementation plan"
  • Step 3: Confirm Docusaurus sidebar picks up the new files
npm run start -- --no-open

Wait for [SUCCESS] Docusaurus website is running at ..., then Ctrl-C. If the build fails with “document not in sidebar” or similar, edit docs/roadmap/specs/_category_.json / plans/_category_.json to auto-include new files, OR add explicit sidebar entries.

Do NOT merge to main yet — later doc updates (changelog, copy audit) land on this same branch and we merge once at the end.

Task 0.1: Create the feature branch in flow_backend

Repo: flow_backend/.

  • Step 1: Confirm clean working tree on develop
cd "<flowproject>/flow_backend" && git checkout develop && git status

Expected: nothing to commit, working tree clean, on develop, ahead of origin by some number of commits (prior merges).

  • Step 2: Create and checkout feature branch
git checkout -b feature/web-brand-parity-v1

Expected: Switched to a new branch 'feature/web-brand-parity-v1'.

  • Step 3: Sanity check the workdir
cd web && npm run lint && npm run build

Expected: lint passes, build succeeds. If either fails, investigate before proceeding — Phase 0 must not start on red.

Task 0.2: Grep audit for leaking tokens and utilities

Files: none — this is a diagnostic.

  • Step 1: Find every font-display usage across the web app

Run (from flow_backend/web/):

grep -rn "font-display" app components --include="*.tsx" --include="*.ts" --include="*.css"

Record every hit in flow_docs/docs/design/2026-04-08-preflight-audit.md (new file, scratch notes — NOT committed). All hits MUST be in the 8 in-scope pages (page.tsx, about/, services/, contact/, privacy/, upgrade/, auth/confirmed/, auth/reset-password/) or in globals.css.

Pass criterion: zero font-display hits in admin/, vendor/, moderator/, or components/ subtrees other than marketing-related files. If leaks exist, STOP and rescope.

  • Step 2: Find every rounded-lg / rounded-md / rounded-xl usage in dashboards

Run:

grep -rn "rounded-lg\|rounded-md\|rounded-xl" app/admin app/vendor app/moderator components/admin components/vendor components/moderator --include="*.tsx"

Record counts. These MUST stay visually identical after Phase 1. This is a pre-baseline — we expect zero diff on these files after the whole plan lands.

  • Step 3: Find every text-foreground and --foreground usage
grep -rn "text-foreground\|--foreground" app components --include="*.tsx" --include="*.ts" --include="*.css"

Record locations. --foreground goes from #374151 to #18181B. Every hit is potentially a visible change (higher contrast body text). Document expectation: slightly darker dashboard body text, acceptable global improvement.

  • Step 4: Find every --card-bg usage
grep -rn "card-bg" app components --include="*.tsx" --include="*.ts" --include="*.css"

Record. This must remain unchanged (kept in globals). Confirms spec §2.1 audit.

  • Step 5: Commit the audit notes

The scratch audit file is NOT committed (it’s disposable). Instead, open the changelog file and add a ### Pre-flight audit findings section summarizing counts.

git add flow_docs/docs/project/changelog/2026-04-08-web-brand-parity.md
git commit -m "docs(web): add brand parity changelog stub + pre-flight findings"

(The changelog file itself is created minimally in Task 0.5 below — this step is sequenced after that. Reorder execution accordingly.)

Task 0.3: Install new dev dependencies

Files: flow_backend/web/package.json, flow_backend/web/package-lock.json.

  • Step 1: Install plaiceholder, axe, lhci

From flow_backend/web/:

npm install --save-dev plaiceholder @axe-core/playwright @lhci/cli

Expected: added N packages success.

  • Step 2: Add npm scripts

Edit flow_backend/web/package.json scripts block to append:

"gen:blur": "tsx scripts/gen-blur-placeholders.ts",
"check:tokens": "tsx scripts/check-token-parity.ts",
"test:visual": "playwright test tests/visual",
"test:a11y": "playwright test tests/a11y",
"lhci": "lhci autorun"

If tsx is not already a dev dep:

npm install --save-dev tsx
  • Step 3: Verify scripts resolve
npm run check:tokens --silent || true

Expected: the command runs (it will fail because the script doesn’t exist yet — that’s fine, we’re verifying npm wiring).

  • Step 4: Commit
git add flow_backend/web/package.json flow_backend/web/package-lock.json
git commit -m "chore(web): add plaiceholder, axe-playwright, lhci, tsx dev deps"

Task 0.4: Write scripts/web-only-tokens.txt allow-list

Files: Create flow_backend/web/scripts/web-only-tokens.txt.

  • Step 1: Create the allow-list file

Contents:

# Tokens that exist in web globals.css but have NO mobile counterpart.
# One token per line followed by the justification.
--card-bg: dashboard card background, mobile uses per-widget colors
--moderator-accent: moderator-role tint, no mobile equivalent (web-only role)
--action-blue: admin chip color, no mobile equivalent (web-only role)
--warning-orange: flagged-content tint, dashboard-only
--electric-violet: partial web-only usage, kept for legacy admin chips
--divider-light: derived from mobile dividerLight but exposed with web naming
--divider-dark: derived from mobile dividerDark but exposed with web naming
  • Step 2: Commit
git add flow_backend/web/scripts/web-only-tokens.txt
git commit -m "chore(web): add web-only token allow-list for parity checker"

Task 0.5: Write scripts/check-token-parity.ts

Files: Create flow_backend/web/scripts/check-token-parity.ts.

This script parses flow_mobile/lib/core/theme/app_theme.dart for Color(0xFF...) + numeric spacing/radius constants and cross-references flow_backend/web/app/globals.css :root block. Exits non-zero on mismatch.

  • Step 1: Write a minimal failing test harness

Create flow_backend/web/__tests__/scripts/check-token-parity.test.ts:

import { describe, it, expect } from 'vitest'
import { parseMobileTokens, parseWebTokens, diffTokens } from '../../scripts/check-token-parity'
 
describe('check-token-parity', () => {
  it('parses mobile Color(0xFF...) constants', () => {
    const src = `static const primaryBrand = Color(0xFFFF5E57);`
    expect(parseMobileTokens(src)).toEqual({ primaryBrand: '#FF5E57' })
  })
 
  it('parses web --token: #hex definitions', () => {
    const src = `:root { --primary-brand: #FF5E57; }`
    expect(parseWebTokens(src)).toEqual({ 'primary-brand': '#FF5E57' })
  })
 
  it('reports missing web tokens', () => {
    const result = diffTokens(
      { primaryBrand: '#FF5E57', mintGreen: '#2ED573' },
      { 'primary-brand': '#FF5E57' },
      new Set()
    )
    expect(result.missing).toContain('mintGreen')
  })
 
  it('respects the allow-list for web-only tokens', () => {
    const result = diffTokens(
      {},
      { 'primary-brand': '#FF5E57', 'card-bg': '#F5F5F7' },
      new Set(['--card-bg'])
    )
    expect(result.extra).not.toContain('card-bg')
  })
})
  • Step 2: Run the test, see it fail
cd flow_backend/web && npm test -- --run __tests__/scripts/check-token-parity.test.ts

Expected: fails with “Cannot find module ’../../scripts/check-token-parity’“.

  • Step 3: Implement the script

Create flow_backend/web/scripts/check-token-parity.ts:

#!/usr/bin/env tsx
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
 
// flow_backend/scripts/ → flow_backend/ → flowproject/
const REPO_ROOT = resolve(__dirname, '..')          // flow_backend/
const PROJECT_ROOT = resolve(REPO_ROOT, '..')       // flowproject/ (parent of all 3 repos)
const MOBILE_THEME = resolve(PROJECT_ROOT, 'flow_mobile/lib/core/theme/app_theme.dart')
const WEB_GLOBALS = resolve(REPO_ROOT, 'web/app/globals.css')
const ALLOWLIST = resolve(__dirname, 'web-only-tokens.txt')
 
export function parseMobileTokens(src: string): Record<string, string> {
  const out: Record<string, string> = {}
  const colorRe = /static\s+const\s+(\w+)\s*=\s*Color\(0xFF([0-9A-Fa-f]{6})\)/g
  let m: RegExpExecArray | null
  while ((m = colorRe.exec(src)) !== null) {
    out[m[1]] = '#' + m[2].toUpperCase()
  }
  return out
}
 
export function parseWebTokens(src: string): Record<string, string> {
  const out: Record<string, string> = {}
  const rootMatch = src.match(/:root\s*\{([^}]*)\}/s)
  if (!rootMatch) return out
  const body = rootMatch[1]
  const varRe = /--([\w-]+):\s*(#[0-9A-Fa-f]{3,8})\s*;/g
  let m: RegExpExecArray | null
  while ((m = varRe.exec(body)) !== null) {
    out[m[1]] = m[2].toUpperCase()
  }
  return out
}
 
export function diffTokens(
  mobile: Record<string, string>,
  web: Record<string, string>,
  allow: Set<string>
) {
  const camelToKebab = (s: string) =>
    s.replace(/([A-Z])/g, '-$1').toLowerCase()
  const mobileKebab: Record<string, string> = {}
  for (const [k, v] of Object.entries(mobile)) mobileKebab[camelToKebab(k)] = v
 
  const missing: string[] = []
  for (const [k, v] of Object.entries(mobileKebab)) {
    if (!(k in web)) { missing.push(k); continue }
    if (web[k].toUpperCase() !== v.toUpperCase()) missing.push(`${k} (value mismatch ${web[k]} vs ${v})`)
  }
  const extra: string[] = []
  for (const k of Object.keys(web)) {
    if (allow.has('--' + k)) continue
    if (!(k in mobileKebab)) extra.push(k)
  }
  return { missing, extra }
}
 
function loadAllowList(): Set<string> {
  const raw = readFileSync(ALLOWLIST, 'utf8')
  return new Set(
    raw.split('\n')
      .map(l => l.trim())
      .filter(l => l && !l.startsWith('#'))
      .map(l => l.split(':')[0].trim())
  )
}
 
function main() {
  const mobile = parseMobileTokens(readFileSync(MOBILE_THEME, 'utf8'))
  const web = parseWebTokens(readFileSync(WEB_GLOBALS, 'utf8'))
  const allow = loadAllowList()
  const { missing, extra } = diffTokens(mobile, web, allow)
 
  if (missing.length) {
    console.error('Missing mobile tokens in web globals.css:')
    for (const m of missing) console.error('  -', m)
  }
  if (extra.length) {
    console.error('Extra web tokens without allow-list entry:')
    for (const e of extra) console.error('  -', e)
  }
  if (missing.length || extra.length) process.exit(1)
  console.log('Token parity: OK')
}
 
if (require.main === module) main()
  • Step 4: Re-run the test, see it pass
npm test -- --run __tests__/scripts/check-token-parity.test.ts

Expected: all 4 tests pass.

  • Step 5: Run the script against current HEAD
npm run check:tokens

Expected: it WILL report missing tokens because Phase 1 hasn’t run yet. Capture the output — this is the “baseline miss list” and becomes the work target for Phase 1. Note it in the changelog pre-flight section.

  • Step 6: Commit
git add flow_backend/web/scripts/check-token-parity.ts flow_backend/web/__tests__/scripts/check-token-parity.test.ts
git commit -m "chore(web): add token parity checker script + tests"

Task 0.6: Baseline Playwright dashboard regression snapshots

Files: Create flow_backend/web/tests/visual/dashboard-regression.spec.ts.

The 10 dashboard pages listed in spec §9.1 become the pixel-stability guard. Capture baselines against current HEAD (before any brand changes).

  • Step 1: Create the test file

flow_backend/web/tests/visual/dashboard-regression.spec.ts:

import { test, expect } from '@playwright/test'
 
const DASHBOARD_ROUTES: { name: string; path: string; role: 'admin' | 'vendor' | 'moderator' }[] = [
  { name: 'admin-login',         path: '/admin/login',         role: 'admin' },
  { name: 'admin-analytics',     path: '/admin/analytics',     role: 'admin' },
  { name: 'admin-users',         path: '/admin/users',         role: 'admin' },
  { name: 'admin-events',        path: '/admin/events',        role: 'admin' },
  { name: 'admin-notifications', path: '/admin/notifications', role: 'admin' },
  { name: 'vendor-home',         path: '/vendor',              role: 'vendor' },
  { name: 'vendor-events',       path: '/vendor/events',       role: 'vendor' },
  { name: 'moderator-home',      path: '/moderator',           role: 'moderator' },
  { name: 'moderator-reports',   path: '/moderator/reports',   role: 'moderator' },
  { name: 'admin-badges',        path: '/admin/badges',        role: 'admin' },
]
 
for (const route of DASHBOARD_ROUTES) {
  test(`dashboard regression: ${route.name}`, async ({ page }) => {
    // Auth setup uses role-specific storage state provided by the existing
    // Playwright project configuration. See playwright.config.ts for fixtures.
    await page.goto(route.path)
    await page.waitForLoadState('networkidle')
    await expect(page).toHaveScreenshot(`${route.name}.png`, {
      fullPage: true,
      maxDiffPixelRatio: 0.002,
    })
  })
}
  • Step 2: Verify existing Playwright auth fixtures

Check flow_backend/web/playwright.config.ts for per-role storage state projects. If roles are not already wired, create throwaway storage states via the existing test-user seed script (check tests/ for a setup file). If no seed exists, mark this task BLOCKED and surface to the human — do not invent a login flow.

  • Step 3: Generate baselines on Linux

The baselines MUST come from Linux/ubuntu. Options:

  1. Push the branch, let a GitHub Actions job run playwright test --update-snapshots, download the artifact, commit.
  2. Run inside WSL / Docker mcr.microsoft.com/playwright:v1.x-jammy.

Prefer option 2 for speed. Run:

docker run --rm -v "$PWD":/work -w /work/flow_backend/web mcr.microsoft.com/playwright:v1.48.0-jammy \
  npx playwright test tests/visual/dashboard-regression.spec.ts --update-snapshots

Expected: 10 baseline PNGs land in tests/visual/dashboard-regression.spec.ts-snapshots/.

  • Step 4: Re-run without --update-snapshots to confirm clean
docker run --rm -v "$PWD":/work -w /work/flow_backend/web mcr.microsoft.com/playwright:v1.48.0-jammy \
  npx playwright test tests/visual/dashboard-regression.spec.ts

Expected: 10 passed.

  • Step 5: Commit
git add flow_backend/web/tests/visual/dashboard-regression.spec.ts flow_backend/web/tests/visual/dashboard-regression.spec.ts-snapshots/
git commit -m "test(web): baseline dashboard visual regression for brand parity guard"

Task 0.7: Create changelog stub and Phase 0 exit gate

Repo: flow_docs/ (switch to the docs repo for this task).

Files: Create flow_docs/docs/project/changelog/2026-04-08-web-brand-parity.md.

  • Step 1: Create the changelog file

Contents:

# Web Brand Parity — Changelog
 
- **Date:** 2026-04-08
- **Branch:** `feature/web-brand-parity-v1`
- **Spec:** `flow_docs/docs/roadmap/specs/2026-04-08-web-brand-parity-design.md`
 
## What
 
Brings the Flow web portal's marketing surfaces (`/`, `/about`, `/services`, `/contact`, `/privacy`, `/upgrade`) and user auth landings (`/auth/confirmed`, `/auth/reset-password`) to brand parity with the Flow mobile app. Adds a `tone: 'neutral' | 'brand'` prop to 6 shared UI primitives so dashboards can stay pixel-stable while marketing opts in.
 
## Why
 
The web portal is primarily an admin tool, but the public marketing and post-auth landings shape first impressions. Today they feel disjoint from the mobile brand. Fixing this closes the "one brand" goal without touching the dashboards that depend on the shared primitives.
 
## Pre-flight audit findings (Phase 0)
 
<!-- Filled in during Task 0.2 -->
- `font-display` hits: TBD
- `rounded-lg/md/xl` dashboard hits: TBD
- `text-foreground`/`--foreground` hits: TBD
- `--card-bg` hits: TBD
- Token parity baseline miss list: TBD
 
## Changes
 
_To be filled during implementation._
 
## Risks and mitigations
 
- **Dashboard regression from shared primitives**`tone="neutral"` default + dashboard visual regression guard (10 routes).
- **Font swap DM Sans → Inter changes every dashboard page** → baselines captured against post-font state in Phase 1; visual review required.
- **`--foreground` value change** → documented as intentional contrast improvement; 16:1 on warm canvas.
- **AI imagery quality** → stop-ship trigger to fall back to abstract.
  • Step 2: Fill in the Phase 0 audit findings

Return to Task 0.2 results and write counts into the Pre-flight audit findings section. Include the token parity baseline miss list from Task 0.5 Step 5.

  • Step 3: Commit Phase 0 exit
cd "<flowproject>/flow_docs"
git add docs/project/changelog/2026-04-08-web-brand-parity.md
git commit -m "docs(web): phase 0 complete — pre-flight audit + baselines for brand parity"

Task 0.8: Phase 0 pass-criteria check

  • Step 1: Verify pass criteria
  1. font-display className appears in ZERO files outside the 8 in-scope pages (plus globals.css). If it leaked, STOP.
  2. All 10 dashboard snapshots exist as committed baselines under tests/visual/dashboard-regression.spec.ts-snapshots/.
  3. npm run check:tokens runs against current HEAD and produces a deterministic baseline miss list (Phase 1 will reduce it to zero).
  4. Branch feature/web-brand-parity-v1 contains 5 commits (audit deps, allow-list, parity script + tests, dashboard baselines, changelog).
  • Step 2: If any criterion fails, STOP

Do not proceed to Phase 1 without all four criteria green. Escalate to the human for any blocker.


Chunk 2: Phase 1 — Tokens, fonts, and tone context

Additive-only token changes, the one justified --foreground flip, Inter font swap, and the ToneProvider context.

Task 1.1: Add the additive token block to globals.css

Files: Modify flow_backend/web/app/globals.css.

  • Step 1: Read the current file to locate the :root block

Use Read on flow_backend/web/app/globals.css. Identify line numbers for the :root { block and the @theme inline { block. Record them.

  • Step 2: Append new tokens inside :root

Insert (immediately before the closing } of :root):

  /* ---------- Web brand parity v1 — 2026-04-08 ---------- */
  /* Mobile textPrimary (zinc-900). Previously #374151. */
  --foreground: #18181B;
 
  /* Mobile parity aliases + new tokens. Additive only. */
  --ink-black: #121212;
  --text-secondary: #52525B;
  --text-tertiary: #A1A1AA;
  --text-on-dark: #FAFAFA;
  --text-on-dark-secondary: #D4D4D8;
  --divider-light: #E4E4E7;
  --divider-dark: #27272A;
  --error: #EF4444;
 
  /* Brand-only surfaces for marketing */
  --surface-warm: #F0EEE9;
  --surface-warm-elev: #FFFFFF;
  --surface-dark-elev: #1E1E1E;
 
  /* Brand gradient, single source */
  --brand-gradient: linear-gradient(135deg, #FF5E57 0%, #8A2BE2 100%);
 
  /* 8pt spatial scale (mobile parity) */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 20px;
  --space-6: 24px;
  --space-8: 32px;
  --space-12: 48px;
  --space-16: 64px;
  --space-24: 96px;
  --space-32: 128px;
 
  /* Brand-scoped radius tokens.
     DO NOT rename to --radius-*, that would collide with Tailwind defaults. */
  --brand-radius-xs: 4px;
  --brand-radius-sm: 8px;
  --brand-radius-chip: 12px;
  --brand-radius-button: 14px;
  --brand-radius-card: 20px;
  --brand-radius-pill: 28px;
  --brand-radius-full: 9999px;
 
  /* Shadow tokens (mobile parity) */
  --shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
  --shadow-md: 0 4px 12px rgba(0,0,0,0.08);
  --shadow-lg: 0 12px 32px rgba(0,0,0,0.12);
  /* ---------- /web brand parity v1 ---------- */

If the existing :root already sets --foreground: #374151;, REMOVE that old line (do not leave duplicates). This is the single intentional value change.

  • Step 3: Remove the old --font-display declaration

Grep for --font-display inside globals.css. Remove any :root declaration and any @theme inline mapping entry. Remove the .font-display { ... } utility rule if present.

  • Step 4: Append new @theme inline entries

Insert inside the @theme inline { ... } block (before its closing }):

  /* Web brand parity v1 — new color utilities */
  --color-ink-black: var(--ink-black);
  --color-text-secondary: var(--text-secondary);
  --color-text-tertiary: var(--text-tertiary);
  --color-text-on-dark: var(--text-on-dark);
  --color-text-on-dark-secondary: var(--text-on-dark-secondary);
  --color-divider-light: var(--divider-light);
  --color-divider-dark: var(--divider-dark);
  --color-error: var(--error);
  --color-surface-warm: var(--surface-warm);
  --color-surface-warm-elev: var(--surface-warm-elev);
  --color-surface-dark-elev: var(--surface-dark-elev);
 
  /* Brand-scoped radius utilities — generates `rounded-button`, `rounded-card`, etc. */
  --radius-button: var(--brand-radius-button);
  --radius-card:   var(--brand-radius-card);
  --radius-chip:   var(--brand-radius-chip);
  --radius-pill:   var(--brand-radius-pill);
  • Step 5: Build and verify
cd flow_backend/web && npm run build

Expected: clean build. Any Tailwind error here means a token collision or typo — fix before moving on.

  • Step 6: Verify token parity script passes
npm run check:tokens

Expected: Token parity: OK. If it still reports missing tokens, map each one to the allow-list (edit scripts/web-only-tokens.txt with justification) or add the missing token to :root.

  • Step 7: Run dashboard regression
docker run --rm -v "$PWD":/work -w /work/flow_backend/web mcr.microsoft.com/playwright:v1.48.0-jammy \
  npx playwright test tests/visual/dashboard-regression.spec.ts

Expected: all 10 dashboard snapshots pass. If any fail, diff the snapshot visually. The only acceptable diff is a subtly darker body text color (from --foreground flip). Everything else is a regression — STOP and fix.

If the --foreground change causes perceptible diffs, re-baseline them intentionally:

docker run ... npx playwright test tests/visual/dashboard-regression.spec.ts --update-snapshots

and note in the changelog: “dashboard baselines re-captured after intentional --foreground contrast shift (16.1:1 AAA on warm canvas)“.

  • Step 8: Commit
git add flow_backend/web/app/globals.css flow_backend/web/scripts/web-only-tokens.txt
git add flow_backend/web/tests/visual/dashboard-regression.spec.ts-snapshots/
git commit -m "feat(web/tokens): add mobile-parity tokens + brand-scoped radii to globals.css"

Task 1.2: Swap the app font to Inter via next/font/google

Files: Modify flow_backend/web/app/layout.tsx.

  • Step 1: Read current layout.tsx

Identify the current Syne and DM_Sans (or similar) next/font/google imports. Record the <body className> that wires the font variables.

  • Step 2: Replace font imports

Edit the imports block:

import { Inter, DM_Mono } from 'next/font/google'
 
const inter = Inter({
  subsets: ['latin'],
  weight: ['400', '500', '600', '700', '800', '900'],
  variable: '--font-sans',
  display: 'swap',
})
 
const dmMono = DM_Mono({
  subsets: ['latin'],
  weight: ['400', '500'],
  variable: '--font-mono',
  display: 'swap',
})

Remove the Syne and DM_Sans imports entirely. Remove any <Syne…> constant and its className wiring.

  • Step 3: Update the <body> className

Replace the font class list with:

<body className={`${inter.variable} ${dmMono.variable} antialiased`}>

(Keep any other existing classNames like antialiased, providers, etc.)

  • Step 4: Build
npm run build

Expected: success. Any Google Fonts fetch issue means network or name typo.

  • Step 5: Smoke-run dev
npm run dev

Open http://localhost:3000/admin/login — verify Inter renders. Kill the dev server. This is a manual one-off check; no snapshot required.

  • Step 6: Re-baseline dashboards after font swap

The font change WILL bust dashboard baselines — that’s the intentional global swap. Re-capture:

docker run --rm -v "$PWD":/work -w /work/flow_backend/web mcr.microsoft.com/playwright:v1.48.0-jammy \
  npx playwright test tests/visual/dashboard-regression.spec.ts --update-snapshots

Visually review each new baseline for legibility — especially admin/badges (data-dense table). If any page is now harder to read, STOP and escalate.

  • Step 7: Commit
git add flow_backend/web/app/layout.tsx flow_backend/web/tests/visual/dashboard-regression.spec.ts-snapshots/
git commit -m "feat(web/tokens): swap app font DM Sans → Inter via next/font/google (400-900)"

Task 1.3: Remove font-display className usages

Files: Any JSX file that still references font-display (from Task 0.2 audit).

  • Step 1: Replace each font-display className

For each hit from the Task 0.2 audit, edit the file:

  • className="font-display ..."className="font-sans font-[800] ..."
  • className="... font-display"className="... font-sans font-[800]"

Keep the rest of the className string identical.

  • Step 2: Verify zero remaining hits
grep -rn "font-display" app components --include="*.tsx" --include="*.ts" --include="*.css"

Expected: no output.

  • Step 3: Build and test
npm run build && npm test -- --run

Expected: clean.

  • Step 4: Commit
git add -u
git commit -m "refactor(web): replace .font-display utility with font-sans font-[800]"

Task 1.4: Create ToneProvider context — TDD

Files: Create flow_backend/web/components/ui/tone-context.tsx + flow_backend/web/__tests__/ui/tone-context.test.tsx.

  • Step 1: Write the failing test

flow_backend/web/__tests__/ui/tone-context.test.tsx:

import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ToneProvider, useTone } from '@/components/ui/tone-context'
 
function Probe({ override }: { override?: 'neutral' | 'brand' }) {
  const t = useTone(override)
  return <span data-testid="tone">{t}</span>
}
 
describe('ToneProvider / useTone', () => {
  it('defaults to neutral when no provider is mounted', () => {
    render(<Probe />)
    expect(screen.getByTestId('tone').textContent).toBe('neutral')
  })
 
  it('returns brand inside <ToneProvider tone="brand">', () => {
    render(<ToneProvider tone="brand"><Probe /></ToneProvider>)
    expect(screen.getByTestId('tone').textContent).toBe('brand')
  })
 
  it('an explicit override wins over context', () => {
    render(<ToneProvider tone="brand"><Probe override="neutral" /></ToneProvider>)
    expect(screen.getByTestId('tone').textContent).toBe('neutral')
  })
 
  it('an explicit override works without any provider', () => {
    render(<Probe override="brand" />)
    expect(screen.getByTestId('tone').textContent).toBe('brand')
  })
})
  • Step 2: Run test, see it fail
npm test -- --run __tests__/ui/tone-context.test.tsx

Expected: fails — module not found.

  • Step 3: Implement the context

flow_backend/web/components/ui/tone-context.tsx:

'use client'
import { createContext, useContext, type ReactNode } from 'react'
 
/**
 * Tone controls which visual variant a primitive renders.
 * `neutral` = dashboard default (existing classes, zero change).
 * `brand` = marketing/auth-landing variant (warm surfaces, brand radii, Inter 800 on CTAs).
 *
 * Opt-in is per-subtree via <ToneProvider> or per-instance via the `tone` prop
 * on primitives. Dashboards never mount a provider, so they stay neutral by default.
 */
export type Tone = 'neutral' | 'brand'
 
const ToneContext = createContext<Tone>('neutral')
 
export function ToneProvider({ tone, children }: { tone: Tone; children: ReactNode }) {
  return <ToneContext.Provider value={tone}>{children}</ToneContext.Provider>
}
 
/**
 * Returns the effective tone for the current subtree.
 * Pass `override` to let a component's own `tone` prop win over the provider.
 */
export function useTone(override?: Tone): Tone {
  const ctx = useContext(ToneContext)
  return override ?? ctx
}
  • Step 4: Re-run test, see it pass
npm test -- --run __tests__/ui/tone-context.test.tsx

Expected: 4 passed.

  • Step 5: Commit
git add flow_backend/web/components/ui/tone-context.tsx flow_backend/web/__tests__/ui/tone-context.test.tsx
git commit -m "feat(web/ui): add ToneProvider / useTone context for brand opt-in"

Task 1.5: Phase 1 exit gate

  • Step 1: Full regression
npm run build
npm test -- --run
npm run check:tokens
docker run --rm -v "$PWD":/work -w /work/flow_backend/web mcr.microsoft.com/playwright:v1.48.0-jammy \
  npx playwright test tests/visual/dashboard-regression.spec.ts

All four must be green.

  • Step 2: Update changelog

Append to flow_docs/docs/project/changelog/2026-04-08-web-brand-parity.md under ## Changes:

### Phase 1 — tokens + fonts + tone context
 
- `globals.css`: added mobile-parity tokens (ink-black, text-secondary/tertiary, divider-light/dark, error, surface-warm/warm-elev/dark-elev, brand-gradient, space-*, brand-radius-*, shadow-*). Exposed via `@theme inline` as `rounded-button/card/chip/pill` and `bg-surface-warm*` utilities.
- `globals.css`: `--foreground` flipped from `#374151` to `#18181B` for mobile parity (zinc-900). Dashboard baselines re-captured; 16.1:1 on warm canvas.
- `layout.tsx`: font loading swapped from DM Sans + Syne to Inter (400-900) via `next/font/google`. Dashboards now render in Inter.
- `.font-display` utility removed; callsites replaced with `font-sans font-[800]`.
- `components/ui/tone-context.tsx`: new `ToneProvider` + `useTone` context. Default tone = neutral.
git add flow_docs/docs/project/changelog/2026-04-08-web-brand-parity.md
git commit -m "docs(web): log phase 1 — tokens + fonts + tone context"

Chunk 3: Phase 2 — Primitive brand tones (6 commits)

Each primitive gets a tone?: Tone prop and a brand branch. Default behavior unchanged. Write tests first. One primitive per commit.

Shared pattern (applies to every Phase 2 task)

Every primitive follows the same template. The implementation task body below shows the pattern for button.tsx. Tasks 2.2–2.6 use the same steps — only the file, variant surface, and brand classes change.

Task 2.1: Button brand tone

Files: Modify flow_backend/web/components/ui/button.tsx. Create flow_backend/web/__tests__/ui/button.brand.test.tsx.

  • Step 1: Read the current button.tsx

Record the current variant options, props interface, exported name(s), and the cn() utility import path. Every edit below preserves these.

  • Step 2: Write the failing brand-tone test

flow_backend/web/__tests__/ui/button.brand.test.tsx:

import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ToneProvider } from '@/components/ui/tone-context'
import { Button } from '@/components/ui/button'
 
describe('Button — brand tone', () => {
  it('renders neutral classes by default (no provider, no prop)', () => {
    render(<Button>Hi</Button>)
    const btn = screen.getByRole('button', { name: 'Hi' })
    expect(btn.className).not.toContain('rounded-button')
  })
 
  it('renders brand classes inside <ToneProvider tone="brand">', () => {
    render(<ToneProvider tone="brand"><Button>Hi</Button></ToneProvider>)
    const btn = screen.getByRole('button', { name: 'Hi' })
    expect(btn.className).toContain('rounded-button')
    expect(btn.className).toContain('bg-primary-brand')
  })
 
  it('explicit tone="neutral" wins over brand context', () => {
    render(<ToneProvider tone="brand"><Button tone="neutral">Hi</Button></ToneProvider>)
    expect(screen.getByRole('button').className).not.toContain('rounded-button')
  })
 
  it('explicit tone="brand" works without provider', () => {
    render(<Button tone="brand">Hi</Button>)
    expect(screen.getByRole('button').className).toContain('rounded-button')
  })
 
  it('brand variant="outline" uses brand coral border', () => {
    render(<Button tone="brand" variant="outline">Hi</Button>)
    expect(screen.getByRole('button').className).toContain('border-primary-brand')
  })
 
  it('forwards onClick and aria-disabled identically across tones', async () => {
    const onClick = vi.fn()
    render(<Button tone="brand" onClick={onClick} aria-disabled="true">Hi</Button>)
    screen.getByRole('button').click()
    expect(onClick).toHaveBeenCalledOnce()
  })
})

(Add import { vi } from 'vitest' at top.)

  • Step 3: Run the test, see it fail
npm test -- --run __tests__/ui/button.brand.test.tsx

Expected: failures around rounded-button not present + tone prop unknown.

  • Step 4: Edit button.tsx minimally

Add the import and the brand branch. The neutral branch is the existing JSX returned unchanged.

Sketch:

import { useTone, type Tone } from './tone-context'
 
// … existing Variant type unchanged …
 
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: Variant
  tone?: Tone
}
 
const brandStyles: Record<Variant, string> = {
  primary:
    'bg-primary-brand hover:bg-[image:var(--brand-gradient)] text-white rounded-button font-[800] px-6 py-3 text-[14px] tracking-[-0.01em] transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none',
  secondary:
    'bg-white border border-foreground/10 text-foreground rounded-button font-[700] px-6 py-3 hover:border-primary-brand transition-colors disabled:opacity-50',
  ghost:
    'bg-transparent text-foreground rounded-button font-[700] px-6 py-3 hover:bg-foreground/5 transition-colors disabled:opacity-50',
  outline:
    'bg-transparent border-2 border-primary-brand text-primary-brand rounded-button font-[700] px-6 py-3 hover:bg-primary-brand hover:text-white transition-colors disabled:opacity-50',
}
 
export function Button({ variant = 'primary', tone, className, ...props }: ButtonProps) {
  const t = useTone(tone)
  if (t === 'brand') {
    return <button className={cn(brandStyles[variant], className)} {...props} />
  }
  // --- neutral branch: identical to HEAD ---
  // (copy the original JSX here verbatim)
}

Match the real variant set to whatever button.tsx already exports (may be default/destructive/outline/secondary/ghost/link — if so, add a brand class for each, or fall back to the neutral branch for variants marketing doesn’t use: destructive, link).

  • Step 5: Re-run the test
npm test -- --run __tests__/ui/button.brand.test.tsx

Expected: 6 passed.

  • Step 6: Run the full Vitest suite
npm test -- --run

Expected: existing Button neutral-mode tests still pass. If a neutral test fails, the neutral branch drifted — fix by restoring the original JSX exactly.

  • Step 7: Dashboard regression
docker run --rm -v "$PWD":/work -w /work/flow_backend/web mcr.microsoft.com/playwright:v1.48.0-jammy \
  npx playwright test tests/visual/dashboard-regression.spec.ts

Expected: 10 passed. Dashboards use Button neutrally; zero diff expected.

  • Step 8: Commit
git add flow_backend/web/components/ui/button.tsx flow_backend/web/__tests__/ui/button.brand.test.tsx
git commit -m "feat(web/ui): add brand tone to Button primitive"

Task 2.2: Card brand tone

Files: Modify flow_backend/web/components/ui/card.tsx. Create flow_backend/web/__tests__/ui/card.brand.test.tsx.

Follow the same 8 steps as Task 2.1. Brand classes target bg-surface-warm-elev rounded-card border-0 shadow-md p-6 on the root element. The neutral branch is unchanged. The test asserts rounded-card + bg-surface-warm-elev on <ToneProvider tone="brand"> and zero presence in default neutral render.

Commit: feat(web/ui): add brand tone to Card primitive.

Task 2.3: Input brand tone

Files: Modify flow_backend/web/components/ui/input.tsx. Create flow_backend/web/__tests__/ui/input.brand.test.tsx.

Brand classes: rounded-button bg-surface-warm-elev border border-divider-light text-foreground px-4 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-brand focus-visible:border-primary-brand placeholder:text-text-tertiary transition-colors.

Test asserts: rounded-button present in brand, not in neutral; focus ring classes present. Preserve ALL existing props (placeholder, disabled, type, etc.) through spread.

Commit: feat(web/ui): add brand tone to Input primitive.

Task 2.4: Textarea brand tone

Same treatment as Input. Brand classes identical except adapted for multi-line (min-h-[120px] resize-y).

Commit: feat(web/ui): add brand tone to Textarea primitive.

Task 2.5: Select brand tone

Same treatment as Input. Brand classes: rounded-button bg-surface-warm-elev border border-divider-light text-foreground px-4 py-3 pr-10 focus-visible:ring-2 focus-visible:ring-primary-brand appearance-none bg-[url(...)] bg-no-repeat bg-[right_1rem_center] (keep existing chevron mechanism — do not re-invent).

Commit: feat(web/ui): add brand tone to Select primitive.

Task 2.6: Modal brand tone

Files: Modify flow_backend/web/components/ui/modal.tsx. Create flow_backend/web/__tests__/ui/modal.brand.test.tsx.

Brand classes on the panel: bg-surface-warm-elev rounded-card shadow-lg p-8 max-w-lg. Backdrop stays neutral (dim overlay is the same across tones). Preserve existing focus-trap, escape-key, and portal behavior — DO NOT re-implement. Tests verify the panel element carries rounded-card in brand mode and keeps existing ARIA (role="dialog", aria-modal="true").

Commit: feat(web/ui): add brand tone to Modal primitive.

Task 2.7: Phase 2 exit gate

  • Step 1: Full suite
npm run build
npm test -- --run
docker run --rm -v "$PWD":/work -w /work/flow_backend/web mcr.microsoft.com/playwright:v1.48.0-jammy \
  npx playwright test tests/visual/dashboard-regression.spec.ts

All green. The Vitest suite now has ~36 new brand-tone tests + the 4 tone-context tests. Dashboard visuals unchanged.

  • Step 2: Update changelog

Append to changelog under ## Changes:

### Phase 2 — primitives
 
- Button / Card / Input / Textarea / Select / Modal: added `tone?: Tone` prop and `brand` variant classes. Neutral branch is byte-identical to HEAD.
- 36 new brand-tone Vitest tests under `__tests__/ui/*.brand.test.tsx`.
- Dashboard visual regression still green — `tone="neutral"` default preserves admin/vendor/moderator pixels.
git commit -am "docs(web): log phase 2 — primitive brand tones"

Chunk 4: Phase 3 — Marketing component tree + Phase 4 imagery

Task 3.1: SvgDuotoneFilter

Files: Create flow_backend/web/components/marketing/SvgDuotoneFilter.tsx.

  • Step 1: Write the component
/**
 * Renders the brand-duotone SVG filter definition once per marketing tree.
 * Every <Image> inside <DarkBand> references it via CSS `filter: url(#brand-duotone)`.
 * Visually hidden (width 0, height 0) but present in the DOM.
 */
export function SvgDuotoneFilter() {
  return (
    <svg
      width="0"
      height="0"
      style={{ position: 'absolute' }}
      aria-hidden="true"
    >
      <defs>
        <filter id="brand-duotone" colorInterpolationFilters="sRGB">
          <feColorMatrix
            type="matrix"
            values="0.33 0.33 0.33 0 0
                    0.33 0.33 0.33 0 0
                    0.33 0.33 0.33 0 0
                    0    0    0    1 0"
          />
          <feComponentTransfer>
            <feFuncR tableValues="0.07 0.54 1.00" type="table" />
            <feFuncG tableValues="0.07 0.17 0.37" type="table" />
            <feFuncB tableValues="0.07 0.88 0.34" type="table" />
          </feComponentTransfer>
        </filter>
      </defs>
    </svg>
  )
}
  • Step 2: Smoke test via Vitest

flow_backend/web/__tests__/marketing/svg-duotone-filter.test.tsx:

import { render } from '@testing-library/react'
import { SvgDuotoneFilter } from '@/components/marketing/SvgDuotoneFilter'
 
test('mounts an svg with a brand-duotone filter def', () => {
  const { container } = render(<SvgDuotoneFilter />)
  expect(container.querySelector('filter#brand-duotone')).toBeTruthy()
  expect(container.querySelector('feColorMatrix')).toBeTruthy()
  expect(container.querySelector('feComponentTransfer')).toBeTruthy()
})

Run: npm test -- --run __tests__/marketing/svg-duotone-filter.test.tsx. Expected: pass.

  • Step 3: Commit
git add flow_backend/web/components/marketing/SvgDuotoneFilter.tsx flow_backend/web/__tests__/marketing/svg-duotone-filter.test.tsx
git commit -m "feat(web/marketing): add SvgDuotoneFilter"

Task 3.2: WarmSection

Files: Create flow_backend/web/components/marketing/WarmSection.tsx.

import type { ReactNode } from 'react'
 
interface Props {
  children: ReactNode
  className?: string
  as?: 'section' | 'div'
}
 
/**
 * Warm-page content section. Max-width 1200px, responsive horizontal padding,
 * vertical rhythm. Children receive the `.reveal` class automatically at the
 * parent level so ScrollRevealProvider picks them up; individual stagger is
 * handled inside components.
 */
export function WarmSection({ children, className = '', as: Tag = 'section' }: Props) {
  return (
    <Tag className={`w-full max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8 py-16 md:py-24 ${className}`}>
      {children}
    </Tag>
  )
}
  • Step 1: Render test

__tests__/marketing/warm-section.test.tsx:

import { render, screen } from '@testing-library/react'
import { WarmSection } from '@/components/marketing/WarmSection'
 
test('renders a <section> with max-width and padding', () => {
  render(<WarmSection><p data-testid="child">hi</p></WarmSection>)
  const child = screen.getByTestId('child')
  const section = child.closest('section')
  expect(section?.className).toContain('max-w-[1200px]')
  expect(section?.className).toContain('mx-auto')
})
 
test('honors the `as` prop to render a div', () => {
  const { container } = render(<WarmSection as="div">x</WarmSection>)
  expect(container.querySelector('div')).toBeTruthy()
  expect(container.querySelector('section')).toBeNull()
})
  • Step 2: Run + commit
npm test -- --run __tests__/marketing/warm-section.test.tsx
git add flow_backend/web/components/marketing/WarmSection.tsx flow_backend/web/__tests__/marketing/warm-section.test.tsx
git commit -m "feat(web/marketing): add WarmSection wrapper"

Task 3.3: DarkBand (abstract-fallback only, no images yet)

Files: Create flow_backend/web/components/marketing/DarkBand.tsx.

Phase 4 will add real imagery; for Phase 3 we ship with imagery="abstract" working end-to-end so the rest of the marketing tree can compose it without waiting on the image generation stop-ship risk.

  • Step 1: Write the component
'use client'
import { useState, type ReactNode } from 'react'
import Image from 'next/image'
 
type Imagery = 'hero-01' | 'hero-02' | 'hero-03' | 'hero-04' | 'abstract'
type Intensity = 'subtle' | 'editorial' | 'theatrical'
 
interface Props {
  imagery?: Imagery
  intensity?: Intensity
  rounded?: boolean
  children: ReactNode
  className?: string
}
 
// Imagery filenames + blur placeholders are wired in Phase 4.
const HERO_SRC: Record<Exclude<Imagery, 'abstract'>, string> = {
  'hero-01': '/brand/hero/hero-01.webp',
  'hero-02': '/brand/hero/hero-02.webp',
  'hero-03': '/brand/hero/hero-03.webp',
  'hero-04': '/brand/hero/hero-04.webp',
}
 
const INTENSITY_CLASS: Record<Intensity, string> = {
  subtle:      'min-h-[320px] md:min-h-[400px]',
  editorial:   'min-h-[480px] md:min-h-[600px]',
  theatrical:  'min-h-[640px] md:min-h-[760px]',
}
 
export function DarkBand({
  imagery = 'hero-01',
  intensity = 'editorial',
  rounded = false,
  children,
  className = '',
}: Props) {
  const [actual, setActual] = useState<Imagery>(imagery)
  const roundedClass = rounded ? 'rounded-card overflow-hidden' : ''
 
  return (
    <div
      className={`relative bg-ink-black text-text-on-dark ${INTENSITY_CLASS[intensity]} ${roundedClass} ${className}`}
    >
      {actual !== 'abstract' && (
        <Image
          src={HERO_SRC[actual]}
          alt=""
          fill
          priority={intensity === 'theatrical'}
          sizes="100vw"
          className="object-cover"
          style={{ filter: 'url(#brand-duotone)' }}
          onError={() => setActual('abstract')}
        />
      )}
      {actual === 'abstract' && <AbstractBackdrop />}
      <div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/20 to-black/60" aria-hidden="true" />
      <div className="relative z-10 flex flex-col items-center justify-center text-center px-6 py-16 md:py-24 min-h-inherit">
        {children}
      </div>
    </div>
  )
}
 
function AbstractBackdrop() {
  return (
    <div className="absolute inset-0 overflow-hidden" aria-hidden="true">
      <div
        className="absolute -top-1/3 -left-1/3 w-[80%] h-[80%] rounded-full opacity-40 blur-3xl animate-blob"
        style={{ background: 'radial-gradient(circle, #FF5E57 0%, transparent 70%)' }}
      />
      <div
        className="absolute -bottom-1/3 -right-1/3 w-[80%] h-[80%] rounded-full opacity-30 blur-3xl animate-blob animation-delay-2000"
        style={{ background: 'radial-gradient(circle, #8A2BE2 0%, transparent 70%)' }}
      />
    </div>
  )
}

animate-blob and animation-delay-2000 must already exist in the project (they’re referenced in spec §5.3). If not, define them in globals.css:

@keyframes blob {
  0%, 100% { transform: translate(0, 0) scale(1); }
  33%      { transform: translate(30px, -40px) scale(1.1); }
  66%      { transform: translate(-20px, 20px) scale(0.95); }
}
.animate-blob { animation: blob 18s ease-in-out infinite; }
.animation-delay-2000 { animation-delay: 2s; }
  • Step 2: Render test with abstract fallback

__tests__/marketing/dark-band.test.tsx:

import { render, screen } from '@testing-library/react'
import { DarkBand } from '@/components/marketing/DarkBand'
 
test('abstract imagery renders no <img>', () => {
  render(<DarkBand imagery="abstract"><h1>Title</h1></DarkBand>)
  expect(screen.queryByRole('img')).toBeNull()
  expect(screen.getByRole('heading')).toBeInTheDocument()
})
 
test('hero imagery mounts a next/image with duotone filter style', () => {
  render(<DarkBand imagery="hero-01"><h1>Title</h1></DarkBand>)
  const img = screen.getByRole('img', { hidden: true })
  expect(img.getAttribute('src')).toContain('/brand/hero/hero-01')
  expect((img as HTMLElement).style.filter).toContain('brand-duotone')
})
  • Step 3: Run + commit
npm test -- --run __tests__/marketing/dark-band.test.tsx
git add flow_backend/web/components/marketing/DarkBand.tsx flow_backend/web/__tests__/marketing/dark-band.test.tsx flow_backend/web/app/globals.css
git commit -m "feat(web/marketing): add DarkBand with abstract fallback + duotone filter"

Task 3.4: Nav

Files: Create flow_backend/web/components/marketing/Nav.tsx.

5 links (Italian): Flow (logo → /), Come funziona (/services), Prezzi (/upgrade), Contatti (/contact), Privacy (/privacy). Plus a primary <Button tone="brand" variant="primary">Scarica l'app</Button> on the right. Mobile breakpoint ≤ 640px collapses links into a hamburger → fixed-position sheet with translate animation.

Use components/ui/button.tsx tone="brand". Use next/link for navigation.

  • Step 1: Implement

(See spec §4.3 for behavior. ~130-line component.)

  • Step 2: Tests

__tests__/marketing/nav.test.tsx:

  • Renders all 5 links by text.

  • Mobile: hamburger button has aria-label="Apri menu", aria-expanded toggles.

  • Primary CTA renders inside a ToneProvider-brand button (assert rounded-button).

  • Nav is semantic <nav aria-label="Navigazione principale">.

  • Step 3: Commit

git commit -m "feat(web/marketing): add Nav with mobile sheet + brand CTA"

Files: Create flow_backend/web/components/marketing/Footer.tsx.

Dark band footer. 3 columns: brand (logo + tagline), product (Come funziona, Prezzi), legal (Privacy, Contatti). Social icons (Instagram, TikTok) as coral SVG buttons. Mandatory disclaimer: Flow è per maggiorenni · 18+ (brand voice §14).

  • Step 1: Implement — wraps content in <DarkBand imagery="abstract" intensity="subtle" rounded={false}>.

  • Step 2: Tests — asserts 18+ text is in DOM, all 4 nav links present, <footer> semantic element, role="contentinfo" or <footer> alone (semantic default).

  • Step 3: Commit

git commit -m "feat(web/marketing): add Footer with 18+ disclaimer + social + dark band"

Task 3.6: MarketingShell

Files: Create flow_backend/web/components/marketing/MarketingShell.tsx.

import type { ReactNode } from 'react'
import { ToneProvider } from '@/components/ui/tone-context'
import { ScrollRevealProvider } from '@/components/ScrollRevealProvider'
import { Nav } from './Nav'
import { Footer } from './Footer'
import { SvgDuotoneFilter } from './SvgDuotoneFilter'
 
/**
 * Top-level wrapper every marketing page mounts.
 * Provides brand tone, scroll reveals, warm canvas background, and the duotone filter def.
 *
 * Background is set on the component's root div — NOT on <body> — so dashboard
 * pages in the same app retain their background.
 *
 * Forces light color-scheme belt-and-braces: marketing is warm regardless of OS dark pref.
 */
export function MarketingShell({ children }: { children: ReactNode }) {
  return (
    <ToneProvider tone="brand">
      <ScrollRevealProvider>
        <div
          className="min-h-screen bg-surface-warm text-foreground"
          style={{ colorScheme: 'light' }}
        >
          <SvgDuotoneFilter />
          <Nav />
          <main>{children}</main>
          <Footer />
        </div>
      </ScrollRevealProvider>
    </ToneProvider>
  )
}

Verify @/components/ScrollRevealProvider import path matches the real export from flow_backend/web/components/ScrollRevealProvider.tsx.

  • Step 1: Test

__tests__/marketing/marketing-shell.test.tsx:

  • Renders children inside <main>.

  • Nav + Footer both present.

  • Root div has bg-surface-warm and inline color-scheme: light.

  • Only one filter#brand-duotone in the DOM (SvgDuotoneFilter mounted once).

  • Step 2: Commit

git commit -m "feat(web/marketing): add MarketingShell (ToneProvider + scroll + nav + footer)"

Task 3.7: AuthLanding

Files: Create flow_backend/web/components/marketing/AuthLanding.tsx.

import type { ReactNode } from 'react'
import { ToneProvider } from '@/components/ui/tone-context'
import { Card } from '@/components/ui/card'
import { SvgDuotoneFilter } from './SvgDuotoneFilter'
 
/**
 * Calm auth landing wrapper for /auth/confirmed and /auth/reset-password.
 * Centered Card on warm canvas, single coral radial glow top-right.
 * No Nav, no Footer, no DarkBand — deliberately minimal.
 */
export function AuthLanding({ children }: { children: ReactNode }) {
  return (
    <ToneProvider tone="brand">
      <div
        className="min-h-screen bg-surface-warm text-foreground flex items-center justify-center p-4 relative overflow-hidden"
        style={{ colorScheme: 'light' }}
      >
        <SvgDuotoneFilter />
        <div
          className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full opacity-20 blur-3xl pointer-events-none"
          style={{ background: 'radial-gradient(circle, #FF5E57 0%, transparent 70%)' }}
          aria-hidden="true"
        />
        <div className="relative z-10 w-full max-w-md">
          <Card tone="brand" className="p-8">
            {children}
          </Card>
        </div>
      </div>
    </ToneProvider>
  )
}
  • Step 1: Test — children inside Card tone=brand, single glow div, no nav/footer present.
  • Step 2: Commitfeat(web/marketing): add AuthLanding wrapper for email-link landings.

Task 3.8: Hero

Files: Create flow_backend/web/components/marketing/Hero.tsx.

Composed: <DarkBand imagery="hero-01" intensity="theatrical"> + H1 + sub + 2 CTAs + trust strip. Props accept H1 / sub / CTA labels + hrefs so landing can pass its exact copy.

  • Step 1: Implement — ~80 lines.
  • Step 2: Test — renders the H1 text, buttons, trust strip, inside a DarkBand.
  • Step 3: Commitfeat(web/marketing): add composed Hero component.

Task 3.9: Phase 3 exit gate

npm run build
npm test -- --run
docker run --rm -v "$PWD":/work -w /work/flow_backend/web mcr.microsoft.com/playwright:v1.48.0-jammy \
  npx playwright test tests/visual/dashboard-regression.spec.ts

All green. Update changelog + commit.

Task 4.1: Generate 4 hero images (time-boxed, 1 day)

Files: flow_backend/web/public/brand/hero/hero-0{1,2,3,4}.webp.

  • Step 1: Run Flux-dev / Midjourney with spec §5.1 prompts (manual).

  • Step 2: Export 1792×1024 WebP quality 85 into public/brand/hero/.

  • Step 3: Visually review for brand fit. If quality is unacceptable after 1 day, engage the stop-ship fallback: skip the commit, keep imagery="abstract" in all consumers, note in changelog. Proceed to Task 4.2 regardless (it’s a no-op if no images land — the script handles an empty directory).

  • Step 4: Commit (if quality OK)feat(web/marketing): add 4 brand hero WebP images.

Task 4.2: scripts/gen-blur-placeholders.ts

Files: Create flow_backend/web/scripts/gen-blur-placeholders.ts.

#!/usr/bin/env tsx
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve, join } from 'node:path'
import { getPlaiceholder } from 'plaiceholder'
 
const DIR = resolve(__dirname, '../public/brand/hero')
const OUT = join(DIR, 'blur.json')
 
async function main() {
  const files = readdirSync(DIR).filter(f => f.endsWith('.webp'))
  const map: Record<string, string> = {}
  for (const file of files) {
    const buf = readFileSync(join(DIR, file))
    const { base64 } = await getPlaiceholder(buf)
    map[file] = base64
  }
  writeFileSync(OUT, JSON.stringify(map, null, 2))
  console.log(`Wrote ${Object.keys(map).length} blur placeholders to ${OUT}`)
}
 
main().catch(e => { console.error(e); process.exit(1) })
  • Step 1: Run
npm run gen:blur

Expected: writes public/brand/hero/blur.json with 4 entries (or 0 if Task 4.1 stop-shipped).

  • Step 2: Wire into DarkBand — import blur.json, pass placeholder="blur" + blurDataURL={blurMap[filename]} on <Image>. Fall back gracefully if the map lacks a key.

  • Step 3: Commitfeat(web/marketing): add blur placeholder pipeline + wire DarkBand.


Chunk 5: Phase 5 — Pages (8 commits)

Each page gets its own commit. Every page test: renders inside the correct shell, core copy strings present, accessibility smoke via toHaveNoViolations (axe-playwright in Phase 7).

Task 5.1: Landing /

Files: Modify flow_backend/web/app/page.tsx.

  • Step 1: Read current page.tsx — preserve any metadata exports, revalidate config, and data fetching (if any).

  • Step 2: Define top-of-file COPY constant

const COPY = {
  hero: {
    h1Line1: 'Vivi il momento.',
    h1Line2: 'Trova il tuo Flow.',
    sub: 'Scopri la notte. Unisciti alla crew.',
    ctaPrimary: 'Entra nel Flow',
    ctaSecondary: 'Come funziona',
    trust: 'Beta gratuita · Nessuna carta · 12k+ in beta · 18+',
  },
  vibe: {
    title: 'La vibe di stasera',
    body: '…', // fill per spec §6 rewrite pass
  },
  // … other sections
} as const
  • Step 3: Replace body with <MarketingShell><Hero …/> <WarmSection>…</WarmSection> <DarkBand imagery="hero-02" intensity="editorial">…</DarkBand> …</MarketingShell>.

  • Step 4: Build + smoke-run dev at / — manually check layout on 375 / 768 / 1440.

  • Step 5: Commitfeat(web/pages): rebuild landing / with brand shell + hero + vibe strip.

Task 5.2: /about

Same pattern. MarketingShell + WarmSections + one DarkBand imagery="hero-03" intensity="editorial" for “our night” section. Copy tightening per spec §6.3.

Commit: feat(web/pages): rebuild /about with brand shell + dark-moment.

Task 5.3: /services

Heaviest copy rewrite. Multiple WarmSections, one abstract DarkBand CTA band.

Commit: feat(web/pages): rebuild /services with brand shell + copy rewrite.

Task 5.4: /contact

Form with <Input tone="brand">, <Textarea tone="brand">, <Button tone="brand" variant="primary">. Success/error microcopy in Italian per spec §8. Form submission wiring unchanged from current HEAD (keep the existing handler / action).

Commit: feat(web/pages): rebuild /contact with brand form primitives.

Task 5.5: /privacy

Layout only. Legal copy verbatim — exempt from rubric. Verify 18+ disclaimer present in footer.

Commit: feat(web/pages): rebuild /privacy layout with brand shell.

Task 5.6: /upgrade

Layout rebuild + copy pass. Use DarkBand imagery="abstract" for the CTA band (no hero image allocated to upgrade in v1).

Commit: feat(web/pages): rebuild /upgrade with brand shell + copy pass.

Task 5.7: /auth/confirmed

Wrap in <AuthLanding>. Status message in Italian. Single link back to mobile app or landing.

Commit: feat(web/pages): rebuild /auth/confirmed with AuthLanding wrapper.

Task 5.8: /auth/reset-password

Wrap in <AuthLanding>. Keep the existing Supabase Auth reset flow handler. Single <Input tone="brand"> for new password + <Button tone="brand" variant="primary">. Inline red error text below field mapped via the §8 Italian error code map. Success: switch to a success-state <Card tone="brand"> with CTA back to /.

Commit: feat(web/pages): rebuild /auth/reset-password with AuthLanding + Italian error map.

Task 5.9: app/error.tsx and app/not-found.tsx

Files: Create both.

Both render <AuthLanding>-style shell (inline minimal, not importing AuthLanding directly since these are root error boundaries and must not crash on import failure). Warm canvas, centered Card, 1-sentence Italian apology, coral CTA button back to /.

app/error.tsx:

'use client'
import Link from 'next/link'
 
export default function Error({ reset }: { error: Error; reset: () => void }) {
  return (
    <div className="min-h-screen bg-surface-warm text-foreground flex items-center justify-center p-4">
      <div className="max-w-md text-center">
        <h1 className="text-3xl font-[800] mb-4">Qualcosa è andato storto.</h1>
        <p className="text-text-secondary mb-8">Riprova tra poco.</p>
        <div className="flex gap-3 justify-center">
          <button onClick={reset} className="rounded-button bg-primary-brand text-white font-[700] px-6 py-3">Riprova</button>
          <Link href="/" className="rounded-button border-2 border-primary-brand text-primary-brand font-[700] px-6 py-3">Torna alla home</Link>
        </div>
      </div>
    </div>
  )
}

app/not-found.tsx — same shell, no reset button, message Questa pagina non esiste.

Commit: feat(web/pages): add brand-treated error.tsx and not-found.tsx.

Task 5.10: Phase 5 exit gate

npm run build
npm test -- --run
docker run --rm ... npx playwright test tests/visual/dashboard-regression.spec.ts

All green. Commit changelog update.


Chunk 6: Phase 6-9 — copy audit, verification, docs, PR

Task 6.1: Marketing copy audit artifact

Files: Create flow_docs/docs/design/2026-04-08-marketing-copy-audit.md.

  • Step 1: Extract every string from the 8 in-scope pages into a table:
| Page | Section | Element | Current copy (post-Phase-5) | Rubric result | Rewrite (if fail) |
|---|---|---|---|---|---|
| / | hero | H1 | Vivi il momento. Trova il tuo Flow. | PASS | — |
| / | hero | sub | Scopri la notte. Unisciti alla crew. | PASS | — |
| … | | | | | |
  • Step 2: Apply brand voice §9.2 rubric — 4 checks (experiential framing, verb-first CTAs, ≤ 8 words for display copy, no marketing clichés).

  • Step 3: Rewrite failures in place in the page files. Recommit each page with refactor(web/pages): rubric-aligned copy rewrite for <page> if any rewrites land.

  • Step 4: Commit the audit artifact itself

git add flow_docs/docs/design/2026-04-08-marketing-copy-audit.md
git commit -m "docs(web): marketing copy audit table for brand voice rubric"

Task 7.1: Marketing visual regression

Files: Create flow_backend/web/tests/visual/marketing.spec.ts.

import { test, expect } from '@playwright/test'
 
const ROUTES = [
  '/', '/about', '/services', '/contact', '/privacy', '/upgrade',
  '/auth/confirmed', '/auth/reset-password',
]
const VIEWPORTS = [
  { name: 'mobile',  width: 375,  height: 800 },
  { name: 'tablet',  width: 768,  height: 1024 },
  { name: 'desktop', width: 1440, height: 900 },
]
 
for (const route of ROUTES) {
  for (const vp of VIEWPORTS) {
    test(`visual ${route} @ ${vp.name}`, async ({ page }) => {
      await page.setViewportSize({ width: vp.width, height: vp.height })
      await page.goto(route)
      await page.waitForLoadState('networkidle')
      await expect(page).toHaveScreenshot(`${route.replace(/\//g, '_') || 'root'}-${vp.name}.png`, {
        fullPage: true,
        maxDiffPixelRatio: 0.002,
      })
    })
  }
}
  • Step 1: Generate baselines on Linux
docker run --rm -v "$PWD":/work -w /work/flow_backend/web mcr.microsoft.com/playwright:v1.48.0-jammy \
  npx playwright test tests/visual/marketing.spec.ts --update-snapshots

Expected: 24 baseline PNGs.

  • Step 2: Re-run clean
docker run ... npx playwright test tests/visual/marketing.spec.ts

Expected: 24 passed.

  • Step 3: Commit
git add flow_backend/web/tests/visual/marketing.spec.ts flow_backend/web/tests/visual/marketing.spec.ts-snapshots/
git commit -m "test(web): marketing visual regression baselines (8 pages × 3 viewports)"

Task 7.2: Accessibility tests

Files: Create flow_backend/web/tests/a11y/marketing.spec.ts.

import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
 
const ROUTES = ['/', '/about', '/services', '/contact', '/privacy', '/upgrade', '/auth/confirmed', '/auth/reset-password']
 
for (const route of ROUTES) {
  test(`a11y: ${route}`, async ({ page }) => {
    await page.goto(route)
    await page.waitForLoadState('networkidle')
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .analyze()
    expect(results.violations).toEqual([])
  })
}
  • Step 1: Run
docker run ... npx playwright test tests/a11y/marketing.spec.ts

Expected: 8 passed, zero violations. Fix any issues in-page (contrast, missing labels, landmark regions) before proceeding.

  • Step 2: Commit
git commit -m "test(web): a11y tests for 8 marketing pages — zero axe violations"

Task 7.3: Lighthouse CI

Files: Create flow_backend/web/lighthouserc.json.

{
  "ci": {
    "collect": {
      "url": ["http://localhost:3000/"],
      "startServerCommand": "npm run build && npm run start",
      "startServerReadyPattern": "Ready in",
      "numberOfRuns": 3,
      "settings": {
        "preset": "desktop",
        "throttlingMethod": "simulate",
        "throttling": {
          "rttMs": 150,
          "throughputKbps": 1638.4,
          "cpuSlowdownMultiplier": 4
        }
      }
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift":  ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time":      ["error", { "maxNumericValue": 200 }],
        "total-byte-weight":        ["error", { "maxNumericValue": 819200 }]
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}
  • Step 1: Run locally
npm run lhci

Expected: pass. If any assertion fails (LCP too high, CLS, TBT, weight), diagnose via the Lighthouse report link. Common fixes: confirm hero priority + blur placeholder, verify no layout-shift from the hero section, ensure fonts load with display: 'swap'.

  • Step 2: Commit
git add flow_backend/web/lighthouserc.json
git commit -m "test(web): lighthouse CI config — landing page perf budget"

Task 7.4: Full verification sweep

  • Step 1: Run every gate
cd flow_backend/web
npm run lint
npm run build
npm test -- --run
npm run check:tokens
docker run ... npx playwright test tests/visual
docker run ... npx playwright test tests/a11y
npm run lhci

All green.

  • Step 2: Review dashboard visual diffs one more time

If any dashboard snapshot differs from Phase 1 baseline, investigate root cause. Acceptable diffs: re-baselined intentional changes from Phase 1 (--foreground, font). Unacceptable: everything else.

Task 8.1: Brand voice doc rewrite

Files: Modify .claude/brand-voice-guidelines.md.

  • Step 1: Find §8.219
grep -n "8.219\|Syne" .claude/brand-voice-guidelines.md
  • Step 2: Replace the paragraph with spec §1.2 canonical phrasing:

Flow uses Inter across every surface — mobile app, web portal, marketing pages, auth flows. Platform divergence comes from layout density and surface temperature, not from typography. Mobile trades in compact dark surfaces because hands-held nightlife context demands glanceability; web trades in warmer, airier layouts because lean-back browsing tolerates more breathing room. Both are the same brand.

  • Step 3: Grep for any lingering “Syne” argument elsewhere in the doc and either delete or rewrite to be historical context only.
grep -n "Syne" .claude/brand-voice-guidelines.md

Expected after fix: zero hits, or only hits in a “historical decisions” section marked as superseded.

  • Step 4: Commit
git add .claude/brand-voice-guidelines.md
git commit -m "docs: update brand voice §8.219 — Inter across all surfaces"

Task 8.2: Finalize changelog + JSDoc

  • Step 1: Complete the changelog ## Changes section with all phases, list visual diffs acknowledged, lighthouse scores achieved, a11y pass, 18+ disclaimer verified.

  • Step 2: Add JSDoc to each touched primitive (Button, Card, Input, Textarea, Select, Modal). Example for Button:

/**
 * Shared Button primitive.
 *
 * @example
 * // Dashboard default (neutral)
 * <Button variant="primary">Save</Button>
 *
 * @example
 * // Marketing opt-in via provider
 * <ToneProvider tone="brand">
 *   <Button variant="primary">Entra nel Flow</Button>
 * </ToneProvider>
 *
 * @example
 * // Per-instance override
 * <Button tone="brand" variant="outline">Scopri</Button>
 */
  • Step 3: Commit
git commit -am "docs(web): finalize brand parity changelog + JSDoc on touched primitives"

Task 9.1: Open the pull request

  • Step 1: Push the branch
git push -u origin feature/web-brand-parity-v1
  • Step 2: Open PR via gh pr create:

Title: Web brand parity v1 — marketing + auth landings

Body (HEREDOC):

## Summary
- Additive token + tone-prop architecture brings web marketing to brand parity with mobile without touching dashboards
- 6 shared primitives gain `tone?: Tone` (default neutral); dashboards stay pixel-stable (10-route visual regression guard)
- 8 pages rebuilt inside a new `components/marketing/*` tree with dark-moment imagery + warm sections + Italian copy rewrite

## Test plan
- [x] Vitest: 40+ new brand-tone + context tests, all green
- [x] Token parity script green against mobile `app_theme.dart`
- [x] Playwright dashboard regression green on 10 routes (Linux baselines)
- [x] Playwright marketing visual baselines captured on 8 pages × 3 viewports
- [x] axe-core zero violations on every in-scope page
- [x] Lighthouse CI green on `/` (LCP < 2.5s, CLS < 0.1, TBT < 200ms, < 800 KB)
- [x] Manual review of re-baselined dashboard snapshots after font + foreground shift

## Screenshots
See `tests/visual/marketing.spec.ts-snapshots/` for the captured baselines.
  • Step 3: Self-review the PR diff

Click through the file tree. Confirm zero touches to admin/, vendor/, moderator/, api/, auth/callback/, auth/login/.


Completion checklist

  • Phase 0: pre-flight audit, allow-list, token parity script, dashboard baselines committed
  • Phase 1: tokens + fonts + tone context, dashboard regression still green
  • Phase 2: 6 primitives × brand tone + tests, 36 new brand tests green
  • Phase 3: 8 marketing components created + tests
  • Phase 4: 4 hero images OR abstract-fallback decision documented
  • Phase 5: 8 pages rebuilt + error.tsx + not-found.tsx
  • Phase 6: marketing copy audit table committed
  • Phase 7: marketing visual baselines + a11y + lighthouse all green
  • Phase 8: brand voice doc rewritten, changelog finalized, JSDoc added
  • Phase 9: PR opened, self-reviewed, ready for human review

Open questions forwarded from spec §12

These are NOT blockers for this plan but should be resolved during implementation:

  1. Is 4 hero images the right starting point, or is v1.1 already scheduled with more slots?
  2. Token parity script in pre-commit vs CI-only?
  3. /upgrade copy scope — pricing vs admin-upgrade prompt?
  4. Is the 10-route dashboard regression list the highest-traffic / highest-sensitivity selection?
  5. Any missing marketing routes (/press, /jobs, /terms)?

Surface each to the human at the start of the relevant phase.