# Sean Tippen Design System - Build Contract If you read only this file, you can still build a correct on-system page without loading the CSS. Live reference and live examples: https://design.seantippen.com/ This is a dark-first, OKLCH, sharp-cornered system for data-dense dashboards and single-page references. Two stylesheets carry the whole thing: palette, three webfonts, type scale, spacing, motion, and every component named below. --- ## The system in one idea: rationed color on a flat dark field Everything is a flat, cool-dark canvas with one rationed cyan accent. Depth comes from lightness steps on a five-level surface ladder, never from decoration. Corners are square. Numbers are monospace with tabular figures so columns lock. Color is signal: cyan means interactive or current, status colors mean status, and nothing is colored just to look colored. Density is fine and expected. A dashboard is supposed to carry a lot of numbers. Calm comes from structural discipline (one spacing scale, one label size, one type hierarchy, one accent), not from removing data. When a page feels noisy the fault is inconsistency, a stray font size, a second accent, or boxes nested inside boxes. Fix the structure and keep the data. The three faces are a committed split and the split is what makes it work: Big Shoulders Display for exactly one hero header, Inter for every other heading and all body and UI text, JetBrains Mono for numbers, code, and uppercase labels. --- ## 1. Paste this into `` ```html ``` tokens.css must come before components.css. All three faces (Inter, JetBrains Mono, Big Shoulders Display) load automatically through `@font-face` inside tokens.css, served from the same domain. Do NOT add a Google Fonts link; there is nothing left for it to fetch and it only costs a round trip. `darkreader-lock` plus `color-scheme: dark` stops a reader's dark-mode browser extension from re-mapping the palette. Keep both on any page whose look matters. Fastest correct start: clone the skeleton and edit content only. - `https://design.seantippen.com/starter.html` - dashboard skeleton with the rail nav, a stat band, a card grid, a scroll-wrapped data table, and one wired chart. --- ## 2. Hard rules (non-negotiable) - Use `var(--token-name)` in CSS. Never hardcode a hex, rgba, hsl, or oklch value. Chart.js dataset colors are the only exception (section 6). - ONE accent per layout. Cyan (`--color-accent`) leads. Neon pink (`--color-secondary`) is a STATE and second-series color only: a second chart series, one contrasting progress bar, one alternate KPI marker. It is never a page theme, never a heading color, never a background wash. - Blurple is banned. Hue 270 to 285 is fenced off at the generator tier and is the single most recognizable AI-generated tell. The accent knob's usable range is roughly 150 to 200; the secondary knob's is 300 to 360. - SIZE LAW: `font-size` only ever takes a `--text-*` token, never a raw rem or px value. `--text-xs` (13px floor) is the absolute minimum for ANY text, including captions, footnotes, chart annotations, badges, and pills. Never shrink a registry component's type in page CSS. - Every uppercase mono label uses `--text-label` (14 to 15px), weight 600 to 700, `--tracking-label`, and a muted color. That covers eyebrows, section numbers, KPI and stat labels, table headers, and nav links. Do not re-implement the role at a smaller size with a local class. - Radius is 0. `--radius` and every `--radius-*` except `--radius-full` resolve to 0. `--radius-full` is only for pills, badges, toggle thumbs, and circular dots. Never type a literal `border-radius` value. - No gradients. Not on backgrounds, not on text, not on rules or bars, not as a hover sheen. The canvas is flat `var(--color-bg)` and accents are solid. There is no sanctioned decorative background layer: no dot grid, no noise, no radial blobs, no cursor-tracking glow, no pattern overlay. `.bg-signal` still exists but paints a flat fill and nothing else. - Glass (`backdrop-filter: blur()`) is for floating chrome only: sticky nav, the nav rail, drawers, command palettes, modals. Never on a flat card. - Amber (`--color-warning`) means warning state. Never a decorative accent. - Text is never pure white. The top step is `--color-text`, which is already tuned; going brighter breaks the ladder. - Hover lifts with `translateY(-1px)` plus a deeper shadow, or picks up an accent border. Never `transform: scale()`, never a glow that follows the pointer. - `tabular-nums` on every numeric column, KPI, and stat value. - Wrap every `.data-table` and every chart canvas in `.table-scroll` or `.scroll-x`. Wide content scrolls inside its own box; the page body must never scroll horizontally at 390px. - All motion sits behind `@media (prefers-reduced-motion: no-preference)`. - No em-dashes in copy. Use commas, periods, semicolons, or parentheses. - Plain, literal copy. No hype words, no fake warmth, no "coming soon". --- ## 3. Tokens (most-used; the full set is in tokens.css) ``` /* generator tier - four knobs re-skin the entire system */ --accent-h 195 cyan, the one rationed accent --secondary-h 320 neon pink, state and second series only --base-h 252 cool-rich neutral hue for every surface and text step --contrast 1 0.85 calm ... 1.2 high-contrast (dark mode) /* surfaces - five-step elevation, depth from lightness not shadow */ --color-bg page canvas --color-surface card / panel / banded section --color-surface-hover row hover, faint fill --color-surface-raised raised control, button rest --color-surface-overlay floating overlay, command palette /* text - four steps, never pure white */ --color-text body and headings --color-text-secondary supporting copy --color-text-muted labels, captions, chrome --color-text-disabled disabled, ghost --text-alpha-90/70/50/30/15 text at opacity, for use over a variable background /* borders */ --color-border --color-border-emphasis --color-border-strong /* accent */ --color-accent the rationed cyan --color-accent-hover hover on accent surfaces --color-accent-solid-hover hover on a solid accent fill --color-accent-border interactive border --color-secondary neon pink, state only --accent-muted selected background tint --accent-subtle faint accent wash (active row, active chip) --color-accent-rgb / --color-secondary-rgb for legacy rgba(var(...), x) call sites /* status - fixed semantic hues, never accent-swapped */ --color-success --color-warning --color-danger --color-info --color-success-subtle --color-warning-subtle --color-danger-subtle --color-info-subtle /* chart series - HEX on purpose, see section 6 */ --green --blue --red --amber --purple --cyan --yellow --orange /* type */ --font-sans Inter, body and UI and every heading except the one hero --font-mono JetBrains Mono, numbers / code / uppercase labels --font-display Big Shoulders Display, the ONE hero header per page --text-xs (13px FLOOR) --text-sm --text-label --text-base --text-lg --text-xl --text-2xl --text-3xl --text-4xl all fluid clamp() --text-label the one size for every uppercase mono label --leading-tight/-snug/-normal/-relaxed --measure 65ch --tracking-display (-0.03em) --tracking-tight (-0.01em) --tracking-label (0.12em) /* spacing */ --space-xs/sm/md/lg/xl fluid --space-1 2 3 4 5 6 8 10 12 16 fixed, 4px base --space-section fluid band padding /* motion */ --duration-instant 80ms --duration-fast 150ms --duration-normal 250ms --duration-slow 400ms --ease-out (default) --ease-in --ease-in-out --ease-spring --ease-bounce /* shadow, focus, radius, layout */ --shadow-sm/-md/-lg --shadow-ring --shadow-float --shadow-float-lg --focus-ring use on :focus-visible, never :focus --radius 0 (and every --radius-* except) --radius-full 9999px --max-width 1100px --sidebar-w 260px ``` Light mode ships too. It activates on `:root.light` or `html[data-theme="light"]`; both hooks work. Every token above re-derives, so a page built on tokens flips for free. Persist the choice in `localStorage` and set the class before first paint. Compatibility aliases exist for older pages and stay supported permanently: `--bg --surface --surface2 --surface3 --surface-hover --text --text-dim --text-muted --text-secondary --text-disabled --accent --accent2 --secondary --border --border-emphasis`. New code should use the `--color-*` names. --- ## 4. Canonical page shell A left nav rail owns section wayfinding at 1100px and up. Below 1100px the rail becomes a slide-in sheet opened by a top-bar menu button. One link list serves both, so there is nothing to keep in sync. ```html
01

