01. The starting point
The design system is mature: a documentation site of about a hundred pages, a Figma library with foundations, components, widgets and templates, and an Apache-licensed React component library with 85 component folders, built on MUI, Emotion and a handful of React-only helpers. Its next major version dropped the theme we use. There is no Angular package and no Web Components build.
Our product is a large Angular application. Before this work it rendered Angular Material components, a PrimeNG-based table library, Bootstrap buttons and about 1,600 hard-coded hex values in component styles, with no dark mode. Every screen looked a little like the design system and none of them looked exactly like it.
The scope we agreed: port the React library's v6 line, pinned to one commit, into a new Angular library inside our Nx workspace. Charts, the code editor, experimental components and the patterns the design system itself marks "not available in code" are out. That is 85 upstream folders, planned as seven waves, of which four are done.
02. Why a native port
Three shortcuts were on the table, and each had a disqualifying cost.
- Wrap the React components. Two frameworks in one bundle, two change-detection models, and every form control a bridge. Storybook would have been the natural catalogue, and Storybook itself depends on React.
- Theme an existing Angular library to look like the design system. The PrimeNG family's token engine injects styles at runtime: a CSP nonce, kilobytes of JavaScript, a regenerated
<style>element per component type on every theme change. - Copy the React kit's styling literally. It mixed four mechanisms at once: generated CSS variables, Emotion classes in the theme object, per-component variables with no shared prefix, and
light-dark()in one theme's overrides, plus a parallel MUI theme for breakpoints.
So we rewrote it, importing only Angular and the CDK. Before a line was written, four read-only research passes studied how others solved the same problems, and the result was a table of what to take and what to leave:
| From | Take | Leave |
|---|---|---|
| Angular Material (M3) | component tokens unset by default, with a system fallback in static CSS | its colour roles; we keep the design system's names |
| PrimeNG / its fork | the per-instance override idea; a typed token map | the runtime engine and style injection |
| Taiga UI | forceable state selectors; options through DI that merge with the parent; attribute selectors on native elements; text as signals; URL-synced controls; an axe pass on every page | global unencapsulated styles, LESS mixins as public API, their docs addon as a dependency |
| The React kit | token names, the theme selectors, helper semantics, the behaviour and API of every component | about 140 compatibility colours, JavaScript branches on the theme name, the classes prop |
03. Theming: three layers of plain CSS
- System tokens carry the design system's names (
--ds-colors-primary,--ds-space-sm), generated into one CSS file under a theme selector, with a dark block that holds only the values that differ. - Component tokens (
--kit-button-height,--kit-tag-color) are never set by the kit. Each is read once with a system fallback, so a value set on any ancestor, or on the host, always wins. - Defaults through DI. Every input defaults from an options token; a provider merges a partial over the parent injector's value, so a container can re-default its children.
the shape of a component stylesheet:host {
/* private switch, set by data-size / data-variant */
--_height: var(--kit-button-height, 32px);
--_bg: var(--kit-button-bg, var(--ds-colors-primary));
height: var(--_height);
background: var(--_bg);
transition: background calc(150ms * var(--ds-motion-scale)) ease;
}
:host([data-size='lg']) { --_height: var(--kit-button-height, 48px); }
/* every state rule exists twice: the real one, and a forced one for the preview */
:host(:hover:not([data-state])), :host([data-state='hover']) {
--_bg: var(--kit-button-bg-hover, color-mix(in srgb, var(--ds-colors-primary) 88%, black));
}
Overrides go cheapest first: a system token on a subtree, a component token on a subtree or a host,
a typed [kitTokens] input, a provided options value. A brand theme is one more token
block; a one-off override is one CSS variable. Dark mode follows prefers-color-scheme
until the app pins it, and a directive re-themes a subtree.
Plain CSS, by ruling
The foundation spec emitted the tokens as Sass partials with mixins, because that is what the rest of the application used. Halfway through the foundation wave I stopped it: mixins hide what a component actually sets, and custom properties already do everything the mixins did.
A twelve-commit conversion: one generated tokens.css, the composite font mixins
dropped in favour of individual properties, and a guard that fails on any .scss,
.sass or .less file, or any @use, @mixin or
@include, in the kit or its preview app. A consuming app lists two stylesheets and
needs no preprocessor.
Generated, never hand-edited
A Node script reads the upstream theme function out of the packed npm tarball, unpacked outside
the workspace so no React dependency is added, classifies every key with a checked-in groups file,
and writes the CSS and a typed TypeScript map. An unknown upstream key stops the generator, so a
version bump cannot add or drop a token silently. Output is deterministic and Prettier-stable.
Upstream's 323 variables became 181 on first emit: the compatibility colours and the deprecated
groups did not survive. The kit added four tokens of its own: a focus-ring colour, width and
offset, and a motion scale that becomes 0 under prefers-reduced-motion.
Icons follow the same rule. A second generator turns 484 upstream SVGs into one tree-shakable
module of typed constants. Up to four fills become colour slots, slot 0 is
currentColor, circles become arc paths, and there is no innerHTML and no
sprite. An icon is a bare 1em glyph; with a label it is role="img",
without one it is hidden from assistive technology.
04. What a component looks like
A native control is an attribute-selector component on the native element, so links stay links and buttons keep their form semantics. A container is an element. Behaviour alone is a directive.
the shape, placeholder names@Component({
selector: 'button[kitButton], a[kitButton]',
hostDirectives: [KitButtonHost],
templateUrl: './button.component.html',
styleUrl: './button.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class KitButtonComponent {}
@Directive({
host: {
'[attr.data-variant]': 'variant()',
'[attr.data-size]': 'size()',
'[attr.aria-disabled]': 'disabled() || null',
},
})
export class KitButtonHost {
private readonly options = inject(KIT_BUTTON_OPTIONS);
readonly variant = input(this.options.variant);
readonly size = input(this.options.size);
readonly disabled = input(false);
}
Every component folder has the same eight files, and the preview app mirrors it:
- Kit: the component's TS, HTML, CSS and spec; a model file for its types; an options file (defaults and English labels, overridable per injector); a tokens file mapping every key to its variable, fallback and kind.
- Preview: a page, a controls file (typed per input, with its own spec), an accessibility file, and a stress page that renders the component 500 times.
- E2E: one keyboard script per component.
Labels are English defaults in the options, never translation keys. The kit has no dependency on
a translation library; an app that translates provides its labels once per injector. Nothing in the
kit is providedIn: 'root'; the one service it has is listed in the kit's providers.
Two boundary rows in the lint config keep it honest: the kit's scope may import only itself, and
its UI libraries may import only Angular and RxJS. Material, PrimeNG, the translation library, the
state library and Bootstrap are banned by name.
05. Signal Forms, not ControlValueAccessor
Angular 22's Signal Forms let a custom control implement FormValueControl<T> (a
value model) or FormCheckboxControl (a checked model). The
[formField] directive binds every other state it finds: disabled, read-only, required,
invalid, touched, errors. We ruled out ControlValueAccessor entirely, and the guard has
a row that fails on NG_VALUE_ACCESSOR anywhere in the kit. A screen still on Reactive
Forms reaches a kit control through the Signal Forms compatibility layer.
The open question going into the checkbox pilot: how does [formField] treat an
attribute-selector component on a native input that also declares
checked = model()? As the native element, or as the custom control?
The pilot answered it: as a custom control, so the component owns the DOM sync. That set the rule
for the form-controls wave, "the composite is the control": the text-input component, not the
inner native input, carries [formField]. A radio inside a form field throws, so the
group is what binds. Eighteen classes implement the contract today. One limit is written down:
a model() cannot learn about a reset to the same value.
06. Overlays without the body container
The CDK's menu trigger and dialog render into a body-level overlay container. Our theme is a subtree selector, so anything rendered outside the subtree loses its tokens: a dark-mode page opens a light-mode menu.
Every overlay is kit-owned and rendered in place on the CDK's inline popover support. Modals
are a native <dialog> opened with showModal(), which gives the top
layer, the backdrop, focus containment and Escape for free. A projected option cannot carry the
CDK's listbox option directive, because DI follows the declaration tree rather than the
projection, so the kit has its own listbox on FocusKeyManager. That leaves two listbox
implementations side by side, which is written down as debt.
07. Accessibility as a test, not a review
- axe on every page and every tab, in light and dark, in a Playwright suite: 949 pages at the last gate.
- One keyboard script per component, following the WAI-ARIA Authoring Practices pattern named in the component's brief: 70 files, 467 tests.
- A focus ring that reaches 3:1 on every surface it can sit on. Upstream hard-coded a ring that measured 2.35:1; the kit's ring is a token, and the guard computes the ratio against every background, compositing translucent and mixed colours itself.
- A
forced-colorsblock wherever state is carried by background alone, 24-pixel minimum targets, and every transition multiplied by the motion-scale token. - Performance in the same suite. 43 stress pages render 500 instances each. The DOM-node count per instance is exact, and timing fails above
max(1.5 × baseline, baseline + 10 ms). The plain 1.5× bar was inside scheduling noise for sub-15 ms medians.
Some of the design system's own colour pairs fail WCAG AA. "Port the design faithfully" and "zero axe violations" cannot both hold, and changing a brand colour is not the kit's call.
Colours ship as designed. Every failing pair is recorded, with its measured ratio, in a shrink-only exceptions list for the design-system team; axe and the guard treat a listed pair as known and any other failure as a bug. The list grew from 16 exceptions over 25 pairs in the first wave to 59 over 125 today, and every entry is a conversation the design team can have with data. The only colour the kit fixes on its own is the focus ring.
One contrast claim was withdrawn: axe reported 4.26:1 for a set of category colours, and it was a reading taken in the middle of a 0.3-second background transition. The tests now wait for transitions to end.
08. A preview app instead of Storybook
The catalogue is a zoneless Angular app in the same workspace. I described it as an interactive Figma: the foundations (colours, icons, radii, spacing, typography), one page per component and one stress page per heavy component, with a searchable, resizable sidebar.
- Four tabs per page: Playground, Tokens (every component token with its resolved value), Accessibility, and Code (the template for the current control values).
- A forced-state switch (hover, active, focus-visible, disabled, invalid) written as
data-state, which is why every state rule exists in two forms. A designer can look at the hover state without holding a mouse still. - Every control is a URL parameter. Controls are typed per input, a few names are reserved and a page that uses one fails, and an empty text parameter means the empty string while an absent one means the default. A link to a specific variant in a specific state in dark mode is a URL.
- The colour-mode switch sits in a sticky header, because comparing a component in light and dark is the most frequent thing anyone does in it.
A second spec, written after I looked at the first version, asks for more for designers: an examples gallery, grouped controls with human labels, a Design tab with anatomy, pixel sizes, tokens resolved in both modes and the Figma link, and a canvas with surface, viewport, zoom and RTL as URL parameters. It has been deferred twice behind component waves. It is the right next thing, because of what happened in section 10.
09. How the agents did most of the work
The port ran on the same harness as the Nx migration (described in its own article): a spec per wave, a plan per batch, implementer agents with a verification command, reviewers, and a run report with a token ledger. What was specific to the kit:
A brief per component
Each implementer received one component and a contract block, written by a planner agent:
- the upstream source paths, the component's entry in the theme file, and the Figma node id;
- the selector, inputs, outputs, slots, host attributes and English labels;
- styles in pixels, component tokens with their fallbacks;
- roles and accessible names, the WAI-ARIA pattern, the keyboard table;
- the preview controls, the stress case with its expected DOM nodes per instance, the contrast pairs to add;
- "deviations to list", and which guards would fail if the contract were violated.
Then seven steps: read, write the kit files, write the spec, write the preview page, write the keyboard script, run the checks, commit. A value the planner had written from memory of the React library, rather than read, was flagged "check it at the click-through".
From a shared tree to a worktree each
Components are independent, so I asked for more agents in parallel. In the display-and-layout wave up to nine implementers worked in one shared checkout, and the shared build was red most of the time: one agent's half-written component broke every other agent's verification.
From the form-controls wave on, each task ran in its own git worktree with
node_modules symlinked, its e2e server on port 4400 + N, and the dispatcher landed
finished tasks by rebase and fast-forward, never while a gate was running. Gates got their own
port after another project's dev server took the default one in the middle of a gate run.
Planning was split the same way: four to six planner agents per wave, one per batch, their files
concatenated by a script.
Reviews and gates
Per task, the conventions reviewer and the "second file" reviewer; per wave, one branch-wide code review. Every branch-wide finding was reproduced as a failing spec before it was fixed, and fixes ran in parallel lanes with disjoint paths. A wave ended with a gate: the full Jest run, the token guard, the e2e suite over every page, and the stress timings.
| Reviewer | W0 | W1 | W2 | W3 |
|---|---|---|---|---|
| conventions reviewer, per task | 1.00 | 1.63 | 2.93 | 1.38 |
| second-file reviewer, per task | 1.81 | 1.45 | 0.48 | 1.96 |
| branch-wide review, per wave | 4.44 | 3.38 | 3.72 | 3.87 |
Code-changing findings per 100k tokens.
The waves
| Wave | Scope | Commits | Agent tokens | Agent hours |
|---|---|---|---|---|
| 0 · Foundation | generators, theming runtime, 484 icons, preview app, e2e project, guards, 8 pilot components (button family, typography, tooltip, checkbox) | 71 | 9.2 M | 10.1 |
| 1 · Display and layout | 26 components: accordion to grid | 80 | ≈ 13 M raw | 13.8 |
| 2 · Form controls | 17 Signal Forms controls and a login-form example | 65 | 9.5 M | 8.0 |
| 3 · Overlays, lists, menus | 18 components: dialog, drawer, snackbar, select, dropdown, breadcrumb, bulk actions… | 102 | 19.2 M | 15.7 |
| Figma correction | tokens plus 25 component tasks across all 69 | 63 | 9.4 M | 8.7 |
Agent hours add up parallel work; the commit history from the first to the last commit spans 32 hours. De-duplicated, the four component waves cost about 48 to 51 million tokens, roughly 0.7 million per component including the foundation, the preview app, the e2e suite and every review.
10. The day Figma disagreed
The rule through four waves was that where the React code and Figma differed, the code won. It was in the deviation tables from the first pilot. Then the designer put the preview app next to the Figma library. 58 of 184 system tokens differed. The primary colour in Figma is near-black; the kit's was blue, and blue survives in Figma only for links and focus. The font, the text colour, the greys, the button radius (a pill, not 6 px) and the dark-mode status colours all differed. "Faithful to the code" had produced something faithful to nobody's current design.
A new architecture decision, the same afternoon: Figma decides how the kit looks; the React code decides behaviour, API and structure. Then three steps: measure, encode, correct.
Measure
A token diff ran immediately, and five read-only audit agents compared every component against its Figma frame while the overlays wave kept going, without gating it. 524 rows in all, sorted into token differences, wrong token bindings, component-level differences, things missing in the kit and things the kit has that Figma does not. Of the 184 system tokens, 53 matched, 58 differed, 31 were kit-only and 42 were not comparable. Figma held another 225 variables with no kit counterpart. The work came out at 51 of 69 components needing their own changes: 18 mechanical, 25 in CSS, 8 structural, the rest token-only.
Encode
The tempting fix was a live Figma export in the generator. That would put a credential and a
network call into a script that runs offline. Instead, one checked-in override file: a source block
(which Figma file, when it was read, which mode is light and which dark), 56 set rows and
7 add rows, each carrying the Figma variable path. The generator applies the override,
else upstream, and it refuses a row equal to upstream (so the file stays a list of differences), an
unknown name, or a row with neither a Figma path nor a reason.
A later Figma version is a re-diff of one override file.
Two places keep the kit's value against Figma, and the designer is told: the full radius stays 9999 px, because Figma's value would square off a circle above 96 px; and the focus ring stays opaque, because Figma's 50 % focus colour measures 1.87 to 2.40:1 and misses 3:1. Where Figma draws no state the kit needs (focus, invalid, read-only), the kit keeps its own.
Correct
Stage 1, the tokens, landed and passed its gate: the generator first reads the override file with byte-identical output, and only then do the system tokens take Figma's values. 4,284 unit tests, 949 e2e pages and 132 timings passed on it. Stage 2 is 25 component tasks in four batches. 23 of them landed before the session's usage limit cut off five running agents, and I called a stop to review what we had before going further. Two tasks, one fix round, two batch reviews and the end gate remain, and no new wave starts until the kit and Figma agree and the designer has seen it.
11. Numbers, and what is open
| Measure | Value |
|---|---|
| Components ported (of 85 upstream folders) | 69 |
| System tokens, light / dark (upstream 323 / 56) | 190 / 41 |
| Icons generated | 484 |
| CSS in the kit, lines · Sass files | 6,205 · 0 |
| Kit source, TypeScript lines (non-spec, incl. generated icons) | 20,365 |
| Spec files in the kit · lines | 151 · 49,480 |
| Unit tests at the last gate | 4,284 |
| E2E pages (axe, light and dark) · stress timings | 949 · 132 |
| Keyboard tests | 467 |
| Contrast pairs checked · listed exceptions | 125 · 59 |
| Commits · elapsed time | 381 · 32 h |
| Agent tokens, five run reports (raw · de-duplicated) | 65.7 M · ≈ 57 – 60 M |
Open, in order:
- Finish the Figma correction, then a human click-through of every page with the designer.
- The designer-facing preview pages: examples, the Design tab, the canvas controls.
- The brand font. Figma uses it on every text style; no file ships until the licence is confirmed, and one weight does not exist as a file at all.
- Waves 4 to 6, specced against Figma from the start: the data-heavy components, among them the table.
- Adoption: the first product screen that renders a kit component, which is also when the e2e suite's place in the pipeline gets decided.
12. If we did it again
- Decide the visual authority on day one, with the designer in the room. "The code wins" was the convenient rule for agents reading a codebase, and it cost a 25-task correction. The code is the behaviour; the design file is the look.
- Diff tokens before porting components. The token diff took minutes. Running it before the first wave would have caught the near-black primary before 69 components were built on the blue one.
- Build the designer pages before the second wave, not after the fourth. The preview app is how a designer finds a mismatch; the better it is, the earlier they find it.
- A worktree per agent from the first parallel wave. A shared checkout with nine writers is a red build.
- Plain CSS from the first commit. Custom properties with fallbacks do everything a token mixin did, and a consuming app needs nothing to read them.
- Make accessibility and performance tests, not review comments. axe on every page, a keyboard script per component, a contrast guard that does its own compositing, a stress page with an exact DOM budget. And an exceptions list for the colours that are not yours to change.
- Encode external truth as a checked-in diff. The override file is reviewable, offline, and a later design version is one re-diff.
The port is the easy half. Agreeing on what "correct" means, and making that agreement a file a generator can read, is the half that decides whether the kit is used.