Immutable. This exact content is served forever at /api/v1/blob/b18aa0790f6ced80.
---
name: compose-design-catalog
description: Generate an importable design-artifact sticker sheet for a whole Compose component system (Compose M3, Wear Compose M3, Glimmer, Glance/Wear widgets). Use when you want a code-derived component catalog — each component in its primary modes, in two variants (ideal render + bordered layout), with extracted design tokens and accessibility greenlines — laid out for import into Figma, Google Stitch, or Claude Design. Pairs with the compose-preview and compose-preview-design-board skills.
---
# Compose Design Catalog
Render a Compose **component system** and export it as an importable **sticker
sheet**: every component in its primary modes, in two variants (the `ideal`
render and a `layout` render that borders every composable), with the system's
design tokens and an accessibility **greenline** layer extracted automatically
from the render — not transcribed from a spec.
This skill is the system-wide, sticker-sheet sibling of
**compose-preview-design-board** (which arranges an arbitrary set of renders
into one HTML brief). It assumes the **compose-preview** skill is installed —
that skill owns the renderer, CLI, and Gradle plugin. Check first with
`compose-preview --version`; if missing, run the bootstrap installer:
```sh
curl -fsSL https://raw.githubusercontent.com/yschimke/skills/main/scripts/install.sh \
| bash
```
## Code is the source of truth
This pipeline is **code-led**. Every value on the sheet — padding, corner
radius, type, colour, touch-target size, `maxLines` / overflow — comes from the
renderer's own data products, so the catalog is correct by construction.
Published design kits (the Material 3 / Wear OS / Glimmer / Widget Figma kits)
are **seed/reference only**: use them for the component inventory and for naming
parity, never as authority. A kit/render divergence is a bug in the kit.
## When to use this skill
- You want a designer-ready catalog of a component library (yours, or a system
like Material 3) generated from the code, refreshable on every change.
- You want the two-variant treatment — an `ideal` render *and* a bordered
`layout` render — plus tokens and a11y annotations, in one bundle.
- You want artifacts that import into Figma / Stitch / Claude Design, on a
branch a designer can pull from.
For *one-off* sets of renders → **compose-preview-design-board**. For *rendering*
or *reviewing* → **compose-preview** / **compose-preview-review**.
## Prerequisite: a catalog module
`@Preview` discovery is local-module only, so the components must be authored as
`@Preview` functions in a Gradle module that depends on the target library
(`androidx.compose.material3`, `androidx.wear.compose.material3`,
`androidx.xr.glimmer:glimmer`, `androidx.glance`, …). Author **one `@Preview`
per component × primary mode**, padded, with the breakpoints the system's kit
documents (e.g. `compact` / `medium` / `expanded` for M3; small/large round for
Wear). See the `samples/design-catalog-*` modules in
[yschimke/compose-ai-tools](https://github.com/yschimke/compose-ai-tools).
### Cataloguing an existing app (the cheap path)
A dedicated catalog module is right when you are documenting a *component
library*. When you are cataloguing an **app that already has `@Preview`
functions**, don't author a parallel set — point the spec at the previews that
are already there. Adoption is then a plugin line plus `catalog.spec.json`,
with **no change to UI code**, and the sheet stays honest because it renders the
same previews the team already maintains.
The [compose-samples catalogs](https://github.com/yschimke/compose-samples/tree/agent/preview-catalogs)
are built this way: JetNews covers 22 of its 23 existing previews without
touching a single composable.
Two rules make this work well:
- **Group by feature, not by widget.** The value of a sample/app catalog is
showing what the app is *for* — adaptive postures, the states of a screen, an
RTL mirror — so `groups`/`section` should follow that, not alphabetical
component names.
- **Put catalog-only fixtures in `src/debug`.** Anything you *do* need to add
(motion fixtures, a composed feature shot) belongs in the debug source set:
the plugin renders the `debug` variant, so they are discovered like any other
preview, but they never reach a release build.
## Declare & validate the spec (`catalog.spec.json`)
The catalog's inventory, grouping, captions, sections and per-component variants
are declared in a hand-authored **`catalog.spec.json`** committed next to the
module. Each component's `preview` **must equal an exact `@Preview` function
name** — a mistyped or renamed name renders nothing and surfaces only as a late
"missing" entry at the *end* of the (long) render. Its shape is documented by
[`scripts/design-artifacts/catalog.spec.schema.json`](https://github.com/yschimke/compose-ai-tools/blob/main/scripts/design-artifacts/catalog.spec.schema.json)
(reference it via `$schema` for editor validation).
Two build-free helpers in compose-ai-tools' `scripts/design-artifacts/` scan the
module's Kotlin source directly — no Gradle build, no render — so you author and
check the spec before spending a render:
```sh
# Scaffold a starter spec from the module's @Preview functions (one flat
# "Components" group; caption and regroup from there):
node scripts/design-artifacts/init-catalog-spec.mjs \
--module :app --system my-system --title "My System" --out catalog.spec.json
# Resolve every `preview` (component + variant) against the discovered functions,
# with typo suggestions, structural checks, and coverage gaps. Exits non-zero on
# errors, so it runs as the pre-flight in design-artifacts.yml before the render:
node scripts/design-artifacts/validate-catalog-spec.mjs --spec catalog.spec.json
```
### Breakpoints: one card, or a card per size
A multipreview (`@WearPreviewDevices`, a local `@CatalogWearBreakpoints`) renders
one function at several device sizes, and the join keys on **function name** — so
by default they all fold into one entry carrying one image per size, tagged from
the spec's `breakpoints` table:
```jsonc
"breakpoints": [
{ "size": "smallRound", "device": "id:wearos_small_round", "widthDp": 192 },
{ "size": "largeRound", "device": "id:wearos_large_round", "widthDp": 227 }
]
```
Declare each by `device` (matched first) *and* `widthDp` (the fallback): a width
is a fingerprint two devices can share, and an undeclared device falls back to
the generic Material width class, making two renders indistinguishable on that
axis — the export warns when it sees one. A Wear catalog that declares no
`breakpoints` inherits the standard round table.
### Reproducing a kit: what splits, and what folds
A design-led catalog — one whose job is to reproduce a *published kit* rather than to publish its
own system — needs one taxonomy decision made consistently, and the tempting rule is the wrong one.
The kit models variation as **variant properties on a component set**: one `Button` set with
`Style = Filled | Tonal | Outline | Child`, one selection set with `Type = Checkbox | Radio |
Switch`. "One kit set is one catalog component" is the right default, and it is what keeps a sheet
browsable — 35 shapes are cells of one card, not 35 cards.
But it cannot be the whole rule, because some of those axes are **separate functions in code**:
> An axis **splits** into a component per value when its values are separate composables; it
> **folds** into cells when they are arguments to one.
`Style=` on the Wear kit's `Button` set splits — `Button`, `FilledTonalButton`, `OutlinedButton`,
`ChildButton` — because which one you call is the choice a reader of the catalog is making. The same
`Style=` on its `Button-Compact` set *folds*, because Compose ships one `CompactButton` that takes
emphasis as `colors`: there is no second function to choose, so there is nothing to split. The test
is the call site, not the word — "emphasis always splits" gets the second case wrong.
Two consequences worth planning for: components that split share their set's node (that is fine and
expected — each cell can still name the kit's own value via `kitAxis`/`kitValue`), and ids follow the
**code's** names while membership follows the **kit's** (`Button/Outlined` for the kit's
`Style=Outline`, because `OutlinedButton` is what a reader greps for).
### Determinism is a capture-state problem, not just a clock problem
Everyone remembers to pin the clock. The subtler cases are components whose *resting* state is not
what the kit draws, or is not stable:
- **A component at rest may be the wrong picture.** A swipe-to-reveal at rest is indistinguishable
from the card underneath it, and every kit cell draws what the gesture uncovers — so seed its
state (`rememberRevealState(RightRevealing)`) and publish the revealed component. What you pin is
where the capture *starts*; the gesture still works in a live session.
- **Anything animated must be pinned, not merely started.** An indeterminate progress indicator, a
placeholder shimmer, a loading spinner: a capture is one frame of it, and the frame differs on
every publish. Give progress a fixed value, and keep a shimmer to the live lane.
- **Anything derived from "now" must be a literal.** A date picker opened on today, a time picker on
now, a relative timestamp — each makes the delivery branch's history noise rather than change.
The cost of getting this wrong is invisible in review and expensive later: the render is green, the
diff is real, and nobody can tell a change from a re-render.
### Look at the renders, and then make a machine look at them
A catalog can be green end to end and still publish **blank cards**. The build compiles, discovery
finds the preview, the render succeeds, the bundle publishes — and the sticker is an empty frame.
Nothing in that pipeline asserts a component *drew* anything.
Review does not catch it either, and on a dark-first catalog it is nearly invisible: a sticker that
drew nothing looks exactly like a sticker that drew something dark.
Three real causes, all of which shipped green before being caught by eye:
- **A missing `Modifier.align`.** On Wear's page and scroll indicators, alignment is what makes the
component lay out at all — without it they collapse to nothing, not to a mispositioned rail.
- **An unsettled animation.** A component whose content animates in from a `LaunchedEffect` captures
as its first frame, and if that frame is `alpha = 0` the sticker is empty.
- **A component captured in the wrong state** — an at-rest swipe-to-reveal, a collapsed
scroll-revealed button.
So render locally before publishing. An Android/Robolectric module needs no CLI at all —
`./gradlew :<module>:composePreviewRender` writes the PNGs — and the render is cheap enough
(seconds, for a couple of hundred previews) to be part of the edit loop rather than a CI-only step.
Then make it a test, because eyes do not scale to a growing sheet:
```kotlin
// composePreview { renderBeforeUnitTests.set(true) } puts the real renderer output where a unit
// test can read it — the same PNGs CI publishes, not a fixture.
@Test fun `no sticker publishes an empty frame`() { /* fail any capture with ~no visible pixels */ }
```
Assert that a sticker drew **something**, not that it drew the right thing: a real lower bound is a
per-component judgement that needs re-tuning on every legitimate change, while "not blank" never
does. And verify the guard fails — revert the fix, watch it name the offender, put the fix back. A
render test that has never failed is a render test that might be reading the wrong directory.
### Record what you did NOT reproduce
A design-led catalog is a claim about coverage, so the claim needs to be checkable. Commit a row per
published kit set carrying either the components that reproduce it or a **stated reason** it is
absent, and test it in both directions:
- a set with neither is an unstated gap;
- an **exclusion whose node something now references** is a decision nobody is making any more.
The second direction is the one that pays off over time. A kit routinely publishes things the code
cannot draw — a size the library has no counterpart for, a style whose painter overload only exists
on some components, an asset that is app content rather than a component — and each of those is a
fact worth writing down once. Without the reverse check, the note survives the limitation and starts
lying.
The same test catches the cheaper mistake: a variant cell **seeded but not implemented**. It renders
green, and it publishes the default picture under another cell's name.
### Dark-first systems
Wear draws its components on a black watch face, so a Wear catalog's component
sticker is a **single dark capture** on a transparent background — `modes:
["dark"]` in the spec, `display.surface: "dark"` so the server's front door
stages the hero on dark rather than washing a light-on-transparent sticker out,
and a local `@Preview(showBackground = false)` multipreview rather than a
light/dark pair.
That single mode is load-bearing beyond presentation: a dark-only catalog cannot
project a `design-map.json` today, because the projector pairs a component's
reference with its `_Light` capture. Wire parity accordingly — see
[design-parity-review](../design-parity-review/references/ci.md#a-dark-only-catalog-projects-zero-components).
When you want a **card per breakpoint** — its own id and caption — use `select`
rather than splitting the `@Preview` in the module (splitting costs the
multipreview's other axes, e.g. `@WearPreviewFontScales`):
```jsonc
{ "componentId": "Home/SmallRound", "preview": "HomeListViewPreview",
"select": { "size": "smallRound" }, "caption": "Home — small round." },
{ "componentId": "Home/LargeRound", "preview": "HomeListViewPreview",
"select": { "size": "largeRound" }, "caption": "Home — large round." }
```
Two entries may share one `preview` as long as each selects a different value. An
**annotation-led** inventory says the same thing in code with
`@CatalogComponent(id = "Layout/List", perBreakpoint = true)`, which yields
`Layout/List/smallRound`, `Layout/List/largeRound`, … — one per breakpoint the
function actually rendered at, in `breakpoints` order. It's a flag, not a list:
the multipreview below it already decides the devices, so the names come from the
renders. One breakpoint keeps the plain id; none resolved keeps the component
whole and warns. Adopting `perBreakpoint` on a published catalog **moves those
sticker URLs**, which is why it's opt-in — the preview server already
disambiguates merely *colliding* card labels on its own. A spec entry always
overrides the annotation. Full rules:
[`docs/design/DESIGN_CATALOGS.md`](https://github.com/yschimke/compose-ai-tools/blob/main/docs/design/DESIGN_CATALOGS.md).
Discovery recognises `@Preview` and any `annotation class` meta-annotated with it
(`@CatalogModes`, `@CatalogTemplate`, …); pass `--preview-annotation <Name>` for a
multipreview annotation imported from another module. The authoritative check
stays the render + completeness gate — this is the fast local/CI pre-flight.
> **Wear catalogs always need this flag.** Wear previews are conventionally
> annotated `@WearPreviewDevices` / `@WearPreviewFontScales` / `@WearPreviewLargeRound`,
> which live in `androidx.wear.compose.ui.tooling.preview` — an external
> artifact the source scan cannot see. Without the flags the validator reports
> **`discovered 0 @Preview function(s)`** and fails every entry, which reads
> like a broken spec rather than a missing flag:
>
> ```sh
> node validate-catalog-spec.mjs --spec catalog.wear.spec.json \
> --preview-annotation WearPreviewDevices \
> --preview-annotation WearPreviewFontScales
> ```
>
> Record the required flags in the spec's `$comment` so the next run doesn't
> rediscover this. The same applies to any app-defined multipreview annotation
> declared in a different module from the previews that use it.
## Workflow
1. **Render the system with its data products.** Ask the renderer for the
captures plus the wireframe, theme, semantics, and a11y products:
```sh
compose-preview show --module samples:design-catalog-m3 \
--with-extension a11y,theme,semantics,semantics-wireframe --json \
> /tmp/m3-show.json
```
- `capture` PNGs → the `ideal` variant.
- `compose/semantics-wireframe` (PNG/SVG) → the `layout` variant (bordered).
- `compose/theme` → the token set (`colorScheme` + `typography` + `shapes`).
- `compose/semantics` v6 → per-node bounds, padding, `textOverflow`
(`maxLines` / `lineCount` / `truncated`).
- `a11y/atf` + `a11y/touchTargets` → the greenline findings.
2. **Build and write the catalog.** The maintained path is the
`generate-design-catalog.mjs` driver: it renders to a portable bundle with
`compose-preview bundle pack --with-semantics`, joins it to `catalog.spec.json`
(matching each component's `preview` to the rendered function name), and writes
the importable bundle. This is exactly what `design-artifacts.yml` runs:
```sh
compose-preview bundle pack --module samples:design-catalog-m3 --with-semantics \
-o build/m3-bundle.png
node scripts/design-artifacts/generate-design-catalog.mjs \
--spec catalog.spec.json --renders build/m3-bundle.png --out out/ \
--renderer "$(compose-preview --version | head -1)"
```
Under the hood the driver feeds the render's data products through
`@design-parity/candidate`'s mappers (`nativeFindings`,
`semanticsToSemanticTree`, `composeThemeToTokens`) into
`@design-parity/catalog-export`. To build a catalog **without** a spec file
(e.g. a custom pipeline), call that library directly:
```ts
import { buildCatalog, writeCatalog } from "@design-parity/catalog-export";
const catalog = buildCatalog(
{ system: "compose-m3", title: "Compose Material 3",
library: ["androidx.compose.material3:material3"],
renderer: "compose-preview 0.17.2" },
sources, // one ComponentSource per component (ideal+layout images, tokens,
// semantics, findings) from the mappers above
);
await writeCatalog(catalog, ".design-artifacts/compose-m3", {
sourceRoot: "build/compose-previews",
});
```
Pass `--source-repo <owner>/<repo>` whenever you generate from a **consumer**
repo. It defaults to `yschimke/compose-ai-tools`, so a bundle built anywhere
else silently bakes README/asset links pointing at the wrong repository —
they resolve, they're just wrong, which is worse than a 404.
> **`@PreviewParameter` previews cannot carry a catalog component.** A
> preview whose composable takes a `@PreviewParameter` argument renders its
> PNG fine, but the renderer emits **no data products for it at all** — no
> `compose/semantics`, layout, fonts or `figma-svg`. The completeness gate
> then refuses to publish the whole catalog with
> `no semantics for: <componentId>` … `incomplete render — refusing to
> publish`. The message names the component, not the cause, so it reads as a
> bad spec.
>
> Don't reach for `--allow-incomplete` (it publishes a sheet with holes) and
> don't drop the component. Add a **zero-argument wrapper preview** in
> `src/debug` that calls the same composable with a literal fixture, and
> point the spec at the wrapper — the full data-product set comes back.
>
> Check for this *before* spending a render: any `@PreviewParameter` in the
> previews a spec references will hit it. It bites Wear catalogs hardest —
> 9 of Jetcaster's 12 Wear previews take a `@PreviewParameter`.
3. **Import.** The bundle is tool-neutral first, Figma second:
```
catalog.json # index: components, both variants, greenlines, optional screen graph
tokens.dtcg.json # W3C DTCG token set — Figma Variables / Tokens Studio / Style Dictionary / Claude Design
figma-variables.json # Figma variable-collection projection (light/dark as modes)
images/<component>/<variant>__<state>[__theme][__size].png
wireframes/<component>.svg # baked structural vector — placed as a true vector node on Figma import
```
- **Claude Design / Stitch** — import the PNGs + `catalog.json`; the DTCG file
seeds tokens. Pair with **compose-preview-design-board** to wrap the same
renders as a browsable HTML brief.
- **Figma** — import `tokens.dtcg.json` via a DTCG/Tokens-Studio plugin, or
create variables from `figma-variables.json`; place the variant PNGs as the
sticker-sheet frames. The maintained path for this is the
**figma-catalog-import** skill (the import-hop sibling of this one): it
drives the `@design-parity/figma-plugin`, decides the import case
(code-led vs design-led × new vs existing file), and reconciles in place
instead of delete-and-rebuild. Declaring a screen graph in the catalog
spec (`screens: [{ id, title?, related }]`) turns a code-led import into
structured per-screen diff pages rather than one flat sheet.
4. **Deliver on a per-system branch.** Publish the generated `out/` to a
`design-artifacts/<system>` branch (`design-artifacts/compose-m3`,
`.../wear-m3`, `.../glimmer`, `.../glance-wear`) — the surface a designer pulls
from, and what the public preview server (`preview.coo.ee`) fetches and serves
at `/<system>/`.
Each publish **appends a commit on the branch tip** rather than force-pushing
a fresh orphan, so the delivery branch carries a per-regeneration history and
a sticker that moved can be traced back to the source commit that moved it
(each commit subject names the render date and the short `main` SHA):
```bash
git log --oneline origin/design-artifacts/compose-m3 -- images/button-filled/
```
Regeneration is automatic on the triggers that matter: `design-artifacts.yml`
runs on a **push to `main`** that touches a catalog or the export driver
(scoped to just the affected systems, so a one-catalog merge regenerates one
branch), at the tail of a **published release**, and on a **weekly cron**.
**Renderer / plugin / CLI changes are deliberately excluded from the push
trigger** — they do change the output, but they're touched by most merges and
each system's render is expensive. That drift is picked up by the cron and the
release chain; dispatch manually if it needs to land sooner.
Don't hand-roll the pipeline in a consumer repo — call the reusable workflow,
which is the same one compose-ai-tools' own catalogs use:
```yaml
publish:
if: ${{ github.repository == 'you/your-repo' }}
permissions:
contents: write
uses: yschimke/compose-ai-tools/.github/workflows/design-artifacts-reusable.yml@main
with:
system: your-system
spec: catalog.spec.json
module: ':app'
# Without these three the branch carries PNGs only — see below.
publish-live-bundle: true
split-per-preview: true
split-mode: full
```
> **A published catalog is static unless you opt into a live bundle.**
> `publish-live-bundle` and `split-per-preview` both default to **`false`**.
> Leave them off and the branch gets rendered PNGs and nothing executable, so
> the server has no daemon to launch: it badges the catalog *"serves baked PNG
> snapshots only — its delivery branch publishes no live bundle"* and the
> device, theme and knob controls in the viewer do nothing. Nothing fails and
> no warning is printed — the publish succeeds and quietly ships a static
> sheet. Turning them on carries the executable bundle under `bundle/`,
> records `liveBundle` in `catalog.json`, and splits it into one
> re-renderable bundle per preview.
>
> **Liveness and trust are independent gates**, and the status page shows them
> in adjacent columns, which invites conflating them. Trust is about whether
> the server will execute *your* branch (`trust/producers.json`); the live
> bundle is about whether the branch contains anything to execute. A catalog
> can read `✓ trusted` and still serve baked PNGs — that's a missing bundle,
> not a trust problem, and no amount of trust config fixes it.
>
> `split-mode: full` (each per-preview bundle keeps its own re-render
> classpath) requires `publish-live-bundle`; `view-only` is the baked tier for
> a render the serve host can't drive. Android/Robolectric and CMP desktop
> catalogs are both live-capable — what matters is that the host bakes the
> matching daemon, not the platform.
>
> Costs to weigh: a full split writes a per-preview bundle for every preview,
> so the delivery branch and the render both grow with catalog size.
> **Publish before you register.** A serve host that fetches
> `design-artifacts/<system>` reconciles its catalog list by *fetching each
> branch*, so registering a system whose branch does not exist yet fails the
> reconcile — on preview.coo.ee that is `HTTP 502 — catalog <system> not
> published: could not fetch …` and one rejected seed entry fails the whole
> run, even though every other catalog was accepted. Land the first
> design-artifacts publish, confirm the branch exists, then open the
> registration change. A registration that raced the publish is fixed by
> re-running the config job once the branch is there — nothing to revert.
> **`embed-deps` when a dep isn't on Central or Google Maven.** The serve box
> rebuilds the live classpath from the Maven coordinates in the bundle, and it
> resolves from exactly two repos — `repo1.maven.org/maven2` and
> `dl.google.com/dl/android/maven2`. A dep from anywhere else (JitPack, a
> company repo, `androidx.dev` snapshots) can't be fetched, the daemon fails to
> build its classpath, and the catalog **silently** falls back to baked PNGs
> with `livebundle-unavailable`. `embed-deps: true` carries the reachable jars
> inside the bundle under `libs/` instead; it costs bundle size, so leave it
> off when every dep is on the two public repos.
>
> Check before you publish rather than reading it off the status page
> afterwards: list the coordinates the module actually resolves and probe both
> repos for each `<artifact>-<version>.pom`. Watch for repos that are declared
> but inert — a `pluginManagement` repo serves build-time compiler plugins and
> never contributes a runtime classpath entry, and an env-gated repo (the
> `COMPOSE_SNAPSHOT_ID` branch in the compose-samples `settings.gradle.kts`)
> contributes nothing when CI doesn't set the variable. Both look alarming in a
> grep and neither affects the live bundle.
## Source
Maintained at [github.com/yschimke/skills](https://github.com/yschimke/skills)
under `skills/compose-design-catalog/`. The export library
(`@design-parity/catalog-export`) lives in
[yschimke/design-parity](https://github.com/yschimke/design-parity); the
renderer/CLI in
[yschimke/compose-ai-tools](https://github.com/yschimke/compose-ai-tools).