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-0 ↔ translate-x-full |
| Mini-cart slide-up | MiniCart |
transition-transform duration-300 ease-out + translate-y-0 ↔ translate-y-full (mobile), translate-x on desktop |
| Drawer backdrop fade | MobileNav, MiniCart |
transition-opacity + opacity-100 ↔ opacity-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 |
legal/¶
| 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.
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 TrustStrip — fill-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-foregroundonbg-background) is white on black — well above WCAG AAA. Muted copy (text-muted-foreground=#d4d4d4on black) is 12.6:1, AAA. Magenta on black (#fc1c80on#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-visiblering or an inlinefocus-visible:ring-2 focus-visible:ring-primaryif the default is invisible against the surface. Neveroutline: nonewithout 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-labelis 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-displayis a class, not a heading — never substitute a<div className="fp-display">for an<h1>. Use both. - Dialogs. Always
role="dialog" aria-label="…"plusaria-hidden={!open}, body-scroll-lock while open, and Escape to close. SeeMobileNavandMiniCartfor 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¶
- Use the token.
bg-primary/text-primaryalways — never a raw#fc1c80. Re-tinting later is a one-line CSS change if every reference goes through--fp-magenta. - Card pattern by copy-paste.
rounded-2xl border border-border/60 bg-white/[0.02]withhover:border-primary/40. Don't invent new card styles; if you need a variant, propose adding it to this doc. - Mobile-first padding. Start with
px-4 py-10, bump tosm:px-6 sm:py-14. Vertical headroom is part of the brand.
Don't¶
- 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.
- 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.)
- Don't widen
max-w-6xlor 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:
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.