Lab · Autopsy

I spent the evening designing, anatomising, and engineering a button.

This is not a tutorial for shipping a component library. I already wrote that kind of note elsewhere. This is the postmortem of one evening spent at the seam between design and engineering: what I was trying to learn, how I got to a credible specimen quickly, what accessibility actually forced me to confront, and what I would bring to an engineer the next morning.

Specimen

Press the button · flip theme · open a panel

Your hover color shouldn’t exist. Every interaction state is derived from one semantic token via color-mix(in oklch, …). Mixer is white in dark mode.

Resolved values

Rest--btn-bg
Hover · 8%--btn-bg-hover
Active · 16%--btn-bg-active

Source

--btn-bg: var(--color-action);
--btn-bg-hover: color-mix(in oklch, var(--color-action), var(--state-mixer) 8%);
--btn-bg-active: color-mix(in oklch, var(--color-action), var(--state-mixer) 16%);
--state-mixer: white; /* flips with theme */

Three tiers

Raw
Ramp only: --ax-blue-600, --ax-gray-*. No UI reads these directly.
Semantic
Theme meaning: --color-action, --color-surface. Only this tier changes between light and dark.
Component
Button contract: --btn-bg*. Never knows what hue it is.

Why I made this

Most of my “design in code” work still ends in a convincing screen. That is useful. It is also incomplete. A Figma frame, or even a React prototype that looks right on my laptop, can hide the entire job of a production control: themes, keyboard focus, loading without layout shift, disabled states that still explain themselves, motion that respects the OS.

I wanted a small object that forced that gap into the open. A button is almost insultingly small. That is the point. If the design-to-engineering bridge fails on something this ordinary, it will fail louder on a booking flow or a design system.

I also wanted a different kind of artifact from my GOV.UK / Figma-to-library notes. Those are how-to paths: connect a file, generate components, open a PR. This page is a learning bench. The specimen is evidence. The writing is what I would tell another designer-engineer after the build cooled down.

The what

One primary button. Six states: rest, hover, focus-visible, active, disabled, loading. Light and dark themes. Interaction colors derived in OKLCH, not hand-picked. A press spring sampled into CSS linear() with no animation library. An inspector that x-rays tokens, the state matrix, the curve, and live contrast.

Everything lives in this portfolio codebase as a lab route. No separate Storybook deploy. No fake Figma plugin. The documentation cells use the same stylesheet as the live control, forced with [data-force], so the gallery cannot quietly become a lie.

The why (the learning goal)

I was not chasing a prettier CTA. I was testing whether I could hold design intent and engineering constraints in the same sitting without dumping either on someone else.

Design intent here meant: brandable action color, a press that feels physical, themes that do not need a second design pass, disabled that still teaches. Engineering constraints meant: compositor-safe motion, AA contrast after theme flips, focus rings that are not hover, loading that does not thrash layout, names and states a screen reader can announce.

The bridge is the habit of treating those as one problem. Not “design first, then annotate for eng.” Not “prototype in whatever CSS works for the demo.” One contract, expressed in tokens and behavior, that either side can argue with.

Prototyping across different codebases

Speed did not come from writing everything from first principles. It came from treating other codebases as reference libraries and this lab as the assembly floor.

What I borrowed, and what I refused

I kept patterns in my head from component libraries I have lived in: React Aria style naming and focus behavior, Untitled UI style size and state vocab, government design system habits around focus rings and hit targets. I did not vendor those packages into this page. I re-expressed the lessons in a thin CSS custom-property system owned by this route.

That distinction matters. Copy-pasting a Button from a design system teaches you their API. Rebuilding the responsibilities of a Button teaches you which responsibilities you were skipping in prototypes.

How I moved quickly

I worked in layers that could fail independently:

  • Tokens first (raw → semantic → component), so theme flips were a data change, not a redesign.
  • One stylesheet for the specimen and the matrix, so docs could not drift.
  • Motion as a sampled spring string, not a new dependency, so the portfolio build stayed boring.
  • Accessibility metering as a small canvas helper, so Chrome returning lab() did not nuke the panel.

Agent-assisted coding helped with scaffolding and refactors. It did not decide the contract. The speed came from a narrow brief (one button), reusable mental models from other stacks, and refusing to open a second repo for a one-evening learning object.

Lesson

Cross-codebase fluency is less about memorising APIs and more about recognising which layer a problem belongs to. If you know where tokens, states, motion, and a11y live in three different systems, you can stand up a credible fourth system in an evening without pretending it is production-ready.