Headline.

One supporting sentence.

``` `body.has-rail` reserves `--sidebar-w` of left padding at 1100px and up and removes it below. `.section` alternates `--color-bg` and `--color-surface` automatically via `:nth-child(odd of .section)`, which gives the banded rhythm for free; keep sections as siblings for it to work. `.section-inner` is the centered 960px column. Scroll-spy is the only script the rail needs. Key the map by each section's real `id`, never by `href.slice(1)`: ```js const links = [...document.querySelectorAll('.nav-rail-link')]; const map = new Map(links.map(a => [a.getAttribute('href').slice(1), a])); const sections = [...document.querySelectorAll('.section[id]')]; function spy(){ let cur = null; for (const s of sections) if (s.getBoundingClientRect().top <= 96) cur = s; if (innerHeight + scrollY >= document.documentElement.scrollHeight - 2) cur = sections[sections.length - 1]; links.forEach(a => a.classList.remove('active')); const link = cur && map.get(cur.id); if (link) link.classList.add('active'); } addEventListener('scroll', () => requestAnimationFrame(spy), { passive: true }); spy(); ``` Mobile sheet: toggle `.open` on both `.nav-rail` and `.nav-rail-scrim`, keep `aria-expanded` on the menu button in sync, close on scrim click, on Escape, and on any rail link click. Every tap target in the rail is at least 44px tall already; do not shrink `.nav-rail-link` padding. --- ## 5. Component class registry Everything below ships in components.css. Use these before inventing anything. ``` ds-reset box-sizing reset opt-in nav, nav-brand, nav-links, nav-controls simple sticky top bar (legacy pattern; the rail is the default for multi- section pages) nav-rail, nav-rail-brand, nav-rail-mark, left section-nav rail with scroll-spy; nav-rail-inner, nav-rail-group, becomes a slide-in sheet under 1100px nav-rail-link (.active), nav-rail-num, (full contract: section 4) nav-rail-foot, nav-rail-scrim (.open) nav-top, nav-menu-btn, nav-top-controls mobile top bar + menu button body.has-rail reserves the rail column on desktop section, section-inner auto-banded page section + centered column section-num, section-title, section-desc section chrome subsection, subsection-title, subsection chrome subsection-desc h-display the ONE Big Shoulders hero header card, card-title, card-desc flat card, no gradient, no blur kpi (.secondary), kpi-label, kpi-value, left-bar KPI marker kpi-delta (.up/.down/.flat), kpi-grid (kpi-grid is the auto-fit container) stat-band > stat > stat-eyebrow, headline number row, divided cells stat-value, stat-meta info-row, info-label, info-value label/value row with bottom border data-table (.dense) bare table, no card wrapper thead th, tbody td, tr.total, tfoot (tr.total / tfoot = emphasized totals) td.num / th.num right-aligned mono tabular numerics td.pos / td.neg success / danger colored numerics table-scroll REQUIRED wrapper for any data-table scroll-x generic wide-content scroll wrapper (chart canvases, wide blocks) btn (.primary / .secondary) buttons search-input underline search field, no box badge (.success/.warning/.danger/.info) status pill, uppercase pill (.success/.warning/.danger/.info/ status dot + label .live) (.live pulses) delta (.up/.down/.flat/.plain) change chip with a triangle glyph chip (.active), chip-close removable / selectable filter chip tabs > tab (.active) underline tabs segmented > button (.active) segmented control toolbar, toolbar-group, toolbar-spacer filter bar row progress (.positive/.warning/.danger/ progress bar; inner .secondary/.sm/.lg) > span progress-block, progress-block-head, labelled progress group progress-block-value sparkline (.lg) fixed slot for a Chart.js micro chart drawer-scrim (.open), drawer (.open), right slide-in panel drawer-head, drawer-title, drawer-body, drawer-close empty-state, empty-state-icon, empty state empty-state-title, empty-state-desc error-state, error-state-icon, error state error-state-title, error-state-desc stale (.fresh/.aging/.dead) "updated 3m ago" freshness dot skeleton-row table loading placeholder, opacity pulse focus-ring token focus ring on :focus-visible bg-signal flat fixed canvas fill; paints no decoration. Optional first body child spotlight inert layout wrapper, kept for compatibility. Paints nothing read-progress scroll-linked reading progress bar reveal scroll-driven fade-up; visible by default, animates only where supported sr-only screen-reader-only live regions ``` `@property` still registers `--angle`, `--glow-x`, `--glow-y`, and `--glow-opacity` so older pages that set them do not break, but nothing in the system reads them any more. Do not build new effects on them. --- ## 6. Chart.js Pin 4.4.7. The Canvas API cannot resolve CSS custom properties at paint time, so chart colors are read out of the computed style at build time. The eight series tokens are HEX for exactly that reason; every other color in the system is OKLCH. ```html ``` ```js const tok = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim(); Chart.defaults.color = tok('--color-text-muted'); Chart.defaults.borderColor = tok('--color-border'); Chart.defaults.font.family = tok('--font-sans'); Chart.defaults.font.size = 12; // the size law applies to charts too Chart.defaults.scale.grid.color = tok('--color-border'); Chart.defaults.scale.ticks.color = tok('--color-text-muted'); Chart.defaults.scale.border = { display: false }; Chart.defaults.plugins.legend.display = false; Chart.defaults.plugins.tooltip.backgroundColor = tok('--color-surface-raised'); Chart.defaults.plugins.tooltip.titleColor = tok('--color-text'); Chart.defaults.plugins.tooltip.bodyColor = tok('--color-text-secondary'); Chart.defaults.plugins.tooltip.borderColor = tok('--color-border'); Chart.defaults.plugins.tooltip.borderWidth = 1; Chart.defaults.plugins.tooltip.cornerRadius = 0; // radius 0 holds in charts // series colors: tok('--green') tok('--blue') tok('--red') tok('--amber') // tok('--purple') tok('--cyan') tok('--yellow') tok('--orange') // fills: append '66' (40%) or '22' (13%) to the hex. ``` `--color-accent` is OKLCH, so it is not directly usable as a canvas fill string in older engines. Round-trip it through a 1x1 canvas when you need the live accent as a hex: ```js function tokenToHex(name){ const c = document.createElement('canvas'); c.width = c.height = 1; const ctx = c.getContext('2d'); ctx.fillStyle = '#000'; ctx.fillStyle = tok(name); ctx.fillRect(0, 0, 1, 1); const d = ctx.getImageData(0, 0, 1, 1).data; return '#' + [d[0], d[1], d[2]].map(v => v.toString(16).padStart(2, '0')).join(''); } ``` Always give a chart canvas a sized parent and wrap it in `.scroll-x`. With `maintainAspectRatio: false`, set an explicit height on that parent or the canvas grows without bound. Re-read the tokens and call `chart.update()` after a theme flip, otherwise the chart keeps the old palette. --- ## 7. Before you ship - Both stylesheets linked, tokens.css before components.css. No Google Fonts link. - `color-scheme: dark`, `darkreader-lock`, and a `theme-color` meta present. - No hex, rgba, hsl, or oklch literal anywhere in CSS. Chart.js datasets excepted. - No raw rem or px `font-size`. Nothing below `--text-xs`. Every uppercase mono label at `--text-label` with `--tracking-label`. - One accent per layout. Pink only as state or second series. No blurple. - No `border-radius` literal. Square by default, `--radius-full` only on pills, badges, toggle thumbs, and dots. - No gradient page or section background, no gradient text. Glass only on nav, rail, drawers, and overlays. - Amber is warning state only. - Every `.data-table` inside `.table-scroll`; every chart canvas inside `.scroll-x` with a height-bearing parent. - `tabular-nums` on every numeric column, KPI, and stat. - Hover lifts, never scales. All motion behind `prefers-reduced-motion`. - Focus rings on `:focus-visible` via `--focus-ring`. Tap targets 44px or larger. - No horizontal page scroll at 390px. Every section reachable at 390px and 1440px. - No em-dashes. Plain, literal copy.