The Button That Had Two Names

(And Why Your Design System Is Lying to You)

TL;DR

A CTA button composed with a reusable link sub-object ended up with two plausible label fields — one on the parent, one on the child. Editors didn't know which one "actually worked," buttons shipped blank, and the sibling schema used the other field entirely. The fix was a Single Field Authority rule: one canonical field per concept, enforced at composition time, not discovered at incident time.

The Setup

I had a ctaButton object. Three fields: text (the button label), link (the destination), style (the visual variant). Perfectly tidy.

Then I introduced a reusable linkItem sub-object — because reusability, because atomic design, because we're professionals. linkItem included its own label field for link text. Composed it into ctaButton. Felt good about it. Moved on.

Now ctaButton had two fields that could plausibly hold the button label:

Only one rendered. Editors saw both.

The Damage

In Sanity Studio, editors faced two text inputs that looked equally valid. Some filled in one, some filled in both, some filled in neither. Blank buttons shipped to production. Nobody filed a bug because nobody was sure which field was "right" — they assumed they were doing it wrong.

Meanwhile, a sibling schema — ctaButtonDoc, used for header CTAs — resolved the label from link.label only. Same conceptual component, different field authority. Same design system, two contradictory answers to the question "where does the button label live?"

The Principle

Single Field Authority: each user-facing concept — label, title, description, URL — resolves from exactly one canonical field. Two fields that could hold the same value is a bug, not a convenience.

If a sub-object brings a field that overlaps with a parent, one becomes canonical and the other gets hidden or removed. Same commit. Not "we'll clean it up later." Now.

The Fix

Four steps, no drama:

  1. Pick a winner. link.label becomes the canonical label for all CTA buttons.
  2. Hide the loser. text stays in the schema (legacy data needs it) but is hidden from the Studio UI.
  3. Add a fallback. GROQ projection uses coalesce(link.label, text) so old content still renders.
  4. Align the siblings. Apply the same pattern to both ctaButton and ctaButtonDoc. One pattern, one authority.

Old content keeps working. Editors see one field. Buttons stop shipping blank.

The Guardrail

The fix solved the symptom. The guardrail prevents recurrence:

Composition Overlap Audit — required whenever you compose a sub-object into a parent:

  1. List every user-facing concept on the parent (label, title, description, URL).
  2. Compare with every field on the incoming sub-object.
  3. For any overlap: pick a canonical field, hide or remove the other, add migration logic if needed.
  4. Do it in the same commit as the composition change.

Atomic Reuse Gate — required before creating or forking a component:

  1. Does this pattern already exist? Extend it.
  2. Will more than one caller consume it? Move it to shared.
  3. Is the API composable (slots, tokens, props)? Prefer that over hardcoded variants.

This turns accidental overlap into a governed decision. The audit adds maybe ten minutes to a PR. Discovering the overlap in production adds days.

Why It Matters

The real cost isn't technical debt. It's editor trust.

When editors see two fields and don't know which one "actually works," they hedge. They fill in both, or they stop using the system patterns altogether. Support spends time answering schema questions instead of improving the system. In multi-brand setups, every overlapping field multiplies: more variants to theme, more places for breaking changes to hide, more opportunities for silent content failures.

A design system that lies to its editors about where data lives isn't a system. It's a suggestion.