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 incomponents/ui/*(6 files touched), a net-newcomponents/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)
- Token diff: a token-comparison script (
scripts/check-token-parity.ts) exits 0 — every color, radius, spacing, and font token in the newglobals.csshas a 1:1 match inflow_mobile/lib/core/theme/app_theme.dart, modulo a documented allow-list of web-only tokens (divider, shadow, error). - Voice rubric: every string in
app/{page,about,services,contact,privacy,upgrade,auth/confirmed,auth/reset-password}/page.tsxclears the §9.2 rubric (checked by a manual audit table committed as an artifact). - 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.
- Accessibility:
@axe-core/playwrightreports zero violations on every marketing + auth landing page. - Performance: Lighthouse CI (configured at
lighthouserc.json, run in GitHub Actions onubuntu-latest, 4G Slow profile) reports LCP < 2.5s, CLS < 0.1, TBT < 200ms on the landing page (/). - Brand voice doc updated: §8.219 of
.claude/brand-voice-guidelines.mdis rewritten to the canonical phrasing below, and no other paragraph in the doc still argues Syne is a web-specific choice. - 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 underapp/. 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.tsxplays the Dialog role. We will not create a newdialog.tsx.components/ui/has 10 files total. The v1 draft of this spec incorrectly listed 12 primitives assumingChip,Badge,Label,Tooltip,Link,Avatarexisted. 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):
| Token | Used by dashboards? | Action |
|---|---|---|
--foreground: #374151 | Yes (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: #ffffff | Yes | Keep unchanged |
--primary-brand: #FF5E57 | Yes | Keep unchanged |
--primary-brand-hover: #FF4757 | Yes | Keep unchanged |
--primary-brand-dark: #D93636 | Yes | Keep unchanged |
--primary-brand-foreground: #FFFFFF | Yes | Keep unchanged |
--action-blue: #1E90FF | Yes (admin chips) | Keep unchanged |
--surface-carbon: #121212 | Yes | Keep + add alias --ink-black pointing to same value |
--surface-highest: #2C2C2C | Yes | Keep unchanged |
--success-green: #2ED573 | Yes | Keep (matches mobile mintGreen) |
--warning-yellow: #FFA502 | Yes | Keep (matches mobile warmSun) |
--card-bg: #F5F5F7 | Yes (dashboards) | Keep unchanged — v1 said remove; removing would break dashboard cards. New brand cards use --surface-warm-elev: #FFFFFF directly. |
--moderator-accent: #8B5CF6 | Yes (moderator dashboard) | Keep unchanged |
--warning-orange: #F97316 | Yes (flagged content) | Keep unchanged |
--electric-violet: #8A2BE2 | Partially | Keep unchanged |
--warm-canvas: #F0EEE9 | Not yet | Keep, promote to marketing default background |
--surface-dark: #1E1E1E | Partially | Keep 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 body | Intentional 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)
| # | Decision | Source |
|---|---|---|
| Q1 | Direction: A/B hybrid. Drop Syne. Inter across both platforms. | user: “A/B, but dont keep syne” |
| Q2 | Scope: shared UI primitives + marketing + user auth landings. Dashboards untouched. | user: “shared component library + marketing + auth” |
| Q3 | Landing personality: B/C hybrid — mobile component DNA with retained web theatrical energy. | user: “ye” to hybrid |
| Q4 | Warm-first surface with dark moments as punctuation. | user: “warm always. be smart.” |
| Q5 | Depth 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+3 | AI-generated imagery (Flux/Midjourney) + SVG duotone filter for consistency + CSS/SVG abstract fallback. Zero licensing cost. | user: “Option 2 + Option 3 hybrid” |
| v2-a | Imagery count: 4 slots, not 10 (answers §12 Q2 from v1). Start minimal; expand in v1.1 if more dark bands appear. | v2 revision |
| v2-b | Runtime SVG duotone filter, not baked into WebP (answers §12 Q3 from v1). Flexibility wins over marginal perf. | v2 revision |
| v2-c | Dark 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-d | i18n: 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-e | Per-page <MarketingShell> wrapper, no (marketing) route group refactor. | v2 revision |
| v2-f | No 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-g | Font 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/loginbrand treatment/auth/callbackUI (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-intlor 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):
| # | File | Current role | Brand tone changes |
|---|---|---|---|
| 1 | button.tsx | Dashboard + marketing CTAs | Adds primary/secondary/ghost/outline brand variants, rounded-button (14px), Inter 800, active scale |
| 2 | card.tsx | Dashboard + marketing sections | Brand tone: bg-surface-warm-elev, rounded-card (20px), no border, shadow-md |
| 3 | input.tsx | Dashboard forms + contact form + reset-password form | Brand tone: rounded-button, coral focus ring via :focus-visible, --foreground text |
| 4 | textarea.tsx | Dashboard + contact form | Same treatment as input |
| 5 | select.tsx | Dashboard + marketing filters | Same treatment as input |
| 6 | modal.tsx | Dashboard modals + marketing dialogs | Brand 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>fromcomponents/ScrollRevealProvider.tsx(do not mount a second IntersectionObserver), renders<SvgDuotoneFilter />once. Named “Shell” not “Layout” to avoid confusion with Next.jslayout.tsxconvention. Each marketing page imports this component explicitly inside its default export — no(marketing)route group.components/marketing/AuthLanding.tsx— wraps/auth/confirmedand/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>withtranslateanimation, not a new primitive).components/marketing/Footer.tsx— dark band. 3 columns (brand + tagline / product links / legal). Coral social icons. MandatoryFlow è 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 CSSfilter: url(#brand-duotone), content layer with.reveal+ stagger. Falls back toimagery="abstract"on image error.components/marketing/WarmSection.tsx— default warm-page section wrapper. Max-width 1200px, responsive horizontal padding, vertical rhythm, auto-applies.revealto 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)
| Slot | Location | Imagery | Intensity |
|---|---|---|---|
| 1 | Landing hero | hero-01 | theatrical |
| 2 | Landing “tonight’s vibe” strip | hero-02 | editorial |
| 3 | About “our night” section | hero-03 | editorial |
| 4 | Footer | abstract | subtle |
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:
hero-01— crowd raising hands, smoke machine haze, backlithero-02— vinyl close-up on turntable, hands reachinghero-03— empty dancefloor before the crowd, neon exit signhero-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
- Extract every string from the 8 in-scope pages into
flow_docs/docs/design/2026-04-08-marketing-copy-audit.mdas a table (page → section → element → current copy → rubric result → rewrite). - Apply the brand voice guidelines §9.2 rubric to each row.
- Rewrite failures. Italian, preserve Flow-as-experiential-state framing.
- Commit the audit table as a documentation artifact.
6.2 Landing hero target copy (intent, final wording during implementation)
| Element | Copy | Rationale |
|---|---|---|
| H1 | Vivi il momento.Trova il tuo Flow. | 5 words, 2 lines, Flow double meaning |
| Sub | Scopri la notte. Unisciti alla crew. | 1 sentence, 6 words, 2 verbs |
| Primary CTA | Entra nel Flow | Verb-first, 3 words |
| Secondary CTA | Come funziona | 2 words, answers the skeptic |
| Trust strip | Beta gratuita · Nessuna carta · 12k+ in beta · 18+ | Cost, commitment, proof, age gate |
6.3 Page-level scope (8 pages)
| Page | File | Scope |
|---|---|---|
| Landing | app/page.tsx | Full brand rebuild + copy rewrite |
| About | app/about/page.tsx | Tighten copy, apply MarketingShell + new primitives |
| Services | app/services/page.tsx | Heaviest copy rewrite (most corporate today) + layout rebuild |
| Contact | app/contact/page.tsx | Layout + form with brand primitives + success/error microcopy |
| Privacy | app/privacy/page.tsx | Layout only; legal copy exempt from rubric; verify 18+ disclaimer |
| Upgrade | app/upgrade/page.tsx | Layout rebuild + copy pass |
| Email confirmed | app/auth/confirmed/page.tsx | AuthLanding wrapper + status copy |
| Password reset | app/auth/reset-password/page.tsx | AuthLanding 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
DarkBandimage load error →onErrorswitchesimageryto'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 viaAuthApiError.message— we match on theerror.codefield 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 / match | Italian message |
|---|---|
invalid_credentials | Email o password non corretti. |
over_request_rate_limit | Troppi tentativi. Riprova tra qualche minuto. |
over_email_send_rate_limit | Troppe email inviate. Riprova più tardi. |
weak_password | Password troppo debole. Almeno 8 caratteri. |
same_password | La nuova password deve essere diversa dalla precedente. |
| any other | Qualcosa è 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.
| # | Route | Role |
|---|---|---|
| 1 | /admin/login | Admin |
| 2 | /admin/analytics | Admin |
| 3 | /admin/users | Admin |
| 4 | /admin/events | Admin |
| 5 | /admin/notifications | Admin |
| 6 | /vendor | Vendor |
| 7 | /vendor/events | Vendor |
| 8 | /moderator | Moderator |
| 9 | /moderator/reports | Moderator |
| 10 | /admin/badges | Admin (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:
#18181Bon#F0EEE9= 16.1:1 → AAA body#FF5E57on#F0EEE9= 3.1:1 → AA large text / UI components only, never body#FAFAFAon#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-displayclassName → replace withfont-sans font-[800]in-place in marketing filesrounded-lg/rounded-mdin 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:
font-displayclassName appears in zero files outside the 8 in-scope pages (if it leaks, stop and rescope)- All 10 dashboard snapshots exist as committed baselines
check-token-parity.tsexits 0 against current HEAD with the allow-list
- Commit:
chore(web): pre-flight token audit + baselines for brand parity
Phase 1 — tokens + infrastructure:
globals.cssedits per §4.1 (additive only, zero value changes except--foreground)app/layout.tsxnext/font/googleInter loading- Remove
.font-displayCSS rule + replace className usages components/ui/tone-context.tsxcreated- Run dashboard Playwright regression → must be green
Phase 2 — primitives (6 commits, one per file):
6. button.tsx → feat(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:
- Phase 1 is additive-only; the one value change (
--foreground) makes dashboards slightly better (higher contrast), verified by snapshot regression. - Primitives default to
tone="neutral"rendering identical classes to HEAD. tone="brand"only activates inside<ToneProvider>, which only wraps marketing/auth pages.- New Tailwind utilities (
rounded-button,rounded-card,bg-surface-warm, etc.) are net-new names that cannot collide with existing dashboard classes. - 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.2flow_docs/docs/project/changelog/2026-04-08-web-brand-parity.md— what + why per memoryflow_docs/docs/design/2026-04-08-marketing-copy-audit.md— rubric audit artifact- JSDoc on each touched primitive with
toneprop example scripts/check-token-parity.tsself-documenting via commentsscripts/web-only-tokens.txtallow-list with justification per line
12. Remaining open questions for spec review iteration 2
- Is the 4-image starting point right, or does v1.1 already have a concrete slot list we should generate for now?
- Should the token parity script run in pre-commit (slow) or only in CI (faster dev loop but later feedback)?
/upgradeis currently a marketing page — is it a pricing page or an existing-admin upgrade prompt? Scope of its copy rewrite depends on the answer.- Dashboard regression guard lists 10 routes — is there a higher-traffic list we should substitute?
- Are there any marketing routes not listed here that we’re about to miss (e.g.
/press,/jobs,/termsseparate from/privacy)?
13. Changelog v2 → v3 (iteration 2 review fixes)
- N1 (critical): Renamed all
:rootradius tokens to--brand-radius-*prefix. Eliminates the self-contradiction where:rootredefined--radius-md/--radius-lgwhile the comment claimed they weren’t being touched. Brand utilities are nowrounded-button/rounded-card/rounded-chip/rounded-pillwith zero name collision risk against Tailwind defaults. - N3 (critical): Added explicit row in §2.1 audit table for
--font-sansDM 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
MarketingShellnow explicitly reuses the existingcomponents/ScrollRevealProvider.tsxinstead of mounting a second IntersectionObserver. - N2: Added
components/marketing/*(8 new files) to the scope bullet. - N4: Rewrote §7.8 to reflect that
@variant darkis class-based not OS-media-based;color-scheme: lightis defensive belt-and-braces, not the primary mechanism. - N6: Renamed
MarketingLayout→MarketingShellthroughout to avoid Next.jslayout.tsxterminology collision. - N8: Swapped
rate_limit_exceeded→over_request_rate_limitin §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/Avataras net-new primitives, mappedDialog→ existingmodal.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 existingrounded-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