@motion-proto/live-tokens 0.24.0 → 0.24.2

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.
@@ -24,7 +24,7 @@ For pattern reference, read any shipped component's source directly from the con
24
24
  ## 4-step 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). 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>` (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.
28
28
  3. **Register** — in `src/main.ts` before `mount(App, ...)`:
29
29
  ```ts
30
30
  import { registerComponent } from '@motion-proto/live-tokens';
@@ -462,6 +462,65 @@ Toggle's tokens are flat per state. Most multi-variant components (Badge, Card,
462
462
 
463
463
  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.
464
464
 
465
+ ## Extension: intrinsics
466
+
467
+ 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).
468
+
469
+ 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.
470
+
471
+ Declare intrinsics so the editor and the contract test stay honest:
472
+
473
+ 1. **Runtime `:global(:root)`** carries the per-variant default like any other variable:
474
+
475
+ ```css
476
+ --mywidget-lg-align: start;
477
+ --mywidget-lg-eyebrow-display: block;
478
+ ```
479
+
480
+ 2. **Editor `<script module>`** exports `intrinsics: IntrinsicSpec[]`, one entry per structural property, each `default` mirroring `:global(:root)` per variant:
481
+
482
+ ```ts
483
+ import type { IntrinsicSpec } from '@motion-proto/live-tokens/component-editor';
484
+
485
+ export const intrinsics: IntrinsicSpec[] = [
486
+ {
487
+ key: 'align',
488
+ variants: ['lg', 'md', 'sm'],
489
+ variable: (v) => `--mywidget-${v}-align`,
490
+ values: ['start', 'center'],
491
+ default: { lg: 'start', md: 'start', sm: 'start' },
492
+ },
493
+ ];
494
+ ```
495
+
496
+ 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:
497
+
498
+ ```ts
499
+ const byKey = new Map(intrinsics.map((i) => [i.key, i]));
500
+ function readIntrinsic(key: string, v: string): string {
501
+ const spec = byKey.get(key)!;
502
+ const raw = readLiteral(spec.variable(v)) ?? spec.default[v]; // store override, else runtime default
503
+ return spec.normalize ? spec.normalize(raw) : raw;
504
+ }
505
+ function getAlign(v: string) {
506
+ return readIntrinsic('align', v) === 'center' ? 'center' : 'start';
507
+ }
508
+ ```
509
+
510
+ Writes go through `setComponentAlias(component, spec.variable(v), { kind: 'literal', value })` so the choice cascades to `:root` like any token.
511
+
512
+ 4. **Pass `intrinsics` to `registerComponent`** so the contract test can see it:
513
+
514
+ ```ts
515
+ registerComponent({
516
+ id: 'mywidget',
517
+ // ...label, icon, sourceFile, editorComponent, schema...
518
+ intrinsics: myWidgetIntrinsics,
519
+ });
520
+ ```
521
+
522
+ 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`.
523
+
465
524
  ## Verification checklist
466
525
 
467
526
  After saving, run the static validator first:
@@ -483,6 +542,8 @@ It enforces the file layout, the `:global(:root)` block, token-suffix vocabulary
483
542
 
484
543
  A new first-party component is auto-covered the moment it lands in `builtInRegistry` — `npm test` will fail if any of the five checks miss. For a consumer-authored component, mirror this pattern in your own test suite if you want the same drift protection (the same test logic works against any `registerComponent` registration; iterate `getComponentRegistryEntries()` after your `main.ts` has run).
485
544
 
545
+ **If your component declares `intrinsics`, the intrinsics contract test covers it too.** `src/editor/component-editor/intrinsicsContract.test.ts` iterates every entry with an `intrinsics` array and asserts, per (intrinsic, variant), that the runtime `:global(:root)` declares a default, the default is one of the spec's `values`, and the editor's `default` equals the runtime default. This is what would have caught a getter defaulting to `center` while `:global(:root)` says `start`. Same auto-coverage rule: declare `intrinsics` on the registry entry and the test picks it up.
546
+
486
547
  Finally navigate to `/components` and confirm the runtime behaviours no static check can see:
487
548
 
488
549
  - [ ] The new component appears in the nav rail under the **CUSTOM** group (system entries above, custom below the labeled divider).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  <script module lang="ts">
