# `@design-parity/kit-index`

The design kit's own **vocabulary**, committed — so a catalog that renders one
component at six variants can say which kit node each of the six should be
compared against, without anyone maintaining that list by hand.

## The problem

A `design-map.json` entry binds a code component to **one** design node:

```jsonc
{ "code": "ui/Button.kt#FilledButton", "source": "figma", "ref": "figma:AbCdEf/57994:2324" }
```

That is exactly right for a parity diff, and not enough for a component that
renders more than one variant. A catalog picturing `Button` at three sizes and
two shapes has six renders and one ref, and nothing says which kit node the
other five belong to. Enumerating them by hand is the mapping-config sprawl a
design map exists to avoid — and it drifts silently the moment the kit gains a
variant, because a missing entry reads as *"no counterpart in the kit"* rather
than *"nobody looked"*.

So this package derives them, from what the kit itself publishes.

## The pipeline

```text
  design kit
      │  dump   ── walk every page, deep
      ▼
  figma-inventory.json      disposable, megabytes
      │  build  ── keep only what design-map.json references
      │            + fetch the component properties the walk cannot see
      ▼
  figma-kit-index.json      COMMITTED, tens of kilobytes
      │  resolve ── project a code knob onto it
      ▼
  { nodeId, name }
```

Only the two generation steps touch the network, and both are deliberate
"refresh the vocabulary" operations that land in a commit a reviewer can see.
**Resolution runs against the committed index, never the live kit**, so a parity
run reports the same thing for everyone and needs no design-tool credentials.

## Where the code side comes from

