@motion-proto/live-tokens 0.74.0 → 0.76.0

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 (171) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +33 -38
  2. package/.claude/skills/live-tokens-create-component/SKILL copy.md +196 -0
  3. package/.claude/skills/live-tokens-create-component/SKILL.md +198 -146
  4. package/.claude/skills/live-tokens-create-component/references/contract-tests.md +95 -52
  5. package/.claude/skills/live-tokens-create-component/references/intrinsics.md +2 -2
  6. package/.claude/skills/live-tokens-create-component/references/linked-siblings.md +2 -2
  7. package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +12 -12
  8. package/.claude/skills/live-tokens-create-component/references/token-naming.md +2 -1
  9. package/.claude/skills/live-tokens-create-page/SKILL.md +187 -0
  10. package/.claude/skills/live-tokens-create-page/references/interaction-sources.md +66 -0
  11. package/.claude/skills/live-tokens-create-page/references/layout-sources.md +87 -0
  12. package/.claude/skills/live-tokens-create-theme/SKILL.md +52 -48
  13. package/.claude/skills/live-tokens-create-theme/references/design-directions.md +1 -1
  14. package/.claude/skills/live-tokens-fix-findings/SKILL.md +69 -60
  15. package/.claude/skills/live-tokens-pick-component/SKILL.md +64 -79
  16. package/.claude/skills/live-tokens-set-colors/SKILL.md +47 -38
  17. package/.claude/skills/live-tokens-set-geometry/SKILL.md +58 -35
  18. package/.claude/skills/live-tokens-set-geometry/references/geometry-anchors.md +5 -3
  19. package/.claude/skills/live-tokens-set-type/SKILL.md +30 -30
  20. package/CHANGELOG.md +138 -0
  21. package/README.md +17 -7
  22. package/bin/check-component.mjs +91 -19
  23. package/bin/check-page.mjs +102 -21
  24. package/bin/cli.mjs +87 -113
  25. package/bin/contractRunner.mjs +945 -0
  26. package/bin/create.mjs +1 -1
  27. package/bin/lib/catalogue.mjs +37 -30
  28. package/bin/lib/findings.mjs +46 -15
  29. package/bin/lib/report.mjs +3 -3
  30. package/bin/lib/tokenVocabulary.mjs +4 -4
  31. package/bin/migrate-routes.mjs +5 -5
  32. package/bin/migrate.mjs +4 -4
  33. package/bin/save-theme.mjs +8 -9
  34. package/bin/set-colors.mjs +9 -11
  35. package/bin/set-geometry.mjs +7 -7
  36. package/bin/set-type.mjs +5 -7
  37. package/bin/setup-claude.mjs +110 -0
  38. package/dist-plugin/{chunk-W6Y4BWFB.js → chunk-6WGFOJXO.js} +22 -0
  39. package/dist-plugin/{chunk-7VRTBGJT.js → chunk-PDNL4NC5.js} +9 -2
  40. package/dist-plugin/{chunk-V3YF6CGT.js → chunk-SWXRVZKT.js} +43 -1
  41. package/dist-plugin/{dataPaths-BhWzd5cL.d.cts → dataPaths-BpIK_Obx.d.cts} +1 -0
  42. package/dist-plugin/{dataPaths-BhWzd5cL.d.ts → dataPaths-BpIK_Obx.d.ts} +1 -0
  43. package/dist-plugin/index.cjs +310 -147
  44. package/dist-plugin/index.d.cts +1 -1
  45. package/dist-plugin/index.d.ts +1 -1
  46. package/dist-plugin/index.js +218 -122
  47. package/dist-plugin/migrateData/index.cjs +66 -2
  48. package/dist-plugin/migrateData/index.d.cts +1 -1
  49. package/dist-plugin/migrateData/index.d.ts +1 -1
  50. package/dist-plugin/migrateData/index.js +3 -3
  51. package/dist-plugin/setColors/index.cjs +61 -4
  52. package/dist-plugin/setColors/index.d.cts +1 -1
  53. package/dist-plugin/setColors/index.d.ts +1 -1
  54. package/dist-plugin/setColors/index.js +5 -5
  55. package/dist-plugin/setGeometry/index.cjs +107 -44
  56. package/dist-plugin/setGeometry/index.d.cts +5 -5
  57. package/dist-plugin/setGeometry/index.d.ts +5 -5
  58. package/dist-plugin/setGeometry/index.js +51 -45
  59. package/dist-plugin/setType/index.cjs +15 -0
  60. package/dist-plugin/setType/index.d.cts +1 -1
  61. package/dist-plugin/setType/index.d.ts +1 -1
  62. package/dist-plugin/setType/index.js +1 -1
  63. package/dist-plugin/tokensCssMigrations/index.d.cts +1 -1
  64. package/dist-plugin/tokensCssMigrations/index.d.ts +1 -1
  65. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  66. package/package.json +35 -8
  67. package/src/app/site.css +19 -9
  68. package/src/editor/bootstrap.ts +2 -0
  69. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +4 -4
  70. package/src/editor/component-editor/DialogEditor.svelte +4 -4
  71. package/src/editor/component-editor/MenuSelectEditor.svelte +4 -1
  72. package/src/editor/component-editor/SegmentedControlEditor.svelte +6 -1
  73. package/src/editor/component-editor/TabBarEditor.svelte +1 -1
  74. package/src/editor/component-editor/TableEditor.svelte +2 -2
  75. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +6 -3
  76. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +41 -20
  77. package/src/editor/core/components/adjustAliases.ts +59 -45
  78. package/src/editor/core/components/aliasKinds.ts +9 -5
  79. package/src/editor/core/preview/themePreview.ts +9 -2
  80. package/src/editor/core/sketch/sketchLayer.ts +22 -0
  81. package/src/editor/core/store/editorStore.ts +10 -1
  82. package/src/editor/core/themes/buildColors.ts +3 -3
  83. package/src/editor/core/themes/liveStateStream.ts +26 -0
  84. package/src/editor/core/themes/migrations/2026-09-07-stroke-role-renames.ts +52 -0
  85. package/src/editor/core/themes/migrations/index.ts +2 -0
  86. package/src/editor/core/themes/themeDocumentSync.ts +22 -11
  87. package/src/editor/core/themes/themeService.ts +9 -2
  88. package/src/editor/pages/ComponentEditorPage.svelte +17 -1
  89. package/src/editor/pages/liveTokensEditorHandle.ts +23 -0
  90. package/src/editor/skill-atlas/SkillAtlas.svelte +75 -573
  91. package/src/editor/skill-atlas/TreeCanvas.svelte +263 -0
  92. package/src/editor/skill-atlas/TreeNodeCard.svelte +136 -53
  93. package/src/editor/skill-atlas/edges.ts +31 -0
  94. package/src/editor/skill-atlas/skillSources.generated.ts +20 -19
  95. package/src/editor/skill-atlas/skillTrees.ts +19 -3840
  96. package/src/editor/skill-atlas/trees/check-compliance.ts +183 -0
  97. package/src/editor/skill-atlas/trees/create-component.ts +275 -0
  98. package/src/editor/skill-atlas/trees/create-page.ts +320 -0
  99. package/src/editor/skill-atlas/trees/create-theme.ts +255 -0
  100. package/src/editor/skill-atlas/trees/fix-findings.ts +469 -0
  101. package/src/editor/skill-atlas/trees/pick-component.ts +300 -0
  102. package/src/editor/skill-atlas/trees/set-colors.ts +148 -0
  103. package/src/editor/skill-atlas/trees/set-geometry.ts +136 -0
  104. package/src/editor/skill-atlas/trees/set-type.ts +142 -0
  105. package/src/editor/skill-atlas/types.ts +3 -4
  106. package/src/editor/skill-atlas/wireLayout.ts +287 -0
  107. package/src/live-tokens/data/themes/autumn.json +15 -15
  108. package/src/live-tokens/data/themes/halloween.json +15 -15
  109. package/src/live-tokens/data/themes/midnight-study.json +15 -15
  110. package/src/live-tokens/data/themes/ocean.json +15 -15
  111. package/src/live-tokens/data/themes/royal-velvet.json +15 -15
  112. package/src/live-tokens/data/themes/sketchy.json +15 -15
  113. package/src/live-tokens/data/themes/spring-meadow.json +15 -15
  114. package/src/live-tokens/data/themes/sunset.json +15 -15
  115. package/src/system/components/Badge.svelte +7 -0
  116. package/src/system/components/Button.svelte +7 -0
  117. package/src/system/components/Callout.svelte +10 -6
  118. package/src/system/components/Card.svelte +23 -4
  119. package/src/system/components/CodeSnippet.svelte +4 -3
  120. package/src/system/components/CollapsibleSection.svelte +23 -8
  121. package/src/system/components/CornerBadge.svelte +6 -0
  122. package/src/system/components/Dialog.svelte +13 -6
  123. package/src/system/components/IconButton.svelte +9 -0
  124. package/src/system/components/Image.svelte +8 -0
  125. package/src/system/components/ImageLightbox.svelte +6 -0
  126. package/src/system/components/InlineEditActions.svelte +7 -0
  127. package/src/system/components/Input.svelte +7 -0
  128. package/src/system/components/MenuSelect.svelte +7 -0
  129. package/src/system/components/Notification.svelte +7 -0
  130. package/src/system/components/Panel.svelte +6 -0
  131. package/src/system/components/ProgressBar.svelte +5 -0
  132. package/src/system/components/RadioButton.svelte +11 -5
  133. package/src/system/components/SectionDivider.svelte +8 -0
  134. package/src/system/components/SegmentedControl.svelte +6 -0
  135. package/src/system/components/SideNavigation.svelte +6 -0
  136. package/src/system/components/Slider.svelte +7 -4
  137. package/src/system/components/TabBar.svelte +15 -9
  138. package/src/system/components/Table.svelte +8 -3
  139. package/src/system/components/Toggle.svelte +4 -4
  140. package/src/system/components/Tooltip.svelte +6 -0
  141. package/src/testing-js/chunk-AO7EZHYV.js +776 -0
  142. package/src/testing-js/chunk-AO7EZHYV.js.map +1 -0
  143. package/src/testing-js/chunk-FAFOAWYL.js +39 -0
  144. package/src/testing-js/chunk-FAFOAWYL.js.map +1 -0
  145. package/src/testing-js/chunk-L73N4NSO.js +23 -0
  146. package/src/testing-js/chunk-L73N4NSO.js.map +1 -0
  147. package/src/testing-js/chunk-LXR3MN6N.js +3063 -0
  148. package/src/testing-js/chunk-LXR3MN6N.js.map +1 -0
  149. package/src/testing-js/chunk-ZMSX6CXR.js +53 -0
  150. package/src/testing-js/chunk-ZMSX6CXR.js.map +1 -0
  151. package/src/testing-js/component-alias.contract.js +81 -0
  152. package/src/testing-js/component-alias.contract.js.map +1 -0
  153. package/src/testing-js/component-editor.contract.js +62 -0
  154. package/src/testing-js/component-editor.contract.js.map +1 -0
  155. package/src/testing-js/component-render.contract.js +568 -0
  156. package/src/testing-js/component-render.contract.js.map +1 -0
  157. package/src/testing-js/index.d.ts +293 -0
  158. package/src/testing-js/index.js +222 -0
  159. package/src/testing-js/index.js.map +1 -0
  160. package/src/testing-js/registry.contract.js +39 -0
  161. package/src/testing-js/registry.contract.js.map +1 -0
  162. package/src/testing-js/vitest-BE6uGF31.d.ts +73 -0
  163. package/src/testing-js/vitest.d.ts +3 -0
  164. package/src/testing-js/vitest.js +13 -0
  165. package/src/testing-js/vitest.js.map +1 -0
  166. package/template/README.md +13 -0
  167. package/template/_gitignore +6 -0
  168. package/template/package.json +3 -1
  169. package/template/src/pages/Home.svelte +4 -18
  170. package/.claude/skills/live-tokens-build-page/SKILL.md +0 -103
  171. package/.claude/skills/live-tokens-build-page/references/layout-sources.md +0 -48
