Back to README

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
--surface
--surface-soft
--ink
--muted
--line
--green
--green-strong
--green-soft
--control
--nav
  • --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#1a7f37 in 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.
  • --navrgba(255,255,255,.94) · dark rgba(36,37,38,.95) — the dock background.
  • --header-core / --header-midHeader gradient stops — rgba(240,242,245,.88) / .58, dark rgba(24,25,26,.9) / .62.
  • --shadow0 2px 12px rgba(16,24,40,.08) · dark 0 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 under prefers-reduced-motion.
  • Radius999px pills 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.

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-button in site.js — no separate toast, since Android shows its own "Copied" popup.
  • Markup.contact-panel > .contact-content + .contact-options (the mailto link and the .copy-button with data-copy).
  • Tokens--green for the panel, white text; --green-strong for 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.

Message Resume Design systems
  • 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, not icon-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-fill overrides the stroke look of .ui-icon because filled glyphs are solid shapes.
  • Highlight slideTapping a tab swaps the page without a document navigation — site.js intercepts 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-index moves (.28s live ::before transition); the URL updates via history.pushState and the back button works via popstate. Neither the animation nor the load waits for the other — both start on tap. Feedback starts on pointerdown and navigation commits on click. Full interaction contract on the dock page.
  • Copy → checkThe copy button morphs on success — site.js toggles .copied and flips the label to “Copied” for 1.8 seconds. The icons scale and rotate (pivoting on transform-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 positionbottom: calc(10px + env(safe-area-inset-bottom, 0px)), with viewport-fit=cover enabled on Android by chrome.js before 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.

The quick brown fox jumps over the lazy dog.

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);
});
This page is itself the test case: every example uses the same classes and symbols as the public site.