Turning a component library into a system with rules
Design System Case Study
A component library answers “which widget?”. I spent thirteen months making this one answer “how do I build this screen?”
Overview
A dark-first component library for broadcast control rooms — the software that runs playout channels, graphics engines and on-air state, used by operators working in low light under real time pressure.
The library began in March 2023. I arrived two years and three months into its life, as the 33rd contributor to a codebase that already had opinions but no written rules. Within a year I was its most active engineer by a factor of three.
Role
I worked across three repositories — the component library, its design-token pipeline, and the developer portal that documents both.
The work split roughly in half: shipping components, and building the architecture and process that decides whether components written by anyone get adopted. This case study is mostly about the second half, because that is the half that outlasts me.
The problem nobody had written down
Thirty-two people had contributed components. Every one of them had been reasonable, and the result was still incoherent: nine components carried their own private copy of what a “status” is, variant and appearance competed for the same job in different components, and the conventions that did exist lived in people's heads and in review comments.
A library in that state doesn't fail loudly. It just stops being cheaper than writing it yourself — so product teams fork, or reach past it, and the library becomes a museum.
My read was that the missing piece was not more components. It was governance that a busy engineer cannot route around: rules expressed as types, scaffolds and console warnings rather than as documentation nobody opens.
Four things I introduced
#1 A prop taxonomy
A four-axis vocabulary that separates semantic role from visual treatment, so variant and appearance stop competing for the same meaning.
Plus a positive-prop-naming rule — no noBorder, no disableFoo — that names its existing violators out loud and asks only that new work not add more.
#2 Warnings, not docs
Two development-only warning composables that cost nothing in production by construction: one deduplicates per session, the other never even evaluates its message strings outside dev.
Now a discouraged design decision tells you so, in your console, at the moment you make it — with the exact migration path and a link to give feedback.
#3 A beta channel
A component lifecycle with real stages, so “not finished yet” became a supported state instead of an argument in review.
One message source feeds both the documentation banner and the console notice, so the two cannot drift apart — the failure mode of every hand-maintained status badge I have seen.
#4 A release train
A predictable monthly cadence instead of releases whenever someone remembered, and an automated version-bump job gated on both package publishes.
It moves two lockfiles that must travel together, and it is idempotent — a re-run reuses its pull request instead of spamming duplicates.
Each rule ships with a scaffold, a checklist item, or a compile-time constraint. Conventions written only in prose decay — these are the path of least resistance.
One vocabulary for the whole library
The clearest example of the approach. A single file — 160 of its 177 lines mine — collapses nine components' worth of divergent local enums into one governed vocabulary.
Each value carries a docstring written in the language of the domain, not of the UI: playout channels, graphics engines, on-air state. Per-component subsets are derived from the canonical list rather than re-declared, so a typo cannot compile.
It went in across the existing components without a major version, using deprecated aliases — because a vocabulary that requires a migration to adopt does not get adopted.
The same instinct produced a second architectural tier. In one commit I added a patterns directory, exported it from the package's public entry point, and registered “Patterns” as a top-level sibling of “Components” in the documentation navigation.
Three moves, one commit — a deliberate tier, not a folder that accumulated. It is where cross-component guidance lives: the page-scaffolding system, loading guidance, anything that answers a question bigger than one widget.
Deprecation became an executable contract too. Changing a shipped pagination API, I kept the old path alive and wrote stories that assert the legacy behaviour still works — backward compatibility enforced by CI rather than promised in a changelog.
Accessibility, standardised rather than sprinkled
Three contracts now hold library-wide instead of per-component: a single tab stop per component, with the choice between real and virtual focus named explicitly rather than implied; disabled items are skipped, enforced once in shared code and asserted in tests; and status is never colour alone.
I also documented the keyboard contract for consumers — key tables across three documentation pages — which is the part product teams almost always skip.
The subtle catch this turned up: a component was placing ARIA popup attributes on a wrapper element where they meant nothing. Correct-looking ARIA on the wrong node is invisible to review and invisible to screen readers. Finding it requires actually knowing the specification.
Thirteen months, reconstructed from git
- 156 commits authored on
main - 61 pull requests opened
- 22 pull requests reviewed for seven colleagues
- ~20,300 hand-written lines
- #1 contributor of the trailing year
- 3 repositories
Authored commits merged to main over the trailing twelve months — merge commits excluded, bots removed. Roughly half of all human authorship on the branch for the period. All-time I rank second, behind the library's original author.
| Contributor | Commits |
|---|---|
| Me | 136 |
| Colleague A | 40 |
| Colleague B | 32 |
| Colleague C | 10 |
| Colleague D | 7 |
| Colleague E | 4 |
Where the hand-written lines went. Generated files, lockfiles and snapshots excluded. Only 18% of what I merged is component code — the rest is the examples, tests and documentation that decide whether a design system actually gets adopted. Tests run roughly 1:1 with component lines; one pattern ships 965 lines of spec against 714 lines of component.
| Category | Lines | Share |
|---|---|---|
| Documentation examples | 5,295 | 26% |
| TypeScript & config | 4,404 | 22% |
| Components | 3,708 | 18% |
| Tests | 3,642 | 18% |
| Documentation | 2,352 | 12% |
| Tooling server | 667 | 3% |
Seven components and a new tier, shipped
ProgressBar — the library's first real progress primitive, determinate and indeterminate, composed as a form field rather than a bare bar. 298 lines, 27 tests. Its icon prop is deliberately tri-state: auto-derive from status, override by name, or suppress. Most libraries collapse “auto” and “off”, then need a second prop to undo it.
SegmentedBar — stacked breakdown bar for quota and distribution displays, sharing the density scale and status vocabulary of the progress bar so the two read as one family. Colour yields to explicitly authored status rather than silently overriding it — a one-line precedence rule that prevents data loss.
StatusIcon & StatusChip — the status-communication primitives the rest of the library leans on, backed by a total map, so adding a status to the vocabulary is a compile error until an icon exists for it.
FeedbackStatusIcon — thirty lines, and deliberately narrower than its sibling: typed to four feedback values so a service state like connected fails to compile. Using the type system to enforce a design distinction the docs could only ask for.
Media — lazy-loaded image and video primitive with hover preview, three distinct empty states, and retry. 42 tests, the largest spec in the shipped set. It began as a colleague's prototype; I rewrote it (+265/−192) and shipped it. alt is a required prop — you cannot construct an inaccessible instance.
Authentication — login and password-reset layout shell. Carried solo, 14 tests, with a 168-line spec document of annotated screens and token-tied spacing rules. It deliberately owns no auth logic, which is the constraint that makes it adoptable across products with different backends.
What the work actually looks like
Rendered from the library's own visual-regression baselines — the images CI compares against on every pull request, not marketing mockups. They render dark because it is a dark-first system, built for control rooms where operators work in low light.
Components I did not create, but now carry
Measured by line-level authorship of code currently alive on main — not commit counts, which duplicate across rebased branches.
| Component | My lines | Total | What I did |
|---|---|---|---|
| Chips | 892 | 1,234 | Broke a hard parent dependency so chips work standalone; fixed a real tab-order defect; 7× test growth; spawned two new components. |
| Table | 1,719 | 5,558 | Fixed a documented prop that had never worked; redesigned the pagination API with a CI-enforced deprecation path; doubled the test suite. |
| Popup | 227 | 790 | Solved nested-overlay stacking in 93 lines, after rejecting my own 466-line proposal. |
| Dropdown | 236 | 1,812 | Deleted its bespoke overlay lifecycle and re-expressed it on the shared popup — one positioning engine for the library instead of two. |
The work that makes other work cheap
A design system that AI tools can read
618 hand-written lines exposing all 49 components to AI coding assistants as queryable tools. Dual transport — local and HTTP with per-session management and a health endpoint. It parses component source statically, so it has no dependency on the library compiling and starts instantly.
Why it matters: a library's value is capped by adoption, and adoption used to be capped by whether a developer read the docs. When the developer is an agent, a system that isn't machine-queryable doesn't get used — the agent writes a raw <button> instead.
Honest scope: running daily and adopted by teammates, but not yet wired into CI or announced in the root README. Shipped and used — not productised.
The primitives layer, and a testing culture
Eight composables authored, four pre-existing ones materially extended, and 23 test files written. Of the 35 composables on main, only six had any test at all — and none of those six were mine to begin with. On this layer I wasn't maintaining a testing culture, I was creating one. My extension of the focus composable ships the first test suite that 2023-era file has ever had: 225 lines, 17 cases.
Alongside it, unglamorous plumbing that was quietly wrong: visual-regression snapshots were Windows-only, required elevated permissions, and silently rewrote the baseline they were meant to verify. I fixed all three.
And three verified instances of the same discipline: hit a missing design token, add it to the token repository, then consume it downstream — rather than hardcoding a value locally. I extended the token build pipeline with a custom transformer to make that possible, correctly marked transitive so it resolves aliases, type-gated rather than name-gated, and idempotent.
Three episodes that are hard to fake
I deleted my own code
September 2025: I wrote roughly 120 lines of bespoke keyboard-navigation logic to fix one component's accessibility.
July 2026: I removed that same code, replaced it with a shared composable carrying two documented focus strategies, and adopted it across four components — additively, so existing consumers were untouched. The rewritten component is 57 lines shorter and gains a type-ahead search it never had.
Recognising a pattern on its third appearance, then paying to generalise it, is the clearest signal of seniority I know how to demonstrate.
I reverted myself
A merged pull request bundled a feature with a silent breaking change to page indexing.
Two days later I reverted my own landed work, split it into two atomic tickets, and re-landed both within two and a half hours. Comparing the reverted tree against the re-landed one shows eight files improved along the way — the round trip bought real quality, not just a tidy history.
Self-reverting merged work is expensive and ego-free. Most engineers patch forward.
I scoped down
I wrote a 466-line proposal introducing a z-index manager. It was abandoned.
I shipped the accessibility half of the ticket instead, then solved the same overlay-stacking problem months later with two props and 93 lines.
The abandoned composable never entered the codebase. Knowing which of your own designs to drop is worth more than the design.
Standards, review, and a portal built solo
Reviewer and release manager
22 pull requests reviewed for seven different colleagues, and 79 merge commits — functionally the release manager for this library, having shipped the 1.3 and 1.4 lines.
Unglamorous triage sits alongside it: a production build broken by an invalid CSS selector, a runtime bug from an import pulled off Node instead of Vue, a TypeScript major-version migration after a dependency bump.
The developer portal — 20 of 23 commits
A self-hosted internal portal that is the front door to the design system: the component library, every released version of its documentation, the design tokens, and the tooling server. Front end, API, four-stage container build, routing, and a deploy pipeline with a health-check gate — roughly 4,500 hand-written lines, built essentially alone across three concentrated pushes.
Documentation that cannot drift: component props, slots and events are pulled live from source rather than written by hand. One metadata source serves both the AI assistants and the human docs site — so the two are structurally incapable of disagreeing.
It also rebuilds five historical toolchains: a 142-line script builds every released version's documentation reproducibly, through nested submodules a worktree won't populate, a decommissioned remote, and toolchains that no longer run on current Node. Incremental, and one failed version never aborts the deploy.
It is the library's largest real consumer — built entirely from the components it documents, which is how it surfaced genuine library bugs that were then fixed upstream.
Complete, tested, and not yet landed
Twenty-four pull requests are open. Roughly 13,600 net-new lines of finished work sits unmerged — stated plainly here, because it is the first thing a reviewer will find.
The honest reading: this is a throughput-versus-landing-rate story, and it cuts both ways. The volume and finish of the unlanded work — full test suites, written documentation, decision tables — is itself evidence of capability. But a page system with no pull request, and a gallery whose documentation is switched off, are finishing gaps, not capability gaps, and they are better raised first than discovered.
| Work | State | Scale | Blocker |
|---|---|---|---|
| ProgressCircle — circular indicator morphing continuously between spinner and measured progress | Open | 682 lines, 64 tests | Waiting on a companion design-token change upstream |
| Stepped progress — one component covering time, goal and step progress via a polymorphic model | Open | 49 tests, 346 doc lines | Awaiting review |
| Gallery — virtualised gallery with multi-select and 2D keyboard navigation | Open | 34 tests | Documentation and stories disabled on the branch; mixed authorship needs restating |
| Global density & appearance config — one declaration replacing per-tag repetition across 37 components | Open | 52 files | Large surface; review latency expected |
| Page scaffolding system — four components producing a correct heading outline by construction | No PR | 38 tests, 399 doc lines | Never proposed for review |
How these numbers were produced
What was measured
Authorship from line-level git blame of code currently alive on main, not commit counts.
Pull-request state read live, so merged, open and closed are never conflated.
Generated files, lockfiles and snapshots excluded from every line total.
Content provenance checked by comparing git blob hashes where authorship was ambiguous.
What was corrected
Commit-count ownership overstated several components — rebased duplicate branches counted the same work twice.
Several composables and one component were carried over from a colleague's branch; git credits the introducing commit, not the author. They are excluded.
One component named in a pull-request title does not exist in the codebase.
Two workflow fixes were authored by an automation agent and are not claimed as my code.
Every figure here survived that filter. Where a claim could not be verified, it was removed rather than softened.