Where design and engineering actually meet

The useful collisions were not aesthetic. They were definitional.

Hover is not focus. Design files often show a richer fill on hover and call it done. Engineering needs a separate keyboard ring that mouse users never see. That is not polish. That is two audiences.

Disabled is still a conversation. Greying out and killing pointer events feels tidy in a prototype. In product, someone still asks why. Keeping pointer events on so a tooltip can fire is an engineering choice driven by a design need.

Theme is a system, not a second artboard. A --state-mixer that flips black and white means hover derivation works in both themes. That is design systems thinking expressed as one custom property.

Motion has a budget and a policy. Springs feel better than durations, but only if you stay on transform and opacity, and only if reduced motion gets a real alternative. Otherwise you shipped decoration.

Those meetings usually happen as Slack threads after handoff. Doing them inside the prototype is the bridge.

How I tested accessibility

I did not run a full audit suite on this lab page. I ran the checks that match the learning goal: can this control defend its own accessibility claims while I change themes and states?

What I used

  • Live contrast metering in the A11y panel: resolve CSS variables to computed colors, convert through a canvas so lab() / oklch() still become sRGB, then apply WCAG 2.2 contrast math. Flip theme and watch the numbers move.
  • Keyboard only pass: Tab to the specimen, confirm :focus-visible ring, Enter / Space to activate, no reliance on hover to understand state.
  • Reduced motion: OS preference plus the in-page toggle. Spinner should stop orbiting and pulse opacity instead. Press should stop scaling.
  • Screen reader spot check: loading exposes aria-busy; disabled keeps a name and a reason path via description / title.
  • Hit target check: 44px minimum height in CSS, not by eye.

What failed, usefully

Dark theme almost shipped with a dark label on blue at about 3.9:1. That is under AA for normal text. The first meter build also reported 0:1 everywhere because it only parsed rgb() while Chrome was returning lab(). Both failures were the point: accessibility that cannot survive theme flips or browser color formats is theatre.

What I deliberately left for later

Automated axe / Lighthouse CI, VoiceOver / NVDA scripted passes, forced-colors and high-contrast themes, and form association in a real app shell. Those are engineer-grade hardening. The specimen proves the contract is testable.

Lessons I am keeping

Small objects teach honest systems. A button forces state completeness. A landing page prototype lets you hide it.

Derive before you decorate. Hand-picked hovers feel crafted and age badly. Derived mixes feel slightly less precious and survive brand change.

Docs must share the implementation bloodstream. If the state gallery is a second stylesheet, it will lie the week after you ship.

Measure the pixels you claim. Token names are not contrast. Computed color is.

Speed is a narrowing of scope plus reuse of mental models. Different codebases are tutors. Your lab route is the exam.

What next with an engineer

If this were heading into a real product, I would not throw the lab CSS over the wall and call it a component. I would walk an engineer through the contract first.

  1. Agree the public API. Props for tone, size, loading, disabled reason, icon slots. Map lab states to their design system primitives (or React Aria / Radix equivalents) instead of forking behavior.
  2. Port tokens into the source of truth. Raw → semantic → component should land in the shared theme package, not in a page CSS file. Keep the derivation math. Drop the demo-only shell colors.
  3. Replace the demo meter with CI. Keep the idea (contrast from rendered styles). Wire axe, theme matrix screenshots, and keyboard tests into PR checks so dark-mode regressions cannot rely on me flipping a chip.
  4. Validate motion in the product motion policy. Confirm springs are allowed, confirm reduced-motion alternatives, confirm we are not animating layout.
  5. Pair on the disabled / loading edge cases. Tooltip strategy, announce loading completion, prevent double submit, match existing form patterns. This is where labs end and product begins.
  6. Delete the specimen when the system owns it. Or keep it as a teaching route, clearly marked as non-source. Learning objects that pretend to be production become debt.

The handoff artifact is not a ZIP of CSS. It is a shared understanding of what must remain true when the button moves into their codebase.

Closing

I made this to practice the middle of the work: the place where a designer’s “this should feel pressed” and an engineer’s “this must be interruptible, themeable, and measurable” become the same sentence.

Use the specimen above as a bench. Break the theme. Force loading. Watch the contrast numbers. Then ask what your own prototypes silently refuse to learn.

Lab specimen and learning note. One button, one stylesheet, no animation libraries. Different purpose from the Figma-to-library tutorials: those teach a pipeline. This teaches a bridge.