@motion-proto/live-tokens 0.55.0 → 0.56.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.claude/skills/{live-tokens-adjust-shape-space → live-tokens-adjust-geometry}/SKILL.md +5 -5
  2. package/.claude/skills/live-tokens-build-page/SKILL.md +2 -0
  3. package/.claude/skills/live-tokens-create-component/SKILL.md +20 -357
  4. package/.claude/skills/live-tokens-create-component/references/fixed-overlays.md +3 -0
  5. package/.claude/skills/live-tokens-create-component/references/intrinsics.md +58 -0
  6. package/.claude/skills/live-tokens-create-component/references/linked-siblings.md +70 -0
  7. package/.claude/skills/live-tokens-generate-theme/SKILL.md +79 -98
  8. package/.claude/skills/live-tokens-generate-theme/references/named-themes.md +18 -0
  9. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +88 -0
  10. package/.claude/skills/live-tokens-pick-component/SKILL.md +4 -1
  11. package/CHANGELOG.md +108 -0
  12. package/README.md +20 -7
  13. package/bin/cli.mjs +41 -3
  14. package/bin/set-fonts.mjs +280 -0
  15. package/dist-plugin/adjust/index.d.cts +2 -2
  16. package/dist-plugin/adjust/index.d.ts +2 -2
  17. package/dist-plugin/fontPairing/index.cjs +411 -0
  18. package/dist-plugin/fontPairing/index.d.cts +109 -0
  19. package/dist-plugin/fontPairing/index.d.ts +109 -0
  20. package/dist-plugin/fontPairing/index.js +320 -0
  21. package/dist-plugin/generateColorsAndType/index.d.cts +2 -2
  22. package/dist-plugin/generateColorsAndType/index.d.ts +2 -2
  23. package/dist-plugin/index-4N-Orzzi.d.cts +3 -0
  24. package/dist-plugin/index-4N-Orzzi.d.ts +3 -0
  25. package/dist-plugin/{index-DpTIRZ2H.d.cts → themeTypes-BAqtv4XO.d.cts} +1 -3
  26. package/dist-plugin/{index-DpTIRZ2H.d.ts → themeTypes-BAqtv4XO.d.ts} +1 -3
  27. package/package.json +3 -2
  28. package/src/editor/core/fonts/applyFontPairing.ts +157 -0
  29. package/src/editor/core/fonts/fontPairing.ts +13 -6
  30. package/src/editor/core/fonts/googleFontsUrl.ts +123 -0
  31. package/src/editor/core/fonts/weightCoverage.ts +92 -0
  32. package/src/editor/docs/content/themes-workflow.md +39 -0
  33. package/src/editor/docs/content.generated.ts +1 -1
  34. package/src/editor/ui/ColorEditPanel.svelte +16 -1
  35. package/src/editor/ui/PaletteEditor.svelte +124 -31
  36. package/src/editor/ui/ProjectFontsSection.svelte +24 -36
  37. package/src/editor/ui/palette/OverridesPanel.svelte +5 -2
  38. package/src/editor/ui/palette/PaletteBase.svelte +13 -0
@@ -1,16 +1,16 @@
1
1
  ---
2
- name: live-tokens-adjust-shape-space
3
- description: Adjust corner radius, padding, gap, and border width across live-tokens components by moving each token alias along the shipped scales. Use when the user asks for pill or capsule buttons, rounded, rounder, sharp, sharper, square corners, softer, harder, a bigger or smaller corner radius, thicker or thinner borders, or talks about spacing and padding. Make the buttons pill shaped, give the cards sharp corners, make the windows sharper, make the UI softer, space it out, tighter, denser, more compact, airier, more breathing room. Changes shape and space aliases per component, never color, fonts, or tokens.css. Not for editing a single token (use the editor) or for color (see live-tokens-generate-theme).
2
+ name: live-tokens-adjust-geometry
3
+ description: Adjust corner radius, padding, gap, and border width across live-tokens components by moving each token alias along the shipped scales. Use when the user asks for pill or capsule buttons, rounded, rounder, sharp, sharper, square corners, softer, harder, a bigger or smaller corner radius, thicker or thinner borders, or talks about spacing and padding. Make the buttons pill shaped, give the cards sharp corners, make the windows sharper, make the UI softer, space it out, tighter, denser, more compact, airier, more breathing room. Also invoked by live-tokens-generate-theme for the geometry half of a whole look. Changes shape and space aliases per component, never color, fonts, or tokens.css. Not for editing a single token (use the editor) or for color (see live-tokens-generate-theme).
4
4
  ---
5
5
 
6
- # Adjusting shape and space
6
+ # Adjusting geometry
7
7
 
8
8
  You translate the request into a small ops file; the CLI resolves each matching alias on its token ladder, writes the result into each component's unsaved buffer, and prints a report card. Never hand-edit the data tree.
9
9
 
10
10
  ## Workflow
11
11
 
12
- 1. Write the ops file to a temp path (not the project tree), e.g. `/tmp/adjust-ops.json`.
13
- 2. Run `npx live-tokens adjust /tmp/adjust-ops.json`. It writes `component-configs/<id>/_working.json` for every component the ops change, which is the buffer the page already runs. `--dry-run` prints the report without writing.
12
+ 1. Write the ops file to `scratch/adjust-ops.json`.
13
+ 2. Run `npx live-tokens adjust scratch/adjust-ops.json`. It writes `component-configs/<id>/_working.json` for every component the ops change, which is the buffer the page already runs. `--dry-run` prints the report without writing.
14
14
  3. Read the report card: every changed alias old → new, plus skips (raw value, off the ladder, already at the ladder end, pill preserved). Exit 1 means the run was rejected; the message names the offending op or the missing input, so fix it and re-run. Read where the controls landed, not only that the run succeeded: a button, badge, input, or tab padding sitting at `--space-6` is on its floor, and one that also carries `--radius-full` wants a targeted lift.
