---
title: 'Design Systems'
source: 'https://academia.sh/en/courses/design-systems'
language: en
updated: '2026-08-19T05:19:53+00:00'
license: 'CC BY-SA 4.0'
lessons: 19
outcomes:
    - 'Translates the design language into tokens'
    - 'Manages the component catalog with documentation and versioning discipline'
    - 'Measures adoption of the system and establishes its governance'
---

# Design Systems

A course that turns repeated design decisions into tokens, a component catalog, and governance rules, building an interface language that scales and can be audited.

## Establishing the System

How a design system differs from a component library, justifying the need for one through a cost calculation, the visual audit, two starting strategies, and writing the design language.

### What Is a Design System

Source: https://academia.sh/en/courses/design-systems/what-is-a-design-system

How a design system differs from a component library, how decisions split into value, component, pattern, and rule layers, and how to compute the share of those decisions a library actually answers.

The two courses so far taught how to get the catalog interface's decisions right one at a
time. The Fundamentals of Interface Design course established the grid, the spacing scale,
the typographic scale, and the color roles; the User Experience and Behavior Design course
showed which research finding each of those decisions rests on and how to measure its
outcome. In both courses the unit was a single decision: how many pixels this gap is, how
many steps this flow takes, whether this change lowered task time.

A decision can be made correctly. Making that same decision the same way a second, tenth,
and hundredth time is a separate problem, and getting each one right individually does not
solve it. The catalog interface is no longer just four screens: the admin panel, the member
profile, the shelf layout screen, and the mobile app all have to speak the same language as
products of the same institution. This lesson defines the structure that turns repetition
itself into a design object, and separates it from what it is most often confused with — the
component library.

#### The System Is the Named Form of Decisions

A **design system** is the structure that names, documents, and distributes an
institution's interface decisions so they can be referenced instead of remade. All three
predicates of that definition are mandatory, and none substitutes for another.

**Naming** makes a decision legible from a name. "The Borrow button's fill is neutral-600
over primary-600" is a decision; `--action-primary` is a name. As long as the name is used,
the decision is not remade, it is referenced.

**Documentation** carries the decision's rationale along with its name. The Color System
lesson showed by calculation that the primary and secondary actions cannot be separated by
tone alone. If that finding is not written down, someone six months later gives the
secondary action a purple fill and has to redo the same calculation from scratch.

**Distribution** makes the decision usable. A scale sitting in a design file that no one can
install is not a decision, it is a wish.

A **component library**, by contrast, is only one delivery form of that structure: a
packaged set of reusable interface parts. The library is the system's output, not the
system itself. The fastest way to sense the difference is to count how many decisions the
interface actually carries.

#### Decisions Sit in Four Layers

The decisions made across the catalog interface's four screens split into four layers. The
**value** layer states which raw magnitude to use: a size step, a color role, a spacing
step. The **component** layer defines a single part's internal structure: a button's inner
padding, a text field's border. The **pattern** layer sets how multiple components line up
together: the ratio between the filter panel and the results list, the relationship between
a form field and its label. The **rule** layer is the behavior contract that holds
everywhere: a destructive action asks for confirmation, a focus indicator cannot be removed.

This split is clarified here so its name is not confused with the term **interface
pattern**: a pattern is the name for how components are arranged relative to each other; a
single component's internal structure belongs to the component layer.

The program below counts these four layers and computes which ones a component library
answers.

```js
// scope.mjs — how many of the catalog interface's decisions a component library answers

// Decisions actually made across the catalog interface's four screens.
// layer: value      -> which raw value to use (color, spacing, size)
// layer: component  -> a single component's internal structure
// layer: pattern    -> how multiple components are arranged together
// layer: rule       -> a behavior rule that holds everywhere
const DECISIONS = [
  { name: "body text size", layer: "value", screens: ["search", "results", "detail", "confirmation"] },
  { name: "heading size step", layer: "value", screens: ["search", "results", "detail", "confirmation"] },
  { name: "primary action fill", layer: "value", screens: ["search", "results", "detail", "confirmation"] },
  { name: "border color", layer: "value", screens: ["search", "results", "detail"] },
  { name: "card inner padding", layer: "value", screens: ["results", "detail"] },
  { name: "group inner spacing", layer: "value", screens: ["search", "results", "detail", "confirmation"] },
  { name: "corner radius", layer: "value", screens: ["search", "results", "detail", "confirmation"] },
  { name: "focus ring thickness", layer: "value", screens: ["search", "results", "detail", "confirmation"] },

  { name: "button internal structure", layer: "component", screens: ["search", "results", "detail", "confirmation"] },
  { name: "text field internal structure", layer: "component", screens: ["search"] },
  { name: "record card internal structure", layer: "component", screens: ["results"] },
  { name: "tag internal structure", layer: "component", screens: ["results", "detail"] },
  { name: "confirmation dialog internal structure", layer: "component", screens: ["confirmation"] },
  { name: "pagination internal structure", layer: "component", screens: ["results"] },

  { name: "filter panel to results list ratio", layer: "pattern", screens: ["results"] },
  { name: "empty results screen layout", layer: "pattern", screens: ["results"] },
  { name: "form field and label arrangement", layer: "pattern", screens: ["search", "confirmation"] },
  { name: "confirmation action placement", layer: "pattern", screens: ["confirmation"] },
  { name: "record metadata order", layer: "pattern", screens: ["results", "detail"] },

  { name: "destructive action requires confirmation", layer: "rule", screens: ["detail", "confirmation"] },
  { name: "focus indicator cannot be removed", layer: "rule", screens: ["search", "results", "detail", "confirmation"] },
  { name: "error message sits below the field", layer: "rule", screens: ["search", "confirmation"] },
  { name: "loading state reserves space", layer: "rule", screens: ["results", "detail"] },
  { name: "date format", layer: "rule", screens: ["results", "detail", "confirmation"] },
];

// A component library packages only the "component" layer.
const LIBRARY_LAYERS = new Set(["component"]);

const layers = ["value", "component", "pattern", "rule"];
console.log("layer      decision count  total usage  library answers it?");
let totalDecisions = 0;
let totalUsage = 0;
let covered = 0;
for (const l of layers) {
  const group = DECISIONS.filter((d) => d.layer === l);
  const usage = group.reduce((t, d) => t + d.screens.length, 0);
  totalDecisions += group.length;
  totalUsage += usage;
  if (LIBRARY_LAYERS.has(l)) covered += group.length;
  console.log(
    `${l.padEnd(10)} ${String(group.length).padStart(14)} ${String(usage).padStart(12)}  ${LIBRARY_LAYERS.has(l) ? "yes" : "no"}`
  );
}
console.log(`total      ${String(totalDecisions).padStart(14)} ${String(totalUsage).padStart(12)}`);
console.log(`share of decisions the library covers: ${((100 * covered) / totalDecisions).toFixed(1)}%`);

// When the value layer is unnamed: every value is rewritten in each component that uses it.
const COMPONENTS = DECISIONS.filter((d) => d.layer === "component");
const valueUsage = {
  "body text size": ["button internal structure", "text field internal structure", "record card internal structure", "tag internal structure", "confirmation dialog internal structure", "pagination internal structure"],
  "heading size step": ["record card internal structure", "confirmation dialog internal structure"],
  "primary action fill": ["button internal structure", "confirmation dialog internal structure"],
  "border color": ["text field internal structure", "record card internal structure", "tag internal structure", "confirmation dialog internal structure"],
  "card inner padding": ["record card internal structure", "confirmation dialog internal structure"],
  "group inner spacing": ["button internal structure", "text field internal structure", "record card internal structure", "tag internal structure", "confirmation dialog internal structure", "pagination internal structure"],
  "corner radius": ["button internal structure", "text field internal structure", "record card internal structure", "tag internal structure", "confirmation dialog internal structure"],
  "focus ring thickness": ["button internal structure", "text field internal structure", "record card internal structure", "pagination internal structure"],
};

console.log("\nvalue decision           unnamed: how many components  named: how many places");
let unnamedTotal = 0;
for (const [name, users] of Object.entries(valueUsage)) {
  unnamedTotal += users.length;
  console.log(`${name.padEnd(24)} ${String(users.length).padStart(28)} ${"1".padStart(23)}`);
}
console.log(`${"total".padEnd(24)} ${String(unnamedTotal).padStart(28)} ${String(Object.keys(valueUsage).length).padStart(23)}`);
console.log(`component count: ${COMPONENTS.length}`);
console.log(`change-site ratio: ${(unnamedTotal / Object.keys(valueUsage).length).toFixed(2)} times`);

console.log("\nchanging a single decision (example: corner radius 4 -> 8)");
console.log(`  unnamed: ${valueUsage["corner radius"].length} component files change`);
console.log("  named  : 1 token definition changes, component files do not change");
```

```
layer      decision count  total usage  library answers it?
value                   8           29  no
component               6           10  yes
pattern                 5            7  no
rule                    5           13  no
total                  24           59
share of decisions the library covers: 25.0%

value decision           unnamed: how many components  named: how many places
body text size                                      6                       1
heading size step                                   2                       1
primary action fill                                 2                       1
border color                                        4                       1
card inner padding                                  2                       1
group inner spacing                                 6                       1
corner radius                                       5                       1
focus ring thickness                                4                       1
total                                              31                       8
component count: 6
change-site ratio: 3.88 times

changing a single decision (example: corner radius 4 -> 8)
  unnamed: 5 component files change
  named  : 1 token definition changes, component files do not change
```

#### The Library Covers a Quarter of the Decisions

The first table gives the lesson's main finding. Six of twenty-four decisions sit in the
component layer: 25%. Even if a component library is delivered complete, three-quarters of
the interface's decisions are still remade on every screen.

That share is itself misleadingly optimistic. The second column shows usage counts: the six
components are used 10 times in total, while the eight value decisions are used 29 times.
The value layer consists of both fewer decisions and more frequently referenced ones.
Failing to hold a frequently referenced decision in one place is the most efficient producer
of inconsistency.

The rule layer carries a separate warning. Five rules apply 13 times, and none of them lives
in a component file. The rule "a destructive action requires confirmation" cannot be written
into a button; a button does not know what happens when it is clicked. These rules can exist
only as documentation, and when they are not documented, each screen rediscovers them on its
own.

#### Naming Reduces Change Sites to One

The second table counts the cost of leaving the value layer unnamed. Eight value decisions
appear in a total of 31 separate places across six components. When the same eight decisions
are named, the number of definitions drops to 8: the number of change sites shrinks by a
factor of 3.88.

The final block makes this concrete through a single change. Raising the corner radius from
4 pixels to 8 means five separate edits in five separate component files if the value is
unnamed. Every one of those edits has to be found; any one that is not found is left alone
in the interface and produces inconsistency. When the name is used, the change site is one,
and components inherit the change automatically.

This is the system-level justification for the mechanism built in the Custom Properties
lesson. There, the adjustment points a component exposed outward were defined as custom
properties; the calculation here shows why that architecture is not a convenience but a
necessity.

#### Five Differences Between a System and a Library

The numbers sharpen the definition. Turning a component library into a design system takes
five more things.

**Values are named.** The numbers and colors components read are defined in a separate
layer, independent of the components. This layer's name will be **design token**, and it
fills the entirety of the next topic.

**Patterns are written down.** How two components stand side by side cannot be written into
the components; it stands as a separate document.

**Rules become a contract.** Behaviors that hold everywhere must rest on text that can be
pointed to when violated.

**Decision rationale is kept.** Why a threshold is 4.5, why a ratio of 1.25 was chosen,
stands alongside the decision. A decision without a rationale gets reargued at the next
review.

**Change is managed.** We saw that when a token's value changes, every place that uses it is
affected; that is the power itself, and it is also the risk itself. Versioning, the
contribution process, and governance are the answer to that risk.

#### A System Is Not a Product, It Is an Addition to Products

One final distinction is needed. A design system does not produce value for a user on its
own; products do. The system's measure is not its own completeness, but how much products
benefit from it.

The practical consequence is that the system measures its own success through **adoption**:
how many products, how many screens, what percentage of decisions come from the system. A
system that is 100% complete but used in no product is more of a failure than one that is
40% incomplete but used on every screen. Adoption measurement is taken up separately in this
course's final topic; what matters here is that the system's reason for existing lies not in
itself but in its use.

This distinction also determines the scope decision. The system covers decisions that
products actually repeat; a layout that appears once, on a single screen, belongs to that
screen, not to the system. How scope is drawn depends on the justification calculation that
is the subject of the next lesson.

#### Summary

- A design system is the named, documented, and distributed form of repeated interface
  decisions; a component library is only one delivery form of that structure.
- Interface decisions sit in four layers: value, component, pattern, and rule. A component
  library packages only one layer.
- In the measured interface, 6 of 24 decisions sit in the component layer; the library's
  coverage is 25%, and the value layer is the most frequently referenced layer, with both
  fewer decisions and more usage.
- Eight unnamed value decisions appear in 31 places across six components; naming reduces
  the number of change sites by a factor of 3.88.
- The rule layer cannot live in any component file; it exists only as documentation, and
  when undocumented, each screen rediscovers it on its own.
- The system's measure is not its own completeness but its adoption in products; scope is
  limited to the decisions products actually repeat.

#### Next Step

This lesson defined what a system is and measured the area the library does not cover. But
the existence of an uncovered area does not prove that closing it pays off. Building a
system is a cost: defining tokens, writing components, maintaining documentation, and
migrating existing screens all take time. The next lesson computes that cost in terms of
screen count, team count, and change frequency; it finds the screen count at which the
systemless and systematic paths cross, and it also names the conditions under which a
system cannot be justified.

### Justifying the Need for a System

Source: https://academia.sh/en/courses/design-systems/justifying-the-need-for-a-system

Comparing the systematic and systemless paths in hours, computing the break-even screen count, sensitivity to each input, how team count multiplies deviation, and the usage threshold for a decision to enter the system.

The previous lesson measured that a component library covers only a quarter of an
interface's decisions. The existence of the uncovered three-quarters shows a gap; it does
not show that filling the gap pays off. Building a system is not free: defining the token
layer, writing components, maintaining documentation, and migrating existing screens all
demand measurable effort.

This lesson compares that effort against the effort it saves in return, in the same unit.
The question is not "is consistency good"; that consistency is good was already shown in
the Repetition and Consistency lesson. The question is: after how many screens does the
system pay for itself, which input moves that point the most, and under which conditions is
not building a system the right call.

#### Two Paths, One Unit

For the comparison, both paths are written in the same unit. On the **systemless path**,
every new screen makes from scratch the decisions that have no counterpart in a system; an
**inconsistency debt** also builds up over time, because the same decision gets answered
differently on different screens, and those deviations have to be found and reconciled
later. On the **systematic path**, a setup cost is paid up front; after that, most decisions
are referenced, a small share are genuinely new, and every screen adds a maintenance share
to the system.

The inputs come from an institution's own measurement. The numbers below are this catalog
institution's measurement; another institution would produce different numbers. What is
portable is the calculation itself.

```js
// justification.mjs — cost comparison of the systematic and systemless paths and the break-even point

// Inputs are in hours and come from this institution's own measurement; another
// institution would produce different numbers. What is portable is the calculation itself.
const G = {
  decisionNew: 0.7,          // cost of making a decision from scratch (options + review + implementation)
  decisionReference: 0.1,    // cost of referencing an existing decision
  decisionsPerScreen: 18,    // from What Is a Design System: 18 of 24 decisions sit in the value/pattern/rule layers
  newDecisionRatio: 0.15,    // share of decisions that are genuinely new on each screen, even on the systematic path
  setup: 90,                 // token layer + components + documentation + distribution
  maintenanceRatio: 0.08,    // maintenance cost fed back into the system per screen (hours)
  deviationProbability: 0.2, // probability that a decision is remade independently and deviates on a new screen
  deviationFix: 1.5,         // finding and reconciling a deviated value later (search + change + verification)
};

function systemless(screens, g = G) {
  const decisionCost = screens * g.decisionsPerScreen * g.decisionNew;
  // Expected number of distinct values per decision: 1 on the first screen, then each
  // further screen has probability p of introducing a new value.
  const distinctValues = 1 + (screens - 1) * g.deviationProbability;
  const debt = g.decisionsPerScreen * (distinctValues - 1) * g.deviationFix;
  return { decisionCost, debt, total: decisionCost + debt, distinctValues };
}

function systematic(screens, g = G) {
  const fresh = screens * g.decisionsPerScreen * g.newDecisionRatio * g.decisionNew;
  const reference = screens * g.decisionsPerScreen * (1 - g.newDecisionRatio) * g.decisionReference;
  const maintenance = screens * g.maintenanceRatio * g.decisionsPerScreen;
  return { setup: g.setup, fresh, reference, maintenance, total: g.setup + fresh + reference + maintenance };
}

console.log("screens  systemless decisions  inconsistency debt  systemless total  systematic total  diff");
for (const e of [1, 2, 3, 5, 8, 10, 15, 20, 30, 50]) {
  const a = systemless(e);
  const b = systematic(e);
  console.log(
    `${String(e).padStart(7)} ${a.decisionCost.toFixed(1).padStart(19)} ${a.debt.toFixed(1).padStart(20)} ` +
      `${a.total.toFixed(1).padStart(17)} ${b.total.toFixed(1).padStart(17)} ${(a.total - b.total).toFixed(1).padStart(6)}`
  );
}

function breakEven(g = G) {
  for (let e = 1; e <= 2000; e++) {
    if (systemless(e, g).total >= systematic(e, g).total) return e;
  }
  return null;
}
console.log(`\nbreak-even screen count (base inputs): ${breakEven()}`);

// Sensitivity: how much does each input move the break-even point?
console.log("\ninput                   value  break-even screen");
const trials = [
  ["setup", [45, 90, 180, 360]],
  ["deviationProbability", [0.05, 0.1, 0.2, 0.4]],
  ["decisionsPerScreen", [6, 12, 18, 30]],
  ["decisionNew", [0.35, 0.7, 1.4]],
];
for (const [key, values] of trials) {
  for (const d of values) {
    const g = { ...G, [key]: d };
    console.log(`${key.padEnd(23)} ${String(d).padStart(6)} ${String(breakEven(g)).padStart(17)}`);
  }
}

// Effect of team count on deviation probability: each additional team is an
// independent source of interpretation.
// q is the probability that a single team interprets a decision differently.
const q = 0.08;
console.log("\nteams  deviation prob  break-even screen  expected distinct values at 20 screens");
for (const n of [1, 2, 3, 4, 6, 8]) {
  const p = 1 - Math.pow(1 - q, n);
  const g = { ...G, deviationProbability: p };
  const distinct = 1 + (20 - 1) * p;
  console.log(
    `${String(n).padStart(5)} ${p.toFixed(3).padStart(15)} ${String(breakEven(g)).padStart(19)} ${distinct.toFixed(2).padStart(36)}`
  );
}

// When does a single decision pay off entering the system? For a decision used u times:
//   systemless = u * decisionNew,  systematic = definition + u * decisionReference
const DEFINITION = 1.5; // cost of naming, documenting, and publishing a decision
console.log("\nusage  systemless  systematic  pays off?");
for (const u of [1, 2, 3, 4, 6, 10]) {
  const a = u * G.decisionNew;
  const b = DEFINITION + u * G.decisionReference;
  console.log(`${String(u).padStart(5)} ${a.toFixed(2).padStart(11)} ${b.toFixed(2).padStart(11)}  ${a >= b ? "yes" : "no"}`);
}
console.log(`usage threshold: ${(DEFINITION / (G.decisionNew - G.decisionReference)).toFixed(2)} -> ${Math.ceil(DEFINITION / (G.decisionNew - G.decisionReference))} uses`);

// Retroactive fix: how much debt has accumulated by the time the system is built late?
console.log("\nscreen system is built at  debt accumulated by then (hours)  debt-to-setup ratio");
for (const e of [1, 5, 10, 20, 40]) {
  const debt = systemless(e).debt;
  console.log(`${String(e).padStart(26)} ${debt.toFixed(1).padStart(31)} ${(debt / G.setup).toFixed(2).padStart(19)}`);
}
```

```
screens  systemless decisions  inconsistency debt  systemless total  systematic total  diff
      1                12.6                  0.0              12.6              94.9  -82.3
      2                25.2                  5.4              30.6              99.7  -69.1
      3                37.8                 10.8              48.6             104.6  -56.0
      5                63.0                 21.6              84.6             114.3  -29.7
      8               100.8                 37.8             138.6             128.9    9.7
     10               126.0                 48.6             174.6             138.6   36.0
     15               189.0                 75.6             264.6             162.9  101.7
     20               252.0                102.6             354.6             187.2  167.4
     30               378.0                156.6             534.6             235.8  298.8
     50               630.0                264.6             894.6             333.0  561.6

break-even screen count (base inputs): 8

input                   value  break-even screen
setup                       45                 4
setup                       90                 8
setup                      180                15
setup                      360                28
deviationProbability      0.05                11
deviationProbability       0.1                 9
deviationProbability       0.2                 8
deviationProbability       0.4                 6
decisionsPerScreen           6                21
decisionsPerScreen          12                11
decisionsPerScreen          18                 8
decisionsPerScreen          30                 5
decisionNew               0.35                13
decisionNew                0.7                 8
decisionNew                1.4                 5

teams  deviation prob  break-even screen  expected distinct values at 20 screens
    1           0.080                  10                                 2.52
    2           0.154                   8                                 3.92
    3           0.221                   7                                 5.20
    4           0.284                   7                                 6.39
    6           0.394                   6                                 8.48
    8           0.487                   5                                10.25

usage  systemless  systematic  pays off?
    1        0.70        1.60  no
    2        1.40        1.70  no
    3        2.10        1.80  yes
    4        2.80        1.90  yes
    6        4.20        2.10  yes
   10        7.00        2.50  yes
usage threshold: 2.50 -> 3 uses

screen system is built at  debt accumulated by then (hours)  debt-to-setup ratio
                         1                             0.0                0.00
                         5                            21.6                0.24
                        10                            48.6                0.54
                        20                           102.6                1.14
                        40                           210.6                2.34
```

#### Break-Even Is the Eighth Screen

The first table puts the two curves side by side. The systematic path starts at 94.9 hours
on the first screen; the systemless path finishes at 12.6 hours. At the fifth screen the
difference still favors the systemless path: 84.6 against 114.3. At the eighth screen the
sign flips, and by the twentieth screen the systematic path is 167.4 hours ahead.

The source of the difference is the slopes. The systemless path's per-screen cost is 12.6
hours of decisions plus a growing debt; the systematic path's per-screen cost is 4.9 hours.
The setup cost is a one-time payment and stays fixed, so its share shrinks as the screen
count grows.

The inconsistency debt column also has to be read on its own. At fifty screens the debt
reaches 264.6 hours — close to three-quarters of the systematic path's **total** cost
(333.0). The debt line item is usually left out of the accounting because it is invisible:
no one keeps a log that says "I spent 3 hours fixing inconsistency this week." Being
invisible does not mean it does not exist.

#### Break-Even Is Most Sensitive to Setup Cost

The second table shows how much each input moves the result, and it says where the
discussion should focus.

**Setup cost** is the most decisive input: going from 45 hours to 360 hours moves the
break-even point from 4 to 28 screens — an eightfold change in the input produces a
sevenfold change in the result. This means the system's **scope** is the most important
decision. A system built small pays off early; a system that tries to cover everything gets
abandoned before it starts paying off.

**Decisions per screen** ranks second: going from 6 decisions to 30 decisions brings the
break-even point down from 21 to 5. This number is the interface's complexity, and it is
measured, not chosen. A low-decision interface — a single read-only screen, a single form —
benefits little from a system.

**Deviation probability** turns out to be less sensitive than expected: raised from 0.05 to
0.40, an eightfold increase, the break-even point drops only from 11 to 6. Fear of
inconsistency is the weakest way to justify a system; the real justification is the volume
of repeated decisions.

#### Team Count Multiplies Deviation

The third table treats a single input on its own: team count. Each additional team is an
independent source of interpretation. Taking the probability that a single team interprets
a decision differently as 8%, the combined deviation probability across eight teams climbs
to 48.7%.

The final column gives this a concrete counterpart: a single decision — say, card inner
padding — spreads across twenty screens into an average of 2.52 distinct values with one
team, and 10.25 distinct values with eight teams. There is no longer such a thing as "card
inner padding" at that institution; there are ten separate card inner paddings, and none of
them knows about the others.

The rule here is: **a system is built for decisions that cross team boundaries.** For
decisions a single team makes within a single product, the benefit a system provides is
already supplied by the team's own memory. The system's real job is not memory, it is
**shared** memory.

#### Not Every Decision Enters the System

The fourth table gives the arithmetic that ties scope to a decision. Naming, documenting,
and publishing a decision costs 1.5 hours; each use after that costs 0.1 hours of reference.
In the systemless case, each use costs 0.7 hours.

The usage threshold is the definition cost divided by the per-use saving: 1.5 / (0.7 − 0.1)
= 2.5, meaning **three times**. A decision used twice loses money if brought into the
system; used three times, it starts paying off.

This ties the scope principle from the previous lesson to a number. A layout that appears
once on one screen does not enter the system. What enters the system is a question asked
the same way in three separate places. The rule also carries a demand for patience: a
decision is not brought into the system the first time it is made, it is brought in the
third time it is encountered. Premature abstraction means freezing a decision that has not
yet been verified.

#### A System Built Late Inherits Debt

The final table answers the timing question. When the system is built at the fortieth
screen, the inconsistency debt accumulated by that point is 210.6 hours: 2.34 times the
setup cost. This debt is not erased automatically once the system is built; every deviated
value has to be found, a decision made about which one is correct, and the value changed.

At the twentieth screen the ratio is 1.14, at the tenth screen 0.54. Because the curve grows
linearly, the cost of delay is also linear; but unlike setup, paying down the debt **cannot
be parallelized**, because the context in which each deviation arose has to be understood.

This also reveals the conditions under which a system cannot be justified. A product whose
screen count will stay below the break-even point, a single-team and short-lived effort, or
a discovery phase where decisions are not yet verified: in all three cases, building a
system is a cost that is never recovered. In such a case the right call is not to skip the
system, but to **defer it and keep an inventory** — logging how many times each decision
repeats makes naming it cheap once the third repetition arrives.

#### Summary

- The system decision is a cost comparison: setup and maintenance on one side, remaking
  decisions and inconsistency debt on the other; both can be written in hours.
- With the measured inputs, the break-even point is the eighth screen; before it the
  systemless path is cheaper, after it the systematic path is.
- The break-even point is most sensitive to setup cost; this shows that scope is the most
  important decision and that a narrowly built system pays off early.
- Deviation probability is less sensitive than expected; what justifies a system is not
  fear of inconsistency but the volume of repeated decisions.
- Team count multiplies deviation: the same decision spreads across twenty screens into an
  average of 10.25 distinct values with eight teams; a system is built for decisions that
  cross team boundaries.
- A decision earns its place in the system once it has been used as many times as the
  definition cost divided by the per-use saving — here, three times; a delayed setup
  inherits the accumulated debt.

#### Next Step

When the calculation says a system should be built, the first task is drawing the scope, and
scope cannot be drawn by estimation. Which decision repeats how many times, how many
distinct forms a value is used in, and how many of those values are genuinely separate
decisions — all of that can only be known by counting the existing interface. The next
lesson performs that count: it extracts color, spacing, font-size, and corner-radius values
from the catalog products' style source, clusters near-duplicates by threshold to find the
true decision count, and shows which share of the inventory is unearned variety.

### Visual Audit

Source: https://academia.sh/en/courses/design-systems/visual-audit

Machine extraction of a value inventory from existing interfaces' style source, threshold-based clustering of near-duplicates, how the linking rule changes the count, and a per-product deviation map.

The previous lesson computed when a system pays for itself and showed that scope is the
most decisive decision. Scope says which decisions enter the system; knowing that requires
knowing which decisions are actually repeated. A scope drawn by estimation either produces
unused tokens or leaves out the most frequently used decision.

A **visual audit** is the counting of values that existing interfaces actually use. The
audit's distinguishing feature is that its source is not memory but the **artifact**: no
one is asked "how many pixels is the card's inner padding," it is read from the style
source. This lesson builds the calculation that performs that reading and separates which
share of the inventory is a genuine decision and which share is unearned variety.

#### The Audit Is Read from the Artifact

The catalog institution has four products: the catalog interface, the admin panel, the
member profile, and the shelf layout screen. The four were written at different times,
partly by different teams. The Repetition and Consistency lesson counted the catalog
interface's four screens by hand; here the count expands to all four products and is done
not by hand but by parsing the source.

The difference between counting by hand and counting by machine is not only speed. A hand
count finds the values that catch the counter's eye; a machine count also finds the
declarations no one has looked at. This is exactly where the audit's value lies: what it is
looking for is not the inconsistency that is already known, but the inconsistency that is
not.

The count runs over five property classes: spacing, font size, corner radius, border
thickness, and color. For the first four, the closeness criterion is pixel difference, and
the thresholds are the distinguishability thresholds from the Repetition and Consistency
lesson. For color the criterion has two parts: two colors are counted as carrying the same
decision only if their contrast ratio against each other is below 1.10 and their largest
channel difference is below 16. Contrast ratio alone is not enough, because two colors of
different hue can share the same relative luminance.