For a Compose catalog, the map and its variant declarations are **derived from
annotations** rather than authored. That half lives upstream in
[`compose-ai-tools`](https://github.com/yschimke/compose-ai-tools), because
every field it reads (`@CatalogComponent(reference = …)`, `@CatalogVariant`,
`@OverrideVariant`) is defined there — and it stops at the point where a design
kit's vocabulary would be needed:

```text
  previews.json
      │  emit-design-map.mjs             compose-ai-tools: knows the ANNOTATIONS
      ├──▶ design-map.json               base refs, one per component
      └──▶ design-map-variants.json      "same component, these knobs turned" —
           (compose-preview-design-map-      unresolved, because `size=l` is a
            variants/v1)                     Compose fact and `Size=Large` is a
      │                                      kit fact
      │  design-parity-kit-index resolve  THIS PACKAGE: knows the KIT
      ▼
  design-map.json with a tagged ref/previewId pair per variant
```

`resolve` reads only committed files, so unlike `dump`/`build` it needs no
credential and is safe on every build:

```console
$ design-parity-kit-index resolve
Wrote design-map.json: 1 variant reference(s) across 1 component(s).

1 variant(s) are a component PROPERTY in the kit, not a variant beside it. A
definition node renders at the defaults, and no exact configured instance was
indexed for these values, so they remain unpaired:
  - Button/Filled / true (icon=true) — Button: `Icon` (INSTANCE_SWAP, default "54616:25409")

1 reference(s) draw optional content by default. Every render made from them
includes it, so a sticker that leaves it out is compared against something it
never claimed:
  - Button/Filled — Button: `Show icon`
```

Three kinds of miss are reported apart because they have different owners: a
variant the kit models **neither way** is a real gap; a **property-shaped** one
means the kit has the thing and a node reference just cannot ask for it; and
**defaulted content** means the reference resolved but draws more than the code
did. Rolling them together is what makes a retired pattern read as neglect.

`--check` turns it into a drift gate instead of a writer. A **collision** — two
previews resolving to one node — is refused outright (exit 2, nothing written):
the same node cannot be both previews' counterpart, and a map that said so would
have the diff report one of the two renders as wrong.

## Three kinds of variation — and only two are addressable

|  | What it is | Addressable? |
| --- | --- | --- |
| **Variant axis** (`Size=Large`) | A sibling node with its own id | ✅ directly |
| **Component property** (`Show icon`) | A switch on the node | ⚠️ only via an instance |
| **Slot / instance swap** | A region, or which sub-component nests | ❌ never |

The middle row is the one that quietly poisons parity results. `GET /v1/images`
renders a node at its **property defaults**, and a reference is a node id with
nowhere to hang an override. So a kit whose `Button` defaults `Show icon` to
`true` renders an icon+label reference for a variant whose name says only
`Type=Round, Size=Small` — and label-only code diffed against it reports a
missing icon as though the *code* were wrong.

The way out is not to mutate the kit. It is that somebody already placed an
instance at the wanted vector, on an examples page, and **that instance's node
id is a renderable handle for a point in property space no definition can
express**. `resolvePropertyInstance` finds it, matching the *whole* property
vector so a near-miss never passes as an exact one.

The third row stays unpaired on purpose. `leading=icon` says what the content
*means*, never which node supplies it; guessing would produce a confident
reference to the wrong thing.

## The governing rule

> **A wrong translation must find nothing, rather than produce a confident bad
> reference.**

Under a `design-led` direction a bad reference drives the code away from the kit
it is copying — and does so while reporting a clean parity result, which is the
worst outcome available. Every unresolved seed is surfaced as unresolved.

Surfaced *with a reason*, though, because "no counterpart in the kit" is true of
every miss and actionable for almost none. `resolve` classifies each one
([`explainUnresolved`](./src/resolve.ts)):

| Reason | What it means | What to do |
| --- | --- | --- |
| `… declares a kit axis/value the set does not publish` | A `kitAxis` / `kitValue` names something this set does not have | Fix the declaration — the reason lists what the set publishes |
| `the reference already draws this` | The base variant carries every seeded value — the render duplicates the reference | Nothing. Not a gap. |
| `each of … exists, but no node carries them together` | Both values are real; the kit's matrix skips their intersection | Nothing here — the kit would have to draw the cell |
| `no counterpart for …` | These seeds have no counterpart at all | The actual lead: map the value, or accept the gap |

The third column is the point. On a real catalog this turned
`error-unselected (state=unselected, status=error)` — where `state=unselected`
resolves perfectly well on its own — into `no counterpart for status=error`,
and stopped two `size=small` renders being read as missing kit nodes when the
reference *is* the small variant.

### When the table cannot reach it: naming the kit's own spelling

Some values no translation reaches. The Material 3 kit spells one date-picker
variant `Type=Full-screen (range)`; a catalog seeding `type=range` resolves to
nothing, and — before the reason table above — did so silently, dropping the
node from the comparison. The workaround was worse than the problem: seed
`type=full-screen (range)` in Kotlin, and a kit spelling, parentheses and all,
becomes load-bearing in catalog source that will rot the next time the kit
renames a value.

A seed may therefore carry the kit's own names beside its own:

```ts
resolver.resolveVariant("figma:AbCdEf/53977:33595", {
  key: "action",
  raw: "longer",              // what the Compose knob is called
  kitAxis: "Configuration",   // what the kit calls the axis
  kitValue: "Text & longer action", // …and the value
});
// → { nodeId: "53977:33576", name: "Configuration=Text & longer action, …" }
```

Three properties worth knowing:

- **Authoritative, not a hint.** A declaration *replaces* the alias tables for
  that seed. `size=l` resolves fine on its own; `size=l` declaring `kitAxis:
  "Sise"` resolves to **nothing**. Falling back would make a typo
  indistinguishable from a correct declaration.
- **Still checked against the kit.** A declared axis or value must be one the
  set really publishes, so the governing rule holds: nobody can declare their
  way to a node the kit does not draw. What they get instead is the `declared`
  reason above, naming the spelling that missed and what the set does publish.
- **Matched without punctuation or case, in any script.** `of lines` finds the
  kit's `# of lines`; declaring an axis should not be a typing exercise. Only
  separators and punctuation are ignored — a kit filing its axes as `サイズ` and
  `状態` keeps them distinct, where the slug normalisation used elsewhere would
  erase both to nothing and match whichever was indexed first.

The same exactness applies wherever a declaration lands. A declared name reaches
a **component property** as readily as an axis — which of the two a kit uses is
its own business — but it must be the property's real name, so `kitAxis: "focus"`
no longer passes for `Show focus indicator` the way the knob key `focus` does. On
a family the kit models as **folder siblings** rather than a set, a declared
value names the sibling outright (`Middle-inset`, not the `Inset` the near-miss
search would find), and a declared *axis* is refused: there are no axes there to
name. And a declaration on an axis another seed already claimed is checked
through the same fusion rule as any other — `Type=Selected` plus a declared
`Error unselected` resolves to nothing rather than silently discarding the first
seed.

Either field can be given alone: an axis alone when only the *name* differs, a
value alone when only the spelling does.

One case a declaration deliberately cannot reach: **two seeds fusing onto one
value in a script that writes no spaces**. The fusion rule asks whether a
published value says exactly what the two seeds say together, which it answers
by comparing word sets — and `エラー選択済み` is a single token however many words
a reader sees in it. Containment is the obvious substitute and is not safe:
`選択` (selected) is a substring of `未選択` (unselected), so a check built on it
accepts the node drawing the opposite state. Telling lexical containment from
semantic fusion needs a segmenter this package has no business carrying, so such
a vector stays unresolved and is reported as unresolved. Space-separated scripts
fuse normally, whatever the alphabet.

That rule is why the matching is fussier than it first looks. A boolean axis
accepts `True` from *any* knob, so a naive matcher had `footer=true` resolving
to `Show back=True` and `supporting=on` resolving to `Leading icon=True` — both
real nodes, both the wrong component. Candidate axes must therefore share a
whole **word** with the knob or its value, matched word-for-word rather than by
substring (`Leading icon` contains the letters of `on`).

## Usage

```ts
import { KitIndexResolver, loadKitIndex } from "@design-parity/kit-index";

const resolver = new KitIndexResolver(await loadKitIndex("figma-kit-index.json"));

// A rendered cross-product cell — every seed must map to a distinct axis, and
// the exact resulting vector must name a real sibling.
resolver.resolveVariant("figma:AbCdEf/57994:2324", [
  { key: "size", raw: "l" },
  { key: "shape", raw: "square" },
]);
// → { nodeId: "57994:2310", name: "Type=Square, Size=Large, State=Enabled" }

// A property-shaped variant, paired with an instance configured that way.
resolver.resolveVariant("figma:AbCdEf/57994:2324", { key: "icon", raw: "false" });
// → { nodeId: "…", name: "State=Enabled (configured instance)" }  — or undefined

// What a reference draws whether or not the code asked for it.
resolver.defaultedContent("figma:AbCdEf/57994:2324");
// → [{ name: "Show icon", setName: "Button" }]
```

The resolver takes the index **object**, not a path: it is a committed artifact
the caller has already read — possibly from a bundle, a cache, or a test
fixture — and a resolver that read a fixed filename from the working directory
could not be used twice in one process or tested without a real kit.

## CLI

```console
$ export FIGMA_TOKEN=figd_…

$ design-parity-kit-index dump  --file AbCdEf --depth 8
31 page(s) in AbCdEf
  [1/31] Buttons (11:1833): 42 component(s), 0 hidden-variant example(s), 7 configured instance(s), deepest 6
  …

$ design-parity-kit-index build --file AbCdEf
Wrote figma-kit-index.json: 55 set(s), 1284 variant(s), 6 hidden variant render
alias(es), 43 standalone component(s), 3 specimen node(s), 38 set(s) carrying
component properties, 61 configured instance render handle(s).

$ design-parity-kit-index validate
figma-kit-index.json is a valid kit index.

$ design-parity-kit-index resolve --check   # CI: fail if the committed map drifted
design-map.json is up to date.
```

Two steps rather than one because they fail differently: `dump` is the expensive
walk of a whole file and its output is disposable; `build` is the cheap
projection worth re-running while a design map is still changing. `build`
degrades without a token — writing an index with no property vocabulary, and
saying so — and `validate` never touches the network.

## Vocabulary

The knob→axis and value→spelling tables are data, not logic, and the defaults
are tuned against Material-3-shaped kits. A kit that files its variants
differently supplies overrides, which merge per key so renaming one axis does
not mean restating the other thirty:

```ts
new KitIndexResolver(index, {
  vocabulary: { axes: { density: ["Density", "Compactness"] } },
});
```

`resolve` reads the same shape from a **`kit-vocabulary.json`** beside the index
— picked up automatically when present, or named with `--vocabulary`:

```jsonc
{
  "axes":   { "density": ["Density", "Compactness"] },
  "values": { "cosy": ["Comfortable"] }
}
```

That file exists so a catalog can learn one more of its kit's spellings without
waiting for a release of this package. Only the *tables* live there — a rule
about how values are matched at all (the multi-word slug match, the fused-axis
search) is logic, and belongs here where it can be tested.

## Files

`figma-kit-index.json` is **generated** — regenerate it, never hand-edit it. It
is validated against
[`schema/kit-index.schema.json`](./schema/kit-index.schema.json) on load,
which matters more for a generated file than a hand-authored one: nobody
proof-reads it, and a half-written index fails as a resolver that silently finds
nothing.
