The system behind adamhickey.com

Design system

The tokens, type, spacing, and components behind this site, read from the live stylesheet as the page loads. The system is written down in four normative specs, COLOR.md, TYPOGRAPHY.md, SPACING.md, and MOTION.md, and twelve automated checks hold every page of the site to it.

12
colors
14
type steps
9
space steps
6
durations
18
components
10
automated checks

Principles

Four ideas decide most questions before they are asked. When a choice is not covered below, one of these usually settles it.

  • One accent, chosen by the groundWarm grounds, dark ink, and a single sage. Neutrals do the layout work. Which sage you use depends on what it sits on, never on the job it is doing.
  • Size or the accent, never bothThe serif carries headings at regular weight, and hierarchy comes from size. Montserrat carries labels and interface. Emphasis gets one signal, not two.
  • Motion confirms, directs, or sets a placeContent rises once as it enters, then holds still. An animation a reader would not miss is decoration, and decoration is cut.
  • Measured, not assertedEvery value on this page is read from the stylesheet as it loads, and every page of the site is checked against these rules on each merge. See How it’s enforced.
01

Color

Warm grounds, dark ink, and one sage. The sage changes by ground, not by job: one value on every light ground, for text and fills alike, and one on charcoal. Its hover is a darker step, shown beside it.

Neutrals · grounds and ink

  • WhiteThe page ground for case studies and build write-ups.
  • WarmThe warm ground, and the primary ink on charcoal.
  • TeaThe feature ground: a hero card, a pull-out, anything that deserves a moment.
  • Muted lightThe alternate ground, for separating neighbors without changing temperature.
  • Muted graySecondary ink on the light grounds. Never on charcoal, where it reads 2.37:1.
  • CharcoalPrimary ink on every light ground, and the dark ground itself.

Accent · one sage, two grounds

  • Default
    Hover
    Sage, on lightText and fill both: links, eyebrows, kickers, button grounds, borders, icons. Clears 4.5:1 on all four light grounds, 4.60 at worst. Hover takes the darker step, so a press reads as a press.
  • Sage, on darkThe sage on charcoal, at 7.47:1, for ink and for the focus ring. The sage on light reads 2.63:1 there.

Status · caution

  • Caution inkCaution text on the light grounds, 5.09:1 at worst. Carries warm type as a filled status line, at 5.90:1.
  • Caution groundA close call, a factor that hurts. Charcoal on it reads 12.44:1 and the caution ink 5.23:1.
  • Caution, on darkLine-art icons on charcoal only, at 4.17:1. Never on a light ground, where it reads 2.62:1.

Ink on ground

One line decides it: pick the ink from the ground, and check the number.

InkWhiteWarmTeaMutedCharcoal
--color-charcoal
--color-muted-gray
--color-accent-text
--color-caution-text
--color-accent-on-dark

4.50+ passes AA for text at any size. 3.00+ is legal for text at 24px and up, and for the boundary of a control. under 3 is decoration only.

  • The four light grounds share one ink systemCharcoal, muted gray, and the sage are all legal as body text on white, warm, tea, and muted light. That is the whole reason four grounds are allowed to exist: four surfaces, one set of inks, nothing to remember.
  • Charcoal flips completelyMuted gray drops to 2.37:1 on it and the light sage to 2.63:1, so neither appears there as ink. The dark band takes warm and the sage on dark. A fill is a different question from an ink, because what has to clear the floor is the type on it. On the dark band the primary button is a warm fill with charcoal type. This is the most plausible mistake in the system, because muted gray is the right answer on all four other grounds.
  • Sage on muted light has no headroom4.60:1 against a 4.5 floor. It passes, and it is the first pair to re-measure if either value ever moves.

Elevation

An alpha step says how loud an ink is; an elevation says how far off the page an object sits. Four rungs make the ladder, and every other shadow is named for the one object that uses it.

  • --shadow-zone1 · A tinted region, resting barely off the page
  • --shadow-card2 · A card at rest
  • --shadow-card-raised3 · The one card a screen asks you to act on
  • --shadow-popover4 · Floating interface on a light page
TokenFor
--shadow-restingA bar or rail sitting on the page: the nav, the progress rail, a step card
--shadow-mediaA framed image or figure: keyline, contact shadow, tucked drop
--shadow-deviceA screen mockup lifted off the page
--shadow-lightboxA photo over the scrim
--shadow-lift / -smA work-card thumbnail on hover, on the full grid and the compact one

All ten live in shell.css, which every page loads. A keyline is not an elevation: 0 0 0 1px draws a border and takes a rule token, never a --shadow-*.

02

Surfaces

Five grounds, each with its own legal ink and accent. Sections alternate between them so the page has rhythm without decoration.

  • Warm

    The page ground

    Charcoal ink, muted gray for secondary copy, and the sage for small accent text.

    Hero, proof strip, case studies band
  • White

    The working surface

    For content-dense sections: the long reading on a write-up or a case study.

    Write-up bodies, products
  • Muted light

    The alternate

    Breaks up runs of warm and white without changing temperature.

    Alternating sections
  • Tea

    The feature ground

    A touch of green in the neutral. Frames what deserves a moment.

    Hero card, the shelf
  • Charcoal

    The dark band

    Warm ink, 75% warm for supporting copy, and only the sage on dark as accent.

    Engagements, contact, footer