15
15
  4. Tell the user to reload the app and look. Offer the inverse op as the undo, and say the edit is unsaved until they save the open theme.
16
16
 
@@ -10,6 +10,8 @@ Two rules above all else:
10
10
  1. **Use a shipped component if one fits.** Import from `@motion-proto/live-tokens/components/<Name>.svelte`. See [[live-tokens-pick-component]] for the catalogue and the confusing-pair decisions. Author custom markup only when nothing fits, and then consider [[live-tokens-create-component]] so the new piece is editable too.
11
11
  2. **Use theme tokens for every value.** Every color, spacing, radius, font-size, and font-family in page CSS is a `var(--token-*)`. No hex literals. No pixel literals. A change in `/live-tokens/editor` should repaint your page.
12
12
 
13
+ For text, reach for a whole text style rather than assembling one: `--heading-xl` through `--heading-sm`, `--body-md`, `--body-sm`, `--eyebrow`, and `--code` each carry a `-font-family`, `-font-size`, `-font-weight`, `-line-height`, and `-letter-spacing`. A heading set from `--heading-lg-*` retypes when the theme's fonts change; one set from a raw `font-size` does not.
14
+
13
15
  ## Layout
14
16
 
15
17
  Pages sit inside the column grid via `--columns-count`, `--columns-gutter`, `--columns-max-width`. Toggle `ColumnsOverlay` (Cmd+G in dev) to visualise it while placing content.
@@ -5,7 +5,7 @@ description: Author a brand-new editable component for a @motion-proto/live-toke
5
5
 
6
6
  # Authoring a component for a live-tokens project
7
7
 
8
- This skill teaches you how to add a new editable component to a project that consumes `@motion-proto/live-tokens`. The end state: a runtime Svelte file, an editor Svelte file, one `registerComponent()` call, and a `/live-tokens/components` page entry under the **CUSTOM** group with full token editing, linked-block sharing, and persistence.
8
+ The end state is a runtime Svelte file, an editor Svelte file, one registration, and an entry on `/live-tokens/components` under the **CUSTOM** group with full token editing, linked-block sharing, and persistence.
9
9
 
10
10
  ## Worked examples ship inside the package
11
11
 
@@ -21,10 +21,10 @@ For pattern reference, read any shipped component's source directly from the con
21
21
 
22
22
  **File-location note.** Shipped editors live in `src/editor/component-editor/` because they're library-internal. For *your* component, **co-locate** both files in `src/system/components/` per the recipe below. Read the shipped files for pattern, ignore their location.
23
23
 
24
- ## 4-step recipe
24
+ ## The recipe
25
25
 
26
26
  1. **Runtime file** — `src/system/components/MyWidget.svelte`. Declare every editable slot as a CSS custom property inside `:global(:root)`, defaulting to a theme token (never a raw value). The plugin parses `:global(:root)` to seed `component-configs/<id>/default.json`; variables declared anywhere else can't be edited.
27
- 2. **Editor file** — `src/system/components/MyWidgetEditor.svelte`. In a `<script module>` block, declare `const component = 'mywidget'`, build a `states: Record<string, Token[]>` for each VariantGroup, and export the flat union as `allTokens: Token[]`. Components with linked siblings also build a `linkableContexts: Map<string, string>` (see the linked-siblings extension below). Components with structural/display controls that aren't token values (alignment, element visibility, layout position) also export an `intrinsics: IntrinsicSpec[]` (see the intrinsics extension below). In the runtime `<script>` block, mount `ComponentEditorBase` with one `VariantGroup` per variant.
27
+ 2. **Editor file** — `src/system/components/MyWidgetEditor.svelte`. In a `<script module>` block, declare `const component = 'mywidget'`, build a `states: Record<string, Token[]>` for each VariantGroup, and export the flat union as `allTokens: Token[]`. Components with linked siblings also build a `linkableContexts: Map<string, string>` (read `references/linked-siblings.md`). Components with structural/display controls that aren't token values (alignment, element visibility, layout position) also export an `intrinsics: IntrinsicSpec[]` (read `references/intrinsics.md`). In the runtime `<script>` block, mount `ComponentEditorBase` with one `VariantGroup` per variant.
28
28
  3. **Register** — pass the component to `bootLiveTokens` in `src/main.ts`. This is the standard boot the scaffold generates and the README documents; `bootLiveTokens` calls `registerComponent` internally at the right point — after its editor init hooks (`cssVarSync.init`, `editorStore.init`), before it seeds configs and mounts the app:
29
29
  ```ts
30
30
  import { bootLiveTokens } from '@motion-proto/live-tokens';
@@ -44,7 +44,7 @@ For pattern reference, read any shipped component's source directly from the con
44
44
  ```
45
45
  The schema side-effect happens inside `registerComponent` (which `bootLiveTokens` calls for you), so you don't call `registerComponentSchema` separately. **Do not place a standalone `registerComponent(...)` *before* `bootLiveTokens`** — that registers before the editor's init hooks run, which is the wrong window and can leave editor changes disconnected from the live page. Only call `registerComponent` directly if your app mounts manually (no `bootLiveTokens`), in which case call it before `mount(App, ...)`.
46
46
  4. **Tell the picker** — open `.claude/skills/live-tokens-pick-component/SKILL.md` and add your new component to the **Catalogue** line under the family it belongs to (Action / Input / Selection / Containers / Messaging / Display). If it's confusable with an existing component (a second selection control, a competing container), add a row to that family's decision table explaining the use-case it owns. Without this step, the component exists but [[live-tokens-pick-component]] can't recommend it when a user asks "which component should I use?" — the same rule applies whether the component is first-party (update the picker shipped in this package) or consumer-authored (update the local copy at `.claude/skills/live-tokens-pick-component/SKILL.md` that `setup-claude` placed in your project).
