Lesson 12 / 19
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.
Contents
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.
// 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 is the portion where the “stable” label goes unbacked.
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.