Components
The registry — every shared component, what it is built from, and when to reach for it. Tokens first, then the components that consume them. The labs live at the bottom as a playground.
Tokens
Every shared value on the site. Components consume these instead of carrying one-off values — check here before introducing a new color, and never ship a hex that isn't on this list.
- --canvas
#f0f2f5· dark#18191a— the app background. Surfaces sit on it, never beside it. - --surface
#ffffff· dark#242526— cards, panels, the drawer. - --surface-soft
#f7f8fa· dark#2f3031— quiet fills inside surfaces. - --ink
#14171a· dark#f2f3f5— primary text. - --muted
#65676b· dark#b0b3b8— secondary text and metadata. - --line
#e1e4e8· dark#3a3b3c— borders and dividers. - --green
#1a7f37in both themes — primary actions, the contact panel, the review FAB. Clears 4.5:1 with white text, so buttons and panels need no per-theme override. - --green-strong
#166534· dark#4cc38a— text links and anything green-on-canvas. Clears 4.5:1 in both themes. - --green-soft
#e6f4ea· dark#1c3325— tinted fills, e.g. secondary-button hover. - --control
#ffffff· dark#242526— control surfaces: the avatar ring, header buttons. - --nav
rgba(255,255,255,.94)· darkrgba(36,37,38,.95)— the dock background. - --header-core / --header-midHeader gradient stops —
rgba(240,242,245,.88)/.58, darkrgba(24,25,26,.9)/.62. - --shadow
0 2px 12px rgba(16,24,40,.08)· dark0 2px 14px rgba(0,0,0,.28)— the one elevation.
- TypeDM Sans, self-hosted as a variable WOFF2 (
assets/fonts/dm-sans.woff2, weights 400–700). Body is 16px / 1.5. There is no type scale — sizes are set per component, which is a smell to fix before it spreads. - Motion200–300ms budget, positional only. The pill glides on
.28s; drawer links stagger at 40ms; the copy confirmation holds 1.8s. Everything dies underprefers-reduced-motion. - Radius
999pxpills are reserved for identity and navigation. Content surfaces use 10–18px — pick from what exists before inventing a new one.
Registry
Every shared component, in one place. Each entry is the quick overview — what it is, when to reach for it, what it is built from. The essays behind the tricky ones live on the Dock page: read the entry to use a component, read the essay to change it.
site-nav the Dock
The bottom tab bar with the sliding highlight pill. Data-driven and generalized to N tabs — nothing in the bar knows the count.
- Use whenNavigating between the section's top-level pages. Main pages get a
TABSentry, docs pages aDOCS_TABSentry; utility pages get a drawer link instead. - Markup
<site-nav></site-nav>—chrome.jsreads<body data-page>to pick the tab set, set the active tab, and position the pill. The dock on this page is the live specimen. - Tokens
--nav,--green,--ink,--muted - Deep diveAnatomy, the pointerdown/click contract, and the Android gesture-bar saga — dead ends included.
site-header
The fixed header: menu button, avatar + Herman pill, Instagram shortcut, and the slide-in drawer with every destination.
- Use whenNever directly — it renders on every page. Edit the markup in
chrome.js. - Markup
<site-header></site-header>. The menu-button SVG is inlined, not sprited: paths inside a<use>shadow tree can't be CSS-targeted, and the morph needs them. - Tokens
--control,--ink,--header-core,--header-mid
Contact panel
The green contact card: headline, a mailto link, and a copy-email button whose icon morphs into a check on copy. Lives on /contact/; kept out of the home page by design.
- Use whenThe direct call to reach out. The copy button morphs via
.copy-buttoninsite.js— no separate toast, since Android shows its own "Copied" popup. - Markup
.contact-panel>.contact-content+.contact-options(the mailto link and the.copy-buttonwithdata-copy). - Tokens
--greenfor the panel, white text;--green-strongfor the links
Buttons
Three controls, three jobs. .action is the labeled button (primary fills, secondary outlines), .icon-button is the compact header action, .skill is metadata — never navigation.
- Tokens
--green,--green-soft,--control,--ink
Icon sprite
One shared SVG sprite in chrome.js owns every interface symbol. Edit a symbol once and every instance updates.
- Conventions24 × 24 viewBox,
currentColor, round joins, 2px default stroke. Purpose-first IDs (icon-mail, noticon-envelope). New symbols come from Tabler — inner path data only, so the stroke styling keeps working. - Try itThe stroke slider in the playground below previews every symbol at any weight.
Motion
Icons move only to communicate state. The vocabulary is deliberately small: 200–300ms, positional, and every transition is killed under prefers-reduced-motion.
- Menu → XThe header menu button morphs while the drawer is open. The drawer slides under the header (z-index below it, top padding clearing it) so the X stays visible and tappable — morphing to an X you can’t see or tap is meaningless. The SVG is inlined in
chrome.js— paths inside a<use>shadow tree can’t be targeted by CSS, so this is the one sanctioned exception to the sprite. - Tab fillThe active tab swaps its outline icon for the filled Tabler partner (
icon-home-fill,icon-document-fill,icon-book-fill), iOS-style..icon-filloverrides the stroke look of.ui-iconbecause filled glyphs are solid shapes. - Highlight slideTapping a tab swaps the page without a document navigation —
site.jsintercepts same-origin tab links, fetches the page, and swaps<main>in place while the tab bar stays mounted as the same element. The highlight pill glides the instant--active-indexmoves (.28s live::beforetransition); the URL updates viahistory.pushStateand the back button works viapopstate. Neither the animation nor the load waits for the other — both start on tap. Feedback starts onpointerdownand navigation commits onclick. Full interaction contract on the dock page. - Copy → checkThe copy button morphs on success —
site.jstoggles.copiedand flips the label to “Copied” for 1.8 seconds. The icons scale and rotate (pivoting ontransform-box: fill-box) so the morph reads as a morph, not a straight swap. No separate toast: the button is the feedback, and Android already shows its own native confirmation — a second toast just overlaps it. - Drawer staggerDrawer links slide in with a 40ms stagger, wrapped in
prefers-reduced-motion: no-preference. - Dock position
bottom: calc(10px + env(safe-area-inset-bottom, 0px)), withviewport-fit=coverenabled on Android bychrome.jsbefore first paint. The inset is evaluated against the live viewport, so the dock tracks Chrome's retracting toolbar with pure CSS. Full saga — dead ends included — on the dock page.
Playground
The bench. Test type, icons, and animation here before changing the shared styles — these are the real classes, not mockups.
Type lab
The production face is DM Sans. Use this bench to test scale, weight, and tracking before changing type or introducing a custom font.
A custom font should be added as local WOFF2 files in assets/, declared with @font-face, then tested here at phone and desktop widths before it reaches the page.
Icon lab
Every symbol in the sprite, live. Drag the stroke weight to preview the set before editing chrome.js.
Lottie
Lottie playground. The player loads from a deferred CDN script; site.js guards on window.lottie, so the page degrades to the static fallbacks below if it’s unreachable. The animations are plain JSON in assets/lottie/, hand-authored for this lab — swap either file for any .json exported from LottieFiles and it just works. Hand-authoring instead? Two gotchas: every shape group must end with a tr transform item or lottie-web silently renders nothing, and layer phase offsets need explicit keyframes — a negative st start time won’t shift them.
check.json · trim-paths draw-on · hover or replay
ping.json · two offset rings · loops until reduced-motion
var anim = lottie.loadAnimation({
container: document.querySelector('[data-lottie]'),
renderer: 'svg', // crisp at any size; 'canvas' is faster for heavy scenes
loop: false,
autoplay: false,
path: '/assets/lottie/check.json'
});
replayButton.addEventListener('click', function () {
anim.goToAndPlay(0, true);
});
Texture lab
Braille grain — braille cells as halftone, not art. The glyph field renders once to a tile, then the tile tints a band in the card's padding, fading out before the text; density is the dial, and the same density can encode a value instead of decorating.
Card, with tooth — in the padding. The band below is a braille tile at the density above, fading out before it reaches the text. Same surface token, same green, but the text area stays clean.
Toronto, in braille — from a real photo. Sky removed by blueness, lake cut at the waterline, dead space cropped; the silhouette that is left renders in braille. It pans back and forth because a photo doesn't tile.
How it works: site.js draws braille cells (U+2800 block, 2×4 dots per cell) onto a small canvas — a dot lands when a seeded hash of the cell beats the density — then uses the canvas as a repeating background. Glyph width never matters, because positions are fixed on the tile; the canvas font is baked into pixels, so the page itself has no monospace dependency. Tint comes from the --green token. The band's fade is a CSS mask (transparent at the top, opaque at the bottom edge), and the slow drift is a transform on an inner layer — the mask stays fixed while the pattern pans, so the loop is seamless every tile width, and the animation stops under prefers-reduced-motion. The skyline card is derived from a real photo instead of a hand-set list: tools/skyline_from_photo.py removes the sky by blueness and the lake at the waterline, crops the dead space, and fills each column's silhouette; the result is baked as 12 rows of braille chars. The band pans back and forth (alternate) since a photo doesn't repeat seamlessly.