03

Type

Two families split by job: Crimson Text at regular weight for anything that speaks, Montserrat for anything that labels. Headings never take bold; hierarchy comes from size.

On the words. A dek is the short line under the headline, and a lede is the opening passage of the body: newsroom spellings, like hed and graf, that the class names use. The full list is in TYPOGRAPHY.md §1.

The two families

  • Crimson Text 400 · the voice · titles, headings, deks, pull quotes

    Judgment you can hold in your hand

  • Crimson Text 400 italic · deks and pull quotes

    From an idea everyone talks about to a working experience you can judge.

  • Montserrat 400 · the interface · body, labels, UI

    Most engagements start with a situation, not a request for deliverables. Each one has a defined shape, and when the problem is still taking shape the first step is a smaller one.

  • Montserrat 600 · uppercase · tracked 0.12em · kickers and eyebrows Engagement 04 · Ideas everywhere, nothing you can use yet

The scale

Fourteen steps, 11px to 80px. The text tier steps arithmetically, because at small sizes the eye reads the absolute difference. The display tier is geometric, a ratio of 21/4: every step doubles four steps later, so 20 becomes 40 and 40 becomes 80.

Not invented for this page: of the 587 type-size declarations the site carried when the scale was set, 78% already landed on one of these steps and 95% within a pixel.

Text tier · arithmetic · never fluid

  • --text-2xsMicro caps. Tracked uppercase, four words or fewer. The floor.
    Engagement 01
  • --text-xsKickers, tags, figure captions, footnotes.
    Minneapolis, 2026
  • --text-smDense UI: nav, footer links, table cells, dt labels.
    Selected work
  • --text-baseBody copy, card copy, list items, buttons.
    Most engagements start with a situation, not a request for deliverables.
  • --text-lgLong reading text. The smallest heading level.
    Most engagements start with a situation.

Display tier · geometric · ratio 2^(1/4)

  • --text-xlh4, block titles.
    Working Product Prototype
  • --text-2xlCard titles at their largest.
    Judgment you can hold
  • --text-3xlh2 at its floor.
    Judgment you can hold
  • --text-4xlh3 at its ceiling; h1 at its floor.
    Judgment you can hold
  • --text-5xlHero title at its floor.
    Judgment
  • --text-6xlh2 at its ceiling.
    Judgment
  • --text-7xl
    Judgment
  • --text-8xlh1 at its ceiling.
    Judgment
  • --text-9xlHero title at its ceiling.
    Judgment

The four fluid ramps

You never hand-write clamp(). Four ramps each interpolate between two named steps across one viewport window, 400px to 1280px. Nothing below h3 is fluid. Resize this window and the last column moves.

RampStepsmin → maxHere, nowUse
--type-title4xl → 8xl34 → 68h1, every page title, the homepage hero included.
--type-section3xl → 6xl28 → 48h2, a section heading.
--type-subsection2xl → 4xl24 → 34h3, a subsection heading.
--type-ledelg → 2xl18 → 24Deks, ledes, pull quotes.
--type-title-inset2xs h1 → 4xl36 → 64The homepage hero, which shares its row with the portrait.

The shared window means an ordering that holds at 400px and at 1280px holds at every width between. One ramp sits outside it: --type-title-inset runs 768 to 1280, the only range where the homepage hero shares its row with the portrait.

The heading ladder

Sizes come from the ladder; the ladder comes from the document outline. Never skip a level to reach a size.

LevelTokenpxFamilyWtLHTrack
h1 page title--type-title34 → 68Serif4001.05−0.02em
h1 in a narrow column--type-title-inset36 → 64Serif4001.05−0.02em
h2 section--type-section28 → 48Serif4001.15−0.015em
h3 subsection--type-subsection24 → 34Serif4001.15−0.01em
h4 block title--text-xl20Sans6001.35−0.01em
h5 card title--text-lg18Sans6001.350
h6 label heading--text-base16Sans6001.350
  • The seam at h3/h4 is deliberateh3 is the last serif level and h4 the first sans one, so the ladder changes voice there. A family change carries hierarchy on its own, which is why the size may drop 34 to 20 across that one gap without reading as broken. There is no in-between size to close it with.
  • h6 is body sizeIt is separated from body copy by weight and family, not by size. That is what the last heading level is for; a step below 16px would only push it into the label sizes.
  • A title that does not fit steps down a rampNot into a media query, not into a nudged vw coefficient, and never into a new clamp(). That one rule is what keeps the count at four ramps instead of back at twenty.
04

Spacing

Every space on this site is a multiple of 4px, which 78.5% of it already was before a single token existed. Below 48px, space is fixed and comes off the nine steps. Above it, space is fluid and comes off a ramp.

The nine steps

