Web Brand Parity — Design Spec

  • Date: 2026-04-08
  • Owner: @elia
  • Status: Draft v3 — post spec review iteration 2 (ready for user review)
  • Scope: flow_backend/web/ — shared primitives in components/ui/* (6 files touched), a net-new components/marketing/* composed-component tree (8 files), marketing pages (/, /about, /services, /contact, /privacy, /upgrade), and the 2 user-visible auth landing pages (/auth/confirmed, /auth/reset-password)
  • Out of scope: admin / vendor / moderator dashboards (stay functional per brand voice guidelines §9.3), /admin/login (admin-only, lives inside dashboard scope), /auth/callback (route handler, no UI), web i18n infrastructure, mobile app changes, Supabase transactional email templates
  • Related files (filesystem, non-docs): .claude/brand-voice-guidelines.md, flow_mobile/lib/core/theme/app_theme.dart, flow_backend/web/app/globals.css, flow_backend/web/CLAUDE.md

1. Goal

Make the Flow web portal’s marketing surfaces and user auth landing pages feel like the same brand as the Flow Flutter mobile app — “perfect to the last drop” — without regressing the functional dashboards that share the same components/ui/ tree.

Critical context: the web app is primarily an admin portal (admin / vendor / moderator dashboards). Marketing and user auth landings are a minority surface layered on top. This shapes the architecture: brand work must be additive and isolated; any change to shared primitives or tokens must leave dashboards pixel-stable.

1.1 Success criteria (measurable)

  1. Token diff: a token-comparison script (scripts/check-token-parity.ts) exits 0 — every color, radius, spacing, and font token in the new globals.css has a 1:1 match in flow_mobile/lib/core/theme/app_theme.dart, modulo a documented allow-list of web-only tokens (divider, shadow, error).
  2. Voice rubric: every string in app/{page,about,services,contact,privacy,upgrade,auth/confirmed,auth/reset-password}/page.tsx clears the §9.2 rubric (checked by a manual audit table committed as an artifact).
  3. Dashboard pixel-stability: Playwright visual regression tests on 10 representative dashboard pages (top routes per role — listed in §9.1) produce zero snapshot diffs after all changes land.
  4. Accessibility: @axe-core/playwright reports zero violations on every marketing + auth landing page.
  5. Performance: Lighthouse CI (configured at lighthouserc.json, run in GitHub Actions on ubuntu-latest, 4G Slow profile) reports LCP < 2.5s, CLS < 0.1, TBT < 200ms on the landing page (/).
  6. Brand voice doc updated: §8.219 of .claude/brand-voice-guidelines.md is rewritten to the canonical phrasing below, and no other paragraph in the doc still argues Syne is a web-specific choice.
  7. Imagery signature moment: the landing page hero displays at least one AI-generated image with duotone filter applied, AND the “tonight’s vibe” strip section exists on the landing page. (“People would screenshot it” is dropped as subjective.)

1.2 Canonical phrasing for brand voice §8.219 rewrite

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.

2. Background

2.1 Current state (verified 2026-04-08)

Actual repo structure:

flow_backend/web/
├── app/
│   ├── page.tsx                    # Landing
│   ├── about/page.tsx              # Marketing
│   ├── services/page.tsx           # Marketing
│   ├── contact/page.tsx            # Marketing
│   ├── privacy/page.tsx            # Marketing
│   ├── upgrade/page.tsx            # Marketing (pricing / upgrade CTA)
│   ├── auth/
│   │   ├── callback/route.ts       # OAuth callback, no UI — out of scope
│   │   ├── confirmed/page.tsx      # Email confirmation landing (in scope)
│   │   └── reset-password/page.tsx # Password reset landing (in scope)
│   ├── admin/login/page.tsx        # Admin-only login — out of scope (§9.3 dashboards)
│   ├── admin/(dashboard)/          # Out of scope
│   ├── vendor/(dashboard)/         # Out of scope
│   ├── moderator/(dashboard)/      # Out of scope
│   ├── api/                        # Out of scope
│   ├── globals.css                 # Current token file (Tailwind v4 @theme inline)
│   ├── layout.tsx                  # Root layout — loads fonts
│   ├── robots.ts, sitemap.ts       # Infra, untouched
│   └── favicon.ico
├── components/ui/
│   ├── button.tsx                  # Primitive — touch
│   ├── card.tsx                    # Primitive — touch
│   ├── empty-state.tsx             # Primitive — leave neutral-only for dashboards
│   ├── ImageUploadCrop.tsx         # Dashboard-only — leave untouched
│   ├── input.tsx                   # Primitive — touch
│   ├── loader.tsx                  # Primitive — leave neutral-only
│   ├── modal.tsx                   # Primitive — touch (acts as Dialog in brand tone)
│   ├── select.tsx                  # Primitive — touch
│   ├── table.tsx                   # Dashboard-only — leave untouched
│   └── textarea.tsx                # Primitive — touch
└── components/                     # Higher-level components tree

Key observations:

  • There is no (marketing) route group — marketing pages are flat under app/. Creating a group would touch imports across every current marketing file and isn’t worth the churn. Instead, marketing pages each render a <MarketingShell> wrapper component explicitly.
  • There is no user-facing login/signup on web. User auth happens in the mobile app. Web auth is limited to the 2 landing pages after email links (confirm + reset). This dramatically shrinks auth scope from “4 pages with forms” to “2 pages with status + one form field.”
  • components/ui/modal.tsx plays the Dialog role. We will not create a new dialog.tsx.
  • components/ui/ has 10 files total. The v1 draft of this spec incorrectly listed 12 primitives assuming Chip, Badge, Label, Tooltip, Link, Avatar existed. None of them do. Decision: do not create these as new primitives. Instead, marketing pages that need chip/badge affordances use inline JSX with token-referenced classes; we don’t pay the composition tax for pages that each have 1-2 chip instances.

Current globals.css uses Tailwind v4 @theme inline syntax. This matters because it generates utilities like bg-primary-brand, text-foreground, rounded-lg, not arbitrary-value bg-[--primary-brand]. The spec’s §4.1 must align with this convention.

Current dashboard token usage (from Grep, must be preserved):

TokenUsed by dashboards?Action
--foreground: #374151Yes (body text via text-foreground)Alias — keep the variable name, change value to #18181B zinc-900. Dashboards get slightly darker body text — documented as an intentional global improvement, not a regression.
--background: #ffffffYesKeep unchanged
--primary-brand: #FF5E57YesKeep unchanged
--primary-brand-hover: #FF4757YesKeep unchanged
--primary-brand-dark: #D93636YesKeep unchanged
--primary-brand-foreground: #FFFFFFYesKeep unchanged
--action-blue: #1E90FFYes (admin chips)Keep unchanged
--surface-carbon: #121212YesKeep + add alias --ink-black pointing to same value
--surface-highest: #2C2C2CYesKeep unchanged
--success-green: #2ED573YesKeep (matches mobile mintGreen)
--warning-yellow: #FFA502YesKeep (matches mobile warmSun)
--card-bg: #F5F5F7Yes (dashboards)Keep unchanged — v1 said remove; removing would break dashboard cards. New brand cards use --surface-warm-elev: #FFFFFF directly.
--moderator-accent: #8B5CF6Yes (moderator dashboard)Keep unchanged
--warning-orange: #F97316Yes (flagged content)Keep unchanged
--electric-violet: #8A2BE2PartiallyKeep unchanged
--warm-canvas: #F0EEE9Not yetKeep, promote to marketing default background
--surface-dark: #1E1E1EPartiallyKeep unchanged
--font-display (Syne)Yes (.font-display utility in JSX)Remove variable + utility. Currently loaded in app/layout.tsx via Syne({ variable: '--font-display' }). Verified at audit time: used only on marketing headings. Marketing headings switch to font-sans with Inter-800.
--font-sans (DM Sans)Yes — every dashboard bodyIntentional global font swap: DM Sans → Inter. Currently loaded via DM_Sans({ variable: '--font-sans' }). Every dashboard page renders in DM Sans today. After this branch, every dashboard page renders in Inter. This is a visible change to dashboards that matches the “Inter everywhere” brand decision from brainstorming Q1. It will bust every dashboard snapshot baseline in Phase 1 — snapshots must be re-baselined after the font swap and visually reviewed for legibility regressions. Justified because: (1) Inter is designed for UI density and reads better than DM Sans in data-dense dashboards, (2) typography parity is a brand goal, (3) the swap is additive at the token level (same variable name).
--font-mono (DM Mono)Yes (code blocks)Keep unchanged

2.2 Decisions locked during brainstorming (2026-04-08)

#DecisionSource
Q1Direction: A/B hybrid. Drop Syne. Inter across both platforms.user: “A/B, but dont keep syne”
Q2Scope: shared UI primitives + marketing + user auth landings. Dashboards untouched.user: “shared component library + marketing + auth”
Q3Landing personality: B/C hybrid — mobile component DNA with retained web theatrical energy.user: “ye” to hybrid
Q4Warm-first surface with dark moments as punctuation.user: “warm always. be smart.”
Q5Depth D — tokens + components + copy audit + signature visual moments + imagery direction.user: “D”
αtone: 'neutral' | 'brand' prop on primitives, ToneProvider context, dashboards stay neutral by default.user: “approach α”
2+3AI-generated imagery (Flux/Midjourney) + SVG duotone filter for consistency + CSS/SVG abstract fallback. Zero licensing cost.user: “Option 2 + Option 3 hybrid”
v2-aImagery count: 4 slots, not 10 (answers §12 Q2 from v1). Start minimal; expand in v1.1 if more dark bands appear.v2 revision
v2-bRuntime SVG duotone filter, not baked into WebP (answers §12 Q3 from v1). Flexibility wins over marginal perf.v2 revision
v2-cDark mode on marketing: force warm (light) regardless of OS preference. Marketing + auth landings render in warm canvas only. Dashboards continue to respect @variant dark.v2 revision
v2-di18n: Italian hardcoded in JSX. No next-intl introduction in this branch. Strings are organized as top-of-file constants per page for future extraction.v2 revision
v2-ePer-page <MarketingShell> wrapper, no (marketing) route group refactor.v2 revision
v2-fNo Dialog primitive. modal.tsx is the Dialog. Chip, Badge, Label, Tooltip, Link, Avatar are NOT created as new primitives — marketing uses inline JSX with token classes.v2 revision
v2-gFont loading: next/font/google, Inter weights 500/600/700/800/900.v2 revision

3. Non-goals

  • Mobile app changes
  • Dashboard redesign (admin / vendor / moderator)
  • /admin/login brand treatment
  • /auth/callback UI (there is none — it’s a route handler)
  • Net-new primitives (Chip/Badge/Label/Tooltip/Link/Avatar) — inline JSX is the answer for marketing
  • next-intl or i18n pipeline
  • Supabase Auth transactional email template rebranding (separate work)
  • Paid photography or illustration
  • New marketing routes, blog, help center, pricing tiers (beyond existing /upgrade)
  • Backend / Supabase changes

4. Architecture

4.1 Token foundation (app/globals.css)

Tailwind v4 @theme inline reality: the current file generates utilities from --color-* mappings. Our new tokens must be added to the @theme inline block to be usable as Tailwind classes (bg-surface-warm, text-text-primary, etc.). Raw CSS variables work too, but the convention is utility classes.

Changes to :root — add new, modify two existing, keep all others:

:root {
  /* --- EXISTING, kept as-is (see §2.1 audit table) --- */
  --background: #ffffff;
  --primary-brand: #FF5E57;
  --primary-brand-hover: #FF4757;
  --primary-brand-dark: #D93636;
  --primary-brand-foreground: #FFFFFF;
  --action-blue: #1E90FF;
  --surface-carbon: #121212;
  --surface-highest: #2C2C2C;
  --success-green: #2ED573;
  --warning-yellow: #FFA502;
  --card-bg: #F5F5F7;
  --moderator-accent: #8B5CF6;
  --warning-orange: #F97316;
  --electric-violet: #8A2BE2;
  --warm-canvas: #F0EEE9;
  --surface-dark: #1E1E1E;
 
  /* --- MODIFIED --- */
  --foreground: #18181B;          /* was #374151 — now matches mobile textPrimary (zinc-900) */
  /* --font-display removed entirely */
 
  /* --- NEW: mobile parity tokens --- */
  --ink-black: #121212;            /* alias for --surface-carbon, mobile naming parity */
  --text-secondary: #52525B;       /* mobile textSecondary (zinc-600) */
  --text-tertiary: #A1A1AA;        /* mobile textTertiary (zinc-400) */
  --text-on-dark: #FAFAFA;
  --text-on-dark-secondary: #D4D4D8;
  --divider-light: #E4E4E7;        /* mobile dividerLight */
  --divider-dark: #27272A;         /* mobile dividerDark */
  --error: #EF4444;                /* mobile error red */
 
  /* --- NEW: brand-only surfaces for marketing --- */
  --surface-warm: #F0EEE9;         /* alias for --warm-canvas, clarity in brand contexts */
  --surface-warm-elev: #FFFFFF;
  --surface-dark-elev: #1E1E1E;    /* alias for --surface-dark */
 
  /* --- NEW: brand gradient, single source --- */
  --brand-gradient: linear-gradient(135deg, #FF5E57 0%, #8A2BE2 100%);
 
  /* --- NEW: spatial scale (8pt, 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;
 
  /* --- NEW: radius scale (mobile parity), brand-scoped to avoid Tailwind default collisions ---
     We intentionally do NOT reuse the names --radius-sm/md/lg/xl that Tailwind v4 uses
     internally for `rounded-sm/md/lg/xl` utilities. These are brand-scoped aliases
     consumed ONLY by `rounded-button` / `rounded-card` / explicit `rounded-[var(--brand-radius-*)]`
     opt-ins. Dashboards keep their current Tailwind default radii untouched. */
  --brand-radius-xs: 4px;
  --brand-radius-sm: 8px;
  --brand-radius-chip: 12px;
  --brand-radius-button: 14px;      /* mobile button radius */
  --brand-radius-card: 20px;        /* mobile card radius */
  --brand-radius-pill: 28px;
  --brand-radius-full: 9999px;
 
  /* --- NEW: 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);
}

Changes to @theme inline — add to expose new tokens as Tailwind utilities:

@theme inline {
  /* ... existing entries kept ... */
 
  /* 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-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);
 
  /* Radius utilities — brand-scoped names only. Dashboards keep Tailwind default
     `rounded-sm/md/lg/xl` untouched. New utilities generated here: `rounded-button`,
     `rounded-card`, `rounded-chip`, `rounded-pill`, `rounded-brand-full`. */
  --radius-button: var(--brand-radius-button); /* 14px → utility `rounded-button` */
  --radius-card:   var(--brand-radius-card);   /* 20px → utility `rounded-card`   */
  --radius-chip:   var(--brand-radius-chip);   /* 12px → utility `rounded-chip`   */
  --radius-pill:   var(--brand-radius-pill);   /* 28px → utility `rounded-pill`   */
 
  /* Remove --font-display entry */
  /* --font-display: var(--font-display);  DELETED */
}