2
2
  import { buildSiblings } from './scaffolding/siblings';
3
- import type { Token, TypeGroupConfig } from './scaffolding/types';
3
+ import type { Token, TypeGroupConfig, IntrinsicSpec } from './scaffolding/types';
4
4
 
5
5
  export const component = 'sectiondivider';
6
6
 
@@ -149,6 +149,57 @@
149
149
  | 'above-description'
150
150
  | 'through-description'
151
151
  | 'below-description';
152
+
153
+ // Structural/display properties driven by the bespoke selects + element
154
+ // toggles below, not the token grid. Each `default` mirrors the runtime
155
+ // SectionDivider's `:global(:root)`; the read-back getters fall back to it
156
+ // when a variant is unedited, and intrinsicsContract.test pins the two
157
+ // copies together. Defaults are per-variant: lg ships eyebrow + description
158
+ // + a description-anchored hairline; md/sm ship the title alone.
159
+ const HAIRLINE_POSITIONS = [
160
+ 'above-label', 'through-label', 'below-label', 'through-description', 'below-description',
161
+ ] as const;
162
+ export const intrinsics: IntrinsicSpec[] = [
163
+ {
164
+ key: 'align',
165
+ variants: ['lg', 'md', 'sm'],
166
+ variable: (v) => `--sectiondivider-${v}-align`,
167
+ values: ['start', 'center'],
168
+ default: { lg: 'start', md: 'start', sm: 'start' },
169
+ },
170
+ {
171
+ key: 'eyebrow-display',
172
+ variants: ['lg', 'md', 'sm'],
173
+ variable: (v) => `--sectiondivider-${v}-eyebrow-display`,
174
+ values: ['block', 'none'],
175
+ default: { lg: 'block', md: 'none', sm: 'none' },
176
+ },
177
+ {
178
+ key: 'description-display',
179
+ variants: ['lg', 'md', 'sm'],
180
+ variable: (v) => `--sectiondivider-${v}-description-display`,
181
+ values: ['flex', 'none'],
182
+ default: { lg: 'flex', md: 'none', sm: 'none' },
183
+ },
184
+ {
185
+ key: 'eyebrow-text-transform',
186
+ variants: ['lg', 'md', 'sm'],
187
+ variable: (v) => `--sectiondivider-${v}-eyebrow-text-transform`,
188
+ values: ['uppercase', 'none'],
189
+ default: { lg: 'none', md: 'none', sm: 'none' },
190
+ },
191
+ {
192
+ key: 'hairline',
193
+ variants: ['lg', 'md', 'sm'],
194
+ variable: (v) => `--sectiondivider-${v}-hairline`,
195
+ values: ['none', ...HAIRLINE_POSITIONS],
196
+ default: { lg: 'below-description', md: 'below-label', sm: 'below-label' },
197
+ // 'above-description' renders identically to 'below-label'; the position
198
+ // dropdown omits it, so coerce on read to keep the control's value valid.
199
+ normalize: (raw) => (raw === 'above-description' ? 'below-label' : raw),
200
+ },
201
+ ];
202
+ const INTRINSIC_BY_KEY = new Map(intrinsics.map((i) => [i.key, i]));
152
203
  </script>
153
204
 
154
205
  <script lang="ts">
@@ -191,8 +242,19 @@
191
242
  return ref.value;
192
243
  }
193
244
 
245
+ // Resolved raw intrinsic value: the stored override, else the spec's
246
+ // per-variant default (which mirrors the runtime :root). Normalized so the
247
+ // getters below branch on a canonical value. Falling back to the spec
248
+ // default — not a hard-coded constant — is what keeps each control's
249
+ // displayed default in step with what the unedited divider actually renders.
250
+ function readIntrinsic(key: string, v: Variant): string {
251
+ const spec = INTRINSIC_BY_KEY.get(key)!;
252
+ const raw = readLiteral(spec.variable(v)) ?? spec.default[v];
253
+ return spec.normalize ? spec.normalize(raw) : raw;
254
+ }
255
+
194
256
  function getAlign(v: Variant): Align {
195
- return readLiteral(`--sectiondivider-${v}-align`) === 'start' ? 'start' : 'center';
257
+ return readIntrinsic('align', v) === 'center' ? 'center' : 'start';
196
258
  }