Two tiers. 4 to 24 in steps of 4, because at 8px a 4px error is half the value. 24 to 48 in steps of 8, because at 40px it is invisible. Every page reads them, the case studies’ cards included.

  • --space-2xs p-1

    Hairline separation. A label off its icon; a tag off its neighbor.

  • --space-xs p-2

    The tightest real gap. Inside a pill, between stacked metadata lines.

  • --space-sm p-3

    A dense inset, a tight row gap.

  • --space-md p-4

    The default. When no reason says otherwise, this.

  • --space-lg p-5

    A comfortable inset for a small card.

  • --space-xl p-6

    The most-used space on the site. Card padding, grid gap, the standard stack.

  • --space-2xl p-8

    Between components. A card off the next card.

  • --space-3xl p-10

    A generous inset for a large card or a panel.

  • --space-4xl p-12

    The largest fixed step. Beyond this, a ramp.

The four ramps

Tokenpx rangeHere, nowJob
--gutter24 → 32Container padding: the page edge to the content.
--space-block48 → 72Between major blocks inside a section.
--space-section64 → 96The vertical padding on a standard full-width band.
--space-section-lg80 → 128The same, for a band with a full-bleed treatment: the hero, the story.

The space ramps share the type ramps’ window, 400px to 1280px. They do not keep a constant ratio between type and space: h1 doubles across the window while --space-section grows by half, so the page reads denser at 1280 than at 400.

The measure

A line of text is measured in characters, not pixels. --measure is 52ch, which lays out about 70. It is in ch because a rem measure is right at only one size, and this site sets body text at five.

This paragraph is capped at --measure. One ch is 1.349 average characters in Montserrat and 1.324 in Crimson Text, measured in Chromium against a real paragraph, so a single token serves both families and stays correct at every size.

Apply it to the text element, never to a wrapper: ch resolves against the element’s own font-size, so a 16px container capping an 18px paragraph would measure the wrong glyph.

The measure caps the text, not the column. Every block starts on the frame’s left edge at one of three widths: the measure for text, --col-wide for most figures, and --col-full for the ones that read across the page.

05

Layout and corners

One container, one gutter, one reading measure, and a fixed header.

TokenValueUse
--container-maxThe single content container. Side gutters come from --gutter.
--gutterPage edge to content, 24 to 32px, in both page families. See Spacing.
--measureThe reading measure. Goes on the text, not its wrapper.
--nav-heightThe fixed header. Anchor targets are offset by it.

Corners

A radius is a length, so it answers to the 4px grid. Two tokens name the two jobs a corner does in the interface, and a third belongs to the artwork.

  • --radius-pillA control whose corners are its short side: a tag, the back button, a progress track.
  • --radius-xlA panel: a card, a thumbnail, a framed figure, and every specimen on this page.
  • 50%circleA shape rather than a corner: a portrait, the round video wrap. Outside the scale.
  • A device shell takes its own radius--shell-radius is set per device, at 18, 26, or 30px, because a phone frame is a drawing of an object. Its screen derives its corner with calc(), outer radius minus bezel, so the frame reads as a ring of one width.
  • Focus rings sit outside what they ringThe ring draws at 4px with a 3px offset, so it frames the control rather than matching its corner.
  • Two button shapes, by jobThe primary and ghost buttons are square. The back button is a pill, the shape this rule gives any control whose corners are its short side.

Breakpoints

Four. Three came from the case studies, which already used them, and the fourth is earned. New work reaches for these before adding another.

AxisQueryWhat changes
640pxmin-width: 640pxSmall tablet: two-up grids begin, the compact case cards, and the shelf split.
768pxmin-width: 768pxThe main axis, by a distance. Single column becomes multi-column, the hero recomposes, full-width phone media returns to inset.
900pxmin-width: 900pxThe earned one. The story section lays out beside its progress rail, and the primary nav links appear. Both were tried at 1024 and both were worse.
1024pxmin-width: 1024pxWide: four-across grids, this page’s own two-column shell, the engagement figure beside its text.

Every max-width query is the paired form of one of the four (767.98, 1023.98), which is the correct technique for a single boundary rather than a fifth axis.

06

Motion

Motion here confirms, directs, or sets a place. Confirm: something the reader did had an effect. Direct: something arrived and is worth looking at. Set a place: the story drifts through four grounds while nobody watches. Anything else is decoration.

The six durations

The name is the job, not the number. The gaps are perceptual: 80 to 200 is tracking the reader against responding, 200 to 300 a color changing against a thing that moved, 500 to 900 arriving against being drawn.

Six speeds, side by side

  • --motion-response

    The interface tracking the reader in real time: the scroll-progress bar, a pointer follow. Anything slower reads as lag rather than as motion.

  • --motion-state

    A property changing under the pointer or the keyboard: color, opacity, background. The most common motion on the site.

  • --motion-move

    Something shifting a short distance or lifting: a card on hover, a shadow deepening.

  • --motion-enter

    An element arriving: a reveal, a thumbnail settling, a step becoming active.

  • --motion-draw

    A line describing itself. SVG stroke animations only: a drawing takes the time a drawing takes.

  • --motion-ambient

    The environment changing behind the content: the story ground drifting between beats. Not meant to be watched.

The two curves

Same distance, same duration, different acceleration. --ease is the default for everything that is not entering. --ease-enter decelerates hard, so an arriving element reads as caught rather than stopped.

Both curves, racing

--ease
--ease-enter

A third is written out rather than tokenized: linear, for progress bars and cross-fades, where there is nothing to accelerate. Reaching for it should be a decision.