Critical safety decision on radius remapping: v1 said “rounded-lg (now 14px)“. This would silently change every dashboard rounded-lg from Tailwind’s default 8px to 14px. Rejected. Instead, all brand radius tokens use the --brand-radius-* prefix in :root, and the @theme inline block exposes them only as NEW utility names (rounded-button, rounded-card, rounded-chip, rounded-pill). Dashboards keep Tailwind default rounded-sm/md/lg/xl untouched. There is no token name collision possible because the :root variables are brand-scoped.

Font loading (app/layout.tsx) — use next/font/google:

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

Replace any existing Syne/DM Sans loading. globals.css --font-sans already points to the Inter variable. No JSX changes needed in dashboards.

.font-display utility removal: grep for all font-display className usages. Replace with font-sans font-[800] (brand mapping). Delete the .font-display CSS rule.

4.2 Component library — tone prop on 6 primitives

Primitives to touch (6 total):

#FileCurrent roleBrand tone changes
1button.tsxDashboard + marketing CTAsAdds primary/secondary/ghost/outline brand variants, rounded-button (14px), Inter 800, active scale
2card.tsxDashboard + marketing sectionsBrand tone: bg-surface-warm-elev, rounded-card (20px), no border, shadow-md
3input.tsxDashboard forms + contact form + reset-password formBrand tone: rounded-button, coral focus ring via :focus-visible, --foreground text
4textarea.tsxDashboard + contact formSame treatment as input
5select.tsxDashboard + marketing filtersSame treatment as input
6modal.tsxDashboard modals + marketing dialogsBrand tone: warm-elev panel, rounded-card, shadow-lg

