Skip to content

Design system

The single source of truth for spacing, typography, motion, elevation, colour tokens, and layout. Read this before adding a button, card, page, or modal — and update it when you ship a new primitive that the codebase will reuse.

For voice + visual identity, pair this with Brand guidelines, Copy voice and UX strategy. This page covers the engineering contract; those cover the expressive one.

1. Colour tokens

All tokens are CSS custom properties declared in apps/web/app/globals.css (:root) and exposed to Tailwind via the @theme inline block. Always reference the token, never the hex.

Primary brand — magenta

Token Hex Tailwind When to use
--fp-magenta #fc1c80 bg-primary · text-primary · bg-magenta Primary CTA, price, active tab, primary icon tint
--fp-magenta-600 #e5006a hover:bg-[--fp-magenta-600] Hover/pressed state for any magenta surface
--fp-magenta-400 #ff4da0 (inline only) Lighter accents, focus rings
--fp-magenta-900 #b9165c (inline only) Shadow/depth tone inside the dragon-fruit glyph

Two derived tints used everywhere:

  • bg-primary/10 — magenta @ 10% opacity. Active drawer link background, "items in cart" pill, soft attention surface.
  • border-primary/40 — magenta @ 40% opacity. Hover border on cards, focus-visible ring fallback, "you can interact with this" cue.

Secondary accents

Token Hex Tailwind When to use
--fp-leaf #03a143 text-leaf "Fresh-picked", "In season", seasonal calendar hits
--fp-leaf-400 #1fb85a (inline only) Hover variant for leaf accents
--fp-sticker #faaf3f text-sticker Warmth — next-day badges, ripe indicator, notifications
--fp-dragon-pink #ff0080 text-dragon-pink Legacy alias of --fp-magenta; do not introduce new uses
--fp-pith #f5f5f0 (inline only) Off-white text on coloured fills (rare)

Rule of thirds: magenta dominates, leaf is the secondary accent, sticker is a rare highlight (under 5% of page surface).

Neutrals + semantic

Token Hex Tailwind Role
--fp-black #000000 bg-background Site canvas (dark mode is default)
--fp-white #ffffff text-foreground Body copy on canvas
--fp-neutral-900 #0a0a0a (inline) Image well backgrounds (bg-neutral-950 is the Tailwind sibling)
--fp-neutral-800 #171717 border-border Default border
--fp-neutral-700 #262626 bg-muted Inline muted surface (drawer link hover)
--fp-neutral-500 #737373 (inline) Muted text in light mode
--fp-neutral-300 #d4d4d4 text-muted-foreground Secondary copy

Semantic vars (--background, --foreground, --primary, --primary-foreground, --muted, --muted-foreground, --border) all alias the raw tokens and flip in the prefers-color-scheme: light media query — but only if the user explicitly opts in via data-theme="auto" on <html>. Dark is the default and the brand.

2. Typography

Loaded via next/font in apps/web/app/layout.tsx. Two families share two CSS variables.

Role Family Weights Source CSS var
Body Montserrat 400 / 500 / 600 / 700 Google Fonts --font-body
Display Grit (if public/fonts/grit.woff2 present) → Caveat Brush → cursive 400 self-host → Google → system --font-display

Tailwind picks up the body family via --font-sans so font-sans (the default) renders Montserrat. The .fp-display class (apps/web/app/globals.css) applies --font-display plus a -0.01em letter-spacing tweak.

Activating Grit

Drop the licensed grit.woff2 at apps/web/public/fonts/grit.woff2 and ship. The @font-face declaration in globals.css picks it up silently — when the file is missing the browser falls through to Caveat Brush automatically. No config flip, no JS toggle.

Scale

Mobile-first; bumped at the sm: breakpoint where it earns its place.