The reveal

One pattern, used everywhere

01
02
03

.reveal rises --motion-rise (20px) and fades in once, over --motion-enter on --ease-enter, siblings offset by --motion-stagger. --motion-lift (1.03) is how much a hoverable thing grows, and --motion-press (0.985) how much a pressed thing shrinks. Without JavaScript the page renders complete.

Reduced motion

Reading your setting…

A reader who asks for reduced motion sees none and loses no content. One rule in style.css stills every CSS animation and transition, and both JavaScript motion files check the query first. The lightbox alone keeps a 150ms fade.

01

Components

Sixteen of them, every one live. Buttons, disclosure, tags, and the work card also show their states.

Buttons

Primary is the sage fill, warm type at 5.32:1; ghost is its outline. On the dark band the primary turns warm with charcoal type. The back pill closes a detail page, and everything else is a text link.

States

Hover darkens the sage to its hover step, and the two outline buttons fill charcoal. There is no pressed or disabled state: nothing on the site disables a button, and a press navigates.

Classes
.btn with .btn-primary, or with .btn-ghost and .btn-ghost-light, and .btn-back
Variants
Primary and ghost on a light ground. The primary inside .eng3-split-cta on the dark band. Back, a pill, at the foot of a detail page.
Tokens
Markup
<a class="btn btn-primary" href="/lab/agent-review.html">Explore Agent Review</a>
<a class="btn btn-ghost btn-ghost-light" href="#enterprise-work">See enterprise work</a>

<!-- the dark band: an engagement page’s closing panel -->
<div class="eng3-split-cta">
  <a class="btn btn-primary" href="mailto:…">Start a conversation</a>
</div>

<!-- light ground, foot of a detail page -->
<a class="btn-back" href="/work">
  <svg viewBox="0 0 24 24" aria-hidden="true">...</svg>
  Back to the work
</a>
  • Do give a view one primary at most. A second one is two things claiming to be the next step.
  • Do pair the primary with the ghost when a view offers two ways in, as the homepage hero and Contact do.
  • Don’t put a button where a link will do. Prose takes text links, and the foot of a detail page takes the back pill.

Disclosure

Why I designed it this way

A ranking is a summary of reasons. Hide them and the reader has to rebuild them to check it, which is more work than not having been given the ranking at all.

The row is the component, and the rest belongs to whoever uses it. .disclosure-row puts the chevron before its label, turning down when open. The hairline, width, and type size belong to the page. The chevron is 1em wide, so it scales with its label.

States

Row

Why I designed it this way
Rest
Why I designed it this way
Hover
Why I designed it this way
Focus
Why I designed it this way

The argument behind the point.

Open
Classes
details > summary.disclosure-row, with .disclosure-chev before the label
Behavior
Native details: Enter and Space toggle it, and the open state is announced. At least 44px of the row is clickable.
Tokens
Markup
<details>
  <summary class="disclosure-row">
    <svg class="disclosure-chev" viewBox="0 0 24 24" fill="none"
         stroke="currentColor" stroke-width="2.25" stroke-linecap="round"
         stroke-linejoin="round" aria-hidden="true" focusable="false">
      <path d="m6 9 6 6 6-6"/>
    </svg>
    <span>Why I designed it this way</span>
  </summary>
  <div>…</div>
</details>
  • Do name what is behind it. “Why I designed it this way” tells a reader they can skip it. “Read more” tells them nothing, so they have to open it to find out, which is the one cost a disclosure exists to avoid.
  • Do leave anything a reader is owed outside it. What folds is the argument behind a point already made in full, never the point.
  • Don’t let the browser draw the mark. list-style: none and both marker rules are in .disclosure-row for this reason. Safari needs ::-webkit-details-marker and ::marker both.
  • Don’t reach for it for a menu or a table row. The nav toggle is a button that opens a menu, and the prototype’s [data-more] opens a detail panel inside a data table. Both are aria-expanded controls, and neither is this.

Tags

Enterprise software Design systems Governance

White pills on a quiet hairline, at --text-xs, descriptive and never interactive. Three, never more, naming the domain and the capability rather than design disciplines. On white the hairline does the work.

Classes
.tag, in a .tag-row
States
None. A tag is never a link or a filter.
Tokens
Markup
<span class="tag">Enterprise software</span>
<span class="tag">Design systems</span>
  • Do use them for flat classification: sector, discipline, tooling.
  • Don’t make one a link or a filter. Nothing on the site filters by tag, so a tag that looks clickable is a promise it cannot keep.

Deliverables list

Dash-marked list for comparisons: the 2px sage dash reads as a mark, not a bullet stack. Items hold to two lines; if one needs three, shorten the item.

Markup
<ul class="engagement-points">
  <li>A prototype real people can use</li>
  <li>What works, and where it falls over</li>
  <li>A build, kill, or park call</li>
</ul>
  • Do keep every item to one line, and name a thing the client receives.
  • Don’t write paragraphs in it. If an item needs a second clause it belongs in the body.

Kicker

Engagement 04 · Ideas everywhere, nothing you can use yet

