Appearance
AskFlorence design system
The canonical reference for building screens that match the editorial home register. Read this BEFORE writing any new page or component. The home page (/, source at src/app/_home/) is the reference implementation — when this doc and the home disagree, the home wins and this doc gets updated.
This document describes the L2 editorial register — the typography, color, layout, motion, and copy patterns that shipped with the v0.29.0 home swap. All new pages on askflorence.health should adopt this register unless explicitly noted otherwise. The legacy /landing-1 archive is a SaaS-style register and is NOT a design reference; it exists only for rollback.
1. Register and philosophy
The site reads like an editorial weekend supplement, not a SaaS marketing page. The audience is Americans being lied to by Healthcare.gov about their insurance options — they don't need another sales pitch, they need clarity. So:
- Paper, not screens. Backgrounds are warm cream and paper tones. Pure white and pure black are off-limits except where calls demand them (primary CTAs, ink text on cream, etc.).
- Editorial, not flashy. Headlines lean on Playfair Display in regular weight with deliberate italic-gold accents on the emotional second clause. Body type is unobtrusive Inter. Prices use Outfit at display sizes — the only place the type rhythm visibly shifts.
- Restraint over motion. Animations are minimal and bulletproof. Scroll-driven reveals were tried and removed (see CLAUDE.md v0.34.0) because consistency matters more than flourish. Default to static; add motion only when it carries meaning.
- Trust over persuasion. Every claim is backed by real plan data. No fake urgency, no countdown timers, no "limited time" copy. The hero proof block compares actual carrier plans byte-for-byte audited against CMS.
- Hyphens, not em dashes or en dashes. Anywhere user-facing. This is a hard rule.
2. Design tokens
Tokens live in src/app/_home/tokens.css scoped to .af-root. Every page that uses this register must wrap its content in <div className="af-root"> and import the tokens.
2.1 Color tokens
| Token | Hex | Intended use |
|---|---|---|
--af-cream | #F6F1E8 | Page background (the "paper" base) |
--af-paper | #FBF6EB | Card backgrounds within sections; lighter than cream so cards float |
--af-sand | #EFE3C6 | Image placeholder backgrounds; subtle lift behind portraits |
--af-line | #E5DCCB | Hairline borders, dividers |
--af-glow | #F5E8C4 | Gold-tinted glow / soft highlights |
--af-ink | #141C2E | Primary text, primary CTA backgrounds |
--af-ink2 | #0F1222 | Slightly deeper ink for hover states on --af-ink |
--af-body | #3A404F | Body copy when ink is too heavy |
--af-stone | #6A6F7C | Stone-grey for secondary copy, captions, helper text |
--af-gold | #C9A967 | Brand gold; subtle accent — labels, gold dots, soft eyebrows |
--af-gold-2 | #B8903F | Deeper gold; the editorial italic accent — never on bodies of text, always on emotional words |
--af-ember | #EFE3C6 | Synonym for sand at the moment; reserve for warm tonal shifts |
--af-green | #1F6B49 | Saved-money green; success values |
--af-green-d | #184F36 | Hover state for green |
--af-red | #A85C3F | Warm-red for "rejected" / sticker-price strikes; never harsh |
--af-red-d | #8B4730 | Hover state for red |
--af-white | #FFFFFF | Use sparingly — deck cards, focused inputs |
Color usage rules:
- Primary CTA =
--af-inkbackground + white text, 4px radius, navy. Always. - Tertiary/text-link CTA =
--af-gold-2text with dashed underline. Always italic Playfair when in editorial sections, Inter when in instructional UI. - Eyebrow labels =
--af-gold-2Inter 11px 600 0.22em uppercase. Optionally preceded by a 32x1px gold rule. - Editorial italic accent = always
--af-gold-2, never--af-gold. Use on the emotional second clause: "Healthcare.gov showed me $960/mo. I'm paying $7." The italic gold is the brand voice in typography. - Helper / muted text =
--af-stoneInter 11.5-13px. - Strike-through prices (fictional / sticker) =
--af-red0.55 opacity with a 3px line-through. - Save / subsidized prices =
--af-greenOutfit at display sizes. - Strikes through Healthcare.gov sticker prices use
--af-rednot--af-red-d. The deeper red is for hover only.
2.2 Typography
Three font families are loaded via next/font in the root layout. Use the variables, not bare names:
| Variable | Family | Roles |
|---|---|---|
--af-font-serif | Playfair Display, Fraunces, Times New Roman | Display headings, editorial emphasis, italic accents |
--af-font-sans | Inter, system-ui | UI, body copy, labels, eyebrows |
--af-font-label | Outfit, Inter | Prices and numerics at display sizes |
Declarative type scale (used everywhere in _home/v4.css):
| Element | Mobile | Desktop | Line-height | Tracking | Notes |
|---|---|---|---|---|---|
| H1 hero | clamp(40px, 7.5vw, 96px) | 96px | 0.98 | -0.035em | Playfair, weight 400. Italic-gold accent on second clause. |
| H2 section opener | 30px | 50-72px | 1.0-1.06 | -0.03em | Playfair, weight 400. Use :where(.af-h2) for fluid clamp(30px, 7vw, 72px). |
| H2 conversion moment | 40px | 60px | 1.04 | -0.025em | Playfair, weight 400. Larger than openers — Proof, How It Works, Audience all use this size. |
| H3 spread title | 30-36px | 56px | 1.03 | -0.025em | Playfair, weight 400. Editorial pull-quote feel. |
| H4 / step head | 18-22px | 24-28px | 1.15-1.2 | -0.012em | Playfair or Inter; use Playfair when within an editorial spread, Inter for UI. |
| Body large | 16-17px | 17-18px | 1.55-1.75 | normal | Inter. Use 17px for editorial spreads, 16px for general body. |
| Body | 14-15px | 14-16px | 1.5-1.6 | 0.01em | Inter. UI body, helper paragraphs. |
| Helper / caption | 11-13.5px | 12-14px | 1.35-1.5 | 0.01-0.02em | Inter, often --af-stone. |
| Eyebrow | 10.5-11px | 11px | 1 | 0.18-0.22em UPPER | Inter, weight 600, --af-gold-2. Always preceded by a 32x1px gold rule unless space-constrained. |
| Display price big | 26-32px | 32-44px | 1 | -0.02 to -0.03em | Outfit, weight 500. |
| Display price huge | 34-56px | 56-84px | 1 | -0.02 to -0.035em | Outfit, weight 500. The conversion moment ("$7"). |
Editorial italic accent recipe:
html
<h1>Healthcare.gov showed me $960/mo. <em style="color: var(--af-gold-2); font-style: italic;">I'm paying $7.</em></h1>Inside <em>, the color shifts to --af-gold-2 and the style stays italic. This is the single most-recognizable typographic move in the brand. Use it sparingly — once per major heading. Never on pure-information lines.
2.3 Spacing & layout
Layout tokens:
| Token | Value | Use |
|---|---|---|
--af-gutter-d | 48px | Desktop section horizontal padding |
--af-gutter-m | 20px | Mobile section horizontal padding |
--af-max-hero | 1240px | Inner container max-width (canonical) |
--af-max-content | 1200px | Older sections still use this; standard moving forward is 1240 |
--af-nav-h | 80px | Used by hero min-height: calc(100svh - var(--af-nav-h)) |
Section structure (canonical):
html
<section class="af-section af-l2-{name}" style="padding: 0px 48px 120px;">
<div style="max-width: 1240px; margin: 0px auto;">
<!-- content -->
</div>
</section>- Desktop horizontal padding: 48px
- Desktop bottom padding: 120px
- Inner container: max-width 1240px, margin auto
- Top padding: 0px (relies on previous section's 120px bottom)
- Exception: a section that follows the hero (which has no bottom padding) gets 80px top.
Mobile section pattern (CSS at _home/v4.css:151-163):
css
@media (max-width: 759px) {
.af-section, .af-proof, .af-feature-strip, .af-audience,
.af-stories, .af-namesake, .af-how, .af-calculator {
padding-left: 20px !important;
padding-right: 20px !important;
padding-bottom: 64px !important;
}
}Critical: every new section MUST have class="af-section ..." so this mobile gutter override catches it. The lifestyle wrapper bug (fixed in v0.34.0) was a section without a class — its desktop 48px padding leaked to mobile and made content 16% narrower than the rest of the page.
Vertical rhythm:
- Section-to-section gap = 0 (sections butt cleanly; the previous's 120/64 bottom is the gap)
- Inside sections, consistent margin-bottom on hero blocks:
clamp(28px, 4.5vh, 52px) - Stories spreads: 140px margin-bottom with
:last-of-typeoverride to prevent double-stacking - H2 to first content: 48-72px gap (smaller for tight sections, larger for hero-style sections)
- H3 to body: 24-32px
- Body to dividers: 36-40px
Container width hierarchy:
| Context | Max-width |
|---|---|
| Hero inner, calc container, howw container, aud container, proof card | 1240px (canonical) |
| Feature row, lifestyle spread (legacy) | 1200px (will migrate to 1240px in next pass) |
| H1 / H2 line-length cap | 1100px (so they wrap before the container does) |
| Body paragraphs in editorial spreads | 500-540px (50-60ch optimal) |
| Body in pitch sidebars | 720px |
| Form max-width | 480px (calc form column) |
2.4 Borders, radii, shadows
| Token | Value |
|---|---|
--af-border-hairline | 1px solid var(--af-line) |
| Card corner radius | 4px (use --radius-lg from globals or hardcode) |
| Pill / badge radius | 2-4px (sharp; never round) |
| Image card radius | 0px (sharp aspects, editorial) |
--af-shadow-card | 0 18px 40px -30px rgba(20, 16, 8, 0.35) |
--af-shadow-portrait | 0 30px 80px -40px rgba(20, 16, 8, 0.45) |
Radii are flat-and-low. The brand reads as architectural, not playful — no big-radius pill chrome. The deepest shadow on the site is on lifestyle portraits.
2.5 Motion tokens
| Token | Value |
|---|---|
--af-motion-fast | 120ms cubic-bezier(0.4, 0, 0.2, 1) |
--af-motion-base | 200ms cubic-bezier(0.4, 0, 0.2, 1) |
--af-motion-slow | 320ms cubic-bezier(0.4, 0, 0.2, 1) |
Use rules:
- Hover transitions:
--af-motion-base - Focus rings:
--af-motion-fast - Card lift / settle:
--af-motion-slow - Avoid scroll-driven reveals. They've burned us 3 times. If you must reveal on scroll, default to visible and treat the animation as enhancement, never required.
3. Component patterns
3.1 Eyebrows
The most-repeated UI element. Always Inter 11px / 600 / --af-gold-2 / 0.22em uppercase. Optionally preceded by a 32x1px gold rule.
html
<div style="display: flex; align-items: baseline; gap: 14px; color: var(--af-gold-2); font-family: Inter; font-size: 11px; font-weight: 600; letter-spacing: 0.22em; text-transform: uppercase;">
<span style="width: 32px; height: 1px; background: var(--af-gold-2); display: inline-block;"></span>
The subsidy Healthcare.gov didn't show you
</div>Variants:
- Section eyebrow — full pattern above
- Card eyebrow — same color/size, no leading rule, often paired with a heavier card-internal label below
- Persona eyebrow ("FOR FAMILIES", "FOR THE SELF-EMPLOYED") — same recipe
- Dark-on-light eyebrow — when used on a navy panel, swap color to
var(--af-glow)orvar(--af-cream)
3.2 Buttons & CTAs
Three roles:
Primary CTA (one per section, max):
html
<a class="af-l2-cta-primary" href="..." style="
display: inline-flex; align-items: center; gap: 10px;
background: var(--af-ink); color: var(--af-white);
text-decoration: none;
padding: 16px 28px; border-radius: 4px;
font-family: Inter; font-size: 15px; font-weight: 600; letter-spacing: 0.04em;
">See your real price <span style="font-size: 18px;">→</span></a>Hover: bg shifts to --af-ink2. Mobile: padding 18px 24px, full-width if it's the only CTA.
Secondary CTA (rarely used; pairs with a primary):
html
<a class="af-l2-cta-secondary" href="..." style="
background: transparent; color: var(--af-ink); border: 1px solid var(--af-ink);
padding: 14px 24px; border-radius: 4px;
font-family: Inter; font-size: 14px; font-weight: 500; letter-spacing: 0.02em;
">View all plans</a>Tertiary / text-link CTA (most common; the "next step" affordance in editorial spreads):
html
<a style="
font-family: Inter; font-size: 14px; font-weight: 600; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--af-gold-2); text-decoration: none;
display: inline-flex; align-items: center; gap: 10px;
">Check your eligibility <span style="font-size: 16px;">→</span></a>Hover: dashed underline appears. The arrow is a literal →, not an SVG icon.
3.3 Form inputs
Two flavors:
White-on-paper input (canonical — calculator form, /plans coverage panel, any future field on the editorial register):
css
background: var(--af-white);
border: 1px solid var(--af-line); /* hairline shape boundary */
padding: 12px 14px;
font-family: Inter;
font-size: 16px;
border-radius: 4px;
transition: background 200ms, border 200ms, box-shadow 200ms;
/* Hover */
border-color: rgba(184, 144, 63, 0.45); /* gold-2 at 45% — warming hint */
/* Focus */
background: var(--af-white);
border-color: var(--af-gold-2);
box-shadow: 0 0 0 3px rgba(184, 144, 63, 0.16);Why white bg + hairline border: earlier versions of this recipe used cream bg with a transparent border, relying on cream-on-paper background differentiation to define the field. That bg-only approach was 1.04:1 contrast — decorative, failed WCAG 1.4.11 (3:1 for non-text UI components), and was genuinely hard on the eyes when fields sat in default state for any length of time.
The current recipe uses white bg + visible hairline border at var(--af-line) (#E5DCCB). White-on-paper reads as a clearly different surface (the universal "ready to type" signal) even though the strict bg-vs-bg ratio is small — perceptual differentiation between pure white and warm paper is unambiguous. The line border carries the WCAG shape boundary at ~1.4:1.
Editorial register holds because the surrounding chrome (Playfair headings, gold accents, paper sections, italic-gold copy emphasis) carries the brand voice. Inputs are a utility surface and should be unambiguous, not decorative — white inputs are universally readable and ship in every editorial-meets-utility context (Apple Card, Wealthfront, Stripe forms inside otherwise editorial pages).
Hover signal: since default and focus both have white bg, hover can't darken the bg without going off-brand. Instead, the border color shifts to gold-2 at 45% alpha (rgba(184, 144, 63, 0.45)) — a warming hint that signals interactivity without competing with the focus ring. Focus then steps the border to full gold-2 + adds the 3px ring.
This recipe was upgraded from the earlier "transparent border + cream bg" pattern in two stages: first added the hairline border (after the /plans coverage inputs sitting in disabled-ready state for minutes made the contrast issue obvious), then flipped the bg to white (after even the line-on-cream version still read as too quiet). Every input on the editorial register uses this recipe now.
Min height 48px on mobile (WCAG 2.5.5). The current 45px ZIP field is just-barely-compliant; new inputs should default to 48px.
Money input with $ prefix:
html
<div class="af-l2-calc__input-money" style="position: relative;">
<span style="position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--af-stone);">$</span>
<input type="text" inputmode="numeric" style="padding-left: 28px; ..." />
</div>Income field gotcha: if the value can legitimately be 0, do NOT render value={n ? n.toString() : ""} — the truthy check eats user-typed zeros. Use a local string state mirrored to the numeric form state. See LandingCalculator.tsx lines ~104-117 for the canonical pattern.
3.4 Cards
Three card families:
Editorial card (paper-on-cream, used for content cards in any section):
css
background: var(--af-paper);
border: 1px solid var(--af-line);
padding: 28px;
border-radius: 4px;
box-shadow: var(--af-shadow-card);Feature card (the home's "Free to use" + "Proof, not promise" tiles): solid color blocks. The olive variant is #3F4A2F with cream text. Deliberate departure from paper-on-cream for accent.
Comparison card (proof block with two columns): outer .af-l2-proof-card is paper, inner two .af-l2-proof-col divs are bordered cells. The "without subsidy" cell uses warm-red tones, the "with subsidies" cell uses gold + green.
3.5 Badges & pills
Sharp corners, small. Never rounded.
Metal-tier pill:
css
font-family: Inter; font-size: 9.5px; font-weight: 600; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--af-gold-2);
background: rgba(201, 169, 103, 0.12);
padding: 3px 7px;
border-radius: 2px;Persona pill (the "Built for: Self-employed | Freelancers | ..." row):
css
font-family: Inter; font-size: 13px; font-weight: 500;
color: var(--af-ink);
border: 1px solid var(--af-line);
padding: 8px 14px;
border-radius: 4px;
text-decoration: none;
transition: ...;
/* Hover */
background: var(--af-ink); color: var(--af-white); border-color: var(--af-ink);3.6 Lifestyle spreads
The 2-col editorial pattern that appears in lifestyle sections + stories.
html
<div class="af-lifestyle-spread" style="
display: grid;
grid-template-columns: 1fr 1.1fr;
gap: 72px;
align-items: center;
margin-bottom: 140px;
">
<!-- Image column (4:5 aspect, sand background, gold inset shadow) -->
<div style="position: relative; aspect-ratio: 4 / 5; background: var(--af-sand); overflow: hidden;">
<img src="..." style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; filter: saturate(0.92) contrast(1.02);" />
<div style="position: absolute; inset: 0; box-shadow: rgba(184, 144, 63, 0.22) 0px 0px 0px 1px inset;"></div>
</div>
<!-- Copy column -->
<div>
<div class="af-eyebrow"><span class="af-eyebrow-rule"></span>For the self-employed</div>
<h3 style="font-family: Playfair; font-size: 56px; line-height: 1.03; letter-spacing: -0.025em; max-width: 16ch;">
Without an employer plan, <em style="color: var(--af-gold-2);">the real price is usually different.</em>
</h3>
<p style="font-family: Inter; font-size: 17px; line-height: 1.75; color: var(--af-body); max-width: 500px;">...</p>
<!-- 2-col stat grid -->
<!-- Tertiary CTA -->
</div>
</div>Mobile: collapses to single column with display: contents flattening intermediate wrappers (see _home/v4.css:3399-...). Order: eyebrow → image → name/role → facts → quote → stats → callout → CTA.
3.7 Form sidebar (the "what happens next" pitch column)
Used on the calculator pre-submit. 2-col grid, form on right, support copy on left. See v0.32.0 entry in CLAUDE.md.
3.8 Proof block (Healthcare.gov vs AskFlorence)
The conversion-critical comparison. Two columns inside a paper card:
- Warning column (left):
--af-red-tinted callout, sticker price in red - Result column (right): gold-tinted callout, subsidized price in green, struck-through original
Both columns share padding, eyebrow, issuer, plan name, premium row, stats grid, callout. The callout text in the result column always frames savings as "Save about $X/yr."
3.9 Navigation
html
<nav class="af-nav" style="
position: sticky; top: 0; z-index: 30;
background: var(--af-cream);
border-bottom: 1px solid var(--af-line);
padding: 18px clamp(16px, 3vw, 48px);
display: flex; align-items: center; justify-content: space-between;
">
<!-- AskFlorence wordmark with gold dot -->
<!-- Inline links: Price calculator, How it works, Stories, Namesake, See your price button -->
<!-- Hamburger, hidden desktop -->
</nav>Mobile: links hide, hamburger shows. The hamburger drawer is rendered imperatively in TargetPage.tsx (makeDrawer).
3.10 Sticky mobile CTA
When the user scrolls past the hero CTA but isn't yet at the calculator, a fixed bottom-right pill appears. State managed in TargetPage.tsx:186-203 — show when heroPastTop && !calcInView && !footerInView.
4. Page composition (the home as a template)
The home flows top-to-bottom in this order. New pages should pick from this menu:
- Hero — H1 with italic-gold accent, mobile photo card, primary CTA, trust strip below.
- Feature strip — 2-card row + persona pill row. Sets up the "what + who".
- Proof block — the conversion-critical comparison.
- Lifestyle spread(s) — editorial 2-col cases (For the Self-Employed, For Families).
- Calculator — pre-submit form with takeover-on-submit. The conversion gate.
- Trust headline — "We handle your data like your doctor."
- How It Works — 5-step zig-zag journey. (Currently static; animations deprioritized.)
- Audience — 3x2 grid of persona tiles. (Currently static.)
- Stories — 5 editorial spreads, named cases.
- Namesake — Florence Nightingale brand origin.
- Footer / Email capture — final CTA + newsletter sign-up.
Plain-text rules for ALL pages:
- One primary CTA per fold; multiple is allowed only when separated by clear vertical breaks.
- Italic-gold accent: max once per major heading. Don't dilute it.
- Eyebrows on every section unless the section is a single block.
5. Mobile-specific patterns
The breakpoint is (max-width: 759px). At that breakpoint:
- Section padX collapses:
48px → 20px - Section padBottom collapses:
120px → 64px - All grids collapse to single column unless they were already vertical
- H1 hits its clamp floor (
40px) - H2 fluid clamp lands at
30-40pxdepending on the H2 class - Mobile photo card (in hero) is rendered SSR (NOT injected client-side — see v0.29.7 fix)
- Mobile sticky CTA appears (see 3.10)
- Lifestyle spread reading order is rebuilt with
display: contents+ per-itemorder: prefers-reduced-motion: reducezeros all transitions and forces transform/opacity to end-state
Mobile gotchas:
- Always include
class="af-section"on new sections so the gutter override catches them. - Touch targets must be ≥ 44px (WCAG 2.5.5). 48px preferred (HIG).
- Body inputs must use
inputmodefor the right keyboard (numeric for ZIP/age/income, email for email, etc.) - iOS Safari URL bar collapses; use
100svh(small viewport height) for hero sizing, NOT100vh.
6. Naming conventions
CSS class prefix: af-l2-{section}__{element}--{modifier}.
af-= AskFlorence project namespacel2-= "L2 register" — the editorial home register (vs L1 legacy, no longer maintained on apex)- Section name:
howw,aud,stories,proof,feature,calc,namesake, etc. - Element name:
__head,__card,__cta,__pill, etc. - Modifier:
--olive,--proof,--strike,--green,--mobile
When adding a new section, pick a short prefix and use it consistently across CSS + markup.
Inline styles are OK for one-off positioning but token usage should still prefer CSS variables.
7. Voice & copy
Voice is editorial-direct. Short sentences, no jargon, no marketing fluff.
Hard rules:
- No em dashes (—) or en dashes (–) anywhere. Use hyphens. This rule is enforced even in lib/updates/index.ts. Search for
—before committing. - No exclamation points outside conversational copy.
- No "we" / "our" in editorial body copy. Use "you" / "your".
- No "limited time", "today only", "act now". The platform is free to use forever; urgency cheapens the trust.
- No fake testimonials. The "stories" section uses illustrative cases ("Common cases, hidden coverage") not pretend customer quotes.
- "Agent" not "broker". This is a regulatory + relationship distinction.
Headline structure:
- Lead with the surprise. ("Healthcare.gov showed me $960/mo.")
- Resolve with the alternative in italic-gold. ("I'm paying $7.")
- Optional clarifier in body: "Real ACA plans, audited byte-for-byte against CMS."
Helper copy:
- 11-13.5px Inter
--af-stone. - One sentence max in helper roles.
- "30 seconds. No spam calls." is the canonical reassurance line in calculator contexts.
8. Don'ts (the negative space)
| Don't | Why |
|---|---|
| Em dashes anywhere | Hard rule. |
| Pure white backgrounds | Reads as SaaS / generic. Use --af-cream or --af-paper. |
| Pure black text | Use --af-ink (#141C2E). |
| Round (>8px) corners | Brand is architectural; high radii read as bouncy/chat-app. |
| Rotating logos / badges | The lantern is fixed. |
| Bouncing or elastic motion | Easing is cubic-bezier(0.16, 1, 0.3, 1) or (0.4, 0, 0.2, 1). Never back.out. |
| Glow halos, particles, parallax | Distracting. |
| Stock-photo crowds, generic shutterstock | Use the existing customer-* portrait library or hire a real shoot. |
| "Powered by AI" badges | We don't lead with the tech. |
| Multi-color gradients | Single-tone gold rules + dot accents are the entire decorative vocabulary. |
| Below-44px touch targets | WCAG 2.5.5 minimum. |
100vh on mobile heroes | Use 100svh. |
| Loading spinners that spin forever | Use the calculator's "Finding your plan…" pattern with a quiet pulse instead. |
| Modals that auto-show without dismissal persistence | The /plans WIP modal always shows because it's investor-facing notice; everywhere else, persist dismissal in sessionStorage. |
| Animation-driven UX states (where missing the animation breaks the flow) | Animations are enhancement only. The page must work statically. |
9. Accessibility
The brand uses warm, low-contrast tones by design. That means accessibility is not optional — it requires deliberate care because the editorial palette flirts with the WCAG line.
9.1 Color contrast audit
Computed against WCAG 2.1 AA / AAA thresholds. AA = 4.5:1 for normal text, 3:1 for large text (≥18px regular OR ≥14px bold). AAA = 7:1.
| Foreground | On cream | On paper | On white | On ink |
|---|---|---|---|---|
--af-ink (#141C2E) | 15.11 AAA | 15.77 AAA | 17.00 AAA | — |
--af-body (#3A404F) | 9.21 AAA | 9.62 AAA | 10.36 AAA | — |
--af-stone (#6A6F7C) | 4.47 ✗ AA | 4.66 AA | 5.03 AA | — |
--af-gold (#C9A967) | 2.00 ✗ | 2.08 ✗ | 2.24 ✗ | 7.57 AAA |
--af-gold-2 (#B8903F) | 2.63 ✗ | 2.74 ✗ | 2.96 ✗ | 5.75 AA |
--af-green (#1F6B49) | 5.73 AA | 5.98 AA | 6.45 AA | — |
--af-green-d (#184F36) | 8.44 AAA | 8.81 AAA | 9.50 AAA | — |
--af-red (#A85C3F) | 4.37 ✗ AA | 4.57 AA | 4.92 AA | — |
--af-red-d (#8B4730) | 6.14 AA | 6.41 AA | 6.90 AA | — |
--af-cream / --af-white on --af-ink | 15.11 AAA / 17.00 AAA | — | — | — |
Critical findings:
--af-stoneon--af-creamis 4.47 — fails AA at small sizes by 0.03. In practice that means: helper text in stone on the cream page background must be ≥18px regular or ≥14px bold (AA-large) OR must move to--af-body(which passes 9.21).--af-goldand--af-gold-2only meet AA on--af-ink— never use either as body-text color on light backgrounds. Reserve them for headings (≥18px), eyebrows on ink panels, and the italic-gold accent inside H2/H3 (where the font size always exceeds 18px).--af-redon--af-creamis 4.37 — fails AA. Use--af-red-dfor any red text on cream/paper that needs to read at body size.--af-redis fine for the strike-through 18px+ price treatment because the visible character is large enough for AA-large.- The italic-gold accent in H2/H3 is always safe because H-text is 30px+; AA-large 3:1 is met (gold-2 on cream is 2.63 — wait, that still fails 3:1). Implication: gold-2 italic accents should be on backgrounds where the contrast clears 3:1. On cream/paper (where it's 2.63-2.74), 3:1 isn't met. Rule: the italic-gold accent is decorative, and all critical meaning in a heading must be readable from the ink portion alone. The accent enriches but doesn't carry the message. This is OK because in every home use ("Healthcare.gov showed me $960/mo. I'm paying $7.") the surrounding ink text carries the message; the italic-gold is the emotional flavoring.
Concrete rules:
- Helper text →
--af-body(#3A404F), not--af-stone, when the font size is < 18px and the body bg is cream. --af-stoneis fine for ≥18px or for paper/white backgrounds.- For data values in plan cards (premium, deductible), the numerals are large enough (≥17px Outfit 600) to pass AA-large in both green and red. Don't shrink them below 17px without retesting.
- The italic-gold accent never carries critical meaning alone.
- Strike-through prices on the home use red (4.37 ✗) at large font size (40-56px) — AA-large passes. If you ever ship a small red strike (e.g. inline at 13-14px), use
--af-red-dnot--af-red.
9.2 Focus states
Every interactive element must have a visible focus ring. The canonical focus treatment, used on the calculator inputs (v4.css: 1712-1717):
css
outline: none;
border-color: var(--af-gold-2);
box-shadow: 0 0 0 3px rgba(184, 144, 63, 0.16);Apply the same recipe to:
- Buttons:
outline: 2px solid var(--af-gold-2); outline-offset: 2px; - Links inside body copy: native browser focus is fine (system underline + outline) since most are inside cards with bg-tinting.
- Cards (when they're the entire tappable area, like aud-tile): use the input recipe — gold-2 ring at 3px alpha.
Do NOT remove focus styles globally. If a treatment looks too heavy visually, fix it with the gold-2 ring at lower alpha — never outline: none without a replacement.
9.3 Keyboard navigation
- All interactive controls are reachable via Tab in document order.
- Skip-link to main content (NOT yet shipped — flag for the next accessibility pass).
- Modal dialogs trap focus (the takeover lifecycle does this; see
LandingCalculator.tsx). - Escape closes modals, drawers, and the takeover.
- Arrow keys are NOT used for navigation in any pattern (no custom carousels, sliders, tab-ARIA roles).
9.4 ARIA and semantics
- Use semantic HTML first. Native
<button>,<a href>,<form>,<input>— these carry the right semantics out of the box. - The takeover wrapper has
role="dialog"+aria-modal="true"and focus moves to the back button on entry. - Hamburger menu has
aria-label="Menu"andaria-expandedtoggles. - Eyebrows are decorative — they don't need ARIA roles.
- Plan cards in lists should have
<article>root with the plan name as the accessible heading. - Stat-strike (the through-line on $11,800) doesn't carry meaning on its own; the visible "$0" carries it. Don't add
<del>if the strike is decorative — it'll be read aloud as struck-through which is correct here.
9.5 Reduced motion
The system honors prefers-reduced-motion: reduce everywhere:
css
@media (prefers-reduced-motion: reduce) {
/* zero out all transitions + animations */
/* force end-states for any transform/opacity */
}Every animation in _home/v4.css includes a reduced-motion override. The takeover cinematic, the calc "searching" pulse, the polaroid hover lift — all collapse to instant transitions when reduced motion is requested. Maintain this discipline for every new pattern.
9.6 Color-blindness considerations
The home's conversion moment is built on red (sticker) → green (subsidized). This is the most-common form of color blindness (red-green deuteranopia / protanopia, ~5-8% of men). Mitigations already in place:
- The strike-through line is a graphic on top of the red text, carrying meaning by shape (line through), not by color alone.
- The save-pill says "Save 99%" in text, not just green.
- Premium labels ("Premium" / "Deductible" / "Max OOP") are always attached to numerals, never standalone color swatches.
For new patterns: never use color alone to convey state. Pair color with iconography, label text, or shape (strike-through, badge, border-style).
9.7 Touch targets
Minimum 44×44 px (WCAG 2.5.5). 48×48 preferred (HIG primary).
- ZIP input on mobile: 45 px (just barely AA; bump to 48 in next iteration).
- Hero CTA: 59 px on mobile ✓.
- Sticky mobile CTA: 59 px ✓.
- Audience tile (whole tile is link): 160 px tall ✓.
- Hamburger button: 24 px icon in 40+ px hit area ✓.
10. Spacing scale
The home uses values that align to a 4px base scale with these canonical multiples:
| Step | px | Common use |
|---|---|---|
| 0 | 0 | flush |
| 1 | 4 | hairline gaps, inline icon spacing |
| 2 | 8 | tight vertical rhythm (helper to input, label gap) |
| 3 | 12 | default form-field gaps, card internal spacing |
| 4 | 16 | small section padding, default card gaps |
| 5 | 20 | mobile section gutter (--af-gutter-m) |
| 6 | 24 | inter-element padding, eyebrow → H2 |
| 7 | 28 | card padding (proof col), form internals |
| 8 | 32 | H2 → first content, lifestyle copy → stat grid |
| 9 | 36 | lifestyle copy max margin |
| 10 | 40 | tablet section transition |
| 12 | 48 | desktop section gutter (--af-gutter-d), H2 → content |
| 14 | 56 | hero internal vertical |
| 16 | 64 | mobile section bottom padding |
| 18 | 72 | lifestyle spread internal gap |
| 20 | 80 | feature-strip top padding |
| 24 | 96 | ample vertical breathing |
| 30 | 120 | desktop section bottom padding |
| 35 | 140 | lifestyle spread margin-bottom |
Rules:
- Stay on the 4px scale. If you need a value not on this list, question whether you really need it.
- Don't introduce 22, 26, 30 (between 20/24 and 24/28/32). The current home has a few exceptions (28/22 in calc card padding) but these are tiny and the system won't grow more.
- Shrink at mobile by jumping down two steps: 120 → 64 (16→8 ratio doesn't hold — but the 64 mobile bottom matches all other sections).
11. State matrices
Every interactive element must define its full state set. Most components in the home today only document default + hover. The canonical specs:
11.1 Buttons
| State | Treatment |
|---|---|
| Default | bg --af-ink, color white, padding 16/28, radius 4, font Inter 15/600, ls 0.04em |
| Hover | bg --af-ink2 (+ same chevron arrow translates 2 px right via CSS) |
| Focus-visible | + outline: 2px solid var(--af-gold-2); outline-offset: 2px; |
| Active (pressed) | bg --af-ink2, transform translateY(1px) |
| Disabled | bg var(--af-stone), color var(--af-cream), cursor not-allowed, opacity 0.6 |
| Loading | text replaced with "Submitting…", disabled+pulse on a tiny gold dot to the left of label, button width reserved (no shift) |
11.2 Inputs (calculator pattern)
| State | Treatment |
|---|---|
| Default | bg --af-white, border 1px solid var(--af-line), Outfit 17/500 ink |
| Hover | bg stays --af-white, border rgba(184, 144, 63, 0.45) (gold-2 at 45%) |
| Focus-visible | bg --af-white, border 1px solid var(--af-gold-2), ring 0 0 0 3px rgba(184,144,63,0.16) |
| Disabled (broken / locked / unavailable) | bg --af-mist (#E8E5DF), color var(--af-stone), cursor not-allowed, opacity 0.6. Use when the field is genuinely off-limits (form-level lock, permission gate, error state). |
| Disabled-ready (placeholder for upcoming feature) | bg --af-white, border var(--af-line) (no opacity dim), cursor: not-allowed, aria-disabled="true", data-tooltip="Coming soon" (or similar). Use when the field is shape-correct and visually confident but not yet wired (e.g., /plans coverage inputs awaiting Phase C/D). The tooltip + cursor signal state without making the field look broken. |
| Error | bg --af-white, border 1px solid var(--af-red-d), ring 0 0 0 3px rgba(168,92,63,0.18) + helper text below in --af-red-d italic Playfair |
| Success | bg --af-white, border 1px solid var(--af-green-d), ring 0 0 0 3px rgba(31,107,73,0.16) (use sparingly; mostly for live-validation like ZIP code recognized) |
11.3 Cards
| State | Treatment |
|---|---|
| Default | bg --af-paper, border 1px solid var(--af-line), radius 4, shadow var(--af-shadow-card) |
| Hover (clickable cards only) | translateY(-1px), border-color var(--af-gold-2), shadow deepens |
| Focus-visible (clickable cards) | + outline: 2px solid var(--af-gold-2); outline-offset: 2px; |
| Active (pressed) | translateY(0), shadow returns to default |
| Loading (skeleton) | bg --af-cream, no border, shimmer pulse from left to right at 12% alpha gold |
| Empty | dashed border 1px dashed var(--af-line), bg transparent, italic Playfair empty-state copy at --af-stone |
11.4 Pills
| State | Treatment |
|---|---|
| Default | per the pill type (metal silver #DCD7C8/#5A5444, etc.) — see §3.5 |
| Hover (interactive only) | persona pills: bg #EAE5DB. Static pills (metal/HSA/save): no hover. |
| Focus-visible (interactive only) | outline: 2px solid var(--af-gold-2); outline-offset: 1px; |
| Active (pressed) | persona pills: opacity 0.85 |
| Selected (filter context) | persona-pill recipe + border: 1px solid var(--af-ink) + background: var(--af-ink) + color: var(--af-cream). To be used in the /plans filter chip pattern. |
12. Logo & asset library
The web-rendering subset is mirrored under public/brand/ and rendered on /design-system for the internal browseable reference (and on /brand-guide for the public-facing partner-and-press version). The full canonical asset library (with print PDFs, business cards, email signatures, social templates, web fonts in all subsets, vector QR codes) lives at:
~/Documents/Documents - Taha's MacBook Pro/image-assets/AskFlorence Assets/AskFlorence-Brand-Assets/Web-mirrored assets (in public/brand/):
| Folder / file | Use |
|---|---|
logo-primary-{cream,paper,ink,white}.png | Primary lockup (lantern + wordmark) for landing pages, decks, social covers |
logo-stacked-{cream,ink,tagline}.png | Vertical lockup for tight horizontals + when tagline is needed |
logo-icon-{cream,ink,sand}.png | Lantern only (no wordmark) for headers, app icons, watermarks |
logo-mono-{dark,light,pure-black,pure-white}.png | Single-color variants for print, mono partner co-branding, emergencies |
favicons/favicon-{16,32,64}-WEB.png | Browser tab icons |
favicons/apple-touch-icon-180-WEB.png | iOS home-screen pin |
favicons/app-icon-512-{cream,ink}-WEB.png | PWA + iOS/Android app icon |
social/linkedin-company-cover-1584x396-WEB.png | LinkedIn company banner |
social/linkedin-personal-cover-1584x396-WEB.png | LinkedIn personal banner (founders) |
social/twitter-header-1500x500-WEB.png | Twitter/X header |
social/instagram-story-1080x1920-WEB.png | Instagram story backdrop |
social/instagram-avatar-{cream,ink}-flame-WEB.png | IG / generic 400x400 avatar |
logo-full.{svg,png} + logo-mark.{svg,png} | Legacy aliases (kept for back-compat with existing references) |
Print / offline assets (canonical library only — do NOT mirror to public/):
| What | Path |
|---|---|
| Business cards (CMYK PRINT) | business-cards/*-PRINT.pdf |
| Business card previews (web) | business-cards/*-WEB.png |
| Email signature PNGs | email-signatures/email-signature-*-WEB.png |
| Vector QR codes (per-founder) | logos/qr_*.svg |
| Print logo masters (CMYK) | logos/*-PRINT.pdf |
| Brand kit print-shop guide | BRAND-KIT-GUIDE.md |
Logo usage rules:
- Maintain clear space equal to the core node diameter (≈ height of the "A" in the wordmark).
- Don't add nodes to the circuit, change the flame shape, or rotate the lantern.
- Don't apply gradients to the logo itself. The brand-asset library's rendered files are the only versions in circulation.
- The gold dot to the right of the wordmark is part of the lockup. Don't suppress it.
- For headers and small contexts, use
logo-icon-*or the wordmark-only treatment from the home nav. Don't shrink the full lockup below 120px wide.
Web fonts:
The home loads three families via next/font in src/app/layout.tsx — Playfair Display, Inter, Outfit. They're available everywhere via --af-font-serif, --af-font-sans, --af-font-label. Don't import fonts manually in component CSS; use the variables.
A fourth family (Fraunces) ships in the brand-asset library but is used only for brand-asset rendering (the wordmark and certain print materials). Don't add it to the web bundle.
13. Where the canonical reference lives in code
| What | Path |
|---|---|
| Tokens | src/app/_home/tokens.css |
| Full register CSS | src/app/_home/v4.css |
| Hero + section markup (SSR) | src/app/_home/target-body.ts |
| Calculator (the takeover + form) | src/app/_home/components/LandingCalculator.tsx |
| Page wiring + mobile injects + sticky CTA | src/app/_home/components/TargetPage.tsx |
| Plan card primitive | src/components/PlanCard.tsx (variants: default, compact, micro) |
| Price reveal primitive | src/components/PriceReveal.tsx (compact variant) |
| Hook for calculator pipeline | src/lib/hooks/use-calculator.ts |
| Internal design system (visual) | src/app/design-system/page.tsx (this doc rendered as a browseable page; noindex) |
| Public brand guide | src/app/brand-guide/page.tsx (partner / press / agent-facing identity guide; indexable) |
When patterns drift, the home is the authoritative source. Update this doc to match the home, not the other way around.
14. When you're building something new
- Wrap your page in
<div className="af-root">. - Import
tokens.cssandv4.cssfrom_home/if you need the full register, or pull just the tokens if you're building a one-off lightweight page. - Pick a class prefix (
af-l2-{name}__{element}). - Build sections with the canonical structure (see 2.3).
- Make sure every section has
class="af-section ..."so the mobile gutter override catches it. - Match the type scale from section 2.2.
- Use the editorial italic-gold accent ONCE per major heading.
- Default to static; add motion only where it has meaning.
- Verify on 360 / 393 / 768 / 1366 / 1440 / 1920 before considering it done.
- Run the calculator audit (
scripts/audit/calculator-baseline-diff.ts) if you touched any pricing math (you shouldn't be).
When in doubt, look at how the home does it. The home IS the design system.
15. /plans patterns (Wave 1, v0.36.0)
The marketplace browse page applies the editorial register to a list-view interaction. It introduces a focused set of /plans-only components on top of the home tokens. Every component uses the af-l2-plans__* class prefix and consumes the shared --af-* tokens. CSS lives at src/app/plans/plans.css.
15.1 Component spec
| Component | Path | Role |
|---|---|---|
PlansNav | src/components/plans/PlansNav.tsx | Editorial nav for the /plans route. Direct visual port of the home navbar (Fraunces wordmark, gold dot, cream bg, hairline). Inline links: Calculator / How it works / Stories. Right-side primary ink CTA "Edit my info ↗" → /#calculator. Hamburger drawer on mobile. |
MarketplaceHero | src/components/plans/MarketplaceHero.tsx | Savings-anchor hero. Eyebrow + H2 with italic-gold accent on second clause + green-chip annual savings line + one primary ink CTA "See best plan". Returns null when totalCount === 0. |
CoveragePanel | src/components/plans/CoveragePanel.tsx | The prominent doctor + Rx affordance below the hero. Two large disabled inputs side-by-side (⚕ doctor / ℞ prescription) + helper note. The user's primary intent on /plans (they came from the calculator's "Check doctor & drug coverage" CTA), so it sits in pride of place. Phase C/D will flip these inputs from disabled to live without restructuring the layout. |
MarketplacePlanCard | src/components/plans/MarketplacePlanCard.tsx | Editorial plan card. Carrier + plan name + plan ID + metal/type/star pills + big premium row with strike + ded/MOOP grid (per-person + family when household ≥ 2) + dedicated copays section (5 standard copays) + disabled coverage pills (⚕ / ℞) + disabled save heart (top-right) + footer plan-document links. 4px metal-tier left strip. |
FilterSidebar | src/components/plans/FilterSidebar.tsx | Desktop sticky left column. Metal pills, carrier checkboxes, plan-type pills, HSA toggle, premium / deductible range sliders. Bottom carries an italic-Playfair note signposting where Phase C/D coverage filters will surface (in CoveragePanel above, not here). |
FilterBottomSheet | src/components/plans/FilterBottomSheet.tsx | Mobile slide-up sheet using the same FilterSidebar body in variant="sheet" mode. Drag-handle, "Clear all" + "Apply (N)" footer with safe-area-inset-bottom, body scroll-lock, focus trap, Escape to close, backdrop click to close. |
SortPills | src/components/plans/SortPills.tsx | 4 sort options as a pill row (desktop) / native select with custom chrome (mobile). Active state = ink bg + cream text per §11.4 selected pill recipe. Designed to grow to 6 when Phase C/D adds rx-coverage and doctor-coverage sort options. |
RangeSlider | src/components/plans/RangeSlider.tsx | Dual-handle range with header showing current min / max formatted. Two stacked range inputs + track-fill overlay. Gold-2 thumb, accessible via Tab. |
EmptyState | src/components/plans/EmptyState.tsx | Italic Playfair "No plans match your filters." + helper + ink primary "Reset filters" CTA + tertiary "Edit my info" link. |
PlanLoadingSkeleton | src/components/plans/PlanLoadingSkeleton.tsx | 5 paper cards with shimmer keyframes. Same height + structure as MarketplacePlanCard so layout doesn't reflow on data load. |
PlansWipModal (restyled) | src/components/PlansWipModal.tsx | Editorial paper card, italic-gold accent on "still being painted." headline, ink primary CTA. Always-mount preserved by design — investor visits should see this every time until founder explicitly says to remove it. |
15.2 Disabled-affordance pattern
The save heart on each card and the two coverage pills (⚕ Check doctors / ℞ Check Rx) are visible-but-disabled in Wave 1. They carry:
aria-disabled="true"plus the nativedisabledattribute- Lower opacity (0.5 for save heart, 0.85 for coverage pills)
- Dashed border (coverage pills only) to signal "input field, not yet wired"
- A
data-tooltipattribute that surfaces via CSS::afteron hover/focus cursor: not-allowed
Screen readers announce "Save plan coming soon, button, dimmed". When Phase C/D ships, removing the disabled attr + wiring the onClick is the only change required — layout doesn't shift because the affordance is already in place.
15.3 Forward-compat hooks for Phase C/D
The /plans Wave 1 ships with explicit hook-points for the parallel doctor + Rx worktree (~/Developer/ask-florence-doctor-rx/):
CoveragePanelinputs flip from disabled to live; chips appear above the plan list when filled.- Card coverage pills flip from disabled to live, drop their
data-tooltip, and gain a state machine (default / loading / "3 of 3 covered"). SortOptiontype grows by two values ("rx_coverage_desc","doctor_coverage_desc") andSortPillsrenders 6 pills.FilterSidebargrows by two filter sections; the existing italic note signposts where they'll appear.
None of these hooks involve new MongoDB fields or API changes — all the data is already on each PlanDisplay (puf.formularyId, puf.networkId).
15.4 Visual reference
The /design-system page renders all /plans components in their default states under section 16. The home page itself (/) is still the authoritative implementation of the L2 register; the /plans surface is the register applied to a marketplace interaction.
16. Mermaid diagrams in docs
Mermaid is the canonical diagram tool for docs/ (VitePress + vitepress-plugin-mermaid). The architecture page and the agents workflows page are the reference implementations — both use a "small focused unit" pattern: short intro paragraph → one tight diagram → table → cross-reference.
16.1 Pick the right diagram type for the job
| Use this | When | Example in repo |
|---|---|---|
sequenceDiagram | "Who does what when" — the actor for each step matters | architecture.md state routing, workflows-and-pain-points today vs AskFlorence |
flowchart / graph TB, LR | Branching logic, decision trees, or a topology shown spatially | architecture.md AWS Org topology, workflows visual summary |
erDiagram | Showing relationships between data models | architecture.md MongoDB collections |
stateDiagram-v2 | Lifecycle of a single entity moving between states | consumer-agent-flow agent queue |
Sequence diagrams beat flowcharts when actor responsibility is the load-bearing question. The agents workflows page works because each row of the sequence shows who's doing the work. A flowchart hides that — actors are implicit. If the diagram is making an "X is overloaded" or "X is automated away" point, sequence diagram first.
16.2 Gotchas (we've hit these — don't repeat them)
| Gotcha | Symptom | Fix |
|---|---|---|
| Semicolons in message text | A->>B: foo; bar parses as two statements (A->>B: foo + bogus bar). The diagram silently fails to render with no error visible in the dev server output (Mermaid errors are client-side). | Use commas, hyphens, or break into two messages. Never put ; in sequenceDiagram message text. |
| Em dashes | Don't cause parse errors but violate the project style rule (no em dashes anywhere new). | Use hyphens (-) or commas. |
Activation operators + / - immediately after arrow | A->>+ B: msg activates B; A->>- B: msg deactivates B. Fine if intentional, confusing if accidental. | Inside message text + / - are fine. At the start of the participant ID (right after the arrow) they're activation operators. |
| Parens in participant alias with HTML break | participant X as Name<br/>(detail) works in modern Mermaid but some older versions choke on the parens. | If a Mermaid build pinned to an older version fails, wrap the display name in quotes: participant X as "Name<br/>(detail)". |
| Trailing colon on Note | Note over A,B: During the year: — fine, but the parser sometimes shows weird spacing. | Acceptable; just be aware. |
| Mermaid errors are client-side | The page returns HTTP 200 even when a diagram fails. The dev server log shows nothing. Errors only appear when you open the page in a browser (sometimes only as a missing diagram block, sometimes as a rendered error message). | When a diagram doesn't appear, open the page in a browser and check the DevTools console for the Mermaid parse error. Or bisect: comment out half the lines, see if the rest renders. |
16.3 Style conventions
- Diagrams are authoritative. When a diagram disagrees with code, the code is wrong (architecture.md TL;DR establishes this rule for the whole
docs/tree). - Mermaid colors should align with the project palette (
--florence-cloud,--florence-cream,--florence-gold, etc.) where styling matters. The workflows visual-summary diagram uses sand/blue/green/red/dark-green to encode "agent / member / AI / waiting / earns passively" — that color encoding stays consistent across the doc. - Don't hack invisible edges to control layout. The
~~~cross-subgraph anchor trick + transparent "legend" subgraphs (used in v1 of workflows-and-pain-points) is brittle. Prefer multiple smaller diagrams with proper headings + a separate legend table. - Caption each diagram. Short paragraph above explaining what to look for; short paragraph or table below tying the takeaway back to the larger narrative. The diagram alone shouldn't have to do the storytelling.
- Cross-link to ADRs / session logs in the prose around the diagram when the architecture has a decision-record.
16.4 Local preview
VitePress dev server (cd docs && npm run dev → localhost:5173) hot-reloads Mermaid diagrams on save. If your local dev fails with a Cannot find module '@tailwindcss/postcss' error, the docs subproject needs a docs/postcss.config.cjs stub with module.exports = { plugins: [] } — this shields Vite from walking up and picking up the parent Next.js app's Tailwind config. (Already in place since 2026-05-12.)