Design system
The tokens, type, spacing, and components behind adamhickey.com. Every value on this page is read from the site stylesheet at load, so what you see here is what ships.
Principles
Warm ground, dark ink, one sage accent. Serif at regular weight carries the headings; Montserrat carries labels and UI. Motion is a reveal, not a decoration: content rises once as it enters, then holds still. When something needs emphasis, it gets size or the accent, never both.
Color
Warm ground, dark ink, one sage hue in three steps. Neutrals do the layout work; sage is the only color, and it splits because no single value does every job. accent-text carries small type, accent-deep fills buttons and draws borders, and accent-on-dark is the one that survives charcoal. Putting the fill step on small text is the mistake the split exists to prevent — it reads 3.56:1 on muted light against a 4.5 floor. When something needs emphasis it gets size or the accent, never both. COLOR.md is normative for all of it, and it is the companion to TYPOGRAPHY.md.
Ink on ground
One line decides it: pick the ink from the ground, and check the number. Every cell below is computed in your browser from the live token values — if a color moves in the stylesheet, the number here moves with it rather than going quietly stale.
| Ink | White | Warm | Tea | Muted | Charcoal |
|---|---|---|---|---|---|
--color-charcoal | |||||
--color-muted-gray | |||||
--color-accent-deep | |||||
--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 sage are all legal as body text on white, warm, tea, and muted. 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 sage to 2.63:1. Neither may appear there. The dark band takes warm, and the lifted sage. 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.
Alpha
Alpha is how ink and rules soften, and it is not a free parameter: each step exists because it clears a stated threshold. Five on dark, two on light, and the two existing shadows.
| Token | Alpha | On charcoal | For |
|---|---|---|---|
--ink-on-dark | 1 | 14.02 | Body and headings on a dark band |
--ink-on-dark-secondary | 0.75 | 8.49 | Supporting copy, captions |
--ink-on-dark-muted | 0.55 | 5.24 | The quietest text allowed — still clears 4.5:1 |
--rule-on-dark | 0.14 | 1.52 | Dividers. Decorative only. |
--rule-on-dark-strong | 0.40 | 3.47 | The boundary of a control. Clears 3:1 on both sides of it. |
--wash-on-dark | 0.06 | 1.19 | Card and control fill on a dark band |
--wash-on-dark-hover | 0.12 | 1.43 | Its hover |
Every alpha above is on charcoal. The light grounds have their own set, and they are the ones a page is far more likely to need.
| Token | Value | For |
|---|---|---|
--rule-hairline | charcoal at 10% | Card edges, table rules |
--rule-strong | charcoal at 16% | A divider that has to read as one |
--wash-on-light-hover | charcoal at 6% | The hover fill on a light ground |
--color-tag-border | #e5e5e0 | The quiet hairline: tag outlines, card edges. 1.22:1 — decorative only. |
--color-accent-ring | accent-deep at 18% | Focus and hover rings on a light ground |
--color-accent-ring-on-dark | accent-on-dark at 22% | The same, on charcoal |
--color-caution | #d98e6a | The caution icon on charcoal, 4.17:1 — icon-only line art at 1.2px, so it answers to the 3:1 non-text floor. 2.62:1 on white; never on a light ground. |
--color-caution-text | #96482a | The caution ink on the light grounds: 6.45 on white, 5.09 on muted-light at worst. Carries warm on itself at 5.90, so it is a filled status line too. |
--color-caution-light | #f6e4dc | The caution ground: charcoal on it 12.44, the caution ink 5.23. A close call, a hurting factor, a driver the hours rule removes. |
Both rings are written as color-mix() rather than an equivalent rgba() literal, and that is the point: the ring’s predecessor was a derived color written out by hand, which then drifted from what it was derived from. Written this way it cannot — move the accent and the ring moves with it.
Below 0.55 is not text. Warm carried fourteen distinct alphas — six under 2:1 doing the same decorative job, five above 4.5:1 doing the same text job — and charcoal six more. Both are collapsed onto the seven above. 39 elements changed color and every one still passes AA; only one moved down, an engagement dek from 0.85 to 0.75, which is 8.49:1 either way. Applying it turned up a role with no token: three background values were neither ink nor rule but a surface tint, so washes exist now — and step 2 had set a hover background to --rule-on-dark because the number happened to be right, which is not the same as the token being right.
Elevation
A different axis from alpha: an alpha step answers “how loud is this ink,” an elevation answers “how far off the page is this object.” Eight tokens, named by what is lifting rather than by how far, all built on literal rgba(0, 0, 0, …); the two that carry a keyline take it from --rule-hairline.
| Token | For |
|---|---|
--shadow-card | A card at rest, with its keyline |
--shadow-resting | A bar or rail sitting on the page: nav, progress rail, step card |
--shadow-media | A framed image or figure: keyline, contact shadow, tucked drop |
--shadow-device | A screen mockup lifted off the page — contact shadow plus soft drop |
--shadow-popover | Floating UI on a light page |
--shadow-lightbox | A photo over the scrim |
--shadow-lift / -sm | A work-card thumbnail on hover, full grid, and compact |
This page and the spec both said two tokens covered the site. Neither half held: there were 19 hand-written box-shadow values, and the two tokens were barely the point — --shadow-sm had no consumers at all and --shadow-card only three, all of them on this page’s own chrome. The site’s shadows were in practice entirely hand-written, five of them the same lifted-screen idea written five ways. 15 distinct rendered values are now 9. A keyline is not an elevation: 0 0 0 1px and its inset form draw a border and take a rule token, never a --shadow-*. A blur is not an edge either: --shadow-media was one 24px halo at 8%, and on a cream drawing over a white or warm ground it was the only thing saying where the picture stopped. It is a ring, a 1px contact shadow and a drop with a negative spread now, so the blur sits under the figure rather than around it.
They live in color.css, which is a taxonomy compromise made on purpose. A shadow is a physical property that happens to be drawn in color, so it belongs with radius and easing — but site-nav.css needs one and the case studies’ inline styles need two, and neither can see style.css. What decides where a token lives is reach, not taxonomy.
Artwork is not interface
The palette governs interface color, and the boundary is deliberate rather than an oversight. An illustration is allowed its own palette; a rendering of a physical object is allowed to look like the object. All three specs draw the same line — COLOR.md section 6 for color, SPACING.md section 4 for the space steps, MOTION.md section 6 for the scale of durations.
- The story scene has six colors of its own
--story-bg,--story-ink,--story-ink-soft,--story-body,--story-railand--story-accent, scoped to.story[data-active]instyle.css. Snapping them onto the palette would flatten the scene into one ground, which is the opposite of the point. They are named, scoped and deliberate — not literals that escaped the audit. - So is the device chrome
--device-chromeis off the palette for the same reason, and lives instyle.cssrather thancolor.cssso that naming it cannot quietly make it palette. See Media below. - “Artwork” is a claim about the thing, not an excuseThe test is whether it renders something that exists outside the interface. A card is not artwork because its color is inconvenient. And the exemption is from the scale, never from legibility: text over an illustration still has to clear its floor, measured in pixels where a token cannot answer.
Where the stylesheet stands
Eighty distinct color values against eleven tokens — 26 hex and 54 rgba(). The interesting part is that this is the opposite of what the type audit found. Typography had no tokens at all; color has had them all along and does not use them.
- 867 literals against 224 token references — and where they actually wereA value a token already names, written out by hand:
#252525395 times,#556b51278,#f5f5f069. Only 21% of color uses went through a token. But the bulk turned out not to be in the stylesheets at all: 319 are SVG attributes inside thearia-hiddenillustration scenes, which the artwork rule exempts, and 486 are in the six Tailwind case studies. The twelve pages that loadstyle.csshad about 30, and they are gone. - A token that was never defined
site-nav.cssreferencedvar(--color-accent, #657D60)five times. There is no--color-accent— it is--color-accent-deep— so all five had always resolved through the literal fallback. It rendered correctly, which is exactly why nobody noticed: the shared header’s accent looked right and was not connected to the palette at all. Four non-color fallbacks in that file are still load-bearing, because--nav-height,--ease,--container-maxand--font-sanslive instyle.cssand six pages cannot see it. - A retired color that never left — now it hasThis page said a single darker sage replaced a raw-plus-deep pair. The raw sage was still here nine times, and
--color-accent-soft— a token, in the palette — was defined as exactly that. Eight of the nine were one thing wearing two hats: the focus ring behind the email popover’s input, duplicated inline across seven pages, and the hovering cursor ring. Both take--color-accent-ringnow, written ascolor-mix()off the accent so it cannot drift from it again. The ninth was the token, and it had no consumers at all — the last formal trace of the retired color was a dead token, so it was deleted rather than redefined. No raw sage remains in any file. - Two accessibility failures, both in the lightbox — fixed
.ah-lightbox-hintwas 11px text at 3.62:1 where 4.5 is required, and it is the line telling a phone user the picture zooms and pans, so it is the opposite of decorative. It takes--ink-on-dark-mutednow: 5.13:1..ah-lightbox-closewas a 42px control whose only boundary was a 1px border at 1.93:1 against a required 3:1; it takes--rule-on-dark-strong: 3.79 outside the button, 3.18 inside. Fixing the second turned up a third thing — the hover border was already 0.4, so raising the resting state to clear the floor would have flattened the hover into no change at all. It steps up to 0.55, and the state stays a state. - Six near-blacks and the near-duplicates — resolvedSix near-blacks were in use. Three were shadow bases and went to black in step 5; two were scrims and became one
--color-scrimin step 6. The duplicates went the same way:#fafafato white,#ccccccto--color-rule,#22241fto charcoal. Every off-palette value left in live CSS is now a documented exception — the artwork, the story section’s four scroll grounds, and the phone bezel. - Ranking by luminance was the wrong toolThe first pass sorted near-duplicate candidates by luminance and got two wrong:
#eaeee2looked like tea-light but is one of the story grounds, and#56718ccame up as a near-match for the sage#5d7559— a slate blue and a green, identical in lightness and nothing alike. Distance in RGB sorted both correctly. Luminance answers “how light is it,” which is exactly right for contrast and exactly wrong for “is this the same color.” - The Tailwind color list is copied six times — fixedEach case study declared the same seven colors inline, 42 hardcoded copies, two of them the same value under two names — and then bypassed even that with 138 arbitrary
text-[#…]values. Every config now readsvar(--color-*), the wayfontSizealready reads the type scale, and the alias is gone:text-darksaid nothing, and its 263 uses saytext-charcoal. 510 utility replacements, zero rendered colors changed.
COLOR.md §8 is the order to fix it in, and it puts the shared token file first rather than sixth — the one thing the type sequence got wrong, when tokenizing a stylesheet both regimes load, before both regimes could resolve the tokens, shipped a live regression for two commits. Steps 1 and 2 are done: the palette lives in color.css, all eighteen pages load it, and both lightbox failures are closed. Step 1 was verified additive by reading six color properties off every element on every page — 34,867 values, none of which moved.
Surfaces
Five grounds, each with its own legal ink and accent. Sections alternate between them so the page has rhythm without decoration; each tile below is painted with the real ground and carries the pairing that belongs on it.
-
Warm
The page ground
Dark ink, charcoal secondary. Small accent text uses the deep sage.
Hero, proof strip, case studies band -
White
The working surface
For content-dense sections. The one ground where cards carry a shadow.
Write-up bodies, products -
Muted light
The alternate
Breaks up runs of warm and white. Also the color of rules and hairlines.
Alternating sections, borders -
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 text at 68% for body, and only the lifted sage as accent.
Engagements, contact, footer
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, and the serif’s own contrast does the rest. Every size below is a step on one scale; the specimens render at the live token and report the pixel value the browser computed, so a step that drifts shows here first. TYPOGRAPHY.md is normative for all of it.
On the words. A dek is the short line between the headline and the body; a lede is the opening passage of the body. Both are American newsroom spellings, misspelled on purpose so an editor’s note could never be typeset as copy — the same tradition as hed and graf. They are the terms the class names use. Not “standfirst,” which is the British word for a dek: the site carried both for the same element until they were reconciled, and .build-standfirst is .build-dek now. One element, one word, one English. 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 · tracked 0.12em · kickers and eyebrows Engagement 04 · Ideas everywhere, nothing you can use yet
The scale
Fourteen steps, base 16px, 11px to 80px. Two tiers on two different principles. The text tier steps arithmetically, because at small sizes the eye reads the absolute difference and not the ratio. The display tier is geometric, and the rule that generates it is worth memorizing: every step doubles four steps later — 20 → 40, 24 → 48, 28 → 56, 34 → 68, 40 → 80. That is a ratio of 21/4, about 1.189, rounded to whole pixels. You can extend it in either direction without a calculator: halve or double, then fill the three between.
Not invented for this page. Of the 587 type-size declarations across the stylesheets and the sixteen pages, 78% already land exactly on one of these steps and 95% land within a pixel; the scale is the site’s own center of gravity with the rounding noise taken out.
Text tier · arithmetic · never fluid
-
Engagement 01
-
Minneapolis, 2026
-
Selected work
-
Most engagements start with a situation, not a request for deliverables.
-
Most engagements start with a situation.
Display tier · geometric · ratio 2^(1/4)
-
Working Product Prototype
-
Judgment you can hold
-
Judgment you can hold
-
Judgment you can hold
-
Judgment
-
Judgment
-
Judgment
-
Judgment
-
Judgment
The four fluid ramps
You never hand-write clamp(). There are four ramps, each interpolating between two named steps, and all four share one viewport window: 400px to 1280px. Nothing below h3 is fluid — a 20px label is 20px on every screen. The last column is live: resize this window and watch it move.
| Ramp | Steps | min → max | Here, now | Use |
|---|---|---|---|---|
--type-title | 4xl → 8xl | 34 → 68 | h1, every page title, the homepage hero included. | |
--type-section | 3xl → 6xl | 28 → 48 | h2, a section heading. | |
--type-subsection | 2xl → 4xl | 24 → 34 | h3, a subsection heading. | |
--type-lede | lg → 2xl | 18 → 24 | Deks, ledes, pull quotes. | |
--type-title-inset | 2xs h1 → 4xl | 36 → 64 | The homepage hero, which shares its row with the portrait. |
One ramp is deliberately outside that window. --type-title-inset runs 768 to 1280 rather than 400 to 1280, because the thing it is sized against only exists in that range: above 768 the homepage hero shares its row with the portrait and gets about 47% of the container, and below it the row stacks and the title takes --type-title like every other h1. A ramp is sized against the column it lands in, not against the page. The shared window is the whole point for the other four, not a tidiness preference. At any width every ramp sits at the same progress between its own endpoints, so an ordering that holds at 400px and at 1280px holds at every width in between — provably, with nothing to test. The custom vw coefficients this replaces are exactly why the base ladder inverts today: h2 outranks h1 on every viewport under 622px, and h3 and h4 cross at 982px.
The heading ladder
Sizes come from the ladder; the ladder comes from the document outline. Never skip a level to reach a size.
| Level | Token | px | Family | Wt | LH | Track |
|---|---|---|---|---|---|---|
h1 page title | --type-title | 34 → 68 | Serif | 400 | 1.05 | −0.02em |
h1 in a narrow column | --type-title-inset | 36 → 64 | Serif | 400 | 1.05 | −0.02em |
h2 section | --type-section | 28 → 48 | Serif | 400 | 1.15 | −0.015em |
h3 subsection | --type-subsection | 24 → 34 | Serif | 400 | 1.15 | −0.01em |
h4 block title | --text-xl | 20 | Sans | 600 | 1.35 | −0.01em |
h5 card title | --text-lg | 18 | Sans | 600 | 1.35 | 0 |
h6 label heading | --text-base | 16 | Sans | 600 | 1.35 | 0 |
- The seam at h3/h4 is deliberate
h3is the last serif level andh4the 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
vwcoefficient, and never into a newclamp(). That one rule is what keeps the count at four ramps instead of back at twenty — and it is what retired the fifth, below. - There is no display ramp, and that is a findingA fifth ramp, 40 to 80, was drafted for the homepage hero. Applying it killed it. Its 80px ceiling had been read off the Tailwind pages’
body h1 { 5rem }— which all six of them override in the hero withtext-[48px] md:text-[68px], so 80px was never rendered anywhere; 68px is the largest type the site puts on screen. Nor was the homepage hero ever the biggest thing on the site:.case-titletopped out at 68px against the hero’s 61.6px. And at 80px the seven-word headline ran to three lines and swamped its own block. The homepage hero is a page title like any other.
Weight
Four, and the site uses all four. Named rather than written as numerals so a weight is chosen by what it is doing, not by how heavy it looks in isolation.
| Token | Value | For |
|---|---|---|
--weight-regular | 400 | Body copy, and every serif heading — the display family carries headings at regular weight |
--weight-medium | 500 | UI labels that need to sit slightly forward of body copy |
--weight-semibold | 600 | Eyebrows, kickers, table headers, buttons |
--weight-bold | 700 | The sans headings inside a case study, where family alone does not separate them |
Weight is also what separates h6 from body copy, since it is the same size — see the ladder above.
Line height and tracking
Both are functions of size, not of component. Bigger type needs less leading and tighter tracking; uppercase always gets tracking, lowercase never gets a positive value.
| Leading | Value | Applies to |
|---|---|---|
--leading-display | 1.05 | 34px and up |
--leading-heading | 1.15 | 24–28px |
--leading-tight | 1.35 | 16–20px headings |
--leading-ui | 1.45 | 11–14px labels, nav, cells |
--leading-body | 1.6 | 16px paragraphs |
--leading-reading | 1.7 | long reading columns |
| Tracking | Value | Applies to |
|---|---|---|
--tracking-display | −0.02em | 56px and up |
--tracking-title | −0.015em | 34–48px |
--tracking-heading | −0.01em | 20–28px |
--tracking-normal | 0 | 16–18px, all lowercase |
--tracking-label | 0.08em | 14px uppercase labels |
--tracking-caps | 0.12em | 11–13px uppercase kickers |
Six and six, against the thirteen line-heights and sixteen tracking values live in the stylesheet today. Numerals that align in a column take font-variant-numeric: tabular-nums in Montserrat, never a second font.
Where the stylesheet stands
Eight passes in, and now measured rather than asserted. Every rendered type size on all eighteen pages sits on one of the fourteen steps, or on --type-title-inset’s own two endpoints, at every width where the ramps are pinned, in both regimes. Forty distinct fixed sizes became fourteen; twenty clamp() expressions, no two alike, became the four shared ramps above and, later, the one that deliberately is not; thirteen line-heights and sixteen trackings became six and six.
Pass seven said the same sentence in the past tense and nothing ran it against the site. It was wrong: the glance block on the three build write-ups carried 13, 15 and 17px hand-written — none of them a step, on pages the claim named. They are --text-sm and --text-base now, and the two that pair inside a step card kept the 2px relationship they already had. scripts/typescale.mjs is the difference between a claim and a fact: 13,944 rendered sizes across eighteen pages at 375, 400, 1280 and 1440px, and it runs in CI beside the contrast check. Its two exemptions are written down rather than assumed — the inset ramp by name, and aria-hidden decoration, on the same argument that exempts the illustration scenes from the palette rule.
| Before | After | |
|---|---|---|
| Distinct fixed sizes | 40 | 14, and they are the scale |
Distinct clamp() | 20, no two alike | 4, and they are the ramps |
| Line-height values | 13 | 6 |
| Letter-spacing values | 16 | 6 |
Type tokens in :root | 2 (the families) | 34, in type.css |
| Base heading ladders | 2, disagreeing | 1 |
| Ordering bugs | 3 in the base, 2 in overrides | 0 |
What survives as a literal is deliberate and annotated where it sits: the 16px root anchor on each page, which cannot be written in rem because it is what rem resolves against; .product-link-ext at 0.875em, an arrow meant to track whatever size link it sits in; and two line-heights that are box control rather than leading.
Three rules that were styling nothing
The last pass was meant to be mechanical — move nine clamp() expressions onto ramps. Three of the nine turned out to be dead, and none of it was visible from reading the stylesheet:
- A second
.hero-inviteTwo top-level rules, a serif one carrying its own clamp and a sans one further down. The sans one wins at every width and overrides every property the serif one sets — including the comment describing the invitation line as serif. The page has always rendered it sans. .engagement-notematches no markupThe class appears nowhere in any of the eighteen files..case-section h3never winsMeasured across the seven pages that use.case-section: 0 of 20h3elements inside one take it, because.case-overview h3and.eng3-steps .eng3-step h3catch every one. And once its clamp became--type-subsectionit was restating what the base ladder already gives anh3, so it is a spacing rule now.
The first needed the cascade resolved, the third needed the DOM. Neither is legible from the source. They surfaced only because every change in this work was measured in a browser rather than assumed — which is the same reason the reference reads its own values back instead of quoting them.
What is left, and it is not typography
- Color has the drift type used to have
text-[#252525]appears 101 times across the case studies andtext-[#657D60]37 times. Those are--color-charcoaland--color-accent-deep, which already exist — andtype.cssnow proves a token file can reach those pages. - Dead rules beyond the three
.build-hero-imgand.build-mode-notehave zero references in any page. Left alone: dead-CSS cleanup is its own pass, and a proper one would check all ofstyle.cssrather than whatever a type change happened to touch.
Spacing
Every space on this site is a multiple of 4px. That was already true of 78.5% of it before a single token existed — the grid was finished here, not introduced. Below 48px space is fixed and comes off the nine steps; above it space is fluid and comes off a ramp, for the same reason type does. The bars render at their live token and report the pixel value the browser computed, so a step that drifts shows here first. SPACING.md is normative for all of it.
The nine steps
Two tiers, and the change of tier is the point. 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 10% and invisible — a step nobody can see is a step nobody should have to choose between. The Tailwind column is not a coincidence: the eight most-used numeric utilities across the six case studies are eight of these nine, in rank order, and the config now reads them from the same tokens.
-
--space-2xsp-1Hairline separation. A label off its icon; a tag off its neighbor.
-
--space-xsp-2The tightest real gap. Inside a pill, between stacked metadata lines.
-
--space-smp-3A dense inset, a tight row gap.
-
--space-mdp-4The default. When no reason says otherwise, this.
-
--space-lgp-5A comfortable inset for a small card.
-
--space-xlp-6The most-used space on the site. Card padding, grid gap, the standard stack.
-
--space-2xlp-8Between components. A card off the next card.
-
--space-3xlp-10A generous inset for a large card or a panel.
-
--space-4xlp-12The largest fixed step. Beyond this, a ramp.
The four ramps
| Token | px range | Here, now | Job |
|---|---|---|---|
--gutter | 24 → 32 | Container padding: the page edge to the content. | |
--space-block | 48 → 72 | Between major blocks inside a section. | |
--space-section | 64 → 96 | The vertical padding on a standard full-width band. | |
--space-section-lg | 80 → 128 | The same, for a band with a full-bleed treatment: the hero, the story. |
All four share the type ramps’ window, 400px to 1280px, so at any width every ramp sits at the same fractional progress between its own endpoints and an ordering that holds at both ends holds everywhere between. What the shared window does not buy is 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. That is the right direction for a wide screen, but it is a consequence rather than a property. Resize this page and the middle column moves.
The measure
A line of text is measured in characters, not pixels. Comfortable is 60 to 75, and --measure is 52ch — 70 characters. In ch rather than rem because a rem measure can only be right at one size, and this site sets body text at five. The proof was in the stylesheet: figcaptions were capped at the same 40rem as the 18px body copy, which is 70 characters at 18px and 115 at 12px.
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 that holds it. .case-narrow is 896px and stays there, because the figures, fact lists, and step cards inside it want the room — capping the container instead would have shrunk every figure on the build write-ups by 23%. The five engagement pages take .case-wide, the same column at the full 1280px frame, because a step card needs about 1200px to give its text the measure beside a readable illustration. Short display text used to sit deliberately tighter, .hero-invite at 44ch and .about-lede at 46ch, on the reasoning that large type wants fewer characters per line. Neither carries its own cap now: the invite runs the headline's width, and the lede shares .about-intro, 60ch in the plain sentence's size, with the sentence above it, so the two paragraphs end on one edge.
Layout, radius, and elevation
| Token | Value | Use |
|---|---|---|
--container-max | | Single content container; side gutters come from --gutter. |
--gutter | | Page edge to content, 24 → 32px. One value for both regimes; see Spacing. |
--measure | | The reading measure, 52ch. Goes on the text, not its wrapper. |
--nav-height | | Fixed header; anchor targets offset by it. |
--radius-xl | | Cards and framed media. |
--radius-pill | | Tags and pill buttons. |
--shadow-card | | Resting card elevation: a hairline plus a soft drop. |
--ease | | State changes at 0.25–0.3s. Reveals use their own spring curve; see Motion. |
Breakpoints
Four, down from twelve distinct widths describing nine boundaries. Three are Tailwind’s, which the six case studies were already using; the fourth is earned. New work reaches for these before adding another.
| Axis | Query | What changes |
|---|---|---|
640px | min-width: 640px | Small tablet: two-up grids begin, the compact case cards and the shelf split. |
768px | min-width: 768px | The main axis, and by a distance — 53 of the site’s 93 queries. Single column becomes multi-column, the hero recomposes, full-width mobile media returns to inset. |
900px | min-width: 900px | The 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: the story’s two-column layout works from 900, and moving the nav hid it entirely from 900 to 1023. |
1024px | min-width: 1024px | Wide step: four-across grids, the reference page’s own two-column shell, the engagement figure beside its text. |
The widths that went: 560, 620, 700, 860, 1000 and 1100, each chosen for one component rather than for the page. Every max-width query is the paired form of one of the four above (767.98, 1023.98), which is correct technique for a single boundary rather than a fifth axis.
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 that is none of those three is decoration, and the test for a new animation is whether a reader who missed it would have lost something. MOTION.md is normative for all of it.
The six durations
The name is the job, not the number. The gaps are perceptual rather than tidy: 80 to 200 is the difference between tracking the reader and responding to them, 200 to 300 between a color changing and a thing that moved, 500 to 900 between arriving and being drawn. Each dot below runs on its own live token and reports what the browser computed.
Six speeds, side by side
-
--motion-responseThe interface tracking the reader in real time: the scroll-progress bar, a pointer follow. Anything slower reads as lag rather than as motion.
-
--motion-stateA property changing under the pointer or the keyboard — color, opacity, background. The most common motion on the site.
-
--motion-moveSomething shifting a short distance or lifting: a card on hover, a shadow deepening.
-
--motion-enterAn element arriving: a reveal, a thumbnail settling, a step becoming active.
-
--motion-drawA line describing itself. SVG stroke animations only — a drawing takes the time a drawing takes.
-
--motion-ambientThe environment changing behind the content: the story ground drifting between beats. Not meant to be watched.
The two curves
Same distance, same duration — only the shape of the acceleration differs, which is the only way to see why there are two. --ease is the default for everything that is not entering. --ease-enter decelerates hard, so an arriving element reads as caught rather than stopped. It replaced two curves that were doing the same job and that no reader could tell apart.
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
.reveal rises --motion-rise (20px) and fades in once as it enters the viewport, over --motion-enter on --ease-enter. Siblings offset by --motion-stagger (60ms), multiplied by index. Two more distances complete the vocabulary: --motion-lift (1.03) is how much a hoverable thing grows, and --motion-press (0.985) how much a pressed thing shrinks — a 3% shrink reads as a flinch on a large surface, which is why it is not 0.97. The hidden state is gated behind a .js class, so without JavaScript the page renders complete rather than blank — an animation that reveals content is never the only thing that reveals it.
Reduced motion
A reader who asks for reduced motion sees no motion and loses no content. Both halves matter. One blanket rule in style.css neuters every CSS animation and transition on the site without naming any of them, and both JavaScript motion files check the query and return before running. So a new animation needs no reduced-motion rule of its own — add one only when the element’s un-animated resting state is wrong, and say why in a comment. The lightbox is the one deliberate exception: it keeps a 150ms linear fade rather than snapping, because an overlay that appears between one frame and the next is more disorienting than one that takes an eighth of a second.
Components
Sixteen of them, and where a specimen is live it is built from the site’s own stylesheet rather than rebuilt here. Four have no specimen: they belong to case-study-base.css, which this page does not load, and a hand-made copy would go stale the moment the case studies changed. Those four carry their markup instead.
Buttons
Primary carries the raw sage; ghost is a hairline that fills dark on hover. Both live on dark ground. On light ground the only button is the pill back link at the foot of a detail page; everything else is a text link.
Markup
<a class="btn btn-primary" href="/contact">Start a conversation</a>
<a class="btn btn-ghost" href="/work">See the work</a>
<!-- 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 primary with ghost on the dark band, where both were measured.
- Don’t put a filled button on light ground. The only light-ground button is the back pill; everything else is a text link.
Tags
White pills with a warm hairline, 0.8125rem. Descriptive only; never interactive. Three, never more, and they name the domain and the capability rather than disciplines only a designer would recognize. One component with two homes: the case study cards and the product cards, which used to set a middot-separated run of their own. On white ground the fill does not read and the hairline does the work; that is the same component on a different surface, not a second treatment.
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
- A prototype real people can use
- What works, and where it falls over
- A build, kill, or park call
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: 0.8125rem Montserrat 600, tracked 0.08em, uppercase, in the accent for its surface. A middot separates the family from the situation.
Markup
<p class="eng3-kicker">How I work · Ideas everywhere, nothing you can use yet</p>
<h1>...</h1>
- Do put it directly above the title, naming position and subject.
- Don’t use it beneath a title as a subtitle. It is an eyebrow; below the title it reads as a stray label.
Engagement card
The whole card is the link. Name in serif regular on two lines, bullets that say what the client gets (two lines each, at most), and the forward link pinned to the card's floor so four cards' links align whatever their copy length. Hover lifts the card and wakes the arrow.
Markup
<a class="engagement-card" href="/engagement/working-product-prototype.html">
<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: uppercase sage labels over facts, and the light-ground twin of the card dash list for anything that scans better as a list than a sentence with nine commas.
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 ondt/dd. - Do keep it to four facts. Past four it stops scanning and becomes a table.
- Don’t use
dlfor a list that has no pairs. The dash list is there for that.
Figures and captions
A 12px radius and the same hairline the tags use, so framed media sits in the system rather than floating on the page. Captions are sans 0.8125rem in muted gray, capped at 40rem so they stay a caption rather than becoming body copy. A figure with nothing to prove is decoration; cut it instead.
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
widthandheight. 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.
Page furniture
The header and footer are live at the top and bottom of this page rather than reproduced here, since duplicating a sticky element in a specimen only invites the two to drift.
- The headerSticky,
--nav-heightat 80px, which is also whatscroll-padding-topuses so an anchor never lands under it. The mark returns home; the links are section anchors on the homepage and absolute paths elsewhere. It carries the reveal clip covered under Media. - The footerCharcoal, warm text, one line naming the practice and the place, the two contact links, and a closing note at 60% opacity. No navigation: anything worth reaching is reachable above it.
- Both are hand-written per pageThere is no include step, so the two page families keep their own copies. That is exactly how the header drifted once before, which is why the cursor and the reveal are built in shared JavaScript rather than pasted markup.
- Do change the header in every page that carries it. There is no include step: the shell is copied into all nineteen files.
- Don’t reproduce the header or footer as a specimen. Two copies of a sticky element drift, and the live ones are at the top and bottom of this page already.
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— 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. The aria-label is what a screen reader announces instead of the three separate pieces inside.
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-labelnaming the destination. Without it the link announces as the thumbnail alt plus a heading plus three tags. - Do ship
widthandheighton 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
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 on the page; the dek is one sentence and stops. Regime A pages set it in --type-title; the homepage hero is the one exception, on --type-title-inset, because it is sized against the 47% column it shares with the portrait.
Markup
<header class="case-hero">
<div class="container">
<p class="case-kicker">Build write-up · 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 is a standfirst, not an abstract.
- Don’t use a second
h1further 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, before the page itself starts. The sibling of the fact table lower down: same dl shape, different ground and position. Four is the number — it is a glance, and a fifth row turns it 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 ondt/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.
Section table of contents
No specimen here, and the reason is the architecture. This lives in case-study-base.css, which only the six Tailwind case studies load — this page loads style.css. A specimen rebuilt from the design system’s own tokens would look right and drift the moment the case studies moved, which is the one thing every other specimen on this page is built not to do. It is a sticky bar under the header on a case study, listing the page’s three sections. Its ground stays rgba() rather than a token on purpose: the tokenized spelling computes to color(srgb ...), which the contrast checks cannot composite, and they read the bar as opaque charcoal and report 38 false failures.
Markup
<nav class="case-toc" aria-label="Section navigation">
<div class="case-toc-inner">
<a href="#overview">Overview</a>
<a href="#my-approach">My approach</a>
<a href="#the-impact">The impact</a>
</div>
</nav>
- Do give it an
aria-label. A page with a site nav and a section nav has twonavlandmarks, and unlabeled they are indistinguishable. - Do keep the links to the sections that actually exist on the page.
- Don’t tokenize its background. The comment sits on the declaration explaining why, and removing it costs the checks their sight of 38 pieces of text.
Read time
No specimen here, and the reason is the architecture. This lives in case-study-base.css, which only the six Tailwind case studies load — this page loads style.css. A specimen rebuilt from the design system’s own tokens would look right and drift the moment the case studies moved, which is the one thing every other specimen on this page is built not to do. A single line above the title on a case study, in the same position the kicker occupies on regime A. It is written by hand, not computed — there is no build step to compute it in.
Markup
<span class="case-readtime">3 min read</span>
<h1>One real-time view of a 450+ truck fleet</h1>
- Do write it as a round number of minutes. Precision no reader can verify is noise.
- Don’t put it after the title. It is an eyebrow, and below the title it reads as a caption on the heading.
Next-case closing
No specimen here, and the reason is the architecture. This lives in case-study-base.css, which only the six Tailwind case studies load — this page loads style.css. A specimen rebuilt from the design system’s own tokens would look right and drift the moment the case studies moved, which is the one thing every other specimen on this page is built not to do. The foot of every case study: one link to the next one, with an eyebrow above the title. It is the only navigation at the bottom of a detail page, and it is deliberately a single destination rather than a grid — a reader who finished one case study is offered the next, not a menu.
Markup
<section class="case-closing" aria-label="Continue reading">
<div class="case-closing-inner">
<a href="innovators-studio-visual-identity.html" class="case-closing-next">
<span class="case-closing-next-eyebrow">Next case study</span>
<span class="case-closing-next-title">Connecting a global innovation network →</span>
</a>
</div>
</section>
- Do let the whole block be one link, and label the section so the landmark is named.
- Don’t offer more than one onward case. The point is a next step, not a choice.
Scroll progress rail
No specimen here, and the reason is the architecture. This lives in case-study-base.css, which only the six Tailwind case studies load — this page loads style.css. A specimen rebuilt from the design system’s own tokens would look right and drift the moment the case studies moved, which is the one thing every other specimen on this page is built not to do. A hairline at the top of a case study showing how far through the reader is. It carries aria-hidden and is empty: it reports position to the eye and to nothing else, because a screen reader already knows where it is in the document.
Markup
<div class="scroll-progress" aria-hidden="true"></div>
- Do keep it
aria-hiddenand empty. It is decoration for a fact assistive technology already has. - Don’t use it on a page short enough to need no scrolling. A rail that never moves reads as a broken one.
Icons
All icons are line art in the accent color, drawn on as they enter. One grid is in use: every icon sits on a 24 viewBox with pathLength="1" at a 1.5px stroke, on the case studies' glance rows and the engagement pages' fact strips, and the engagement-page one-person icons thin it to a 1px hairline in CSS. Everything else is shared law:
- Stroke only, never fill
stroke="currentColor"with round caps and joins, so the icon takes its color from context: lifted sage on dark, deep sage on light. - Every shape carries pathLengthOne normalized length means one
stroke-dasharrayrule animates every icon uniformly, whatever its real geometry. - Decorative, always
aria-hidden="true"on every icon. The heading beside it carries the meaning; the icon never carries it alone. - Two to four shapesMore than four strokes stops reading as a mark at 44px. Simplify the idea before adding a line.
The draw-on, live
Three engagement-page icons at their real size, color, and hairline, drawing on with the site's own keyframes and 110ms stagger.
Media
Two clips run on this site: the header logo reveal and the hero portrait. Both follow one rule, learned the hard way.
Device chrome
A phone mockup is a drawing of a physical object, so its frame answers to the artwork rule rather than to the scales — SPACING.md section 4 carves it out of the space steps and COLOR.md section 6 carves its color out of the palette. Changing how much room a card gives its contents must never change how a phone looks. Both tokens live in style.css, not shell.css, for exactly that reason.
--bezel— 8px, one thicknessThe frames were 5, 8, 8 and 10px. Measured at 1440, the 218px mockup carried the 5px frame and the 190px one carried 10px, so the thickness ran inversely to the device: drift, not a ramp.--device-chrome—#1b1d22A shade darker and a few degrees cooler than the charcoal, which is what lets a shell read as a molded object beside charcoal type. The two are 1.10:1 apart, which is why three frames sat on the interface charcoal for months without anyone noticing.- The screen’s corner is derived, never authoredA frame reads as a constant-width ring only when the screen’s corner is concentric with the shell’s: inner radius = outer − bezel. Three of the four were a pixel out. Each device now declares its own
--shell-radiusand the screen derives its corner withcalc(), so the relationship cannot drift again.
- 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.
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:
- One focus ring, everywhereEvery link and button: a 2px accent outline, offset 3px, on
:focus-visibleso keyboard users get it and mouse clicks stay clean.
- Reduced motion is a contractEvery animation carries a
prefers-reduced-motionpath 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
.jsclass 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 is meant to sit at 4.5:1 or better on its ground, and a control’s boundary at 3:1. This is why sage exists in three steps rather than one: the text step manages 2.63:1 on charcoal where the lifted step reaches 7.47:1, and the fill step clears 3:1 everywhere but falls to 3.56:1 as small text on muted light. Every pair is measured live in Color. Two places did not meet it, both in the lightbox, and both are fixed — see Color. This said “text sits at 4.5:1 or better” as a flat claim until the color audit showed it was not true.
Writing
The copy is part of the system. These rules are already enforced across the site; new copy follows them.
- No em dashes, anywhereCommas, colons, and periods carry the rhythm. This holds in body copy, captions, commit messages, and alt text alike.
- Only confirmed numbersEvery figure on the site traces to a real, verified fact. No invented metrics, no unattributed testimonials, no “up to.” A section waits empty rather than shipping a plausible number.
- Sell the change, not the artifactCards and bullets lead with what the client gets. The deliverables themselves are itemized on the page behind the card, not on it.
- Two lines, then stopA list item that needs a third line needs a shorter item. Cut the sentence in half, then do it again.
- Say it onceNo heading restating its intro, no paragraph repeating its own figure’s caption. If it is said nearby, it is not said here.
- Headings speak, labels labelAnything with a voice sets in Crimson Text at regular weight. Anything that names or navigates sets in Montserrat. Emphasis comes from size or the accent, never both.
Editions
One source, one site. Until 2026-09-08 the same source built two, and what separated them was which one a search engine was allowed to see; the staging edition is archived, read-only, at the same fifteen pages this one carries.
- Live · adamhickey.comTwenty-eight pages, and the only edition there is. The public practice, its own repository, and since 2026-09-08 where the work happens. No
noindex, the analytics tag on every page, and every merged change visible to a stranger about a minute later. - Archive · adamdhickey-collab.github.io/adamhickey-nextThe staging edition until 2026-09-08, archived that day at the fifteen pages live had. It carried
noindexpermanently and still does: an archived tree keeps serving, and that line is what keeps a stale copy of this site out of an index.
- Withholding is a decision, not a build artifactDoor County Found, Lucy Learns and While We’re Here were held out of live for months. Nothing about the code decided that; a person did, and on 2026-09-05 the same person reversed it — which is the point. The reason given had been that they read as a side pursuit beside enterprise work; the reason against was that they are the longest writing on the site and sat where every crawler was told to ignore them.
- One edition means one gateWith no staging in front of it, a pull request’s checks are what stand between a change and a stranger. They run on the branch, so a red mark lands before the merge rather than after it, and publishing is still the hard step to undo.
- The archive is a record, not a sourceA page lifted from the archived tree carries staging’s
noindexand no analytics tag. Swap both before it merges, and read the live tree first: the archive stopped at its #169 and this one did not.
Changing it
Rules earned the hard way. Each of these exists because skipping it broke something real.
- Every asset change bumps its tokenStylesheets, scripts, images, the favicon, the clips: all carry
?v=. A returning visitor holds the old file otherwise, and the bug that follows is invisible to whoever shipped it, because their own browser fetched the new one. - There is no include stepThe header and footer are hand-written into every page. A change to either is a change to every file that carries it, in every edition. Grep, do not assume; and prefer shared JavaScript over pasted markup for anything with behavior.
- Measure, do not eyeballRead the number back out of the browser: computed styles, bounding boxes, contrast ratios, line counts. Twice this year a change looked right and was not, and both times the measurement caught it before it shipped.
- Retire, do not accumulateWhen two tokens differ only where a rule forced them apart, the rule is the thing to fix. Two colors left this palette that way, and both had been quietly failing contrast at small sizes.
- The reference is only true if it ships with the changeA specimen copied by hand goes stale the moment the site moves. Three tags on this page said four for a week. Update the section in the same pass as the code, or it becomes fiction.