The eyebrow above a page title: --text-xs Montserrat 600, uppercase, tracked --tracking-caps, in its surface’s accent. Every kicker and eyebrow on the site is this one treatment. The exception is a case study’s read time: 14px, muted, on --tracking-label.

Markup
<p class="eng3-kicker">How I work &middot; Ideas everywhere, nothing you can use yet</p>
<h1>...</h1>
  • Do put it directly above the title, naming position and subject.
  • Do type it in ordinary capitalization and let the stylesheet set the capitals. A screen reader and a search engine then get a sentence rather than a shout.
  • Don’t use it beneath a title as a subtitle. It is an eyebrow; below the title it reads as a stray label.
  • Don’t set one in sentence case, or give a new kicker class its own tracking. A kicker that leaves out text-transform: uppercase reads as a different kind of label from the rest.

Engagement card

The whole card is the link. Name in serif on two lines, bullets that say what the client gets, and the forward link pinned to the card’s floor so a row’s links align. Hover lifts the card and wakes the arrow.

Markup
<a class="engagement-card" href="/how-i-work.html#prototypes">
  <span class="engagement-num">04</span>
  <h3>...</h3>
  <ul class="engagement-points"><li>...</li></ul>
  <span class="engagement-go">How this works<svg aria-hidden="true">...</svg></span>
</a>
  • Do let the whole card be the link, as here. The target is then the card, not a phrase inside it.
  • Don’t nest a second link inside. An anchor inside an anchor is invalid, and the inner one is what a keyboard finds.

Fact table and dash list, on light

Role
Everything: product, identity, build
Built with
Astro, structured content, Pages CMS
  • Dog-friendly and vegetarian are real filters, not tags someone remembers to add
  • Every card’s town and category stamps render from the record

The write-up pages’ pair. Labels over facts, in the kicker’s 12px uppercase sage, on every case study and write-up. Under it, the light-ground twin of the card dash list.

Markup
<dl class="build-facts">
  <div><dt>Role</dt><dd>Everything: product, identity, build</dd></div>
  <div><dt>Built with</dt><dd>Astro, structured content, Pages CMS</dd></div>
</dl>

<ul class="build-list">
  <li>Dog-friendly and vegetarian are real filters, not tags someone remembers to add</li>
</ul>
  • Do wrap each pair in its own div. The grid is laid on the wrapper, not on dt/dd.
  • Do keep it to four facts. Past four it stops scanning and becomes a table.
  • Don’t use dl for a list that has no pairs. The dash list is there for that.

Number row, on light

1
day to replace it with a timer that walks the steps
0
changes to the speaking half, which shipped on and stayed on

The fact table with the figure first: a serif number at display size over a muted label. Tiles are 7rem each, so three or five across both look deliberate. The figures at the top of this page are this component.

Markup
<dl class="build-facts writing-facts">
  <div><dt>1</dt><dd>day to replace it with a timer</dd></div>
  <div><dt>0</dt><dd>changes to the speaking half</dd></div>
</dl>
  • Do wrap each pair in its own div, the same as the fact table. This is that component with a modifier on it, not a second one.
  • Do let a figure of one character still be a figure. A 0 that means nothing moved is often the most interesting tile in the row.
  • Don’t reach for it when the values are words. That is the fact table above. A tile is 7rem, and the display size is only worth spending on a figure.

Case study cards, on light

User experience audits

User experience audits

Focus areas

  • Visual hierarchy
  • Form clarity
  • Accessibility & contrast

Fiori style guide

Fiori style guide

Unified standards

  • Colors & typography
  • Core UI elements

Fiori pattern library

Fiori pattern library

Reusable components

  • Progress stepper
  • Filter bar

The case studies’ working, in warm cards on the --rule-card edge: a title, the artifact, and what it was for. The artifact and its notes split 45 to 55. The bento is one tall card beside a stack of two. Variants in the same file carry USDA’s picture-over-notes cards, the overview panels, and the career pages’ framed screens. The grid takes the page’s full width from 1024px.

Markup
<div class="cc-grid">
  <div class="cc-card">
    <h4 class="cc-title">User experience audits</h4>
    <div class="cc-split">
      <img class="cc-img cc-img-tall" …>
      <div class="cc-body">
        <p class="cc-label">Focus areas</p>
        <ul class="cc-list"><li>Visual hierarchy</li></ul>
      </div>
    </div>
  </div>
  <div class="cc-stack">… two cards …</div>
</div>
  • Do put the tall artifact in the lone card and the wide ones in the stack, so the two columns end together.
  • Don’t use it for an argument. A figure that makes a point is a rail or a flow; these cards show the work.

Overview brief, on the feature ground

The problem

Forty apps, built by different teams, each with its own layout.

The solution

One design system and one accessibility standard, checked in the release.

My part

The research, the audits, the style guide, and the training.

Every case study’s Overview: each paragraph under a field label, three to a row from 64rem with the column gap alone between them, on tea-light, the palette’s feature ground. A fourth field opens the row above the other three.

Markup
<section class="build-chapter ground-tea"> …
  <div class="cc-brief">
    <div class="cc-brief-item"><h3 class="cc-kicker">The problem</h3><p>…</p></div>
  </div>
  • Do keep it to the problem, the solution, and your part. Three fields fill the row.
  • Don’t put headings in the serif here. The labels are the hero’s own field labels, so the brief reads as the facts strip continued.

