# 05 · Design system (mono)
Self-contained spec. This file is the source of truth for tokens + motion; the
tokens and CSS below are **already implemented** in `app/globals.css` and
consumed across `components/` and `app/` — this is not aspirational, it's a
description of what ships today, kept here so new screens stay on-brand.
Derived from the **mono / EVASION** template (itself v0-generated). Light, editorial, monochrome, sharp, with a single red accent and animated grain + scroll-reveal motion. **No photography** — imagery is rendered data. (Verified: no `` or `next/image` usage anywhere in `app/` or `components/` except a single unused leftover component, see "Vestigial component, not on the 'no photography' path" further down.)
> **Doc-index gap:** `CLAUDE.md`'s documentation index lists two files this doc used to point to — `docs/design/0pt-v0-build-brief.md` (per-component v0 prompts) and `docs/design/0pt-design-coverage-and-visuals.md` (full page/component inventory + visual rules). **Neither file exists anywhere in this repo** (confirmed via full-repo search). They are referenced but were never written. This doc no longer treats them as available references — anything they were supposed to cover (per-component prompts, the full visual-mapping table) is not documented anywhere right now. Whoever owns design docs next should either write them or remove the rows from `CLAUDE.md`'s index; not resolved in this pass.
## Tokens
Verified against `app/globals.css` (the file actually imported by `app/layout.tsx`):
```
THEME (light, default):
--background #ffffff --foreground #0a0a0a --muted-foreground #737373
--border #e5e5e5 --card #ffffff --input #f5f5f5
THEME (dark sections — footer, editorial/manifesto block, technology/engine block):
--background #0a0a0a --foreground #fafafa --muted-foreground #a3a3a3 --border #262626
ACCENT (single, sparing): red --accent-red #e40014 (one CTA / one number / one marker per screen)
PROVENANCE squares (ONLY non-mono color besides the red CTA):
level 0 #737373 (unavailable/licensed) · 1 #143aff (single source) · 2 #fcbb00 (partial) · 3 #00d294 (corroborated)
RADIUS: 0 everywhere (`--radius: 0rem` in app/globals.css; shadcn/ui components inherit --radius-sm/md/lg/xl derived from it, so they render square too).
TYPE: Inter (everything; headlines weight 500–600, letter-spacing -0.02em, large) · JetBrains Mono (eyebrows, labels, numbers, codes, table headers — UPPERCASE, letter-spacing mostly 0.08–0.22em but ranging down to 0.02em on some labels/table headers (e.g. `provenance-badge.tsx`, the source table headers in `app/kallor/page.tsx`), ~10–12px, #737373). Loaded via `next/font/google` in `app/layout.tsx` as CSS vars `--font-inter` / `--font-jetbrains-mono`.
GRID: 8px base; graph-paper hairlines; generous whitespace; left-aligned headlines.
SECTION SPINE: real ordered indices 00–06 as mono eyebrows (hero=00, philosophy=01, catalog=02, [03 unused/reserved], collection/coverage=04, testimonials=05, pricing=06). The two dark sections (editorial "Manifesto", technology "The engine") intentionally break the numbered spine — they carry a text eyebrow instead of a number.
```
**Correction — a third section also breaks the spine, and it isn't dark:** verified render order in `app/page.tsx` is Hero(00) → Philosophy(01) → FeaturedProducts/Catalog(02) → **Technology (dark, no number)** → **GallerySection (no number)** → Collection(04) → **Editorial (dark, no number)** → Testimonials(05) → Pricing(06) → Footer(dark). `components/sections/gallery-section.tsx` sits exactly where a "03" would fall but carries a text eyebrow (`"— Visualizations"`) like the dark sections, plus its own unrelated local `01`–`05` indices on its five tiles (a per-tile counter, not a continuation of the page's section spine — don't confuse the two). So "03 unused/reserved" is accurate in that no section ever shows the numeral 03, but three sections break the numbered spine, not two — only two of those three happen to be dark-themed.
**Correction vs. previous version of this doc:** the dark theme was previously described as appearing on "footer, one editorial block" (2 places). Verified in `components/sections/`: **three** sections actually use the dark theme — `footer-section.tsx`, `editorial-section.tsx` (the "Manifesto" block), and `technology-section.tsx` (the "#motorn" / "The engine" block, `id="motorn"`). All three set `className="dark ..." ` and a `bg-[#0a0a0a] text-[#fafafa]` pairing.
**Implementation note (not yet reconciled in code):** `editorial-section.tsx` and `technology-section.tsx` use the documented dark border `#262626` consistently. `footer-section.tsx` instead hardcodes `#1f1f1f` for its borders — a different, undocumented shade. All three sections hardcode hex literals (`#0a0a0a`, `#fafafa`, `#737373`, `#a3a3a3`, plus the two different border shades) rather than referencing the `.dark` CSS-variable overrides that already exist in `app/globals.css`'s `--border`/`--foreground`/`--muted-foreground` tokens. This works today because the values happen to match (except the footer border), but it means the dark sections don't actually pick up `.dark`-scoped custom-property overrides — they're hand-pinned. Flagging as a small cleanup opportunity, not fixing here (out of scope for a docs pass).
## globals.css (already live in `app/globals.css` — do not re-add elsewhere)
The block below is the actual motion + grain CSS, condensed for reference. It ships today in `app/globals.css` (with the keyframes/classes spelled out at full length rather than this compact form — functionally identical):
```css
@keyframes reveal-up{0%{opacity:0;transform:translateY(60px)}to{opacity:1;transform:translateY(0)}}
@keyframes reveal-left{0%{opacity:0;transform:translateX(-60px)}to{opacity:1;transform:translateX(0)}}
@keyframes reveal-right{0%{opacity:0;transform:translateX(60px)}to{opacity:1;transform:translateX(0)}}
@keyframes scale-in{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}
@keyframes fadeIn{0%{opacity:0;transform:scale(1.02)}to{opacity:1;transform:scale(1)}}
@keyframes slideUp{0%{opacity:0;transform:translateY(100%)}to{opacity:1;transform:translateY(0)}}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-20px)}}
@keyframes grain{0%,100%{transform:translate(0,0)}10%{transform:translate(-5%,-10%)}20%{transform:translate(-15%,5%)}30%{transform:translate(7%,-25%)}40%{transform:translate(-5%,25%)}50%{transform:translate(-15%,10%)}60%{transform:translate(15%,0)}70%{transform:translateY(15%)}80%{transform:translate(3%,35%)}90%{transform:translate(-10%,10%)}}
.reveal{opacity:0}
.reveal.in-view{animation:.8s ease-out forwards reveal-up}
.reveal-left.in-view{animation:.8s ease-out forwards reveal-left}
.reveal-right.in-view{animation:.8s ease-out forwards reveal-right}
.reveal-scale.in-view{animation:.6s ease-out forwards scale-in}
.reveal-fade.in-view{animation:.7s ease-out forwards fadeIn}
.delay-1{animation-delay:.1s}.delay-2{animation-delay:.2s}.delay-3{animation-delay:.3s}.delay-4{animation-delay:.4s}.delay-5{animation-delay:.5s}.delay-6{animation-delay:.6s}
.animate-float{animation:6s ease-in-out infinite float}
.grain-overlay::before{content:"";position:absolute;inset:-100%;opacity:.03;pointer-events:none;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");animation:8s steps(10,end) infinite grain}
@media(prefers-reduced-motion:reduce){.reveal,.reveal-left,.reveal-right,.reveal-scale,.reveal-fade{opacity:1;animation:none}.animate-float,.grain-overlay::before{animation:none}}
```
The provenance colors and `--accent-red` also live in `app/globals.css` as CSS custom properties (`--accent-red`, `--prov-0..3`), exposed to Tailwind via `@theme inline` as `--color-accent-red`, `--color-prov-0..3`.
**Known leftover, unused file:** `styles/globals.css` also exists in this repo and is **not** imported anywhere (`app/layout.tsx` imports `./globals.css`, i.e. `app/globals.css`, only). `styles/globals.css` is a stray copy of the original shadcn/v0 scaffold — it uses `oklch()` colors, Geist/Geist Mono fonts, and `--radius: 0.625rem`, none of which match this design system. It is dead code. Don't edit it expecting it to affect the app, and consider deleting it in a future cleanup (not done in this pass — out of scope).
## Reveal / motion system
Three pieces work together; the doc previously only described one of them:
1. **`useReveal()` hook** — `hooks/use-reveal.ts`. Wire a section's ref with it; descendants carrying `.reveal` (+ optional `.reveal-left/-right/-scale/-fade` and `.delay-N`) animate to `.in-view` via `IntersectionObserver` (`threshold: 0.15`, `rootMargin: "0px 0px -10% 0px"`) when the section scrolls into view. **Difference from a naive IntersectionObserver:** if the section's top edge is already inside the viewport on mount (e.g. direct navigation to a page whose hero is already visible), the hook applies `.in-view` immediately via `requestAnimationFrame` instead of waiting for a scroll event — this avoids a flash of invisible content on first paint.
```tsx
"use client";
import { useEffect, useRef } from "react";
export function useReveal() {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const applyReveal = () => {
el.querySelectorAll(".reveal, .reveal-left, .reveal-right, .reveal-scale, .reveal-fade")
.forEach((n) => n.classList.add("in-view"));
};
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight * 0.9) {
requestAnimationFrame(applyReveal);
return;
}
const io = new IntersectionObserver((entries) => entries.forEach((e) => {
if (e.isIntersecting) { applyReveal(); io.unobserve(el); }
}), { threshold: 0.15, rootMargin: "0px 0px -10% 0px" });
io.observe(el);
return () => io.disconnect();
}, []);
return ref;
}
```
2. **``** — `components/route-reveal.tsx`, mounted once, globally, in `app/layout.tsx` inside the providers. On every client-side route change (via `usePathname()`), it force-applies `.in-view` to any `.reveal*` element whose top edge is already within 95% of the viewport height — covering standalone pages (e.g. `/bestall`, `/katalog/[id]`) that render fully visible on load, where per-section `IntersectionObserver`s might not fire before paint. Runs on an immediate pass, a `requestAnimationFrame` pass, and a 600ms-delayed pass (for async-loaded content). **This component was not documented in the previous version of this file** — it's load-bearing for anything not covered by `useReveal()`'s own "already visible" check (e.g. content that becomes visible slightly after mount).
3. **CSS keyframes/classes** in `app/globals.css` (above) define the actual animation.
Every section wraps in a tag using `useReveal()`; children get `reveal[/-left/-right/-scale/-fade]` + `delay-N`. This pattern is used consistently across `components/sections/*.tsx`.
## Buttons & labels
- Primary: solid `#0a0a0a` (`--foreground`) bg, `--background` text, square, mono uppercase ~12px, padding varies by placement (header CTA and hero CTAs use `px-5..7 py-2.5..4`, i.e. roughly 10–16px vertical / 20–28px horizontal — treat the doc's original "14px 26px" as an approximate baseline, not an exact contract); hover → bg `var(--accent-red)`.
- Secondary: transparent, 1px `border-foreground`; hover → bg `--foreground`, `--background` text.
- Eyebrow: mono uppercase 11px, spacing ~0.14–0.18em, `--muted-foreground`, with a 2-digit index where the section spine applies (e.g. `"01 — Philosophy"`).
- Headlines: sentence case, never uppercase.
- **Implementation note:** there is no shared `Button`/`MonoButton` component implementing this pattern — `components/ui/button.tsx` is the generic, unmodified shadcn/ui button (rounded via `--radius-*` tokens, which happen to resolve to 0 here, but otherwise a stock `cva` variant set: default/destructive/outline/secondary/ghost/link). The mono square CTA look described above is hand-rolled per call site as inline Tailwind classes on raw ``/`