Use Tailwind Notes
Hero headline fp-display text-5xl sm:text-6xl LegalPage and PlaceholderPage hero
Section heading fp-display text-4xl sm:text-5xl AtlasPeek title, dialogs
Card heading (display) fp-display text-2xl MiniCart "Your cart", drawer "Menu"
Card heading (sans) text-base font-semibold TrustStrip item title, MiniCart line item
Body text-sm (14px) / text-base (16px) Default text-base only on long-form
Muted secondary text-sm text-muted-foreground Subtitles, helper copy
Caption text-xs text-muted-foreground Card body, tiny helper
Eyebrow text-xs uppercase tracking-wider text-muted-foreground "A botanical field guide" pre-titles
Tab label text-[10px] font-medium uppercase tracking-wider Bottom-tab labels (MobileNav)

Never mix the script face with form labels, data tables, or anything under 18px. fp-display is a heading face only.

3. Spacing scale

Tailwind's default 4px scale, used with a deliberate 4 / 6 / 10 / 14 rhythm. If a value isn't in this list, check whether the design wants it before adding a new step.

Step Tailwind Pixels Where it shows up
1 gap-1 / p-1 4 Icon-to-label intra-button
2 gap-2 / p-2 8 Icon button padding, header right cluster
3 gap-3 / p-3 12 TrustStrip item inner gap
4 gap-4 / p-4 / px-4 16 Mobile horizontal page padding, card inner padding
5 px-5 / py-5 20 Drawer header, MiniCart header
6 gap-6 / px-6 24 sm: horizontal page padding
8 mb-8 / gap-8 32 Section header → grid gap, header desktop nav gap
10 py-10 / gap-10 40 Page vertical padding (default), footer column gap
12 py-12 48 Footer outer padding
14 sm:py-14 56 Page vertical padding (sm:)
16 py-16 / py-24 64 / 96 Section break, placeholder hero

Layout-level constants

These are not "tokens" the codebase invents — they're the four combinations every page repeats:

mx-auto max-w-6xl px-4 sm:px-6   ← content container
max-w-3xl                          ← long-form / legal
max-w-md                           ← forms (login, search, single field)
py-10 sm:py-14                     ← page vertical rhythm

max-w-6xl is 72rem (1152px). Never widen it without UX strategy buy-in — the line-length math is tuned for the 16px body and Apple- grade mobile feel.

4. Elevation (surfaces)

Three surfaces. Cards stack with borders, not shadows — hover never adds shadow, only border colour.

a. fp-glass — sticky top header

Vision-Pro / macOS-Tahoe frosted: backdrop-filter: blur(24px) saturate(180%) plus a top-to-bottom black gradient and two 1px inset highlights (one bright top, one dark bottom). On supporting browsers, a CSS scroll-timeline deepens the surface as the user scrolls past the hero.

Use only for the sticky <header>. Implementation in apps/web/components/layout/Header.tsx.

b. fp-glass-bottom — mobile bottom tab bar

Same recipe, inverted. Highlights at the top edge, shadow points downward into the viewport. Pairs with pb-[calc(5px+env(safe-area- inset-bottom))] to clear the iOS home indicator.

Use only for the fixed mobile bottom nav. Implementation in apps/web/components/layout/MobileNav.tsx.

c. Card — rounded-2xl border border-border/60 bg-white/[0.02]

The everyday surface. Inline pattern, not a class — copy these four utilities verbatim. Hover adds border-primary/40 and no scale on lists. Image-led cards (AtlasPeek, ProductCard) may add group-hover:scale-105 on the inner <Image> only, never on the card itself.

Used in: TrustStrip, ProductCard, AtlasPeek family cards, MiniCart line-item thumbnails (a 16×16 variant), OrderCard, SavedBoxesList.

A sibling pattern — bg-neutral-950/95 with shadow-2xl — is used only by the install prompt. Don't introduce shadows elsewhere.

5. Motion

One ease curve, three durations.

Duration Token / utility When
150ms transition-colors (default) Hover state changes (link colour, border, fill)
200ms transition-colors duration-200 Default on interactive surfaces (buttons, cards)
300ms duration-300 ease-out Drawers, overlays, mini-cart slide
500ms duration-500 Image scale on card hover (only)