```js
// audit.mjs — extracting an inventory from four catalog products' style source and clustering near-duplicates

// Declarations taken from the four products' style source. Each line is prefixed with a
// product code: C catalog, A admin panel, M member profile, S shelf layout screen.
const SOURCE = `
C .search-field { padding: 8px 12px; font-size: 14px; border-radius: 4px; border: 1px solid #b7bcc2; color: #212327; }
C .search-button { padding: 8px 16px; font-size: 14px; border-radius: 4px; background: #275ea5; color: #ffffff; }
C .record-card { padding: 16px; border-radius: 8px; border: 1px solid #d3d6d9; background: #ffffff; }
C .record-title { font-size: 20px; color: #212327; margin-bottom: 4px; }
C .record-author { font-size: 16px; color: #5e656e; margin-bottom: 8px; }
C .record-metadata { font-size: 13px; color: #757e8a; }
C .record-tag { padding: 2px 8px; font-size: 12px; border-radius: 12px; background: #dee9f7; color: #15335b; }
C .results-list { gap: 24px; padding: 32px 24px; }
C .pagination { gap: 8px; font-size: 14px; padding: 24px 0; }
C .confirm-dialog { padding: 24px; border-radius: 8px; background: #ffffff; border: 1px solid #d3d6d9; }
C .confirm-warning { font-size: 14px; color: #ab3321; margin-top: 8px; }
A .panel-heading { font-size: 24px; color: #1f2226; margin-bottom: 16px; }
A .panel-card { padding: 14px; border-radius: 6px; border: 1px solid #d5d8db; background: #fdfdfd; }
A .panel-table { font-size: 13px; color: #333333; }
A .panel-table-heading { font-size: 12px; color: #6b7280; padding: 6px 10px; }
A .panel-row { padding: 10px 12px; border-bottom: 1px solid #e9eaec; }
A .panel-button { padding: 6px 14px; font-size: 13px; border-radius: 3px; background: #2a5fa8; color: #ffffff; }
A .panel-secondary-button { padding: 6px 14px; font-size: 13px; border-radius: 3px; border: 1px solid #b7bcc2; color: #275ea5; }
A .panel-warning { font-size: 12px; color: #a83224; padding: 10px; border-radius: 3px; }
A .panel-section { gap: 20px; padding: 20px; }
M .profile-heading { font-size: 22px; color: #212327; margin-bottom: 12px; }
M .profile-card { padding: 20px; border-radius: 8px; border: 1px solid #d3d6d9; background: #ffffff; }
M .borrow-row { padding: 12px 16px; font-size: 15px; color: #22252a; border-bottom: 1px solid #e9eaec; }
M .borrow-date { font-size: 13px; color: #5e656e; }
M .overdue-warning { font-size: 13px; color: #ab3321; padding: 8px 12px; border-radius: 4px; background: #fbeae7; }
M .profile-button { padding: 10px 18px; font-size: 15px; border-radius: 6px; background: #275ea5; color: #ffffff; }
M .profile-section { gap: 16px; padding: 28px 24px; }
S .shelf-heading { font-size: 18px; color: #212327; margin-bottom: 8px; }
S .shelf-cell { padding: 12px; border-radius: 4px; border: 2px solid #b7bcc2; }
S .shelf-code { font-size: 12px; color: #757e8a; }
S .shelf-occupied { background: #e9eaec; border: 2px solid #969da6; }
S .shelf-selected { border: 2px solid #3075cf; background: #dee9f7; }
S .shelf-grid { gap: 6px; padding: 18px; }
S .shelf-warning { font-size: 13px; color: #b03526; padding: 8px; border-radius: 4px; }
`;

// --- extraction ----------------------------------------------------------------
const LENGTH_PROPERTY = {
  spacing: /^(padding|margin-top|margin-bottom|gap)$/,
  "font size": /^font-size$/,
  "corner radius": /^border-radius$/,
};
const CLASSES = ["spacing", "font size", "corner radius", "border thickness", "color"];
const inventory = Object.fromEntries(CLASSES.map((s) => [s, []]));

for (const line of SOURCE.trim().split("\n")) {
  const product = line[0];
  const body = line.slice(line.indexOf("{") + 1, line.lastIndexOf("}"));
  for (const declaration of body.split(";")) {
    const i = declaration.indexOf(":");
    if (i < 0) continue;
    const property = declaration.slice(0, i).trim();
    const value = declaration.slice(i + 1).trim();

    for (const [cls, pattern] of Object.entries(LENGTH_PROPERTY)) {
      if (!pattern.test(property)) continue;
      for (const p of value.split(/\s+/)) {
        const m = p.match(/^(\d+(?:\.\d+)?)px$/);
        if (m) inventory[cls].push({ value: Number(m[1]), product });
      }
    }
    if (/^border(-bottom)?$/.test(property)) {
      const m = value.match(/^(\d+(?:\.\d+)?)px/);
      if (m) inventory["border thickness"].push({ value: Number(m[1]), product });
    }
    for (const m of value.matchAll(/#([0-9a-f]{6})/g)) {
      inventory.color.push({ value: "#" + m[1], product });
    }
  }
}

// --- closeness criteria ---------------------------------------------------------
const THRESHOLD = { spacing: 2, "font size": 1, "corner radius": 2, "border thickness": 0.5 };
const toChannels = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}
const channelDiff = (a, b) => Math.max(...a.map((v, i) => Math.abs(v - b[i])));

// Do two values carry the same decision? For color the criterion is both contrast
// ratio and channel difference together.
function close(cls, a, b) {
  if (cls !== "color") return Math.abs(a - b) <= THRESHOLD[cls];
  return contrast(toChannels(a), toChannels(b)) < 1.1 && channelDiff(toChannels(a), toChannels(b)) < 16;
}
function sortValues(cls, values) {
  const unique = [...new Set(values)];
  return cls === "color"
    ? unique.sort((a, b) => luminance(toChannels(b)) - luminance(toChannels(a)))
    : unique.sort((a, b) => a - b);
}

// Chain linking: finding a single close member is enough to join a cluster.
function chainLink(cls, values) {
  const clusters = [];
  for (const d of sortValues(cls, values)) {
    const k = clusters.find((k) => k.some((o) => close(cls, d, o)));
    if (k) k.push(d);
    else clusters.push([d]);
  }
  return clusters;
}

// Diameter-bounded linking: joining a cluster requires being close to EVERY member in it.
function diameterBound(cls, values) {
  const clusters = [];
  for (const d of sortValues(cls, values)) {
    const k = clusters.find((k) => k.every((o) => close(cls, d, o)));
    if (k) k.push(d);
    else clusters.push([d]);
  }
  return clusters;
}

console.log("property           usage  distinct values  chain-link clusters  diameter-bound clusters");
let totalUsage = 0;
let totalDistinct = 0;
let totalChain = 0;
let totalDiameter = 0;
const diameterClusters = {};
for (const cls of CLASSES) {
  const values = inventory[cls].map((k) => k.value);
  const z = chainLink(cls, values);
  const c = diameterBound(cls, values);
  diameterClusters[cls] = c;
  const distinct = new Set(values).size;
  totalUsage += values.length;
  totalDistinct += distinct;
  totalChain += z.length;
  totalDiameter += c.length;
  console.log(
    `${cls.padEnd(18)} ${String(values.length).padStart(6)} ${String(distinct).padStart(16)} ` +
      `${String(z.length).padStart(21)} ${String(c.length).padStart(24)}`
  );
}
console.log(
  `${"total".padEnd(18)} ${String(totalUsage).padStart(6)} ${String(totalDistinct).padStart(16)} ` +
    `${String(totalChain).padStart(21)} ${String(totalDiameter).padStart(24)}`
);
console.log(
  `unearned variety (diameter-bound): ${totalDistinct - totalDiameter} values, ` +
    `${((100 * (totalDistinct - totalDiameter)) / totalDistinct).toFixed(1)}%`
);

console.log("\ndiameter-bound clusters (same decision, different spelling)");
for (const cls of CLASSES) {
  for (const k of diameterClusters[cls]) {
    if (k.length > 1) console.log(`  ${cls.padEnd(18)} ${k.join(", ")}`);
  }
}

// --- per-product deviation -------------------------------------------------------
// A cluster's most-used member is the "canonical spelling"; the rest count as deviation.
const count = {};
for (const cls of CLASSES) {
  for (const record of inventory[cls]) {
    const a = `${cls}:${record.value}`;
    count[a] = (count[a] || 0) + 1;
  }
}
const canonical = new Map();
for (const cls of CLASSES) {
  for (const k of diameterClusters[cls]) {
    const top = k.reduce((a, b) => ((count[`${cls}:${b}`] || 0) > (count[`${cls}:${a}`] || 0) ? b : a));
    for (const member of k) canonical.set(`${cls}:${member}`, top);
  }
}
console.log("\nproduct  usage  deviates from canonical  deviation rate");
for (const product of ["C", "A", "M", "S"]) {
  let total = 0;
  let deviating = 0;
  for (const cls of CLASSES) {
    for (const record of inventory[cls].filter((k) => k.product === product)) {
      total++;
      if (canonical.get(`${cls}:${record.value}`) !== record.value) deviating++;
    }
  }
  console.log(
    `${product.padStart(7)} ${String(total).padStart(6)} ${String(deviating).padStart(24)} ${((100 * deviating) / total).toFixed(1).padStart(14)}%`
  );
}
```

```
property           usage  distinct values  chain-link clusters  diameter-bound clusters
spacing                45               13                     4                        8
font size              22                9                     5                        7
corner radius          14                5                     2                        3
border thickness       11                2                     2                        2
color                  43               23                    15                       15
total                 135               52                    28                       35
unearned variety (diameter-bound): 17 values, 32.7%

diameter-bound clusters (same decision, different spelling)
  spacing            2, 4
  spacing            6, 8
  spacing            10, 12
  spacing            14, 16
  spacing            18, 20
  font size          12, 13
  font size          14, 15
  corner radius      3, 4
  corner radius      6, 8
  color              #ffffff, #fdfdfd
  color              #e9eaec, #dee9f7
  color              #d5d8db, #d3d6d9
  color              #b03526, #ab3321, #a83224
  color              #2a5fa8, #275ea5
  color              #22252a, #212327, #1f2226

product  usage  deviates from canonical  deviation rate
      C     46                        3            6.5%
      A     37                       20           54.1%
      M     31                        6           19.4%
      S     21                        5           23.8%
```

#### The Linking Rule Determines the Count

The first table's two cluster columns produce two different results from the same data: 28
and 35. The difference looks small, but it concentrates in a single row. For the spacing
class, chain linking finds 4 clusters, diameter-bounded linking finds 8.

The reason is that **chain linking** only requires finding a single close member to join a
cluster. Because the spacing values progress two at a time — 2, 4, 6, 8, 10, 12, 14, 16, 18,
20 — every value is close to the one before it, and the chain never breaks. As a result, 2
and 20 end up in the same cluster, even though a 2-pixel gap and a 20-pixel gap are not the
same decision.

**Diameter-bounded linking** requires being close to every member of a cluster to join it.
This bounds the cluster's width by the threshold and breaks the chain. In the same data, 2
and 4 stay in one cluster, and 6 opens a new one.

This distinction directly affects the audit report's reliability. Chain linking's count
overstates the near-duplicate problem: "thirteen spacing values are really four decisions"
is the audit's most striking sentence and its most wrong one. The correct count is eight,
and unearned variety totals 17 of 52 values — 32.7%.

#### Clustering Produces a Recommendation, Not a Decision

The second block shows what the clusters contain, and this is where the criterion's limit
becomes visible.

Most of the color clusters are correct: `#22252a`, `#212327`, and `#1f2226` are three
separate dark text colors from three products, and they cannot be told apart. The trio
`#b03526`, `#ab3321`, `#a83224` is, in the same way, three separate error reds. `#2a5fa8`
and `#275ea5` are two separate primary-action blues. All of these should have been a single
decision, and each was made three times.

One cluster, though, is wrong: `#e9eaec` and `#dee9f7`. The first is a neutral gray, the
second a pale blue fill. Because their relative luminance is close and their channel
difference stays under the threshold, the criterion counts them as the same; but the two do
different jobs — one is the fill of an occupied shelf cell, the other the highlight of a
selected record. The criterion cannot know this, because it looks only at the value, not at
the role the value carries.

The rule that follows from this holds for the entire audit: **clustering is a list of
recommendations, not a list of decisions.** Every cluster is read by a human and asked two
questions: do these values do the same job, and would merging them lose a distinction. A
cluster answered no to the first question is split; a cluster answered yes to the second is
recorded as is.

#### Deviation Concentrates in One Place

The third table is the audit's most directly actionable output. Each cluster's most-used
member is taken as the **canonical spelling**; the other members count as deviation.

The result does not distribute evenly. Of the catalog interface's 46 uses, only 3 are
deviation: 6.5%. Of the admin panel's 37 uses, 20 are deviation: 54.1%. The member profile
is at 19.4%, the shelf screen at 23.8%.

This distribution gives the scope and sequencing decision together. The admin panel alone
carries the largest share of total deviation; fixing it first in the migration to the
system yields more gain than any of the other three products. It also opens a question: why
is the admin panel so far off? The answer is usually either that it was written by a
separate team or at a different time — in either case, the fix does not end with changing
values, it requires understanding why that team's or that period's decisions diverged.

The catalog interface's low deviation rate also needs a separate reading. The canonical
spellings are, to a large extent, already its own values, because the most usage is there.
This is where the assumption "the most-used value is correct" quietly enters the audit. The
assumption is not always true; the most-used value is the value of the product with the
most screens written — a result of its volume, not its quality.

#### What the Audit Delivers

A visual audit produces three documents, and all three feed the next steps.

**The value inventory**: the values used for each property class, along with their usage
counts. These counts determine which value is kept when the scale is built.

**The cluster report**: the near-duplicate clusters and the human decision made for each
one — merge, split, or record as is.

**The deviation map**: how far each product is from the canonical spelling. This map gives
the migration order and the cost estimate.

What the audit does not deliver is the scale itself. The inventory says what is used, not
what should be used. The transition between the two is a separate decision, and it can be
made in two different ways.

#### Summary

- A visual audit is the machine counting of an interface's actually used values from its
  style source; its source is the artifact, not memory, and what it looks for is unknown
  inconsistency.
- The near-duplicate criterion varies by class: pixel difference for lengths, and both
  contrast ratio and the largest channel difference together for color.
- The linking rule determines the result: chain linking collapses 13 spacing values into 4
  clusters and overstates near-duplication; diameter-bounded linking finds 8 clusters.
- Across the four measured products, 17 of 52 distinct values are unearned variety; this
  32.7% share can be closed at no cost.
- Clustering produces recommendations; every cluster is read together with its role,
  because the criterion looks at the value, not at the value's job.
- The deviation map gives the migration order: if one of the four products carries the
  largest share of total deviation, the migration starts there.

#### Next Step

Once the inventory is in hand, two paths open up. The first path builds the scale from
principles and fits the existing values onto it; the second path declares the most-used
existing values a scale and continues from there. The two paths differ in both migration
cost and the quality of the scale they produce, and both are computable. The next lesson
applies these two strategies to the same inventory; it compares how many values will change
visibly, how many components will need touching, and whether the derived scales pass the
criteria from the Spacing Scale lesson.

### Building from Scratch and from Existing

Source: https://academia.sh/en/courses/design-systems/building-from-scratch-and-from-existing

Comparing the from-scratch and from-existing strategies on the same inventory for migration cost and scale quality, building the hybrid path with an exception log, and freeze-based phased migration.

The visual audit delivered the inventory: 52 distinct values across four products, 17 of
them unearned variety. The inventory says what is used; it does not say what should be used.
This lesson builds the transition between the two and shows that it can be made in two
different ways.

**Building from scratch** derives the scale independently of the inventory, from the
criteria in the Spacing Scale and Typographic Scale lessons; it then fits the existing
values onto that scale. **Building from existing** declares the most-used values a scale;
the scale is a summary of the inventory. The two paths produce different migration costs
and different scale quality, and these two quantities are independent of each other.

#### Two Strategies, One Inventory

The comparison is made across three numeric property classes: spacing, font size, and
corner radius. Color is treated separately, because color's scale is not a sequence of
numbers but a role table, and its migration is measured by a matching check, not by drift.

Each strategy is evaluated with two sets of criteria. **Scale quality**: the number of
steps, the number of neighbor pairs that fall below the distinguishability threshold, and
the number of neighbor pairs whose ratio reaches 2 or more, the requirement for grouping.
**Migration cost**: how many uses have their value shifted, how many of those shifts exceed
the distinguishability threshold, and what the largest shift is.

```js
// strategy.mjs — comparing two starting strategies on the same inventory

// Inventory extracted in the Visual Audit lesson: value -> usage count.
const INVENTORY = {
  spacing: { 2: 1, 4: 1, 6: 4, 8: 9, 10: 4, 12: 6, 14: 3, 16: 5, 18: 2, 20: 3, 24: 5, 28: 1, 32: 1 },
  "font size": { 12: 4, 13: 7, 14: 4, 15: 2, 16: 1, 18: 1, 20: 1, 22: 1, 24: 1 },
  "corner radius": { 3: 3, 4: 5, 6: 2, 8: 3, 12: 1 },
};
const THRESHOLD = { spacing: 2, "font size": 1, "corner radius": 2 };

// Strategy A: the scale is built from principles. The spacing scale is a hybrid scale
// (Spacing Scale lesson); font size is a base-16 scale with a 1.25 ratio (Typographic Scale lesson).
const FROM_SCRATCH = {
  spacing: [4, 8, 12, 16, 24, 32, 48, 64, 96],
  "font size": [-1, 0, 1, 2, 3].map((n) => Math.round(16 * Math.pow(1.25, n))),
  "corner radius": [4, 8],
};

// Strategy B: the scale is derived from existing values. The most-used values are
// selected until they cover a set share of usage.
const COVERAGE_TARGET = 0.8;
function fromExisting(distribution, target) {
  const total = Object.values(distribution).reduce((t, v) => t + v, 0);
  const sorted = Object.entries(distribution)
    .map(([d, n]) => [Number(d), n])
    .sort((a, b) => b[1] - a[1] || a[0] - b[0]);
  const selected = [];
  let accumulated = 0;
  for (const [d, n] of sorted) {
    if (accumulated / total >= target) break;
    selected.push(d);
    accumulated += n;
  }
  return selected.sort((a, b) => a - b);
}
const FROM_EXISTING = Object.fromEntries(
  Object.entries(INVENTORY).map(([s, d]) => [s, fromExisting(d, COVERAGE_TARGET)])
);

// --- criteria calculations ----------------------------------------------------
const nearest = (d, scale) => scale.reduce((a, b) => (Math.abs(b - d) < Math.abs(a - d) ? b : a));

function scaleQuality(scale, threshold) {
  let indistinguishable = 0;
  let grouping = 0;
  for (let i = 1; i < scale.length; i++) {
    if (scale[i] - scale[i - 1] <= threshold) indistinguishable++;
    if (scale[i] / scale[i - 1] >= 2) grouping++;
  }
  return { steps: scale.length, indistinguishable, grouping };
}

function migrationCost(distribution, scale, threshold) {
  let usage = 0;
  let shifted = 0;
  let visible = 0;
  let largest = 0;
  let totalShift = 0;
  for (const [d, n] of Object.entries(distribution)) {
    const value = Number(d);
    const target = nearest(value, scale);
    const shift = Math.abs(target - value);
    usage += n;
    if (shift > 0) shifted += n;
    if (shift > threshold) visible += n;
    totalShift += shift * n;
    largest = Math.max(largest, shift);
  }
  return { usage, shifted, visible, largest, avgShift: totalShift / usage };
}

for (const [name, scales] of [["A from scratch", FROM_SCRATCH], ["B from existing", FROM_EXISTING]]) {
  console.log(`--- ${name} ---`);
  console.log("property         scale                          steps  indistinguishable  grouping");
  for (const cls of Object.keys(INVENTORY)) {
    const k = scaleQuality(scales[cls], THRESHOLD[cls]);
    console.log(
      `${cls.padEnd(16)} ${scales[cls].join(",").padEnd(30)} ${String(k.steps).padStart(5)} ` +
        `${String(k.indistinguishable).padStart(18)} ${String(k.grouping).padStart(10)}`
    );
  }
  console.log("property         usage  shifted  visible shift  largest shift  avg shift");
  let totalUsage = 0;
  let totalShifted = 0;
  let totalVisible = 0;
  for (const cls of Object.keys(INVENTORY)) {
    const g = migrationCost(INVENTORY[cls], scales[cls], THRESHOLD[cls]);
    totalUsage += g.usage;
    totalShifted += g.shifted;
    totalVisible += g.visible;
    console.log(
      `${cls.padEnd(16)} ${String(g.usage).padStart(6)} ${String(g.shifted).padStart(8)} ` +
        `${String(g.visible).padStart(15)} ${String(g.largest).padStart(15)} ${g.avgShift.toFixed(2).padStart(10)}`
    );
  }
  console.log(
    `${"total".padEnd(16)} ${String(totalUsage).padStart(6)} ${String(totalShifted).padStart(8)} ${String(totalVisible).padStart(15)}` +
      `   shifted rate ${((100 * totalShifted) / totalUsage).toFixed(1)}%  visible rate ${((100 * totalVisible) / totalUsage).toFixed(1)}%\n`
  );
}

// --- hybrid path: from-scratch scale + a logged exception ---------------------
// A raw value that does not visibly shift onto the scale is snapped to it; a value that
// does shift either gets a step added to the scale or is snapped anyway. The criterion for
// adding a step is the usage count.
const EXCEPTION_THRESHOLD = 3;
console.log("--- hybrid path: from-scratch scale, decision for visible shifts ---");
console.log("property         raw value  usage  target  shift  decision");
const added = {};
for (const cls of Object.keys(INVENTORY)) {
  added[cls] = [];
  for (const [d, n] of Object.entries(INVENTORY[cls])) {
    const value = Number(d);
    const target = nearest(value, FROM_SCRATCH[cls]);
    const shift = Math.abs(target - value);
    if (shift <= THRESHOLD[cls]) continue;
    const decision = n >= EXCEPTION_THRESHOLD ? "add a step to the scale" : "snap the value";
    if (n >= EXCEPTION_THRESHOLD) added[cls].push(value);
    console.log(
      `${cls.padEnd(16)} ${String(value).padStart(9)} ${String(n).padStart(6)} ${String(target).padStart(6)} ` +
        `${String(shift).padStart(5)}  ${decision}`
    );
  }
}
console.log("\nproperty         expanded scale                  steps  indistinguishable  visible remaining");
for (const cls of Object.keys(INVENTORY)) {
  const scale = [...new Set([...FROM_SCRATCH[cls], ...added[cls]])].sort((a, b) => a - b);
  const k = scaleQuality(scale, THRESHOLD[cls]);
  const g = migrationCost(INVENTORY[cls], scale, THRESHOLD[cls]);
  console.log(
    `${cls.padEnd(16)} ${scale.join(",").padEnd(32)} ${String(k.steps).padStart(5)} ` +
      `${String(k.indistinguishable).padStart(18)} ${String(g.visible).padStart(17)}`
  );
}

// --- phased migration: coverage after a freeze ---------------------------------
// Freeze: new declarations may come only from the scale. Old declarations pass only when
// that file is touched for another task. r is the share of old declarations touched per period.
console.log("\nperiod  r=0.10  r=0.20  r=0.35   (share of declarations matching the scale)");
const initial = 0.6; // share of declarations already matching the scale at the moment of the freeze
for (const period of [0, 1, 2, 3, 4, 6, 8]) {
  const row = [0.1, 0.2, 0.35].map((r) => (1 - (1 - initial) * Math.pow(1 - r, period)).toFixed(3));
  console.log(`${String(period).padStart(6)} ${row[0].padStart(7)} ${row[1].padStart(7)} ${row[2].padStart(7)}`);
}
```

```
--- A from scratch ---
property         scale                          steps  indistinguishable  grouping
spacing          4,8,12,16,24,32,48,64,96           9                  0          1
font size        13,16,20,25,31                     5                  0          0
corner radius    4,8                                2                  0          1
property         usage  shifted  visible shift  largest shift  avg shift
spacing              45       18               4               4       0.98
font size            22       13               2               2       0.68
corner radius        14        6               1               4       0.79
total                81       37               7   shifted rate 45.7%  visible rate 8.6%

--- B from existing ---
property         scale                          steps  indistinguishable  grouping
spacing          6,8,10,12,14,16,24                 7                  5          0
font size        12,13,14,15,16                     5                  4          0
corner radius    3,4,6,8                            4                  3          0
property         usage  shifted  visible shift  largest shift  avg shift
spacing              45        9               6               8       0.76
font size            22        4               4               8       0.91
corner radius        14        1               1               4       0.29
total                81       14              11   shifted rate 17.3%  visible rate 13.6%

--- hybrid path: from-scratch scale, decision for visible shifts ---
property         raw value  usage  target  shift  decision
spacing                 20      3     16     4  add a step to the scale
spacing                 28      1     24     4  snap the value
font size               18      1     16     2  snap the value
font size               22      1     20     2  snap the value
corner radius           12      1      8     4  snap the value

property         expanded scale                  steps  indistinguishable  visible remaining
spacing          4,8,12,16,20,24,32,48,64,96         10                  0                 1
font size        13,16,20,25,31                       5                  0                 2
corner radius    4,8                                  2                  0                 1

period  r=0.10  r=0.20  r=0.35   (share of declarations matching the scale)
     0   0.600   0.600   0.600
     1   0.640   0.680   0.740
     2   0.676   0.744   0.831
     3   0.708   0.795   0.890
     4   0.738   0.836   0.929
     6   0.787   0.895   0.970
     8   0.828   0.933   0.987
```

#### Shifted-Value Count Is the Wrong Metric

The two strategies' total rows point the expected way at first glance: 45.7% of uses shift
when built from scratch, only 17.3% when built from existing. It is not surprising that a
scale summarizing the inventory sits closer to the inventory.

The next column reverses that reading. The share of uses that change visibly is 8.6% when
built from scratch, 13.6% when built from existing. That is, the scale derived from the
inventory shifts fewer values, but it shifts **a larger share of them visibly**.

The reason lies in the tail of the distribution. Building from existing selects the
most-used values; because 80% of usage concentrates in small values, the scale gets
squeezed between 6 and 24. Large values — 28- and 32-pixel section gaps — fall outside the
scale and get pulled to 24; 20 pixels drops to 16 as well. Because small values shift almost
not at all, the total shifted count looks low, but every shift that does happen is a large
one: the largest shift is 8 pixels.

In the scale built from scratch, the situation is reversed. A large number of values shift
1–2 pixels — these fall below the distinguishability threshold and close at no visible
cost. Only seven uses actually change.

The metric that follows is this: **migration cost is measured not by the number of shifted
values but by the number of shifts that exceed the threshold.** This distinction is a
consequence of the finding in the Repetition and Consistency lesson: most inconsistency is
invisible, and invisible inconsistency is fixed at no cost.

#### A Summary of the Inventory Is Not a Scale

The scale quality tables show a second difference, and this difference is not open to
debate.

In the three scales built from scratch, the number of neighbor pairs falling below the
distinguishability threshold is zero. In the scales built from existing, that same count is
5, 4, and 3, respectively — 12 in total. The font-size scale runs 12, 13, 14, 15, 16 — none
of the five steps can be told apart from the next. A scale like this does not reduce the
decision count, it only documents the indecision that was already there.

The grouping column gives the same result a second time. The from-scratch spacing scale has
one neighbor pair with a ratio of 2 or more; the from-existing scale has none. The rule in
the Proximity and Grouping lesson required a group boundary to be at least twice the
group's inner spacing; with the from-existing scale, that rule cannot be expressed through
neighboring steps.

The conclusion is this: the inventory's most-used values do not form a scale. A scale's job
is to narrow options; selecting the most-used values legitimizes the most-often-made
indecisive decisions.

#### The Hybrid Path Keeps the Scale, Logs the Exception

The third block builds the path between the two strategies. The scale is built from
scratch; then every raw value that shifts visibly is handled one at a time, and one of two
decisions is made.

The criterion is the usage count. In the Justifying the Need for a System lesson, the
minimum usage count required for a decision to enter the system was computed as three; that
same threshold is used here as the criterion for adding a step to the scale. A raw value
used three or more times shows that the scale is missing something; a value used once or
twice gets snapped onto the scale.

In the table, only one value crosses the threshold: the 20-pixel spacing, used three times.
It is added to the scale, and the spacing scale becomes 4, 8, 12, 16, 20, 24, 32, 48, 64,
96. In the expanded scale, the number of indistinguishable neighbor pairs is still zero —
the addition did not break the scale — and the remaining visible usage in spacing dropped
from 4 to 1.

The other four values — the 28-pixel spacing, the 12-pixel corner radius, and the 18- and 22-pixel font
size — get snapped onto the scale, each with a single use. These decisions go into the **exception log**
from the Repetition and Consistency lesson: which value was snapped to what, and which screen was
affected, is written down. If no log is kept, the same value comes back six months later and no one knows
why it was removed.

#### Freezing Spreads the Migration Over Time

The final table measures how the migration is carried out. Changing every declaration at
once is the fastest path and the riskiest one: the entire change ships at the same time, and
a single mistake affects every screen at once.

The alternative is a **freeze**: after a set point in time, every newly written declaration
must come from the scale; old declarations pass only when that file is touched for another
task. If 60% of declarations already match the scale at the moment of the freeze, the
coverage rate then rises depending on what share of old declarations gets touched each
period.

The table compares three speeds. If 10% of old declarations are touched per period,
coverage stays at 0.828 after eight periods; if 35% are touched, it reaches 0.987 in the
same span. At the middle speed of 20%, it is 0.933 after eight periods.

Two conclusions follow. First, freezing is sufficient on its own, but far too slow for a
slow-moving product; products with a low touch rate need a planned migration on top of it.
Second, coverage never reaches a full 1 — exponential approach always leaves the last few
percent behind. This remainder means the migration has to be finished by hand at some
point.

#### Which Strategy, and When

The decision looks at three inputs. If the product is new or the existing base is small,
building from scratch is the only reasonable path; there is no migration cost to begin
with, and the scale quality is taken in full. If the existing base is large and tolerance
for visible change is low — a contractually fixed appearance, a heavily used internal tool —
the hybrid path is chosen: the scale is built from scratch, and a step is added for raw
values whose usage crosses the threshold.

Building from existing, then, survives not as a scale-building strategy but as a **reading
of the inventory**. The most-used values do not give the scale; they give the region where
the scale should concentrate. The answer to why a scale built from scratch should be finer
at the low end and coarser at the high end lies in that distribution.

#### Summary

- On the same inventory, building from scratch shifts 45.7% of uses but changes only 8.6%
  of them visibly; building from existing shifts 17.3% and changes 13.6% visibly.
- Migration cost is measured by the number of shifts exceeding the threshold, not by the
  number of shifted values; a scale derived from the inventory preserves small values and
  cuts off large ones.
- The inventory's most-used values do not form a scale: across the three from-existing
  scales, the number of indistinguishable neighbor pairs is 12, and the number of pairs
  meeting the grouping ratio is zero.
- The hybrid path builds the scale from scratch and sorts visibly shifting raw values by
  usage count; a value used three or more times gets a step added to the scale, fewer uses
  get snapped onto it and logged as an exception.
- Freezing spreads the migration over time; coverage approaches exponentially and never
  reaches a full 1, so the remainder has to be closed with a planned effort.
- Building from scratch is chosen for a new or small-base product, the hybrid path for a
  large base closed to change; building from existing is not a scale strategy but a
  reading tool.

#### Next Step

Once the scales are built, the system's numeric skeleton is ready, but the skeleton does
not say what should look right. Two separate interfaces can be built with the same scale,
and neither may carry the institution's voice. What determines a decision that cannot be
tied to a number is a separate question: whether a button is filled or outlined, whether an
error message apologizes or instructs, whether the interface speaks quietly or encourages.
The next lesson builds the foundation for those decisions; it computes, on a matrix, which
conditions make a principle force a decision, which principle sits idle, and how a
tone-of-voice contract gets checked.

### Design Language

Source: https://academia.sh/en/courses/design-systems/design-language

Testing design principles with a distinctiveness check, deriving idle principles and unsupported decisions from a principle-decision matrix, reading principle conflicts, and checking a tone-of-voice contract.

The previous four lessons built the system's numeric skeleton: scope, justification,
inventory, and scale. The skeleton says what can be used; it does not say which option is
right. Two separate interfaces can be built with the same spacing scale; one dense and
quiet, the other airy and directive. Both conform to the scale.

**Design language** is the set of decision grounds that fills the gap the scales leave
behind: principles, tone of voice, and brand expression. This lesson's question is whether
these grounds are checkable. A principle is a principle only if it can reject an option; a
tone of voice is a contract only if a violation of it can be pointed to.

#### A Principle Is a Sentence Whose Opposite Is Defensible

A design principle's job is to force a decision. The criterion for forcing a decision is
this: the principle's opposite must also be a defensible position. "We care about the
user" is not a principle, because no one defends its opposite; it rules out no option and
therefore settles no argument. "Record first, tools second" is a principle, because its
opposite — the interface showing its own tools first — is a position genuinely defended
somewhere.

The same criterion applies to consistency. The Repetition and Consistency lesson showed
consistency's value, but consistency is not a design **principle**; it is the system's
condition of existing. Principles determine which option is taken within the system.

The second criterion is showing that the principle actually touches decisions. The program
below runs two checks at once: it tests principles by the defensibility of their opposite
and extracts the overlap between principles and decisions. The third block checks the
tone-of-voice contract.

```js
// language.mjs — distinctiveness of design principles, principle-decision overlap, and tone-of-voice audit

// 1) Distinctiveness: a principle forces a decision only if its opposite is defensible.
// "opposite" is the written form of the principle's counterpart; "defensible" is a human judgment.
const PRINCIPLES = [
  { name: "record first", opposite: "tools first", defensible: true },
  { name: "quiet interface", opposite: "directive interface", defensible: true },
  { name: "reversibility", opposite: "confirmation-based protection", defensible: true },
  { name: "dense list", opposite: "airy list", defensible: true },
  { name: "we care about the user", opposite: "we do not care about the user", defensible: false },
  { name: "being consistent", opposite: "being inconsistent", defensible: false },
  { name: "accessibility is non-negotiable", opposite: "accessibility depends on the feature", defensible: true },
];

console.log("principle                      opposite                       distinctive?");
for (const i of PRINCIPLES) {
  console.log(`${i.name.padEnd(33)} ${i.opposite.padEnd(30)} ${i.defensible ? "yes" : "NO"}`);
}
const distinctive = PRINCIPLES.filter((i) => i.defensible);
console.log(`distinctive principle count: ${distinctive.length} / ${PRINCIPLES.length}`);

// 2) Principle-decision matrix. For each decision, which principle affects it and in which direction.
// direction: +1 supports the decision, -1 opposes it.
const DECISIONS = [
  { name: "record title larger than cover image", effect: { "record first": 1, "dense list": 1 } },
  { name: "results list laid out densely", effect: { "dense list": 1, "quiet interface": 1 } },
  { name: "borrowing is one click, reversible", effect: { reversibility: 1, "quiet interface": 1 } },
  { name: "record deletion asks for confirmation", effect: { reversibility: -1, "quiet interface": -1 } },
  { name: "filter panel always open", effect: { "dense list": -1, "record first": 1 } },
  { name: "focus indicator on every component", effect: { "accessibility is non-negotiable": 1 } },
  { name: "suggestion list shown in the empty state", effect: { "quiet interface": -1 } },
  { name: "cover images shown as small thumbnails", effect: { "record first": 1, "dense list": 1 } },
  { name: "top menu stays fixed", effect: {} },
  { name: "continuous scroll instead of pagination", effect: {} },
];

console.log("\nprinciple                      supports  opposes  total");
for (const i of PRINCIPLES) {
  const plus = DECISIONS.filter((k) => k.effect[i.name] === 1).length;
  const minus = DECISIONS.filter((k) => k.effect[i.name] === -1).length;
  console.log(
    `${i.name.padEnd(33)} ${String(plus).padStart(8)} ${String(minus).padStart(8)} ${String(plus + minus).padStart(6)}`
  );
}

const idlePrinciples = PRINCIPLES.filter((i) => !DECISIONS.some((k) => i.name in k.effect));
const unsupported = DECISIONS.filter((k) => Object.keys(k.effect).length === 0);
const conflicting = DECISIONS.filter((k) => {
  const y = Object.values(k.effect);
  return y.includes(1) && y.includes(-1);
});
const tension = DECISIONS.filter((k) => Object.values(k.effect).every((v) => v === -1) && Object.keys(k.effect).length > 0);

console.log(`\nidle principle (determined no decision): ${idlePrinciples.map((i) => i.name).join(", ") || "none"}`);
console.log(`unsupported decision (tied to no principle): ${unsupported.map((k) => k.name).join(", ") || "none"}`);
console.log(`decision carrying an internal conflict: ${conflicting.map((k) => k.name).join(", ") || "none"}`);
console.log(`decision made despite the principles: ${tension.map((k) => k.name).join(", ") || "none"}`);

// 3) Tone-of-voice contract audit. Rules must be checkable.
const TEXTS = [
  { location: "search empty result", text: "Your search returned no results. Search by author name or ISBN." },
  { location: "borrow successful", text: "Borrowed. Due back March 12." },
  { location: "borrow error", text: "We are sorry, unfortunately an error occurred. Please try again." },
  { location: "overdue warning", text: "The due date has passed. Use the extend button to renew." },
  { location: "field validation", text: "You entered an invalid value." },
  { location: "delete confirmation", text: "The record will be deleted. This action cannot be undone. Confirm to delete." },
  { location: "empty shelf state", text: "This shelf has no records. To add a record, select the shelf and use the add button, then select a title from the list." },
];

const FORBIDDEN = ["sorry", "unfortunately", "invalid", "you entered", "please"];
const ACTION_VERB = ["search", "use", "confirm", "select", "try", "add"];
const MAX_WORDS = 14;

console.log("\nlocation           words  forbidden word            action  exclaim  result");
let violating = 0;
for (const m of TEXTS) {
  const lower = m.text.toLowerCase();
  const words = m.text.trim().split(/\s+/).length;
  const forbidden = FORBIDDEN.filter((y) => lower.includes(y));
  const action = ACTION_VERB.some((f) => lower.includes(f));
  const exclaim = m.text.includes("!");
  const violations = [];
  if (words > MAX_WORDS) violations.push("long");
  if (forbidden.length) violations.push("forbidden");
  if (!action) violations.push("no action");
  if (exclaim) violations.push("exclaim");
  if (violations.length) violating++;
  console.log(
    `${m.location.padEnd(19)} ${String(words).padStart(5)}  ${(forbidden.join(",") || "-").padEnd(26)} ` +
      `${(action ? "yes" : "NO").padEnd(6)} ${(exclaim ? "yes" : "-").padEnd(7)} ${violations.join("+") || "pass"}`
  );
}
console.log(`text with a violation: ${violating} / ${TEXTS.length}`);
```

```
principle                      opposite                       distinctive?
record first                      tools first                    yes
quiet interface                   directive interface            yes
reversibility                     confirmation-based protection  yes
dense list                        airy list                      yes
we care about the user            we do not care about the user  NO
being consistent                  being inconsistent             NO
accessibility is non-negotiable   accessibility depends on the feature yes
distinctive principle count: 5 / 7

principle                      supports  opposes  total
record first                             3        0      3
quiet interface                          2        2      4
reversibility                            1        1      2
dense list                               3        1      4
we care about the user                   0        0      0
being consistent                         0        0      0
accessibility is non-negotiable          1        0      1

idle principle (determined no decision): we care about the user, being consistent
unsupported decision (tied to no principle): top menu stays fixed, continuous scroll instead of pagination
decision carrying an internal conflict: filter panel always open
decision made despite the principles: record deletion asks for confirmation, suggestion list shown in the empty state

location           words  forbidden word            action  exclaim  result
search empty result    11  -                          yes    -       pass
borrow successful       5  -                          NO     -       no action
borrow error           10  sorry,unfortunately,please yes    -       forbidden
overdue warning        11  -                          yes    -       pass
field validation        5  invalid,you entered        NO     -       forbidden+no action
delete confirmation    13  -                          yes    -       pass
empty shelf state      24  -                          yes    -       long
text with a violation: 4 / 7
```

#### Two Checks, Four Findings

The first table eliminates two of seven candidate principles. "We care about the user" and
"being consistent" are not distinctive, because their opposites cannot be defended. The
second table eliminates the same two principles a second time: they support no decision and
oppose none, their total effect is zero. Two independent criteria flagging the same two
principles is not a coincidence; a sentence whose opposite cannot be defended can already
rule out no option.

An **idle principle** does not make it onto the list. Every line in a principles document
has a cost: time to read, the burden of memorizing it, and a newcomer taking it seriously
and trying to apply it for nothing. An ineffective principle dilutes the document and
lowers the credibility of the ones that do work.

An **unsupported decision** is the finding in the opposite direction: the top menu staying
fixed and using continuous scroll instead of pagination cannot be tied to any principle.
This does not mean the decisions are wrong; it means their grounds are not written down.
Two possibilities follow. Either the decisions are genuinely unsupported and fall at the
first objection, or the principles list is incomplete and an unwritten principle is what
determines these decisions. In the second case, the missing principle is found and written
down; this is the audit's most productive output.

#### A Conflict Is Not a Flaw

The third finding is the filter panel staying always open. This decision supports one
principle (record first: filters speed up reaching a record) and opposes another (dense
list: the panel narrows the list area).

A conflict like this is not a flaw in the system, it is proof that the principles are
working. A set of principles that never conflict is usually a set of independent sentences
that names no real trade-off. What is needed is not to remove the conflict but to write
down the **priority order**: which principle outranks which. At the catalog institution,
accessibility is non-negotiable, record first comes next, density ranks last. Once that
order is written down, the filter panel decision becomes uncontested.

The fourth finding is a separate category: the decisions that record deletion asks for
confirmation and that a suggestion list is shown in the empty state only stand **against**
principles. This shows that the decisions' rationale comes from somewhere outside the
principles — the deletion confirmation comes from a data-loss risk, the suggestion list
from a usability finding. These are legitimate rationales, but because they do not appear
in the principles document, they need to be recorded. Otherwise, someone applying the
principles will find these decisions "against the system" and remove them.

#### Tone of Voice Becomes a Contract

The final table takes up the second part of design language. **Tone of voice** is
described in most documents with adjectives — clear, respectful, helpful — and these
adjectives can reject no text. Converting it into a contract means reducing the adjectives
to checkable rules: a maximum word count, which words are not used, whether every message
proposes an action, whether exclamation marks are used.

Four of the seven texts carry a violation. The "borrow error" message contains three
forbidden words: apology and pleading phrases carry no information, lengthen the message,
and blur rather than reduce the impression that the error originated with the user. The
"field validation" message both carries a forbidden word and does not say what to do: two
violations in five words. The "empty shelf state" text exceeds the limit at twenty-four
words.

The fourth violation, though, calls the rule itself into question. The message "Borrowed.
Due back March 12." is flagged because it proposes no action, but this message is a
confirmation; it asks nothing of the user. Here the rule produces a false positive.

This is the general limit of a tone-of-voice audit. Rules do not know the text's **type**.
The fix is not to loosen the rule but to tie it to the type: the action-proposal
requirement applies to error and empty-state messages, not to confirmation messages. When
audit rules are not defined together with the text type they apply to, they either produce
unnecessary warnings or miss real violations.

#### The Language's Counterpart in Tokens

The final part of design language is brand expression, and here it ties back to the
system's numeric layer. A principle having a counterpart in the system means that principle
constrains a family of tokens.

The "dense list" principle constrains which steps of the spacing scale get used in a list
context. "Quiet interface" constrains the saturation choices of the color system and the
number of accent roles. "Accessibility is non-negotiable" removes contrast thresholds from
being a matter of preference.

Some principles, though, have no token counterpart: "reversibility" is not a number but a
behavior rule, and it lives in the rule layer defined in an earlier lesson. This
distinction has to be made, because trying to express, through a token, a principle that
does not fit the token layer produces tokens that carry no meaning.

Brand expression is the sum of these two channels: constrained values in the numeric layer,
written behaviors in the rule layer. An institution's voice comes from both together; a
color on its own, or a sentence pattern on its own, does not produce a brand.

#### Summary

- A design principle is a principle only if its opposite is a defensible position; a
  sentence whose opposite cannot be defended rules out no option and shows zero effect in
  the principle-decision matrix. An idle principle is dropped from the document; an
  unsupported decision either has no rationale or points to an unwritten principle, and the
  second case is the audit's most productive output.
- A principle conflict is not a flaw, it is proof that the principles name real trade-offs;
  the fix is not to remove the conflict but to write down the priority order.
- Decisions that stand only against principles come from a different rationale, and if that
  rationale is not recorded, the decisions later get judged as against the system and
  removed.
- Tone of voice becomes a contract once it is reduced from adjectives to checkable rules: a
  word limit, a forbidden-word list, an action-proposal requirement, and a punctuation
  rule.
- Audit rules produce a false positive when they are not tied to text type; expecting an
  action proposal from a confirmation message is an example.
- A principle's counterpart in the system is either constraining a family of tokens or
  being written into the rule layer; a principle that fits neither cannot be applied.

#### Next Step

The system's founding decisions are complete at this point: scope was drawn, justification
computed, the inventory extracted, a scale strategy chosen, and design language made
checkable. What follows is these decisions' counterpart living in the machine. The scale
steps, color roles, and layout decisions need to turn into named values; which layer those
names sit in, how they reference each other, and which references are forbidden is a
separate architectural question. The next topic starts with tokens: its first lesson
defines the three layers, sets the cross-layer reference rules, and checks the token graph
for cycles, idle tokens, and layer violations.

## Tokens and the Foundation Layer

The primitive, semantic, and component layers of design tokens; color, typography, spacing, and size tokens; systematizing breakpoint and grid decisions; and theme derivation.

### Design Tokens

Source: https://academia.sh/en/courses/design-systems/design-tokens

Splitting tokens into primitive, semantic, and component layers, setting rules for cross-layer references, and resolving the token graph for cycles, undefined references, layer violations, and orphaned tokens.

The system's founding decisions are complete: the scope is drawn, the scales are chosen,
the design language is auditable. So far, every one of these decisions still lives as
prose. An interface cannot read prose; it reads named values.

A **design token** is the machine-readable, named counterpart of a design decision. The
What Is a Design System lesson showed that naming reduced the number of change sites by
a factor of 3.88; this lesson establishes how those names are organized. The problem is
not keeping a single list — a single list eventually turns into a dictionary where it
becomes unclear which name was set for which purpose. Tokens are layered, and cross-layer
references are bound to a rule.

#### Three Layers, Two Rules

The **primitive layer** carries raw values: color steps, scale steps, size steps. A
primitive token is a number or a color; it carries no purpose. `neutral-500` is a shade
of gray and says nothing about where it will be used.

The **semantic layer** names a job and binds it to a primitive token. `border` is a job;
which gray it is comes second. The Color System lesson established this distinction for
color; token architecture establishes the same distinction for spacing, size, and radius.

The **component layer** opens the setting points of a single component and binds them to
a semantic token. `button-surface` is a setting point. The Custom Properties lesson
called the names a component exposes outward its **style interface**; component tokens
are that interface's system-level counterpart.

References across layers follow two rules. The primitive layer references no token at
all; it carries only a fixed value. The component layer references only the semantic
layer; referencing a primitive directly is forbidden. The semantic layer may reference a
primitive or another semantic token.

The second rule's rationale was given in the Color System lesson: the moment a component
writes `--neutral-600`, the system is punctured, because that component is no longer
affected by role reassignment or a theme change. Once the rule is written down, a
violation becomes auditable.

```js
// token.mjs — resolving and auditing a three-layer token graph

// Layer rules:
//   primitive -> carries only a fixed value, cannot reference another token
//   semantic  -> references a primitive or semantic token
//   component -> references only a semantic token
const ALLOWED = { primitive: [], semantic: ["primitive", "semantic"], component: ["semantic"] };

const TOKENS = {
  // --- primitive layer ---
  "neutral-000": { layer: "primitive", value: "#ffffff" },
  "neutral-500": { layer: "primitive", value: "#757e8a" },
  "neutral-600": { layer: "primitive", value: "#5e656e" },
  "neutral-900": { layer: "primitive", value: "#212327" },
  "primary-100": { layer: "primitive", value: "#dee9f7" },
  "primary-600": { layer: "primitive", value: "#275ea5" },
  "primary-800": { layer: "primitive", value: "#15335b" },
  "spacing-1": { layer: "primitive", value: "4px" },
  "spacing-2": { layer: "primitive", value: "8px" },
  "spacing-3": { layer: "primitive", value: "12px" },
  "spacing-4": { layer: "primitive", value: "16px" },
  "spacing-6": { layer: "primitive", value: "24px" },
  "step-0": { layer: "primitive", value: "16px" },
  "step-1": { layer: "primitive", value: "20px" },
  "radius-1": { layer: "primitive", value: "4px" },

  // --- semantic layer ---
  surface: { layer: "semantic", value: "{neutral-000}" },
  "text-primary": { layer: "semantic", value: "{neutral-900}" },
  "text-secondary": { layer: "semantic", value: "{neutral-600}" },
  border: { layer: "semantic", value: "{neutral-500}" },
  "action-primary": { layer: "semantic", value: "{primary-600}" },
  "action-primary-on": { layer: "semantic", value: "{neutral-000}" },
  "accent-pale": { layer: "semantic", value: "{primary-100}" },
  "accent-pale-on": { layer: "semantic", value: "{primary-800}" },
  "spacing-within-group": { layer: "semantic", value: "{spacing-2}" },
  "spacing-between-block": { layer: "semantic", value: "{spacing-4}" },
  "spacing-between-section": { layer: "semantic", value: "{spacing-6}" },
  "body-size": { layer: "semantic", value: "{step-0}" },
  "title-size": { layer: "semantic", value: "{step-1}" },
  "radius-default": { layer: "semantic", value: "{radius-1}" },
  "warning-ground": { layer: "semantic", value: "{warning-border}" },   // cycle
  "warning-border": { layer: "semantic", value: "{warning-ground}" },   // cycle
  "spacing-tight": { layer: "semantic", value: "{spacing-1}" },          // referenced from nowhere

  // --- component layer ---
  "button-surface": { layer: "component", value: "{action-primary}" },
  "button-text": { layer: "component", value: "{action-primary-on}" },
  "button-padding-y": { layer: "component", value: "{spacing-within-group}" },
  "button-padding-x": { layer: "component", value: "{spacing-between-block}" },
  "button-radius": { layer: "component", value: "{radius-default}" },
  "card-surface": { layer: "component", value: "{surface}" },
  "card-border": { layer: "component", value: "{neutral-500}" },          // layer violation: directly to a primitive
  "card-padding": { layer: "component", value: "{spacing-between-block}" },
  "record-title-size": { layer: "component", value: "{title-size}" },
  "record-metadata-color": { layer: "component", value: "{text-tertiary}" }, // undefined reference
  "label-surface": { layer: "component", value: "{accent-pale}" },
  "label-text": { layer: "component", value: "{accent-pale-on}" },
};

const REFERENCE = /^\{([\w-]+)\}$/;
const target = (name) => {
  const m = TOKENS[name].value.match(REFERENCE);
  return m ? m[1] : null;
};

// --- resolution --------------------------------------------------------------
function resolve(name) {
  const visited = new Set();
  let current = name;
  const chain = [name];
  while (true) {
    if (!(current in TOKENS)) return { value: null, reason: `undefined: ${current}`, chain };
    if (visited.has(current)) return { value: null, reason: "cycle", chain };
    visited.add(current);
    const h = target(current);
    if (h === null) return { value: TOKENS[current].value, reason: null, chain };
    current = h;
    chain.push(current);
  }
}

// --- audits --------------------------------------------------------------------
const broken = [];
const layerViolations = [];
for (const name of Object.keys(TOKENS)) {
  const c = resolve(name);
  if (c.reason) broken.push({ name, ...c });
  const h = target(name);
  if (h === null) continue;
  const own = TOKENS[name].layer;
  const targetLayer = h in TOKENS ? TOKENS[h].layer : null;
  if (targetLayer && !ALLOWED[own].includes(targetLayer)) {
    layerViolations.push({ name, own, h, targetLayer });
  }
}

// Tokens read directly in component style files. Not every semantic token needs a
// component-token counterpart; the component layer is built only where a setting
// point is opened.
const DIRECT_USE = new Set([
  "text-primary", "text-secondary", "border", "body-size", "spacing-between-section",
  "spacing-within-group", "surface",
]);

// Incoming count: how many tokens reference a given token?
const incoming = Object.fromEntries(Object.keys(TOKENS).map((a) => [a, 0]));
for (const name of Object.keys(TOKENS)) {
  const h = target(name);
  if (h && h in incoming) incoming[h]++;
}
// The component layer counts as leaves; a token in the primitive or semantic layer
// with no incoming reference is orphaned.
const orphaned = Object.keys(TOKENS).filter(
  (a) => TOKENS[a].layer !== "component" && incoming[a] === 0 && !DIRECT_USE.has(a)
);

const counts = { primitive: 0, semantic: 0, component: 0 };
for (const a of Object.keys(TOKENS)) counts[TOKENS[a].layer]++;
console.log("layer      token count");
for (const k of ["primitive", "semantic", "component"]) console.log(`${k.padEnd(10)} ${String(counts[k]).padStart(15)}`);
console.log(`total      ${String(Object.keys(TOKENS).length).padStart(15)}`);

console.log("\ncomponent token           chain                                                    value");
for (const name of Object.keys(TOKENS).filter((a) => TOKENS[a].layer === "component")) {
  const c = resolve(name);
  console.log(
    `${name.padEnd(24)} ${c.chain.join(" > ").padEnd(54)} ${c.value === null ? "UNRESOLVED (" + c.reason + ")" : c.value}`
  );
}

console.log("\nunresolved tokens");
for (const k of broken) console.log(`  ${k.name.padEnd(24)} ${k.reason.padEnd(26)} chain: ${k.chain.join(" > ")}`);

console.log("\nlayer violations");
for (const i of layerViolations) {
  console.log(`  ${i.name} (${i.own}) -> ${i.h} (${i.targetLayer});  allowed: ${ALLOWED[i.own].join(", ")}`);
}

console.log("\norphaned tokens (no reference from a token or a style file)");
for (const a of orphaned) console.log(`  ${a.padEnd(24)} layer: ${TOKENS[a].layer}`);

// --- chain length distribution ------------------------------------------------
const lengths = {};
for (const name of Object.keys(TOKENS)) {
  const c = resolve(name);
  if (c.reason) continue;
  lengths[c.chain.length] = (lengths[c.chain.length] || 0) + 1;
}
console.log("\nchain length   token count");
for (const u of Object.keys(lengths).sort((a, b) => a - b)) {
  console.log(`${u.padStart(15)} ${String(lengths[u]).padStart(16)}`);
}

// --- layer transitions ---------------------------------------------------------
const transitions = {};
for (const name of Object.keys(TOKENS)) {
  const h = target(name);
  if (!h || !(h in TOKENS)) continue;
  const key = `${TOKENS[name].layer} -> ${TOKENS[h].layer}`;
  transitions[key] = (transitions[key] || 0) + 1;
}
console.log("\nlayer transition        references  allowed");
for (const [g, n] of Object.entries(transitions).sort()) {
  const [source, dest] = g.split(" -> ");
  console.log(`${g.padEnd(23)} ${String(n).padStart(7)}  ${ALLOWED[source].includes(dest) ? "yes" : "NO"}`);
}
```

```
layer      token count
primitive               15
semantic                17
component               12
total                   44

component token           chain                                                    value
button-surface           button-surface > action-primary > primary-600          #275ea5
button-text              button-text > action-primary-on > neutral-000          #ffffff
button-padding-y         button-padding-y > spacing-within-group > spacing-2    8px
button-padding-x         button-padding-x > spacing-between-block > spacing-4   16px
button-radius            button-radius > radius-default > radius-1              4px
card-surface             card-surface > surface > neutral-000                   #ffffff
card-border              card-border > neutral-500                              #757e8a
card-padding             card-padding > spacing-between-block > spacing-4       16px
record-title-size        record-title-size > title-size > step-1                20px
record-metadata-color    record-metadata-color > text-tertiary                  UNRESOLVED (undefined: text-tertiary)
label-surface            label-surface > accent-pale > primary-100              #dee9f7
label-text               label-text > accent-pale-on > primary-800              #15335b

unresolved tokens
  warning-ground           cycle                      chain: warning-ground > warning-border > warning-ground
  warning-border           cycle                      chain: warning-border > warning-ground > warning-border
  record-metadata-color    undefined: text-tertiary   chain: record-metadata-color > text-tertiary

layer violations
  card-border (component) -> neutral-500 (primitive);  allowed: semantic

orphaned tokens (no reference from a token or a style file)
  spacing-3                layer: primitive
  spacing-tight            layer: semantic

chain length   token count
              1               15
              2               16
              3               10

layer transition        references  allowed
component -> primitive        1  NO
component -> semantic        10  yes
semantic -> primitive        15  yes
semantic -> semantic          2  yes
```

#### The Graph Produces Four Kinds of Defect

A token set is a graph: the nodes are names, the edges are references. The moment it is
seen as a graph, it becomes possible to audit, and four distinct kinds of defect surface.

**Cycle.** `warning-ground` and `warning-border` reference each other. Neither produces a
value. The Custom Properties lesson showed the same behavior at the CSS level: two custom
properties that reference each other are treated as invalid. The difference is that the
result there surfaces at runtime; the audit here runs before publication.

**Undefined reference.** The `record-metadata-color` token references the name
`text-tertiary`, and no such token exists. This usually comes from a name being renamed
later or a token being left in draft. When the audit does not run, the component silently
falls back to the wrong color, because an undefined custom property produces invalidity
at computed-value time and the color is inherited from the parent element.

**Layer violation.** `card-border` sits in the component layer but references the
primitive token `neutral-500` directly. The result produces a correct color — `#757e8a`
shows up in the output — and that is exactly what makes this defect dangerous. Because the
value is correct, it gives no visual sign. Its effect surfaces only when the theme
changes: the card border stays at the light theme's gray in the dark theme, because it
skipped the role layer.

**Orphaned token.** `spacing-3` and `spacing-tight` receive no reference from either a
token or a style file. These look harmless, but they make the system harder to read: the
more unused names a list carries, the less clear it is to whoever is choosing among them
which one to pick. An orphaned token is either removed or its reason for staying is
written down.

#### Not Every Semantic Token Needs a Component Token

The orphaned-token audit requires a distinction, and that distinction is an architectural
decision.

Semantic tokens such as `text-primary`, `border`, and `body-size` have no component-token
counterpart at all. They are read directly in style files. The code knows them through
the `DIRECT_USE` set, and they do not count as orphaned.

The distinction is this: the component layer is not built for **every** semantic token,
only where a **setting point** is opened. `button-surface` exists because the button's
variants change its surface. `record-title-size` exists because the record card's dense
and sparse versions choose a different title size. Body text's color, by contrast, is
never adjusted in any component; opening a component token for it would produce a setting
point that never gets used.

The layer-transition table counts this balance: 10 component tokens reference the
semantic layer, 15 semantic tokens reference the primitive layer. The component layer is
smaller than the semantic layer, and that is how it should be. If the component layer
ever reaches the same size as the semantic layer, either every role has been wrapped
unnecessarily, or the semantic layer was built incompletely.

#### Chain Length Is a Trade-off

The chain-length distribution shows three values: 15 tokens carry a value directly, 16
resolve one step later, 10 resolve two steps later. The graph's depth is capped at three.

This is not a coincidence; it is the direct consequence of the layer count. In a
three-layer system, the longest chain is component → semantic → primitive. The semantic
layer referencing within itself stretches this limit — the output has two
semantic-to-semantic references — and every stretch carries a cost.

The cost is traceability. Understanding why a component ended up with that color means
following the whole chain. A three-step chain is readable; in a six-step chain it becomes
unclear which step needs to change. A long chain also signals something: if the same role
has been wrapped more than once, one of the intermediate steps is unnecessary.

Flattening the chain entirely, however, is just as wrong. A system where component tokens
bind directly to primitive values is the layer violation generalized, and it makes theme
switching impossible. The right target is not the shortest chain, but the **shortest
chain within the rule**.

#### Written Form and Naming Convention

A token's counterpart in style is a custom property. The layer distinction is made
visible through a name prefix; the `--card-` prefix convention from the Custom Properties
lesson expands here to three layers.

```css
:root {
  /* primitive */
  --neutral-500: #757e8a;
  --primary-600: #275ea5;
  --spacing-2: 8px;
  --spacing-4: 16px;

  /* semantic */
  --border: var(--neutral-500);
  --action-primary: var(--primary-600);
  --spacing-within-group: var(--spacing-2);
  --spacing-between-block: var(--spacing-4);
}

.button {
  /* component: references only semantic names */
  --button-surface: var(--action-primary);
  --button-padding-y: var(--spacing-within-group);
  --button-padding-x: var(--spacing-between-block);

  background-color: var(--button-surface);
  padding: var(--button-padding-y) var(--button-padding-x);
}
```

Declaring component tokens on the component's own rule, not on the root element, is a
scope decision. When the declaration stays on the component, the name is valid only
within that subtree, and the root element does not turn into a list carrying every
component's setting points. The moment-of-computation rule from the Custom Properties
lesson applies here too: `--button-surface` is computed on the button element, so if
`--action-primary` is redefined on an ancestor of the button, the button inherits it.

The last part of the naming convention is how the primitive layer is named. Primitive
names carry no role, only position: `neutral-500`, `spacing-2`, `step-1`. The Typographic
Scale lesson showed that a step number is more durable than a meaning-bearing name; the
same reasoning holds for the whole primitive layer. The only layer that carries meaning
is the semantic layer, and its name already says so.

#### Summary

- A design token is the machine-readable, named counterpart of a design decision; tokens
  are organized not as a single list but as a layered graph.
- There are three layers: the primitive carries the raw value, the semantic carries the
  job, the component layer carries the setting point. The primitive layer makes no
  reference; the component layer may reference only the semantic layer.
- The graph produces four kinds of defect: cycles, undefined references, layer
  violations, and orphaned tokens. The layer violation is the most dangerous, because it
  produces a correct value and surfaces only when the theme changes.
- The component layer is not built for every semantic token, only where a setting point
  is opened; the component layer is expected to stay smaller than the semantic layer.
- Chain length is a consequence of the layer count; a long chain reduces traceability,
  and flattening it entirely makes theme switching impossible. The target is the shortest
  chain within the rule.
- Primitive names carry position, only the semantic layer carries meaning; component
  tokens are declared on the component's own rule, not on the root element.

#### Next Step

With the layer architecture in place, the next step is filling the layers in, and color
is the area that carries the most constraints in this work. A color token is not just a
name and a value: which ground it can be used on, which threshold it has to clear, and
which derivative it switches to in which state are all part of the definition. The next
lesson writes color roles as a contract, states which foreground can fall on which
ground, and checks that statement automatically with the contrast ratio.

### Color Tokens

Source: https://academia.sh/en/courses/design-systems/color-tokens

Writing color roles as a contract, declaring legal foreground–ground pairs, testing state derivatives with the separation threshold, and auditing the contract automatically in every theme.

The previous lesson established token layers and cross-layer reference rules. In that
architecture, a color token was only a name and a reference: `border` was bound to the
`neutral-500` step. That is only part of what a color carries.

The rest of a color token is **where it can be used**: which grounds the `text-secondary`
role can fall on, which threshold it must clear, and which derivative it switches to in
which state. Without this written down, every component checks on its own or not at all.
This lesson defines color tokens together with their pairings and turns the definition
into an auditable contract.

#### A Contract Is More Than a Role List

In the Color System lesson, roles were bound to steps, and pairings were listed and
checked by hand. At the system level, two things change.

First, the pairing list becomes a **contract**: a component may use only a pair written
into it. A foreground–ground combination outside the contract is not a preference, it is
a violation. Second, the contract reruns for every theme; passing once is not enough.

The role set also grows: four more sets join the ten roles from the Color System lesson — state
derivatives (hover, active, disabled), semantic color pairs, and the focus ring. The Interaction States
lesson established that states must be measurably separated at the 1.2 contrast ratio this course adopted;
the contract checks this threshold too.

The third part of the contract is **pairs with no criterion**. A disabled control's text
is exempt from the accessibility criteria's contrast requirement, and the exemption
appears in the contract not as an absent threshold but as one explicitly written as
`null`: the decision is made and recorded.

```js
// color.mjs — writing color roles as a contract and auditing the contract

function hslRgb(h, s, l) {
  s /= 100; l /= 100;
  const k = (n) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  return [f(0), f(8), f(4)].map((v) => Math.round(v * 255));
}
const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

// Primitive layer: families and step lightness values (the ramp from the Color System lesson).
const LIGHTNESS = { "000": 100, "050": 97, 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const FAMILY = { neutral: [214, 8], primary: [214, 62], success: [145, 55], warning: [38, 85], error: [8, 68] };
const color = (family, step) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[step]);

// Semantic layer: each role names a job and binds to a primitive step.
const ROLE = {
  surface: ["neutral", "000"],
  "surface-secondary": ["neutral", "050"],
  "surface-selected": ["primary", "100"],
  border: ["neutral", "500"],
  "text-primary": ["neutral", "900"],
  "text-secondary": ["neutral", "600"],
  "text-tertiary": ["neutral", "500"],
  "action-primary": ["primary", "600"],
  "action-primary-on": ["neutral", "000"],
  "action-primary-hover": ["primary", "700"],
  "action-primary-active": ["primary", "800"],
  "action-disabled": ["neutral", "200"],
  "action-disabled-on": ["neutral", "500"],
  "focus-ring": ["primary", "600"],
  "success-ground": ["success", "100"],
  "success-text": ["success", "800"],
  "warning-ground": ["warning", "100"],
  "warning-text": ["warning", "800"],
  "error-ground": ["error", "100"],
  "error-text": ["error", "700"],
};
const roleColor = (role) => color(...ROLE[role]);

console.log("role                     family  step     hex");
for (const [name, [family, b]] of Object.entries(ROLE)) {
  console.log(`${name.padEnd(24)} ${family.padEnd(7)} ${b.padStart(7)}  ${hex(roleColor(name))}`);
}

// Contract: which foreground can fall on which ground, and which threshold applies.
// A null threshold means the criterion does not apply; the reason must be written into the contract.
const CONTRACT = [
  ["text-primary", "surface", 4.5],
  ["text-primary", "surface-secondary", 4.5],
  ["text-secondary", "surface", 4.5],
  ["text-tertiary", "surface", 4.5],
  ["text-primary", "surface-selected", 4.5],
  ["border", "surface", 3.0],
  ["action-primary", "surface", 3.0],
  ["action-primary-on", "action-primary", 4.5],
  ["action-primary-on", "action-primary-hover", 4.5],
  ["action-primary-on", "action-primary-active", 4.5],
  ["action-disabled-on", "action-disabled", null],
  ["focus-ring", "surface", 3.0],
  ["success-text", "success-ground", 4.5],
  ["warning-text", "warning-ground", 4.5],
  ["error-text", "error-ground", 4.5],
];

console.log("\nforeground               ground                   contrast   threshold  result");
let failing = 0;
for (const [fg, ground, threshold] of CONTRACT) {
  const k = contrast(roleColor(fg), roleColor(ground));
  const result = threshold === null ? "exempt" : k >= threshold ? "passed" : "FAILED";
  if (threshold !== null && k < threshold) failing++;
  console.log(
    `${fg.padEnd(24)} ${ground.padEnd(24)} ${k.toFixed(2).padStart(7)}:1 ${(threshold === null ? "-" : threshold.toFixed(1)).padStart(6)}  ${result}`
  );
}
console.log(`pairs that miss the threshold: ${failing} / ${CONTRACT.filter((s) => s[2] !== null).length}`);

// State derivatives: every state role must be distinguishable from its base role.
// The criterion is the 1.2 contrast ratio adopted in the Interaction States lesson.
const SEPARATION_THRESHOLD = 1.2;
const STATES = [
  ["action-primary-hover", "action-primary"],
  ["action-primary-active", "action-primary-hover"],
  ["action-disabled", "surface"],
  ["surface-selected", "surface"],
  ["surface-secondary", "surface"],
];
console.log("\nstate role               base role              contrast  threshold  result");
for (const [state, base] of STATES) {
  const k = contrast(roleColor(state), roleColor(base));
  console.log(
    `${state.padEnd(24)} ${base.padEnd(22)} ${k.toFixed(3).padStart(8)} ${SEPARATION_THRESHOLD.toFixed(1).padStart(5)}  ${k >= SEPARATION_THRESHOLD ? "passed" : "FAILED"}`
  );
}

// For the text-tertiary role: which neutral step first clears the 4.5 threshold?
console.log("\nneutral step  against surface  4.5 threshold  3.0 threshold (large text)");
for (const b of ["300", "400", "500", "600", "700"]) {
  const k = contrast(color("neutral", b), roleColor("surface"));
  console.log(
    `${b.padStart(12)} ${k.toFixed(2).padStart(13)} ${(k >= 4.5 ? "passed" : "FAILED").padStart(10)} ${(k >= 3.0 ? "passed" : "FAILED").padStart(23)}`
  );
}

// For surface-secondary: which neutral step first clears the 1.2 separation threshold?
console.log("\nneutral step  against surface  1.2 separation threshold  text-secondary on it");
for (const b of ["050", "100", "200", "300"]) {
  const z = color("neutral", b);
  const sep = contrast(z, roleColor("surface"));
  const mt = contrast(roleColor("text-secondary"), z);
  console.log(
    `${b.padStart(12)} ${sep.toFixed(3).padStart(13)} ${(sep >= 1.2 ? "passed" : "FAILED").padStart(16)} ` +
      `${(mt.toFixed(2) + ":1 " + (mt >= 4.5 ? "passed" : "FAILED")).padStart(24)}`
  );
}

// Dark-theme counterpart: values derived with the preservation calculation from the Dark Theme lesson.
const DARK = {
  surface: "#212327",
  "surface-secondary": "#34383d",
  "text-primary": "#ffffff",
  "text-secondary": "#989fa8",
  border: "#7b848f",
  "action-primary": "#89a9d2",
  "action-primary-on": "#212327",
  "error-text": "#dd9589",
};
const missing = Object.keys(ROLE).filter((r) => !(r in DARK));
console.log(`\nroles with a dark-theme counterpart: ${Object.keys(DARK).length} / ${Object.keys(ROLE).length}`);
console.log("roles missing a dark counterpart:");
for (const r of missing) console.log(`  ${r}`);

// The part of the contract that can be audited in the dark theme
const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
console.log("\npairs auditable in the dark theme");
let audited = 0;
for (const [fg, ground, threshold] of CONTRACT) {
  if (threshold === null || !(fg in DARK) || !(ground in DARK)) continue;
  audited++;
  const k = contrast(rgb(DARK[fg]), rgb(DARK[ground]));
  console.log(
    `  ${fg.padEnd(24)} ${ground.padEnd(20)} ${k.toFixed(2).padStart(6)}:1 ${threshold.toFixed(1).padStart(5)}  ${k >= threshold ? "passed" : "FAILED"}`
  );
}
console.log(`audited: ${audited} / ${CONTRACT.filter((s) => s[2] !== null).length}`);
```

```
role                     family  step     hex
surface                  neutral     000  #ffffff
surface-secondary        neutral     050  #f7f7f8
surface-selected         primary     100  #dee9f7
border                   neutral     500  #757e8a
text-primary             neutral     900  #212327
text-secondary           neutral     600  #5e656e
text-tertiary            neutral     500  #757e8a
action-primary           primary     600  #275ea5
action-primary-on        neutral     000  #ffffff
action-primary-hover     primary     700  #1e4980
action-primary-active    primary     800  #15335b
action-disabled          neutral     200  #d3d6d9
action-disabled-on       neutral     500  #757e8a
focus-ring               primary     600  #275ea5
success-ground           success     100  #dff6e9
success-text             success     800  #195733
warning-ground           warning     100  #fcefd9
warning-text             warning     800  #684508
error-ground             error       100  #f8e0dd
error-text               error       700  #852819

foreground               ground                   contrast   threshold  result
text-primary             surface                    15.74:1    4.5  passed
text-primary             surface-secondary          14.70:1    4.5  passed
text-secondary           surface                     5.89:1    4.5  passed
text-tertiary            surface                     4.11:1    4.5  FAILED
text-primary             surface-selected           12.82:1    4.5  passed
border                   surface                     4.11:1    3.0  passed
action-primary           surface                     6.50:1    3.0  passed
action-primary-on        action-primary              6.50:1    4.5  passed
action-primary-on        action-primary-hover        9.05:1    4.5  passed
action-primary-on        action-primary-active      12.68:1    4.5  passed
action-disabled-on       action-disabled             2.82:1      -  exempt
focus-ring               surface                     6.50:1    3.0  passed
success-text             success-ground              7.54:1    4.5  passed
warning-text             warning-ground              7.57:1    4.5  passed
error-text               error-ground                7.21:1    4.5  passed
pairs that miss the threshold: 1 / 14

state role               base role              contrast  threshold  result
action-primary-hover     action-primary            1.393   1.2  passed
action-primary-active    action-primary-hover      1.401   1.2  passed
action-disabled          surface                   1.459   1.2  passed
surface-selected         surface                   1.228   1.2  passed
surface-secondary        surface                   1.071   1.2  FAILED

neutral step  against surface  4.5 threshold  3.0 threshold (large text)
         300          1.91     FAILED                  FAILED
         400          2.74     FAILED                  FAILED
         500          4.11     FAILED                  passed
         600          5.89     passed                  passed
         700          8.39     passed                  passed

neutral step  against surface  1.2 separation threshold  text-secondary on it
         050         1.071           FAILED            5.51:1 passed
         100         1.204           passed            4.90:1 passed
         200         1.459           passed            4.04:1 FAILED
         300         1.912           passed            3.08:1 FAILED

roles with a dark-theme counterpart: 8 / 20
roles missing a dark counterpart:
  surface-selected
  text-tertiary
  action-primary-hover
  action-primary-active
  action-disabled
  action-disabled-on
  focus-ring
  success-ground
  success-text
  warning-ground
  warning-text
  error-ground

pairs auditable in the dark theme
  text-primary             surface               15.74:1   4.5  passed
  text-primary             surface-secondary     11.80:1   4.5  passed
  text-secondary           surface                5.89:1   4.5  passed
  border                   surface                4.15:1   3.0  passed
  action-primary           surface                6.50:1   3.0  passed
  action-primary-on        action-primary         6.50:1   4.5  passed
audited: 6 / 14
```

#### The Third Text Level Cannot Be Built in Color

The contract audit fails one of the fourteen criteria-bearing pairs: the `text-tertiary`
role produces a 4.11:1 contrast against the surface and cannot clear the 4.5 threshold.

The third table gives the options for a fix. The first neutral step that clears the 4.5
threshold is 600; but 600 is already the step for the `text-secondary` role. So the third
text level cannot exist on this scale without sitting on the same color as the second
level.

This finding is a direct consequence of the Visual Hierarchy lesson's two-channel rule.
If the color channel cannot carry three levels, the third level moves to another channel:
size, weight, or position. The Typographic Scale lesson already separated the metadata
level on both size and contrast, so color needs no third shade.

The table's last column shows one more route: the neutral 500 step clears the 3.0
threshold that applies to large text. If the third level will be used only in large text,
the role can be kept, but that requires attaching a condition to the role's name, and a
conditional role should not be used unless that condition is written into the contract —
the component using it does not otherwise know it.

#### The Secondary Surface Produces No Separation

The state-derivative table gives the second finding, and this finding is quieter than the
first. Four state roles clear the separation threshold: hover at 1.393, active at 1.401,
disabled at 1.459, selected at 1.228. The fifth does not: the `surface-secondary` role
produces only a 1.071 contrast against the surface.

This means every rule written on the assumption of a second surface level goes unbacked.
Wherever a side panel, a table header, or a card's surface is set apart with neutral 050,
the separation does not actually exist — a scale step is spent for no visual result. This
is the color-side counterpart of the unbacked-variety concept from the Repetition and
Consistency lesson.

The last table measures the fix and shows a trade-off. Neutral 100 is the first step that
clears the separation threshold (1.204), and the secondary text on top of it still clears
4.90:1. Moving to neutral 200 strengthens the surface separation (1.459) but drops the
secondary text to 4.04, losing the threshold.

A system-level rule follows from this: **darkening a surface role requires every text
role on top of it to be re-audited.** A surface is not a decision on its own; it is a
decision together with every role that falls on it. The contract's job is exactly to make
this dependency visible.

#### A Pair with No Criterion Is Also a Decision

The disabled action's text carries a 2.82:1 contrast against its ground, and the table
reads "exempt." This row is the most misunderstood part of the contract.

The absence of a criterion is not an oversight, it is a written decision: a disabled
control is closed to interaction, and the accessibility criteria exempt it from the
contrast requirement. Writing the exemption explicitly into the contract does two jobs —
the audit tool does not flag that row as an error, and the reader sees it was not a
forgotten check.

Its cost is recorded too: why a disabled button is disabled cannot be understood through
color alone, and low contrast by itself carries no information. The Microcopy lesson's
rule repeats here — the state's reason is given through text. Where the contract writes
no criterion, another contract takes over.

#### The Contract Reruns in Every Theme

The last two blocks give this lesson's main system-level finding. Only eight of the
twenty roles have a defined dark-theme counterpart; twelve are missing. Of the contract's
fourteen criteria-bearing pairs, only six can be audited in the dark theme.

All six auditable pairs pass — expected, since they were derived with the Dark Theme
lesson's preservation calculation. The problem is not the pairs that pass; it is the
eight pairs that **cannot be audited**. The dark theme has no selected-surface role, so
components using it reference an undefined token — the undefined-reference defect from
the previous lesson.

This also shows how to find the gap: through the product of the role set and the theme
set. Twenty roles and two themes make forty cells; twelve are empty. Without a report,
these gaps surface only once a user switches to the dark theme.

That every semantic color is missing stands out on its own. The success, warning, and
error pairs are defined and audited in the light theme; none exist in the dark theme.
Semantic colors are the least-used roles but appear at the most critical moments, so
their absence is noticed latest.

#### The Contract's Written Form

Roles and pairings live in separate files. Roles are written in style as custom
properties; pairings are written as a data file the audit reads.

```css
:root {
  /* primitive */
  --neutral-000: #ffffff;
  --neutral-100: #e9eaec;
  --neutral-600: #5e656e;
  --neutral-900: #212327;
  --primary-600: #275ea5;
  --primary-700: #1e4980;
  --primary-800: #15335b;

  /* semantic */
  --surface: var(--neutral-000);
  --surface-secondary: var(--neutral-100);
  --text-primary: var(--neutral-900);
  --text-secondary: var(--neutral-600);
  --action-primary: var(--primary-600);
  --action-primary-hover: var(--primary-700);
  --action-primary-active: var(--primary-800);
  --action-primary-on: var(--neutral-000);
}
```

The pairing contract has no counterpart in style, and it should not. CSS cannot compute a
contrast ratio; only an audit running at build time or in a test stage can read the
contract. This is the main reason the token system is a data layer independent of style:
values turn into style, rules turn into audits.

The second detail is that `surface-secondary` has been moved from neutral 050 to neutral
100. The audit made this fix mandatory; the written form carries the corrected version.
Leaving a defect the audit found unfixed in the source reduces the audit to a report
generator.

#### Summary

- A color token is not just a name and a value; which grounds it can fall on and which
  threshold it must clear are part of the definition.
- The pairing list is a contract: a foreground–ground combination that is not in the
  contract is a violation, not a preference, and the contract is rerun for every theme.
- The scale cannot serve every role: the third text level cannot be built on this scale
  (neutral 500 does not clear the threshold, neutral 600 collides with the second level),
  and the secondary surface cannot be separated at neutral 050 with a 1.071 contrast. An
  unserviceable role moves to the size or weight channel; darkening a surface requires
  every text role on it to be re-audited.
- A pair with no criterion is a written decision; the exemption is stated explicitly in
  the contract, and the information it lacks is supplied through text.
- Only eight of the twenty roles have a defined dark-theme counterpart; six of the
  fourteen criteria-bearing pairs can be audited. Missing cells are found through the
  product of the role set and the theme set.
- Values turn into style, rules turn into audits; because CSS cannot compute a contrast
  ratio, the contract lives outside style.

#### Next Step

On the color side, the contract was built on the pairing concept: a role's correctness
was measured together with another role, not on its own. On the typography side,
dependency takes another form. A text level is not a single number: size, line height,
weight, and letter spacing together form one decision, and spreading these four across
separate tokens lets wrong combinations get assembled. The next lesson defines typography
tokens as composite tokens, fits line-box heights onto the spacing grid, and audits the
levels' distinction threshold.

### Typography Tokens

Source: https://academia.sh/en/courses/design-systems/typography-tokens

Defining text levels as composite tokens, fitting the line box onto the spacing grid, the trade-off between grid choice and multiplier drift, and auditing levels with the distinction threshold.

Color tokens showed a dependency: a role's correctness was measured not on its own but
together with the ground it fell on. In typography, the dependency moves inside a single
role.

A text level is not a single number. If the record title's size is 20 pixels, its line
height, weight, and letter spacing are part of that decision too. Spreading these four
fields across separate tokens and assembling them one by one in components lets wrong
combinations get built, and the level as a whole appears nowhere. This lesson defines
typography as a **composite token** and adds a second constraint — the line box has to
fit onto the spacing scale's grid.

#### Composite Tokens and the Grid Constraint

A **composite token** gathers more than one field under a single name. `record-title` is
not a size, it is a record: a size step, weight, line box, and letter spacing.

The reason for being composite is not consistency, it is **validity**. The Line Length
and Spacing lesson showed that the leading multiplier cannot go below the typeface's
ascent-plus-descent sum. With size and multiplier as separate tokens, this condition has
to be retested for every combination; in a composite token, it is tested once and
recorded.

The second constraint comes from the spacing scale. The vertical measure a text block
occupies is its line-box height; a card's total height is the sum of these boxes and the
spacing between them. Because the spacing scale is based on 4 pixels, totals drift off
the grid if line boxes are not multiples of 4. The constraint is: **the line box must be
a multiple of the spacing scale's base step.** So the multiplier cannot be chosen freely;
it is targeted, the box is rounded, and the actual multiplier falls out of that rounding.

```js
// typography.mjs — composite typography tokens, fitting the line box to the grid, and the distinction audit

const BASE = 16;          // body size (px)
const RATIO = 1.25;       // ratio chosen in the Typographic Scale lesson
const GRID = 4;            // base step of the hybrid scale in the Spacing Scale lesson (px)
const ASCENT_DESCENT = 1.051; // the typeface's declared ascent+descent (em); lower bound
const TARGET_MULTIPLIER = 1.5;  // leading multiplier chosen for body text

const STEPS = [-1, 0, 1, 2, 3];
const size = (n) => Math.round(BASE * Math.pow(RATIO, n));

console.log("step  size  target box  box fit to grid  actual multiplier  drift  ascent-descent");
const BOX = {};
for (const n of STEPS) {
  const b = size(n);
  const target = b * TARGET_MULTIPLIER;
  const box = Math.round(target / GRID) * GRID;
  const multiplier = box / b;
  BOX[n] = box;
  console.log(
    `${String(n).padStart(4)} ${String(b).padStart(6)} ${target.toFixed(2).padStart(11)} ` +
      `${String(box).padStart(21)} ${multiplier.toFixed(4).padStart(14)} ${(multiplier - TARGET_MULTIPLIER).toFixed(4).padStart(7)} ` +
      `${(multiplier >= ASCENT_DESCENT ? "passed" : "FAILED").padStart(13)}`
  );
}

// Grid choice is a trade-off: a coarse grid keeps totals tidy but widens multiplier drift.
console.log("\ngrid  largest multiplier drift  multiplier range        card total  fits the grid");
for (const g of [1, 2, 4, 8]) {
  const multipliers = STEPS.map((n) => (Math.round((size(n) * TARGET_MULTIPLIER) / g) * g) / size(n));
  const drift = Math.max(...multipliers.map((c) => Math.abs(c - TARGET_MULTIPLIER)));
  const box = Object.fromEntries(STEPS.map((n) => [n, Math.round((size(n) * TARGET_MULTIPLIER) / g) * g]));
  const cardTotal = 16 + box[1] + 8 + 2 * box[0] + 8 + box[-1] + 16;
  console.log(
    `${String(g).padStart(4)} ${drift.toFixed(4).padStart(22)}  ${Math.min(...multipliers).toFixed(3)} - ${Math.max(...multipliers).toFixed(3)} ` +
      `${String(cardTotal).padStart(19)} ${(cardTotal % 4 === 0 ? "yes" : "NO").padStart(21)}`
  );
}

// Composite tokens: a text level is not a single number, it is a four-field record.
// Letter-spacing rule: 0 at the base step, -0.005em per step above, +0.005em per step below.
const letterSpacing = (n) => -0.005 * n;

const STYLES = [
  { name: "page-title", step: 3, weight: 700, region: "page" },
  { name: "section-title", step: 2, weight: 700, region: "page" },
  { name: "record-title", step: 1, weight: 600, region: "record" },
  { name: "body", step: 0, weight: 400, region: "record" },
  { name: "body-strong", step: 0, weight: 600, region: "record" },
  { name: "button-text", step: 0, weight: 600, region: "action" },
  { name: "metadata", step: -1, weight: 400, region: "record" },
  { name: "label", step: -1, weight: 600, region: "record" },
];

console.log("\nstyle           size  weight  line box  multiplier  letter spacing (em)  letter spacing (px)");
for (const s of STYLES) {
  const px = size(s.step);
  const em = letterSpacing(s.step);
  console.log(
    `${s.name.padEnd(16)} ${String(px).padStart(4)} ${String(s.weight).padStart(7)} ${String(BOX[s.step]).padStart(9)} ` +
      `${(BOX[s.step] / px).toFixed(3).padStart(11)} ${em.toFixed(3).padStart(20)} ${(em * px).toFixed(3).padStart(19)}`
  );
}

// Distinction audit: styles that appear together in the same region must be distinguishable.
// The criterion is the Visual Hierarchy lesson's two-channel rule: size ratio >= 1.15 or
// weight difference >= 200.
const SIZE_THRESHOLD = 1.15;
const WEIGHT_THRESHOLD = 200;
console.log("\nregion  style pair                      size ratio  weight diff  channel passed");
let weak = 0;
for (const region of [...new Set(STYLES.map((s) => s.region))]) {
  const group = STYLES.filter((s) => s.region === region);
  for (let i = 0; i < group.length; i++) {
    for (let j = i + 1; j < group.length; j++) {
      const a = group[i];
      const b = group[j];
      const sizeRatio = Math.max(size(a.step), size(b.step)) / Math.min(size(a.step), size(b.step));
      const weightDiff = Math.abs(a.weight - b.weight);
      const passed = [];
      if (sizeRatio >= SIZE_THRESHOLD) passed.push("size");
      if (weightDiff >= WEIGHT_THRESHOLD) passed.push("weight");
      if (!passed.length) weak++;
      console.log(
        `${region.padEnd(7)} ${(a.name + " / " + b.name).padEnd(32)} ${sizeRatio.toFixed(3).padStart(10)} ` +
          `${String(weightDiff).padStart(12)}  ${passed.join("+") || "WEAK"}`
      );
    }
  }
}
console.log(`pairs producing no distinction: ${weak}`);

// Record card's vertical measure: line boxes and spacing steps must sum onto the grid.
const SPACING = { "within-group": 8, "between-block": 16, "between-section": 24 };
const CARD = [
  { kind: "spacing", name: "between-block" },
  { kind: "style", name: "record-title", lines: 1 },
  { kind: "spacing", name: "within-group" },
  { kind: "style", name: "body", lines: 2 },
  { kind: "spacing", name: "within-group" },
  { kind: "style", name: "metadata", lines: 1 },
  { kind: "spacing", name: "between-block" },
];
console.log("\nrecord card vertical measure");
let total = 0;
for (const p of CARD) {
  if (p.kind === "spacing") {
    total += SPACING[p.name];
    console.log(`  spacing  ${p.name.padEnd(16)} ${String(SPACING[p.name]).padStart(5)} px`);
  } else {
    const s = STYLES.find((x) => x.name === p.name);
    const y = BOX[s.step] * p.lines;
    total += y;
    console.log(`  text     ${(p.name + " x" + p.lines).padEnd(16)} ${String(y).padStart(5)} px  (box ${BOX[s.step]})`);
  }
}
console.log(`  total                             ${String(total).padStart(5)} px   fits the ${GRID} px grid: ${total % GRID === 0 ? "yes" : "NO"}`);

// The same card if line boxes had not been fit to the grid.
console.log("\nsame card with line boxes not fit to the grid");
let raw = 0;
for (const p of CARD) {
  if (p.kind === "spacing") raw += SPACING[p.name];
  else {
    const s = STYLES.find((x) => x.name === p.name);
    raw += size(s.step) * TARGET_MULTIPLIER * p.lines;
  }
}
console.log(`  total ${raw.toFixed(1)} px   fits the grid: ${raw % GRID === 0 ? "yes" : "NO"}   drift: ${(total - raw).toFixed(1)} px`);
```

```
step  size  target box  box fit to grid  actual multiplier  drift  ascent-descent
  -1     13       19.50                    20         1.5385  0.0385        passed
   0     16       24.00                    24         1.5000  0.0000        passed
   1     20       30.00                    32         1.6000  0.1000        passed
   2     25       37.50                    36         1.4400 -0.0600        passed
   3     31       46.50                    48         1.5484  0.0484        passed

grid  largest multiplier drift  multiplier range        card total  fits the grid
   1                 0.0385  1.500 - 1.538                 146                    NO
   2                 0.0385  1.484 - 1.538                 146                    NO
   4                 0.1000  1.440 - 1.600                 148                   yes
   8                 0.2692  1.231 - 1.600                 144                   yes

style           size  weight  line box  multiplier  letter spacing (em)  letter spacing (px)
page-title         31     700        48       1.548               -0.015              -0.465
section-title      25     700        36       1.440               -0.010              -0.250
record-title       20     600        32       1.600               -0.005              -0.100
body               16     400        24       1.500                0.000               0.000
body-strong        16     600        24       1.500                0.000               0.000
button-text        16     600        24       1.500                0.000               0.000
metadata           13     400        20       1.538                0.005               0.065
label              13     600        20       1.538                0.005               0.065

region  style pair                      size ratio  weight diff  channel passed
page    page-title / section-title            1.240            0  size
record  record-title / body                   1.250          200  size+weight
record  record-title / body-strong            1.250            0  size
record  record-title / metadata               1.538          200  size+weight
record  record-title / label                  1.538            0  size
record  body / body-strong                    1.000          200  weight
record  body / metadata                       1.231            0  size
record  body / label                          1.231          200  size+weight
record  body-strong / metadata                1.231          200  size+weight
record  body-strong / label                   1.231            0  size
record  metadata / label                      1.000          200  weight
pairs producing no distinction: 0

record card vertical measure
  spacing  between-block       16 px
  text     record-title x1     32 px  (box 32)
  spacing  within-group         8 px
  text     body x2             48 px  (box 24)
  spacing  within-group         8 px
  text     metadata x1         20 px  (box 20)
  spacing  between-block       16 px
  total                               148 px   fits the 4 px grid: yes

same card with line boxes not fit to the grid
  total 145.5 px   fits the grid: NO   drift: 2.5 px
```

#### Rounding Spreads the Multiplier

The first table shows the constraint's cost. The target multiplier is 1.5 at every step;
the realized multipliers spread between 1.44 and 1.60.

The largest drift is at step 1: the 20-pixel text's target box is 30 pixels, and rounded
to 4 it becomes 32, pushing the multiplier to 1.60. At step 2, rounding goes down: the
target box for 25 pixels is 37.5, the rounded box is 36, and the multiplier is 1.44.

The drift's direction is not arbitrary; it depends on the size's distance from the grid.
If the size is a multiple of the grid — 16 is a multiple of 4 — the target box is a
multiple too, and the drift stays at zero. The Typographic Scale lesson showed rounding
distorting the neighboring-step ratio; here the same rounding shows its effect a second
time, now on leading. The source is the same: fitting a continuous sequence onto an
integer grid.

The last column gives an assurance. At none of the five steps does the multiplier drop
below the 1.051 lower bound the typeface imposes. If that bound were crossed, the
rounding would be unacceptable; grid conformance does not justify lines overlapping.

#### Grid Choice Is a Trade-off

The second table questions the constraint itself. Four candidate grids are compared on
two criteria: multiplier drift and whether the card total fits the grid.

At the 1- and 2-pixel grids, multiplier drift stays small (0.0385), but the card total
comes out to 146 pixels and misses the 4-pixel spacing grid. This is the fine grid's
real cost: line boxes look tidy, but component heights fall out of step with the spacing
scale, and alignments drift once two components stack.

At the 8-pixel grid, the situation reverses. The card total fits (144), but the
multiplier range opens up to between 1.231 and 1.600; the drift is 0.2692. A 1.231
multiplier means text is visibly cramped at one step of the same scale. A coarse grid
tidies totals and breaks typography.

Four pixels is the only candidate that satisfies both criteria together: drift stays
capped at 0.10, and the card total, 148, fits the grid. The reason for the choice is not
aesthetics, it is the intersection of two constraints.

#### Letter Spacing Is a Size-Dependent Derivative

The third table gives the composite tokens' full form. The letter-spacing column is not
chosen one by one; it is derived from the step: zero at the base size, 0.005 em tighter
per step above, 0.005 em looser per step below.

The rule's rationale is optical. The same letter spacing looks wider at a large size,
because the spacing grows with the size; at a small size, letters draw closer together
and legibility drops. The derivation balances these two tendencies in opposite
directions.

The pixel column makes the derivation's result concrete: −0.465 pixels at the page title,
+0.065 pixels at the metadata. Because the values are written in em, this conversion
happens on its own and needs no recalculation when the size changes — the reason the
token is defined in em rather than pixels, which would need a separate value for every
step.

#### The Distinction Audit Runs Within a Region

The fourth table applies the Visual Hierarchy lesson's two-channel rule to the style set.
All eleven pairs are distinguished on at least one channel; no pair produces no
distinction.

The audit's **region** constraint is decisive here. The Typographic Scale lesson noted
that the neighbor check treats levels as an ordered list, and that hierarchy is
meaningful only among elements seen at the same time. This calculation turns that warning
into a rule: every style has a region, and the comparison runs only between pairs in the
same region. `button-text` stands alone in the action region and enters no pair; sharing
`body-strong`'s size and weight is not a problem, because the two never compete in the same
visual space.

Two pairs are distinguished on the weight channel alone: `body` and `body-strong`,
`metadata` and `label`. Their size ratio is 1.000 — an acceptable design, but one that
carries a condition: the weight difference has to actually be renderable. The Typeface
Selection lesson showed a variable typeface producing intermediate weights; with a
static-weight family, if 600 is missing, the browser rounds it to 700, and `label` and
`page-title` collapse onto the same weight. A distinction resting on a single channel
depends on that channel actually existing.

#### Totals Close on the Grid

The last two blocks prove why the constraint was set. The record card is made of three text blocks and
four spacing steps; with line boxes fit to the grid, the total is 148 pixels, a multiple of 4.

The same card, with line boxes left unrounded, comes to 145.5 pixels. A half-pixel
remainder is invisible in a single card, but once ten cards stack in a results list, the
remainder grows to five pixels and alignment with the filter panel beside them drifts.
This is the vertical-axis counterpart of the problem the Grid Systems lesson showed on
the horizontal axis: a measure that is not derived produces an error that accumulates as
it repeats.

The card's total height also delivers a side output: 148 pixels feeds the Density
Decisions lesson's records-per-screen calculation. That calculation stays fixed when
typography tokens are fit to the grid; unfit, every card takes a slightly different
height and the calculation becomes approximate.

#### Written Form

Composite tokens are written in style as a single class; the fields come from separate
custom properties but are applied together.

```css
:root {
  --step--1: 13px;
  --step-0: 16px;
  --step-1: 20px;

  --box--1: 20px;
  --box-0: 24px;
  --box-1: 32px;
}

.style-record-title {
  font-size: var(--step-1);
  line-height: var(--box-1);
  font-weight: 600;
  letter-spacing: -0.005em;
}

.style-body {
  font-size: var(--step-0);
  line-height: var(--box-0);
  font-weight: 400;
  letter-spacing: 0;
}
```

Writing line height as pixels instead of a unitless multiplier is deliberate: a unitless
multiplier is inherited and re-multiplied by a descendant's own size, while a pixel value
stays fixed when inherited. The grid constraint calls for a fixed box, so the box is
declared in pixels. The cost is that if text of a different size appears inside a style
class, the box will not fit it; the system forbids this as a rule — style classes do not
nest.

How the component layer is built here is a decision too. A component token such as
`record-title-size` is opened only if the record card's dense and sparse versions use
different steps. Where it is not opened, the component uses the style class directly, and
no setting point is produced.

#### Summary

- A text level is a composite token: size, weight, line box, and letter spacing are
  defined under a single name, because validity conditions are built across the fields.
- The line box must be a multiple of the spacing scale's base step; the multiplier is
  targeted, the box is rounded, and the actual multiplier falls out of that rounding.
- Rounding spreads the target multiplier (with a 1.5 target, the realized multipliers
  spread between 1.44 and 1.60), and grid choice is the trade-off between this spread and
  the totals: a fine grid preserves the multiplier but shifts component totals, a coarse
  grid tidies totals but opens the multiplier range to 0.27.
- Letter spacing is derived from the step in em; using em needs no recalculation when the
  size changes.
- The distinction audit runs within a region; pairs distinguished on the weight channel
  alone depend on that weight actually existing in the typeface.
- With boxes fit to the grid, the card's total height is 148 pixels and fits the grid;
  left unfit, it comes to 145.5 pixels, and the remainder accumulates as the card
  repeats.

#### Next Step

Line boxes are now fit to the spacing grid, but the grid itself is still defined in
pixels. If a user increases the root font size, or the browser changes the scale, pixel
values stay fixed and spacing does not grow while the text grows. The next lesson puts
the measurement system in numbers: it converts spacing and size tokens to relative units,
checks whether they produce integer pixels at different root sizes, and tests component
heights against the touch-target criterion.

### Spacing and Size Tokens

Source: https://academia.sh/en/courses/design-systems/spacing-and-size-tokens

Converting the spacing scale to relative units, preserving the text-to-space ratio when the root size changes, testing component heights against target-size thresholds, and deriving nested corner radii.

The previous lesson fit line boxes onto the spacing scale's 4-pixel grid and showed the
record card's total height closing on the grid. All of that calculation was in pixels.

Pixels carry a problem. When a user increases their browser's root font size, text
grows; spacing written in pixels does not. The interface turns into a mix of grown text
and fixed spacing, breaking the balance the Density Decisions lesson established. This
lesson puts numbers to the measurement system: it ties unit choice to a calculation,
derives component heights from scale steps, and audits whether the derivation meets
accessibility thresholds.

#### Unit Is Not a Presentation Detail

The spacing scale is a sequence of numbers; which unit it is written in is not part of
the scale, it is part of the scale's behavior. There are two candidates.

**Pixels** are fixed. Whatever the root size, 16 pixels is 16 pixels, which makes
design-time calculations easy: grid conformance holds under every condition.

A **relative unit** is a multiple of the root font size. When the root is 16 pixels, 1
unit is 16 pixels; once the root rises to 20 pixels, it is 20 pixels. The scale inherits
the user's font-size preference automatically.

The choice is not a preference, it is a calculation's result. The program below compares
the two regimes on the same card, then derives component measures and audits them
against thresholds.

```js
// scale.mjs — numbering spacing and size tokens, unit choice, and derived measures

const ROOT_BASE = 16; // default root font size (px)
const SCALE = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96];

console.log("step  px  rem      root 16 px  root 18 px  root 20 px  integer (18)  integer (20)");
for (let i = 0; i < SCALE.length; i++) {
  const px = SCALE[i];
  const rem = px / ROOT_BASE;
  const r18 = rem * 18;
  const r20 = rem * 20;
  console.log(
    `${String(i + 1).padStart(4)} ${String(px).padStart(3)} ${rem.toFixed(4).padStart(7)} ` +
      `${px.toFixed(2).padStart(10)} ${r18.toFixed(2).padStart(10)} ${r20.toFixed(2).padStart(10)} ` +
      `${(Number.isInteger(r18) ? "yes" : "no").padStart(14)} ${(Number.isInteger(r20) ? "yes" : "no").padStart(14)}`
  );
}

// Unit choice: when the root size grows, do text and space grow together?
// Card: top space + title box + within-group + two lines of body + within-group + metadata + bottom space
const CARD = { topSpace: 16, titleBox: 32, withinGroup: 8, bodyBox: 24, bodyLines: 2, metadataBox: 20, bottomSpace: 16 };
function card(root, spacingRem) {
  const scale = root / ROOT_BASE;          // text always scales with the root
  const factor = spacingRem ? scale : 1;   // spacing scales if rem, stays fixed if px
  const text = (CARD.titleBox + CARD.bodyBox * CARD.bodyLines + CARD.metadataBox) * scale;
  const space = (CARD.topSpace + CARD.withinGroup * 2 + CARD.bottomSpace) * factor;
  return { text, space, total: text + space, ratio: text / space };
}

console.log("\nunit  root  text (px)  space (px)  total (px)  text/space ratio");
for (const [name, isRem] of [["px ", false], ["rem", true]]) {
  for (const root of [16, 18, 20, 24]) {
    const c = card(root, isRem);
    console.log(
      `${name}  ${String(root).padStart(3)} ${c.text.toFixed(1).padStart(11)} ${c.space.toFixed(1).padStart(12)} ` +
        `${c.total.toFixed(1).padStart(12)} ${c.ratio.toFixed(3).padStart(19)}`
    );
  }
}

// Component height is derived: line box + 2 x vertical padding + 2 x border width.
// Thresholds: WCAG 2.5.8 Target Size (Minimum) 24 px, WCAG 2.5.5 Target Size (Enhanced) 44 px.
const MIN = 24;
const ENHANCED = 44;
const height = (box, padding, border) => box + 2 * padding + 2 * border;

const COMPONENTS = [
  { name: "button", box: 24, padding: 8, border: 1 },
  { name: "button-large", box: 24, padding: 12, border: 1 },
  { name: "text-field", box: 24, padding: 8, border: 1 },
  { name: "icon-button", box: 16, padding: 8, border: 1 },
  { name: "label", box: 20, padding: 4, border: 0 },
  { name: "list-row", box: 24, padding: 12, border: 0 },
];
console.log("\ncomponent      box  padding  border  height  2.5.8 (24)  2.5.5 (44)");
for (const c of COMPONENTS) {
  const y = height(c.box, c.padding, c.border);
  console.log(
    `${c.name.padEnd(14)} ${String(c.box).padStart(4)} ${String(c.padding).padStart(9)} ${String(c.border).padStart(6)} ` +
      `${String(y).padStart(8)} ${(y >= MIN ? "passed" : "FAILED").padStart(11)} ${(y >= ENHANCED ? "passed" : "FAILED").padStart(11)}`
  );
}

// Can the 44 px threshold be hit exactly with scale steps?
console.log("\nbox 24, border 1 -> padding step  height  2.5.5");
for (const padding of SCALE) {
  const y = height(24, padding, 1);
  if (y > 60) break;
  console.log(`${String(padding).padStart(33)} ${String(y).padStart(8)} ${(y >= ENHANCED ? "passes" : "misses").padStart(7)}`);
}
console.log("same calculation with border 0:");
for (const padding of SCALE) {
  const y = height(24, padding, 0);
  if (y > 60) break;
  console.log(`${String(padding).padStart(33)} ${String(y).padStart(8)} ${(y >= ENHANCED ? "passes" : "misses").padStart(7)}`);
}

// Nested corner radius: inner radius = outer radius - the padding between them.
// A fully round radius (999) is an exception: it does not pass to the inner element, which takes its own radius.
const RADIUS_SCALE = [0, 4, 8, 12, 999];
console.log("\nnested pair                 outer radius  padding  computed  used  in scale");
for (const [name, outer, padding] of [
  ["card / body", 8, 16],
  ["card / cover image", 8, 4],
  ["popover / title  ", 12, 8],
  ["popover / button", 12, 4],
  ["label / counter badge", 999, 4],
  ["button / icon frame", 4, 2],
]) {
  const computed = outer === 999 ? 999 : outer - padding;
  const used = outer === 999 ? 999 : Math.max(0, computed);
  console.log(
    `${name.padEnd(26)} ${String(outer).padStart(11)} ${String(padding).padStart(10)} ${String(computed).padStart(11)} ` +
      `${String(used).padStart(11)}  ${RADIUS_SCALE.includes(used) ? "yes" : "NO"}`
  );
}

// Icon scale is derived not from typography steps, but from line boxes.
console.log("\nline box  aligned icon size  in scale");
const ICON = [12, 16, 20, 24, 32];
for (const box of [20, 24, 32, 36, 48]) {
  // The icon is centered inside the line box; an icon larger than 2/3 of the box overflows the line.
  const target = Math.floor(((2 / 3) * box) / 4) * 4;
  console.log(
    `${String(box).padStart(9)} ${String(target).padStart(19)} ${(ICON.includes(target) ? "yes" : "NO").padStart(9)}`
  );
}
```

```
step  px  rem      root 16 px  root 18 px  root 20 px  integer (18)  integer (20)
   1   4  0.2500       4.00       4.50       5.00             no            yes
   2   8  0.5000       8.00       9.00      10.00            yes            yes
   3  12  0.7500      12.00      13.50      15.00             no            yes
   4  16  1.0000      16.00      18.00      20.00            yes            yes
   5  20  1.2500      20.00      22.50      25.00             no            yes
   6  24  1.5000      24.00      27.00      30.00            yes            yes
   7  32  2.0000      32.00      36.00      40.00            yes            yes
   8  48  3.0000      48.00      54.00      60.00            yes            yes
   9  64  4.0000      64.00      72.00      80.00            yes            yes
  10  96  6.0000      96.00     108.00     120.00            yes            yes

unit  root  text (px)  space (px)  total (px)  text/space ratio
px    16       100.0         48.0        148.0               2.083
px    18       112.5         48.0        160.5               2.344
px    20       125.0         48.0        173.0               2.604
px    24       150.0         48.0        198.0               3.125
rem   16       100.0         48.0        148.0               2.083
rem   18       112.5         54.0        166.5               2.083
rem   20       125.0         60.0        185.0               2.083
rem   24       150.0         72.0        222.0               2.083

component      box  padding  border  height  2.5.8 (24)  2.5.5 (44)
button           24         8      1       42      passed      FAILED
button-large     24        12      1       50      passed      passed
text-field       24         8      1       42      passed      FAILED
icon-button      16         8      1       34      passed      FAILED
label            20         4      0       28      passed      FAILED
list-row         24        12      0       48      passed      passed

box 24, border 1 -> padding step  height  2.5.5
                                4       34  misses
                                8       42  misses
                               12       50  passes
                               16       58  passes
same calculation with border 0:
                                4       32  misses
                                8       40  misses
                               12       48  passes
                               16       56  passes

nested pair                 outer radius  padding  computed  used  in scale
card / body                          8         16          -8           0  yes
card / cover image                   8          4           4           4  yes
popover / title                     12          8           4           4  yes
popover / button                    12          4           8           8  yes
label / counter badge              999          4         999         999  yes
button / icon frame                  4          2           2           2  NO

line box  aligned icon size  in scale
       20                  12       yes
       24                  16       yes
       32                  20       yes
       36                  24       yes
       48                  32       yes
```

#### A Relative Unit Does Not Give Integer Pixels

The first table shows the cost of switching to a relative unit. The scale steps are integers at a 16-pixel
root, and integers at a 20-pixel root too. At an 18-pixel root, three steps produce half pixels: 4.50,
13.50, and 22.50.

This is not a defect, it is the consequence of what a relative unit is: at a root size
that is not a multiple of 16, the scale produces fractions of a division by 16. The
result: **grid conformance is an invariant of design time, not of runtime.** The designer
works on a 4-pixel grid; the user sees a scaled version of that grid at their own root
size, and because the grid's ratios are preserved, the visual result is not broken.

Half-pixel values by themselves cause no problem; the paint stage rounds them at its own
resolution. The problem appears only where one alignment is computed two different ways —
if one edge is in pixels and its neighbor is a relative unit, the roundings can go in
different directions. The rule follows: **mixed units are not used on a single axis.**

#### Fixed Spacing Breaks Density

The second table gives the actual decision. The same card is measured in two regimes, at
four different root sizes.

In the pixel regime, text height grows with the root — from 100 to 150 pixels — but space
stays fixed at 48. The text-to-space ratio rises from 2.083 to 3.125, a 50 percent
increase: when the user enlarges the text, the interface does not grow proportionally, it
**compresses**.

In the relative-unit regime, the ratio is 2.083 at all four root sizes: the card grows
and the ratios are preserved. This is also what preserves the grouping-ratio criterion
from the Proximity and Grouping lesson — the ratio between within-group spacing and a
group boundary stays independent of root size.

This takes unit choice out of the realm of preference. Spacing tokens are written in a
relative unit, because spacing's job is not to state an absolute distance, it is to
**establish a ratio**, and the unit that preserves a ratio is the relative one.

Exceptions are recorded. Border width, focus-ring width, and hairline rules stay in
pixels: their job is drawing a visible boundary, not establishing a ratio, and they need
not grow with the root size.

#### Component Height Is Not Chosen, It Is Derived

The third table shows where size tokens come from: a component's height is not defined as
a token, it is computed from three tokens.

$$
\text{height} = \text{line box} + 2 \cdot \text{vertical padding} + 2 \cdot \text{border width}
$$

The rationale is the same as the Grid Systems lesson's column-width relation: writing a
dependent quantity as though it were an independent decision produces inconsistency at
the first change. If the button's height is written as 42 pixels, 42 becomes wrong the
moment the line box changes, and nobody notices.

The audit runs against two thresholds. All six components clear the 24 pixels WCAG 2.5.8
requires; only two clear the 44 pixels 2.5.5 requires. The standard button stays at 42
pixels — two pixels below the threshold.

#### Two Pixels Cannot Be Closed with a Scale Step

The fourth block shows why. With the line box fixed at 24 and the border at 1, padding is
chosen from the scale steps: step 8 produces 42, and the next step, 12, jumps straight to
50. The value in between, 44, has no counterpart on the scale. Reducing the border to
zero does not solve it either: step 8 gives 40, step 12 gives 48.

Three options remain, and all three carry a cost. The first is raising padding to 12 and
growing the button to 50 pixels, which lowers the Density Decisions lesson's
records-per-screen count. The second is adding a 10-pixel step to the scale; the Spacing
Scale lesson showed that the gap between 8 and 10 sits below the distinction threshold,
so the scale would gain a step with no payoff. The third is leaving the visual size at 42
and expanding the hit area to 44; the Screen Size and Input Type lesson calculated that
this needs the spacing between buttons to grow as well.

At the system level, the right decision is the third, and it is recorded: the standard
button appears at 42 pixels, its hit area is 44 pixels, and the spacing between buttons
is chosen from the 20-pixel step at minimum. This is the second example of a token valid
not on its own but together with its neighbor; the first was color pairings.

#### Radius and Icon Scale Are Derived Too

The fifth table computes nested corner radii. The inner radius comes from subtracting the
padding between the two curves from the outer radius; a negative result drops to zero,
keeping the two curves concentric. The card's body sits 16 pixels inside, so its radius
is zero; the cover image sits 4 pixels inside, so its radius is 4.

One row falls outside the scale: the icon frame inside the button wants a 2-pixel radius,
and 2 is not on the radius scale. The decision runs two ways — either the frame's padding
rises to 4 and the radius drops to zero, or 2 is added to the scale. The Repetition and
Consistency lesson measured small values in corner radius as indistinguishable, and that
finding points to the first option.

A fully round radius is defined as an exception and does not enter the derivation. The
badge inside it is also fully round; subtraction is meaningless here, because fully round
is not a radius, it is a rule.

The last table shows the icon scale derived not from typographic steps, but from line
boxes. An icon is centered inside the line box and must not exceed two-thirds of it;
beyond that, it overflows above and below the line. All five icon sizes computed for the
five line boxes find a match on the scale, because both the boxes and the icon scale are
fit to the 4-pixel grid.

#### Written Form

Spacing and size tokens are written in two layers; component heights are never written,
they are obtained by calculation.

```css
:root {
  /* primitive: scale steps, in relative units */
  --spacing-1: 0.25rem;
  --spacing-2: 0.5rem;
  --spacing-3: 0.75rem;
  --spacing-4: 1rem;
  --spacing-6: 1.5rem;

  /* primitive: measures that stay in pixels */
  --border-thin: 1px;
  --border-thick: 2px;
  --radius-1: 4px;
  --radius-2: 8px;

  /* semantic */
  --spacing-within-group: var(--spacing-2);
  --spacing-between-block: var(--spacing-4);
  --spacing-between-section: var(--spacing-6);
}

.button {
  --button-padding-y: var(--spacing-within-group);
  --button-padding-x: var(--spacing-between-block);

  padding: var(--button-padding-y) var(--button-padding-x);
  border: var(--border-thin) solid currentColor;
  border-radius: var(--radius-1);
  line-height: 24px;
}
```

The button's height appears nowhere in this declaration. Height falls out of the sum of
the line box and the padding, and whether that sum reaches 44 pixels is the audit's job.
A fixed height would let content overflow once text wraps or the root size grows.

#### Summary

- Unit choice is not a presentation detail, it is a decision that determines the scale's
  behavior: a relative unit inherits the user's root font-size preference.
- A relative unit does not give integer pixels at every root size; grid conformance is an
  invariant of design time, not of runtime. Mixed units are not used on a single axis.
- In a card whose spacing is written in pixels, when the root rises from 16 to 24 the
  text-to-space ratio climbs from 2.083 to 3.125; in a relative unit, the ratio stays
  2.083 at all four root sizes.
- Border width, focus ring, and hairline rules stay in pixels, because their job is to
  draw a boundary, not establish a ratio.
- Component height is not written as a token; it is derived from the line box, padding,
  and border width, and audited against target-size thresholds. The standard button
  stays at 42 pixels, and scale steps cannot hit the 44 threshold exactly; the decision
  is to keep the visual size and expand the hit area and the neighboring spacing.
- Corner radius in nested elements is derived by subtraction; fully round is not a value
  but a rule and does not enter the derivation. Icon scale is derived from the line box,
  not the typographic step.

#### Next Step

Scale steps, typography, and size tokens were built on the assumption of a single
viewport. When the interface changes width, column count, margin, and gutter change too,
and where that change happens has so far been a set of decisions given one at a time. The
next lesson turns breakpoints into a token set, audits the column arithmetic at every
breakpoint, tests spacing consistency, and reports breakpoints that change no layout
decision.

### Breakpoints and Grid

Source: https://academia.sh/en/courses/design-systems/breakpoints-and-grid

Turning breakpoints into tokens that carry a layout configuration, auditing column arithmetic at every breakpoint, detecting an orphaned breakpoint, and deriving bounds from content criteria.

Every token so far has been built on the assumption of a single viewport. Color roles
are independent of width, and so are typography steps. Spacing and size tokens are not:
the margin is 16 pixels on a narrow screen and 24 on a wide one, and the column count
changes entirely.

A **breakpoint** states where this change happens. The Principle of Responsive Design
lesson introduced a breakpoint as a media-query threshold; at the system level it is more
than that. A breakpoint is not a single number, it is a record carrying every layout
decision that holds past that threshold: column count, margin, gutter, and the
container's upper bound. This lesson turns that record into a token and audits the
arithmetic between records.

#### A Breakpoint Carries a Configuration

The Grid Systems lesson showed that column width is a dependent quantity:

$$
\text{column} = \frac{\text{container} - 2 \cdot \text{margin} - (n - 1) \cdot \text{gutter}}{n}
$$

The breakpoint set distributes this relation's inputs across width ranges. The audit's
job is to show that the relation gives a meaningful result in every range and that the
ranges are consistent with each other.

The audit seeks answers to five questions. Does column width come out as an integer? Do
the margin and gutter values come from the spacing scale? Does a breakpoint carry a
configuration different from the one before it? From which width are content criteria
met? What is the container's upper bound chosen against?

```js
// breakpoint.mjs — auditing the breakpoint set with column arithmetic

const SPACING_SCALE = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96];

// Every breakpoint carries a layout configuration. maxWidth null means the container is fluid.
const BREAKPOINTS = [
  { name: "narrow", min: 320, columns: 4, margin: 16, gutter: 16, maxWidth: null },
  { name: "medium", min: 640, columns: 8, margin: 24, gutter: 24, maxWidth: null },
  { name: "wide", min: 1024, columns: 12, margin: 24, gutter: 24, maxWidth: null },
  { name: "extra-wide", min: 1440, columns: 12, margin: 24, gutter: 24, maxWidth: 1200 },
  { name: "ultra", min: 1920, columns: 12, margin: 24, gutter: 24, maxWidth: 1200 },
];

const containerWidth = (bp, viewport) => (bp.maxWidth === null ? viewport : Math.min(viewport, bp.maxWidth));
const columnWidth = (container, bp) => (container - 2 * bp.margin - (bp.columns - 1) * bp.gutter) / bp.columns;
const span = (n, column, gutter) => n * column + (n - 1) * gutter;

console.log("breakpoint  min width  columns  margin  gutter  max width  container  column width  integer");
for (const bp of BREAKPOINTS) {
  const container = containerWidth(bp, bp.min);
  const s = columnWidth(container, bp);
  console.log(
    `${bp.name.padEnd(11)} ${String(bp.min).padStart(9)} ${String(bp.columns).padStart(7)} ${String(bp.margin).padStart(6)} ` +
      `${String(bp.gutter).padStart(6)} ${String(bp.maxWidth ?? "-").padStart(9)} ${String(container).padStart(9)} ` +
      `${s.toFixed(3).padStart(13)} ${(Number.isInteger(s) ? "yes" : "no").padStart(7)}`
  );
}

// Do the margin and gutter values come from the spacing scale?
console.log("\nbreakpoint  margin in scale  gutter in scale");
for (const bp of BREAKPOINTS) {
  console.log(
    `${bp.name.padEnd(11)} ${(SPACING_SCALE.includes(bp.margin) ? "yes" : "NO").padStart(15)} ` +
      `${(SPACING_SCALE.includes(bp.gutter) ? "yes" : "NO").padStart(15)}`
  );
}

// Orphaned breakpoint: carries the same layout configuration as the one before it.
const signature = (bp) => `${bp.columns}/${bp.margin}/${bp.gutter}/${bp.maxWidth}`;
console.log("\nbreakpoint  signature          same as previous");
for (let i = 0; i < BREAKPOINTS.length; i++) {
  const same = i > 0 && signature(BREAKPOINTS[i]) === signature(BREAKPOINTS[i - 1]);
  console.log(`${BREAKPOINTS[i].name.padEnd(11)} ${signature(BREAKPOINTS[i]).padEnd(18)} ${same ? "YES (orphaned)" : "no"}`);
}

// In the fluid range, at how many viewport widths does the column width come out as an integer?
console.log("\nbreakpoint  range             sample width  widths producing an integer");
for (const bp of BREAKPOINTS) {
  const upper = BREAKPOINTS[BREAKPOINTS.indexOf(bp) + 1]?.min ?? 2560;
  let integer = 0;
  let sample = 0;
  for (let w = bp.min; w < upper; w++) {
    sample++;
    if (Number.isInteger(columnWidth(containerWidth(bp, w), bp))) integer++;
  }
  console.log(
    `${bp.name.padEnd(11)} ${(bp.min + " - " + (upper - 1)).padEnd(17)} ${String(sample).padStart(14)} ` +
      `${(integer + "  (" + ((100 * integer) / sample).toFixed(1) + "%)").padStart(31)}`
  );
}

// Content criterion: the filter panel needs at least 270 px, the results list at least 480 px
// (values computed in the Grid Systems lesson).
const FILTER_MIN = 270;
const LIST_MIN = 480;
console.log("\nbreakpoint  split     filter (px)  list (px)  filter >=270  list >=480  decision");
for (const bp of BREAKPOINTS) {
  const container = containerWidth(bp, bp.min);
  const s = columnWidth(container, bp);
  let chosen = null;
  for (let a = 1; a < bp.columns; a++) {
    const filter = span(a, s, bp.gutter);
    const list = span(bp.columns - a, s, bp.gutter);
    if (filter >= FILTER_MIN && list >= LIST_MIN) {
      chosen = { a, filter, list };
      break;
    }
  }
  if (chosen) {
    console.log(
      `${bp.name.padEnd(11)} ${(chosen.a + "+" + (bp.columns - chosen.a)).padEnd(9)} ${chosen.filter.toFixed(1).padStart(11)} ` +
        `${chosen.list.toFixed(1).padStart(11)} ${"yes".padStart(13)} ${"yes".padStart(12)}  two columns`
    );
  } else {
    console.log(
      `${bp.name.padEnd(11)} ${"-".padEnd(9)} ${"-".padStart(11)} ${"-".padStart(11)} ${"-".padStart(13)} ${"-".padStart(12)}  one column`
    );
  }
}

// The smallest viewport width at which the two-column layout opens
const wide = BREAKPOINTS.find((bp) => bp.name === "wide");
let threshold = null;
for (let w = 320; w <= 1600; w++) {
  const s = columnWidth(containerWidth(wide, w), wide);
  const fits = [...Array(wide.columns - 1).keys()]
    .map((i) => i + 1)
    .some((a) => span(a, s, wide.gutter) >= FILTER_MIN && span(wide.columns - a, s, wide.gutter) >= LIST_MIN);
  if (fits) {
    threshold = w;
    break;
  }
}
console.log(`\nsmallest width at which the two-column layout opens with 12 columns: ${threshold} px`);
console.log(`wide breakpoint's minimum width: ${wide.min} px  ->  ${wide.min >= threshold ? "meets the content criterion" : "TOO EARLY"}`);

// Rationale for the max width: line length. At 16 px text, average character width is 7.005 px
// (measured in the Line Length and Spacing lesson); a 75-character upper bound is 525 px.
const CHARACTER_PX = 7.005;
console.log("\ncontainer  9-column span  characters  in 45-75 range");
for (const container of [1024, 1200, 1440, 1920]) {
  const bp = { columns: 12, margin: 24, gutter: 24 };
  const s = columnWidth(container, bp);
  const width = span(9, s, bp.gutter);
  const characters = width / CHARACTER_PX;
  console.log(
    `${String(container).padStart(9)} ${width.toFixed(1).padStart(14)} ${characters.toFixed(1).padStart(11)} ` +
      `${(characters <= 75 ? "yes" : "NO").padStart(16)}`
  );
}
```

```
breakpoint  min width  columns  margin  gutter  max width  container  column width  integer
narrow            320       4     16     16         -       320        60.000     yes
medium            640       8     24     24         -       640        53.000     yes
wide             1024      12     24     24         -      1024        59.333      no
extra-wide       1440      12     24     24      1200      1200        74.000     yes
ultra            1920      12     24     24      1200      1200        74.000     yes

breakpoint  margin in scale  gutter in scale
narrow                  yes             yes
medium                  yes             yes
wide                    yes             yes
extra-wide              yes             yes
ultra                   yes             yes

breakpoint  signature          same as previous
narrow      4/16/16/null       no
medium      8/24/24/null       no
wide        12/24/24/null      no
extra-wide  12/24/24/1200      no
ultra       12/24/24/1200      YES (orphaned)

breakpoint  range             sample width  widths producing an integer
narrow      320 - 639                    320                     80  (25.0%)
medium      640 - 1023                   384                     48  (12.5%)
wide        1024 - 1439                  416                      34  (8.2%)
extra-wide  1440 - 1919                  480                   480  (100.0%)
ultra       1920 - 2559                  640                   640  (100.0%)

breakpoint  split     filter (px)  list (px)  filter >=270  list >=480  decision
narrow      -                   -           -             -            -  one column
medium      -                   -           -             -            -  one column
wide        4+8             309.3       642.7           yes          yes  two columns
extra-wide  3+9             270.0       858.0           yes          yes  two columns
ultra       3+9             270.0       858.0           yes          yes  two columns

smallest width at which the two-column layout opens with 12 columns: 888 px
wide breakpoint's minimum width: 1024 px  ->  meets the content criterion

container  9-column span  characters  in 45-75 range
     1024          726.0       103.6               NO
     1200          858.0       122.5               NO
     1440         1038.0       148.2               NO
     1920         1398.0       199.6               NO
```

#### The Orphaned Breakpoint

The third table gives the most direct finding. The `ultra` breakpoint carries the same
configuration as `extra-wide`: 12 columns, 24-pixel margin, 24-pixel gutter, 1200-pixel
upper bound. No layout decision changes between the two.

A breakpoint like this produces no rule, only a name. Its cost is paid in three places:
the style file carries one more media query, the test list includes one more width, and
someone new tries to find out what changes at that width. An orphaned breakpoint is the
layout-side counterpart of the orphaned token from the previous lessons, and it is
removed the same way.

The number of breakpoints is not a target. Every breakpoint must change a layout
decision; if it does not, it is not a breakpoint, it is a number.

#### Integer Columns Cannot Be a Rule

The fourth table measures the most commonly misunderstood side of layout arithmetic. In
the fluid ranges, column width comes out as an integer very rarely: at 25 percent of
widths in the narrow range, 12.5 percent in the medium range, and only 8.2 percent in the
wide range.

In the two ranges where the container is fixed by the upper bound, the rate is 100
percent. The reason is clear: once the container is fixed, column width is fixed too,
and only a single value is computed.

What follows is that integer column width cannot be a design target. In a fluid grid,
column width changes continuously; forcing it to an integer means rounding the container
to integer multiples, and that leaves uneven gaps at the edges. The right approach is to
never write the column width at all: the grid is defined with fraction units, column
width is computed at the paint stage, and alignment comes from the grid itself.

The calculation's purpose becomes clear here too. Column width is computed not to be
declared, but to **test content criteria**: how many pixels a block gets, and whether
that is enough.

#### Breakpoints Are Derived from Content, Not Devices

The fifth and sixth blocks answer where breakpoint values should come from. The filter
panel wants at least 270 pixels, the results list at least 480; these values were
computed from content criteria in the Grid Systems lesson.

At the narrow and medium breakpoints, no division meets both criteria at once; the
layout stays single-column. At the wide breakpoint, the 4+8 division works; at
extra-wide, 3+9.

The sixth block shows a mismatch. With the 12-column configuration, the smallest width at
which the two-column layout can open is 888 pixels; but the `wide` breakpoint's minimum
is 1024. Across the 136-pixel band between them, the layout stays single-column even
though the content fits two columns.

This is a finding about where breakpoint values are chosen from. 1024 is not a content criterion, it is a
device width; it comes from habit, not calculation. The value derived from content is 888, and rounding it
to something close on the spacing scale gives 896. This is the layout-side counterpart of asking whether a
decision will still be right in five years: a breakpoint tied to device widths goes wrong once devices
change; a breakpoint tied to a content criterion stays right as long as the content does not change.

#### The Upper Bound Is a Content Decision Too

The last table looks for the rationale behind the container's upper bound and gives an unexpected result.
The nine-column results-list area is 726 pixels wide in a 1024-pixel container, 858 at 1200, 1038 at 1440,
1398 at 1920. With the average character width measured in the Line Length and Spacing lesson, that comes
to 103.6, 122.5, 148.2, and 199.6 characters respectively. All four are outside the 45–75 range.

This shows that the upper bound does not solve line length on its own. The 1200-pixel
upper bound brings the results-list area down to 858 pixels, but 858 pixels still means a
122-character line.

The right reading is this: the container's upper bound is the **layout's** upper bound,
not the **text's**. The text's upper bound is a separate token, and it is tied to a
character count, not a column count. The summary paragraph in a record card uses its own
measure limit, not the full nine columns. Confusing the two either narrows the layout
needlessly or lets the text run too long to read.

#### Written Form

Breakpoints are defined as tokens, but they cannot be used directly in media-query
conditions: a media-query condition cannot read a custom property. This is the second
example of the boundary between the token system and style; the first was the contrast
contract.

```css
:root {
  --breakpoint-medium: 640px;
  --breakpoint-wide: 896px;
  --breakpoint-extra-wide: 1440px;

  --grid-columns: 4;
  --grid-margin: var(--spacing-4);
  --grid-gutter: var(--spacing-4);
  --grid-max-width: none;
}

@media (min-width: 640px) {
  :root {
    --grid-columns: 8;
    --grid-margin: var(--spacing-6);
    --grid-gutter: var(--spacing-6);
  }
}

@media (min-width: 896px) {
  :root { --grid-columns: 12; }
}

@media (min-width: 1440px) {
  :root { --grid-max-width: 1200px; }
}

.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), 1fr);
  gap: var(--grid-gutter);
  padding-inline: var(--grid-margin);
  max-width: var(--grid-max-width);
  margin-inline: auto;
}
```

Because the numbers in the query conditions are written by hand, they carry the risk of
drifting from the token values. The fix is a build step that generates query conditions
from the token file: breakpoint values stay in a single source, and the style file is
generated from it. The division of labor from the Preprocessors and Postprocessors
lesson applies here — the query condition belongs to build time, the column count to
runtime.

Note that column width appears nowhere. Because the grid is defined with fraction units,
the width is computed at the paint stage; the calculation in this lesson was done not to
produce a declaration, but to test content criteria.

#### Summary

- A breakpoint is not a number, it is a configuration record carrying every layout
  decision that holds past that threshold: column count, margin, gutter, and the
  container's upper bound.
- A breakpoint carrying the same configuration as the one before it is orphaned; it
  produces no rule, only maintenance load, and it is removed.
- In fluid ranges, column width rarely comes out as an integer — only 8.2 percent of
  widths in the wide range. Integer columns cannot be a design target; the grid is
  defined with fraction units. The calculation's purpose is not to produce a declaration
  but to test content criteria.
- Breakpoint values are derived from a content criterion, not a device width; in the
  measured set, the two-column layout can open at 888 pixels while the breakpoint sits
  at 1024.
- The container's upper bound is the layout's bound, not the text's; the text measure is
  a separate token tied to a character count.
- Media-query conditions cannot read a custom property; breakpoint values are kept in a
  single source and query conditions are generated at build time.

#### Next Step

The foundation layer is complete here: color, typography, spacing, size, and layout
decisions are all named and derivable. One gap remains. In the Color Tokens lesson, only
eight of twenty roles had a dark-theme counterpart; twelve cells were empty. A system
carrying more than one theme does not mean writing a separate value list for each theme,
it means deriving the themes from a single rule. The next lesson builds that derivation:
it produces the dark theme and a brand theme from the light theme, closes the
missing-token report, and generates style output from the token source.

### Theme Management

Source: https://academia.sh/en/courses/design-systems/theme-management

Deriving themes from a source table with a single rule, the contracts mirroring preserves and misses, keeping the correction as an exception list, testing the brand theme with a single family, and measuring role–theme coverage.

The foundation layer was completed in the previous lesson: color, typography, spacing,
size, and layout decisions are named and derivable. What remains is the gap left over
from the Color Tokens lesson, where only eight of twenty roles had a dark-theme
counterpart and the missing cells were waiting to be filled by hand.

Filling it by hand does not work, for two reasons. First, every new role has to be
written once more in every theme, and this work grows as the product of the role count
and the theme count. Second, a hand-written value carries no rationale — why a role
lands on that step in the dark theme is not recorded anywhere. This lesson builds a
theme not as a value list, but as a table **derived** from a source theme, and measures
where the derivation falls short.

#### A Theme Is a Derivation Rule

A theme is the table binding every role in the semantic layer to a primitive value, and
the difference between two themes is the rule for that binding. The simplest rule for the
dark theme is **step mirroring**: the two ends of the lightness ramp swap places, `000`
with `900`, `050` with `800`. Surface moves from lightest to darkest, text from darkest
to lightest; role names and the contract stay the same.

The rule's value is that a single line carries every role — when a new role is added,
its dark counterpart is not written separately, the rule already covers it. The rule's
limit is found by measuring: mirroring does not preserve the contrast ratio, because the
lightness ramp is not symmetric on the axis of perceived brightness.

The script below runs both contracts in every theme: the threshold contract (the minimum
contrast ratio a foreground–ground pair must carry) and the **state separation** contract
(the smallest ratio for a state role to be distinguishable from its base role, adopted as
1.2 in the Interaction States lesson).

```js
// theme.mjs — deriving themes from a single rule and auditing the contract in every theme

function hslRgb(h, s, l) {
  s /= 100; l /= 100;
  const k = (n) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  return [f(0), f(8), f(4)].map((v) => Math.round(v * 255));
}
const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");
function channel(v) {
  const s = v / 255;
  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

// Primitive layer (the ramp from the Color Tokens lesson).
const LIGHTNESS = { "000": 100, "050": 97, 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const FAMILY = { neutral: [214, 8], primary: [214, 62], success: [145, 55], warning: [38, 85], error: [8, 68] };

// Semantic layer: role -> [family, step]. The light theme is the source.
const LIGHT = {
  surface: ["neutral", "000"], "surface-secondary": ["neutral", "050"], "surface-selected": ["primary", "100"],
  border: ["neutral", "500"], "text-primary": ["neutral", "900"], "text-secondary": ["neutral", "600"],
  "action-primary": ["primary", "600"], "action-primary-on": ["neutral", "000"], "action-primary-hover": ["primary", "700"],
  "action-primary-active": ["primary", "800"], "action-disabled": ["neutral", "200"],
  "action-disabled-on": ["neutral", "500"], "focus-ring": ["primary", "600"],
  "success-ground": ["success", "100"], "success-text": ["success", "800"],
  "warning-ground": ["warning", "100"], "warning-text": ["warning", "800"],
  "error-ground": ["error", "100"], "error-text": ["error", "700"],
};

// Contract: foreground, ground, threshold.
const CONTRACT = [
  ["text-primary", "surface", 4.5], ["text-primary", "surface-secondary", 4.5],
  ["text-secondary", "surface", 4.5], ["text-primary", "surface-selected", 4.5],
  ["border", "surface", 3.0], ["action-primary", "surface", 3.0],
  ["action-primary-on", "action-primary", 4.5], ["action-primary-on", "action-primary-hover", 4.5],
  ["action-primary-on", "action-primary-active", 4.5], ["focus-ring", "surface", 3.0],
  ["success-text", "success-ground", 4.5], ["warning-text", "warning-ground", 4.5],
  ["error-text", "error-ground", 4.5],
];

// Derivation rule 1: step mirroring. The lightness ramp is reversed.
const MIRROR = { "000": "900", "050": "800", 100: "700", 200: "600", 300: "500", 400: "400", 500: "300", 600: "200", 700: "100", 800: "050", 900: "000" };
const mirror = (source) =>
  Object.fromEntries(Object.entries(source).map(([role, [family, b]]) => [role, [family, MIRROR[b]]]));

// Derivation rule 2: brand theme. Only the "primary" family's hue and saturation change.
const brandFamily = (hue, saturation) => ({ ...FAMILY, primary: [hue, saturation] });

function audit(roleTable, familyTable) {
  const toColor = (role) => {
    const [family, b] = roleTable[role];
    return hslRgb(familyTable[family][0], familyTable[family][1], LIGHTNESS[b]);
  };
  return CONTRACT.map(([fg, ground, threshold]) => {
    const k = contrast(toColor(fg), toColor(ground));
    return { fg, ground, threshold, k, passed: k >= threshold };
  });
}
const failing = (result) => result.filter((s) => !s.passed);
const printContract = (title, result) => {
  console.log(`\n${title}`);
  console.log("foreground               ground                 ratio  threshold  result");
  for (const s of result) {
    console.log(
      `${s.fg.padEnd(24)} ${s.ground.padEnd(22)} ${s.k.toFixed(2).padStart(5)} ${s.threshold.toFixed(1).padStart(6)}  ${s.passed ? "passed" : "FAILED"}`
    );
  }
  console.log(`missing the threshold: ${failing(result).length} / ${result.length}`);
};

// Second contract: state derivatives must be distinguishable from the base (Interaction States: 1.2).
const SEPARATION = [
  ["action-primary-hover", "action-primary"], ["action-primary-active", "action-primary-hover"],
  ["surface-secondary", "surface"], ["action-disabled", "surface"],
];
const SEPARATION_THRESHOLD = 1.2;
function auditSeparation(roleTable, familyTable) {
  const toColor = (role) => {
    const [family, b] = roleTable[role];
    return hslRgb(familyTable[family][0], familyTable[family][1], LIGHTNESS[b]);
  };
  return SEPARATION.map(([state, base]) => {
    const k = contrast(toColor(state), toColor(base));
    return { state, base, k, passed: k >= SEPARATION_THRESHOLD };
  });
}
const printSeparation = (title, result) => {
  console.log(`\n${title}`);
  console.log("state role               base role              ratio  threshold  result");
  for (const s of result) {
    console.log(
      `${s.state.padEnd(24)} ${s.base.padEnd(22)} ${s.k.toFixed(3).padStart(5)} ${SEPARATION_THRESHOLD.toFixed(1).padStart(6)}  ${s.passed ? "passed" : "FAILED"}`
    );
  }
  console.log(`not distinguishable: ${result.filter((s) => !s.passed).length} / ${result.length}`);
};

console.log("--- 1. light theme (source) ---");
printContract("light theme contract", audit(LIGHT, FAMILY));
printSeparation("light theme separation", auditSeparation(LIGHT, FAMILY));

console.log("\n--- 2. dark theme derived by mirroring ---");
const DARK = mirror(LIGHT);
printContract("dark theme contract (raw derivation)", audit(DARK, FAMILY));
printSeparation("dark theme separation (raw derivation)", auditSeparation(DARK, FAMILY));

console.log("\n--- 3. correction in the dark theme ---");
// Mirroring only shifts the step of the pairs that are left failing; it is a correction, not a rule.
const DARK_CORRECTION = {
  border: ["neutral", "400"],
  "action-primary": ["primary", "300"],
  "action-primary-on": ["neutral", "900"],
  "action-primary-hover": ["primary", "200"],
  "action-primary-active": ["primary", "100"],
  "focus-ring": ["primary", "300"],
};
const DARK2 = { ...DARK, ...DARK_CORRECTION };
printContract("dark theme contract (after correction)", audit(DARK2, FAMILY));
printSeparation("dark theme separation (after correction)", auditSeparation(DARK2, FAMILY));
console.log(`correction count: ${Object.keys(DARK_CORRECTION).length} roles / ${Object.keys(LIGHT).length}`);

console.log("\n--- 4. brand theme: only the primary family's hue changes ---");
for (const [name, hue, saturation] of [["purple", 276, 62], ["green", 145, 62], ["orange", 28, 62]]) {
  const families = brandFamily(hue, saturation);
  const light = failing(audit(LIGHT, families));
  const dark = failing(audit(DARK2, families));
  console.log(
    `${name.padEnd(8)} hue ${String(hue).padStart(3)}  light theme failing: ${light.length}  dark theme failing: ${dark.length}` +
      (light.length ? "  (" + light.map((s) => s.fg + "/" + s.ground).join(", ") + ")" : "")
  );
}

console.log("\n--- 5. coverage: role x theme cells ---");
const THEMES = { light: LIGHT, dark: DARK2 };
let missing = 0;
for (const role of Object.keys(LIGHT)) {
  for (const [name, table] of Object.entries(THEMES)) if (!(role in table)) { missing++; console.log(`  missing: ${role} / ${name}`); }
}
console.log(`defined cells: ${Object.keys(LIGHT).length * Object.keys(THEMES).length - missing} / ${Object.keys(LIGHT).length * Object.keys(THEMES).length}`);

console.log("\n--- 6. style output (first four roles) ---");
const styleOutput = (selector, table, families) =>
  [`${selector} {`]
    .concat(Object.entries(table).slice(0, 4).map(([role, [family, b]]) =>
      `  --color-${role}: ${hex(hslRgb(families[family][0], families[family][1], LIGHTNESS[b]))};`))
    .concat(["}"]).join("\n");
console.log(styleOutput(":root", LIGHT, FAMILY));
console.log(styleOutput('[data-theme="dark"]', DARK2, FAMILY));
```

```
--- 1. light theme (source) ---

light theme contract
foreground               ground                 ratio  threshold  result
text-primary             surface                15.74    4.5  passed
text-primary             surface-secondary      14.70    4.5  passed
text-secondary           surface                 5.89    4.5  passed
text-primary             surface-selected       12.82    4.5  passed
border                   surface                 4.11    3.0  passed
action-primary           surface                 6.50    3.0  passed
action-primary-on        action-primary          6.50    4.5  passed
action-primary-on        action-primary-hover    9.05    4.5  passed
action-primary-on        action-primary-active  12.68    4.5  passed
focus-ring               surface                 6.50    3.0  passed
success-text             success-ground          7.54    4.5  passed
warning-text             warning-ground          7.57    4.5  passed
error-text               error-ground            7.21    4.5  passed
missing the threshold: 0 / 13

light theme separation
state role               base role              ratio  threshold  result
action-primary-hover     action-primary         1.393    1.2  passed
action-primary-active    action-primary-hover   1.401    1.2  passed
surface-secondary        surface                1.071    1.2  FAILED
action-disabled          surface                1.459    1.2  passed
not distinguishable: 1 / 4

--- 2. dark theme derived by mirroring ---

dark theme contract (raw derivation)
foreground               ground                 ratio  threshold  result
text-primary             surface                15.74    4.5  passed
text-primary             surface-secondary      11.80    4.5  passed
text-secondary           surface                10.78    4.5  passed
text-primary             surface-selected        9.05    4.5  passed
border                   surface                 8.23    3.0  passed
action-primary           surface                10.29    3.0  passed
action-primary-on        action-primary         10.29    4.5  passed
action-primary-on        action-primary-hover   12.82    4.5  passed
action-primary-on        action-primary-active  14.63    4.5  passed
focus-ring               surface                10.29    3.0  passed
success-text             success-ground          5.02    4.5  passed
warning-text             warning-ground          5.09    4.5  passed
error-text               error-ground            7.21    4.5  passed
missing the threshold: 0 / 13

dark theme separation (raw derivation)
state role               base role              ratio  threshold  result
action-primary-hover     action-primary         1.246    1.2  passed
action-primary-active    action-primary-hover   1.141    1.2  FAILED
surface-secondary        surface                1.333    1.2  passed
action-disabled          surface                2.670    1.2  passed
not distinguishable: 1 / 4

--- 3. correction in the dark theme ---

dark theme contract (after correction)
foreground               ground                 ratio  threshold  result
text-primary             surface                15.74    4.5  passed
text-primary             surface-secondary      11.80    4.5  passed
text-secondary           surface                10.78    4.5  passed
text-primary             surface-selected        9.05    4.5  passed
border                   surface                 5.75    3.0  passed
action-primary           surface                 7.63    3.0  passed
action-primary-on        action-primary          7.63    4.5  passed
action-primary-on        action-primary-hover   10.29    4.5  passed
action-primary-on        action-primary-active  12.82    4.5  passed
focus-ring               surface                 7.63    3.0  passed
success-text             success-ground          5.02    4.5  passed
warning-text             warning-ground          5.09    4.5  passed
error-text               error-ground            7.21    4.5  passed
missing the threshold: 0 / 13

dark theme separation (after correction)
state role               base role              ratio  threshold  result
action-primary-hover     action-primary         1.349    1.2  passed
action-primary-active    action-primary-hover   1.246    1.2  passed
surface-secondary        surface                1.333    1.2  passed
action-disabled          surface                2.670    1.2  passed
not distinguishable: 0 / 4
correction count: 6 roles / 19

--- 4. brand theme: only the primary family's hue changes ---
purple   hue 276  light theme failing: 0  dark theme failing: 0
green    hue 145  light theme failing: 1  dark theme failing: 0  (action-primary-on/action-primary)
orange   hue  28  light theme failing: 0  dark theme failing: 0

--- 5. coverage: role x theme cells ---
defined cells: 38 / 38

--- 6. style output (first four roles) ---
:root {
  --color-surface: #ffffff;
  --color-surface-secondary: #f7f7f8;
  --color-surface-selected: #dee9f7;
  --color-border: #757e8a;
}
[data-theme="dark"] {
  --color-surface: #212327;
  --color-surface-secondary: #34383d;
  --color-surface-selected: #1e4980;
  --color-border: #969da6;
}
```

#### What Mirroring Preserves and What It Misses

The first block repeats the light theme's state: all thirteen threshold-contract pairs
pass, while in state separation the secondary surface fails at a 1.071 ratio — the
defect identified in the Color Tokens lesson, kept here deliberately, since the
derivation's source is not a flawless table but the real one.

The second block is the result of raw mirroring, and it shows two things. The entire
threshold contract passes in the dark theme too, and the ratios mostly **rise**. The
separation threshold the secondary surface missed in the light theme passes in the dark
theme at 1.333: same rule, same roles, different result — this is why assuming a
decision validated in one theme holds in another is wrong.

The same block shows where the rule misses too. The ratio between the active state and
the hover state drops to 1.141 in the dark theme and stays below the threshold. The
reason is the ramp's structure: mirroring carries steps `700` and `800` to `100` and
`050`, and the lightness difference between these two steps is smaller than at the other
end. The rule is not broken; its assumption — that the ramp is symmetric — is only
approximately true.

#### The Correction Is an Exception List

The third block applies the correction, which does not change the rule; it is a six-line
exception list written on top of the table the rule produces. Six roles are about a third
of nineteen, and that ratio is proof the rule works: thirteen roles got the right value
with no intervention at all.

Keeping the exception as a list does two things: the audit shows the exceptions also
clear the contract — after the correction, both contracts close with zero violations —
and a reviewer, by looking at the list, sees exactly where the rule falls short, which
feeds the next ramp adjustment. Writing the values into the table one by one would lose
this distinction: which value came from the rule and which from a decision would no
longer be visible.

#### Brand Theme: Testing the One Family That Changes

The fourth block applies the second derivation rule. A brand theme changes only the
`primary` family's hue and saturation, leaving the role table untouched: role names,
step bindings, and the contract stay the same, and the only change is two numbers in the
primitive layer.

The result shows that a brand color is not a free choice. Purple and orange hues clear
the contract in both themes; green fails one pairing in the light theme, where the text
on top of the primary action cannot hit the 4.5 threshold against the green `600` step.
This is not a design preference, it is a measurable constraint, and it has two routes —
either the action role binds to a darker step, or the on-action text is chosen from the
family's own light end. Whichever is chosen, the decision is written into the brand
theme's token, and the audit confirms it on the next run.

#### Coverage and Output

The fifth block counts coverage: nineteen roles times two themes is thirty-eight cells,
and all are defined. The twelve empty cells from the Color Tokens lesson were closed not
by filling them in by hand, but by writing a rule. Coverage measurement works the same
way once a theme is added — the moment a new theme's rule is written, every role gets
its counterpart.

The sixth block generates style output from the token source. The difference between
themes is gathered in one place: the light theme's values on the root selector, the dark
theme's on the selector carrying the theme attribute. Components carry no theme
information at all, only the role name. This is the system-level counterpart of the
custom-property pattern established under Style Architecture.

Matching the operating system's preference is added on top of this with a single media
query; the preference itself was covered in the User Preferences lesson. The system's
responsibility is not reading the preference, it is making sure the table answering the
preference is complete and audited.

#### Summary

- A theme is not a value list written role by role, it is a mapping derived from a
  source table; the rule is written once, and new roles follow it automatically.
- The mirroring rule preserves the threshold contract but not the contrast ratios: the
  secondary surface, which misses the separation threshold in the light theme, passes in
  the dark theme, while the active state drops to 1.141 and fails instead. Every
  contract is rerun in every theme.
- The correction does not change the rule; it is an exception list written on top of it.
  Six of nineteen roles were corrected, thirteen came from the rule, and which came from
  where is recorded.
- A brand theme changes only one family's hue and saturation; these two numbers can drop
  the contract — at the green hue, the text on top of the primary action cannot hit the
  threshold in the light theme.
- Coverage is measured through the product of the role set and the theme set: all
  thirty-eight cells are defined. In the style output, the difference between themes is
  gathered in a single selector, and components read only the role name.

#### Next Step

The foundation layer closes with this lesson: values are named, bound to scales, and
themes are derived from a single rule and audited. But a system's user works with the
component, not the token. Whoever writes the Borrow button searches not for the
`--color-action-primary` value but for the "primary button" component; failing to find
it, they write their own, and the system's coverage quietly narrows. The next topic
takes up this layer, and its first lesson starts with the catalog itself: which
components enter the system, how a component's maturity level is measured, and how a
need the catalog does not cover gets met.

## Catalog and Governance

Component catalog scope and maturity levels, the shared naming layer between design and code, documentation coverage, versioning and breaking-change management, the contribution process, governance models, adoption measurement, and toolchain synchronization.

### Component Catalog

Source: https://academia.sh/en/courses/design-systems/component-catalog

The scope criterion that decides whether a component earns a place in the catalog, the exit conditions for maturity levels, and the audit that ties catalog health to usage share.

Once the token layer is complete, the system's values are named and derivable: a
color has a name, a spacing step has a number, a theme's values compute from the
primitive layer. But components use these values, and the component layer cannot be
derived by computation the way tokens can. What enters the catalog, how ready it is,
and who owns it are separate decisions.

This lesson separates three questions: by what criterion does a component earn a place
in the catalog, what makes a cataloged component "ready," and by what number is a
catalog's health actually judged?

#### What the Catalog Is and Is Not

A component catalog is not a list of the components a system offers; it is the
structure that records, for each component, **who owns it, what maturity level it is
at, and what contract it makes**. Twenty components in a repository does not make a
catalog — a catalog is defined by its entry and exit rules.

In a component library without a catalog, every component looks equally trustworthy:
the person using it cannot tell whether it will still look the same in six months or
its name will change tomorrow. A catalog reduces that uncertainty to a single maturity
label per component.

#### The Scope Criterion

A catalog's natural tendency is to grow by absorbing everything that appears in the
interface. That tendency has two costs: every component must be documented, versioned,
and migrated, and the larger the catalog gets, the harder it is to search.

The decision to bring a component into scope comes down to three questions:

- **Recurrence.** How many places is the component used in? Something used in a single
  place belongs to the product, not the catalog.
- **Ownership spread.** How many teams use it? A component used by a single team
  evolves faster in that team's own repository; pulling it into the catalog only adds
  review overhead.
- **Decision bearing.** Does the component carry a design decision? The "Borrow" button
  carries the primary-action-color decision; a record row's column order does not — that
  is the product's information-architecture decision.

Two of these three questions can be turned into numbers. The program below takes the
catalog interface system's twenty-two components, derives the maturity distribution,
checks whether each meets its level's criterion, and lists the ones outside the scope
criterion.

```js
// catalog.mjs — component catalog scope and maturity audit

// Each record carries a component's state in the catalog: maturity level, the
// evidence fields that level requires, version, and how many places and teams use it.
const CATALOG = [
  { name: "button",              level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "2.3.0", usage: 412, team: 6 },
  { name: "link",                level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.4.2", usage: 268, team: 6 },
  { name: "text-field",          level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "2.0.1", usage: 143, team: 5 },
  { name: "checkbox",            level: "stable",      doc: true,  example: true,  counterExample: false, test: true,  access: true,  migrationNote: false, version: "1.2.0", usage:  96, team: 5 },
  { name: "radio-group",         level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: false, migrationNote: false, version: "1.1.0", usage:  34, team: 4 },
  { name: "search-field",        level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.6.0", usage:  71, team: 4 },
  { name: "badge",               level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.0.3", usage: 188, team: 6 },
  { name: "card",                level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.3.1", usage: 157, team: 6 },
  { name: "breadcrumb",          level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.0.0", usage:  29, team: 3 },
  { name: "pagination",          level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.2.4", usage:  41, team: 4 },
  { name: "notification-banner", level: "stable",      doc: true,  example: false, counterExample: false, test: true,  access: true,  migrationNote: false, version: "0.9.0", usage:  63, team: 5 },
  { name: "empty-state",         level: "stable",      doc: true,  example: true,  counterExample: true,  test: true,  access: true,  migrationNote: false, version: "1.1.2", usage:  52, team: 5 },
  { name: "skeleton",            level: "candidate",   doc: true,  example: true,  counterExample: false, test: true,  access: false, migrationNote: false, version: "0.7.0", usage:  38, team: 3 },
  { name: "tabs",                level: "candidate",   doc: true,  example: true,  counterExample: false, test: false, access: false, migrationNote: false, version: "0.5.1", usage:  24, team: 3 },
  { name: "dropdown",            level: "candidate",   doc: false, example: true,  counterExample: false, test: false, access: false, migrationNote: false, version: "0.4.0", usage:  57, team: 4 },
  { name: "accordion",           level: "candidate",   doc: true,  example: true,  counterExample: false, test: false, access: false, migrationNote: false, version: "0.3.2", usage:  17, team: 2 },
  { name: "modal",               level: "candidate",   doc: true,  example: false, counterExample: false, test: true,  access: false, migrationNote: false, version: "0.6.0", usage:  45, team: 5 },
  { name: "tooltip",             level: "experimental", doc: false, example: false, counterExample: false, test: false, access: false, migrationNote: false, version: "0.1.0", usage:  12, team: 2 },
  { name: "table",               level: "experimental", doc: false, example: true,  counterExample: false, test: false, access: false, migrationNote: false, version: "0.2.1", usage:   9, team: 2 },
  { name: "record-row",          level: "experimental", doc: false, example: false, counterExample: false, test: false, access: false, migrationNote: false, version: "0.1.0", usage:   6, team: 1 },
  { name: "filter-panel",        level: "candidate",   doc: true,  example: true,  counterExample: false, test: false, access: false, migrationNote: false, version: "0.3.0", usage:   4, team: 1 },
  { name: "loading-indicator",   level: "deprecated", doc: true, example: true, counterExample: true,  test: true,  access: true,  migrationNote: true,  version: "1.0.0", usage:  31, team: 4 },
];

// Exit criterion for each level: the fields that must be filled to remain at that level.
const CRITERIA = {
  experimental: [],
  candidate:    ["doc", "example"],
  stable:       ["doc", "example", "counterExample", "test", "access"],
  deprecated:   ["doc", "migrationNote"],
};

// Stable level additionally requires version 1.0.0 or above.
const majorVersion = (s) => Number(s.split(".")[0]);

const LEVELS = ["experimental", "candidate", "stable", "deprecated"];
const LABEL = { experimental: "experimental", candidate: "candidate", stable: "stable", deprecated: "deprecated" };

const totalUsage = CATALOG.reduce((t, b) => t + b.usage, 0);

console.log("maturity level   components   usage   usage share");
for (const d of LEVELS) {
  const group = CATALOG.filter((b) => b.level === d);
  const k = group.reduce((t, b) => t + b.usage, 0);
  console.log(
    `${LABEL[d].padEnd(17)} ${String(group.length).padStart(9)} ${String(k).padStart(9)} ` +
    `${((k / totalUsage) * 100).toFixed(1).padStart(11)}%`
  );
}
console.log(`${"total".padEnd(17)} ${String(CATALOG.length).padStart(9)} ${String(totalUsage).padStart(9)} ${"100.0".padStart(11)}%`);

// Components that fail the criterion for their level.
console.log("\ncomponent             level         missing criterion");
let violation = 0;
for (const b of CATALOG) {
  const missing = CRITERIA[b.level].filter((field) => !b[field]);
  if (b.level === "stable" && majorVersion(b.version) < 1) missing.push(`version<1.0.0 (${b.version})`);
  if (missing.length === 0) continue;
  violation++;
  console.log(`${b.name.padEnd(21)} ${LABEL[b.level].padEnd(13)} ${missing.join(", ")}`);
}
console.log(`components failing their criterion: ${violation}/${CATALOG.length}`);

// Scope criterion: a component earns a place in the catalog only when used by at
// least two teams and in at least eight places. Below that it belongs to the
// product, not the catalog.
const TEAM_THRESHOLD = 2;
const USAGE_THRESHOLD = 8;
console.log("\noutside the scope criterion (team < 2 or usage < 8)");
console.log("component             team   usage");
for (const b of CATALOG) {
  if (b.team >= TEAM_THRESHOLD && b.usage >= USAGE_THRESHOLD) continue;
  console.log(`${b.name.padEnd(21)} ${String(b.team).padStart(4)} ${String(b.usage).padStart(10)}`);
}

// One-line health indicator: how much of the usage falls on stable components.
const stableUsage = CATALOG.filter((b) => b.level === "stable").reduce((t, b) => t + b.usage, 0);
const passingCriterion = CATALOG.filter((b) => {
  const missing = CRITERIA[b.level].filter((field) => !b[field]);
  return missing.length === 0 && !(b.level === "stable" && majorVersion(b.version) < 1);
});
const passingUsage = passingCriterion.reduce((t, b) => t + b.usage, 0);
console.log(`\nusage share at stable level        : ${((stableUsage / totalUsage) * 100).toFixed(1)}%`);
console.log(`usage share of components passing criterion : ${((passingUsage / totalUsage) * 100).toFixed(1)}%`);
```

```
maturity level   components   usage   usage share
experimental              3        27         1.5%
candidate                 6       185        10.3%
stable                   12      1554        86.5%
deprecated                1        31         1.7%
total                    22      1797       100.0%

component             level         missing criterion
checkbox              stable        counterExample
radio-group           stable        access
notification-banner   stable        example, counterExample, version<1.0.0 (0.9.0)
dropdown              candidate     doc
modal                 candidate     example
components failing their criterion: 5/22

outside the scope criterion (team < 2 or usage < 8)
component             team   usage
record-row               1          6
filter-panel             1          4

usage share at stable level        : 86.5%
usage share of components passing criterion : 83.6%
```

#### Maturity Levels

The output's first block shows the four levels; what they mean comes from how
different a promise each makes:

- **Experimental.** The system says this component's shape and name may still change,
  and the team using it absorbs that change itself. Documentation is not required;
  only the component's level must be visible.
- **Candidate.** The shape has settled but the contract is not yet locked. Documentation
  and at least one example are required; a breaking change can still happen, but it is
  announced.
- **Stable.** The contract is locked; a breaking change can only arrive with a major
  version bump and a migration path. This promise ties to the output's five evidence
  fields: documentation, example, counter-example, test, and accessibility review.
- **Deprecated.** The component should no longer be used but still works. This level's
  criterion is the migration note: what to use instead must be written down.

Levels are defined not as labels but as **exit criteria**: saying "this component is
stable" claims the five listed fields are filled. The output's second block shows that
claim failing in three places — `checkbox` has no counter-example, `radio-group` has
not passed accessibility review, and `notification-banner` is marked stable with three
gaps at once. These rows are not a bug list but a **demote-or-complete** work list, one
the criterion alone made possible.

Level transitions are not one-directional: a component that stays stable while failing
accessibility review can be demoted to candidate. A demotion differs from a breaking
change — the component still works, but the system's promise of stability has been
withdrawn.

#### What Falls Outside Scope

The third block runs the scope criterion: `record-row` and `filter-panel` are used by
a single team and fewer than eight places. Both are parts of the catalog interface
itself and belong to the product, not the system.

This resolves a confusion born from the system first being built inside a product:
because it grew out of the catalog interface, every part of the product looks like a
natural candidate at first glance. But `record-row` decides which fields of a
bibliographic record are shown in what order, and that decision cannot be reused
elsewhere. The badge, button, and link inside that row, by contrast, can be.

The thresholds themselves are a decision that scales with the organization: in a
two-team organization, "at least two teams" eliminates almost nothing; in a ten-team
organization it could be raised to three. The criterion's value lies not in the number
but in the reasoning behind it being written down.

#### Not Measuring the Catalog by Component Count

The last two lines give two numbers for the catalog's health: 86.5% of usage falls on stable components,
and 83.6% falls on components that genuinely meet the criterion for their level. The 2.9-point gap cannot
be read directly, because the two numbers measure different sets: the second covers every level. The
portion where the "stable" label goes unbacked is computed separately and is larger — 193 of the stable
usage, 10.7% of the total, is missing at least one of the five evidence fields.

These two numbers say something different from component count. Three of the
twenty-two components are experimental, but their usage share is only 1.5% — the
catalog's immature portion touches only a small slice of the interface. A report
measuring by component count would say "13.6% of components are experimental," making
the same catalog look riskier.

The reverse can also happen: `dropdown` is an undocumented candidate used in 57 places
— on its own, more than all experimental components combined. A usage-weighted
indicator brings it to the front; a count-based one would rank it equally with the
other five candidates. Usage weighting is what sorts the catalog's work list.

#### Summary

- A component catalog is not a component list; it is the structure that records each
  component's owner, maturity level, and contract.
- The scope decision comes down to recurrence, ownership spread, and decision bearing;
  the first two can be turned into thresholds and filter out product-specific
  components.
- Maturity levels are not labels but exit criteria: each level is defined by the
  evidence fields it requires to be filled, and a level can change in either direction.
- Catalog health is measured by usage share, not component count; unweighted counting
  makes the same catalog look riskier or safer than it is.
- The list of components that fail their criterion turns directly into a work list once
  it is sorted by usage weight.

#### Next Step

The scope and maturity decisions rested on an assumption: that when a component is
named, the designer and the developer mean the same thing. Whether `dropdown` has a
counterpart in the design file, or whether `tooltip` is the same component as a "hint
bubble" in some product, cannot be read from this table. The next lesson establishes
the naming convention: it ties design names to code names with a rule, writes a script
that checks it, and counts the mismatched names on both sides.

### Naming Conventions

Source: https://academia.sh/en/courses/design-systems/naming-conventions

The derivation rule between design names and code names, the grammar check on names, detecting synonymous-name clashes, and reporting the gap between variant axes and props.

The previous lesson's catalog table rested on an assumption: that when a component is
named, the designer and the developer mean the same thing. Whether the table's
`tooltip` row has a counterpart in the design file, or a part sitting in the design
library never appears in code at all, cannot be read from that table.

The naming convention closes this gap: not an aesthetic exercise, but a
**machine-checkable** mapping between two separate records. Once built, the "in design,
not in code" and "in code, not in design" lists become computable and surface the
system's most common, most silent defect.

#### Two Name Sets, One Concept

A component is named in two places. In the design library it carries a hierarchical
name, shown to people and free to contain spaces, capital letters, and punctuation. In
code it carries a module name that appears in the file system and in imports, and it
must be ASCII.

Managing the two name sets **independently** is the system's most common form of decay:
a designer renames a part and the code name stays as it was, or a developer adds a new
component that never gets a counterpart in the design library. Neither produces an
error, because no one has ever asserted an equivalence between the two sets.

The convention is exactly that assertion of equivalence, and it has three parts:

- **Grammar.** The form a design name is written in: how many segments, what
  capitalization.
- **Derivation.** How the code name is computed from the design name — a function,
  since two names "looking alike" cannot be checked, but computing one from the other
  can.
- **Uniqueness.** One name per concept. Two names for the same thing produce two
  components.

#### Writing the Derivation Rule

The derivation rule starts with normalization. Typographic characters a design tool's
export can introduce — smart quotes, en and em dashes, ellipses — must convert to their
ASCII equivalents, and that conversion runs before the string is lowercased so every
later step sees a predictable ASCII string. The step is defensive: a name carrying one
of these characters derives a code name the catalog does not expect, and nothing in the
design tool shows the difference.

The program below takes the design library's twenty-one names and the code catalog's
twenty-one names, applies the derivation rule, checks grammar, runs the synonym
dictionary, and finally compares variant axes against props.

```js
// naming.mjs — checking the mapping between design names and code names

// Design library names: "Category/Component" and variant axes.
const DESIGN = [
  { name: "Action/Button",                eksen: ["type", "size", "state"] },
  { name: "Action/Link",                  eksen: ["type", "state"] },
  { name: "Form/Text Field",              eksen: ["size", "state"] },
  { name: "Form/Checkbox",                eksen: ["state", "selected"] },
  { name: "Form/Radio Group",             eksen: ["direction", "state"] },
  { name: "Form/Dropdown",                eksen: ["size", "state"] },
  { name: "Form/Search Field",            eksen: ["size", "state"] },
  { name: "Display/Badge",                eksen: ["tone", "size"] },
  { name: "Container/Card",               eksen: ["elevation", "clickable"] },
  { name: "Navigation/Breadcrumb",        eksen: ["truncation"] },
  { name: "Navigation/Pagination",        eksen: ["size"] },
  { name: "Navigation/Tabs",              eksen: ["direction", "size"] },
  { name: "Feedback/Notification Banner", eksen: ["tone", "dismissible"] },
  { name: "Feedback/Empty State",         eksen: ["size"] },
  { name: "Feedback/Skeleton",            eksen: ["rows"] },
  { name: "Layer/Modal",                  eksen: ["size"] },
  { name: "Layer/Hint Bubble",            eksen: ["direction"] },
  { name: "Container/Accordion",          eksen: ["size"] },
  { name: "Display/Table",                eksen: ["density"] },
  { name: "Card Component",               eksen: ["elevation"] },
  { name: "Form/Input/Number Field",      eksen: ["size"] },
];

// Code catalog names and each component's props.
const CODE = [
  { name: "button",              props: ["type", "size", "state"] },
  { name: "link",                props: ["type", "state"] },
  { name: "text-field",          props: ["size", "state"] },
  { name: "checkbox",            props: ["state", "selected"] },
  { name: "radio-group",         props: ["direction", "state"] },
  { name: "dropdown",            props: ["size", "state", "searchable"] },
  { name: "search-field",        props: ["size", "state"] },
  { name: "badge",               props: ["tone", "size"] },
  { name: "card",                props: ["elevation", "clickable"] },
  { name: "breadcrumb",          props: ["truncation"] },
  { name: "pagination",          props: ["size"] },
  { name: "tabs",                props: ["direction", "size"] },
  { name: "notification-banner", props: ["tone", "dismissible"] },
  { name: "empty-state",         props: ["size"] },
  { name: "skeleton",            props: ["rows"] },
  { name: "modal",               props: ["size", "dismissible"] },
  { name: "tooltip",             props: ["direction"] },
  { name: "accordion",           props: ["size"] },
  { name: "table",               props: ["density"] },
  { name: "number-field",        props: ["size"] },
  { name: "help-text",           props: [] },
];

// Typographic characters a design tool's export can introduce (smart quotes, dashes,
// ellipsis) are normalized to ASCII first, then the string is lowercased. In the
// reverse order, some Unicode characters expand into a multi-character sequence when
// case-folded and the code name breaks without any visible sign in the design tool.
const TRANSLIT = { "’": "'", "‘": "'", "“": '"', "”": '"', "–": "-", "—": "-", "…": "..." };
const normalize = (s) => [...s].map((ch) => TRANSLIT[ch] ?? ch).join("");

// Derivation rule: last segment -> normalize -> lowercase -> space becomes hyphen.
const expectedFromDesign = (designName) => {
  const last = designName.split("/").at(-1);
  return normalize(last).toLowerCase().replace(/\s+/g, "-");
};

// Grammar rule: the name has two segments, each segment in Title Case.
function grammar(designName) {
  const flaws = [];
  const parts = designName.split("/");
  if (parts.length !== 2) flaws.push(`part count ${parts.length}, should be 2`);
  for (const p of parts) {
    for (const word of p.split(/\s+/)) {
      if (word !== word.charAt(0).toUpperCase() + word.slice(1)) {
        flaws.push(`"${word}" is not in Title Case`);
      }
    }
  }
  return flaws;
}

// Synonym dictionary: the one accepted name for a concept and the rejected names.
const SYNONYMS = [
  { accepted: "tooltip", rejected: ["hint-bubble", "explanation-bubble"] },
  { accepted: "modal", rejected: ["dialog", "popup"] },
  { accepted: "notification-banner", rejected: ["alert-box"] },
];

const codeNames = new Set(CODE.map((k) => k.name));

console.log("design name                     expected code name   status");
let matched = 0;
const unmatchedDesign = [];
for (const t of DESIGN) {
  const expected = expectedFromDesign(t.name);
  const found = codeNames.has(expected);
  if (found) matched++; else unmatchedDesign.push(expected);
  console.log(`${t.name.padEnd(32)} ${expected.padEnd(20)} ${found ? "matched" : "NO CODE"}`);
}

const expectedSet = new Set(DESIGN.map((t) => expectedFromDesign(t.name)));
const unmatchedCode = CODE.filter((k) => !expectedSet.has(k.name)).map((k) => k.name);
console.log(`\ncode names with no design counterpart: ${JSON.stringify(unmatchedCode)}`);

console.log("\ngrammar flaws");
for (const t of DESIGN) {
  const flaws = grammar(t.name);
  if (flaws.length) console.log(`${t.name.padEnd(32)} ${flaws.join("; ")}`);
}

console.log("\nsynonym clashes");
const allNames = [...codeNames, ...DESIGN.map((t) => expectedFromDesign(t.name))];
for (const e of SYNONYMS) {
  const present = e.rejected.filter((r) => allNames.includes(r));
  if (present.length) console.log(`${e.accepted.padEnd(20)} used instead: ${JSON.stringify(present)}`);
}

console.log("\nvariant axis vs. prop differences");
const codeMap = new Map(CODE.map((k) => [k.name, k.props]));
for (const t of DESIGN) {
  const props = codeMap.get(expectedFromDesign(t.name));
  if (!props) continue;
  const designOnly = t.eksen.filter((e) => !props.includes(e));
  const codeOnly = props.filter((o) => !t.eksen.includes(o));
  if (designOnly.length === 0 && codeOnly.length === 0) continue;
  console.log(`${t.name.padEnd(32)} design only: ${JSON.stringify(designOnly)}  code only: ${JSON.stringify(codeOnly)}`);
}

const rate = (matched / DESIGN.length) * 100;
console.log(`\nmatched names                : ${matched}/${DESIGN.length} (${rate.toFixed(1)}%)`);
console.log(`no code counterpart          : ${unmatchedCode.length}`);
console.log(`no design counterpart        : ${unmatchedDesign.length}`);
```

```
design name                     expected code name   status
Action/Button                    button               matched
Action/Link                      link                 matched
Form/Text Field                  text-field           matched
Form/Checkbox                    checkbox             matched
Form/Radio Group                 radio-group          matched
Form/Dropdown                    dropdown             matched
Form/Search Field                search-field         matched
Display/Badge                    badge                matched
Container/Card                   card                 matched
Navigation/Breadcrumb            breadcrumb           matched
Navigation/Pagination            pagination           matched
Navigation/Tabs                  tabs                 matched
Feedback/Notification Banner     notification-banner  matched
Feedback/Empty State             empty-state          matched
Feedback/Skeleton                skeleton             matched
Layer/Modal                      modal                matched
Layer/Hint Bubble                hint-bubble          NO CODE
Container/Accordion              accordion            matched
Display/Table                    table                matched
Card Component                   card-component       NO CODE
Form/Input/Number Field          number-field         matched

code names with no design counterpart: ["tooltip","help-text"]

grammar flaws
Card Component                   part count 1, should be 2
Form/Input/Number Field          part count 3, should be 2

synonym clashes
tooltip              used instead: ["hint-bubble"]

variant axis vs. prop differences
Form/Dropdown                    design only: []  code only: ["searchable"]
Layer/Modal                      design only: []  code only: ["dismissible"]

matched names                : 19/21 (90.5%)
no code counterpart          : 2
no design counterpart        : 2
```

#### Reading the Report

Nineteen of the twenty-one names match. The remaining four rows — two unmatched design
names and two unmatched code names — represent four kinds of defect, each closed by a
different piece of work.

`Layer/Hint Bubble` and `tooltip` are **the same component with two names**. This is
the most damaging row on the list, because neither side shows a gap: the component
exists on both sides but cannot be linked. The synonym dictionary is the only check
that catches this; unless the rejected names sit next to the accepted one, a checking
program cannot know that two names point to the same concept.

`Card Component` **carries two flaws at once**: it has no category and duplicates the
already-existing `Container/Card` entry. The word "Component" is also outside the
convention — every catalog entry is already a component, so the name saying so again
draws no distinction.

`help-text` **exists in code but not in design**. This does not always mean a defect —
a helper with no visual counterpart existing only in code is ordinary. What the
convention asks is that the row be **explicitly marked**: if the catalog is not
annotated "no design counterpart," the check reports it as a defect every run, and the
report stops being read.

`Form/Input/Number Field` shows an interesting case: it fails the grammar check but
passes the name match, because the derivation rule takes the last segment and the
three-part name still produces the correct code name. This is why the two checks stay
**separate** — the derivation rule can afford to be lenient, but a lenient grammar rule
would scatter categories and make the library harder to search.

#### Variant Axis vs. Prop

Naming does not cover the component name alone. In the design library, a component has
axes that produce its variants; in code their counterpart is the props defined in the
Component-Based Interface Development course. Names on both sides must match too,
because the conversation between designer and developer runs through these axis names.

The last block shows two gaps, and both point the same direction: an axis present in
code is missing from design. The `dropdown` component's `searchable` prop and the
`modal` component's `dismissible` prop were added on the code side and never made it
into the design library.

This direction is informative about the cause. An axis that appears in code usually
comes from a product need — some team needed a long list, so a search feature was
added. An axis that appears in design but not in code, by contrast, is a design
decision that was never implemented. The two cases close differently: the first
requires updating the design library, the second requires an item entering the
contribution process.

Axis **values** also need to be part of the convention. Whether the `type` axis takes
"primary" and "secondary" in design and `primary` and `secondary` in code can be
checked; if design says "main action" while code uses `primary`, the conversation
breaks even though the names match.

#### Sustaining the Convention

The checking program is not a one-time cleanup tool. Name sets keep changing, and the
gap always accumulates again; two operating decisions determine how well this holds.

The first is whether the check is **blocking**. A check that produces a report but
stops nothing turns into unread output within a few months. Setting a floor for the
match rate and stopping the pipeline below that floor is what turns the report into a
decision.

The second is **recording exceptions**. Legitimate mismatches like `help-text` go into
an exception list, and every entry carries a reason. As that list grows, it becomes
clear the convention itself needs revisiting — the list's length is itself an
indicator.

#### Summary

- A component is named in two separate sets; when the two sets are managed
  independently, the gap between them accumulates without producing an error anywhere.
- The convention has three parts: the name's grammar, the derivation rule that computes
  a code name from a design name, and the uniqueness rule that gives one name per
  concept.
- Typographic characters are normalized to ASCII as the **first** step of the
  derivation; a name carrying one of them otherwise derives a code name the catalog
  does not expect.
- A synonym clash shows no gap on either side, so it can only be caught with a synonym
  dictionary; it is the most damaging kind of defect.
- Variant axes and props must match too; the direction of the gap tells you whether the
  work belongs in the design library or in the contribution process.
- Unless the check is tied to a floor and exceptions are recorded with their reasons,
  the report stops being read.

#### Next Step

Once names match, it becomes possible to talk about a component, but that is not
enough to **use** it. Which state calls for `dropdown` instead of `search-field`, and
when a `modal` gives way to a separate page instead, cannot be read from names, and
neither can the distinction between `badge` and `notification-banner`. The next lesson
defines the required sections of component documentation, shows why a counter-example
is as necessary as an example, and computes documentation coverage weighted by usage.

### Documentation

Source: https://academia.sh/en/courses/design-systems/documentation

The required sections of component documentation, the information a counter-example carries, computing documentation coverage at the section and component level, and comparing the prop table against the interface.

Once names match, it becomes possible to talk about a component, but that is not
enough to use it. Which state calls for `dropdown` instead of `search-field`, when a
`modal` gives way to a separate page instead, and the distinction between `badge` and
`notification-banner` cannot be read from names.

Documentation carries these decisions. Component documentation is not a usage guide; it
is **the readable form of the component's contract**: what it does, what it does not
do, what input it takes, when it should not be chosen. This lesson defines the doc's
required sections and shows how to measure coverage.

#### The Doc's Required Sections

A component doc's sections correspond to the questions the person using it asks in
order:

- **Purpose.** What job does the component do? One sentence. A component whose purpose
  takes two sentences to explain is usually two components.
- **When to use.** In which context it is the right choice. This section does not
  describe the component; it gives a **selection criterion**.
- **When not to use.** The counter-example. It says which component to use instead.
- **Prop table.** The names of the props, the values they accept, their defaults, and
  whether they are required.
- **Example.** Usage that can be copied and run. Source text, not a picture.
- **Accessibility note.** Keyboard behavior, focus order, and the line between what the
  component provides on its own and what the person using it must provide.
- **Related components.** Links to neighboring components; the person using it may have
  started from the wrong place.

Six of these seven sections are familiar. Across most systems, the two most often
missing are the counter-example and related components; both do the same job:
steering the person **away from** this component.

#### The Information a Counter-Example Carries

The example shows the correct use of a component. The counter-example shows something
else: the component's **boundary**. Neither substitutes for the other.

A concrete example from the catalog interface: `badge` shows a record's status —
"on the shelf," "on loan," "reserved." `notification-banner` reports an event to the
user — "your loan was completed." Both are small, colored, short-text boxes describing
a state. Only the counter-example puts the distinction into writing: a badge is a
property of an **object** and stays with it; a notification banner is the result of an
**action** and appears after it. This cannot be read from the prop table, since the two
components' props are nearly identical.

The counter-example's second job is closing the escape hatch. As shown in the
Component-Based Interface Development course, a component hands its caller an escape
hatch, and if the doc does not say where that hatch ends, every team draws its own
boundary. "Do not put a button inside a badge — use `button` for a clickable state" is
a rule, and it can be checked.

#### Computing Coverage

Tracking documentation as "done" or "not done" is not useful; docs are written
partially and go stale partially. The program below takes the catalog's nineteen
documented components, computes coverage at the section and component level, sorts
gaps by usage impact, and finally compares the prop table against the real interface.

```js
// docs.mjs — component-level and section-level documentation coverage

// A component doc's required sections.
const SECTION = ["purpose", "when-to-use", "when-not-to-use", "prop-table", "example", "accessibility-note", "related"];

// For each component, the sections it has and its usage count (same catalog as lesson 01).
const DOCS = [
  { name: "button",              usage: 412, has: ["purpose", "when-to-use", "when-not-to-use", "prop-table", "example", "accessibility-note", "related"] },
  { name: "link",                usage: 268, has: ["purpose", "when-to-use", "when-not-to-use", "prop-table", "example", "accessibility-note", "related"] },
  { name: "badge",               usage: 188, has: ["purpose", "when-to-use", "when-not-to-use", "prop-table", "example", "accessibility-note"] },
  { name: "card",                usage: 157, has: ["purpose", "when-to-use", "when-not-to-use", "prop-table", "example", "accessibility-note", "related"] },
  { name: "text-field",          usage: 143, has: ["purpose", "when-to-use", "when-not-to-use", "prop-table", "example", "accessibility-note", "related"] },
  { name: "checkbox",            usage:  96, has: ["purpose", "when-to-use", "prop-table", "example", "accessibility-note", "related"] },
  { name: "search-field",        usage:  71, has: ["purpose", "when-to-use", "when-not-to-use", "prop-table", "example", "accessibility-note", "related"] },
  { name: "notification-banner", usage:  63, has: ["purpose", "when-to-use", "prop-table", "accessibility-note"] },
  { name: "dropdown",            usage:  57, has: ["prop-table", "example"] },
  { name: "empty-state",         usage:  52, has: ["purpose", "when-to-use", "when-not-to-use", "prop-table", "example", "accessibility-note", "related"] },
  { name: "modal",               usage:  45, has: ["purpose", "when-to-use", "prop-table", "accessibility-note"] },
  { name: "pagination",          usage:  41, has: ["purpose", "when-to-use", "when-not-to-use", "prop-table", "example", "accessibility-note", "related"] },
  { name: "skeleton",            usage:  38, has: ["purpose", "when-to-use", "prop-table", "example"] },
  { name: "radio-group",         usage:  34, has: ["purpose", "when-to-use", "when-not-to-use", "prop-table", "example", "related"] },
  { name: "breadcrumb",          usage:  29, has: ["purpose", "when-to-use", "when-not-to-use", "prop-table", "example", "accessibility-note", "related"] },
  { name: "tabs",                usage:  24, has: ["purpose", "when-to-use", "prop-table", "example"] },
  { name: "accordion",           usage:  17, has: ["purpose", "prop-table", "example"] },
  { name: "tooltip",             usage:  12, has: ["prop-table"] },
  { name: "table",               usage:   9, has: ["prop-table", "example"] },
];

// Section-level coverage: which section is missing most often?
console.log("section              has doc      component share   usage share");
const totalUsage = DOCS.reduce((t, b) => t + b.usage, 0);
for (const sec of SECTION) {
  const has = DOCS.filter((b) => b.has.includes(sec));
  const k = has.reduce((t, b) => t + b.usage, 0);
  console.log(
    `${sec.padEnd(20)} ${String(has.length).padStart(6)}/${DOCS.length} ` +
    `${((has.length / DOCS.length) * 100).toFixed(1).padStart(15)}% ` +
    `${((k / totalUsage) * 100).toFixed(1).padStart(11)}%`
  );
}

// Component-level coverage and gap impact.
const rows = DOCS.map((b) => {
  const missing = SECTION.filter((sec) => !b.has.includes(sec));
  return { ...b, missing, coverage: (SECTION.length - missing.length) / SECTION.length, impact: missing.length * b.usage };
}).sort((a, b) => b.impact - a.impact);

console.log("\ncomponent             coverage   usage   gap impact   missing sections");
for (const r of rows) {
  if (r.missing.length === 0) continue;
  console.log(
    `${r.name.padEnd(21)} ${(r.coverage * 100).toFixed(0).padStart(7)}% ${String(r.usage).padStart(7)} ` +
    `${String(r.impact).padStart(12)}   ${r.missing.join(", ")}`
  );
}

const unweightedCoverage = rows.reduce((t, r) => t + r.coverage, 0) / rows.length;
const weightedCoverage = rows.reduce((t, r) => t + r.coverage * r.usage, 0) / totalUsage;
console.log(`\nunweighted coverage (component average) : ${(unweightedCoverage * 100).toFixed(1)}%`);
console.log(`usage-weighted coverage                 : ${(weightedCoverage * 100).toFixed(1)}%`);

// Comparing the prop table against the real interface.
const INTERFACE = {
  button:   ["type", "size", "state", "icon", "width"],
  dropdown: ["size", "state", "searchable", "multiple"],
  badge:    ["tone", "size"],
  modal:    ["size", "dismissible", "title"],
};
const DOCUMENTED = {
  button:   ["type", "size", "state", "icon"],
  dropdown: ["size", "state"],
  badge:    ["tone", "size", "icon"],
  modal:    ["size", "title"],
};

console.log("\nprop table drift");
console.log("component   undocumented               documented but not in interface");
for (const name of Object.keys(INTERFACE)) {
  const missing = INTERFACE[name].filter((o) => !DOCUMENTED[name].includes(o));
  const extra = DOCUMENTED[name].filter((o) => !INTERFACE[name].includes(o));
  console.log(`${name.padEnd(11)} ${JSON.stringify(missing).padEnd(26)} ${JSON.stringify(extra)}`);
}
const totalProps = Object.values(INTERFACE).reduce((t, a) => t + a.length, 0);
const documentedCount = Object.keys(INTERFACE).reduce((t, name) => t + INTERFACE[name].filter((o) => DOCUMENTED[name].includes(o)).length, 0);
console.log(`prop table coverage: ${documentedCount}/${totalProps} (${((documentedCount / totalProps) * 100).toFixed(1)}%)`);
```

```
section              has doc      component share   usage share
purpose                  16/19            84.2%        95.6%
when-to-use              15/19            78.9%        94.6%
when-not-to-use          10/19            52.6%        79.4%
prop-table               19/19           100.0%       100.0%
example                  16/19            84.2%        93.2%
accessibility-note       12/19            63.2%        89.1%
related                  10/19            52.6%        74.2%

component             coverage   usage   gap impact   missing sections
dropdown                   29%      57          285   purpose, when-to-use, when-not-to-use, accessibility-note, related
notification-banner        57%      63          189   when-not-to-use, example, related
badge                      86%     188          188   related
modal                      57%      45          135   when-not-to-use, example, related
skeleton                   57%      38          114   when-not-to-use, accessibility-note, related
checkbox                   86%      96           96   when-not-to-use
tabs                       57%      24           72   when-not-to-use, accessibility-note, related
tooltip                    14%      12           72   purpose, when-to-use, when-not-to-use, example, accessibility-note, related
accordion                  43%      17           68   when-to-use, when-not-to-use, accessibility-note, related
table                      29%       9           45   purpose, when-to-use, when-not-to-use, accessibility-note, related
radio-group                86%      34           34   accessibility-note

unweighted coverage (component average) : 73.7%
usage-weighted coverage                 : 89.4%

prop table drift
component   undocumented               documented but not in interface
button      ["width"]                  []
dropdown    ["searchable","multiple"]  []
badge       []                         ["icon"]
modal       ["dismissible"]            []
prop table coverage: 10/14 (71.4%)
```

#### What Section-Level Coverage Says

The first table shows which section is missing system-wide. The prop table is one
hundred percent full, since it can be generated from the interface and usually is. The
counter-example and related-components sections, by contrast, sit at 52.6%: only one
component in two has them.

This distribution is not random: the section that can be auto-generated is full; **the
section that requires writing down a decision is empty**. A documentation gap is often
not a matter of time but of an undecided call — writing a counter-example requires the
component's boundary to already be decided, and it has not been.

The gap between the two columns is also meaningful: the counter-example exists for
52.6% of components but covers 79.4% of usage, so what is missing tends to be the
less-used components. The related-components section shows a narrower gap — 52.6%
against 74.2% — showing the gap also reaches heavily used components, and the second
table confirms it.

#### Ranking the Gaps

The second table shows that coverage percentage and work priority differ. `badge` sits
at 86% coverage — one of the best-looking components in the list — but its gap impact
is 188, third-highest, because it is used in 188 places and its one gap touches
everyone. `table`, at 29% coverage, looks far worse, but its impact is only 45; it is
used in nine places.

The gap-impact product — missing section count times usage count — combines both into a single ranking.
`dropdown`, at the top of the ranking, was also seen in the first lesson: an undocumented candidate used
in 57 places. When two separate audits point at the same component, that component is next in line.

The 15.7-point gap between unweighted and weighted coverage is also worth reading: the
two answer different questions — unweighted asks "how much of the catalog is
documented," weighted asks "how much of the usage lands on a documented component."
Reporting only the weighted number hides low-usage components staying undocumented;
reporting only the unweighted number misses components like `badge` that have one gap
but wide reach. Both are reported together.

#### The Prop Table Going Stale

The last block shows documentation's most insidious form of decay: the doc exists, but
it is wrong. `button`'s `width` prop and `dropdown`'s two props were added to the
interface but never written into the table — **missing** information: the person using
the component does not know the prop exists, writes their own workaround, and produces
drift.

The `badge` row carries a different defect: the `icon` prop is still in the doc but no
longer exists in the interface — **wrong** information, more expensive than missing
information. The person using it trusts the doc, passes the prop, nothing happens, and
they look for the bug in their own code.

This block also shows the direction of the fix: as long as the prop table is written by
hand it will inevitably go stale; generated from the interface definition, drift
becomes structurally impossible. The sections that must be written by hand — purpose,
selection criterion, counter-example — are already the ones that cannot be generated.
Documentation tooling follows this distinction: generate what can be generated, force
what cannot to be written.

#### Summary

- Component documentation has seven sections, and each one answers a specific question
  the person using it asks.
- The counter-example carries boundary information the prop table cannot show; it is
  the only section that puts the distinction between two similar-looking components
  into writing.
- Sections that can be auto-generated stay full; sections that require writing down a
  decision stay empty — a documentation gap is often a decision gap.
- Coverage is reported both unweighted and usage-weighted together; one hides the
  neglect of low-usage components, the other hides a single gap in a widely used one.
- The product of missing-section count and usage produces a work ranking different from
  the coverage percentage.
- A prop documented but absent from the interface is more expensive than a prop present
  in the interface but undocumented; when the prop table is generated from the
  interface definition, this drift becomes structurally impossible.

#### Next Step

Comparing the doc's prop table against the interface left a question open: adding a
`width` prop to `button` and removing the `icon` prop from `badge` do not carry the
same weight. The first breaks no usage; the second breaks every place that passes that
prop. The next lesson ties this distinction to the version number: it writes a script
that compares two versions' component interfaces, classifies the changes, and computes
the next version number, then generates the migration guide from it.

### Versioning and Release

Source: https://academia.sh/en/courses/design-systems/versioning-and-release

What counts as breaking in a component interface, deriving the version number from comparing two snapshots, generating the migration guide, and measuring migration cost by call site.

Comparing the doc's prop table against the interface left a question open: adding a
`width` prop to `button` and removing the `icon` prop from `badge` do not carry the
same weight. The first breaks no usage; the second breaks every place that passes that
prop.

Versioning turns this weight difference into a number. Semantic versioning, introduced
in the Modules, Tooling and the Ecosystem course, ties a package's version number to
the class of a change. Applying it to the component catalog needs exactly one thing: an
exact list of what counts as breaking in a component interface.

#### What Is Breaking in a Component Interface

A component's contract is not limited to its props, but its props are the measurable
part of it. The following changes are breaking:

- **Removing a component.** The most visible one; any import breaks immediately.
- **Removing a prop.** Calls that pass that prop go silently inert.
- **Renaming a prop.** It looks like a removal combined with an addition, but the
  migration step is different.
- **An optional prop becoming required.** Every call that does not pass the prop
  breaks.
- **A default value changing.** Every call that never touched the source text sees
  different behavior.
- **A value set narrowing.** Calls passing the value that was dropped fall into an
  undefined state.

Two other classes fall outside this list and are compatible: **additive** — a new
component, a new optional prop, a widened value set — and **patch**, a behavior fix
that does not change the interface.

One important item is missing from the list: **the DOM tree structure and class
names**. If a component's output tree can be styled from outside with CSS, that tree is
also part of the contract, and changing it is breaking. The catalog therefore either
brings the tree into the contract explicitly or states explicitly that it does not; a
system that does neither produces unnamed breaking changes with every release.

#### Computing the Diff

The program below takes two snapshots of the catalog, applies the rules above,
classifies the changes, derives the version number, and generates the migration guide.
Migration cost is measured by the number of call sites each change affects.

```js
// version.mjs — comparing two catalog snapshots and deriving the version number

// A snapshot: component -> prop name -> { value set, default, required }
const OLD = {
  version: "3.4.2",
  component: {
    button:   { type: { values: ["primary", "secondary", "silent"], default: "secondary", required: false },
                size: { values: ["small", "medium"], default: "medium", required: false },
                icon: { values: null, default: null, required: false } },
    badge:    { tone: { values: ["neutral", "positive", "warning", "negative"], default: "neutral", required: false },
                size: { values: ["small", "medium"], default: "medium", required: false },
                icon: { values: null, default: false, required: false } },
    dropdown: { size: { values: ["small", "medium"], default: "medium", required: false },
                state: { values: null, default: null, required: false } },
    modal:    { size: { values: ["medium", "large"], default: "medium", required: false },
                title: { values: null, default: null, required: false } },
    "notification-banner": { tone: { values: ["info", "positive", "warning", "negative"], default: "info", required: false },
                dismissible: { values: null, default: true, required: false } },
    "loading-indicator": { size: { values: ["small", "medium"], default: "medium", required: false } },
    skeleton: { rows: { values: null, default: 3, required: false } },
  },
};

const NEW = {
  component: {
    button:   { type: { values: ["primary", "secondary", "silent", "danger"], default: "secondary", required: false },
                size: { values: ["small", "medium"], default: "medium", required: false },
                "icon-name": { values: null, default: null, required: false },
                width: { values: ["content", "full"], default: "content", required: false } },
    badge:    { tone: { values: ["neutral", "positive", "warning", "negative"], default: "neutral", required: false },
                size: { values: ["small", "medium"], default: "small", required: false } },
    dropdown: { size: { values: ["small", "medium"], default: "medium", required: false },
                state: { values: null, default: null, required: false },
                searchable: { values: null, default: false, required: false },
                multiple: { values: null, default: false, required: false } },
    modal:    { size: { values: ["medium", "large"], default: "medium", required: false },
                title: { values: null, default: null, required: true },
                dismissible: { values: null, default: true, required: false } },
    "notification-banner": { tone: { values: ["positive", "warning", "negative"], default: "warning", required: false },
                dismissible: { values: null, default: true, required: false } },
    skeleton: { rows: { values: null, default: 3, required: false } },
    "number-field": { size: { values: ["small", "medium"], default: "medium", required: false } },
  },
};

// Rename is reported by hand; otherwise it looks like a removal + an addition.
const RENAMES = [{ component: "button", old: "icon", new: "icon-name" }];

// Call site counts: these determine the migration cost.
const USAGE = { button: 412, badge: 188, dropdown: 57, modal: 45, "notification-banner": 63, "loading-indicator": 31, skeleton: 38 };
// Call sites per prop (places that pass that prop explicitly).
const PROP_USAGE = { "button.icon": 96, "badge.icon": 24, "badge.size": 61, "notification-banner.tone": 63, "modal.title": 40 };

const sameSet = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const findRename = (component, name, dir) =>
  RENAMES.find((r) => r.component === component && r[dir] === name);

const changes = [];
const add = (cls, component, description, migrationStep, impact) =>
  changes.push({ cls, component, description, migrationStep, impact });

for (const [name, oldProps] of Object.entries(OLD.component)) {
  const newProps = NEW.component[name];
  if (!newProps) {
    add("breaking", name, "component removed", "use the skeleton component instead", USAGE[name] ?? 0);
    continue;
  }
  for (const [propName, oldProp] of Object.entries(oldProps)) {
    const renamed = findRename(name, propName, "old");
    if (renamed) {
      add("breaking", name, `prop renamed: ${propName} → ${renamed.new}`,
        `every call passing ${propName} switches to ${renamed.new}`, PROP_USAGE[`${name}.${propName}`] ?? 0);
      continue;
    }
    const newProp = newProps[propName];
    if (!newProp) {
      add("breaking", name, `prop removed: ${propName}`, `removed from calls passing ${propName}`,
        PROP_USAGE[`${name}.${propName}`] ?? 0);
      continue;
    }
    if (!oldProp.required && newProp.required) {
      add("breaking", name, `prop became required: ${propName}`, `a value is added to calls not passing ${propName}`,
        (USAGE[name] ?? 0) - (PROP_USAGE[`${name}.${propName}`] ?? 0));
    }
    if (oldProp.default !== newProp.default) {
      add("breaking", name, `default changed: ${propName} ${JSON.stringify(oldProp.default)} → ${JSON.stringify(newProp.default)}`,
        `calls not passing ${propName} now write the old value explicitly`,
        (USAGE[name] ?? 0) - (PROP_USAGE[`${name}.${propName}`] ?? 0));
    }
    if (oldProp.values && newProp.values && !sameSet(oldProp.values, newProp.values)) {
      const dropped = oldProp.values.filter((d) => !newProp.values.includes(d));
      const added = newProp.values.filter((d) => !oldProp.values.includes(d));
      if (dropped.length) {
        add("breaking", name, `value set narrowed: ${propName} ${JSON.stringify(dropped)} dropped`,
          `calls passing ${JSON.stringify(dropped)} move to another value`, PROP_USAGE[`${name}.${propName}`] ?? 0);
      }
      if (added.length) add("additive", name, `value set widened: ${propName} ${JSON.stringify(added)} added`, "-", 0);
    }
  }
  for (const propName of Object.keys(newProps)) {
    if (oldProps[propName]) continue;
    if (findRename(name, propName, "new")) continue;
    const cls = newProps[propName].required ? "breaking" : "additive";
    add(cls, name, `${cls === "breaking" ? "required " : ""}prop added: ${propName}`,
      cls === "breaking" ? `${propName} is added to every call` : "-", cls === "breaking" ? (USAGE[name] ?? 0) : 0);
  }
}
for (const name of Object.keys(NEW.component)) {
  if (!OLD.component[name]) add("additive", name, "component added", "-", 0);
}

const ORDER = { breaking: 0, additive: 1, patch: 2 };
changes.sort((a, b) => ORDER[a.cls] - ORDER[b.cls] || b.impact - a.impact);

console.log("class      component            change");
for (const d of changes) console.log(`${d.cls.padEnd(10)} ${d.component.padEnd(20)} ${d.description}`);

const count = { breaking: 0, additive: 0, patch: 0 };
for (const d of changes) count[d.cls]++;
console.log(`\nbreaking: ${count.breaking}   additive: ${count.additive}   patch: ${count.patch}`);

// Version derivation: the heaviest class determines which component of the version increases.
function nextVersion(current, count) {
  const [major, minor, patch] = current.split(".").map(Number);
  if (count.breaking > 0) return `${major + 1}.0.0`;
  if (count.additive > 0) return `${major}.${minor + 1}.0`;
  return `${major}.${minor}.${patch + 1}`;
}
console.log(`current version: ${OLD.version}   derived version: ${nextVersion(OLD.version, count)}`);
console.log(`if breaking changes were held back: ${nextVersion(OLD.version, { breaking: 0, additive: count.additive, patch: count.patch })}`);

// Migration guide: breaking changes only, with the call site count each one affects.
console.log("\n— MIGRATION GUIDE —");
let totalImpact = 0;
for (const d of changes.filter((x) => x.cls === "breaking")) {
  totalImpact += d.impact;
  console.log(`* ${d.component}: ${d.description}\n    to do: ${d.migrationStep}\n    affected call sites: ${d.impact}`);
}
console.log(`\ntotal migration cost: ${totalImpact} call sites`);
console.log(`average per breaking change: ${(totalImpact / count.breaking).toFixed(1)} call sites`);
```

```
class      component            change
breaking   badge                default changed: size "medium" → "small"
breaking   button               prop renamed: icon → icon-name
breaking   notification-banner  value set narrowed: tone ["info"] dropped
breaking   loading-indicator    component removed
breaking   badge                prop removed: icon
breaking   modal                prop became required: title
breaking   notification-banner  default changed: tone "info" → "warning"
additive   button               value set widened: type ["danger"] added
additive   button               prop added: width
additive   dropdown             prop added: searchable
additive   dropdown             prop added: multiple
additive   modal                prop added: dismissible
additive   number-field         component added

breaking: 7   additive: 6   patch: 0
current version: 3.4.2   derived version: 4.0.0
if breaking changes were held back: 3.5.0

— MIGRATION GUIDE —
* badge: default changed: size "medium" → "small"
    to do: calls not passing size now write the old value explicitly
    affected call sites: 127
* button: prop renamed: icon → icon-name
    to do: every call passing icon switches to icon-name
    affected call sites: 96
* notification-banner: value set narrowed: tone ["info"] dropped
    to do: calls passing ["info"] move to another value
    affected call sites: 63
* loading-indicator: component removed
    to do: use the skeleton component instead
    affected call sites: 31
* badge: prop removed: icon
    to do: removed from calls passing icon
    affected call sites: 24
* modal: prop became required: title
    to do: a value is added to calls not passing title
    affected call sites: 5
* notification-banner: default changed: tone "info" → "warning"
    to do: calls not passing tone now write the old value explicitly
    affected call sites: 0

total migration cost: 346 call sites
average per breaking change: 49.4 call sites
```

#### Deriving the Version Number

The version number is not a decision but a **result**: the program finds seven
breaking changes, so version 3.4.2 goes to 4.0.0. Had the breaking changes been held
back and only the additions shipped, the version would be 3.5.0 — this second line
turns the version number from a debatable topic into the result of release scope.

This distinction produces a practical option: if none of the seven breaking changes are
mandatory, the additive release can ship right away and the breaking changes
accumulate for the next major version. **Accumulating** them is cheaper than shipping
each in its own major version — teams using the catalog migrate once, not seven times.

The rename row shows the boundary of the diff: `icon` becoming `icon-name` is
recognized as a rename only because it was reported by hand. Unreported, it would have
produced two rows — "prop removed: icon" and "prop added: icon-name." The version
number would still read 4.0.0, but the migration guide would be wrong, with no way to
know the removal and the addition are two faces of the same prop. Taking the diff is
automatic; **declaring intent is manual**.

#### Measuring Migration Cost

The migration guide is the breaking changes sorted by call site count, and this
ranking feeds two decisions.

The first is which breaking change is actually worth it: `badge`'s `size` default
change affects 127 call sites, removing the `icon` prop affects only 24 — two changes
in the same release differing in cost by more than fivefold. If the default change's
reason does not justify those 127 sites, it is pulled back and dropped from the
breaking list.

The second is how the migration is carried out. Some steps are **mechanical** — a
rename is a string substitution, automatable with a codemod. Others require a
**decision**: which of the 63 calls passing `notification-banner`'s `info` tone should
become `positive` and which `warning` cannot be decided without looking. Separating
these two classes is what makes the migration-time estimate realistic.

The last row shows an interesting case: `notification-banner`'s default-change impact
is zero, because all 63 calls using it already pass `tone` explicitly. The change is
breaking by contract — it bumps the version number — but carries no migration cost.
Contract and cost are separate: the version number looks at the contract, the release
plan at the cost.

#### Release Shape

Whether the catalog ships as a single package or as a separate package per component
changes the distribution of the numbers above.

When shipped as a **single package**, a breaking change in one component bumps the
whole package's major version. In the example above, the team using `skeleton` is
forced onto 4.0.0 because of `badge`, even though nothing changed for them, and they
still must make a migration decision. In exchange, dependency management stays single
and no cross-version compatibility problem arises.

When shipped as **one package per component**, each component moves at its own pace and
migration decisions separate out. The cost is that different versions of the same
system can sit side by side in the dependency tree; if two components bind to different
versions of the same token layer, consistency — the system's reason for existing — is
lost.

The choice is made by comparing two numbers: the call sites forced into an unnecessary
migration versus the inconsistency produced by side-by-side versions. A single package
is cheap when the catalog is small and its components are tightly coupled; separate
packages become cheap as the catalog grows and the coupling between components loosens.

#### The Deprecation Window

A breaking change is not made directly: a deprecation mark is placed first, then
removal follows. The gap between them is a **window**, measured in releases, not on a
calendar — "for one major version" is more useful than "for three months," since it
keeps its meaning even if the release cadence changes.

The window's job is to keep migration from piling up at release time. Had `icon` been
deprecated one major version earlier, part of its 96 call sites would already have
moved, and the 4.0.0 release's migration cost would have come out lower. This is also
why the deprecation warning must include the **migration step**; a warning that only
says "this prop will be removed" speeds up nothing.

#### Summary

- What counts as breaking in a component interface is a countable list: removing the
  component, removing or renaming a prop, a prop becoming required, a default changing,
  and a value set narrowing.
- The version number is not a decision but the result of the heaviest change class in
  the release; accumulating breaking changes into a single major version is cheaper
  than shipping each one separately.
- Taking the diff can be automated, but intent declarations like a rename must be made
  by hand; if they are not reported, the guide comes out wrong.
- Migration cost is measured by the number of affected call sites, and mechanical steps
  must be separated from steps that require a decision.
- Contract and cost are separate things: a change with zero migration cost can still
  bump the version number.
- The choice between a single package and one package per component is made by
  comparing the call sites forced into unnecessary migration against the
  inconsistency produced by side-by-side versions.

#### Next Step

The version and migration guide this lesson produced did not ask where the changes
came from; the interface definition was read twice, and something had changed in
between. Who proposed the change, who reviewed it, and by what criterion it was
accepted stayed open. Whose decision was it to add the `width` prop to `button`, and how
many days did that decision take? The next lesson builds the contribution process as a
state machine, detects invalid transitions, and measures the time from proposal to
release by breaking it into stages.

### Contribution Process

Source: https://academia.sh/en/courses/design-systems/contribution-process

Defining the flow from proposal to release as a state machine, tying acceptance gates to approvals, detecting invalid transitions, and finding the bottleneck by measuring stage durations.

The version and migration guide the previous lesson produced did not ask where the
changes came from; the interface definition was read twice, and something had changed
in between. Whose decision was it to add the `width` prop to `button`, who reviewed it,
and how many days did that take?

The contribution process answers these questions. When a design system turns into
something the teams using it cannot change, one of two outcomes follows: the system
falls behind the need, or teams bypass it and write their own copies. The contribution
process exists to prevent both, but it carries a cost of its own, and that cost must be
measured.

#### The Stages of the Flow

A contribution passes through several distinct states from the moment it is proposed
to the moment it releases, and each state has a question of its own:

- **Proposal.** What is being asked for? At this stage, not a solution but a need is
  written: which screen, what problem, why the existing components cannot solve it.
- **Discovery.** Is this really the system's job? The scope criterion defined in the
  first lesson is run here; if the need is specific to a single team, the contribution
  is redirected to the product.
- **Draft.** How will it be solved? The interface is designed, the doc sections are
  written, migration impact is computed if there is one.
- **Review.** Does the solution meet the system's criteria? Three separate reviews run:
  design, code, and accessibility.
- **Acceptance.** The decision has been made. Writing the release note is pending.
- **Release.** The contribution shipped in a release.

Three terminal states are added to these: **rejected**, **deferred**, and a return from
review to draft. Rejection is not a failure; it is proof that the scope criterion is
working. A contribution process that never rejects anything shows that the criterion is
not being run.

#### Building the State Machine

Defining the flow as a written document is not enough; which transition is valid and
which approvals a transition requires must be machine-checkable. The program below
takes the history of seven contributions, checks transitions and gates, computes stage
durations, and finds the bottleneck.

```js
// contribution.mjs — contribution flow state machine, gate checks, stage durations

// Valid transitions. Any transition not in the list is invalid.
const TRANSITION = {
  proposal:  ["discovery", "rejected", "deferred"],
  discovery: ["draft", "rejected", "deferred"],
  draft:     ["review", "deferred"],
  review:    ["acceptance", "draft", "rejected"],
  acceptance: ["release"],
  release:   [],
  rejected:  [],
  deferred:  ["discovery"],
};

// Gates: the approvals that must be collected before a transition can happen.
const GATE = {
  "review->acceptance": ["design", "code", "accessibility"],
  "acceptance->release": ["release-notes"],
};

// Contribution histories: [day, state] pairs and the approvals collected by that point.
const CONTRIBUTIONS = [
  { name: "button.width", approvals: ["design", "code", "accessibility", "release-notes"],
    history: [[0, "proposal"], [2, "discovery"], [5, "draft"], [9, "review"], [12, "acceptance"], [14, "release"]] },
  { name: "dropdown.searchable", approvals: ["design", "code", "accessibility", "release-notes"],
    history: [[0, "proposal"], [6, "discovery"], [11, "draft"], [18, "review"], [47, "acceptance"], [52, "release"]] },
  { name: "number-field", approvals: ["design", "code", "accessibility", "release-notes"],
    history: [[0, "proposal"], [3, "discovery"], [10, "draft"], [21, "review"], [29, "draft"], [36, "review"], [44, "acceptance"], [46, "release"]] },
  { name: "tooltip.docs", approvals: ["design", "code", "accessibility", "release-notes"],
    history: [[0, "proposal"], [4, "deferred"], [61, "discovery"], [66, "draft"], [70, "review"], [78, "acceptance"], [80, "release"]] },
  { name: "card.clickable", approvals: ["design", "code", "accessibility", "release-notes"],
    history: [[0, "proposal"], [3, "review"], [8, "acceptance"], [10, "release"]] },
  { name: "table.density", approvals: ["design", "code", "release-notes"],
    history: [[0, "proposal"], [5, "discovery"], [9, "draft"], [16, "review"], [23, "acceptance"], [25, "release"]] },
  { name: "badge.icon-removal", approvals: ["design"],
    history: [[0, "proposal"], [7, "discovery"], [19, "rejected"]] },
];

// 1. Transition and gate checks.
console.log("invalid transitions and gate violations");
let flawed = 0;
for (const c of CONTRIBUTIONS) {
  const flaws = [];
  for (let i = 1; i < c.history.length; i++) {
    const before = c.history[i - 1][1];
    const now = c.history[i][1];
    if (!TRANSITION[before].includes(now)) flaws.push(`invalid transition: ${before} → ${now}`);
    const gate = GATE[`${before}->${now}`];
    if (gate) {
      const missing = gate.filter((o) => !c.approvals.includes(o));
      if (missing.length) flaws.push(`missing approval for ${before} → ${now}: ${missing.join(", ")}`);
    }
  }
  if (flaws.length) {
    flawed++;
    console.log(`${c.name.padEnd(23)} ${flaws.join(" | ")}`);
  }
}
console.log(`flawed contributions: ${flawed}/${CONTRIBUTIONS.length}`);

// 2. Time spent per stage.
const durations = {};
for (const c of CONTRIBUTIONS) {
  for (let i = 1; i < c.history.length; i++) {
    const state = c.history[i - 1][1];
    const duration = c.history[i][0] - c.history[i - 1][0];
    (durations[state] ??= []).push(duration);
  }
}
const median = (arr) => {
  const s = [...arr].sort((a, b) => a - b);
  const m = Math.floor(s.length / 2);
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};

console.log("\nstage        transitions   median days   longest days   total days");
for (const state of ["proposal", "discovery", "draft", "review", "acceptance", "deferred"]) {
  const d = durations[state] ?? [];
  if (d.length === 0) continue;
  console.log(
    `${state.padEnd(12)} ${String(d.length).padStart(11)} ${String(median(d)).padStart(13)} ` +
    `${String(Math.max(...d)).padStart(14)} ${String(d.reduce((t, x) => t + x, 0)).padStart(11)}`
  );
}

// 3. End-to-end time: proposal -> release, for contributions that reached release.
const released = CONTRIBUTIONS.filter((c) => c.history.at(-1)[1] === "release");
const endToEnd = released.map((c) => c.history.at(-1)[0] - c.history[0][0]);
console.log("\ncontribution            end-to-end days");
for (const c of released) console.log(`${c.name.padEnd(23)} ${String(c.history.at(-1)[0]).padStart(15)}`);
console.log(`median end-to-end time: ${median(endToEnd)} days`);
console.log(`longest: ${Math.max(...endToEnd)} days   shortest: ${Math.min(...endToEnd)} days`);

// 4. Bottleneck: give each stage's share of the total time spent.
const totalDuration = Object.values(durations).flat().reduce((t, x) => t + x, 0);
console.log("\nstage's share of total time");
const shares = Object.entries(durations)
  .map(([state, d]) => [state, d.reduce((t, x) => t + x, 0)])
  .sort((a, b) => b[1] - a[1]);
for (const [state, t] of shares) {
  console.log(`${state.padEnd(12)} ${String(t).padStart(4)} days  ${((t / totalDuration) * 100).toFixed(1)}%`);
}
```

```
invalid transitions and gate violations
card.clickable          invalid transition: proposal → review
table.density           missing approval for review → acceptance: accessibility
flawed contributions: 2/7

stage        transitions   median days   longest days   total days
proposal               7             4              7          30
discovery              6             5             12          36
draft                  6             7             11          40
review                 7             8             29          68
acceptance             6             2              5          15
deferred               1            57             57          57

contribution            end-to-end days
button.width                         14
dropdown.searchable                  52
number-field                         46
tooltip.docs                         80
card.clickable                       10
table.density                        25
median end-to-end time: 35.5 days
longest: 80 days   shortest: 10 days

stage's share of total time
review         68 days  27.6%
deferred       57 days  23.2%
draft          40 days  16.3%
discovery      36 days  14.6%
proposal       30 days  12.2%
acceptance     15 days  6.1%
```

#### Reading the Two Flaws

Two of the seven contributions are flawed, and the two flaws differ and break
different things.

`card.clickable` jumped straight from proposal to review; discovery and draft were
skipped. The result is the shortest end-to-end time on the list: ten days. The work of
the two skipped stages was never done either: the scope criterion never ran, so
whether this change was the system's job was never asked, and with no draft written,
migration impact was never computed. This fast-looking path pushes its cost to later.

`table.density` made the transitions correctly but was accepted without accessibility
approval. This overlaps with a row from the first lesson's catalog table: `table`'s
accessibility review looked like it had not passed. Two audits show the same gap from
two different places — one from the catalog's state, the other from the contribution's
history.

This is where gates earn their value. Writing that a review type is "expected" is not
enough; unless it becomes a condition of the transition, it is the first thing skipped
when things get tight.

#### Where the Time Goes

The second table gives stage durations. Review is the most expensive stage both by
median (8 days) and by total time share (27.6%). But total time share alone can
mislead: the `deferred` state's 23.2% share comes entirely from a single contribution —
`tooltip.docs` sat deferred for 57 days.

This is why median and total are read together: total says where resources are going,
median says how long a typical contribution waits. The `deferred` state's median is
also 57, but computed over a single observation, it is not a trend — it is a single
event.

Review shows a similar spread: median 8 days, longest 29. That 29-day wait belongs to
the `dropdown.searchable` contribution and alone explains most of the third table's
52-day end-to-end time. The problem is not the review stage's average but its
**tail**; improving the process should target that long tail, not the median.

`number-field`'s contribution went from review back to draft, then re-entered review.
This return is a valid transition and a sign the flow is working; a review that never
sends a contribution back would show the review had become a formality.

#### The Process's Cost and Classifying Contributions

The median end-to-end time is 35.5 days — neither good nor bad on its own; it gains
meaning against a threshold: **when a contribution's time through the system exceeds
the time it takes to write its own copy, teams write their own copies.** If adding an
optional feature takes a team half a day in its own repository, a thirty-five-day flow
never brings that feature into the system at all; the system stops seeing real needs.

The fix is not removing the gates but classifying contributions. The classification
built in the previous lesson applies directly here:

- **Additive contributions** — a new optional prop, a widened value set — break no
  call. For these, discovery can be shortened and design review can be merged with
  code review.
- **Breaking contributions** follow the full flow; a draft is not considered complete
  until migration cost is computed.
- **New component proposals** follow the longest flow, because this is exactly where
  the scope criterion is actually run.

Once these three paths' times are measured separately, the statement "our process is
slow" turns into a measurable claim: in which class, at which stage, how many days.

#### What the Contributor Sees

The process also has a face that shows outward: the person contributing must be able
to see what state their contribution is in and who the next step is waiting on. This
is the state machine's most concrete benefit — every contribution has exactly one
state, and that state has an owner.

The second requirement is that the **definition of done** be written down. Unless the
conditions for a draft moving to review — doc sections written, migration impact
computed, an example added — are told to the contributor in advance, the review stage
turns into producing a list of gaps instead of a decision, and the queue grows longer.
Review's 29-day longest wait is more often caused not by a slow review but by a draft
entering review incomplete.

#### Summary

- The contribution flow consists of proposal, discovery, draft, review, acceptance,
  and release; each state has its own question and its own owner.
- Transition validity and acceptance-gate approvals are machine-checkable; when they
  are not checked, the stage most often skipped is a review with no condition attached
  to it.
- A contribution that skips a stage looks fast, but the work of the skipped stage was
  never done; the cost is pushed to later.
- Total time share tells you where resources are going, median tells you the typical
  wait; without reading both together, a single long wait looks like a trend.
- When a contribution's time through the system exceeds the time it takes to write its
  own copy, the system stops seeing real needs; the fix is not removing the gates but
  defining separate paths for additive and breaking contributions.
- Unless the definition of done is written in advance, the review stage turns into
  producing a list of gaps instead of a decision, and the queue grows longer.

#### Next Step

This lesson's state machine did not ask one thing: who does the review? Having all
three approvals come from a single central team produces a different system than
having approvals come from within the contributing teams themselves; the two differ in
queue time, consistency level, and how they behave at scale. The next lesson takes up
this question as a governance model: it models centralized, federated, and hybrid
arrangements over demand and capacity, and computes each one's queue time and the
number of deviations it produces at two different scales.

### Governance Model

Source: https://academia.sh/en/courses/design-systems/governance-model

Modeling centralized, federated, and hybrid governance over demand and capacity, queue time turning into local copies, the tipping point that shifts with scale, and separating decisions that must stay centralized.

The previous lesson's state machine did not ask one thing: who does the review?
Having all three approvals come from a single central team produces a different system
than having approvals come from within the contributing teams themselves.

The governance model is the answer to this question, and it is a choice between two
quantities: **consistency** and **speed**. If a single team reviews everything,
decisions are consistent but the queue grows; if every team reviews itself, the queue
shortens but decisions scatter. This lesson measures the trade-off with a model and
shows that the choice depends on scale.

#### Three Models

**Centralized governance.** The system has a single owning team. Component design,
review, and release all pass through this team. Product teams propose contributions,
but the decision is central. Capacity is bounded by this team's size and does not grow
as the organization grows.

**Federated governance.** The system's components are distributed among the product
teams. Each team reviews and releases the components it owns internally. Capacity
grows together with team count. But the reviewer only sees their own product; they do
not know how the same decision was made in another product.

**Hybrid governance.** A core team holds the token layer, the naming convention, and
breaking-change approval; drafting and the first review happen inside the product
teams. Capacity depends on both the product teams' preparation capacity and the core's
approval capacity — whichever is smaller sets the bound.

What separates these three models is how much of the contributions are reviewed with
**an eye that sees the whole system**. The model below takes this ratio directly as a
variable and computes the deviation count from it.

#### Building the Model

The model tracks three things: demand arriving per period, the model's capacity, and
the work accumulating in the queue. When the queue exceeds a certain duration, a
contribution is abandoned and that team writes its own copy; this copy is a deviation.
The second source of deviation is reviews carried out without system-wide visibility.

```js
// governance.mjs — comparing three governance models by queue time and deviation count

// Assumptions (all taken as this organization's measured values):
const PERIOD = 12;                  // how many periods to track
const DEMAND_PER_TEAM = 5;          // contribution demand a product team produces per period
const WAIT_THRESHOLD = 2;           // work waiting longer than this many periods is abandoned
const BASE_DEVIATION_RATE = 0.35;   // probability that a contribution reviewed without system-wide visibility deviates

// Models. "capacity" gives the number of contributions completable in one period;
// "visibility" gives how much of the contributions are reviewed with an eye that
// sees the whole system.
const MODELS = [
  {
    name: "centralized",
    capacity: () => 22,               // single team; independent of team count
    visibility: 0.95,
  },
  {
    name: "federated",
    capacity: (team) => 6 * team,     // each product team runs its own review
    visibility: 0.15,                 // spot-check review only
  },
  {
    name: "hybrid",
    capacity: (team) => Math.min(55, 6 * team), // product team prepares, core approves
    visibility: 0.70,
  },
];

function run(capacity, visibility, demand) {
  let queue = 0, accepted = 0, localCopy = 0, queueTotal = 0;
  for (let d = 0; d < PERIOD; d++) {
    queue += demand;
    const processed = Math.min(queue, capacity);
    queue -= processed;
    accepted += processed;
    const ceiling = WAIT_THRESHOLD * capacity;
    if (queue > ceiling) {
      localCopy += queue - ceiling;
      queue = ceiling;
    }
    queueTotal += queue;
  }
  const avgQueue = queueTotal / PERIOD;
  return {
    accepted,
    localCopy,
    wait: avgQueue / capacity,
    reviewDeviation: accepted * BASE_DEVIATION_RATE * (1 - visibility),
    finalQueue: queue,
  };
}

for (const team of [4, 12]) {
  const demand = DEMAND_PER_TEAM * team;
  console.log(`\n=== ${team} product teams · ${demand} contributions demanded per period · ${PERIOD} periods · total demand ${demand * PERIOD} ===`);
  console.log("model         capacity   accepted     pending    wait(periods)   review deviation   local copy   total deviation");
  const rows = [];
  for (const m of MODELS) {
    const cap = m.capacity(team);
    const s = run(cap, m.visibility, demand);
    const total = s.reviewDeviation + s.localCopy;
    rows.push({ name: m.name, total });
    console.log(
      `${m.name.padEnd(13)} ${String(cap).padStart(8)} ${String(s.accepted).padStart(10)} ${String(s.finalQueue).padStart(10)} ` +
      `${s.wait.toFixed(2).padStart(15)} ${s.reviewDeviation.toFixed(1).padStart(19)} ` +
      `${String(s.localCopy).padStart(13)} ${total.toFixed(1).padStart(16)}`
    );
  }
  rows.sort((a, b) => a.total - b.total);
  console.log(`model producing least deviation: ${rows[0].name} (${rows[0].total.toFixed(1)})`);
}

// Tipping point: up to how many product teams does the centralized model keep up
// without building a queue?
console.log("\nteams   demand   centralized capacity   builds a queue");
for (let team = 2; team <= 8; team++) {
  const demand = DEMAND_PER_TEAM * team;
  const cap = MODELS[0].capacity(team);
  console.log(`${String(team).padStart(5)} ${String(demand).padStart(8)} ${String(cap).padStart(21)} ${(demand > cap ? "yes" : "no").padStart(16)}`);
}

// How much the core's approval capacity changes the outcome in the hybrid model.
console.log("\ncore approval capacity in the hybrid model (12 teams, demand 60, total demand 720)");
console.log("core   effective capacity   accepted     pending   local copy   total deviation");
for (const core of [35, 45, 55, 65]) {
  const cap = Math.min(core, 6 * 12);
  const s = run(cap, 0.70, 60);
  console.log(
    `${String(core).padStart(4)} ${String(cap).padStart(19)} ${String(s.accepted).padStart(10)} ` +
    `${String(s.finalQueue).padStart(10)} ${String(s.localCopy).padStart(13)} ` +
    `${(s.reviewDeviation + s.localCopy).toFixed(1).padStart(17)}`
  );
}
```

```

=== 4 product teams · 20 contributions demanded per period · 12 periods · total demand 240 ===
model         capacity   accepted     pending    wait(periods)   review deviation   local copy   total deviation
centralized         22        240          0            0.00                 4.2             0              4.2
federated           24        240          0            0.00                71.4             0             71.4
hybrid              24        240          0            0.00                25.2             0             25.2
model producing least deviation: centralized (4.2)

=== 12 product teams · 60 contributions demanded per period · 12 periods · total demand 720 ===
model         capacity   accepted     pending    wait(periods)   review deviation   local copy   total deviation
centralized         22        264         44            1.98                 4.6           412            416.6
federated           72        720          0            0.00               214.2             0            214.2
hybrid              55        660         60            0.59                69.3             0             69.3
model producing least deviation: hybrid (69.3)

teams   demand   centralized capacity   builds a queue
    2       10                    22               no
    3       15                    22               no
    4       20                    22               no
    5       25                    22              yes
    6       30                    22              yes
    7       35                    22              yes
    8       40                    22              yes

core approval capacity in the hybrid model (12 teams, demand 60, total demand 720)
core   effective capacity   accepted     pending   local copy   total deviation
  35                  35        420         70           230             274.1
  45                  45        540         90            90             146.7
  55                  55        660         60             0              69.3
  65                  65        720          0             0              75.6
```

#### The Outcome Changing with Scale

The two tables run the same three models at two different scales, and the ranking
reverses.

At four product teams, the centralized model gives the best result: all 240
contributions ship, there is no wait, and deviation is 4.2. The federated model does
the same work in the same time, but its deviation is 71.4 — seventeen times higher. At
this scale, the central team's capacity is enough to meet demand; the federated
model's extra capacity buys nothing, only paying the cost of lower review visibility.

At twelve product teams, the centralized model collapses: deviation is 416.6. 412 of
that number is local copies. The central team can only handle 264 of the 720 demanded;
the rest sits in the queue past the wait threshold and teams write their own copies.

This mechanism is the point most governance discussions miss: the centralized model's
cost is **not the wait itself** but the wait turning into abandonment. The central
team's review quality has not dropped at all — its deviation rate is still 4.6, the
lowest value. The system stays good at what it reviews; the problem is that it is not
reviewing most of the work.

The third table gives the centralized model's tipping point: no queue builds up to
four product teams, and one starts from the fifth on. This number is not a rule but the
ratio of this organization's capacity to its demand — enlarge the central team and the
point shifts right. What decides is not the model's name but this ratio.

#### Tuning the Hybrid Model

The last table shows how sensitive the hybrid model is to a single parameter — the
core's approval capacity — and it needs to be read carefully.

At a core capacity of 35, the hybrid model is nearly as bad as the centralized one: 230
local copies. At capacity 45, local copies drop to 90; at 55, to zero. The hybrid
model working, in other words, depends on the core approving **fast enough to keep up
with the product teams' preparation speed**. If the core becomes the bottleneck, a
model named hybrid is a centralized model in disguise.

The last row holds a trap: at core capacity 65, total deviation rises to 75.6 — higher
than 69.3 at 55. This does not mean raising capacity is harmful. At 55, 660
contributions have shipped and 60 still sit in the queue; at 65, all 720 have shipped.
Deviation per shipped contribution is the same in both cases — 69.3 over 660 and 75.6
over 720 both equal 0.105. The total looks lower only because part of the work had not
been done yet.

This is a general flaw to avoid when building a measurement: **unmet demand can look
like a success.** Unless the number of contributions shipped and the length of the
pending queue are reported, a system that does no work produces the cleanest-looking
numbers.

#### Decisions That Must Stay Centralized

Model choice is not a single switch; each type of decision can be kept centralized or
distributed on its own. The criterion for the split is **how many components a
decision affects at once**.

Decisions that must stay centralized are the ones whose effect reaches beyond
individual components: the token layer's structure, the naming convention, maturity
levels' exit criteria, breaking-change approval, and accessibility criteria. One of
these being applied differently in two teams ends the system's being a system.

Decisions that can be distributed are the ones that stay within a single component:
adding an optional prop to a component, widening a value set, writing doc examples,
designing product-specific components. The classification built in the previous lesson
turns directly into an authorization rule here: **additive contributions are
distributed, breaking contributions stay centralized.**

For this split to work, a third record is needed: **ownership**. Every component must
have a named owner; a component with no owner is reviewed under no model. The
ownership record is part of the catalog and is kept together with the maturity level
defined in the first lesson.

#### Summary

- Governance model is a choice between consistency and speed; the three
  arrangements — centralized, federated, hybrid — are defined by the variables capacity
  and review visibility. The centralized model's capacity does not grow with the
  organization; the federated model's capacity grows but its review visibility drops.
- The centralized model's cost is not the wait itself but the wait turning into local
  copies; the system stays good at what it reviews, but it stops reviewing most of the
  work.
- Model choice depends on scale: the same three models favor the centralized model at
  four teams and the hybrid model at twelve. The tipping point is computed from the
  ratio of capacity to demand.
- If the core's approval capacity in the hybrid model does not keep up with the product
  teams' preparation speed, the model turns into a centralized model called hybrid.
- Unmet demand misleads when it is not reported; a low deviation number is deceptive
  unless shipped contributions and pending queue are reported together.
- Decisions are distributed one by one: ones affecting multiple components stay
  centralized, ones staying within a single component are distributed; every component
  must have a written owner.

#### Next Step

In this lesson's model, "local copy" was an assumption: it was assumed that a certain
portion of the work waiting in the queue would leave the system. In a real
organization, this number is not assumed, it is measured. The next lesson builds
adoption measurement: it computes the ratio of system component usage to local copies
through a repository scan, derives coverage percentage by team, produces a deviation
list, and shows which local copy points to a component that should enter the catalog.

### Adoption Measurement

Source: https://academia.sh/en/courses/design-systems/adoption-measurement

Computing coverage percentage by team through a repository scan, its difference from traffic-weighted coverage, classifying the deviation list into local copies and catalog candidates, and the limits of heuristic matching.

The previous lesson's model treated "local copy" as an assumption: it assumed that a
certain portion of the work waiting in the queue would leave the system. In a real
organization, this number is not assumed, it is measured.

Adoption measurement's purpose is not to produce a success report. Finding where the
system is not used tells you what it is missing; every local copy points either to a
gap in the catalog, a findability problem, or a clogged contribution path.

#### What Gets Measured

The weakest question you can ask about adoption is "are teams using the system"; the
answer is always "yes." The measurable question is: **what percentage of the component
calls in the interface come from the system?**

This ratio is coverage percentage, and it can be measured two different ways:

- **Static scan.** Imports and component calls in the source text are counted. Every
  call site counts as one unit; a call in a rarely opened admin screen carries the same
  weight as a call on the home page.
- **Runtime telemetry.** The component instances actually created are counted and
  weighted by how often the screen is viewed.

The two answer different questions, and both are needed. The static scan measures
maintenance load: it tells you how many places would need code changed. The
traffic-weighted measurement measures user impact: it tells you how much of the visible
interface the system is producing.

#### Running the Scan

The program below scans seven files across three product teams. It resolves each
file's imports, counts component calls, classifies each call by whether it comes from
the system package or a local file, and computes the two coverage percentages
together. It then classifies the local components against the catalog.

```js
// adoption.mjs — coverage percentage via repository scan, deviation list, catalog candidates

// Source files. The path's first segment gives the team. The number is that screen's
// views per period; runtime weighting comes from here. View descriptions are in the
// form el(component, props, ...children).
const REPO = [
  { path: "product-catalog/search-page.js", views: 48200, source: `
import { SearchField, Card, Badge, Button, Pagination, EmptyState } from "@system/components";
import { FacetPanel } from "../components/facet-panel";
export const SearchPage = () => el("div", {},
  el(SearchField), el(FacetPanel),
  el(Card, {}, el(Badge), el(Button)),
  el(Card, {}, el(Badge), el(Button)),
  el(EmptyState), el(Pagination));` },
  { path: "product-catalog/record-page.js", views: 31400, source: `
import { Card, Badge, Button, Breadcrumb, Tabs } from "@system/components";
import { RecordCard } from "../components/record-card";
export const RecordPage = () => el("div", {},
  el(Breadcrumb), el(RecordCard), el(Tabs),
  el(Card, {}, el(Badge), el(Button), el(Button)));` },
  { path: "product-catalog/borrow-page.js", views: 9800, source: `
import { Button, NotificationBanner, Skeleton } from "@system/components";
import { CustomButton } from "../components/custom-button";
import { LoadingBar } from "../components/loading-bar";
export const BorrowPage = () => el("div", {},
  el(NotificationBanner), el(Skeleton),
  el(CustomButton), el(CustomButton), el(Button), el(LoadingBar));` },
  { path: "product-membership/member-login.js", views: 22600, source: `
import { TextField, Button, Link, NotificationBanner } from "@system/components";
export const MemberLogin = () => el("form", {},
  el(TextField), el(TextField), el(Button), el(Link), el(NotificationBanner));` },
  { path: "product-membership/member-profile.js", views: 7300, source: `
import { Card, TextField, Checkbox, Button } from "@system/components";
import { StatusBadge } from "../components/status-badge";
import { CustomButton } from "../components/custom-button";
export const MemberProfile = () => el("div", {},
  el(Card, {}, el(StatusBadge), el(TextField), el(Checkbox)),
  el(CustomButton), el(Button));` },
  { path: "product-admin/shelf-management.js", views: 1900, source: `
import { Button, TextField, Modal } from "@system/components";
import { DataTable } from "../components/data-table";
import { FacetPanel } from "../components/facet-panel";
import { SimpleDropdown } from "../components/simple-dropdown";
export const ShelfManagement = () => el("div", {},
  el(FacetPanel), el(SimpleDropdown), el(DataTable),
  el(Modal, {}, el(TextField), el(Button)));` },
  { path: "product-admin/user-management.js", views: 1200, source: `
import { Button, TextField } from "@system/components";
import { DataTable } from "../components/data-table";
import { CustomModal } from "../components/custom-modal";
import { StatusBadge } from "../components/status-badge";
export const UserManagement = () => el("div", {},
  el(DataTable), el(StatusBadge), el(StatusBadge),
  el(CustomModal, {}, el(TextField), el(Button)));` },
];

const SYSTEM_PACKAGE = "@system/components";
// Catalog components' code-side names; local-copy detection looks these up.
const CATALOG = ["Button", "Link", "TextField", "Checkbox", "Badge", "Card", "Pagination",
  "SearchField", "EmptyState", "Modal", "Skeleton", "Tabs", "Dropdown", "NotificationBanner", "Breadcrumb"];

// Resolve a file's imports: name -> source.
function imports(source) {
  const map = new Map();
  for (const m of source.matchAll(/import\s*\{([^}]+)\}\s*from\s*"([^"]+)"/g)) {
    for (const name of m[1].split(",").map((s) => s.trim()).filter(Boolean)) map.set(name, m[2]);
  }
  return map;
}
// Usage count: an el() call is a component if its first argument starts with a capital letter.
function usages(source) {
  const count = new Map();
  for (const m of source.matchAll(/\bel\(\s*([A-Z][A-Za-z0-9]*)/g)) count.set(m[1], (count.get(m[1]) ?? 0) + 1);
  return count;
}

const teams = new Map();
const localTotal = new Map();
for (const file of REPO) {
  const team = file.path.split("/")[0];
  const imp = imports(file.source);
  const use = usages(file.source);
  const e = teams.get(team) ?? { system: 0, local: 0, weightedSystem: 0, weightedLocal: 0 };
  for (const [name, count] of use) {
    const source = imp.get(name);
    if (!source) continue;
    const isSystem = source === SYSTEM_PACKAGE;
    e[isSystem ? "system" : "local"] += count;
    e[isSystem ? "weightedSystem" : "weightedLocal"] += count * file.views;
    if (!isSystem) {
      const l = localTotal.get(name) ?? { count: 0, teams: new Set() };
      l.count += count;
      l.teams.add(team);
      localTotal.set(name, l);
    }
  }
  teams.set(team, e);
}

console.log("team                 system   local   coverage   traffic-weighted coverage");
let totalSystem = 0, totalLocal = 0, wSystem = 0, wLocal = 0;
for (const [team, e] of teams) {
  totalSystem += e.system; totalLocal += e.local;
  wSystem += e.weightedSystem; wLocal += e.weightedLocal;
  const coverage = (e.system / (e.system + e.local)) * 100;
  const weighted = (e.weightedSystem / (e.weightedSystem + e.weightedLocal)) * 100;
  console.log(
    `${team.padEnd(20)} ${String(e.system).padStart(6)} ${String(e.local).padStart(7)} ` +
    `${(coverage.toFixed(1) + "%").padStart(10)} ${(weighted.toFixed(1) + "%").padStart(26)}`
  );
}
console.log(
  `${"TOTAL".padEnd(20)} ${String(totalSystem).padStart(6)} ${String(totalLocal).padStart(7)} ` +
  `${(((totalSystem / (totalSystem + totalLocal)) * 100).toFixed(1) + "%").padStart(10)} ` +
  `${(((wSystem / (wSystem + wLocal)) * 100).toFixed(1) + "%").padStart(25)}`
);

// Deviation list: does the local component have a catalog counterpart?
const findMatch = (localName) => CATALOG.find((k) => localName.includes(k)) ?? null;
console.log("\ndeviation list");
console.log("local component      usage   teams   catalog match        class");
const candidates = [];
for (const [name, l] of [...localTotal].sort((a, b) => b[1].count - a[1].count)) {
  const match = findMatch(name);
  const cls = match ? "local copy" : (l.teams.size >= 2 ? "catalog candidate" : "product-specific");
  if (cls === "catalog candidate") candidates.push(name);
  console.log(
    `${name.padEnd(21)} ${String(l.count).padStart(6)} ${String(l.teams.size).padStart(7)}   ` +
    `${(match ?? "-").padEnd(20)} ${cls}`
  );
}

const copyCount = [...localTotal].filter(([name]) => findMatch(name)).reduce((t, [, l]) => t + l.count, 0);
console.log(`\nlocal-copy usage             : ${copyCount} (could move into the catalog)`);
console.log(`local components that are catalog candidates: ${JSON.stringify(candidates)}`);
console.log(`coverage if copies moved into the catalog: ${(((totalSystem + copyCount) / (totalSystem + totalLocal)) * 100).toFixed(1)}%`);
```

```
team                 system   local   coverage   traffic-weighted coverage
product-catalog          18       5      78.3%                      85.7%
product-membership        9       2      81.8%                      90.7%
product-admin             5       7      41.7%                      43.5%
TOTAL                    32      14      69.6%                     85.7%

deviation list
local component      usage   teams   catalog match        class
CustomButton               3       2   Button               local copy
StatusBadge                3       2   Badge                local copy
FacetPanel                 2       2   -                    catalog candidate
DataTable                  2       1   -                    product-specific
RecordCard                 1       1   Card                 local copy
LoadingBar                 1       1   -                    product-specific
SimpleDropdown             1       1   Dropdown             local copy
CustomModal                1       1   Modal                local copy

local-copy usage             : 9 (could move into the catalog)
local components that are catalog candidates: ["FacetPanel"]
coverage if copies moved into the catalog: 89.1%
```

#### Telling the Two Coverage Numbers Apart

The total row shows a 16.1-point gap between the two numbers: static coverage is
69.6%, traffic-weighted coverage is 85.7%. The gap comes from a single team —
`product-admin`'s coverage is 41.7%, the lowest on the list, but its screens' view
count is small next to the others', so it nearly disappears in the weighted
calculation.

Which number to use depends on the question. When it is how much of what the user sees
comes from the system, the weighted number is correct. When it is the system's
maintenance load and how many places a breaking change would touch, the static number
is correct; the versioning lesson's migration-cost calculation also rests on this
second number.

Reporting only the weighted number hides low-traffic products dropping out of the
system entirely: `product-admin` sits at 41.7%, and if its situation does not improve,
the system will never be used in this product at all. A governance decision that only
looks at the weighted number never sees this team.

#### Classifying the Deviation List

The deviation list splits local components into three classes, and each class has a
different job.

**Local copy** — a component with a catalog counterpart. `CustomButton` and
`StatusBadge` are each used by two separate teams; both were written in place of a
component already in the system. These rows raise a question: why was the copy
written? Usually one of three reasons — the system component lacked a needed feature,
the escape hatch was insufficient, or the contribution path was stuck in the queue seen
in the previous lesson. Remove the copy without finding the answer and the same copy
returns under another name.

**Catalog candidate** — a component with no counterpart that is used by two separate
teams. `FacetPanel` shows up in this class. The first lesson's scope criterion was
looking for exactly this situation: a part duplicated across multiple teams is a
candidate for entering the catalog.

**Product-specific** — a component with no counterpart, used by a single team.
`DataTable` and `LoadingBar` fall in this class. These are not deviations; they are
parts of the product itself, and bringing them into the catalog would only add review
overhead.

The last line gives the coverage impact of closing the deviation: if the nine local
copies moved into the system, coverage would rise from 69.6% to 89.1%. This number is
not a target; it is a **ceiling**: it shows the best-case return on closing the copies
and lets that work be compared against other work.

#### The Limit of Heuristic Matching

The report is the output of a program, and the matching rule the program uses is
heuristic: if a catalog name appears inside a local name, it is counted as a copy. This
rule is wrong in two places, and both are instructive.

The `RecordCard` row is flagged as a copy of the `Card` component. But like `record-row` in the first
lesson, a record card is a product-specific part that decides which fields of a bibliographic record are
shown in what order — its name containing "Card" does not show it is a copy of the card component; it more
likely just **uses** card. What the heuristic matcher produces is not a conclusion but a **review queue**.

The `FacetPanel` row's error is more interesting: the program flags it as a catalog
candidate, since nothing named "FacetPanel" exists in the catalog. But the first
lesson's catalog table has an entry called `filter-panel` — used by a single team in
only four places, so it never met the scope criterion. Placed side by side, the real
problem becomes visible: an unused filter panel sits in the catalog while two teams
have each written their own facet panel.

This is not a scope problem but a **findability** problem, and its source is the
synonym issue defined in the naming lesson. The fix is not adding a new component but
writing the existing component's synonymous names into the dictionary and feeding the
search surface from it. Measurement reaches this conclusion only by reading two reports
together; no single number says it alone.

#### The Cost of Measurement Itself

Adoption measurement is an ongoing job, and it carries two traps.

The first is turning coverage percentage into a **target**. If teams are judged by this
number, product-specific components get forced into the catalog and the catalog loses
the scope criterion from the first lesson. Coverage percentage is a diagnostic tool;
the target is closing the causes of the deviation.

The second is the scan **undercounting**. The program above counts imports and calls; a
component that wraps a system component and re-exports it, or a screen that uses a
system component but overrides its styling entirely, escapes this count. Both are
deviations and need separate checks: a list of wrapping components and a count of
style rules written onto system components from outside.

#### Summary

- Adoption is not measured by "is the system used" but by what percentage of the
  interface's component calls come from the system.
- Static scan gives maintenance load, traffic-weighted measurement gives user impact;
  reporting only the weighted number hides low-traffic products dropping out of the
  system.
- The deviation list splits into three classes: local copy points to a gap to close,
  catalog candidate to a new component, product-specific to no action at all. If a
  local copy's cause is not found before removing it, the same copy returns under
  another name.
- Matching based on name similarity is heuristic and gets it wrong; the list it
  produces is not a conclusion but a review queue.
- When an unused component sitting in the catalog and the local counterpart teams wrote
  are seen side by side, the problem is not scope but findability; the fix is recording
  synonymous names.
- If coverage percentage is turned into a target, the catalog loses its scope
  criterion; measurement is a diagnostic tool.

#### Next Step

This lesson's scan stayed on the code side: it checked whether the system component
was used, but not whether the values it carries match the design library's. A team may
be using the system button while the button's fill color differs from the one in the
design file; coverage percentage counts this as one hundred percent. The next
lesson builds the toolchain: it produces a diff report comparing the token export
coming out of the design tool against the token definition in code, normalizes names
and color formats to eliminate false diffs, and classifies the real deviation that
remains.

### Toolchain

Source: https://academia.sh/en/courses/design-systems/toolchain

The single-direction source of truth between the design tool and code, normalizing the token export, eliminating false diffs, and classifying the remaining real deviation to arrive at a sync rate.

The previous lesson's scan stayed on the code side: it checked whether the system
component was used, but not whether the values it carries match the design library's.
A team may be using the system button while the button's fill color differs from the
design file's; coverage percentage counts this as one hundred percent.

The toolchain closes this gap. Its job is to **continuously verify** that the values in
the design tool and the values in code are the same. This lesson defines the chain's
direction, produces the diff report, and shows the normalization needed for the report
to become readable.

#### The Direction of Truth

If a value is kept in two places, which one is true must be decided in advance. There
are three arrangements, and all three are single-direction:

- **The design tool is the source.** Values are defined in the design library,
  exported, and generated into code. Code is never edited by hand.
- **Code is the source.** Values are defined in a token file and imported into the
  design tool. The designer changes the value in the file, not in the tool.
- **There is a neutral source.** Values are defined in a file independent of both the
  tool and the code — a JSON document — and both the design tool and code are fed from
  this file.

A fourth option that looks available, **two-way sync**, does not work: if both sides
have write access, the same token changing on both at once produces a conflict with no
rule to resolve it — which value is newer does not tell you which is more correct.
This is why the first decision in building a toolchain is which direction it flows;
technical choices come after.

Once the direction is chosen, the chain's shape is the same: **export → transform →
target formats**. The transform step converts names to the target's convention, brings
values into the target's units, and computes derived tokens. There can be more than one
target format — CSS custom properties, mobile format files, and doc pages can all be
generated from the same source.

#### Producing the Diff Report

Whichever direction is chosen, the two sides still need to be checked for sameness.
The real obstacle to this check is that the two sides **write the same value in a
different form**: the design tool writes a color in uppercase hex, code in lowercase;
the design tool writes a length in pixels, code as a root-relative unit.

The program below compares eighteen design tokens against eighteen code tokens in
three stages: raw, with only names normalized, and with both names and values
normalized.

```js
// toolchain.mjs — diff report between the design tool's token export and the code
// token definition

// The design tool's exported name-value pairs.
const DESIGN = {
  "Color/Surface/Primary": "#FFFFFF",
  "Color/Surface/Secondary": "#F7F7F8",
  "Color/Border/Default": "#B7BCC2",
  "Color/Text/Primary": "rgb(31, 35, 40)",
  "Color/Text/Secondary": "#5A6169",
  "Color/Action/Main": "#275EA5",
  "Color/Action/On Main": "#FFF",
  "Color/Action/Secondary": "#3B7BC8",
  "Spacing/Step 1": "4px",
  "Spacing/Step 2": "8px",
  "Spacing/Step 3": "12px",
  "Spacing/Step 4": "16px",
  "Typography/Body/Size": "16px",
  "Typography/Body/Line Height": "1.5",
  "Typography/Heading/Size": "24px",
  "Radius/Small": "4px",
  "Radius/Medium": "8px",
  "Shadow/Elevation 1": "0 1px 2px rgba(0, 0, 0, 0.08)",
};

// The custom properties defined in code.
const CODE = {
  "--color-surface-primary": "#ffffff",
  "--color-surface-secondary": "#f7f7f8",
  "--color-border-default": "#b7bcc2",
  "--color-text-primary": "#1f2328",
  "--color-text-secondary": "#5a6169",
  "--color-action-main": "#275ea5",
  "--color-action-on-main": "#ffffff",
  "--color-action-secondary": "#2f6bb5",
  "--spacing-step-1": "0.25rem",
  "--spacing-step-2": "0.5rem",
  "--spacing-step-3": "0.75rem",
  "--spacing-step-4": "1rem",
  "--typography-body-size": "1rem",
  "--typography-body-line-height": "1.5",
  "--typography-heading-size": "1.5rem",
  "--radius-small": "0.25rem",
  "--radius-medium": "0.5rem",
  "--color-action-danger": "#a12d2d",
};

// Name normalization: the same transliteration rule established in the naming lesson.
const TRANSLIT = { "’": "'", "‘": "'", "“": '"', "”": '"', "–": "-", "—": "-", "…": "..." };
const normalize = (s) => [...s].map((ch) => TRANSLIT[ch] ?? ch).join("");
const nameNormal = (designName) => "--" + normalize(designName).toLowerCase().replace(/[/\s]+/g, "-");

// Value normalization: color -> six-digit lowercase hex, length -> pixels.
const ROOT_SIZE = 16;
function valueNormal(value) {
  const v = value.trim().replace(/\s+/g, " ");
  const shortHex = v.match(/^#([0-9a-fA-F]{3})$/);
  if (shortHex) return "#" + [...shortHex[1]].map((h) => h + h).join("").toLowerCase();
  if (/^#[0-9a-fA-F]{6}$/.test(v)) return v.toLowerCase();
  const rgb = v.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
  if (rgb) return "#" + rgb.slice(1, 4).map((n) => Number(n).toString(16).padStart(2, "0")).join("");
  const rem = v.match(/^(-?[\d.]+)rem$/);
  if (rem) return `${Number(rem[1]) * ROOT_SIZE}px`;
  const px = v.match(/^(-?[\d.]+)px$/);
  if (px) return `${Number(px[1])}px`;
  return v;
}

// Three-stage comparison: raw, name normalization only, full normalization.
function compare(nameTransform, valueTransform) {
  const left = new Map(Object.entries(DESIGN).map(([a, d]) => [nameTransform(a), valueTransform(d)]));
  const right = new Map(Object.entries(CODE).map(([a, d]) => [a, valueTransform(d)]));
  const designOnly = [...left.keys()].filter((a) => !right.has(a));
  const codeOnly = [...right.keys()].filter((a) => !left.has(a));
  const valueDiff = [...left.entries()].filter(([a, d]) => right.has(a) && right.get(a) !== d).map(([a]) => a);
  return { designOnly, codeOnly, valueDiff, common: left.size - designOnly.length };
}

const stages = [
  ["raw comparison", (a) => a, (d) => d],
  ["name normalization", nameNormal, (d) => d],
  ["name + value normalization", nameNormal, valueNormal],
];

console.log("stage                       common name   design only   code only   value diff   total diff");
let last = null;
for (const [name, nT, vT] of stages) {
  const s = compare(nT, vT);
  last = s;
  const total = s.designOnly.length + s.codeOnly.length + s.valueDiff.length;
  console.log(
    `${name.padEnd(27)} ${String(s.common).padStart(11)} ${String(s.designOnly.length).padStart(13)} ` +
    `${String(s.codeOnly.length).padStart(11)} ${String(s.valueDiff.length).padStart(12)} ${String(total).padStart(11)}`
  );
}

console.log("\n— REAL DEVIATIONS —");
for (const a of last.valueDiff) {
  const designKey = Object.keys(DESIGN).find((k) => nameNormal(k) === a);
  console.log(`value diff: ${a}\n    design: ${DESIGN[designKey]} → ${valueNormal(DESIGN[designKey])}\n    code  : ${CODE[a]} → ${valueNormal(CODE[a])}`);
}
for (const a of last.designOnly) console.log(`design only: ${a} (${DESIGN[Object.keys(DESIGN).find((k) => nameNormal(k) === a)]})`);
for (const a of last.codeOnly) console.log(`code only: ${a} (${CODE[a]})`);

// Sync rate and gate: at what threshold does a release get blocked?
const totalTokens = new Set([...Object.keys(DESIGN).map(nameNormal), ...Object.keys(CODE)]).size;
const realDiff = last.designOnly.length + last.codeOnly.length + last.valueDiff.length;
console.log(`\ntotal tokens (union): ${totalTokens}`);
console.log(`real deviation: ${realDiff}   sync rate: ${(((totalTokens - realDiff) / totalTokens) * 100).toFixed(1)}%`);
```

```
stage                       common name   design only   code only   value diff   total diff
raw comparison                        0            18          18            0          36
name normalization                   17             1           1           16          18
name + value normalization           17             1           1            1           3

— REAL DEVIATIONS —
value diff: --color-action-secondary
    design: #3B7BC8 → #3b7bc8
    code  : #2f6bb5 → #2f6bb5
design only: --shadow-elevation-1 (0 1px 2px rgba(0, 0, 0, 0.08))
code only: --color-action-danger (#a12d2d)

total tokens (union): 19
real deviation: 3   sync rate: 84.2%
```

#### Eliminating False Diffs

The three-stage table explains why the diff report goes unread in most organizations. The raw comparison
finds 36 diffs, and **most of them are not real**; the two sides are using different naming conventions. A
report like this gets read once, then gets closed.

Name normalization brings the diff down to 18, using the same transliteration rule
established in the naming lesson — its cost was paid there, and its return is
collected here. Without that convention, this step would need its own mapping table,
one that would go stale as tokens were added.

Value normalization brings the diff down to 3. The 15 eliminated diffs came from four format distinctions:
hex-notation letter case, three-digit short notation, the `rgb` functional notation, and length units
written as root-relative instead of pixels. None produce a visual deviation; both sides describe the same
color and the same length.

The order itself is a design decision: normalization happens **before** the diff is
computed, not after. Computing the diff first and then trying to eliminate it means
deciding which diff is false all over again every time.

#### Reading the Remaining Three Deviations

Each of the three remaining rows produces a different piece of work.

**Value diff** — `--color-action-secondary` differs between the two sides. This is a
violation of the source of truth: one side was changed without waiting for the other.
Even once the source's direction has been decided, this row alone does not say which
side is correct; what it says is that the flow broke somewhere. Given that this color
is the secondary action color, and that the typography and color lessons computed the
contrast ratio between it and the primary action color, the deviation's consequence may
not be visual but functional.

**Design only** — `--shadow-elevation-1` exists in the design library but not in code.
This is a design decision that was never implemented, and it enters the contribution
process as a piece of work. This row staying in the report keeps the decision from
being forgotten.

**Code only** — `--color-action-danger` exists in code but not in design. A team
needed a color for a destructive action and added the token on the code side. This
resolves into one of two outcomes: the token is legitimate and gets added to the
design library, or it is not and gets removed. Left undecided, it stays, gets used, and
its counterpart cannot be found the next time a theme is derived.

The sync rate — 84.2% — is these three rows reduced to a single number, and the same
gate built for name matching in the naming lesson can be built here too: when the rate
falls below a floor, the release is blocked. What matters is not the number itself but
that it is **measured on every release**; a deviation is not a one-time event, it is a
quantity that accumulates.

#### The Layer the Chain Cannot Cover

The line between what the toolchain can and cannot do is sharp, and it explains why
the structure built across this lesson cannot be built by tooling alone.

The chain syncs **values**: color, spacing, typography, radius, shadow — name-value
pairs that can be compared in a machine and generated automatically.

The chain cannot sync **behavior**. Which state a button takes on which color, where a
modal sends focus, how a dropdown is navigated by keyboard cannot be exported from a
design tool. This layer is synced by the naming convention built in the second lesson,
the doc sections defined in the third lesson, and the review gates built in the fifth
lesson. The toolchain is the guardian of the token layer; the guardian of the component
layer is governance.

#### Summary

- If a value is kept in two places, the direction of the source of truth must be
  decided in advance; two-way sync is not an option because it produces unresolvable
  conflicts. The chain's shape is independent of direction: export, transform, and
  multiple target formats.
- A diff report is unreadable without normalization; in the example, 33 of 36 diffs
  were false diffs coming only from naming-convention differences.
- Normalization happens before the comparison; the name rule is inherited from the
  naming convention, the value rule reduces colors to a single notation and lengths to
  a single unit.
- The remaining deviations fall into three classes: a value diff shows the flow broke
  somewhere, a design-only token shows an unimplemented decision, a code-only token
  shows an unrecorded addition.
- The sync rate is measured on every release and tied to a floor; a deviation is not an
  event but a quantity that accumulates.
- The toolchain syncs values; it cannot sync behavior. The component layer's equality
  is guaranteed by the naming convention, doc sections, and review gates.

#### Course Wrap-Up

This topic built the layer of a design system that comes after values. What enters the
catalog was tied to a scope criterion, and how ready an entry is was tied to maturity
levels' exit conditions. The shared language between design and code was turned into a
derivation rule, and the mismatched names were counted. Documentation was split into seven
required sections, and its coverage was computed weighted by usage. The version number
stopped being a debatable topic and became the result of the interface diff; migration
cost was measured by call site. The contribution flow became a state machine,
governance models were compared by queue and deviation counts, adoption was measured
through a repository scan, and finally, the token layer's equality was tied to a diff
report.

What all this structure has in common is that every decision was tied to a number:
coverage percentage, gap impact, migration cost, queue time, deviation count, sync
rate. A design system's success is tracked by these numbers; untracked, it turns into
a library no one notices has been abandoned.

The catalog is built and governed. But every entry in the catalog still leaves one
question unanswered: how is this component used with a keyboard, where does it send
focus, how does it announce itself to a screen reader? The third lesson defined an
"accessibility note" as a doc section and counted it among the stable maturity level's
exit criteria, but it never said what goes inside that note. The next course,
**Accessible Component Patterns**, fills this gap: it spells out, one by one, the
keyboard behavior, focus management, and screen-reader information for every component
in the catalog, from button to dropdown, from tabs to modal; it designs focus traps for
complex interaction patterns and makes interface text and localization requirements
part of the system.

