Design System
Components
The website's component library is small, flat, and organized by role: a handful of layout/ chrome pieces, a set of ui/ primitives, content cards/, and one *Section.astro per page-builder section that SectionRenderer dispatches. Almost every component is surface-aware — it ships its dark values plus a light: override, so the same atom reads correctly on the dark page or inside a cream reading island, and "gold never touches paper" stays automatic.
Everything lives under website/src/components/. Components render Sanity fields directly — when a field is empty the markup collapses, with no hardcoded fallback copy (defaults come from Studio initialValue + seed scripts). For the tokens and the light: variant that drive the surface system, see Brand tokens & theme and Tailwind v4.
How the library is organized
The folders map to responsibility, not to a deep atomic hierarchy:
| Folder | What's in it |
|---|---|
layout/ | Header.astro, Footer.astro — site chrome, data-driven from siteSettings |
ui/ | atoms + composites: Button, Pill, Stat, SectionHead, SectionShell, Marquee, WorkCard, FauxBrowser, ImageComparison |
cards/ | TestimonialCard, PricingTier, FaqItem, ProcessStep, ServiceCard, PostCard |
sections/ | one *Section.astro per page-builder section (HeroSection, WorkSection, RichTextSection, …) |
blog/ | BlogPost, AuthorByline, RelatedPosts — used by /blog/[slug] |
portfolio/ | PortfolioPost — used by /portfolio/[slug] |
Two top-level files glue the section layer together:
SectionRenderer.astro— aswitch (s._type)over thesections[]discriminated union. It wraps every section in<AnimatedSection>(the scroll-reveal toggle) and dispatches to the matching*Section.astro, threading reference-fallback arrays (clients/testimonials/caseStudies) so alogoWallSection/testimonialsSection/workSectionwith no manual picks falls back to the featured docs. The full section catalog lives in Section types; adding one is covered in Adding a section.PostGrid.astro— the shared blog + portfolio index grid.mode="blog" | "portfolio"plus avariantofstandard(3-col cards),featured(hero card + grid), ormosaic(asymmetric 12-col,[wide, std, std, wide, std]rhythm per 5-item row). It renders every item throughPostCardand wraps each cell in adata-revealdiv.
stega breaks string equality — clean before any logic
In draft/preview mode the Sanity client encodes every string with invisible Unicode chars for click-to-edit. Those chars are invisible but not zero-length, so variant === 'mosaic' and palettes[colorway] lookups silently miss. Any Sanity string used as a comparison, switch, or object-index key must be stegaClean()'d first. PostGrid cleans variant; WorkCard cleans colorway (palettes[stegaClean(colorway)] ?? palettes.forest) so an unknown value can't leave the palette undefined and throw on palette.bg. Rendering a plain string ({post.title}) needs no cleaning. See Visual editing for the full rule.
UI primitives
Button
ui/Button.astro renders an <a> (when href is set) or a <button>, merging classes via twMerge. Five variants:
primary— neutral high-contrast (paper-on-ink, inverts to ink-on-paper vialight:). Default.gold— the paid-conversion button; solid gold, reads on any surface.forest— secondary; solid forest.ghost— tertiary outline; inverts by surface.ghost-paper— outline tuned for dark backgrounds specifically (nolight:flip).
<Button variant="gold" href="/contact" arrow>Book a strategy call</Button>
primary/gold/forest pad to px-5.5 py-3.5 (≈48px tall, so they already clear the touch-target floor); the outline variants use px-5 py-3. Pass arrow to append a →. primary/ghost invert by surface because they're the neutral buttons; gold/forest are solid brand fills that read anywhere.
Button copy must be action-led
Label buttons with the action: "Book a strategy call", "Request a proposal". Never "Learn more" or "Submit". This is a brand-voice rule (receipts over promises), not a styling one.
Pill
ui/Pill.astro is a rounded label span. Variants solid / outline / gold, with an optional leading dot. gold is the brand pill that reads on any surface; solid/outline invert by surface. The dot color is surface-derived (text-gold light:text-forest), except on the gold pill where it's forest.
Stat
ui/Stat.astro renders a big number + label (+ optional unit and sub). Size is md / lg / xl (the number scales 44px → 64px → 88px); tone is ink / gold / forest. The gold tone is surface-aware — gold on dark, forest on light — so a gold accent stat never renders gold text on a cream surface.
SectionHead
ui/SectionHead.astro is the shared eyebrow + <h2> + optional ghost-CTA header used by ~10 sections. It carries data-reveal on its root <header>, so every consuming section gets its heading as a reveal unit for free. The H2 is rendered with set:html={renderAccentHeading(title)} so inline *asterisk* accent words become surface-derived <span>s (see Typography).
<SectionHead eyebrow={eyebrow} title={title} cta={{label, href}} />
toneis deprecated — color is now derived from the section's surface via thelight:variant. The prop is accepted but ignored; don't pass it.border(defaulttrue) draws a bottom hairline under the heading. Turn it off for sections that draw their own divider (e.g. FAQ, whose first row already has a top border).
SectionShell
ui/SectionShell.astro owns each section's outer chrome: the <section> landmark, the max-width content wrapper, the anchor scroll offset, the surface tone, and the panel-vs-full-bleed treatment driven by the Studio sectionLayout lever.
- Panel (default) — content sits in a floating rounded
.section-panelcard above the grid backdrop. - Full-bleed — the chosen background spans edge-to-edge (or transparent, so the grid shows through).
Tone is derived from the background: paper/paper-2 → light (ink text, forest accents), everything else → dark. It writes data-surface={tone} on the <section> — the ancestor that the light: custom variant keys on. innerClass overrides the inner wrapper max-width; class adds classes to the panel surface (panel mode only).
stegaClean is load-bearing in SectionShell
mode and background are Sanity strings used as logic/lookup keys (stegaClean(layout?.mode) === 'fullBleed', BG_CLASS[bg]). Without stegaClean() the draft-mode invisible chars break the comparison and the panel/full-bleed and background-color logic silently fall to defaults in preview only.
Marquee, WorkCard, FauxBrowser
ui/Marquee.astro— abg-cardband bordered top and bottom in gold, with gold four-cusped astroid star separators (a hand-tuned SVG hypocycloid, not a glyph) between phrases;highlighteditems render in gold. The client script clones one server-rendered sequence until it overflows the viewport, then translates by exactly one sequence's pitch for a seamless loop at any width. It'saria-hidden, and reduced-motion / no-JS render a static row.ui/WorkCard.astro— a case-study card with three colorways (forest/gold/ink, keyed via astegaClean'd lookup with aforestfallback), a decorativeFauxBrowserscreen, and an optionalheadlineStatand corner↗that rotates on hover.min-h-105, 3-up at desktop.ui/FauxBrowser.astro— pure-CSS browser chrome (threebg-current/30dots over a gradient pane) usingcurrentColorso it tints to whatever card it sits in. Decorative;aria-hidden.
Cards
cards/ holds the repeated content blocks. All are surface-aware and most are wrapped in a data-reveal div by their parent rather than carrying data-reveal themselves (so the reveal transition can't clobber their own hover/open transitions).
| Card | Notes |
|---|---|
TestimonialCard | featured = self-contained forest-deep card that pins its own data-surface="dark" so accents stay correct even inside a light section; standard = bg-card/paper-2 following the section surface. Five-star row, auto-computed initials avatar. |
PricingTier | featured flips to a cream contrast card (bg-paper + forest border + accents — no gold text on paper) with a flag badge; other tiers are dark cards with gold accents. Takes reveal direction + revealDelay for the synced tier-row entrance. |
FaqItem | native <details>/<summary> with the group-open: Tailwind variant; the plus icon is two CSS bars that rotate 45° into a × (drawn in <style>, not a glyph, so it sits dead-center). |
ProcessStep | numbered step card (Step 01…), bg-card/paper-2, hover border. |
ServiceCard | numbered service card with optional pills and href (renders <a> vs <div> accordingly), gold number chip. |
PostCard | the blog and portfolio grid card. size is standard / hero / wide; stamps data-surface="light" so it stays cream over the dark page; builds webp srcset/sizes; accepts an optional headlineStat badge for portfolio cards. See Blog & portfolio. |
The eyebrow is a class, not a component
Eyebrow is intentionally not a component — it's the .eyebrow utility class in src/styles/global.css. It's a small mono uppercase label:
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
Apply it with the color as a Tailwind utility — color is the only thing callers vary, and it's surface-derived via the same light: mechanism as the rest:
<span class="eyebrow text-gold light:text-forest">Selected work</span>
It's plain CSS rather than a component (or @apply, which tailwind.md forbids) because the pattern carries no logic. The general rule: treat short utility-styled spans as classNames, not components, unless they carry logic.
Touch targets — standalone interactive controls (WCAG 2.5.8)
Standalone links need a ≥48×48px hit area on touch
Any standalone interactive control — a bare text link, an icon-only button, a link styled as inline text — must present a ≥48×48px hit area on touch devices, even when its visible text is shorter. Buttons and button-styled <a>s usually clear this through their padding (px-5.5 py-3.5), so this is mostly about the standalone links that don't. In-text hyperlinks inside a paragraph are exempt (the WCAG inline exception) — don't shim those.
Don't inflate the visible element. Overlay an invisible, transparent hit-expander span, gated to coarse pointers so a mouse isn't given an oversized focus-stealing target:
<a class="group relative inline-flex items-center gap-1.5 ..." href={href}>
{label}
<span class="absolute top-1/2 left-1/2 size-full min-h-12 min-w-12 -translate-1/2 pointer-fine:hidden"></span>
<span class="..." aria-hidden="true">→</span>
</a>
The <a> is relative; the span is absolute, centered (top-1/2 left-1/2 -translate-1/2), size-full so it never shrinks below the visible bounds, and min-h-12 min-w-12 (48px) so it grows past them when text is small. pointer-fine:hidden removes it on mouse/trackpad. It carries no text and no aria — a pure hit-area shim, transparent to the a11y tree.
Stacked / clustered targets
The overlay alone is only safe for isolated links. A 48px shim on 24px-tall text reaches 12px past each edge, so when neighbors sit <24px apart the shims overlap and the lower one (later in DOM) steals taps meant for the upper. For dense groups, widen spacing on touch instead of overlaying harder (Header.astro and Footer.astro use all three patterns):
- Tight vertical lists (footer link columns, mobile-nav children) — keep the shim and add
pointer-coarse:gap-6to the list so the 48px boxes tile without overlap. Fine pointers keep the compact gap, so desktop is pixel-identical. - A link beside its own control (nav link + dropdown caret) — give the link a height-only shim (
min-h-12, dropmin-w-12) so it can't grow sideways into the control, and size the icon button up on touch (pointer-coarse:h-12 pointer-coarse:w-12) rather than overlaying it. - Rows that already tile with no gap (dropdown panel items) — grow the row's own padding on touch (
pointer-coarse:py-3) to reach 48px; no overlay needed.
See Accessibility for the broader a11y posture.
Section component conventions
Every *Section.astro follows the same shape so URL anchors, accessibility names, and analytics handles behave consistently. In practice most sections delegate this to SectionShell, which emits the markup below:
<section data-section="<slug>" aria-label="<purpose>" ...>—data-sectionis hardcoded per component (repeats safely; analytics-friendly).aria-labelis a static string that gives the section a screen-reader name.- The inner wrapper carries the URL anchor only when
anchoris passed:<div id={anchor} class="mx-auto ... scroll-mt-16">. With noanchorthere's noid, so dropping the same section twice on a page never collides silently. scroll-mt-16on the wrapper offsets the sticky header (h-16= 64px) for hash navigation.
Pass anchor="..." from the page that assembles sections (e.g. <CtaSection anchor="contact" />) — don't bake URL anchors into the component itself.
Sections render their props directly with no hardcoded copy, so empty fields collapse cleanly. Coerce array props to [] defensively — Sanity returns null (not undefined) for missing array fields, and JS default parameters only catch undefined. Two universal controls thread through SectionRenderer: hideSection (filtered out at GROQ fetch time, never reaches the renderer) and animate (the per-section scroll-in toggle). Reveal-unit wiring — where data-reveal goes and the placement traps — is documented in Scroll-in reveals; the hero headline entrance is Hero headline animation.
Where this lives
- Inventory + conventions —
website/CLAUDE.md(the "Components", "Section component conventions", and "Touch targets" sections). - Layout —
website/src/components/layout/Header.astro,Footer.astro. - UI primitives —
website/src/components/ui/(Button.astro,Pill.astro,Stat.astro,SectionHead.astro,SectionShell.astro,Marquee.astro,WorkCard.astro,FauxBrowser.astro,ImageComparison.astro). - Cards —
website/src/components/cards/(TestimonialCard.astro,PricingTier.astro,FaqItem.astro,ProcessStep.astro,ServiceCard.astro,PostCard.astro). - Sections + dispatch —
website/src/components/sections/*Section.astro,website/src/components/SectionRenderer.astro,website/src/components/PostGrid.astro. - Blog / portfolio composites —
website/src/components/blog/,website/src/components/portfolio/. - The
.eyebrowclass +light:variant —website/src/styles/global.css.