197
259
  function getColorFamily(v: Variant): string {
198
260
  const raw = cfg[`--sectiondivider-${v}-color-family`];
@@ -201,13 +263,9 @@
201
263
  }
202
264
  /** Active hairline position OR `'none'` (= hidden). */
203
265
  function getHairlineValue(v: Variant): HairlinePosition | 'none' {
204
- const raw = readLiteral(`--sectiondivider-${v}-hairline`);
205
- if (raw === undefined || raw === 'none') return 'none';
206
- // 'above-description' renders identically to 'below-label' coerce on read
207
- // so the dropdown's option list (which omits 'above-description') stays valid.
208
- if (raw === 'above-description') return 'below-label';
209
- const positions: HairlinePosition[] = ['above-label', 'through-label', 'below-label', 'through-description', 'below-description'];
210
- return (positions as string[]).includes(raw) ? (raw as HairlinePosition) : 'none';
266
+ const raw = readIntrinsic('hairline', v);
267
+ if (raw === 'none') return 'none';
268
+ return (HAIRLINE_POSITIONS as readonly string[]).includes(raw) ? (raw as HairlinePosition) : 'none';
211
269
  }
212
270
  function getShowHairline(v: Variant): boolean {
213
271
  return getHairlineValue(v) !== 'none';
@@ -220,17 +278,13 @@
220
278
  return val === 'none' ? 'above-label' : val;
221
279
  }
222
280
  function getShowEyebrow(v: Variant): boolean {
223
- return readLiteral(`--sectiondivider-${v}-eyebrow-display`) === 'block';
281
+ return readIntrinsic('eyebrow-display', v) === 'block';
224
282
  }
225
283
  function getEyebrowUppercase(v: Variant): boolean {
226
- return readLiteral(`--sectiondivider-${v}-eyebrow-text-transform`) === 'uppercase';
284
+ return readIntrinsic('eyebrow-text-transform', v) === 'uppercase';
227
285
  }
228
286
  function getShowDescription(v: Variant): boolean {
229
- const raw = readLiteral(`--sectiondivider-${v}-description-display`);
230
- // Default: shown (flex) when unset. Matches the :root default and the
231
- // legacy `getShowDescription` semantics for files that never set the key.
232
- if (raw === undefined) return true;
233
- return raw === 'flex';
287
+ return readIntrinsic('description-display', v) !== 'none';
234
288
  }
235
289
  /** Write an intrinsic to the aliases bucket as a literal so it cascades
236
290
  * through cssVarSync to `:root` on both the editor iframe and host page. */
@@ -391,8 +445,8 @@
391
445
  value={getAlign(v.key)}
392
446
  onchange={(e) => setIntrinsic(v.key, 'align', (e.currentTarget as HTMLSelectElement).value)}
393
447
  >
394
- <option value="center">Center</option>
395
448
  <option value="start">Start</option>
449
+ <option value="center">Center</option>
396
450
  </select>
397
451
  </div>
398
452
  <div class="property-row sd-intrinsic-row">
@@ -18,3 +18,7 @@ export { buildTypeGroupTokens } from './scaffolding/buildTypeGroupTokens';
18
18
 
19
19
  // Token schema type — the shape of an entry in an editor's `allTokens` array.
20
20
  export type { Token } from './scaffolding/types';
21
+
22
+ // Intrinsic spec — structural/display props an editor drives outside the token
23
+ // grid (alignment, visibility). Pass an array as `registerComponent({ intrinsics })`.
24
+ export type { IntrinsicSpec } from './scaffolding/types';
@@ -1,5 +1,5 @@
1
1
  import type { Component } from 'svelte';
2
- import type { Token } from './scaffolding/types';
2
+ import type { Token, IntrinsicSpec } from './scaffolding/types';
3
3
  import { registerComponentSchema } from '../core/store/editorStore';
4
4
 
5
5
  import BadgeEditor, { allTokens as badgeTokens } from './BadgeEditor.svelte';
@@ -18,7 +18,7 @@ import MenuSelectEditor, { allTokens as menuSelectTokens } from './MenuSelectEdi
18
18
  import NotificationEditor, { allTokens as notificationTokens } from './NotificationEditor.svelte';
