@yschimke/compose-design-map
v2.27.0
Published
Project a compose-preview discovery manifest into design-parity's design-map.json, plus a sidecar of unresolved variant declarations. Dependency-free.
Maintainers
Readme
@yschimke/compose-design-map
design-map.json is design-parity's correspondence
file: it says which design node a code component is meant to look like. This package writes
one, from the catalog annotations compose-ai-tools
defines.
./gradlew :<module>:composePreviewDiscover
npx --yes @yschimke/compose-design-map \
--previews <module>/build/compose-previews/previews.jsonDependency-free and Node-only, so npx is the whole install. Both outputs are generated —
regenerate rather than edit. --check regenerates in memory and exits non-zero if a committed copy
has drifted, which is the CI posture.
Pin the version in CI. Both files are committed and checked, so the projection's version is an input to a checked-in artifact: float it, and a release here turns a downstream repo red for a change nobody there made.
Why the producer lives here
Every field the projection reads is defined in this repository:
| Field on previews.json | Declared by |
| --- | --- |
| catalog.reference, referenceSet, noReference, referenceContentsOnly, kitAxis | @CatalogComponent |
| catalog.props, catalog.state, catalog.kitValue | @CatalogVariant |
| overrides.seeds, overrides.props | @OverrideVariant / @PreviewAxis |
| overrides.kitAxis, overrides.kitValue, overrides.noReference | @OverrideVariant |
Rename one of those and the projection has to change in the same commit. Keeping the two on opposite sides of a repo boundary is how a manifest reader goes quietly stale — and the reference belongs on the annotation rather than in a JSON map for the same reason: a map keyed on preview names drifts the moment a preview is renamed, and fails silently when it does.
The consuming half already lived here too — design-references.mjs
reads a design-map.json to build a published catalog's references/index.json. Until now nothing
in the ecosystem wrote one except a hand-maintained script in a downstream catalog repo.
Where the split falls
A catalog picturing Button at three sizes and two shapes has six renders and one reference.
Pairing the other five means answering "which kit node is size=l?" — and that is not a question
this repo can answer:
previews.json
│
│ compose-design-map ← THIS PACKAGE. Knows what the annotations mean.
│
├──▶ design-map.json base references, one per component. Valid on its own.
│
└──▶ design-map-variants.json "these previews are the same component with these knobs
turned" — unresolved, because `size=l` is a fact about
the Compose API and `Size=Large` is a fact about somebody's
design kit
│
│ @design-parity/kit-index ← THE OTHER REPO. Knows what the KIT means.
▼
design-map.json with a tagged ref/previewId pair per variantsize=l → Size=Large is a translation against a kit's published vocabulary. That vocabulary is a
Figma concern, it needs a Figma credential to derive, and it differs per kit — none of which this
repo has any business holding. So the variant renders come out as declarations and a resolver
that owns a kit index turns them into node ids.
When the catalog knows the kit's word for it
Some values no translation table reaches: the Material 3 kit files one date-picker variant as
Type=Full-screen (range), and type=range finds nothing against it. kitAxis / kitValue are
how a variant names both sides — the Compose word in props/strings, the kit's word beside it —
and this projection carries them onto the seed so the resolver can prefer them over its own tables:
@CatalogVariant(of = "DatePicker/Modal", props = ["type=range"],
kitAxis = "Type", kitValue = "Full-screen (range)"){ "seeds": [{ "key": "type", "raw": "range",
"kitAxis": "Type", "kitValue": "Full-screen (range)" }] }Projecting is not translating: nothing here checks a declaration against a kit, because there is no kit here to check against. The one thing it does judge is whether the declaration can be placed — the annotation carries one pair per variant, so a cell seeding two knobs gives no way to say which one the axis names. Those are reported and dropped rather than guessed at, since guessing pins the wrong axis and resolves, confidently, to the wrong node.
The two halves are separable because the first is useful alone: a repo with no kit index still gets a valid map of base references, which is most of the value at none of the cost.
The sidecar
design-map-variants.json carries schema: "compose-preview-design-map-variants/v1"; a resolver
must match that string before reading it. One entry per component that has variant renders:
{
"schema": "compose-preview-design-map-variants/v1",
"components": [
{
"code": "catalog/Catalog.kt#FilledButton", // the design-map entry these belong to
"componentId": "Button/Filled",
"reference": "figma:AbCdEf/1:2", // the node a resolver walks from
"basePreviewId": "…FilledButton_Light",
"renders": [
{ "previewId": "…FilledButton_Light_VARIANT_l", "name": "l",
"seeds": [{ "key": "size", "raw": "l" }, { "key": "shape", "raw": "round" }] },
{ "previewId": "…FilledButton_Light_VARIANT_indeterminate", "name": "indeterminate",
"seeds": [{ "key": "progress", "raw": "indeterminate" }],
"noReference": "The kit publishes determinate progress cells only." }
]
}
]
}It is a separate file rather than another key on the map because the design-map schema sets
additionalProperties: false — a map carrying an extra key would fail its own validator. No file is
written when nothing declares an axis or a stated cell absence. A render carrying noReference
does not enter kit-node resolution; the reason is the result, and remains reportable alongside the
cells that do resolve.
Two things worth knowing
One capture per component is mapped, not one per rendered mode — a component maps to a single design node. Where a composable publishes a themed pair, the light capture is the one that pairs, because that is the mode design kits draw their frames in: diffing a dark render against a light reference reports the whole palette as a finding.
Where it publishes exactly one mode, that one pairs, whatever it is. A dark-first catalog — a
Wear watch face is a black screen, so its component multipreview is a single dark capture — names no
Light capture anywhere, and demanding one used to project the whole catalog to an empty map: a
file reading as "nothing here corresponds to the kit" rather than "the projector could not see
these", which --strict could not fire on either.
Several modes with no light among them is the one case that stays unmapped. Picking one would be
guessing which of Dark and Coral the kit drew, so those components are reported
(diagnostics.ambiguousMode, and a --strict failure) rather than paired at random.
A breakpoint fan-out is a size axis, not a mode. A multipreview that draws one composable at
several screen sizes — the Wear round breakpoints are the live case — publishes several captures of
it, told apart by the same id segment a themed pair uses. Read as modes they are unresolvable
(Light is nowhere among wearos_small_round / wearos_large_round), so a full-screen component
used to drop out of the map entirely the moment it gained a second size.
They are told apart by a fact the id does not carry: each capture names a device, and the devices
have different widths. A palette does not change the frame's width, so captures whose modes map
one-to-one onto distinct device widths are a size axis and one of them can be picked on the merits:
- the narrowest is the base by default, because that is the size a kit draws — a kit publishes its screen artwork at one size and leaves adaptation to the implementation, and the narrowest is the one every larger screen is an adaptation of;
--base-breakpoint <dp>moves it, for a kit that draws somewhere else. A named base a given composable does not render falls back to the narrowest rather than dropping it — rendering a subset of the catalog's breakpoints is a legitimate thing for one screen to do;- the sizes the base did not take fold under it as cells, seeded
breakpoint=<dp>and named<dp>dp, so they are published rather than discarded.
A bare breakpoint=<dp> is a value no kit vocabulary contains, so such a cell resolves against
nothing — correctly, for the majority of kits, which draw every screen cell at one size and have no
size axis at all. Where a kit does publish screen size as a variant property, the component says
so:
@CatalogComponent(id = "Picker", breakpointKit = ["225=Larger Screen (BP)=Yes"])and the 225dp cell is seeded breakpoint=225 with kitAxis/kitValue attached, pairing with the
kit node the picture was always there for. It is a per-component declaration rather than a
per-run flag because it is a property of one component's kit set, not of the catalog; a size the
component never draws, or a malformed entry, keeps the bare seed and is reported unresolved rather
than mispaired. A breakpoint capture is the one kind of cell that cannot carry @OverrideVariant
(kitAxis = …) itself — it is not an annotation at all — which is why the mapping lives on the
component.
Two captures of the same width are still a mode, whatever devices they name: nothing orders them,
so they stay ambiguousMode. An @OverrideVariant cell rides the base breakpoint only — the
product of both axes would multiply the sheet by every size, and the base carries the matrix.
overrides.props beats overrides.seeds where both exist. They are not the same list. seeds
holds only the values that differ from the composable's defaults; props — emitted for a
@PreviewAxis cross product — carries the full axis assignment, defaults included. A cell that
knows its own axes pairs by construction, which is exactly what OverrideVariantSpec.props was
added for. A cell described only by its non-default seeds is missing the axes it happens to sit at,
and a kit that spells its default size explicitly in a combination cell then has nothing to match.