Impact table, on light

Dimension
Outcome
Velocity
The shared library cut the average time from design to development by about a third.
Quality
Standards and accessibility checks built into the process cut UI defects in production roughly in half, quarter over quarter.

Every client case study’s results: a warm card holding a Dimension and Outcome table, each dimension led by its line icon in a sage disc. The card is as wide as its contents, the measure plus the dimension column, so the longest line ends on its padding. “How I measured this” and the closing line sit inside it.

Markup
<div class="cc-card cc-impact">
  <div class="cc-impact-row cc-impact-head"><div>Dimension</div><div>Outcome</div></div>
  <div class="cc-impact-row">
    <div class="cc-impact-dim"><span class="cc-impact-disc"><svg …></span><span>Velocity</span></div>
    <div class="cc-impact-out">…</div>
  </div>
</div>
  • Do put the figure in the sentence, and how it was counted under “How I measured this.”
  • Don’t stretch it to the page’s width. A table is reading, and reading is as wide as the measure.

Figures and captions

The Connect design system thumbnail, used here as a figure specimen.
Every figure earns a caption that says what the picture proves, not what it contains.

Framed by --shadow-media, whose ring is the edge. Captions are --text-xs in muted gray, capped at --measure. A figure with nothing to prove is decoration, so cut it.

Markup
<figure class="build-shot">
  <img src="../img/shelf/connect-design-system.webp?v=16"
       width="1200" height="900" loading="lazy" decoding="async"
       alt="The Connect design system thumbnail." />
  <figcaption>Every figure earns a caption that says what the picture proves.</figcaption>
</figure>
  • Do always ship width and height. Without them the page reflows as each image decodes.
  • Do write a caption that says what the picture proves, not what it contains.
  • Don’t repeat the caption in alt. A screen reader then hears the same sentence twice.

Forward link

Uppercase Montserrat with an arrow that eases forward on hover. Used at the foot of a card as its single call to action.

Markup
<span class="engagement-go">
  How this works
  <svg viewBox="0 0 24 24" aria-hidden="true">...</svg>
</span>
  • Do use it inside a card that is itself a link, as the visible affordance.
  • Don’t use it standalone as a link. It is a span, so on its own it is unfocusable and unreachable by keyboard.

Work card

The homepage’s selected work, and the most-seen component on the site. The whole card is one link; the thumbnail lifts on hover with --shadow-lift, or --shadow-lift-sm on the compact grid. Its aria-label speaks for the pieces inside.

States

Classes
a.case-card in a .case-grid: .case-thumb, an h3, a .tag-row
Behavior
One link for the whole card, named by its aria-label. Focus shows the ring and the same change hover does.
Tokens
Markup
<div class="case-grid">
  <a class="case-card" href="/case-study/sap-product-maturity.html"
     aria-label="View case study: Making 40+ enterprise apps feel like one product">
    <div class="case-thumb">
      <img src="/img/site/sap-hero-02.webp" width="1672" height="941"
           loading="lazy" decoding="async" alt="..." />
    </div>
    <h3>Making 40+ enterprise apps feel like one product</h3>
    <div class="tag-row">
      <span class="tag">Enterprise software</span>
    </div>
  </a>
</div>
  • Do give the card an aria-label naming the destination. Without it the link announces as the thumbnail alt plus a heading plus three tags.
  • Do ship width and height on the thumbnail. The grid reflows as each image decodes otherwise.
  • Don’t put a second link or button inside. The card is already the link, and the inner one is what a keyboard reaches first.

Case hero

Build write-up · 3 min read

How I built Lucy Learns

An offline-first training app for one household: designed, built, and illustrated solo.

The top of every detail page: kicker, title, dek. The kicker names the family and the read time; the title is the only h1; the dek is one sentence and stops. Only the homepage hero takes --type-title-inset.

Markup
<header class="case-hero">
  <div class="container">
    <p class="case-kicker">Build write-up &middot; 3 min read</p>
    <h1 class="case-title">How I built Lucy Learns</h1>
    <p class="build-dek">An offline-first training app for one household.</p>
  </div>
</header>
  • Do keep the dek to one sentence. It introduces the page; it is not an abstract.
  • Don’t use a second h1 further down the page. The title here is it.
  • Don’t reach for --type-title-inset. It exists for the homepage hero and its column, and nothing else.

Glance block

Role
Everything: product, design, build
Built with
Plain JavaScript, no framework, no build step
Runs as
An installable phone app that works offline
Status
In use, still in development

Four facts at the top of a build write-up. The sibling of the fact table: same dl shape, different ground and position. Four is the number; a fifth row turns a glance into a table nobody reads.

Markup
<div class="glance">
  <dl class="glance-facts">
    <div><dt>Role</dt><dd>Everything: product, design, build</dd></div>
    <div><dt>Built with</dt><dd>Plain JavaScript, no framework</dd></div>
  </dl>
</div>
  • Do wrap each pair in its own div. The grid is on the wrapper, not on dt/dd.
  • Don’t go past four rows. Past four it stops being a glance, and the fact table lower down is the thing you want.
02

Icons