19
19
  import ProgressBarEditor, { allTokens as progressBarTokens } from './ProgressBarEditor.svelte';
20
20
  import RadioButtonEditor, { allTokens as radioButtonTokens } from './RadioButtonEditor.svelte';
21
- import SectionDividerEditor, { allTokens as sectionDividerTokens } from './SectionDividerEditor.svelte';
21
+ import SectionDividerEditor, { allTokens as sectionDividerTokens, intrinsics as sectionDividerIntrinsics } from './SectionDividerEditor.svelte';
22
22
  import SegmentedControlEditor, { allTokens as segmentedControlTokens } from './SegmentedControlEditor.svelte';
23
23
  import SideNavigationEditor, { allTokens as sideNavigationTokens } from './SideNavigationEditor.svelte';
24
24
  import TableEditor, { allTokens as tableTokens } from './TableEditor.svelte';
@@ -72,6 +72,10 @@ export interface RegistryEntry {
72
72
  editorComponent: Component<any, any, any>;
73
73
  /** Flat token list — the editor's declarative description of its token surface. */
74
74
  schema: Token[];
75
+ /** Structural/display properties controlled outside the token grid. Optional —
76
+ most components have none. When present, each spec's per-variant default is
77
+ pinned to the runtime `:global(:root)` by intrinsicsContract.test. */
78
+ intrinsics?: IntrinsicSpec[];
75
79
  /** `'system'` for first-party entries; `'custom'` for entries added via `registerComponent()`. */
76
80
  origin: 'system' | 'custom';
77
81
  }
@@ -226,6 +230,7 @@ const builtInRegistry: Readonly<Record<BuiltInComponentId, RegistryEntry>> = Obj
226
230
  sourceFile: 'src/system/components/SectionDivider.svelte',
227
231
  editorComponent: SectionDividerEditor,
228
232
  schema: sectionDividerTokens,
233
+ intrinsics: sectionDividerIntrinsics,
229
234
  origin: 'system',
230
235
  },
231
236
  collapsiblesection: {
@@ -36,6 +36,31 @@ export type Token = {
36
36
  family?: string;
37
37
  };
38
38
 
39
+ /** An intrinsic: a structural/display property (alignment, hairline position,
40
+ element visibility) driven by a bespoke editor control rather than the
41
+ generic token grid. Unlike a Token it carries the default twice — once in
42
+ the runtime component's `:global(:root)` and once in the editor's read-back
43
+ (`default` below, which the getters fall back to when unset). Those two
44
+ copies must agree or the control displays a state the page doesn't render
45
+ (the SectionDivider align bug). The intrinsics contract test pins them. */
46
+ export type IntrinsicSpec = {
47
+ /** Stable id, used in diagnostics and to key getters off the spec. */
48
+ key: string;
49
+ /** Variant keys this intrinsic spans (e.g. ['lg','md','sm']). */
50
+ variants: string[];
51
+ /** Per-variant CSS custom property. */
52
+ variable: (variant: string) => string;
53
+ /** Allowed raw values; the runtime `:root` default must be one of these. */
54
+ values: string[];
55
+ /** Editor's unset-default raw value per variant — the read-back getters
56
+ source their default from here. Pinned to the runtime `:global(:root)`
57
+ default by the intrinsics contract test. */
58
+ default: Record<string, string>;
59
+ /** Folds render-equivalent raw values to one canonical form before
60
+ comparison (e.g. SectionDivider's 'above-description' ≡ 'below-label'). */
61
+ normalize?: (raw: string) => string;
62
+ };
63
+
39
64
  /** Editor type-group: a fieldset containing a coordinated set of typography tokens
40
65
  (text color + font-family/size/weight/line-height) for a piece of content
41
66
  (e.g. a card title, notification body). Optional outline rows let
@@ -183,7 +183,7 @@
183
183
  #b1b2b2 62%,
184
184
  #626363 100%
185
185
  );
186
- color: rgba(0, 0, 0, 0.88);
186
+ color: #000;
187
187
  border: 1px solid rgba(255, 255, 255, 0.55);
188
188
  border-radius: 9999px;
189
189
  font-family: Manrope, system-ui, -apple-system, sans-serif;