Primitives explicitly skipped (4 total): empty-state.tsx, loader.tsx, ImageUploadCrop.tsx, table.tsx. Dashboard-only, not used by marketing or auth landings, left untouched.

components/ui/tone-context.tsx (new file, ~20 lines):

'use client'
import { createContext, useContext, type ReactNode } from 'react'
 
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>
}
 
export function useTone(override?: Tone): Tone {
  const ctx = useContext(ToneContext)
  return override ?? ctx
}

Per-primitive implementation pattern (example — button.tsx):

import { useTone, type Tone } from './tone-context'
 
type Variant = 'primary' | 'secondary' | 'ghost' | 'outline'
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: Variant
  tone?: Tone
}
 
const neutralStyles: Record<Variant, string> = {
  /* current classes, unchanged from HEAD */
}
 
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]',
  secondary: 'bg-white border border-foreground/10 text-foreground rounded-button font-[700] px-6 py-3 hover:border-primary-brand',
  ghost:     'bg-transparent text-foreground rounded-button font-[700] px-6 py-3 hover:bg-foreground/5',
  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',
}
 
export function Button({ variant = 'primary', tone, className, ...props }: Props) {
  const t = useTone(tone)
  const styles = t === 'brand' ? brandStyles[variant] : neutralStyles[variant]
  return <button className={cn(styles, className)} {...props} />
}

