← Context/ loops
Adopt the shared Button in one member — the per-member executor for the Button rollout
One member per run, never a sweep. Replace the buttons, delete the recipes they made redundant, raise everything else.
One member per run. Never a sweep. The contract is Component API Contract And The Control Scale; this is the executor. Where they disagree, the spec wins and the disagreement is a bug in this loop.
What you are doing
Replacing a member's hand-rolled buttons with @augment-it/shared-ui's Button,
and deleting the CSS that replacement made redundant.
The deletion is the point of the job. A member that adopts the component and keeps its recipes has added a dependency and removed nothing. If your diff has no red in it, you have not finished.
The rollout, in numbers
302 <button> elements across 18 members. Five members carry zero aria-*
attributes — org-workbench has 61 buttons and not one. Button is accessible by
construction: type="button" by default, real disabled rather than a class,
:focus-visible that will not double-paint against the federal rule, and
size="icon" refusing to render without an accessible name. Those five members
gain the most and will show the largest measurable delta.
The API you are adopting
<Button variant="primary" size="md">resolve →</Button>variant: primary · secondary · outline · ghost · destructive · link
(default secondary)
size: sm · md · lg · icon (default md)
Override rungs, in order of preference — never a raw value:
0 — layout is the parent's job, and is not a deviation
A Button never positions itself. If it needs to sit at the end of a row, align to a baseline, right-align in a grid column, or stop stretching in a column-flex container, that is the container's concern.
Two spellings, both free:
/* the parent owns alignment */
.my-row { display: flex; align-items: flex-end; }
/* or wrap it — a plain div, usually zero CSS */
<div class="my-row-end"><Button …>Not now</Button></div>This rung exists because the absence of it was measured. Five layout-only overrides appeared across three independent migrations —
margin-inline-start: auto,justify-self: end,align-self: center, and twice "a Button inside a column-flex container stretches to full width." Every one carried adata-deviationreading some variant of "the ladder has no rung for layout."Placement is not a design departure, and routing it through rung 4 puts it in the member's catalog under Deviations, where it buries the real ones. A deviation section listing five margin adjustments teaches a reader to skim it.
Why it happens: Button sets a height and never a width, so in a
flex-direction: column container it stretches. That is correct component
behaviour — a control that pinned its own width could not be used in a toolbar —
and the container is the only place that knows what the right answer is.
variant+sizeradius="lg"— a token NAMEradius="lg/60"—calc(var(--radius-lg) * 0.6)class=+data-deviation="reason"— legal, declared, and it shows up in the member's catalog under Deviations
Steps
Read the member's CSS first, all of it. You are about to delete from it.
Read the member's prefix out of
DESIGN.mdfrontmatter first. The directory name is not the prefix —response-reviewerisresp,request-reviewerisreq.--member <wrong>fails closed with Member not found, which is good, but only after you have wasted the run.Check the dependency.
@augment-it/shared-uishould be installed in every member — if it is already in yourpackage.json, skip this step and do not edit the file. If it is somehow missing, add —"@augment-it/shared-ui": "workspace:*"— then runpnpm install. Do not stagepnpm-lock.yaml: it is shared, every concurrent migration writes it, and the manager stages it once.Map each
<button>to a variant by what it does, not by how it looks. The accent-filled commit action isprimary; the quiet default issecondary; a destructive action isdestructiveeven if the member drew it grey.Preserving the member's current appearance is not a goal. If the new variant looks different from what the member drew, that is the migration working. An agent's default instinct is to keep the pixels, and keeping them is how a member that was drawn by eye manufactures false evidence for a variant nobody needs.
Replace, then delete. Every recipe the replacement orphaned comes out. Verify with a search that nothing still references the class.
Look past the button recipes. In the first migration the highest-value deletion was not a button rule at all — it was
.member select:focus, .member input:focus { outline: … }, made redundant by the federal focus ring and actively painting a second ring in a second colour over it. A local:focusrule at class specificity beats*:focus-visible. Check for one.Count the recipes yourself. A central sweep undercounts: the first member's plan said four, the real number was eight.
Run the gates (below), verify what you cannot see, and stop.
Gates
pnpm --filter @augment-it/<member> check must be clean
pnpm --filter @augment-it/<member> build a typecheck is not a build
pnpm design:drift --member <prefix> must not INCREASE
pnpm design:drift federation count must not increase⚠️ Do not trust a baseline written in this document. It has moved five times in one day — 99, 93, 56, 69, 72 — as checks were fixed and invisible members were registered. Measure it yourself at the top of your run and report before-and-after. The gate is relative: the number you measured must not go up. An absolute number here would have given one engineer 21 points of slack it did not know it had.
⚠️ The federation count is not attributable to you while other migrations are running. Several members migrate in parallel in one working tree, so
git statuswill show files that are not yours and the federation number reflects everyone. The member-scoped count is your attributable gate. Report both, and name exactly which files are yours.
The drift gate is blind to the actual deliverable
design:drift has no button check at all. A member can delete fourteen
rule-sets and seventy-seven lines and the gate will not move a single point.
Say that plainly to yourself before you start, because it has a sharp edge: an agent optimising for the gate would delete nothing. The gate proves you did no harm; only the diff proves you did the work. Report both, and treat a net diff that is not strongly negative as a sign you have not finished.
One browser, several agents
Under parallel migration the Playwright browser is shared, and other agents
will navigate your tab out from under you. This has happened: an evaluate ran
against the wrong member's DOM and returned another member's markup while the
tab title still said the right thing — a failure that produces confidently
wrong verification rather than an error.
Make navigate-and-assert atomic — and the MCP browser cannot do this.
browser_navigate and browser_evaluate are separate tool calls, so there is
always a window for another agent to move your tab. One engineer was navigated
away mid-evaluate and received a different member's DOM.
Launch your own headless chromium from a node script via Bash instead. Fully isolated, and navigate + assert + measure genuinely is one call.
Either way, assert on the member's root class inside the evaluate, never the tab title. A title can be right while the DOM is someone else's.
The alias that silently does nothing — read this before you build a probe
In rsbuild 2.x, source.alias is accepted and silently ignored. The key is
resolve.alias. Getting this wrong does not error. It bundles the real
@augment-it/workspace, which finds a live workspace-service on this machine and
renders real production data.
This is worse than every other trap in this file, because the failure mode is a
full, plausible page. One engineer's probe reported 866 buttons and record-set
names like investors-2026-07-01.csv 378 rows · 104 cols, from a four-row
fixture. Another rendered 1,982 real responses and caught it only because the
numbers were too round to have come from an eleven-record stub.
And on 2026-09-13 a probe in this state drove its own click-path through
content_ingest.preview_url → corpus.add and wrote a real file into live client
data at clients/reach-edu/corpus/…. It violated the standing rule that
browser-driven reads are unrestricted and writes go only to a designated safe
target — not because the engineer ignored the rule, but because nothing told it
it was talking to production.
Three defences, all mandatory, because any one of them can be got wrong:
resolve.alias, neversource.alias.Assert the stub is actually in the bundle before trusting a single number: put a sentinel string in the stub and
grep -c '<sentinel>' dist-probe/static/js/*.js. Zero hits means you are looking at production.Kill the network in
addInitScript, belt and braces:JSawait page.addInitScript(() => { window.WebSocket = class { constructor() { throw new Error('probe: no sockets'); } }; window.fetch = () => Promise.reject(new Error('probe: no fetch')); });
A probe that renders an empty surface wastes your time. A probe that renders production can write to it.
Building the probe — the parts that are not guessable
Five engineers hit the same five walls. None of these produce an error; every one produces a confidently wrong pass.
Where the probe goes: apps/<member>/probe/. An earlier version of this
playbook said "outside apps/", which is exactly backwards and self-defeating —
a probe outside apps/ cannot resolve @augment-it/* at all. Verified in
scripts/design-drift.mjs: findMemberFiles() walks only
apps/<member>/src, and the S5 unregistered-member sweep enumerates only
direct children of apps/. A sibling of src/ is therefore invisible to every
F-check and to S5, while inheriting the member's own node_modules — rsbuild,
plugin-svelte, @augment-it/theme, @augment-it/shared-ui — for free.
Corollary with teeth: the fixture stub must not live in src/. That
directory is scanned. Put it in probe/ with everything else.
Delete the whole directory before the final gate run regardless.
There is no Playwright in this repo. Not in apps/, packages/, e2e/, or a
root node_modules, and adding one would touch package.json in a way nothing
here needs. It resolves from the MCP server's npx cache.
Do not find for the binary and pick what turns up — two engineers did and
both got a stale revision. Several playwright-core copies coexist in that cache at
different versions, and the newest one may pin a Chromium revision that is not
installed at all. Match them explicitly:
find ~/.npm/_npx -maxdepth 4 -type d -name playwright-core # candidates
# then, per candidate, read the revision it actually requires:
cat <candidate>/browsers.json | grep -A2 '"name": "chromium'
find ~/Library/Caches/ms-playwright -maxdepth 1 -type d # what is installedPick the playwright-core whose pinned revision exists on disk. One engineer's "newest wins" heuristic selected a 1.63.0-alpha pinning revision 1243 with nothing matching; 1.61.1 → 1228 was the working pair.
The binary's path and name both vary by age. Older revisions:
chromium_headless_shell-<rev>/chrome-mac/headless_shell. Newer:
chromium_headless_shell-<rev>/chrome-headless-shell-mac-arm64/chrome-headless-shell.
Look for both names under the revision you matched.
require(), not import(). await import(PW + '/index.js') resolves to an
object with no .chromium on it, and fails as "Cannot read properties of
undefined" several lines later.
The probe's rsbuild config must set root: __dirname. rsbuild defaults root
to the nearest package.json directory — so from apps/<member>/probe/, passing
-c rsbuild.config.ts silently loads the member's own config and boots the
member on the member's port. One engineer's first run reported "built in 0.41s"
on :3014 and looked entirely successful. Pass both flags absolute, and use the
member's own bin shim rather than npx:
cd apps/<member>/probe && P=$(pwd)
../node_modules/.bin/rsbuild build -r "$P" -c "$P/rsbuild.config.ts"The stub must be reactive, or you will verify an empty surface. A plain-object
stub never re-runs the member's $derived, so the page renders with zero rows and
every data-driven button in its empty state — and reports a clean pass. Name it
stub-workspace.svelte.ts and back its fields with $state. One engineer's first
run reported "all 0 / Promote 0 records" and looked entirely successful.
Read the member's predicates and dialog calls before shaping the fixture.
Aliasing the workspace is necessary and not sufficient. One member's auto-select
only fires on a parent with more than ten columns, so a three-column fixture
leaves the table empty forever. window.confirm blocks headless outright — stub
it via addInitScript or the whole post-confirm branch is unreachable.
theme.css transitions body *, so computed style races. A disabled-state
read taken mid-transition returns intermediate values and looks exactly like the
component failing to apply its rule. One engineer got as far as a CDP
getMatchedStylesForNode dump before realising the rule matched fine and the
clock was wrong. Settle ~1.2s before reading computed style.
getComputedStyle returns a LIVE object. Focus A, hold the reference, focus
B, read — and you get A's values after A lost focus. Snapshot inside the same
evaluate, per element, with JSON.parse(JSON.stringify(...)) before moving
focus. This one reads as a clean pass, which is why it is on the list.
:focus-visible does not match a programmatic .focus(). A focus probe driven
by el.focus() reports boxShadow: none on every control — indistinguishable
from the federal ring being broken. Drive focus with real Tab keypresses.
A template-literal page.evaluate string eats backslashes. /\s+/ becomes
/s+/ at runtime, which silently truncates class names mid-word
(ow-list-actions → ow-li) and produces plausible-but-wrong attribution rather
than an error. Pass a real function, or double the backslash.
Do not dedup call sites by shape alone. Keying on class + variant + size + text
collapses genuinely distinct call sites — two different × closes in two
components are both secondary|lg|×. Include an ancestor-class path in the key,
or your stated coverage number is lower than what you actually verified.
Measure the before, don't compute it
The loop asks for a numeric delta and until now gave no way to obtain the first half of it. Arithmetic on deleted CSS is not a measurement.
git archive HEAD apps/<member>/src | tar -x -C probe/before --strip-components=3Then add a second rsbuild entry pointed at probe/before/App.svelte. Same
browser, same viewport, same theme, same fixture — the delta becomes a diff of two
evaluates. This is what caught a margin-right on a bare button selector that
was silently widening every control in one member; no amount of reading the diff
would have surfaced it.
The same move is what turns a suspected double focus ring into a proven one.
git show HEAD:<path> the old CSS, rebuild, read outline and boxShadow at
focus. It also exposed three rules that looked like focus handling and had never
painted anything in their lives — --focus-ring holds a box-shadow value, so
outline: var(--focus-ring, …) is invalid at computed-value time and dropped, and
because the property is defined the fallback after the comma never applies
either.
Capture both gate numbers at the top of the run
Member-scoped and federation, before touching anything. Under parallel migration the federation number moves for reasons that are not yours, and an engineer who only captured the member number ends up reasoning backwards to prove a 72 wasn't theirs.
The a11y delta is usually not aria
This loop's headline number — "five members carry zero aria-*" — has now
mis-aimed two disciplined members in a row. One went 9 → 9 and that was correct:
nothing it migrated was a toggle or a disclosure, and every icon-only control
already had a name. Its real delta was two live WCAG 2.2 SC 2.5.8 target-size
failures and a control boundary at 1.26:1 against a 3:1 floor.
Lead with target size and boundary contrast. Check aria second. The recurring findings across nine migrations, in order of how often they turn out to be real:
Controls under the 24px target floor — found in six of nine members.
Boundaries drawn with
--color-border(≈1.26:1) instead of--color-border-strong(≈3.44:1).opacity: 0.xstanding in for a disabled state — appearance without state.Icon-only controls whose accessible name is the glyph itself.
Missing
type="button", defaulting tosubmit.
When a member hosts other members
docs-portal mounts other members' galleries into its own document, and carries
bare section / h2 / code selectors plus generic .cell / .chip / .grid
classes. A member can be entirely inside its own boundary and still style its
neighbours. If yours is a host, raise it — do not prefix it. A containment
pass is its own piece of work, not a line in a button diff.
Verify what you cannot see
Most members hide most of their buttons behind {#if}. In the first migration
six of nine never rendered without NATS and seven Docker services running.
An agent told to "run the gates and stop" will report success having seen three of nine. State your visual coverage explicitly — how many buttons you actually looked at, and how.
Where a button will not render, the probe technique that works: a throwaway
rsbuild app aliased at the member's real sources, importing the member's own
app.css, rendering the components directly with fixture props. Delete it
afterwards.
Judgement calls that are yours
Are the member's chips Buttons? The first migration answered yes, and the reasoning generalises: a chip is a button with a variant, a size, a focus ring and a disabled state. What makes a chip row a separate organ is the group behaviour — single-selection, roving tabindex, arrow keys — which lives in the row. So
FilterChipRowcomposes Buttons rather than replacing them.Use
aria-pressed, notrole="tab". Correct tab semantics needsaria-controls,role="tabpanel"and arrow-key handling; half-implemented tabs are worse for a screen reader than honest toggle buttons. Full radiogroup/tab semantics is aFilterChipRowjob, not yours.Does anything need an override? Use rung 2 or 3 if so, and say so in your report. A recurring override is evidence for a missing variant, which is a finding rather than a failure.
Can a rung-4 override even reach it? Mechanical test, check it before you start: a member's global class lands at
(0,1,0); anythingButtondeclares inside its own scoped<style>lands at(0,2,0)after Svelte hashing. If the property you need to change is oneButtonsets, rung 4 viaclass=cannot win (usestyle=) — and the next move is!important, which is how a component becomes decorative. A control that needs to fight the component is a missing organ, not a deviation. Say so and move on.Count the properties you would have to override. Past roughly four, you are re-drawing the control.
chat's.command-rowneeded eight —display,height,width,text-align,white-space,padding,border-radius,border— at which pointButtoncontributes onlytype="button", which the element already had.Check the neighbours before committing to
ghostorlink. A role-correct variant can still destroy the member's only interactivity cue: one migration mapped a dismissal toghostand rendered it indistinguishable from a non-interactive muted span doing the same job one section below. Correct by role, a regression in fact.Is it actually a row? This is now the most common holdout in the rollout and the single most-cited missing organ. Four members have produced the same shape: a full-bleed, left-aligned, wrapping, variable-height list row or card that someone made into a
<button>— measured at 39px, 56px, 89px and 93px against a 32px control height.Buttonisinline-flex,justify-content: center,white-space: nowrap, fixed height. Adopting one takes rung-4 overrides for height,justify-content,text-align,white-spaceandflexsimultaneously — every geometric property the component contributes, leaving behind only a focus ring the federal*:focus-visiblerule already provides. Leave it raw, put the rationale in the CSS, and raise the organ. The general form of the rule: if the override would negate the base recipe rather than adjust it, it is a different organ.Is a button actually a link?
variant="link"exists; an<a>styled as a button is a different fix and may be out of scope.
Rung 0 is not free, and it has its own regression mode. Three self-inflicted bugs in one run came from layout fixes, and no gate caught any of them — only the before/after probe did.
Making a popover
display: flex; flex-direction: columnto stop Buttons sitting inline crushed every item from 26.8px to 17px, under the very 24px floor the migration exists to fix, because a height-capped column-flex container shrinks its items. Addflex: 0 0 autoto the items..menu > li { display: flex }sized rows to content — 179px inside a 210px menu.display: gridwas needed.A three-column label grid overflowed a control once the control gained a fixed height.
A rung-0 wrapper may carry CSS. The examples elsewhere say "a plain div,
usually zero CSS", which reads as a rule. It is not: a wrapper that has to carry
margin-inline-start: auto and align-self: center, or a new member class like
.link-remove-slot, is still rung 0 and still not a deviation. Do not route it to
rung 4 because it needed a declaration.
A selector that out-specifies the component's base recipe must be REMOVED, not
raised — whether or not it still matches anything. The delete-what-you-made-dead
rule is binary and misses this case. .resp-app button at (0,1,1) beats
.ui-btn at (0,1,0), so its border: 0 erases the component's border box
entirely and its border-radius makes rungs 2 and 3 dead on arrival in that
member. The rule still matches every Button — it is not dead — and leaving it
makes the component decorative.
Raise, don't chase
You will find things that are wrong and not your job — dead CSS, an aria-* that
lies, a swallowed error, a token used against its documented role. Report them.
Do not fix them.
Rogue debugging costs three things at once: the commit stops being attributable, the gate stops meaning what it said, and the finding stops being reviewable because it arrives already fixed.
The line: if removing it is part of the migration, remove it. If fixing it is a separate act of repair, raise it and move on.
For each finding give: what (one sentence) · where (path:line) · how
found · blast radius (this member, or federation-wide?) · confidence
(measured, or suspected?).
Hard rules
Touch only your member, plus its
package.json. Notpackages/, notshell/, not another member, notapps/docs-portal/src/members.ts.Do not commit. Author, run the gates, report. The manager commits — one member per commit, so the history stays bisectable across eighteen migrations.
Never a raw value in an override. A literal is how the 170 phantom declarations happened.
Pick tokens by role, not appearance. A button is
--radius-mdbecause DESIGN.md §Shapes says buttons are, regardless of what looks right to you. This is not hypothetical —chathas eleven declarations one scale step off because someone picked by eye.
Related
Component API Contract And The Control Scale — the contract
Converge The Federated Design System — the governance loop this serves
Prove The Component API On Request Reviewer — the proof case this generalises
packages/shared-ui/src/Button.svelte— the component, read its header