Arbor
Linktree · 2025–26
Ambiguity is more expensive than the structure that prevents it.
Project Brief
Arbor is Linktree’s design system. I joined as its senior software engineer, owning its direction and doing the work, and spent the next year consolidating its micro-frontends onto one version and one token set.
Ambiguity is more expensive than the structure that prevents it. Removing it is design work, not maintenance, and this is the case for that claim.
The incident
Primary button meant three different things, and each of them was correct.
Arbor had shipped alongside the legacy component library rather than replacing it. Both rendered on the same surfaces, at the same time, and each carried its own tokens. There was no shared source of truth between them, not because one had been removed, but because the new system arrived before there was anywhere to put one.
So the same name resolved differently depending on where you were standing. A primary button was a black field with white text in one place, a purple field with white text in another, and in at least one feature a secondary-level variant promoted by hand because that was the one that read correctly against everything around it.
Each of those was a defensible decision at the moment someone made it. None of them threw. A button that renders is a button that works, and everyone who approved one was looking at a screen where it looked right.
Two libraries rendering on the same surfaces, each carrying its own tokens. The name was not wrong in any one place. It was simply not decisive anywhere.
Fixing that wasn’t a matter of picking one of the three. Arbor needed the standing to change the shape of the Frontyard codebase, which meant untangling module federation and the absence of a singleton CSS loading experience. A design system cannot own appearance if it cannot control what CSS arrives, in what order, and once.
The diagnosis
It was not a color problem.
Underneath the divergence sat a token hierarchy with no levels in it. No scoping. No naming conventions describing where in a component’s structure a token applied. Everything flat, on one level, sharing relationships.
You cannot have a primitive token that also acts as a component token.
A value cannot be both the raw material and the application of the raw material. The moment it’s both, every consumer of the primitive inherits a decision that belonged to one component. And the only way to correct that component is to change something a hundred other places are reading.
Left: each level does one job. Right: the same value is the raw material and the application of it, so every consumer of the primitive inherits a decision that belonged to one component.
The team had run into this friction before I arrived and had reached a decision about it: never add another layer of component tokens. The reasoning (I’m paraphrasing their thinking rather than quoting it) was that to the designers who owned token creation in Figma, a second layer seemed more complicated, and keeping everything on one level was simpler.
That was a reasonable call, made by the people who would have had to maintain the thing they were declining, and a second layer genuinely is more moving parts. The trouble is that declining it is what produced the complexity. The structure they were avoiding was cheaper than the ambiguity they got instead.
A simplification that costs more than the structure it avoids is very hard to see from the inside, because on the way in it reads as prudence.
And none of it errors. Nothing looks broken. The contradiction surfaces only when somebody tries to add the second level and finds it already occupied.
That is the whole subject of this project. The failures that cost us the most never threw.
Scope
The design-systems function did not exist when I arrived. For the first few months it was me.
A staff product designer joined afterwards and we paired on building and delivering the system. He spearheaded the Figma component library and owned that half of the equation: he sat on the design team and worked daily with the designers shipping features who needed opinions about which components to use where. I owned the code.
Two people, against more than ten micro-frontends.
The collaboration was a schedule rather than a disposition: weekly office hours with the entire design team, a standing 1:1 with the staff designer that ran his engineering opinions into my design work and my design opinions into his, and a weekly meeting with the two product design teams driving the visual language. That last one existed to read where things were heading, surface problems while they were still cheap, and make connections between different teams’ work that nobody else was positioned to see.
Step one: legibility
One Tailwind configuration. One component library. One token set.
This is the part that gets mistaken for a large Tailwind migration, and the condition above is why it isn’t. You cannot debug a system you cannot read. Two token sets overlapping meant that when something rendered wrong, there was no reliable way to determine which one had produced it. And no way to correct it in one place, because there was no one place.
Unifying how surfaces mounted and loaded was not normalization for its own sake. It was making one library’s CSS arrive once, in a known order, so that a token could have a single answer.
The before half is not untidy, it is undecidable: two sets define the same name and nothing says which wins. The after half does not look neater so much as it looks answerable.
The method
Audit what is actually consumed. Remove what isn’t. Write the codemod. Then change.
@linktr.ee/arbor went from version 9 to version 27 in six months, eighteen major releases, while every micro-frontend stayed on one version and one token set throughout. That is only possible if migration is a first-class, automated capability rather than a request made of other teams.
Linktree publishes the codemods package itself, and describes it as being for upgrading Arbor across major versions and migrating off the legacy component library. Eighteen honest majors, not eighteen impositions.
Governance, and the tax it charges
Intention encoded in the commit. CI enforcing the format on push. A release script deriving each package’s version from those commits. The version flagging the pull request for human review. And the written record letting the reviewer approve confidently.
The elegant part is the pairing: the version number says this is risky, and the written context says here is exactly why it isn’t. The tax and its remedy were designed together. Over-versioning on its own would have produced friction and nothing else. With the context attached, it produced a conversation at precisely the moment someone was deciding.
The objection to all of this is that it’s slow. A codebase that was updated too quickly needs slow thinking. The friction isn’t a cost of the method. It is the method, and it’s what produced the cross-team conversation that made the migration land.
The judgment
I shipped the rename first and it was wrong.
The clearest instance of the flat hierarchy was in type. The brand typeface ships as two cuts in one family — Display, which is more extended, and Product, whose width reads as body copy — and which cut a token resolved to was a value someone had to know rather than a name they could select. That’s a primitive carrying a component’s decision, in the one corner of the system where I could fix it by renaming.
I renamed the family to Product, promoted Display to a peer primitive, and exposed both as named utilities from a single variable font file, so choosing correctly cost nothing. The semantics were right. What I hadn’t shipped was a migration path. Consumers had to carry the change themselves.
So I reverted it at the next major boundary, un-shipping my own work to protect the surfaces the project existed to fix. Then I built the codemod, hardened it against the cases that actually occur (!important prefixes, class-merging groups, headings that had lost their weight), and taught it to flag adjacent font-family utilities, so a hand-written declaration sitting beside a migrated one could not pass silently. The rename re-landed inside the typography core three weeks later and held.
You cannot rename a token. You can only migrate one.
Escalation
The standing complaint about design systems is that developers don’t change their habits. Three responses, and each rung moves closer to the moment of decision while asking less of the person, not more:
Documentation asks them to remember it exists, go and find it, and apply it. A lint rule in CI and locally flags the problem at the moment of writing, and asks them to work out the fix. A reviewer that annotates pull requests hands over the code to write, and asks only that they accept it.
That last one came out of auditing Arbor against the best systems in the field and noticing the gap in our own workflow. The research didn’t only answer component questions: it produced the roadmap for the system’s own tooling.
The point isn’t to deliver knowledge where the decision happens. It’s to progressively collapse the distance between knowing and doing, until the right thing costs less than the wrong thing.
The closer an intervention sits to the decision, the less it asks. Documentation is the furthest lever here, which is not the same as a useless one.
Closing the loop on truth
Building an agent surface is now common. Linktree markets Arbor publicly as “Linktree components, one prompt away”, working with Claude, Cursor and VS Code.
What I’d argue is less common is testing whether it’s true.
We built evals that measured whether the agent surface changed what a model actually produced. Effect, not existence. Then a CI fixture that asserted the guidance was true rather than that a model had complied with it. That distinction is the whole point: a model complying with a false claim is worse than a model ignoring it, and measuring compliance would have produced a nicer number and taught us nothing.
The eval found false claims in our own guidance, which is why we built it. Humans had been reading those claims for months. We added provenance to the guidance artifacts so a consumer could see where a claim came from, and status flags to every component so a consumer could ask may I recommend this? rather than guess.
Same enemy, one level up. Unification made ambiguity in the code legible, because you cannot debug what you cannot read. Evals made ambiguity in the guidance legible, because you cannot trust what you cannot test.
The accessibility work followed the same shape. An audit found gaps across several hundred instances; a counted baseline turned that into a number; a gate stopped it growing; and development-time warnings caught the most common cause at the moment of authoring. Finding, then counting, then gating.
What is true now
Tokens sync bidirectionally between code and Figma with no manual step. Every pull request that changes a token pushes to Figma — to main, or to a matching branch, since the naming conventions are shared and so the branch mapping could be automated too. It is linted, it runs as a GitHub Action, and it logs what changed, what needs adding, and why.
The documentation builds itself, and is readable by people and by models through a documentation site, an MCP server and a CLI.
Two things stopped happening, which is the evidence I’d point at first: manual token syncing, and documentation going stale.
Write something once and then point at it.
One source, five destinations, none of them privileged over the others — which is the point, because one of the five is not a person and consumes the same truth the same way.
What it becomes
Bidirectional sync means design and code cannot silently disagree any more. Structurally, drift is gone.
What remains once drift is gone is ambiguity. And the two are not the same thing. Drift is when design and code disagree and one of them is wrong. Ambiguity is when they disagree and neither is wrong.
That is where my current work begins.