# design-tokens Agent Notes

Scoped instructions for `packages/design-tokens/`. Read together with
the [root `AGENTS.md`](../../AGENTS.md) and the
[`README.md`](README.md) (which is the consumer-facing doc).

## Authority order

1. The published Figma library (file key in `figma-tokens.lock`).
   - Canonical **semantic colours** come from the committed DTCG export under
     `figma-export/`, regenerated by `:sync-export`. Variables are not
     reachable via the REST `:sync` without Figma Enterprise, so the Plugin-API
     export is the source of truth for their per-mode values.
2. `src/overrides.ts` — only for values Figma doesn't own.
3. Nothing else. Atoms must not declare hex codes; consumers must not
   re-derive radii or spacing.

If a value belongs to the design system, put it in Figma and re-sync.
If it doesn't belong (font fallback stack, motion curve, breakpoint),
add it to `overrides.ts` with a doc comment explaining why.

## Token sync flows

- **Figma plugin push:** branch `design-tokens/figma-sync`, token path
  `packages/design-tokens/figma-export/`. CI triggers on `push` to that branch
  (`figma-export/**` only) and merges `main` into the sync branch before
  generating so workflow helpers stay current.
- `:sync-export` (`scripts/sync-from-export.ts`) — a **generic, file-name
  agnostic** DTCG resolver. It reads *every* `*.json` under `figma-export/`,
  builds one cross-collection registry, resolves every `{alias}` (including
  per-mode Light/Dark chains) to a concrete value, and writes the resolved
  shadcn surface to `src/generated/figma.tokens.json`. It **fails loud** on any
  unresolved reference or an empty colour surface — never filter exports by file
  name, and never downgrade a resolution failure to a warning.
- `:sync` (`scripts/sync-from-figma.ts`) — Figma REST / Enterprise. Does not
  fetch variables on standard plans.

After any sync, run `:generate` and review the snapshot diff.

### How a collection is classified

Roles are **declared** in [`src/collections.ts`](src/collections.ts), keyed by
Figma collection name (`$metadata.collection`, falling back to the file stem) —
never inferred from a collection's shape:

| Role | Lands in | Emitted as |
| --- | --- | --- |
| `semantic` | `color.<name>` (from its `base/*` group) | `--zl-<name>` (shadcn roles) |
| `themed` | `themed.<group>.<name>` | `--zl-<group>-<name>` |
| `viewport` | `typography.<mode>` | not emitted |
| `primitives` | `radius`/`text`/`font`/`font-weight`/`container` | per category |
| `registry-only` | nothing | nothing |

Every collection feeds the alias registry regardless of role, so a
`registry-only` collection is still referenceable as `{brand.purple.500}` — the
role controls what a collection *surfaces*, not whether it can be resolved
through.

**Adding a collection in Figma is a decision, so the sync makes you make it —
but as a red check, not a dead workflow.** An export the manifest doesn't name
falls back to `registry-only` (surfacing nothing) and is reported in
`$source.unclassifiedCollections`; a stale manifest entry lands in
`$source.staleCollectionRoles`. `sync-from-export.spec.ts` asserts both are
empty, so `full-pr` goes red on the sync PR until someone classifies it.

Do **not** turn these back into throws. `:sync-export` runs *before* the
workflow opens or updates the sync PR, so a throw kills the run with no PR and
therefore no check — the only trace is a workflow log nobody reads. Failing
soft lands a reviewable PR and puts the signal where the work is. Exactly one
collection may hold `semantic`; that one does still throw, because there is no
safe default.