Never go longer than 300ms on a transform; never animate layout properties (height, width, top) on a route the user paid to reach.

Patterns in the wild

Pattern Where Code
Mobile drawer slide MobileNav transition-transform duration-300 ease-out + translate-x-0translate-x-full
Mini-cart slide-up MiniCart transition-transform duration-300 ease-out + translate-y-0translate-y-full (mobile), translate-x on desktop
Drawer backdrop fade MobileNav, MiniCart transition-opacity + opacity-100opacity-0 (with pointer-events-none)
Card image zoom AtlasPeek, ProductCard transition-transform duration-500 group-hover:scale-105
Header scroll densify Header CSS @supports (animation-timeline: scroll()) keyframes in globals.css
Skeleton shimmer ui/Skeleton animate-pulse (Tailwind built-in)
Splash sequence pwa/AppSplash hand-tuned ms timeline (250 / 500 / 900 / 1400ms)
Install prompt entrance pwa/InstallPrompt inline @keyframes fp-slide-up — 300ms ease-out, 12px rise + fade
Spinners Loader2 from lucide animate-spin

Reduced-motion (prefers-reduced-motion) is honoured by Tailwind's motion-reduce: modifiers — apply on transforms longer than 200ms if adding new motion that's larger than a colour swap.

6. Layout tokens

Container widths

Width Token Use
28rem max-w-md Forms (login, single-input pages)
32rem max-w-lg Bottom tab bar inner grid
48rem max-w-3xl Long-form (legal pages, placeholder hero)
72rem max-w-6xl All content sections (default)

Breakpoints

Tailwind defaults — kept untouched on purpose. We only design at three:

Token Min width What changes
sm: 640px Horizontal padding bumps to px-6, vertical to py-14, grids gain a column
md: 768px Mobile nav switches off, desktop horizontal nav appears
lg: 1024px Atlas + product grid go to 3–4 columns, footer to 4 columns

Safe-area padding (iOS)

Use env(safe-area-inset-*) everywhere a fixed surface meets a device edge. Three canonical recipes:

pt-[calc(10px+env(safe-area-inset-top))]      ← Header (extra 10px clearance)
pb-[calc(5px+env(safe-area-inset-bottom))]    ← bottom tab bar
pb-[calc(16px+env(safe-area-inset-bottom))]   ← MiniCart footer

The viewport is viewportFit: "cover" and zoom is locked (maximumScale: 1, userScalable: false). Don't unset these — they're load-bearing for the native-app feel.

z-index hierarchy

There is no --z-* design token; we use Tailwind's literals so the stack is greppable. The order is fixed.

z Tailwind Surface
40 z-40 Mobile bottom tab bar
50 z-50 Sticky header (fp-glass)
60 z-[60] Install prompt
70 z-[70] Mobile drawer backdrop
71 z-[71] Mobile drawer panel
80 z-[80] Mini-cart backdrop
81 z-[81] Mini-cart panel
100 z-[100] App splash (overlays everything during boot)

Don't introduce new layers between these. Drawers must sit above the header, mini-cart above drawers, splash above all. If you need a new dialog, slot it into the 70–81 range; if you need a new toast, use 90.

7. Components inventory

One-line description + path. Group by domain. All paths are apps/web/components/<group>/<File>.tsx.

account/

Component Purpose
AccountHeader.tsx Top card on /account — greeting, points placeholder, quick stats, logout pill
OrderCard.tsx Server-rendered order tile with tracking link + line-item thumbnails
ReorderButton.tsx One-tap reorder; loops a previous order's items into the cart and opens MiniCart
SavedBoxesList.tsx Server list of the customer's saved custom boxes; nudges to builder on empty
UpcomingDelivery.tsx Placeholder card for the (Phase 2) subscriptions panel

atlas/

Component Purpose
AtlasPeek.tsx Home-page teaser — six hand-picked botanical families as image cards

auth/