Why only 7: the v1 list of 12 included 6 primitives that don’t exist (Chip, Badge, Label, Dialog, Tooltip, Link, Avatar). Creating 6 net-new primitives for pages that each have 1-2 instances of chip/badge affordances is a composition tax that doesn’t pay off. Marketing pages use inline JSX with token classes (e.g., <span className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary-brand/10 text-primary-brand text-sm font-semibold">…</span> for a chip). When we have 3+ reuses of the same pattern, we promote to a primitive in v1.1.

4.3 Layout & composed components (components/marketing/*, new tree)

Files to create:

  • components/marketing/MarketingShell.tsx — wraps every marketing page. Provides <ToneProvider tone="brand">, renders <Nav> + <main> + <Footer>, sets a top-level <div> background to --surface-warm (not on <body> to preserve dashboard bg), reuses the existing <ScrollRevealProvider> from components/ScrollRevealProvider.tsx (do not mount a second IntersectionObserver), renders <SvgDuotoneFilter /> once. Named “Shell” not “Layout” to avoid confusion with Next.js layout.tsx convention. Each marketing page imports this component explicitly inside its default export — no (marketing) route group.
  • components/marketing/AuthLanding.tsx — wraps /auth/confirmed and /auth/reset-password. ToneProvider tone="brand", small centered logo, warm canvas bg, single centered <Card tone="brand"> holding the page’s content, one subtle coral radial glow top-right (CSS only, no image).
  • components/marketing/Nav.tsx — warm canvas bg, backdrop-blur on scroll, logo left, 5 links (Flow / Come funziona / Prezzi / Contatti / Privacy), <Button tone="brand" variant="primary"> right. Mobile: hamburger → full-bleed sheet (implemented as a fixed-position <div> with translate animation, not a new primitive).
  • components/marketing/Footer.tsx — dark band. 3 columns (brand + tagline / product links / legal). Coral social icons. Mandatory Flow è per maggiorenni · 18+ per brand doc §14.
  • components/marketing/DarkBand.tsx — the workhorse. Props: imagery?: 'hero-01'|'hero-02'|'hero-03'|'hero-04'|'abstract', intensity?: 'subtle'|'editorial'|'theatrical', rounded?: boolean. Internal: absolute <Image fill priority placeholder="blur">, gradient overlay, duotone filter applied via CSS filter: url(#brand-duotone), content layer with .reveal + stagger. Falls back to imagery="abstract" on image error.
  • components/marketing/WarmSection.tsx — default warm-page section wrapper. Max-width 1200px, responsive horizontal padding, vertical rhythm, auto-applies .reveal to direct children.
  • components/marketing/Hero.tsx — composed: <DarkBand imagery="hero-01" intensity="theatrical"> + H1 + sub + 2 buttons + trust strip.
  • components/marketing/SvgDuotoneFilter.tsx — renders the <svg><filter id="brand-duotone"> block, mounted once per marketing layout.

Responsive breakpoints (making v1 ambiguity explicit):

  • Mobile: <= 640px
  • Tablet: 641-1023px
  • Desktop: >= 1024px

Hero headline sizes: 28px mobile / 36px tablet / 48px desktop, Inter 900, tracking-tight, centered.

Hero CTAs use rounded-button (14px), not pills. Theatricality = size + animated entrance + dark-band context, not corner radius.

4.4 Dark-moment recipe (4 slots)

SlotLocationImageryIntensity
1Landing herohero-01theatrical
2Landing “tonight’s vibe” striphero-02editorial
3About “our night” sectionhero-03editorial
4Footerabstractsubtle

Services and /upgrade use imagery="abstract" CTA bands. hero-04 is generated as a spare for /upgrade hero if we want it later.

5. Imagery pipeline

5.1 Generation (one-off, 4 images)

Directory: public/brand/hero/ — committed to git (~4 WebP files, ~1 MB total at quality 85).

Prompt template:

editorial photo, 35mm film grain, italian underground venue,
coral and violet practical lighting, mid-distance silhouettes,
no visible faces, shot at midnight, dutch angle, cinematic depth of field,
{scene_modifier},
high contrast, dark background, desaturated midtones

Scenes:

  1. hero-01 — crowd raising hands, smoke machine haze, backlit
  2. hero-02 — vinyl close-up on turntable, hands reaching
  3. hero-03 — empty dancefloor before the crowd, neon exit sign
  4. hero-04 — (spare) bar taps with liquid motion, coral pendant lights

Tool: Flux-dev on HuggingFace Spaces (free) or Midjourney if user has sub. Output 1792×1024 WebP quality 85.

Blur placeholder pipeline: use plaiceholder (npm) at build time via a one-shot script scripts/gen-blur-placeholders.ts that reads public/brand/hero/*.webp and writes public/brand/hero/blur.json (filename → base64). DarkBand imports this JSON statically. No build-step sharp dependency beyond what plaiceholder already pulls.

Stop-ship trigger: if imagery quality from Flux-dev is unacceptable after 1 day of iteration (subjective, owner’s call), ship marketing with imagery="abstract" everywhere and defer photography to v1.1. This prevents the brand work from blocking on a generation issue.

5.2 SVG duotone filter (runtime)

components/marketing/SvgDuotoneFilter.tsx renders a hidden <svg> with the filter definition. Applied via CSS filter: url(#brand-duotone) on every <Image> inside <DarkBand>.

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>
  )
}

5.3 Abstract fallback (CSS only)

DarkBand with imagery="abstract" renders two animated blobs on a dark background using the existing animate-blob keyframe and the brand colors via radial gradients. No image loaded.

6. Copy audit & voice enforcement

6.1 Process

  1. Extract every string from the 8 in-scope pages into flow_docs/docs/design/2026-04-08-marketing-copy-audit.md as a table (page → section → element → current copy → rubric result → rewrite).
  2. Apply the brand voice guidelines §9.2 rubric to each row.
  3. Rewrite failures. Italian, preserve Flow-as-experiential-state framing.
  4. Commit the audit table as a documentation artifact.

6.2 Landing hero target copy (intent, final wording during implementation)

ElementCopyRationale
H1Vivi il momento.
Trova il tuo Flow.
5 words, 2 lines, Flow double meaning
SubScopri la notte. Unisciti alla crew.1 sentence, 6 words, 2 verbs
Primary CTAEntra nel FlowVerb-first, 3 words
Secondary CTACome funziona2 words, answers the skeptic
Trust stripBeta gratuita · Nessuna carta · 12k+ in beta · 18+Cost, commitment, proof, age gate

6.3 Page-level scope (8 pages)

PageFileScope
Landingapp/page.tsxFull brand rebuild + copy rewrite
Aboutapp/about/page.tsxTighten copy, apply MarketingShell + new primitives
Servicesapp/services/page.tsxHeaviest copy rewrite (most corporate today) + layout rebuild
Contactapp/contact/page.tsxLayout + form with brand primitives + success/error microcopy
Privacyapp/privacy/page.tsxLayout only; legal copy exempt from rubric; verify 18+ disclaimer
Upgradeapp/upgrade/page.tsxLayout rebuild + copy pass
Email confirmedapp/auth/confirmed/page.tsxAuthLanding wrapper + status copy
Password resetapp/auth/reset-password/page.tsxAuthLanding wrapper + single form field + success/error microcopy

7. Design decisions with rationale

7.1 Inter only, no Syne

One brand, one font. Typographic distinction via weight and scale. Mobile proves it works.

7.2 Hero buttons are not pills

Same 14px radius as every other brand button. Theatricality = size, animation, context.

7.3 Warm-first with dark moments

Default = warm canvas. Dark = punctuation (hero image, vibe strip, footer). Partiful proves warm-cream reads more distinctive than default dark for a nightlife brand.

7.4 tone prop, not CSS theme cascade

Per-primitive opt-in via React context, not global variable override. Dashboards cannot accidentally inherit brand styling. Only 7 primitives touched; tokens are deliberately additive (new names) not overrides (no remapping of existing rounded-lg).

7.5 AI imagery + SVG duotone

4 generated images, 1 filter. Source swappable, filter is the brand.

7.6 Auth landings stay calm

/auth/confirmed and /auth/reset-password are status / form pages after an email click. Calm, centered, one subtle glow. No dark band, no theatrical animation.

7.7 No net-new primitives

Chip/Badge/Label/Tooltip/Link/Avatar are NOT created. Marketing uses inline JSX with token classes. Promoted to primitives in v1.1 when we see ≥3 reuses of the same pattern.

7.8 Marketing forces light (warm) regardless of OS dark preference

Current globals.css line 3 defines @variant dark (&:where(.dark, .dark *)) — class-based, not OS-media-based, so dashboards do not auto-respond to prefers-color-scheme today. Marketing pages therefore don’t need to defend against an OS dark mode (there is none), but they DO need to guarantee that nothing inside MarketingShell ever inherits a .dark class from a future refactor. MarketingShell renders its top-level <div> with className="!bg-surface-warm" and inline style={{ colorScheme: 'light' }} belt-and-braces. This is a deliberate brand choice (Q4): warm IS the brand on marketing, period.

8. Error handling

  • DarkBand image load erroronError switches imagery to 'abstract' via local state. Zero broken image slots.
  • Marketing error.tsx + not-found.tsx — brand-treated: warm canvas, centered <Card tone="brand">, 1-sentence apology in voice, coral CTA back to /. No stack traces shown.
  • Auth landing error states — for /auth/reset-password: inline red text below field, no toast. Supabase Auth SDK v2 errors surface via AuthApiError.message — we match on the error.code field when present (Supabase JS v2.51+ returns structured codes) and fall back to the message string with a catch-all friendly translation.

Italian error map (verified against Supabase JS v2 error response shape during implementation — treat this list as intent, not final):

Code / matchItalian message
invalid_credentialsEmail o password non corretti.
over_request_rate_limitTroppi tentativi. Riprova tra qualche minuto.
over_email_send_rate_limitTroppe email inviate. Riprova più tardi.
weak_passwordPassword troppo debole. Almeno 8 caratteri.
same_passwordLa nuova password deve essere diversa dalla precedente.
any otherQualcosa è andato storto. Riprova tra poco.
  • ToneProvider — no error surface. Context only.

9. Testing strategy

9.1 Visual regression (Playwright @playwright/test with toHaveScreenshot)

Baseline OS: Linux (GitHub Actions ubuntu-latest). Local Windows runs use --update-snapshots only, never committed as baselines.

Marketing + auth pages (in-scope visual coverage): screenshot tests at 375 / 768 / 1440 for all 8 pages → 24 snapshots.

Dashboard regression guard (out-of-scope pixel-stability): 10 representative dashboard pages — if any snapshot diffs, brand styling leaked.

#RouteRole
1/admin/loginAdmin
2/admin/analyticsAdmin
3/admin/usersAdmin
4/admin/eventsAdmin
5/admin/notificationsAdmin
6/vendorVendor
7/vendor/eventsVendor
8/moderatorModerator
9/moderator/reportsModerator
10/admin/badgesAdmin (data-dense table page — highest DM-Sans→Inter sensitivity)

Route-group syntax ((dashboard)) is not present in URLs; these are the real navigable paths.

9.2 Component tests (Vitest + RTL)

Per touched primitive (6 × ~6 tests ≈ 36 tests):

  • Renders in neutral tone (default, no provider)
  • Renders in brand tone via ToneProvider
  • Explicit tone="neutral" prop overrides brand context
  • Explicit tone="brand" prop works without provider
  • ARIA + keyboard interaction unchanged across tones
  • Token classes resolve to expected computed styles (smoke test via getComputedStyle)

Plus ~10 tests on ToneProvider / useTone directly.

9.3 Accessibility — @axe-core/playwright, zero violations on all 8 in-scope pages.

Contrast notes:

  • #18181B on #F0EEE9 = 16.1:1 → AAA body
  • #FF5E57 on #F0EEE9 = 3.1:1 → AA large text / UI components only, never body
  • #FAFAFA on #121212 = 18.4:1 → AAA body

9.4 Performance — Lighthouse CI

Environment: GitHub Actions ubuntu-latest, lighthouserc.json at repo root, 4G Slow profile, desktop formfactor.

Budget (landing page only — marketing pages are heaviest):

  • LCP < 2.5s
  • CLS < 0.1
  • TBT < 200ms
  • Total transfer size < 800 KB including hero image

Hero images: <Image priority placeholder="blur" sizes="100vw"> with static blurDataURL from public/brand/hero/blur.json.

9.5 Token parity script

scripts/check-token-parity.ts (new) — parses flow_mobile/lib/core/theme/app_theme.dart for Color(0xFF…) definitions and spaceN / radiusN constants, cross-references against flow_backend/web/app/globals.css :root block. Exits non-zero if any mobile token is missing or mismatched, unless the token appears in an allow-list (scripts/web-only-tokens.txt) for things like --card-bg, --moderator-accent, --action-blue. Run in CI + pre-commit.

10. Rollout plan

Branch: feature/web-brand-parity-v1 off current main of the monorepo (per memory dev workflow).

Step 0 — pre-flight audit (blocks all other work):

  • Grep every existing dashboard usage of tokens/utilities that might change:
    • font-display className → replace with font-sans font-[800] in-place in marketing files
    • rounded-lg / rounded-md in dashboard pages → confirm unchanged by additive approach
    • --foreground / text-foreground → confirm the zinc-900 shift is acceptable
    • --card-bg → confirm kept unchanged in globals
  • Baseline all 10 dashboard regression snapshots (§9.1) against current HEAD — these are the “before” pictures
  • Write scripts/check-token-parity.ts, run it against current state, add baseline allow-list
  • Pass criteria for Phase 0:
    1. font-display className appears in zero files outside the 8 in-scope pages (if it leaks, stop and rescope)
    2. All 10 dashboard snapshots exist as committed baselines
    3. check-token-parity.ts exits 0 against current HEAD with the allow-list
  • Commit: chore(web): pre-flight token audit + baselines for brand parity

Phase 1 — tokens + infrastructure:

  1. globals.css edits per §4.1 (additive only, zero value changes except --foreground)
  2. app/layout.tsx next/font/google Inter loading
  3. Remove .font-display CSS rule + replace className usages
  4. components/ui/tone-context.tsx created
  5. Run dashboard Playwright regression → must be green

Phase 2 — primitives (6 commits, one per file): 6. button.tsxfeat(web/ui): add brand tone to Button 7. card.tsx 8. input.tsx 9. textarea.tsx 10. select.tsx 11. modal.tsx 12. Component tests committed alongside each primitive

Phase 3 — marketing infrastructure: 13. components/marketing/* created — MarketingShell, AuthLanding, Nav, Footer, DarkBand, WarmSection, Hero, SvgDuotoneFilter

Phase 4 — imagery (time-boxed 1 day): 14. Generate 4 hero images, commit to public/brand/hero/ 15. scripts/gen-blur-placeholders.ts + run → blur.json 16. Stop-ship check: if quality unacceptable, switch all DarkBand usages to imagery="abstract" and continue

Phase 5 — pages (one commit per page): 17. / landing 18. /about 19. /services 20. /contact 21. /privacy 22. /upgrade 23. /auth/confirmed 24. /auth/reset-password

Phase 6 — copy audit: 25. Extract strings, build audit table, rewrite failures, commit artifact

Phase 7 — verification: 26. Playwright baselines (both marketing + dashboard regression guard) 27. axe + Lighthouse passes 28. Token parity script green

Phase 8 — docs: 29. Brand voice doc §8.219 rewrite 30. Changelog entry flow_docs/docs/project/changelog/2026-04-08-web-brand-parity.md 31. JSDoc on touched primitives

Phase 9 — PR: 32. Open PR with before/after screenshots, audit table, test results, Lighthouse scores

No feature flag — safe because:

  1. Phase 1 is additive-only; the one value change (--foreground) makes dashboards slightly better (higher contrast), verified by snapshot regression.
  2. Primitives default to tone="neutral" rendering identical classes to HEAD.
  3. tone="brand" only activates inside <ToneProvider>, which only wraps marketing/auth pages.
  4. New Tailwind utilities (rounded-button, rounded-card, bg-surface-warm, etc.) are net-new names that cannot collide with existing dashboard classes.
  5. Dashboard visual regression tests catch any leak before merge.

11. Documentation deliverables

  • This spec (current file)
  • .claude/brand-voice-guidelines.md §8.219 rewrite → canonical phrasing from §1.2
  • flow_docs/docs/project/changelog/2026-04-08-web-brand-parity.md — what + why per memory
  • flow_docs/docs/design/2026-04-08-marketing-copy-audit.md — rubric audit artifact
  • JSDoc on each touched primitive with tone prop example
  • scripts/check-token-parity.ts self-documenting via comments
  • scripts/web-only-tokens.txt allow-list with justification per line

12. Remaining open questions for spec review iteration 2

  1. Is the 4-image starting point right, or does v1.1 already have a concrete slot list we should generate for now?
  2. Should the token parity script run in pre-commit (slow) or only in CI (faster dev loop but later feedback)?
  3. /upgrade is currently a marketing page — is it a pricing page or an existing-admin upgrade prompt? Scope of its copy rewrite depends on the answer.
  4. Dashboard regression guard lists 10 routes — is there a higher-traffic list we should substitute?
  5. Are there any marketing routes not listed here that we’re about to miss (e.g. /press, /jobs, /terms separate from /privacy)?

13. Changelog v2 → v3 (iteration 2 review fixes)

  • N1 (critical): Renamed all :root radius tokens to --brand-radius-* prefix. Eliminates the self-contradiction where :root redefined --radius-md/--radius-lg while the comment claimed they weren’t being touched. Brand utilities are now rounded-button / rounded-card / rounded-chip / rounded-pill with zero name collision risk against Tailwind defaults.
  • N3 (critical): Added explicit row in §2.1 audit table for --font-sans DM Sans → Inter. Called out as an intentional global dashboard body-font change that will bust every dashboard snapshot baseline in Phase 1 and must be visually reviewed.
  • N7: Stripped (dashboard) route-group syntax from §9.1 Playwright URLs. Real paths: /admin/analytics, /vendor, etc.
  • N10: Reconciled primitive count — 6 not 7. §4.2 table, §9.2 test count, §10 Phase 2 commit list all updated.
  • N11: §4.3 MarketingShell now explicitly reuses the existing components/ScrollRevealProvider.tsx instead of mounting a second IntersectionObserver.
  • N2: Added components/marketing/* (8 new files) to the scope bullet.
  • N4: Rewrote §7.8 to reflect that @variant dark is class-based not OS-media-based; color-scheme: light is defensive belt-and-braces, not the primary mechanism.
  • N6: Renamed MarketingLayoutMarketingShell throughout to avoid Next.js layout.tsx terminology collision.
  • N8: Swapped rate_limit_exceededover_request_rate_limit in §8 Italian error map (matches Supabase JS v2 canonical codes).
  • N9: Added explicit Phase 0 pass criteria in §10.

14. Changelog v1 → v2 (iteration 1 review fixes)

  • Fixed primitive inventory: 7 actual (not 12 claimed), removed Chip/Badge/Label/Tooltip/Link/Avatar as net-new primitives, mapped Dialog → existing modal.tsx
  • Fixed page inventory: no user-facing login/signup on web (mobile-only), auth scope is /auth/confirmed + /auth/reset-password, added /upgrade, removed (marketing) route group refactor
  • Fixed token strategy for Tailwind v4 @theme inline — additive only, no remapping of existing rounded-lg/rounded-md, new utilities under new names (rounded-button, rounded-card)
  • Added dashboard token usage audit table in §2.1 — documents which existing tokens are kept vs. modified vs. removed
  • Added pre-flight audit as Phase 0 of §10
  • Added missing decisions: dark mode behavior on marketing (force light), i18n strategy (Italian hardcoded), font loading (next/font/google), blur placeholder pipeline (plaiceholder), responsive breakpoints, Playwright baseline OS
  • Resolved §12 Q2 from v1 → 4 images not 10
  • Resolved §12 Q3 from v1 → runtime SVG filter not baked
  • Replaced subjective success criteria (§1.1 “cannot name inconsistency”, §1.3 “people would screenshot”) with token parity script + concrete “hero exists + vibe strip exists”
  • Aligned Supabase error code list to SDK v2 reality, added fallback catch-all
  • Added stop-ship trigger for imagery phase
  • Added explicit safety rationale for “no feature flag”
  • Added dashboard regression guard with 10 routes
  • Added token parity script as verification artifact