All icons are line art in the accent color, drawn on as they enter. One grid: a 24 viewBox, a 1.5 stroke, round caps and joins, and pathLength="1" on every shape.

  • Stroke only, never fillstroke="currentColor", so the icon takes its color from context: the sage on dark over charcoal, the sage on light everywhere else.
  • Every shape carries pathLengthOne normalized length means one stroke-dasharray rule draws every icon on uniformly, whatever its real geometry. The rule animates path and circle, so draw a line or a rectangle as a path.
  • Decorative, alwaysaria-hidden="true" on every icon. The label beside it carries the meaning, and the icon never carries it alone.
  • Two to five shapesPast five strokes an icon stops reading as a mark at the sizes the site sets it. Simplify the idea before adding a line.

The set, drawing on

  • magnifier
  • code
  • target
  • clock
  • people
  • document-check
  • drafting-compass
  • compass
  • person-check
  • send
  • grid-four
  • tokens
  • pencil
  • route-arrow
  • fork
  • key
  • check-circle
  • crop
  • accessibility
  • components
  • scan-gap
  • search-alert
  • trend-up

Each icon draws over --motion-draw on --ease, offset by --motion-stagger times its place in the row, with the site’s own keyframes. The small interface marks, the chevrons, the arrow, the check, and the cross, are drawn at a heavier stroke and never animate.

03

Media

Two clips run on this site: the header logo reveal and the hero portrait. Both follow one rule, learned the hard way.

  • A clip that plays once hands the element backWhen the reveal finishes it fades out over the static mark and removes itself. Leaving a paused video holding a required element looks fine until the page is backgrounded: browsers are free to drop a paused video’s decoded frame, and it returns empty. That is a real bug this site shipped and fixed.
  • The static state sets the boxThe mark or poster stays in flow and defines the space; the clip is layered over it, and the mark is painted beneath from the clip’s first frame. The header never shifts, the fallback is already there rather than swapped in late, and the hand-back is a fade between two things on screen, not a swap that waits on a phone to rasterize the SVG.
  • Every clip has a deadlineA start deadline for one that never begins, an end handler for one that finishes, and a backstop for one that stalls in between. All three land on the same finished state.
  • Silent, and never requiredMuted, playsinline, and skipped entirely under reduced motion. Nothing the clip carries is information the page needs.
01

Accessibility

Not a checklist pass at the end; these are standing patterns every page ships with.

  • A skip link on every pageFirst focusable element, off-screen until focused, jumps to #main. Its focused state:
Skip to main content
  • One focus ring, everywhereEvery link and button: a 2px outline in --color-focus-ring, offset 3px, on :focus-visible, so keyboard users get it and mouse clicks stay clean. Each ground sets the ring’s color, so on charcoal it is the sage on dark, as below.
  • Reduced motion is a contractEvery animation carries a prefers-reduced-motion path that lands on the finished state. The custom cursor opts out entirely and leaves the native one.
  • No JavaScript, no blank pageHidden-until-revealed states are gated behind a .js class the script sets. When the script never runs, everything simply renders complete.
  • Decoration declares itselfIcons and ornamental media carry aria-hidden="true"; the text beside them carries the meaning. Real images get alt text that describes the composition, not the filename.
  • Contrast floors are load-bearingText sits at 4.5:1 or better on its ground, and a control’s boundary at 3:1. That is why the sage has one value for light grounds and one for charcoal: the light sage reads 2.63:1 on charcoal, where the sage on dark reaches 7.47:1. Every pair is computed live in Color, and every page is measured on each merge. See How it’s enforced.
01

How it’s enforced

A design system is only as true as the last time someone checked it. Twelve scripts check this one, reading the four specs as rules and every rendered page as evidence, after every merge across all twenty-seven pages.

In about a second, with nothing installed

  • deployable.mjsThe tree can ship: no tracked symlink or ignored file that would break the deploy.
  • tokens.mjsEvery token the specs and this page name exists in the CSS, and every swatch here resolves to a declared value.
  • counts.mjsEvery number the docs assert is recounted from the files on disk, including the page count in the sentence above.
  • seo.mjsEach page’s canonical address, share card, and structured data agree with the page, and the sitemap lists every page once.
  • keys.mjsEach article’s key ideas are still its section headings.
  • oxford.mjsEvery list of three or more, in the copy, the metadata, and the specs, has its comma before the “and.”

In a browser, on every page

  • resting.mjsEvery color a reader can see clears its contrast floor at rest, measured by the pixel where text sits on a photograph.
  • states.mjsEvery hover, focus, and script-applied state clears the same floors, including states a page only reaches after a press.
  • typescale.mjsEvery rendered type size is a step on the scale, at four widths from 375 to 1440px.
  • curves.mjsNo border stops partway around a rounded corner.
  • cards.mjsEvery card on a ground has an edge that reads, at 1.2:1 or better.
  • readtime.mjsEvery page that states a read time states the right one: the words a reader meets at 238 a minute, plus a few seconds for each picture.

Each check prints the page count before its verdict, because a check that quietly measures less passes while looking at less.

02

Decision log

This is how each part got there: what the audits found, and what changed. The reasons are what stop a rule from being quietly undone.

ColorEighty hand-written values, brought onto the palette

