Internal — not linked from primary nav

Verified, not decorated.

The design system for taly.dev. Every number on this page is computed, not asserted — the contrast ratios below are the actual WCAG relative-luminance output for these tokens, run against the real hex values in styles.css, not eyeballed.

The idea

One idea, not ten effects — chosen before any of this was coded.

Every real claim on this site already carries a measured number: 5,400+ tests, a dated Lighthouse score, a specific commit. The system makes that ethos a formal rule instead of a habit — monospace figures for anything measured (stats, dates, contrast ratios, spacing values), set against a calm, restrained type hierarchy for narrative text, on a strict 8px grid. Motion only ever confirms something the user just did — a hover, a focus, a section entering view — never motion running on its own.

That is why this page's own contrast table isn't a static screenshot: it's the same numbers computed live from the same tokens the rest of the site uses.

Type scale

Named by role, not size. --text-h1 through --text-h4 are the exact curves already live on Home and About — formalized here, not changed. --text-display is the one new role, used only on this page.

Display

--text-display · clamp(2.75rem, 2.1rem + 3vw, 4.5rem)

Heading 1

--text-h1 · clamp(2rem, 4.5vw, 3rem) · live on .hero h1

Heading 2

--text-h2 · clamp(1.5rem, 3vw, 2rem) · live on .section h2

Heading 3

--text-h3 · 1.1rem · live on .project-card h3

Heading 4 / label

--text-h4 · 0.95rem · live on .skill-group h3

Body large — the lede under every hero.

--text-body-lg · 1.15rem

Body — running text, paragraphs, list items.

--text-body · 1rem

Body small — meta rows, tag lists, table cells.

--text-body-sm · 0.9rem

Caption / eyebrow

--text-caption · 0.8rem

2026-08-11 · 6.09:1 · 5,400+

--text-mono-label · the recurring "measured" device

Spacing scale

8px base grid. Every value already existed somewhere in the CSS before this system named it — --space-9 is the section padding you're reading right now.

--space-1 · 4px
--space-2 · 8px
--space-3 · 12px
--space-4 · 16px
--space-5 · 24px
--space-6 · 32px
--space-7 · 40px
--space-8 · 48px
--space-9 · 64px
--space-10 · 96px

Color, with contrast verified — not asserted

Computed with the WCAG 2.x relative-luminance formula against the real hex values in :root, run for both themes. Full method and script in docs/contrast-verification.md in this PR. WCAG 2.1 AA requires 4.5:1 for normal text and 3:1 for large text (≥24px, or ≥19px bold) and for non-text UI boundaries like input borders.

PairLightDarkNeeds
Body text on page background17.77:1 AAA16.59:1 AAA4.5:1
Muted text on page background7.38:1 AAA9.15:1 AAA4.5:1
Muted text on raised background (cards)6.77:1 AA8.38:1 AAA4.5:1
Accent text on page background6.09:1 AA10.50:1 AAA4.5:1
Button text on accent fill6.09:1 AA10.91:1 AAA4.5:1
"Live" status text on its pill5.36:1 AA9.27:1 AAA4.5:1
"Active build" status text on its pill7.95:1 AAA8.33:1 AAA4.5:1
Shipped-version status text on its pill4.64:1 AA8.60:1 AAA4.5:1
Error text on page background5.59:1 AA7.26:1 AAA (fixed — was 3.33:1)4.5:1
Decorative border (dividers, card edges)1.37:1 n/a — not a UI boundary1.52:1 n/a—
Interactive border (inputs, secondary button)3.33:1 pass (new token)3.70:1 pass (new token)3:1

Two real fixes came out of running the numbers, not the aesthetic pass: dark-mode form error text was inheriting the light-mode red at 3.33:1 on the dark background — below AA for text — and secondary-button / input borders were using the decorative --border token at ~1.4:1, below the 3:1 WCAG 1.4.11 threshold for interactive-element boundaries. Both now have their own verified tokens (--error per theme, --border-interactive).

Accent --accentverified
Raised surface --bg-raisedverified
Status: live --status-live-bgverified
Error --errorverified

Motion

--ease-standard is a no-overshoot cubic-bezier — nothing on this site bounces. Three durations only: --duration-fast (120ms, hover/focus), --duration-base (200ms, cards), --duration-slow (420ms, section reveal on scroll). Every animated rule on the site is silenced under prefers-reduced-motion: reduce from one global rule — hover a card or button below, or reload with reduced motion on, to compare.

Try it: hover the cards in "Selected work" on the Home page, or focus a form field below. Scroll this page slowly and watch each block above fade up once, on entry — that's [data-reveal] plus an IntersectionObserver in script.js, gated so the page is fully visible by default if JavaScript never runs.

States

Default, hover, focus, active, disabled, loading — shown together, not just described.

Tab to the field below to see the focus ring; it uses --focus-ring, distinct from the accent so it stays visible against accent-colored elements too.

Loading & empty states

No async content ships on the site yet — the contact list is a static link, not a form-and-response. These exist so the next thing that fetches or submits has somewhere to land instead of inventing a spinner under deadline.

Components already live on the site

Rendered here with the same classes Home, About, and the case study use — this page doesn't have its own component language.

  • Component sample

    Active build

    This is the exact .project-card markup from the Home page — hover it.

    • Token
    • Verified
    View real ones →
3Contrast fixes shipped by this system
0Regressions on Home / About
Callout

Used in the case study for a measured, slightly cautionary note — border-left accent, not a full color fill, so it doesn't compete with real status pills.