47
- 5. **Verify** open `/live-tokens/components` and run the verification checklist at the bottom of this file.
47
+ 5. **Verify** with the checklist at the bottom of this file.
48
48
 
49
49
  ## Token discipline
50
50
 
@@ -106,12 +106,12 @@ The authoritative recognised list lives in `bin/check-component.mjs` (`KNOWN_SUF
106
106
 
107
107
  ### Rules that bite
108
108
 
109
- - **Fixed overlays must portal to `<body>`.** Any `position: fixed` layer (modal, lightbox, full-screen backdrop) is trapped — clipped or painted under other chrome — by a transformed / `isolation` / `contain` / `will-change` ancestor, which real consumer pages (and the editor's own preview pane) commonly have. Render the fixed layer with `use:portal` from `src/system/internal/portal.ts` so it escapes to `<body>`; pass `use:portal={enabled}` to keep an in-flow preview variant in place (see `Dialog`). `check:overlay-portal` fails the build if a component has `position: fixed` without it. Anchored popovers (`position: absolute` relative to a trigger, like `Tooltip`) are exempt. Two consequences of moving to `<body>`: DOM events from the layer no longer bubble to a consumer ancestor (use component callbacks, as `Dialog` does), and a subtree-scoped CSS-variable theme no longer reaches it (this library themes via `:root`, so fine here). If the layer is a modal, also give it `role="dialog"` + `aria-modal`, move focus in on open and restore it on close, and trap `Tab` (see `ImageLightbox`).
109
+ - **Fixed overlays must portal to `<body>`.** Any `position: fixed` layer is trapped by a transformed or `contain`ed ancestor, which real pages and the editor's preview pane both have. `check:overlay-portal` fails the build without it. Read `references/fixed-overlays.md` before authoring a modal, lightbox, or backdrop.
110
110
  - **State before property.** `--mywidget-button-hover-surface` ✓ — `--mywidget-button-surface-hover` ✗ (breaks sibling matching).
111
111
  - **Defaults reference theme tokens, never raw values.** `var(--surface-primary)` ✓ — `#6a4ce8` ✗.
112
112
  - **No abbreviations.** `bg` → `surface`; `fg` → `text`; component ids are never abbreviated.
113
113
  - **Text aliases.** Neutral scale is `--text-primary` / `--text-secondary` / `--text-tertiary` / `--text-muted` / `--text-disabled`. Family-tinted is `--text-primary-color`, `--text-accent`, `--text-success`. There is no `--text-neutral`.
114
- - **Typography `groupKey` on multi-slot components must include the slot prefix.** `groupKey: 'value-font-family'` and `groupKey: 'label-font-family'` ✓ — bare `groupKey: 'font-family'` silently merges them into one link tree ✗. Single-slot components can use a bare typography `groupKey`; add the slot prefix the moment a second slot appears. The same trap applies to type-group **colors** (two slots ending in `-text` collapsing to one `text` key). Let the helpers handle both: see "Deriving groupKeys" below.
114
+ - **Typography `groupKey` on multi-slot components must include the slot prefix.** `groupKey: 'value-font-family'` and `groupKey: 'label-font-family'` ✓ — bare `groupKey: 'font-family'` silently merges them into one link tree ✗. Single-slot components can use a bare typography `groupKey`; add the slot prefix the moment a second slot appears. The same trap applies to type-group **colors** (two slots ending in `-text` collapsing to one `text` key). Let the helpers handle both, below.
115
115
  - **Let the type-group helpers derive slot-scoped keys; never rely on the bare last-dash default.** When you build typography tokens with `buildTypeGroupColorTokens` / `buildTypeGroupTokens` / `buildTypeGroupFontTokens`, **pass `{ component, variants }`** so each slot gets a distinct, structural `groupKey`:
116
116
 
117
117
  ```ts
@@ -123,10 +123,6 @@ The authoritative recognised list lives in `bin/check-component.mjs` (`KNOWN_SUF
123
123
 
124
124
  The helper strips the `--<component>-` prefix and those segments, keeping the rest: `--mywidget-header-default-text` → `header-text`, `--mywidget-header-default-text-font-family` → `header-text-font-family`. Two parts ending in the same word stay distinct; one slot across variants collapses to one key. To override a single derived key, set `colorGroupKey` on that type-group config — it wins and is never recomputed, so your fix survives. There is no name-based fallback: a bare `buildTypeGroupColorTokens` call emits un-grouped (solo) colors rather than guessing, and a bare *font* helper across multiple slots is a `check-component` warning (its default `font-family`/… keys would merge the slots' fonts).
125
125
 
126
- ### Linked siblings
127
-
128
- Tokens that share a `groupKey` and declare `canBeLinked: true` form a sibling set with a link toggle in the editor. Linkage is **dev-declared** — you author it when building the component. Users opt out of an existing link per-property; they never add or reshape links. Do not expose UI for users to add siblings or mark properties as linkable.
129
-
130
126
  ## State model
131
127
 
132
128
  Components *can* have two state axes. Many don't: container and messaging components (Card, Badge, Callout, CollapsibleSection) have only variants, no hover/disabled. Skip the rest of this section for those.
@@ -176,365 +172,32 @@ That covers everything the worked examples use. Additional primitives (`LinkedBl
176
172
 
177
173
  **Never deep-import `node_modules/@motion-proto/live-tokens/src/...`.** Reading those files for pattern reference is fine; importing them at runtime is not. If you need something not exported, file an issue rather than reaching in.
178
174
 
179
- ## Worked example: shipped Toggle, end-to-end
180
-
181
- Toggle ships in the package and exercises every rule above. For your own component, copy this pattern and substitute your id; just don't reuse `toggle` itself (registrations against a built-in id win with a console warning, but the right call is a unique id).
182
-
183
- ### Runtime: `src/system/components/Toggle.svelte`
184
-
185
- ```svelte
186
- <script lang="ts">
187
- interface Props {
188
- checked?: boolean;
189
- disabled?: boolean;
190
- label?: string;
191
- /** Editor preview hook. Paints hover tokens without a real pointer. */
192
- class?: string;
193
- onchange?: (checked: boolean) => void;
194
- }
195
- let {
196
- checked = false, disabled = false, label = '',
197
- class: className = '', onchange,
198
- }: Props = $props();
199
- function toggle() {
200
- if (disabled) return;
201
- onchange?.(!checked);
202
- }
203
- </script>
204
-
205
- <button
206
- type="button" role="switch" aria-checked={checked}
207
- class="toggle {className}" class:on={checked}
208
- {disabled} onclick={toggle}
209
- >
210
- <span class="track"><span class="thumb"></span></span>
211
- {#if label}<span class="label">{label}</span>{/if}
212
- </button>
213
-
214
- <style>
215
- :global(:root) {
216
- /* Default (off resting). Carries geometry + label typography for every state. */
217
- --toggle-track-surface: var(--surface-neutral);
218
- --toggle-track-border: var(--border-neutral);
219
- --toggle-track-border-width: var(--border-width-1);
220
- --toggle-track-radius: var(--radius-full);
221
- --toggle-track-width: var(--space-32);
222
- --toggle-track-thickness: var(--space-16);
223
- --toggle-thumb-surface: var(--surface-neutral-highest);
224
- --toggle-thumb-border: var(--border-neutral-strong);
225
- --toggle-thumb-size: var(--space-12);
226
- --toggle-label-text: var(--text-primary);
227
- --toggle-label-font-family: var(--font-sans);
228
- --toggle-label-font-size: var(--font-size-sm);
229
- --toggle-label-font-weight: var(--font-weight-normal);
230
- --toggle-gap: var(--space-8);
231
-
232
- /* Hover (default + hover interaction). */
233
- --toggle-hover-track-surface: var(--surface-neutral-high);
234
- --toggle-hover-thumb-surface: var(--text-primary);
235
-
236
- /* On (component state). */
237
- --toggle-on-track-surface: var(--surface-brand-high);
238
- --toggle-on-track-border: var(--border-brand);
239
- --toggle-on-thumb-surface: var(--text-primary);
240
- --toggle-on-thumb-border: var(--border-brand-strong);
241
-
242
- /* On + hover. */
243
- --toggle-on-hover-track-surface: var(--surface-brand-higher);
244
- --toggle-on-hover-thumb-surface: var(--text-primary);
245
-
246
- /* Disabled (terminal, applies regardless of on/off). */
247
- --toggle-disabled-track-surface: var(--surface-neutral-lower);
248
- --toggle-disabled-thumb-surface: var(--surface-neutral);
249
- --toggle-disabled-label-text: var(--text-disabled);
250
- }
251
-
252
- .toggle { display: inline-flex; align-items: center; gap: var(--toggle-gap); }
253
- .track {
254
- width: var(--toggle-track-width); height: var(--toggle-track-thickness);
255
- background: var(--toggle-track-surface);
256
- border: var(--toggle-track-border-width) solid var(--toggle-track-border);
257
- border-radius: var(--toggle-track-radius);
258
- }
259
- .thumb {
260
- width: var(--toggle-thumb-size); height: var(--toggle-thumb-size);
261
- background: var(--toggle-thumb-surface);
262
- border: var(--toggle-track-border-width) solid var(--toggle-thumb-border);
263
- }
264
- .label {
265
- color: var(--toggle-label-text);
266
- font-family: var(--toggle-label-font-family);
267
- font-size: var(--toggle-label-font-size);
268
- font-weight: var(--toggle-label-font-weight);
269
- }
270
-
271
- .toggle:hover:not(:disabled) .track,
272
- .toggle.force-hover:not(:disabled) .track { background: var(--toggle-hover-track-surface); }
273
- .toggle:hover:not(:disabled) .thumb,
274
- .toggle.force-hover:not(:disabled) .thumb { background: var(--toggle-hover-thumb-surface); }
275
-
276
- .toggle.on .track {
277
- background: var(--toggle-on-track-surface);
278
- border-color: var(--toggle-on-track-border);
279
- }
280
- .toggle.on .thumb {
281
- background: var(--toggle-on-thumb-surface);
282
- border-color: var(--toggle-on-thumb-border);
283
- }
284
-
285
- .toggle.on:hover:not(:disabled) .track,
286
- .toggle.on.force-hover:not(:disabled) .track { background: var(--toggle-on-hover-track-surface); }
287
- .toggle.on:hover:not(:disabled) .thumb,
288
- .toggle.on.force-hover:not(:disabled) .thumb { background: var(--toggle-on-hover-thumb-surface); }
289
-
290
- .toggle:disabled .track { background: var(--toggle-disabled-track-surface); }
291
- .toggle:disabled .thumb { background: var(--toggle-disabled-thumb-surface); }
292
- .toggle:disabled .label { color: var(--toggle-disabled-label-text); }
293
- </style>
294
- ```
175
+ ## Worked example: the shipped Toggle
295
176
 
296
- What to notice:
177
+ Toggle exercises every rule above in the fewest lines. Read both files from the package rather than from memory, because they are the contract and this skill is not:
178
+
179
+ - `node_modules/@motion-proto/live-tokens/src/system/components/Toggle.svelte`
180
+ - `node_modules/@motion-proto/live-tokens/src/editor/component-editor/ToggleEditor.svelte`
181
+
182
+ What to notice in the runtime file:
297
183
 
298
- - The `:global(:root)` block declares every editable variable. Variables in scoped selectors don't get edited; the plugin only parses `:global(:root)`.
299
- - Every default references a theme token; no raw values.
300
184
  - Component states (`on`, `disabled`) name themselves in the token: `--toggle-on-*`, `--toggle-disabled-*`.
301
185
  - Interaction states layer on top: `--toggle-hover-*` for default+hover, `--toggle-on-hover-*` for on+hover.
302
186
  - Disabled is terminal: no `--toggle-disabled-hover-*`, no `--toggle-on-disabled-*`.
303
187
  - The `force-hover` class pairs with the editor's preview hook so hover tokens paint without a real pointer. Each `:hover` selector has a matching `.force-hover` sibling.
304
188
 
305
- ### Editor: `src/system/components/ToggleEditor.svelte`
306
-
307
- ```svelte
308
- <script module lang="ts">
309
- import type { Token } from '@motion-proto/live-tokens/component-editor';
310
- export const component = 'toggle';
311
-
312
- const states: Record<string, Token[]> = {
313
- default: [
314
- { label: 'track surface', variable: '--toggle-track-surface' },
315
- { label: 'track border', variable: '--toggle-track-border' },
316
- { label: 'track border width', variable: '--toggle-track-border-width' },
317
- { label: 'track radius', variable: '--toggle-track-radius' },
318
- { label: 'track width', variable: '--toggle-track-width' },
319
- { label: 'track thickness', variable: '--toggle-track-thickness' },
320
- { label: 'thumb surface', variable: '--toggle-thumb-surface' },
321
- { label: 'thumb border', variable: '--toggle-thumb-border' },
322
- { label: 'thumb size', variable: '--toggle-thumb-size' },
323
- { label: 'label text', variable: '--toggle-label-text' },
324
- { label: 'label font family', variable: '--toggle-label-font-family' },
325
- { label: 'label font size', variable: '--toggle-label-font-size' },
326
- { label: 'label font weight', variable: '--toggle-label-font-weight' },
327
- { label: 'label gap', variable: '--toggle-gap' },
328
- ],
329
- hover: [
330
- { label: 'track surface', variable: '--toggle-hover-track-surface' },
331
- { label: 'thumb surface', variable: '--toggle-hover-thumb-surface' },
332
- ],
333
- on: [
334
- { label: 'track surface', variable: '--toggle-on-track-surface' },
335
- { label: 'track border', variable: '--toggle-on-track-border' },
336
- { label: 'thumb surface', variable: '--toggle-on-thumb-surface' },
337
- { label: 'thumb border', variable: '--toggle-on-thumb-border' },
338
- ],
339
- 'on hover': [
340
- { label: 'track surface', variable: '--toggle-on-hover-track-surface' },
341
- { label: 'thumb surface', variable: '--toggle-on-hover-thumb-surface' },
342
- ],
343
- disabled: [
344
- { label: 'track surface', variable: '--toggle-disabled-track-surface' },
345
- { label: 'thumb surface', variable: '--toggle-disabled-thumb-surface' },
346
- { label: 'label text', variable: '--toggle-disabled-label-text' },
347
- ],
348
- };
349
- export const allTokens: Token[] = Object.values(states).flat();
350
- </script>
351
-
352
- <script lang="ts">
353
- import Toggle from './Toggle.svelte';
354
- import {
355
- VariantGroup, ComponentEditorBase,
356
- } from '@motion-proto/live-tokens/component-editor';
357
-
358
- function previewProps(state: string) {
359
- return {
360
- checked: state === 'on' || state === 'on hover',
361
- disabled: state === 'disabled',
362
- forceClass: state === 'hover' || state === 'on hover' ? 'force-hover' : '',
363
- };
364
- }
365
- </script>
366
-
367
- <ComponentEditorBase
368
- {component}
369
- title="Toggle"
370
- description="On/off switch with sliding thumb."
371
- tokens={allTokens}
372
- >
373
- <VariantGroup name="toggle" title="Toggle" {states} {component}>
374
- {#snippet children({ activeState })}
375
- {@const p = previewProps(activeState)}
376
- <Toggle checked={p.checked} disabled={p.disabled} class={p.forceClass} label="Enable feature" />
377
- {/snippet}
378
- </VariantGroup>
379
- </ComponentEditorBase>
380
- ```
381
-
382
- What to notice:
383
-
384
- - `component` is the string id; must match `registerComponent({ id })` exactly.
385
- - `states` keys become the VariantGroup tab labels the user sees. Multi-word keys (`'on hover'`) need quoting.
386
- - `allTokens` is a flat union of every state's tokens. The editor store needs it for reset-to-default and sibling resolution.
387
- - `previewProps` translates the active editor tab into runtime props (`checked`, `disabled`, `force-hover` class).
388
- - No `groupKey`, no `canBeLinked`: Toggle has no linked siblings. For components that share base properties across variants, see the linked-siblings extension below.
389
-
390
- ### Register: `src/main.ts`
391
-
392
- ```ts
393
- import { bootLiveTokens } from '@motion-proto/live-tokens';
394
- import App from './App.svelte';
395
- import ToggleEditor, { allTokens as toggleTokens } from './system/components/ToggleEditor.svelte';
396
-
397
- bootLiveTokens(App, '#app', {
398
- components: [{
399
- id: 'mytoggle', // unique id; don't reuse 'toggle'
400
- label: 'My Toggle',
401
- icon: 'fas fa-toggle-on',
402
- sourceFile: 'src/system/components/Toggle.svelte',
403
- editorComponent: ToggleEditor,
404
- schema: toggleTokens,
405
- }],
406
- });
407
- ```
408
-
409
- If your app mounts manually instead of via `bootLiveTokens`, call `registerComponent({ id: 'mytoggle', ... })` yourself before `mount(App, ...)`. If you reuse `id: 'toggle'`, the consumer's component wins over the built-in (with a console warning). The collision rule protects you, but for a fresh component pick an id that doesn't collide.
410
-
411
- ## Extension: linked siblings
412
-
413
- Toggle's tokens are flat per state. Most multi-variant components (Badge, Card, SegmentedControl) share base properties across variants and surface that equality via a *linked block*: one edit propagates to every variant, while per-variant properties stay independent. Five additions to the Toggle pattern; see `BadgeEditor.svelte` in `node_modules` for the full file.
414
-
415
- 1. **Mark linkable tokens** with `canBeLinked: true` + a `groupKey`. Peers sharing a `groupKey` form a link set across variants.
416
-
417
- ```ts
418
- function variantBaseTokens(v: Variant): Token[] {
419
- return [
420
- { label: 'padding', canBeLinked: true, groupKey: 'padding', variable: `--badge-${v}-padding` },
421
- { label: 'corner radius', canBeLinked: true, groupKey: 'radius', variable: `--badge-${v}-radius` },
422
- ];
423
- }
424
- // Colors omit canBeLinked. Per-variant by design.
425
- function variantColorTokens(v: Variant): Token[] {
426
- return [
427
- { label: 'surface color', groupKey: 'surface', variable: `--badge-${v}-surface` },
428
- { label: 'text color', groupKey: 'text', variable: `--badge-${v}-text` },
429
- ];
430
- }
431
- ```
432
-
433
- 2. **Build a `linkableContexts: Map<variable, contextLabel>`** in `<script module>`. The label (e.g. `"success base"`) is how the LinkageChart row identifies this variable. Plain literal Map, no helper needed.
189
+ What to notice in the editor file:
434
190
 
435
- ```ts
436
- const linkableContexts = new Map<string, string>(
437
- variants.flatMap((v) =>
438
- variantBaseTokens(v)
439
- .filter((t) => t.canBeLinked)
440
- .map((t) => [t.variable, `${v} base`] as [string, string]),
441
- ),
442
- );
443
- ```
191
+ - No `groupKey`, no `canBeLinked`: Toggle has no linked siblings. For components that share base properties across variants, read `references/linked-siblings.md`.
444
192
 
445
- 3. **Compute `linked` and mask currently-linked rows** out of per-state lists, so they render once inside the LinkedBlock instead of twice.
193
+ For your own component, copy the pattern and substitute your id. Registering against a built-in id wins with a console warning, but the right call is a unique id.
446
194
 
447
- ```ts
448
- import { editorState } from '@motion-proto/live-tokens';
449
- import { computeLinkedBlock, withLinkedDisabled, buildSiblings }
450
- from '@motion-proto/live-tokens/component-editor';
451
-
452
- let linked = $derived(computeLinkedBlock(component, linkableContexts, allTokens, $editorState));
453
- let visibleVariantStates = $derived((v: Variant) => Object.fromEntries(
454
- Object.entries(variantStates(v)).map(([name, list]) => [name, withLinkedDisabled(list, linked.varSet)]),
455
- ));
456
- ```
195
+ ## Extensions
457
196
 
458
- 4. **Pass `{linked}` to `ComponentEditorBase`** so the LinkedBlock renders above the variant groups.
459
-
460
- 5. **Multi-variant editors iterate VariantGroups** with `buildSiblings` so cross-variant link rows resolve to their peers.
461
-
462
- ```svelte
463
- <ComponentEditorBase {component} title="Badge" tokens={allTokens} {linked} variants={variantOptions}>
464
- {#each variants as v}
465
- <VariantGroup
466
- name={v}
467
- title={v}
468
- states={visibleVariantStates(v)}
469
- {component}
470
- siblings={buildSiblings(variants, v, variantStates)}
471
- >
472
- ...preview snippet
473
- </VariantGroup>
474
- {/each}
475
- </ComponentEditorBase>
476
- ```
477
-
478
- Single-variant components with multi-state linked tokens still set `canBeLinked` + `linkableContexts`, but skip `buildSiblings` and the `{#each}` loop. Components with no linked tokens (Toggle, SectionDivider) skip all five steps — `ComponentEditorBase` renders fine without a `{linked}` prop.
479
-
480
- ## Extension: intrinsics
481
-
482
- Some components expose **structural or display choices** that aren't token values: an alignment (start / center), an element's visibility (show / hide), a layout position. These ride a bespoke `<select>` or checkbox you author in an editor snippet, not the generic token grid, so they don't belong in `allTokens`. Toggle and most components have none. SectionDivider is the worked example (alignment, hairline position, eyebrow / description visibility).
483
-
484
- An intrinsic still cascades through a CSS custom property with a default in the runtime `:global(:root)`. The trap: that default now lives in two places, the runtime `:global(:root)` AND the editor's read-back getter. When they disagree the control displays a state the page never renders, and a native `<select>` won't even fire `onchange` to write the "change" the user thinks they made. `:global(:root)` is the source of truth.
485
-
486
- Declare intrinsics so the editor and the contract test stay honest:
487
-
488
- 1. **Runtime `:global(:root)`** carries the per-variant default like any other variable:
489
-
490
- ```css
491
- --mywidget-lg-align: start;
492
- --mywidget-lg-eyebrow-display: block;
493
- ```
494
-
495
- 2. **Editor `<script module>`** exports `intrinsics: IntrinsicSpec[]`, one entry per structural property, each `default` mirroring `:global(:root)` per variant:
496
-
497
- ```ts
498
- import type { IntrinsicSpec } from '@motion-proto/live-tokens/component-editor';
499
-
500
- export const intrinsics: IntrinsicSpec[] = [
501
- {
502
- key: 'align',
503
- variants: ['lg', 'md', 'sm'],
504
- variable: (v) => `--mywidget-${v}-align`,
505
- values: ['start', 'center'],
506
- default: { lg: 'start', md: 'start', sm: 'start' },
507
- },
508
- ];
509
- ```
510
-
511
- 3. **Read-back getters fall back to the spec default**, never a hard-coded constant. This is the rule that keeps the control's displayed default in step with what an unedited instance renders:
512
-
513
- ```ts
514
- const byKey = new Map(intrinsics.map((i) => [i.key, i]));
515
- function readIntrinsic(key: string, v: string): string {
516
- const spec = byKey.get(key)!;
517
- const raw = readLiteral(spec.variable(v)) ?? spec.default[v]; // store override, else runtime default
518
- return spec.normalize ? spec.normalize(raw) : raw;
519
- }
520
- function getAlign(v: string) {
521
- return readIntrinsic('align', v) === 'center' ? 'center' : 'start';
522
- }
523
- ```
524
-
525
- Writes go through `setComponentAlias(component, spec.variable(v), { kind: 'literal', value })` so the choice cascades to `:root` like any token.
526
-
527
- 4. **Pass `intrinsics` to `registerComponent`** so the contract test can see it:
528
-
529
- ```ts
530
- registerComponent({
531
- id: 'mywidget',
532
- // ...label, icon, sourceFile, editorComponent, schema...
533
- intrinsics: myWidgetIntrinsics,
534
- });
535
- ```
197
+ Read the one you need; most components need neither.
536
198
 
537
- Use `normalize` only when two raw values render identically and the dropdown lists just one (SectionDivider folds `above-description` into `below-label`). Properties that look like intrinsics but aren't: preview-only props with no persistence (a size selector that only changes the demo), `setComponentConfig` editor metadata (Dialog's button variants), and token-valued selects (a control choosing between two tokens). None carry a duplicated runtime default, so none need an `IntrinsicSpec`.
199
+ - `references/linked-siblings.md`: variants that share base properties and should move together (Badge, Card, SegmentedControl).
200
+ - `references/intrinsics.md`: structural or display choices that are not token values (an alignment, an element's visibility), where the runtime default and the editor's read-back must agree.
538
201
 
539
202
  ## Verification checklist
540
203
 
@@ -0,0 +1,3 @@
1
+ # Fixed overlays must portal to body
2
+
3
+ Any `position: fixed` layer (modal, lightbox, full-screen backdrop) is trapped — clipped or painted under other chrome — by a transformed / `isolation` / `contain` / `will-change` ancestor, which real consumer pages (and the editor's own preview pane) commonly have. Render the fixed layer with `use:portal` from `src/system/internal/portal.ts` so it escapes to `<body>`; pass `use:portal={enabled}` to keep an in-flow preview variant in place (see `Dialog`). `check:overlay-portal` fails the build if a component has `position: fixed` without it. Anchored popovers (`position: absolute` relative to a trigger, like `Tooltip`) are exempt. Two consequences of moving to `<body>`: DOM events from the layer no longer bubble to a consumer ancestor (use component callbacks, as `Dialog` does), and a subtree-scoped CSS-variable theme no longer reaches it (this library themes via `:root`, so fine here). If the layer is a modal, also give it `role="dialog"` + `aria-modal`, move focus in on open and restore it on close, and trap `Tab` (see `ImageLightbox`).
@@ -0,0 +1,58 @@
1
+ # Extension: intrinsics
2
+
3
+ Some components expose **structural or display choices** that aren't token values: an alignment (start / center), an element's visibility (show / hide), a layout position. These ride a bespoke `<select>` or checkbox you author in an editor snippet, not the generic token grid, so they don't belong in `allTokens`. Toggle and most components have none. SectionDivider is the worked example (alignment, hairline position, eyebrow / description visibility).
4
+
5
+ An intrinsic still cascades through a CSS custom property with a default in the runtime `:global(:root)`. The trap: that default now lives in two places, the runtime `:global(:root)` AND the editor's read-back getter. When they disagree the control displays a state the page never renders, and a native `<select>` won't even fire `onchange` to write the "change" the user thinks they made. `:global(:root)` is the source of truth.
6
+
7
+ Declare intrinsics so the editor and the contract test stay honest:
8
+
9
+ 1. **Runtime `:global(:root)`** carries the per-variant default like any other variable:
10
+
11
+ ```css
12
+ --mywidget-lg-align: start;
13
+ --mywidget-lg-eyebrow-display: block;
14
+ ```
15
+
16
+ 2. **Editor `<script module>`** exports `intrinsics: IntrinsicSpec[]`, one entry per structural property, each `default` mirroring `:global(:root)` per variant:
17
+
18
+ ```ts
19
+ import type { IntrinsicSpec } from '@motion-proto/live-tokens/component-editor';
20
+
21
+ export const intrinsics: IntrinsicSpec[] = [
22
+ {
23
+ key: 'align',
24
+ variants: ['lg', 'md', 'sm'],
25
+ variable: (v) => `--mywidget-${v}-align`,
26
+ values: ['start', 'center'],
27
+ default: { lg: 'start', md: 'start', sm: 'start' },
28
+ },
29
+ ];
30
+ ```
31
+
32
+ 3. **Read-back getters fall back to the spec default**, never a hard-coded constant. This is the rule that keeps the control's displayed default in step with what an unedited instance renders:
33
+
34
+ ```ts
35
+ const byKey = new Map(intrinsics.map((i) => [i.key, i]));
36
+ function readIntrinsic(key: string, v: string): string {
37
+ const spec = byKey.get(key)!;
38
+ const raw = readLiteral(spec.variable(v)) ?? spec.default[v]; // store override, else runtime default
39
+ return spec.normalize ? spec.normalize(raw) : raw;
40
+ }
41
+ function getAlign(v: string) {
42
+ return readIntrinsic('align', v) === 'center' ? 'center' : 'start';
43
+ }
44
+ ```
45
+
46
+ Writes go through `setComponentAlias(component, spec.variable(v), { kind: 'literal', value })` so the choice cascades to `:root` like any token.
47
+
48
+ 4. **Pass `intrinsics` to `registerComponent`** so the contract test can see it:
49
+
50
+ ```ts
51
+ registerComponent({
52
+ id: 'mywidget',
53
+ // ...label, icon, sourceFile, editorComponent, schema...
54
+ intrinsics: myWidgetIntrinsics,
55
+ });
56
+ ```
57
+
58
+ Use `normalize` only when two raw values render identically and the dropdown lists just one (SectionDivider folds `above-description` into `below-label`). Properties that look like intrinsics but aren't: preview-only props with no persistence (a size selector that only changes the demo), `setComponentConfig` editor metadata (Dialog's button variants), and token-valued selects (a control choosing between two tokens). None carry a duplicated runtime default, so none need an `IntrinsicSpec`.
@@ -0,0 +1,70 @@
1
+ # Extension: linked siblings
2
+
3
+ Read this when your component has more than one variant and those variants share base properties (surface, radius, padding) that should move together. Toggle and SectionDivider have no linked tokens and skip all of it.
4
+
5
+ Toggle's tokens are flat per state. Most multi-variant components (Badge, Card, SegmentedControl) share base properties across variants and surface that equality via a *linked block*: one edit propagates to every variant, while per-variant properties stay independent. Five additions to the Toggle pattern; see `BadgeEditor.svelte` in `node_modules` for the full file.
6
+
7
+ 1. **Mark linkable tokens** with `canBeLinked: true` + a `groupKey`. Peers sharing a `groupKey` form a link set across variants.
8
+
9
+ ```ts
10
+ function variantBaseTokens(v: Variant): Token[] {
11
+ return [
12
+ { label: 'padding', canBeLinked: true, groupKey: 'padding', variable: `--badge-${v}-padding` },
13
+ { label: 'corner radius', canBeLinked: true, groupKey: 'radius', variable: `--badge-${v}-radius` },
14
+ ];
15
+ }
16
+ // Colors omit canBeLinked. Per-variant by design.
17
+ function variantColorTokens(v: Variant): Token[] {
18
+ return [
19
+ { label: 'surface color', groupKey: 'surface', variable: `--badge-${v}-surface` },
20
+ { label: 'text color', groupKey: 'text', variable: `--badge-${v}-text` },
21
+ ];
22
+ }
23
+ ```
24
+
25
+ 2. **Build a `linkableContexts: Map<variable, contextLabel>`** in `<script module>`. The label (e.g. `"success base"`) is how the LinkageChart row identifies this variable. Plain literal Map, no helper needed.
26
+
27
+ ```ts
28
+ const linkableContexts = new Map<string, string>(
29
+ variants.flatMap((v) =>
30
+ variantBaseTokens(v)
31
+ .filter((t) => t.canBeLinked)
32
+ .map((t) => [t.variable, `${v} base`] as [string, string]),
33
+ ),
34
+ );
35
+ ```
36
+
37
+ 3. **Compute `linked` and mask currently-linked rows** out of per-state lists, so they render once inside the LinkedBlock instead of twice.
38
+
39
+ ```ts
40
+ import { editorState } from '@motion-proto/live-tokens';
41
+ import { computeLinkedBlock, withLinkedDisabled, buildSiblings }
42
+ from '@motion-proto/live-tokens/component-editor';
43
+
44
+ let linked = $derived(computeLinkedBlock(component, linkableContexts, allTokens, $editorState));
45
+ let visibleVariantStates = $derived((v: Variant) => Object.fromEntries(
46
+ Object.entries(variantStates(v)).map(([name, list]) => [name, withLinkedDisabled(list, linked.varSet)]),
47
+ ));
48
+ ```
49
+
50
+ 4. **Pass `{linked}` to `ComponentEditorBase`** so the LinkedBlock renders above the variant groups.
51
+
52
+ 5. **Multi-variant editors iterate VariantGroups** with `buildSiblings` so cross-variant link rows resolve to their peers.
53
+
54
+ ```svelte
55
+ <ComponentEditorBase {component} title="Badge" tokens={allTokens} {linked} variants={variantOptions}>
56
+ {#each variants as v}
57
+ <VariantGroup
58
+ name={v}
59
+ title={v}
60
+ states={visibleVariantStates(v)}
61
+ {component}
62
+ siblings={buildSiblings(variants, v, variantStates)}
63
+ >
64
+ ...preview snippet
65
+ </VariantGroup>
66
+ {/each}
67
+ </ComponentEditorBase>
68
+ ```
69
+
70
+ Single-variant components with multi-state linked tokens still set `canBeLinked` + `linkableContexts`, but skip `buildSiblings` and the `{#each}` loop. Components with no linked tokens (Toggle, SectionDivider) skip all five steps — `ComponentEditorBase` renders fine without a `{linked}` prop.