Governance completed· verified
I locked one component family end to end: raw form controls moved from 296 to 208, with six explicit exception roles so drift cannot return without a decision.
Etienne AmionHead of Design Engineering
hi@etie.ukProduct design after AI
Product design no longer has to end at a Figma frame. AI makes it possible to design working, testable software — while encoding the system, best practice and evidence gates the work must pass before production.
One full feature delivered one-shot through the generated handoff pack
Watch this page’s own component move through the model. The route advances automatically; it stops only where current evidence or a human decision is required.
Advance request · P2 → P3
this page’s own component · product agent
Gate refused · smoke stale
Evidence ledger
Source state
commit 8c27f1 · current
Cinematic recording
flow-03.webm · available
Smoke assertions
12 scenarios · prior source
Human decision
not yet recorded
02
diagnosis
The failure mode
Output that looks ready to build, but was never validated — produced because an AI expanded past its brief into the next role’s territory.
Ask an AI to capture a client need and it starts designing the interface. Ask it to design the interface and it starts writing the schema. The documents look complete, but contain decisions nobody on the team actually made.
The contradiction is misplaced confidence: more output, more production issues, and less certainty about which decisions were actually tested. Untested Detail names the cause behind the measured symptoms.
Boundary ledger
03
claim one
The practice
Before this fixes a team, it fixed how I work. Agentic design is not a faster way to make the old artifact — it makes a different artifact, one that can be tested instead of presented.
In Figma I designed a picture of the software and argued it would work. Now I design the software and find out.
This alone is adoptable tomorrow, without changing how anything ships. It produces better design, not just faster design.
The design system is extracted from the components that ship, and the rules the work has to pass are machine-readable and load themselves. Nobody has to remember best practice for it to be applied, so drift takes a decision rather than an off day — and the standard does not leave when a person does.
Empty, error, loading, and green-but-stale. The responsive matrix, accessible names, live regions. The work a human most often misses is exactly the work a Figma frame is not obliged to show.
The customer need and the user journeys are re-consulted at every stage, not read once at kickoff and slowly forgotten. This is the individual-scale antidote to Untested Detail.
A recording shows a flow working once. A smoke report asserts it still works against the current build. The design is tested against real interaction, at a fidelity I could not previously have justified building.
04
roles
The doctrine
Signal owns the truth about the need. Engineering owns the truth about production. Product connects them by turning intent into a tested, inspectable artifact both sides can trust.
Product holds the work together. It does not own every decision.
I made that relationship operational: three role owners, each with an agent that accelerates their expertise without silently taking decisions from the others.
05
20 stages
The machine
The workflow removes repeated interpretation between research, design and engineering. Twenty stages add verified context to the same artifact; two gates protect the moments where automation cannot replace evidence or human judgment.
P1
Signal → Product
P2
Product
GATE 01 · P2 · stage 04c
see the evidence gate running above ↑Cinematic recordings staying green is not evidence — they don’t assert. The smoke report is.
P2 cannot advance while any in-scope scenario is red or stale. A recording can help critique the experience; only the smoke report asserts that the flow works.
P3
Product + reviewers
GATE 02 · P3 · stage 08
No recorded decision, no handoff.
The engineering handoff will not run until product signoff is recorded. Generated detail cannot quietly become an implementation decision.
P4
Product → Engineering
RETURN · P4 · stage 16
loops to 01The smoke suite written at the assertion gate travels with the pack. The same scenarios are built to run against production.
Best-practice checks authored when the artifact is first built are the assertions that later guard the release. Stage 16 is not a new kind of evidence — it is those same scenarios pointed at the live system, with what they find looping back to Signal intake.
06
one artifact
The implementation principle
The practice above produces a testable artifact. This is the next claim: that same artifact is the one that ships. Scrub backwards through its history — the production build is the concept commit with its edges finished, not a reconstruction started from a spec. There is no handoff where the design is re-derived and quietly changed.
Structure and real data. No polish.
Behaviour arrives. Nothing is mocked.
Type, spacing, colour, motion. No behaviour.
The states nobody demos — and the gate stops waving one through.
One check is green and stale. Green and stale is not evidence.
import { useId, useState } from 'react' // @phase concept — the markup, and the data it stands on.//// One component. One render tree. Everything that changes between phases is a// lookup keyed by the `phase` prop: classes from STYLE, aria wiring from A11Y,// gate behaviour from GATE. The tree below is never rebranched, so the node the// commit graph mounts is the node it still holds four phases later.//// The checks are this page's real quality floor, not sample rows — each one is a// rule DESIGN.md holds this build to. `keys` ships green and stale on purpose:// §9 requires the blocked-on-stale state to be reachable rather than asserted. const CHECKS = [ { id: 'no-js', label: 'renders without JS', passing: true, stale: false }, { id: 'motion', label: 'reduced motion respected', passing: true, stale: false }, { id: 'keys', label: 'keyboard operable', passing: true, stale: true }, { id: 'focus', label: 'focus visible on every control', passing: true, stale: false }, { id: 'contrast', label: 'contrast floor AA', passing: true, stale: false }, { id: 'reveal', label: 'no scroll-gated content', passing: true, stale: false },] export default function ArtifactCard({ phase = 'dev' }) { const uid = useId() const S = STYLE[phase] const A = A11Y[phase] const live = phase !== 'concept' const { checks, toggle, rerun, empty, setEmpty } = useChecks() const gate = GATE[phase](checks) const staleNote = checks.some((c) => A.showStale(c)) return ( <div className={S.root}> <div className={S.head}> <div className={S.heading}> <span className={S.title} id={`${uid}-title`}> Quality floor </span> <span className={S.meta}>this page</span> </div> <span className={S.count}>{A.summary(checks)}</span> </div> <ul className={S.list} {...A.list} aria-labelledby={`${uid}-title`}> {checks.map((check) => { const chip = A.chip(check) return ( <li key={check.id} className={S.item}> <input id={`${uid}-${check.id}`} className={S.box} type="checkbox" checked={check.passing} disabled={!live} onChange={live ? () => toggle(check.id) : undefined} {...A.box(check, uid)} /> <label className={S.label} htmlFor={`${uid}-${check.id}`}> {check.label} </label> {chip && ( <span aria-hidden="true" className={`${S.chip} ${S.chipTone[chip.tone]}`}> {chip.label} </span> )} {A.showStale(check) && ( <button className={S.rerun} type="button" onClick={() => rerun(check.id)}> re-run </button> )} </li> ) })} </ul> {checks.length === 0 && <p className={S.note}>No checks defined. Nothing to gate on.</p>} {staleNote && ( <p className={S.note} id={`${uid}-stale`}> One check is green and stale. Green and stale is not evidence. </p> )} <div className={`${S.gate} ${S.gateTone[gate.tone]}`} {...A.gate}> {gate.label} </div> {A.canEmpty && ( <button className={S.demo} type="button" onClick={() => setEmpty(!empty)}> {empty ? 'restore checks' : 'show empty state'} </button> )} </div> )} // @phase prototype — real state, real behaviour. Nothing here is a mock. function useChecks() { const [passing, setPassing] = useState(() => CHECKS.map((c) => c.passing)) const [stale, setStale] = useState(() => CHECKS.map((c) => c.stale)) const [empty, setEmpty] = useState(false) const checks = empty ? [] : CHECKS.map((c, i) => ({ ...c, passing: passing[i], stale: stale[i] })) const at = (id) => CHECKS.findIndex((c) => c.id === id) const swap = (setter, i, value) => setter((prev) => prev.map((v, j) => (j === i ? value : v))) return { checks, empty, setEmpty, toggle: (id) => swap(setPassing, at(id), !passing[at(id)]), rerun: (id) => swap(setStale, at(id), false), }} // Gate behaviour is phase data too. Prototype and design read `passing` and// nothing else — which is the bug: a check can be green and stale at once, and// both phases wave it through. The dev phase is where staleness starts to// count, and it is why the rule has to be worded "red or stale". const countRed = (checks) => checks.filter((c) => !c.passing).lengthconst plural = (n) => (n === 1 ? '' : 's')const blocked = (n, kind) => ({ tone: 'blocked', label: `blocked · ${n} check${plural(n)} ${kind}`,})const ready = { tone: 'ready', label: 'ready for engineering' }const onlyRed = (checks) => countRed(checks) ? blocked(countRed(checks), 'red') : ready const GATE = { concept: () => ({ tone: 'inert', label: 'gate' }), prototype: onlyRed, design: onlyRed, dev: (checks) => { if (checks.length === 0) return { tone: 'inert', label: 'gate not armed' } if (countRed(checks)) return blocked(countRed(checks), 'red') const stale = checks.filter((c) => c.stale).length return stale ? blocked(stale, 'stale') : ready },} // @phase design — type, spacing, colour, motion. Not one line of behaviour.//// This is the phase that has to carry the visual argument, because the model// says so: design is where type, spacing and colour arrive, and dev may not// change them. So the stale and failing treatments are authored here, at full// strength, even though nothing reaches them until the dev phase turns the// states on. Designing a state you cannot yet see is the whole point.//// Sized against its own container rather than the viewport: this component// renders wide in the scrubber and roughly a quarter as wide in the static// four-up, and a finished component is expected to survive both. const REFINED = { root: '@container flex h-full w-full flex-col gap-4 rounded-sm border border-line bg-panel p-5 @sm:gap-5 @sm:p-6', head: 'flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-line pb-3', heading: 'flex flex-wrap items-baseline gap-x-3 gap-y-0.5', title: 'font-display text-lead leading-tight text-ink @sm:text-h3', meta: 'font-mono text-mono-2xs uppercase text-muted', count: 'font-mono text-mono-2xs tabular-nums uppercase text-muted', list: 'flex flex-col', item: 'flex items-center gap-3 border-b border-line/60 py-2 last:border-b-0 @sm:py-2.5', box: 'size-4 shrink-0 accent-accent', label: 'min-w-0 font-mono text-mono-2xs text-ink @sm:text-mono-xs', chip: 'ml-auto shrink-0 rounded-xs px-1.5 py-0.5 font-mono text-mono-2xs uppercase', chipTone: { ok: 'text-muted', stale: 'bg-warning/12 text-warning', red: 'bg-warning text-paper', }, rerun: 'shrink-0 rounded-xs border border-muted px-2 py-0.5 font-mono text-mono-2xs uppercase text-muted transition-colors hover:border-ink hover:text-ink', note: 'font-mono text-mono-2xs leading-relaxed text-warning', gate: 'mt-auto rounded-xs px-3 py-2.5 font-mono text-mono-xs transition-colors duration-200', gateTone: { inert: 'bg-shade text-muted', blocked: 'bg-warning text-paper', ready: 'bg-accent text-panel', }, demo: 'self-start font-mono text-mono-2xs uppercase text-muted underline decoration-1 underline-offset-2 hover:text-ink',} const STYLE = { // Greybox. Real markup with the polish subtracted, not a picture of a card. concept: { root: 'flex h-full w-full flex-col gap-2 border border-dashed border-muted p-2', head: 'flex gap-2 border-b border-dashed border-muted pb-1', heading: 'flex gap-2', title: 'text-sm text-muted', meta: 'text-sm text-muted', count: 'text-sm text-muted', list: 'flex flex-col gap-1', item: 'flex items-center gap-2', box: 'size-4 shrink-0', label: 'text-sm text-muted', chip: 'text-sm text-muted', chipTone: { ok: '', stale: '', red: '' }, rerun: 'text-sm text-muted', note: 'text-sm text-muted', gate: 'mt-auto border border-dashed border-muted px-2 py-1 text-sm text-muted', gateTone: { inert: '', blocked: '', ready: '' }, demo: 'text-sm text-muted', }, // Works. Ugly. No hierarchy, no rhythm, browser defaults left alone. prototype: { root: 'flex h-full w-full flex-col gap-2 border border-muted p-2', head: 'flex gap-2', heading: 'flex gap-2', title: 'text-sm font-semibold text-ink', meta: 'text-sm text-muted', count: 'text-sm text-muted', list: 'flex flex-col gap-1', item: 'flex items-center gap-2', box: 'size-4 shrink-0', label: 'text-sm text-ink', chip: 'text-sm text-ink', chipTone: { ok: '', stale: '', red: '' }, rerun: 'border border-muted px-1 text-sm text-ink', note: 'text-sm text-ink', gate: 'mt-auto border border-muted px-2 py-1 text-sm', gateTone: { inert: 'text-muted', blocked: 'text-ink', ready: 'text-ink' }, demo: 'self-start text-sm text-ink underline', }, design: REFINED, dev: REFINED,} // @phase dev — accessible, responsive, and honest about the states it reaches.//// Not one class changes here. What changes is which states the component is// willing to show you: the per-row verdict, the stale badge and its re-run, the// live region on the gate, and an empty state you can actually reach. The// design phase drew all of it; this is the phase that stops hiding it. const QUIET = { list: {}, box: () => ({}), gate: {}, chip: () => null, summary: () => '', showStale: () => false, canEmpty: false,} const A11Y = { concept: QUIET, prototype: QUIET, design: QUIET, dev: { list: { role: 'group' }, box: (check, uid) => ({ 'aria-describedby': check.stale && check.passing ? `${uid}-stale` : undefined, }), gate: { role: 'status', 'aria-live': 'polite' }, // The verdict per row, in the same words the gate uses. A passing check that // is out of date reads `stale`, not `pass` — the distinction the earlier // phases cannot make. chip: (check) => { if (!check.passing) return { tone: 'red', label: 'red' } return check.stale ? { tone: 'stale', label: 'stale' } : { tone: 'ok', label: 'pass' } }, summary: (checks) => { if (checks.length === 0) return 'no checks' const red = checks.filter((c) => !c.passing).length const stale = checks.filter((c) => c.passing && c.stale).length const parts = [`${checks.length} checks`] if (red) parts.push(`${red} red`) if (stale) parts.push(`${stale} stale`) return parts.join(' · ') }, showStale: (check) => check.stale && check.passing, canEmpty: true, },}All four phases, with the complete source. This is what renders with JavaScript off, and what you get if you have asked your system not to animate things.
07
accepted
Evidence
The doctrine was adopted as the operating manifesto across Signal, Product and Engineering at a climate-tech product organisation.
“I’m not going upstream to see what’s going on. The README files you produce are the source of truth.”
Engineering accepted the generated pack as authoritative input, and a full feature was delivered one-shot through it. The system reduced reinterpretation without lowering the standard of evidence.
Judgment hands engineering a sealed evidence pack — signed, not re-derived.
Governance completed· verified
I locked one component family end to end: raw form controls moved from 296 to 208, with six explicit exception roles so drift cannot return without a decision.
Conflict reconciled· verified
I reconciled three conflicting brand blues into one token set that clears WCAG AA.
Finding, not outcome· observed
An instrumented census found 1,070 hand-rolled buttons against 30 uses of the approved component. The audit measures the problem; the lock is what removes it.
08
open
Partnership
For organisations generating faster than they can validate — and ready to make Product the connective layer between customer signal, excellent interface design and production engineering.