Component Purpose
LoginForm.tsx Email + password form; submits to /api/auth/login, redirects to ?next=
LogoutButton.tsx Pill button — POSTs /api/auth/logout, refreshes router
TikTokButton.tsx "Continue with TikTok" — kicks off OAuth on /api/auth/tiktok/authorize

box-builder/

Component Purpose
BoxBuilder.tsx The full custom-box experience — sections, qty steppers, server-priced summary, save flow

brand/

Component Purpose
Logo.tsx The wordmark + glyph image. White-on-dark only
SocialIcons.tsx Outline InstagramIcon, TikTokIcon, WhatsAppIcon (Lucide dropped these)
TrustStrip.tsx Three-up trust row: leaf / sticker / magenta accents under hero

cart/

Component Purpose
CartCount.tsx Tiny numeric badge that hydrates from the cart store
CartView.tsx Full /cart page body — line items, totals, checkout CTA
MiniCart.tsx Slide-up dialog (mobile bottom, desktop right) opened by Add-to-Cart

layout/

Component Purpose
Header.tsx Sticky fp-glass top bar — logo, desktop nav, account icon, cart pill
MobileNav.tsx Bottom tab bar + slide-in drawer + MobileMenuButton for the Header
Footer.tsx 4-column footer — wordmark, social, links, legal — with magenta fp-stripe
PlaceholderPage.tsx "Coming in Phase X" generic page wrapper for routes that aren't built yet
Component Purpose
LegalPage.tsx Wrapper for Terms / Privacy / Refund — long-form prose with prose-invert

product/

Component Purpose
ProductCard.tsx Server-rendered grid cell — square image, name, price, hover border
ProductGrid.tsx 2 / 3 / 4-column responsive grid with empty state
ProductActions.tsx Client island — variation selector, quantity stepper, add-to-cart

pwa/

Component Purpose
AppSplash.tsx Native-feel splash that runs once per session in display-mode: standalone
InstallPrompt.tsx Device-aware install banner; iOS = instructions, Chromium = native prompt
ServiceWorkerRegister.tsx Silent registration of /sw.js after idle

seo/

Component Purpose
JsonLd.tsx Renders one or more JSON-LD objects in a <script type="application/ld+json">

social/

Component Purpose
SocialEmbed.tsx Lazy-mounted Instagram / TikTok / native-video embed with branded fallback

saved-box/

Component Purpose
SavedBoxActions.tsx Public /b/{slug} actions — Add-all-to-cart and Customise-in-builder

ui/

Component Purpose
Skeleton.tsx The only loading primitive: Skeleton, SkeletonText, SkeletonCard

8. Icon system

lucide-react, outline, strokeWidth={1.75} as the project default. Sizing via Tailwind utility, never the SVG attribute.

import { ShoppingBag } from "lucide-react";
<ShoppingBag className="h-5 w-5" strokeWidth={1.75} />

Standard sizes:

Token When
h-3 w-3 (12px) Inline glyph inside a stepper button
h-4 w-4 (16px) Trust strip, in-line bullets, secondary controls
h-5 w-5 (20px) Header / nav / drawer / cart-bar primary icons

Brand glyphs (Instagram, TikTok, WhatsApp — Lucide removed them) ship as inline outline SVGs in components/brand/SocialIcons.tsx, matching the 1.75 stroke and currentColor tinting. Add new brand glyphs to that file, never as one-offs.

Never mix outline with filled icons. The single exception is Star in TrustStripfill-current strokeWidth={0} to read as a review star, not a vector.

9. Radii

Token Pixels When
rounded-md 6 Skeletons (Skeleton.tsx)
rounded-xl 12 Inputs, drawer link rows, mini-cart line-item thumbnail
rounded-2xl 16 All cards, dialogs, install prompt, the trust strip
rounded-full 9999 Buttons, pills, icon-only buttons, badges, avatars

rounded-t-2xl is used on the mobile mini-cart sheet's top edge so it matches the desktop dialog's full radius.

10. Accessibility baseline