@@ -407,24 +407,26 @@
407
407
  </div>
408
408
  {/if}
409
409
  </div>
410
- {#if unboxedPreview}
411
- <div class="sketch-scope" bind:this={sketchStage}>
412
- {@render children?.({ activeState: activeTab })}
413
- </div>
414
- {:else}
415
- <ShadowBackdrop mode={bgMode} colorVariable={bgVar} padding={backdropPadding}>
416
- {#snippet controls()}
417
- <div class="canvas-toolbar">
418
- <span class="canvas-toolbar-eyebrow">Background</span>
419
- <ShadowBackdropControls bind:mode={bgMode} colorVariable={bgVar} modes={backdropModes ?? ['default', 'image', 'color']} />
420
- {@render canvasToolbarExtras?.()}
421
- </div>
422
- {/snippet}
410
+ <div class="preview-stage">
411
+ {#if unboxedPreview}
423
412
  <div class="sketch-scope" bind:this={sketchStage}>
424
- {@render children?.({ activeState: activeTab })}
425
- </div>
426
- </ShadowBackdrop>
427
- {/if}
413
+ {@render children?.({ activeState: activeTab })}
414
+ </div>
415
+ {:else}
416
+ <ShadowBackdrop mode={bgMode} colorVariable={bgVar} padding={backdropPadding}>
417
+ {#snippet controls()}
418
+ <div class="canvas-toolbar">
419
+ <span class="canvas-toolbar-eyebrow">Background</span>
420
+ <ShadowBackdropControls bind:mode={bgMode} colorVariable={bgVar} modes={backdropModes ?? ['default', 'image', 'color']} />
421
+ {@render canvasToolbarExtras?.()}
422
+ </div>
423
+ {/snippet}
424
+ <div class="sketch-scope" bind:this={sketchStage}>
425
+ {@render children?.({ activeState: activeTab })}
426
+ </div>
427
+ </ShadowBackdrop>
428
+ {/if}
429
+ </div>
428
430
 
429
431
  {#if tabsStripVisible}
430
432
  <div class="tabs-states-block">
@@ -556,9 +558,13 @@
556
558
  /* Pin the preview + state-tab strip to the top of the page scroll so
557
559
  property edits stay visually connected to the preview without scrolling.
558
560
  The card background extends through the sticky band so the property grid
559
- scrolls cleanly behind it. The element-grouped property layout (see
560
- StateBlock) fans out horizontally, which keeps the property section
561
- short enough that the sticky preview rarely steals usable space. */
561
+ scrolls cleanly behind it.
562
+
563
+ The cap is load-bearing: a sticky band taller than the viewport covers the
564
+ property controls at every scroll position, which the tallest previews
565
+ (SideNavigation, Image, Card) reach on a 13-inch laptop. Capping the band
566
+ and scrolling the stage inside it keeps the header and the state strip
567
+ pinned and always leaves the controls a viewport to live in. */
562
568
  .tabs-preview {
563
569
  position: sticky;
564
570
  top: 0;
@@ -566,6 +572,7 @@
566
572
  display: flex;
567
573
  flex-direction: column;
568
574
  gap: var(--ui-space-20);
575
+ max-height: 50vh;
569
576
  background: var(--ui-surface-low);
570
577
  /* Bleed the background up through the card's top padding so content
571
578
  scrolling behind doesn't peek between the viewport edge and the
@@ -577,6 +584,20 @@
577
584
  border-radius: var(--ui-radius-md) var(--ui-radius-md) 0 0;
578
585
  }
579
586
 
587
+ .preview-stage {
588
+ display: flex;
589
+ flex-direction: column;
590
+ gap: var(--ui-space-20);
591
+ flex: 1 1 auto;
592
+ min-height: 0;
593
+ overflow: auto;
594
+ }
595
+
596
+ .preview-header,
597
+ .tabs-states-block {
598
+ flex: none;
599
+ }
600
+
580
601
  /* Soft fade at the bottom of the sticky band so property rows scrolling
581
602
  up don't sharply cut against the pinned preview. Greyscale (no accent).
582
603
  Sits below the preview body, above scrolling content. */
@@ -1,7 +1,7 @@
1
1
  import { matchesKind, stripSide } from './aliasKinds';
2
2
  import type { AliasDiskValue, ComponentConfig } from '../themes/themeTypes';
3
3
 
4
- export type AdjustKind = 'radius' | 'padding' | 'gap' | 'border-width';
4
+ export type AdjustKind = 'radius' | 'padding' | 'gap' | 'border-width' | 'divider-width' | 'accent-width';
5
5
 
6
6
  export interface AdjustOp {
7
7
  /** Component id; omitted applies the op to every config. */
@@ -9,11 +9,11 @@ export interface AdjustOp {
9
9
  kind: AdjustKind;
10
10
  set?: string;
11
11
  shift?: number;
12
- /** Radius shifts only: admits `--radius-full` as the ladder's top rung. */
12
+ /** Radius shifts only: admits `--radius-full` as the top step of the scale. */
13
13
  full?: boolean;
14
14
  }
15
15
 
16
- export type SkipReason = 'raw-value' | 'off-ladder' | 'clamped' | 'pill-preserved';
16
+ export type SkipReason = 'raw-value' | 'off-scale' | 'clamped' | 'pill-preserved' | 'none-preserved';
17
17
 
18
18
  export interface AliasChange {
19
19
  variable: string;
@@ -44,7 +44,7 @@ export interface AdjustResult {
44
44
 
45
45
  const RADIUS_FULL = '--radius-full';
46
46
 
47
- const RADIUS_RUNGS = [
47
+ const RADIUS_STEPS = [
48
48
  '--radius-none', '--radius-sm', '--radius-md', '--radius-lg', '--radius-xl',
49
49
  '--radius-2xl', '--radius-3xl', '--radius-4xl',
50
50
  ];
@@ -57,43 +57,56 @@ const SPACE_SETTABLE = [
57
57
  '--space-12', '--space-16', '--space-20', '--space-24', '--space-32', '--space-48',
58
58
  ];
59
59
 
60
- const SPACE_RUNGS = SPACE_SETTABLE;
60
+ const SPACE_STEPS = SPACE_SETTABLE;
61
61
 
62
62
  /** Content insets stop at `--space-4`. Below it the text sits against its own
63
63
  edge, which is a deliberate choice rather than somewhere a relative
64
64
  "tighter" should deposit you. Outer space is exempt: a 2px gap between an
65
65
  icon and its label, or a 2px margin under a bar, is ordinary design. Both
66
- rungs stay pickable by hand and settable by name. */
67
- const INSET_RUNGS = SPACE_SETTABLE.slice(SPACE_SETTABLE.indexOf('--space-4'));
66
+ steps stay pickable by hand and settable by name. */
67
+ const INSET_STEPS = SPACE_SETTABLE.slice(SPACE_SETTABLE.indexOf('--space-4'));
68
68
 
69
- /** Padding that wraps a line of type stops a rung higher. Those components
69
+ /** Padding that wraps a line of type stops a step higher. Those components
70
70
  double it horizontally (`themed-padding($h: 2)`), so `--space-4` is 4px
71
71
  over an 18px line and 8px inside a pill's own corner: squeezed rather than
72
72
  compact. No shipped default puts text below `--space-6`. */
73
- const TEXT_INSET_RUNGS = SPACE_SETTABLE.slice(SPACE_SETTABLE.indexOf('--space-6'));
73
+ const TEXT_INSET_STEPS = SPACE_SETTABLE.slice(SPACE_SETTABLE.indexOf('--space-6'));
74
74
 
75
75
  const SPACE_FAMILY = [...SPACE_SETTABLE, '--space-40', '--space-64', '--space-96', '--space-128'];
76
76
 
77
- const BORDER_WIDTH_RUNGS = [
78
- '--border-width-0', '--border-width-1', '--border-width-2', '--border-width-3',
77
+ const BORDER_WIDTH_NONE = '--border-width-0';
78
+
79
+ const BORDER_WIDTH_SETTABLE = [
80
+ BORDER_WIDTH_NONE, '--border-width-1', '--border-width-2', '--border-width-3',
79
81
  '--border-width-4', '--border-width-5', '--border-width-6', '--border-width-8',
80
82
  '--border-width-10', '--border-width-12', '--border-width-16', '--border-width-20',
81
83
  '--border-width-24',
82
84
  ];
83
85
 
84
- const LADDERS: Record<AdjustKind, { rungs: string[]; settable: string[]; family: string[] }> = {
86
+ /** A line that is not drawn is a decision, so a shift never draws one and
87
+ never erases one. Zero is reached by `set` alone, like `--radius-full`. */
88
+ const STROKE_STEPS = BORDER_WIDTH_SETTABLE.slice(1);
89
+
90
+ /** Three stroke roles ride the one `--border-width-*` scale. A border
91
+ encloses, a divider separates, an accent emphasises, and an intent
92
+ such as "hairline rules" names one role without the others. */
93
+ const STROKE_SCALE = {
94
+ steps: STROKE_STEPS,
95
+ settable: BORDER_WIDTH_SETTABLE,
96
+ family: BORDER_WIDTH_SETTABLE,
97
+ };
98
+
99
+ const SCALES: Record<AdjustKind, { steps: string[]; settable: string[]; family: string[] }> = {
85
100
  radius: {
86
- rungs: RADIUS_RUNGS,
87
- settable: [...RADIUS_RUNGS, RADIUS_FULL],
88
- family: [...RADIUS_RUNGS, RADIUS_FULL],
89
- },
90
- padding: { rungs: SPACE_RUNGS, settable: SPACE_SETTABLE, family: SPACE_FAMILY },
91
- gap: { rungs: SPACE_RUNGS, settable: SPACE_SETTABLE, family: SPACE_FAMILY },
92
- 'border-width': {
93
- rungs: BORDER_WIDTH_RUNGS,
94
- settable: BORDER_WIDTH_RUNGS,
95
- family: BORDER_WIDTH_RUNGS,
101
+ steps: RADIUS_STEPS,
102
+ settable: [...RADIUS_STEPS, RADIUS_FULL],
103
+ family: [...RADIUS_STEPS, RADIUS_FULL],
96
104
  },
105
+ padding: { steps: SPACE_STEPS, settable: SPACE_SETTABLE, family: SPACE_FAMILY },
106
+ gap: { steps: SPACE_STEPS, settable: SPACE_SETTABLE, family: SPACE_FAMILY },
107
+ 'border-width': STROKE_SCALE,
108
+ 'divider-width': STROKE_SCALE,
109
+ 'accent-width': STROKE_SCALE,
97
110
  };
98
111
 
99
112
  const TOKEN_NAME = /^--[a-z0-9-]+$/;
@@ -104,28 +117,28 @@ type Outcome = { from: string; to: string } | { skip: SkipReason };
104
117
  rather than the kind. Which floor depends on what the padding surrounds,
105
118
  and the config says so itself: a variant that also declares a
106
119
  `-text-font-size` is holding type. */
107
- function rungsFor(op: AdjustOp, variable: string, aliases: ComponentConfig['aliases']): string[] {
108
- if (op.kind === 'radius') return op.full ? [...RADIUS_RUNGS, RADIUS_FULL] : RADIUS_RUNGS;
109
- if (op.kind !== 'padding') return LADDERS[op.kind].rungs;
120
+ function stepsFor(op: AdjustOp, variable: string, aliases: ComponentConfig['aliases']): string[] {
121
+ if (op.kind === 'radius') return op.full ? [...RADIUS_STEPS, RADIUS_FULL] : RADIUS_STEPS;
122
+ if (op.kind !== 'padding') return SCALES[op.kind].steps;
110
123
  const base = stripSide(variable);
111
- if (!base.endsWith('-padding')) return LADDERS.padding.rungs;
124
+ if (!base.endsWith('-padding')) return SCALES.padding.steps;
112
125
  const variant = base.slice(0, -'-padding'.length);
113
- return `${variant}-text-font-size` in aliases ? TEXT_INSET_RUNGS : INSET_RUNGS;
126
+ return `${variant}-text-font-size` in aliases ? TEXT_INSET_STEPS : INSET_STEPS;
114
127
  }
115
128
 
116
129
  function spaceStep(token: string): number {
117
130
  return Number(token.slice('--space-'.length));
118
131
  }
119
132
 
120
- /** An off-rung value has no step to count from, so landing on the first rung
133
+ /** An off-scale value has no step to count from, so landing on the first step
121
134
  the shift points at is itself that shift's opening step. Snapping to the
122
- nearest rung instead would spend two visible steps on a one-step request.
123
- Returns -1 when the ladder holds nothing in that direction. */
124
- function snapRung(token: string, rungs: string[], direction: number): number {
135
+ nearest step instead would spend two visible steps on a one-step request.
136
+ Returns -1 when the scale holds nothing in that direction. */
137
+ function snapStep(token: string, steps: string[], direction: number): number {
125
138
  const step = spaceStep(token);
126
- if (direction > 0) return rungs.findIndex((rung) => spaceStep(rung) > step);
127
- for (let index = rungs.length - 1; index >= 0; index--) {
128
- if (spaceStep(rungs[index]) < step) return index;
139
+ if (direction > 0) return steps.findIndex((candidate) => spaceStep(candidate) > step);
140
+ for (let index = steps.length - 1; index >= 0; index--) {
141
+ if (spaceStep(steps[index]) < step) return index;
129
142
  }
130
143
  return -1;
131
144
  }
@@ -137,28 +150,29 @@ function resolve(
137
150
  aliases: ComponentConfig['aliases'],
138
151
  ): Outcome {
139
152
  if (typeof value !== 'string' || !TOKEN_NAME.test(value)) return { skip: 'raw-value' };
140
- if (!LADDERS[op.kind].family.includes(value)) return { skip: 'off-ladder' };
153
+ if (!SCALES[op.kind].family.includes(value)) return { skip: 'off-scale' };
141
154
  if (op.set !== undefined) return { from: value, to: op.set };
142
155
 
143
- const rungs = rungsFor(op, variable, aliases);
144
- let index = rungs.indexOf(value);
156
+ const steps = stepsFor(op, variable, aliases);
157
+ let index = steps.indexOf(value);
145
158
  let shift = op.shift!;
146
159
  if (index < 0) {
147
160
  if (value === RADIUS_FULL) return { skip: 'pill-preserved' };
148
- index = snapRung(value, rungs, shift);
161
+ if (value === BORDER_WIDTH_NONE) return { skip: 'none-preserved' };
162
+ index = snapStep(value, steps, shift);
149
163
  if (index < 0) return { skip: 'clamped' };
150
164
  shift -= Math.sign(shift);
151
165
  }
152
166
 
153
167
  const wanted = index + shift;
154
- const landed = Math.min(Math.max(wanted, 0), rungs.length - 1);
155
- if (landed !== wanted && rungs[landed] === value) return { skip: 'clamped' };
156
- return { from: value, to: rungs[landed] };
168
+ const landed = Math.min(Math.max(wanted, 0), steps.length - 1);
169
+ if (landed !== wanted && steps[landed] === value) return { skip: 'clamped' };
170
+ return { from: value, to: steps[landed] };
157
171
  }
158
172
 
159
173
  function validateOp(op: AdjustOp, configs: Record<string, ComponentConfig>): void {
160
- const ladder = LADDERS[op.kind];
161
- if (!ladder) throw new Error(`Unknown kind "${op.kind}" (expected ${Object.keys(LADDERS).join(', ')})`);
174
+ const scale = SCALES[op.kind];
175
+ if (!scale) throw new Error(`Unknown kind "${op.kind}" (expected ${Object.keys(SCALES).join(', ')})`);
162
176
  if (op.target !== undefined && !(op.target in configs)) {
163
177
  throw new Error(`Unknown target component "${op.target}"`);
164
178
  }
@@ -166,9 +180,9 @@ function validateOp(op: AdjustOp, configs: Record<string, ComponentConfig>): voi
166
180
  const hasSet = op.set !== undefined;
167
181
  const hasShift = op.shift !== undefined;
168
182
  if (hasSet === hasShift) throw new Error(`A ${op.kind} op needs exactly one of "set" or "shift"`);
169
- const settable = ladder.settable;
183
+ const settable = scale.settable;
170
184
  if (hasSet && !settable.includes(op.set!)) {
171
- throw new Error(`"${op.set}" is not on the ${op.kind} ladder`);
185
+ throw new Error(`"${op.set}" is not on the ${op.kind} scale`);
172
186
  }
173
187
  if (hasShift && !Number.isInteger(op.shift)) {
174
188
  throw new Error(`"shift" must be a whole number of steps, got ${op.shift}`);
@@ -2,8 +2,9 @@ export type TokenKind =
2
2
  | 'surface'
3
3
  | 'border'
4
4
  | 'border-width'
5
- | 'radius'
6
5
  | 'divider-width'
6
+ | 'accent-width'
7
+ | 'radius'
7
8
  | 'divider-height'
8
9
  | 'divider-inset'
9
10
  | 'dot-size'
@@ -28,7 +29,7 @@ export type TokenKind =
28
29
  `suffix:` arrays out of this file, which is why they are plain literals.
29
30
 
30
31
  Order matters: `-text` must run before `-border`/`-surface`, and
31
- `-accent-width` before `-accent`, because the first match wins. A variable
32
+ `-accent-width` before `-accent` and `-width`, because the first match wins. A variable
32
33
  matching nothing falls through to `text-color` (a palette picker), but that
33
34
  fall-through is a smell — `check-component` rejects an unrecognised suffix,
34
35
  so add the name here rather than letting it drift. */
@@ -47,7 +48,10 @@ export const KIND_RULES: ReadonlyArray<{
47
48
  '-description', '-hint', '-error', '-placeholder', '-value'],
48
49
  prefix: ['--text-'] },
49
50
  { kind: 'radius', suffix: ['-radius'], prefix: ['--radius-'] },
50
- { kind: 'divider-width', suffix: ['-divider-width', '-divider-thickness'] },
51
+ // Three stroke roles share the `--border-width-*` scale: a border encloses,
52
+ // a divider separates, an accent emphasises. `set-geometry` moves each role
53
+ // on its own, so a suffix has to say which line it names.
54
+ { kind: 'divider-width', suffix: ['-divider-width', '-divider-thickness', '-hairline-thickness', '-thickness'] },
51
55
  { kind: 'divider-height', suffix: ['-divider-height', '-track-height'] },
52
56
  { kind: 'divider-inset', suffix: ['-divider-inset', '-inset'] },
53
57
  { kind: 'dot-size', suffix: ['-dot-size'] },
@@ -58,8 +62,8 @@ export const KIND_RULES: ReadonlyArray<{
58
62
  { kind: 'gap', suffix: ['-gap'] },
59
63
  { kind: 'duration', suffix: ['-duration'], prefix: ['--duration-'] },
60
64
  { kind: 'easing', suffix: ['-easing'], prefix: ['--ease-'] },
61
- { kind: 'border-width', suffix: ['-border-width', '-accent-width', '-hairline-thickness', '-thickness'],
62
- prefix: ['--border-width-'] },
65
+ { kind: 'accent-width', suffix: ['-accent-width', '-indicator-width'] },
66
+ { kind: 'border-width', suffix: ['-border-width'], prefix: ['--border-width-'] },
63
67
  { kind: 'border', suffix: ['-border'], prefix: ['--border-'] },
64
68
  // A dimension with no more specific name behind it — a panel's width, an
65
69
  // avatar's size. Last of the geometry rules, so every `-border-width`,
@@ -48,12 +48,19 @@ export function renderTheme(theme: Theme, defaults: Theme): RenderedTheme {
48
48
  migrateColorsAndTypeFonts(colorsAndType);
49
49
  const state = colorsAndTypeToState(colorsAndType);
50
50
  state.components = {};
51
+ // The stamp is the theme's, never the entry's: `normalizeTheme` strips a
52
+ // per-entry `schemaVersion` on every read, so reading one back yields
53
+ // undefined and replays every migration over already-current data.
51
54
  for (const [comp, config] of Object.entries(defaults.componentConfigs)) {
52
- state.components[comp] = toComponentSlice(comp, config.aliases, config.config, config.schemaVersion);
55
+ state.components[comp] = toComponentSlice(
56
+ comp, config.aliases, config.config, defaults.componentSchemaVersion,
57
+ );
53
58
  }
54
59
  for (const [comp, config] of Object.entries(theme.componentConfigs)) {
55
60
  if (!(comp in state.components)) continue;
56
- state.components[comp] = toComponentSlice(comp, config.aliases, config.config, config.schemaVersion);
61
+ state.components[comp] = toComponentSlice(
62
+ comp, config.aliases, config.config, theme.componentSchemaVersion,
63
+ );
57
64
  }
58
65
  const vars = deriveCssVars(state);
59
66
  return { vars, fontSources: colorsAndType.fontSources ?? [] };
@@ -119,6 +119,15 @@ const PART_SPECS: readonly PartSpec[] = [
119
119
  sel: '.icon-button.outline', fill: 'transparent', stroke: 'var(--iconbutton-outline-border)',
120
120
  radius: 'var(--iconbutton-outline-radius, 0px)',
121
121
  },
122
+ // The dot names its colours `-fill` and `-border-color`, so it supplies
123
+ // both explicitly rather than through a `-surface`/`-border` stem.
124
+ {
125
+ sel: '.radio-dot',
126
+ fill: 'var(--radiobutton-default-dot-fill)',
127
+ stroke: 'var(--radiobutton-default-dot-border-color)',
128
+ },
129
+ { sel: '.save-btn', stem: 'inlineeditactions-save-default' },
130
+ { sel: '.cancel-btn', stem: 'inlineeditactions-cancel-default' },
122
131
 
123
132
  // Chips
124
133
  ...BADGE_VARIANTS.map((v) => ({ sel: `.badge-${v}`, stem: `badge-${v}` })),
@@ -172,6 +181,17 @@ const PART_SPECS: readonly PartSpec[] = [
172
181
  },
173
182
  // The arrow is the tooltip's own ::after, so the box takes the fill only.
174
183
  { sel: '.tooltip', stem: 'tooltip', positioned: true, strokeless: true, unmasked: true },
184
+ // Absolutely positioned inside its own wrapper, and clips the picture to the
185
+ // frame the same way `.image` does.
186
+ { sel: '.image-lightbox-thumb', stem: 'imagelightbox-tile', positioned: true, clips: true },
187
+ // A fixed full-viewport scrim behind the open modal; no border of its own.
188
+ {
189
+ sel: '.image-lightbox-overlay', fill: 'var(--imagelightbox-overlay-surface)',
190
+ stroke: 'transparent', positioned: true, unmasked: true,
191
+ },
192
+ // Close and the two nav chevrons all carry this class; each is fixed and
193
+ // floats over the photo rather than a scrim of its own.
194
+ { sel: '.image-lightbox-chrome', stem: 'imagelightbox-chrome', positioned: true, unmasked: true },
175
195
 
176
196
  // Status blocks
177
197
  ...STATUS_VARIANTS.map((v) => ({ sel: `.callout-${v}`, stem: `callout-${v}` })),
@@ -300,6 +320,8 @@ const STATE_COLOURS: readonly StateSpec[] = [
300
320
  hoverPair('.button.outline', 'button-outline'),
301
321
  ...FILLED_BUTTON_VARIANTS.map((v) => hoverPair(`.icon-button.${v}`, `iconbutton-${v}`)),
302
322
  hoverPair('.icon-button.outline', 'iconbutton-outline'),
323
+ hoverPair('.save-btn', 'inlineeditactions-save'),
324
+ hoverPair('.cancel-btn', 'inlineeditactions-cancel'),
303
325
  hoverPair('.tab', 'tabbar'),
304
326
  // A menu's hover belongs to the item under the pointer. The item is not a
305
327
  // drawn part, so it keeps its own background and lights up on its own.
@@ -327,15 +327,24 @@ export function seedComponentsFromApi(
327
327
  * separately, causing two complete renderer passes and briefly exposing a
328
328
  * mixed old-components/new-tokens state. Build the final state first and emit
329
329
  * it once instead.
330
+ *
331
+ * A payload mixes sources: a working buffer and a shipped default each carry
332
+ * their own stamp, while a config resolved from the open theme has had its
333
+ * stamp stripped by `normalizeTheme` and is covered by the theme-level
334
+ * `componentSchemaVersion`. Defaulting a stripped entry to 0 replays every
335
+ * migration over already-current data.
330
336
  */
331
337
  export function loadThemeFromApi(
332
338
  colorsAndType: ColorsAndType,
333
339
  configs: Record<string, ComponentSeed>,
340
+ themeComponentSchemaVersion: number,
334
341
  ): void {
335
342
  const next = colorsAndTypeToState(colorsAndType);
336
343
  next.components = {};
337
344
  for (const [comp, cfg] of Object.entries(configs)) {
338
- const split = toComponentSlice(comp, cfg.aliases, cfg.config, cfg.schemaVersion ?? 0);
345
+ const split = toComponentSlice(
346
+ comp, cfg.aliases, cfg.config, cfg.schemaVersion ?? themeComponentSchemaVersion,
347
+ );
339
348
  next.components[comp] = { aliases: { ...split.aliases }, config: { ...split.config } };
340
349
  setSavedComponentBaseline(comp, componentBaseline(split));
341
350
  }
@@ -307,7 +307,7 @@ function runContrastGate(
307
307
  const hint = scheme === 'dark'
308
308
  ? `raise the ${label} base color lightness (derived text tops out at 2× its L)`
309
309
  : `raise the ${label} base color lightness or reduce its chroma`;
310
- return `${c.textVar} reaches ${c.ratio.toFixed(2)}:1 vs ${c.against} (floor ${c.floor}:1) — ${hint}`;
310
+ return `${c.textVar} reaches ${c.ratio.toFixed(2)}:1 vs ${c.against} (floor ${c.floor}:1): ${hint}`;
311
311
  });
312
312
 
313
313
  return { scheme, checks, failures };
@@ -371,7 +371,7 @@ function applyCanvasGradient(canvas: PaletteConfig, scheme: SchemeDirection): st
371
371
  ? Math.min(anchor + 2, labels.length - 1)
372
372
  : Math.max(anchor - 2, 0);
373
373
  if (sky === anchor) {
374
- return `skipped — the Canvas base color anchors at the ramp edge (${labels[anchor]}), leaving no room for a sky; commit the canvas further from ${scheme === 'dark' ? 'black' : 'white'}`;
374
+ return `skipped: the Canvas base color anchors at the ramp edge (${labels[anchor]}), leaving no room for a sky; commit the canvas further from ${scheme === 'dark' ? 'black' : 'white'}`;
375
375
  }
376
376
  canvas.emptyMode = 'gradient';
377
377
  canvas.gradientStyle = 'linear';
@@ -382,7 +382,7 @@ function applyCanvasGradient(canvas: PaletteConfig, scheme: SchemeDirection): st
382
382
  { position: 100, paletteLabel: labels[anchor] },
383
383
  ];
384
384
  canvas.gradientSize = 'window';
385
- return `on, ${labels[sky]} → ${labels[anchor]}`;
385
+ return `on, ${labels[sky]} to ${labels[anchor]}`;
386
386
  }
387
387
 
388
388
  /** The tokens.css scale, the starting point when a theme carries no shadows.
@@ -0,0 +1,26 @@
1
+ import { API_BASE } from '../storage/apiBase';
2
+ import { hydrateAppliedTheme } from './themeDocumentSync';
3
+ import type { LiveState } from './themeService';
4
+
5
+ export const LIVE_STATE_EVENT = 'live-state';
6
+
7
+ let source: EventSource | null = null;
8
+
9
+ /**
10
+ * Follow the dev server's live state. A buffer or the active pointer written
11
+ * from outside the page (a CLI verb, a branch switch) arrives as one frame,
12
+ * and this document hydrates from it the way it does from an Apply.
13
+ */
14
+ export function init(): void {
15
+ if (source || typeof EventSource === 'undefined') return;
16
+ source = new EventSource(`${API_BASE}/events`);
17
+ source.addEventListener(LIVE_STATE_EVENT, (event) => {
18
+ const state = JSON.parse((event as MessageEvent<string>).data) as LiveState;
19
+ hydrateAppliedTheme(state.fileName, state);
20
+ });
21
+ }
22
+
23
+ export function __resetForTests(): void {
24
+ source?.close();
25
+ source = null;
26
+ }
@@ -0,0 +1,52 @@
1
+ import type { Migration } from './index';
2
+
3
+ /**
4
+ * Stroke role renames (2026-09-07).
5
+ *
6
+ * Three stroke roles share the `--border-width-*` scale: a border encloses, a
7
+ * divider separates, an accent emphasises. `set-geometry` moves each role on
8
+ * its own, and `aliasKinds` reads the role off the suffix, so a divider that
9
+ * was still named `-border` moved with the borders. These four components
10
+ * renamed their dividers and accents to say which line they are. Values are
11
+ * unchanged, so this is a pure key rename.
12
+ */
13
+ const RENAMES: Record<string, Record<string, string>> = {
14
+ tabbar: Object.fromEntries(
15
+ ['default', 'hover', 'active', 'disabled'].map((s) => [
16
+ `--tabbar-${s}-indicator-border-width`,
17
+ `--tabbar-${s}-indicator-width`,
18
+ ]),
19
+ ),
20
+ collapsiblesection: Object.fromEntries(
21
+ ['default', 'hover'].flatMap((s) => [
22
+ [`--collapsiblesection-divider-${s}-border`, `--collapsiblesection-divider-${s}-hairline-color`],
23
+ [`--collapsiblesection-divider-${s}-border-width`, `--collapsiblesection-divider-${s}-hairline-thickness`],
24
+ ]),
25
+ ),
26
+ dialog: Object.fromEntries(
27
+ ['header', 'footer'].flatMap((part) => [
28
+ [`--dialog-${part}-border`, `--dialog-${part}-divider`],
29
+ [`--dialog-${part}-border-width`, `--dialog-${part}-divider-width`],
30
+ ]),
31
+ ),
32
+ table: {
33
+ '--table-default-header-border': '--table-default-header-divider',
34
+ '--table-default-header-border-width': '--table-default-header-divider-width',
35
+ },
36
+ };
37
+
38
+ export const componentMigration_2026_09_07_strokeRoleRenames: Migration = {
39
+ id: '2026-09-07-stroke-role-renames',
40
+ fromVersion: 26,
41
+ toVersion: 27,
42
+ appliesTo: 'component-config',
43
+ apply(rawVars, meta) {
44
+ const renames = meta.component ? RENAMES[meta.component] : undefined;
45
+ if (!renames) return { ...rawVars };
46
+ const out: Record<string, string> = {};
47
+ for (const [key, value] of Object.entries(rawVars)) {
48
+ out[renames[key] ?? key] = value;
49
+ }
50
+ return out;
51
+ },
52
+ };
@@ -74,6 +74,7 @@ import { componentMigration_2026_09_01_tabbarActiveTint } from './2026-09-01-tab
74
74
  import { componentMigration_2026_09_01_gateSuffixEnabled } from './2026-09-01-gate-suffix-enabled';
75
75
  import { componentMigration_2026_09_02_sectiondividerDropTitleOutline } from './2026-09-02-sectiondivider-drop-title-outline';
76
76
  import { colorsAndTypeMigration_2026_09_03_dropLegacyComponentKeys } from './2026-09-03-drop-legacy-component-keys';
77
+ import { componentMigration_2026_09_07_strokeRoleRenames } from './2026-09-07-stroke-role-renames';
77
78
 
78
79
  /**
79
80
  * Registered migrations. Order in this array does not matter — the runner
@@ -114,6 +115,7 @@ export const MIGRATIONS: Migration[] = [
114
115
  componentMigration_2026_09_01_gateSuffixEnabled,
115
116
  componentMigration_2026_09_02_sectiondividerDropTitleOutline,
116
117
  colorsAndTypeMigration_2026_09_03_dropLegacyComponentKeys,
118
+ componentMigration_2026_09_07_strokeRoleRenames,
117
119
  ];
118
120
 
119
121
  function countFor(kind: 'colors-and-type' | 'component-config'): number {
@@ -3,20 +3,20 @@ import { openThemeSlug } from '../store/editorConfigStore';
3
3
  import { loadThemeFromApi } from '../store/editorStore';
4
4
  import { migrateColorsAndTypeFonts } from '../fonts/fontMigration';
5
5
  import { openThemeSketchSettings } from '../sketch/sketchStore';
6
- import type { ApplyThemeResult } from './themeService';
6
+ import type { LiveState } from './themeService';
7
7
 
8
8
  const CHANNEL_NAME = 'live-tokens:active-theme:v1';
9
9
  export const THEME_APPLIED_EVENT = 'live-tokens:theme-applied';
10
10
 
11
11
  export interface AppliedThemeDetail {
12
12
  fileName: string;
13
- result: ApplyThemeResult;
13
+ result: LiveState;
14
14
  }
15
15
 
16
16
  interface AppliedThemeMessage {
17
17
  type: 'theme-applied';
18
18
  fileName: string;
19
- result: ApplyThemeResult;
19
+ result: LiveState;
20
20
  }
21
21
 
22
22
  let channel: BroadcastChannel | null = null;
@@ -32,19 +32,30 @@ function isAppliedThemeMessage(value: unknown): value is AppliedThemeMessage {
32
32
  && typeof message.result.componentConfigs === 'object';
33
33
  }
34
34
 
35
+ /** A layer answered from a buffer: the live state is past the open theme. */
36
+ function hasUnsavedLayer(state: LiveState): boolean {
37
+ return state.colorsAndType._source === 'working'
38
+ || Object.values(state.componentConfigs).some((cfg) => cfg._source === 'working');
39
+ }
40
+
35
41
  /**
36
- * Replace this document's editor state with the complete state returned by
37
- * the theme apply endpoint. Each same-origin document owns its own Svelte
38
- * store, so writing CSS into another document is not enough: the receiving
39
- * editor must hydrate the typed palette and component layers as well.
42
+ * Replace this document's editor state with the complete live state the
43
+ * server resolved, after an Apply or an outside write. Each same-origin
44
+ * document owns its own Svelte store, so writing CSS into another document is
45
+ * not enough: the receiving editor must hydrate the typed palette and
46
+ * component layers as well.
40
47
  */
41
- export function hydrateAppliedTheme(fileName: string, result: ApplyThemeResult): void {
48
+ export function hydrateAppliedTheme(fileName: string, result: LiveState): void {
42
49
  const colorsAndType = structuredClone(result.colorsAndType);
43
50
  migrateColorsAndTypeFonts(colorsAndType);
44
- loadThemeFromApi(colorsAndType, structuredClone(result.componentConfigs));
51
+ loadThemeFromApi(
52
+ colorsAndType,
53
+ structuredClone(result.componentConfigs),
54
+ result.theme.componentSchemaVersion,
55
+ );
45
56
  openThemeSlug.set(fileName);
46
57
  openThemeSketchSettings(result.theme.sketchSettings);
47
- liveMovedSinceBake.set(false);
58
+ liveMovedSinceBake.set(hasUnsavedLayer(result));
48
59
  if (typeof document !== 'undefined') {
49
60
  document.dispatchEvent(new CustomEvent<AppliedThemeDetail>(THEME_APPLIED_EVENT, {
50
61
  detail: { fileName, result },
@@ -63,6 +74,6 @@ export function init(): void {
63
74
  }
64
75
 
65
76
  /** Notify every already-open editor/host document after the server switches. */
66
- export function broadcastAppliedTheme(fileName: string, result: ApplyThemeResult): void {
77
+ export function broadcastAppliedTheme(fileName: string, result: LiveState): void {
67
78
  channel?.postMessage({ type: 'theme-applied', fileName, result } satisfies AppliedThemeMessage);
68
79
  }
@@ -59,11 +59,18 @@ export function freshName(base: string, taken: Set<string>): string {
59
59
  return `${base}_${Date.now()}`;
60
60
  }
61
61
 
62
- export interface ApplyThemeResult {
63
- ok: boolean;
62
+ /** The whole live state as the server resolves it: the open theme and every
63
+ * layer read through the live doors. An Apply answers with it, and the event
64
+ * stream sends it after an outside write. */
65
+ export interface LiveState {
66
+ fileName: string;
64
67
  theme: Theme;
65
68
  colorsAndType: ColorsAndType;
66
69
  componentConfigs: Record<string, ComponentConfig>;
70
+ }
71
+
72
+ export interface ApplyThemeResult extends LiveState {
73
+ ok: boolean;
67
74
  /** Components the theme carries data for that this install doesn't have. */
68
75
  skippedComponents: string[];
69
76
  /** What the completeness fill (`normalizeTheme`) had to add to reach this