A portable spec for the look and behavior of this site, written so another repo can adopt the same design language. It's framework-agnostic: the source here is plain HTML/CSS/JS, but everything below is expressed as design tokens and component recipes you can drop into React/Vue/Svelte/Tailwind just as easily.
Two files carry the entire system:
assets/theme.css — the whole palette as CSS variables, plus all shared-chrome styles (bottom bar, menu sheet, toasts). The source of truth for color.assets/site.js — injects the bottom nav bar and slide-up menu, applies the saved light/dark preference before paint, keeps the mobile theme-color in sync, exposes a per-page tools API, and registers the offline service worker.Every page opts in with two lines in <head> (no build step, no bundler):
<link rel="stylesheet" href="assets/theme.css">
<script src="assets/site.js"></script>../assets/… from a sub-folder — the paths just need to resolve to /assets/…. site.js derives the site root from its own URL, so it works at any folder depth and any domain.site.js synchronously in <head> — do not add defer/async. It uses document.currentScript to find the site root, and it sets the theme before first paint to avoid a flash.The rest of the recommended <head> (fonts, viewport, theme-color):
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#222436">
<title>Page title</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,900&family=Hanken+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/theme.css">
<script src="assets/site.js"></script>viewport-fit=cover plus the safe-area insets used throughout are what make it sit correctly on notched phones and installed PWAs.
On a component framework? You don't need site.js's DOM-injection approach — reimplement the bottom bar and menu as components. But keep theme.css's :root token blocks (section 3) verbatim and keep the theming mechanism (section 9). Those two are the actual "design system"; the rest is presentation you can rebuild.
All color is expressed as CSS custom properties on :root. Never hardcode a hex value in a component — reference a token, and light/dark comes for free.
Token reference (light is TokyoNight Day):
:root {
/* Surfaces & text */
--bg: #e1e2e7; /* page background */
--surface: #ffffff; /* cards, sheets, raised elements */
--surface2: #eaebf1; /* subtle fills: hover, table headers, code bg */
--ink: #2e3358; /* primary text */
--muted: #63699a; /* secondary text, captions, labels */
--faint: #9ba0c4; /* tertiary / disabled text, strikethrough rules */
--line: #cdd0e0; /* hairline borders */
--line2: #bcc0d8; /* stronger borders (bar, sheet, emphasized) */
/* Accent (historically named "orange"; it's now blue — name kept for back-compat) */
--orange: #2e7de9; /* the accent — links, active states, focus */
--orange-ink: #1a63c4; /* accent text on a wash (higher contrast) */
--orange-soft: #5a94ee; /* lighter accent */
--orange-wash: #dce7fb; /* accent tint fill (active pills, current row) */
/* Semantic green — "happening now" / success */
--leaf: #587539;
--leaf-wash: #dde8d0;
--on-accent: #ffffff; /* text/icons sitting ON an accent fill */
--shadow: 0 1px 2px rgba(40,50,90,.06), 0 8px 24px -14px rgba(40,60,130,.30);
--radius: 14px;
}Dark mode (TokyoNight Moon) applies the same tokens in two selectors, so it covers both "OS is dark and the user hasn't forced light" and "user forced dark":
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { /* …dark values… */ }
}
:root[data-theme="dark"] { /* …same dark values… */ }Dark values:
--bg:#222436; --surface:#2b2e45; --surface2:#31344f;
--ink:#c8d3f5; --muted:#828bb8; --faint:#565f89;
--line:#383b52; --line2:#454a6e;
--orange:#82aaff; --orange-ink:#a5c1ff; --orange-soft:#6f9bf0; --orange-wash:#2d3f76;
--leaf:#c3e88d; --leaf-wash:#2b3a2c;
--on-accent:#1b1d2b;
--shadow:0 1px 2px rgba(0,0,0,.3), 0 10px 30px -16px rgba(0,0,0,.7);Rules for using color:
--orange-wash with --orange-ink is the pairing for "this is the current / active thing" (selected nav pill, current row in the file tree). --orange as a solid fill always uses --on-accent for text on top.--leaf is reserved for the "happening now" / live / success meaning — don't use it decoratively.color-mix() handles translucent accents where a wash isn't defined, e.g. link underlines: border-bottom:1px solid color-mix(in srgb,var(--orange) 40%,transparent).<meta name="theme-color">) is kept equal to --bg by site.js on every theme change — set it to the dark --bg (#222436) as the static default so the very first paint matches.Three Google fonts, each with a distinct job. This split is the single biggest driver of the "look" — a warm display serif over a clean grotesk body, with a monospace used only for labels and numbers.
h1–h3, card titles, the hero, the brand mark.Load weights: Fraunces 400/600/900, Hanken Grotesk 400/500/600/700, Space Mono 400/700.
Type recipes:
/* Body */
body{
font-family:"Hanken Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
line-height:1.5; /* 1.6 for long-form prose */
color:var(--ink); background:var(--bg);
}
/* Display h1 — the signature look: heavy Fraunces, tight tracking, fluid size */
h1{
font-family:"Fraunces",serif; font-weight:900;
font-size:clamp(40px,10vw,64px); /* hero pages go up to clamp(44px,12vw,80px) */
letter-spacing:-.03em; line-height:.95; margin:8px 0 6px;
}
h2{ font-family:"Fraunces",serif; font-weight:600; font-size:26px; letter-spacing:-.01em; }
h3{ font-family:"Fraunces",serif; font-weight:600; font-size:20px; }
/* Kicker — the small uppercase mono label above a heading */
.kick{
font-family:"Space Mono",monospace; font-weight:700;
font-size:12px; letter-spacing:.2em; text-transform:uppercase;
color:var(--orange);
}
/* Secondary / caption text */
.sub{ color:var(--muted); font-size:15px; }Conventions:
clamp(min, vw, max) so they scale with the viewport without media queries.letter-spacing:.2em; text-transform:uppercase); used plain for numbers, times, and dates with font-variant-numeric:tabular-nums where alignment matters (clocks).1.5; long-form article prose bumps to 1.6.Radius:
999px16px to 22px--radius) — 14px5px to 12pxShadow: one elevation token, --shadow, used on every raised element (cards, chips, sheets, the bar). It's a tight contact shadow plus a soft wide shadow, tuned darker for dark mode. Don't invent new shadows — reuse --shadow.
Elevation and borders: raised surfaces are background:var(--surface) plus 1px solid var(--line) (or --line2 for stronger edges) plus --shadow. That trio is the card look.
Layout spacing:
max-width of 640px (pages and cards), 680px (long-form prose), 520px (centered hero card rows), 780px (dense schedule). Centered with margin:0 auto.48px 20px is typical; long-form uses 48px 20px 96px. Always reserve bottom room for the fixed nav bar — site.js adds .pages-has-nav, and theme.css pads the body by --pages-nav-h plus 22px plus the safe-area inset.padding-left:max(20px,env(safe-area-inset-left)) and the right equivalent on full-bleed pages.Motion:
.16s (nav, tools, cards). Sheet slide is .28s cubic-bezier(.32,.72,0,1). Micro-feedback hovers are .15s.transform:translateY(-2px) (grid cards -3px) plus border-color:var(--orange).transform:scale(.94) on buttons, scale(.985) on cards.-webkit-tap-highlight-color:transparent.These are the repeating building blocks. Each is theme-aware because it only uses tokens.
Card (the workhorse) — raised surface, hairline border, --shadow, lift on hover. Title in Fraunces, description in --muted, an accent "Open →" affordance.
a.card{
display:block; background:var(--surface); border:1px solid var(--line);
border-radius:16px; padding:20px 22px; text-decoration:none; color:var(--ink);
box-shadow:var(--shadow);
transition:transform .16s,border-color .16s;
-webkit-tap-highlight-color:transparent;
}
a.card:hover{ border-color:var(--orange); transform:translateY(-2px); }
a.card:active{ transform:scale(.985); }
.card-title{ font-family:"Fraunces",serif; font-weight:900; font-size:22px; line-height:1.15; }
.card-desc{ color:var(--muted); font-size:14.5px; margin-top:6px; }
.card-meta{ display:flex; align-items:center; justify-content:space-between; margin-top:14px; }
.card-meta .date{ font-family:"Space Mono",monospace; font-size:11px; color:var(--muted); }
.card-meta .go{ color:var(--orange); font-weight:700; font-size:14px; margin-left:auto; }A grid of cards (landing / section) uses display:flex; gap:14px; flex-wrap:wrap with each card flex:1 1 200px, border-radius:18px, lifting -3px on hover.
Chip (metadata / at-a-glance) — a small rounded label-over-value, or a pill toggle.
/* label/value stat chip */
.chip{ background:var(--surface); border:1px solid var(--line); border-radius:12px;
padding:7px 13px; box-shadow:var(--shadow); }
.chip .k{ display:block; font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); }
.chip .v{ display:block; font-family:"Space Mono",monospace; font-weight:700; font-size:16px; color:var(--ink); }
/* pill chip toggle — ghost, and a solid accent "on" state */
.pill{ display:inline-flex; align-items:center; gap:6px; background:var(--surface);
border:1px solid var(--line2); color:var(--ink); border-radius:999px;
padding:6px 12px; font-size:13px; font-weight:600; cursor:pointer; transition:.15s; }
.pill:hover{ border-color:var(--orange); color:var(--orange); }
.pill.solid{ background:var(--orange); border-color:var(--orange); color:var(--on-accent); }Badge (status tag) — a tiny uppercase tag; solid accent for "now", outlined for "next".
.badge{ display:inline-flex; align-items:center; font-family:"Hanken Grotesk";
font-size:10px; font-weight:800; letter-spacing:.07em; text-transform:uppercase;
padding:3px 8px; border-radius:6px; }
.badge.now{ background:var(--orange); color:var(--on-accent); }
.badge.next{ background:transparent; color:var(--orange-ink); border:1px solid var(--orange); }Custom checkbox (checklists) — square, accent when checked, drawn check via a CSS border. Checked items dim and strike through. State persists in localStorage (see section 10).
input[type=checkbox]{
appearance:none; -webkit-appearance:none; width:22px; height:22px; flex:none; margin:0;
border:2px solid var(--line); border-radius:6px; background:var(--bg);
cursor:pointer; position:relative; transition:.12s;
}
input[type=checkbox]:checked{ background:var(--orange); border-color:var(--orange); }
input[type=checkbox]:checked::after{
content:""; position:absolute; left:6px; top:2px; width:6px; height:11px;
border:solid var(--on-accent); border-width:0 2.5px 2.5px 0; transform:rotate(45deg);
}
/* dim the label of a ticked row */
input:checked ~ label{ opacity:.5; text-decoration:line-through; }Table (reference / troubleshooting) — always wrapped in a horizontally-scrolling container so it never breaks a phone layout. Header row on --surface2, first column mono.
.tablewrap{ overflow-x:auto; border:1px solid var(--line); border-radius:12px; box-shadow:var(--shadow); }
table{ width:100%; border-collapse:collapse; background:var(--surface); min-width:320px; }
th,td{ padding:10px 14px; text-align:left; border-bottom:1px solid var(--line); font-size:14px; vertical-align:top; }
tr:last-child td{ border-bottom:0; }
th{ background:var(--surface2); font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); }
td:first-child{ font-family:"Space Mono",monospace; font-weight:700; white-space:nowrap; }Inline code and code blocks:
code{ font-family:"Space Mono",monospace; font-size:.88em; background:var(--surface2);
border:1px solid var(--line); border-radius:5px; padding:1px 5px; }
pre{ background:var(--surface); border:1px solid var(--line); border-radius:12px;
padding:14px 16px; overflow-x:auto; box-shadow:var(--shadow); }
pre code{ background:none; border:0; padding:0; font-size:13px; line-height:1.5; }Blockquote and notes callout:
blockquote{ margin:16px 0; padding:2px 16px; border-left:4px solid var(--orange);
color:var(--muted); background:var(--surface); border-radius:0 10px 10px 0; }
.notes{ background:var(--surface); border:1px solid var(--line); border-radius:14px;
padding:4px 18px 14px; box-shadow:var(--shadow); }Links (in prose) — accent color, no underline until hover, with a soft color-mix underline at rest:
a{ color:var(--orange); text-decoration:none;
border-bottom:1px solid color-mix(in srgb,var(--orange) 40%,transparent); }
a:hover{ border-bottom-color:var(--orange); }Icon button:
.iconbtn{ width:38px; height:38px; flex:none; border-radius:11px;
border:1px solid var(--line2); background:var(--surface); color:var(--ink);
font-size:17px; cursor:pointer; display:grid; place-items:center; transition:.15s; }
.iconbtn:hover{ border-color:var(--orange); color:var(--orange); }Sticky translucent header (for dense / long pages):
header{ position:sticky; top:0; z-index:20;
background:color-mix(in srgb,var(--bg) 88%,transparent);
backdrop-filter:saturate(1.4) blur(10px); border-bottom:1px solid var(--line); }Section divider heading — a Fraunces label with a rule that fills the remaining width:
.sec{ font-family:"Fraunces",serif; font-weight:600; font-size:15px; color:var(--muted);
text-transform:uppercase; display:flex; align-items:center; gap:10px; }
.sec::after{ content:""; flex:1; height:1px; background:var(--line); }A floating, thumb-reachable dock injected on every page by site.js. It is always exactly three equal-width pills:
--orange-wash) when already home.Key styling (from theme.css):
:root{ --pages-nav-h:58px; } /* exported so pages can offset above it */
.pages-nav{
position:fixed; inset-inline:0; margin-inline:auto;
width:min(640px,calc(100% - 20px)); /* full-width on phones, capped mid-page on desktop */
bottom:calc(14px + env(safe-area-inset-bottom,0px));
z-index:45; display:flex; gap:6px; padding:6px; border-radius:999px;
background:var(--surface); border:1px solid var(--line2); box-shadow:var(--shadow);
}
.pages-nav__btn{ flex:1 1 0; height:46px; border-radius:999px; /* equal-width pills */
font:600 14px/1 "Hanken Grotesk",system-ui,sans-serif; }
.pages-nav__btn.is-active{ background:var(--orange-wash); color:var(--orange-ink); }
@media (max-width:360px){ .pages-nav__txt{ display:none; } } /* icon-only on tiny screens */Principles worth carrying over even if you rebuild it:
--surface, not glass — predictable contrast over any content.44px tall. No overflow menus in the bar itself — page-specific actions go inside the menu sheet.env(safe-area-inset-bottom)), and the page reserves bottom padding so nothing hides behind it.☰ Menu opens a modal bottom sheet (.pages-sheet) that grows up from the bar. Top to bottom it contains:
Sheet mechanics: full-screen scrim (rgba(16,18,30,.5)), panel max-width:520px border-radius:22px 22px 0 0, slides in with transform:translateY(101%) to 0 over .28s cubic-bezier(.32,.72,0,1), a grab handle at top, Esc / scrim / ✕ to close, and body scroll locked while open.
Per-page tools API — a page adds its own actions to the menu from its own <script>, no bar surgery:
Pages.addHelper({ label, icon, href }); // a link
Pages.addHelper({ label, icon, onClick, disabled }); // an action
Pages.addHelpers([ … ]); // several at oncecurrent:true to mark the active tool.This is the extension point: the shell stays fixed at three buttons, and every page contributes its own verbs into one predictable place.
The mechanism, in order of precedence:
data-theme="light" or data-theme="dark" on <html> forces a mode.@media (prefers-color-scheme: dark).site.js handles the rest:
localStorage['theme'] and sets data-theme on <html>. (This is why the script must be synchronous in <head>, not deferred.)data-theme and writes localStorage['theme']. A MutationObserver on data-theme keeps every theme control's glyph and label, and the mobile theme-color meta, in sync.☾ for dark, ☀ for light — with a text state label beside it.To reproduce in a framework: keep the two :root blocks and the data-theme plus prefers-color-scheme contract exactly; back the toggle with the same localStorage['theme'] key; apply the attribute before hydration or paint.
manifest.webmanifest (display:standalone, background_color and theme_color set to the dark --bg #222436, plus maskable icons at 192px and 512px) makes it "Add to Home Screen"-able. site.js injects the manifest and apple-touch-icon links, plus an emoji favicon as an inline SVG data URI (no extra file):// 🗒️ notepad favicon, crisp at any size:
'data:image/svg+xml,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🗒️</text></svg>')sw.js): cache-first, precaches the whole site so pages stay viewable offline or if the host is down. It only overwrites the cache on a real 200 (never a 404 or 500), and falls back to the cached shell for navigations. Requires a secure context (https or localhost) — a no-op on file://..pages-toast), auto-dismissing (errors linger longer and wrap).localStorage for lightweight state: checklists persist ticked items per page under a "<type>:" + location.pathname key; theme under 'theme'; the first-run offline confirmation under 'offline-ready'. Pattern: read on load, write on change, wrap in try/catch (private mode can throw).44px (the bar uses 46px) on anything interactive.aria-label; the nav uses aria-current="page" for the active item; the sheet is role="dialog" aria-modal="true" and closes on Esc.env(safe-area-inset-*) on fixed and full-bleed elements.-webkit-tap-highlight-color:transparent on interactive elements, paired with a visible :active transform so taps still feel responsive.To make another repo look and behave like this one:
theme.css's :root light block and both dark blocks over verbatim (section 3). Everything else keys off these.--shadow and --radius for every raised surface; don't invent new elevation..16s transitions, hover-lift, :active press, no tap-highlight (section 5).data-theme override plus prefers-color-scheme, localStorage['theme'], applied before paint, theme-color synced.site.js or rebuild it as components.The litmus test for any new component: does it reference only tokens (so it's theme-aware), does it lift on hover and press on tap, and does it put its actions in the shared menu rather than inventing new chrome?