Mobile-first, screen-reader-friendly, keyboard-complete. Non-negotiable.

  • Contrast. Body copy (text-foreground on bg-background) is white on black — well above WCAG AAA. Muted copy (text-muted-foreground = #d4d4d4 on black) is 12.6:1, AAA. Magenta on black (#fc1c80 on #000) is 5.6:1 — passes WCAG AA for normal text and AAA for large text. Don't put magenta text on any non-black surface without re-checking contrast.
  • Focus. Every interactive element gets the browser's default focus-visible ring or an inline focus-visible:ring-2 focus-visible:ring-primary if the default is invisible against the surface. Never outline: none without a replacement.
  • Tap targets. 40×40px minimum, 44×44px for primary actions. The Header account/cart cluster, drawer links, and bottom-tab cells all meet this.
  • aria-label is required when: the button has only an icon (Header account, MiniCart close, drawer close, cart bar X), the link's text is shorter than the user intent (<Link aria-label="Fruit Plug — home"> on the logo), or the control opens a region (aria-label="Open menu").
  • Heading order. One <h1> per page, then <h2> for sections. fp-display is a class, not a heading — never substitute a <div className="fp-display"> for an <h1>. Use both.
  • Dialogs. Always role="dialog" aria-label="…" plus aria-hidden={!open}, body-scroll-lock while open, and Escape to close. See MobileNav and MiniCart for the canonical pattern.
  • Reduced motion. Use motion-reduce: for any transform > 200ms.
  • aria-current="page" on the active bottom-tab link.

11. Do / don't

Three of each, distilled from UX strategy and Brand guidelines.

Do

  1. Use the token. bg-primary / text-primary always — never a raw #fc1c80. Re-tinting later is a one-line CSS change if every reference goes through --fp-magenta.
  2. Card pattern by copy-paste. rounded-2xl border border-border/60 bg-white/[0.02] with hover:border-primary/40. Don't invent new card styles; if you need a variant, propose adding it to this doc.
  3. Mobile-first padding. Start with px-4 py-10, bump to sm:px-6 sm:py-14. Vertical headroom is part of the brand.

Don't

  1. Don't ship a shadow. Cards stack with borders. The only shadowed surface is the install prompt — and it's a dialog, not a card. Shadows on dark backgrounds read as muddy.
  2. Don't use emoji in the UI. Lucide-only inside the product. Save emoji for Instagram captions and never the PDP, button, nav, or email body. (See copy voice.)
  3. Don't widen max-w-6xl or unset zoom-lock. Both are tuned for the Apple-grade mobile feel. Touch them and the home/PDP feels like a desktop port on the iPhone 14 viewport.

12. Changing tokens

Where they live

apps/web/app/globals.css:

  • Raw palette:root { --fp-magenta: …; --fp-leaf: …; … }
  • Semantic aliases:root { --background: var(--fp-black); … }
  • Tailwind exposure@theme inline { --color-primary: var(--primary); … }

How Tailwind picks them up

Tailwind v4's @theme inline block lifts each --color-* / --font-* variable into a utility — --color-primary becomes bg-primary and text-primary, --font-display becomes font-display. Don't add a Tailwind config file. When you add a new token, add it once to :root, once to @theme inline, then it's reachable from every component without a rebuild dance.

Light-mode override

Triggered only when the user sets data-theme="auto" on <html> and the OS prefers light. Override block lives in globals.css under @media (prefers-color-scheme: light). Keep edits to the semantic aliases (--background, --foreground, --muted-foreground, --border) — never invert raw brand tokens.

Bumping the icon generator

If you change apps/web/public/brand/logo.png or want a new background colour for the PWA icons, regenerate:

cd apps/web
node scripts/generate-icons.mjs
node scripts/generate-mascot.mjs

The script samples palette swatches directly from the PNG, so a new brand-pink would update --fp-magenta-* and the icon background in one pass. Re-deploy the static public/brand/ and public/icons/ folders — no plugin or runtime change needed. See Brand guidelines › Favicon & PWA icons for the full file matrix.