This replaced shape inference ("the Light/Dark collection is the semantic
surface, anything else multi-mode is viewport typography"), which silently
mis-routed `Syntax` and `Gradient Colors` into one bucket where the
later-sorting file replaced the earlier one outright. Within a role, `claim()`
still guards two collections landing on the same key. Neither failure is
catchable downstream: the snapshot test only sees names that already reached
`build.ts`.

## Coexistence: legacy vs shadcn

Two colour systems ship at once so consumers migrate incrementally:

- **Legacy** (`--zl-color-surface-*`, `--zl-color-text-*`, `--zl-color-gray-*`,
  spacing, radius) is frozen in `src/legacy.tokens.json` and emitted verbatim by
  `build.ts`. Do not change its values or names — that is the migration's stable
  floor. In the typed export it stays under `tokens.color.*`.
- **shadcn** (`--zl-background`, `--zl-foreground`, `--zl-primary`, `--zl-card`,
  `--zl-border`, `--zl-sidebar-*`, `--zl-chart-*`) comes from the designer export
  and lives under `tokens.theme.*`. This is the target surface.
- **Themed groups** (`--zl-syntax-*`, `--zl-gradient-*`) are the designer's
  other Light/Dark collections, under `tokens.<group>.*`. They theme like the
  shadcn colours but are deliberately kept out of `css/shadcn.css`: that file
  owns the *unprefixed* shadcn contract, and `syntax`/`gradient` are not shadcn
  role names. Reach them as `--zl-*` or `bg-zl-gradient-red-start`.

When migrating a consumer, replace legacy `--zl-color-*` references with the new
`--zl-*` names (or `bg-zl-*` / `text-zl-*` Tailwind utilities) and verify light +
dark in the browser. Retire legacy names only once no consumer references them.

**Console exception:** `apps/console` imports `css/shadcn.css` and authors the
**unprefixed** shadcn contract (`bg-background`, `text-muted-foreground`). Do
not reintroduce `bg-zl-*` there — the bridge file owns the mapping.

## Surfaces you can change

- `src/overrides.ts` — hand-written supplements. Adding a new category
  here means updating `scripts/build.ts` to emit it.
- `scripts/sync-from-figma.ts` — Figma REST handling. Be careful: this
  runs in CI with a real token; preserve the pinned-library guarantee.
- `scripts/sync-from-export.ts` + `figma-export/` — offline DTCG sync and its
  committed export provenance. Re-export from Figma and re-run `:sync-export`
  to refresh values.
- `src/collections.ts` — the role each Figma collection plays. Edit it when the
  designer adds, renames, or repurposes a collection; the sync fails loudly
  until it matches the export.
- `scripts/build.ts` — emitter. Output must remain deterministic for
  the snapshot test to be meaningful.
- `src/tokens.snapshot.spec.ts` — snapshot. Update intentionally when
  the Figma sync legitimately renames a token; never blanket-accept the
  snapshot diff without reviewing what consumers will need.

## Surfaces you must NOT change by hand

- `src/generated/figma.tokens.json` — overwritten by `:sync` /
  `:sync-export`.
- `src/generated/tokens.css`, `tokens.ts`, `tailwind.css`, `shadcn.css` —
  overwritten by `:generate`.
- `src/legacy.tokens.json` — frozen legacy colour source. Only touch it to
  deliberately retire a legacy token once no consumer references it.
- `figma-tokens.lock` — only bumped as part of a sync PR.

## Output ordering

`scripts/build.ts` emits legacy semantic colours, then the new shadcn colours
(`theme.*`), then the remaining categories in a fixed order (spacing → radius →
font → motion → focus → breakpoint → container → layout → themed groups).
Spacing keys are numeric-sorted. Themed groups emit last, in export order, so a
new Light/Dark collection from Figma appends to the diff rather than shifting
every existing line. Keep new categories appended at the end for the same
reason.

## Tests

Single Vitest project, Node mode: `tokens.snapshot.spec.ts` (public-name guard,
both surfaces) and `sync-from-export.spec.ts` (resolver logic + real-export
regression). If you add a token category, extend the snapshot to cover its keys
so it can't silently disappear.

## Branding overrides at runtime

Tenants override colours/typography/shape on a per-flow basis through
`<zitadel-login branding="...">`. That overlay is applied by
`packages/components/src/orchestrator/branding-to-tokens.ts` — it
writes to the same `--zl-*` variables that `tokens.css` defines, on
the orchestrator's own shadow root. Do not introduce a separate
"branding tokens" surface; everything routes through `--zl-*`.

## Don't

- Don't add a token category to `overrides.ts` that Figma should own.
- Don't import this package from `packages/api` or `packages/api-mock`.
  Tokens are a UI concern.
- Don't hand-edit anything under `src/generated/`. If the generated
  output is wrong, fix the input (lock, json, or overrides) and re-run
  `:generate`.
- Don't re-couple `:sync-export` to specific export file names. The designer
  reorganises collections freely; the resolver must stay shape-driven.