The first color audit found 80 distinct values against 11 tokens, 26 hex and 54 rgba(), and only 21% of color uses going through a token. Most of the rest sat in illustration SVGs or the six Tailwind case studies.

  • A token that was never defined. site-nav.css asked for --color-accent five times. It did not exist, so all five had always rendered through a literal fallback that happened to look right.
  • A retired color that never left. The raw sage was still in nine places, one of them a token with no consumers. Eight became --color-accent-ring, written as color-mix() so it cannot drift again, and the dead token was deleted.
  • Two accessibility failures, both in the lightbox. An 11px hint at 3.62:1 and a close button whose only boundary measured 1.93:1. Both were fixed, and the accessibility rules stopped claiming 4.5:1 everywhere until it was true.
  • Six near-blacks and their near-duplicates collapsed onto black, one scrim token, white, and charcoal. Every off-palette value left is a documented exception.
  • The Tailwind color list, copied six times, became one set of var(--color-*) references: 510 utility replacements, and zero rendered colors changed.

Ranking near-duplicates by luminance got two of them wrong, including a slate blue matched to the sage because the two are equally light. Luminance answers how light a color is, which is right for contrast and wrong for whether two colors are the same.

ColorFour sages became three

The sage on light was two values: one for text, and a lighter one for fills. Nobody had checked: the primary button had used the text step all along, because warm type on the lighter sage measured 4.12:1, under the text floor.

Merging the two found the one place a straight swap failed: the focus ring on charcoal, where the text sage reads 2.63:1. The ring became a token each ground sets, and on charcoal it went from 3.40:1 to 7.47:1.

ButtonsA hover nobody could see

The state grid on this page showed it the day it was built: none of the three buttons changed visibly under a pointer.

  • The primary mixed the sage 90% with transparent, letting the ground through, so it moved 1.25:1 from rest. On white its warm label fell to 4.24:1, under the text floor, and no check saw it, because a color-mix() with transparency is a color the contrast checks cannot composite. It takes --color-accent-hover now: 6.90:1, and a change you can see.
  • The ghost filled charcoal on hover, which on the dark band is no change at all. Every ghost on the site turned out to be the light-ground form, whose hover already inverted, so the dark form takes the dark band’s hover wash.
  • The back pill had a transition and no hover rule. It takes the light-ground ghost’s, so the two outline buttons answer a pointer the same way.

The same pass found this page documenting the buttons on the wrong ground: primary and ghost live on light grounds, and on the dark band the primary turns warm.

AlphaTwenty alphas onto seven

Warm carried fourteen alphas on charcoal, and charcoal six more. Both collapsed onto the seven in the Alpha table. 39 elements changed color, and every one still passes AA. The pass also found a surface tint with no token, so the washes exist now.

ElevationNineteen hand-written shadows onto ten tokens

The spec said two tokens covered the site’s shadows. There were 19 hand-written box-shadow values, and five were the same lifted-screen idea written five ways. Fifteen distinct values became ten, named for what is lifting.

--shadow-media was a single 24px halo at 8%, the only thing saying where a cream drawing stopped. It is a ring, a contact shadow, and a tucked drop now, so the blur sits under the figure.

TypeForty sizes onto fourteen steps
BeforeAfter
Distinct fixed sizes4014, and they are the scale
Distinct clamp()20, no two alike4, and they are the ramps
Line-height values136
Letter-spacing values166
Base heading ladders2, disagreeing1
Ordering bugs3 in the base, 2 in overrides0

The custom vw coefficients the ramps replaced had inverted the ladder: h2 outranked h1 under 622px. One pass said every size was on the scale and missed three. That is why typescale.mjs measures every rendered size at four widths.

A fifth ramp, 40 to 80px, was drafted for the homepage hero and killed by applying it: its 80px ceiling had never rendered anywhere, and at 80px the headline ran to three lines.

TypeThree rules that were styling nothing

A pass meant to be mechanical, moving nine clamp() expressions onto ramps, found three of the nine dead: overridden, unmatched, or outranked. None of it showed in the stylesheet; each surfaced because the change was measured in a browser.

DisclosureThree patterns became one

The site once folded content three ways, and this page’s toggles drew no mark at all, leaving the browser to draw one. Chrome, Safari, and Firefox each draw it differently. A component with no mark of its own gets the browser’s.

LayoutTwelve breakpoints onto four

Twelve distinct widths described nine boundaries, each chosen for one component rather than the page. Six went. Three of the four that stayed were Tailwind’s, already on the case studies, and 900px earned its place when 1024 was tried and was worse.

SpacingThe measure moved from rem to ch

Figcaptions were capped at the same 40rem as 18px body copy, which is 70 characters at 18px and 115 at 12px. So --measure is 52ch and goes on the text element itself.

MediaOne bezel, and concentric corners

The phone frames were 5, 8, 8, and 10px, and ran inversely to the device: the largest mockup had the thinnest frame. Three screens’ corners were off concentric. One --bezel and a derived inner radius fixed both.

GovernanceOne site, since 2026-09-08

Until 2026-09-08 a private staging edition sat in front of this site. It is archived. A merge is public about a minute later now, and the checks in How it’s enforced stand between a change and a reader.