@motion-proto/live-tokens 0.94.3 → 0.95.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 (104) hide show
  1. package/.claude/skills/live-tokens-create-page/SKILL.md +7 -3
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +6 -6
  4. package/dist-plugin/{chunk-7XSUHJUH.js → chunk-4J6JPSVS.js} +53 -2
  5. package/dist-plugin/{chunk-W5VQV32K.js → chunk-GYRULB2X.js} +1 -1
  6. package/dist-plugin/{chunk-ONYAIXCZ.js → chunk-QQASYPW7.js} +32 -9
  7. package/dist-plugin/index.cjs +598 -17
  8. package/dist-plugin/index.d.cts +9 -1
  9. package/dist-plugin/index.d.ts +9 -1
  10. package/dist-plugin/index.js +513 -6
  11. package/dist-plugin/migrateData/index.cjs +32 -9
  12. package/dist-plugin/migrateData/index.js +2 -2
  13. package/dist-plugin/setColors/index.cjs +32 -22
  14. package/dist-plugin/setColors/index.js +2 -15
  15. package/dist-plugin/setGeometry/index.cjs +32 -9
  16. package/dist-plugin/setGeometry/index.js +1 -1
  17. package/dist-plugin/tokensCssMigrations/index.cjs +53 -2
  18. package/dist-plugin/tokensCssMigrations/index.d.cts +9 -2
  19. package/dist-plugin/tokensCssMigrations/index.d.ts +9 -2
  20. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  21. package/package.json +8 -3
  22. package/src/demo/Demo.svelte +39 -5
  23. package/src/demo/DemoBar.svelte +23 -5
  24. package/src/demo/FloatingTokenTags.css +33 -4
  25. package/src/demo/FloatingTokenTags.svelte +81 -68
  26. package/src/demo/Section.svelte +1 -7
  27. package/src/demo/SourceLinks.svelte +5 -4
  28. package/src/demo/TestingLoops.svelte +128 -19
  29. package/src/demo/evalResults.ts +7 -7
  30. package/src/demo/sections/SectionClaude.svelte +20 -13
  31. package/src/demo/sections/SectionFooter.svelte +4 -5
  32. package/src/demo/sections/SectionGetStarted.svelte +5 -6
  33. package/src/demo/sections/SectionHero.svelte +43 -23
  34. package/src/demo/sections/SectionKit.svelte +141 -45
  35. package/src/demo/sections/SectionLayers.svelte +3 -3
  36. package/src/demo/sections/SectionOffering.svelte +8 -23
  37. package/src/editor/bootstrap.ts +12 -6
  38. package/src/editor/component-editor/ImageEditor.svelte +1 -1
  39. package/src/editor/component-editor/ImageLightboxEditor.svelte +3 -4
  40. package/src/editor/component-editor/registration.ts +36 -0
  41. package/src/editor/component-editor/registry.ts +5 -12
  42. package/src/editor/component-editor/scaffolding/ShadowBackdrop.svelte +11 -2
  43. package/src/editor/component-editor/scaffolding/StateBlock.svelte +7 -0
  44. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +1 -2
  45. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +17 -2
  46. package/src/editor/core/store/editorPersistence.ts +6 -1
  47. package/src/editor/core/store/editorRenderer.ts +1 -4
  48. package/src/editor/core/store/editorStore.ts +5 -9
  49. package/src/editor/core/store/editorTypes.ts +2 -2
  50. package/src/editor/core/themes/migrations/2026-10-15-responsive-columns.ts +40 -0
  51. package/src/editor/core/themes/migrations/index.ts +2 -0
  52. package/src/editor/core/themes/slices/columns.ts +112 -42
  53. package/src/editor/docs/Docs.svelte +25 -22
  54. package/src/editor/docs/chapters.ts +1 -0
  55. package/src/editor/docs/content/editing-tokens.md +5 -3
  56. package/src/editor/docs/content/production-css.md +116 -0
  57. package/src/editor/docs/content/themes-workflow.md +2 -0
  58. package/src/editor/docs/content.generated.ts +3 -2
  59. package/src/editor/index.ts +2 -1
  60. package/src/editor/overlay/LiveEditorOverlay.svelte +67 -19
  61. package/src/editor/overlay/LiveTokensRouter.svelte +8 -4
  62. package/src/editor/pages/Editor.svelte +15 -1
  63. package/src/editor/pages/EditorShell.svelte +312 -99
  64. package/src/editor/skill-atlas/SkillAtlas.svelte +111 -24
  65. package/src/editor/skill-atlas/TreeCanvas.svelte +14 -0
  66. package/src/editor/skill-atlas/skillSources.generated.ts +1 -1
  67. package/src/editor/skill-atlas/trees/create-page.ts +24 -24
  68. package/src/editor/skill-atlas/wireLayout.ts +62 -0
  69. package/src/editor/ui/BezierCurveEditor.svelte +3 -0
  70. package/src/editor/ui/FontStackEditor.svelte +2 -2
  71. package/src/editor/ui/PaletteEditor.svelte +64 -6
  72. package/src/editor/ui/UIPaletteSelector.svelte +2 -0
  73. package/src/editor/ui/colors/ColorsTab.svelte +5 -2
  74. package/src/editor/ui/palette/OverridesPanel.svelte +1 -0
  75. package/src/editor/ui/palette/ScaleCurveEditor.svelte +127 -18
  76. package/src/editor/ui/sections/ColumnsSection.svelte +79 -49
  77. package/src/editor/ui/sections/ShadowsSection.svelte +14 -0
  78. package/src/editor/ui/sections/WashesSection.svelte +11 -0
  79. package/src/editor/ui/sketch/SketchTab.svelte +1 -1
  80. package/src/live-tokens/data/colors-and-type/autumn.json +1 -4
  81. package/src/live-tokens/data/colors-and-type/default.json +1 -4
  82. package/src/live-tokens/data/colors-and-type/halloween.json +1 -4
  83. package/src/live-tokens/data/colors-and-type/midnight-study.json +1 -4
  84. package/src/live-tokens/data/colors-and-type/ocean.json +1 -4
  85. package/src/live-tokens/data/colors-and-type/royal-velvet.json +1 -4
  86. package/src/live-tokens/data/colors-and-type/sketchy.json +1 -4
  87. package/src/live-tokens/data/colors-and-type/spring-meadow.json +1 -4
  88. package/src/live-tokens/data/colors-and-type/sunset.json +1 -4
  89. package/src/live-tokens/data/themes/autumn.json +1 -4
  90. package/src/live-tokens/data/themes/halloween.json +1 -4
  91. package/src/live-tokens/data/themes/midnight-study.json +1 -4
  92. package/src/live-tokens/data/themes/ocean.json +1 -4
  93. package/src/live-tokens/data/themes/royal-velvet.json +1 -4
  94. package/src/live-tokens/data/themes/sketchy.json +1 -4
  95. package/src/live-tokens/data/themes/spring-meadow.json +1 -4
  96. package/src/live-tokens/data/themes/sunset.json +1 -4
  97. package/src/live-tokens/data/tokens.generated.css +1 -4
  98. package/src/system/components/SectionDivider.svelte +4 -0
  99. package/src/system/styles/tokens.css +30 -4
  100. package/template/src/main.ts +2 -2
  101. package/template/src/pages/Home.svelte +11 -8
  102. package/template/vite.config.ts +7 -3
  103. package/src/system/assets/offering.webp +0 -0
  104. /package/dist-plugin/{chunk-O6GQ6GBH.js → chunk-A4TBP4MM.js} +0 -0
@@ -17,13 +17,15 @@
17
17
  .offering {
18
18
  grid-column: 1 / -1;
19
19
  display: grid;
20
- grid-template-columns: repeat(var(--columns-count), 1fr);
21
- column-gap: var(--columns-gutter);
20
+ grid-template-columns: subgrid;
22
21
  }
23
22
 
24
23
  .offering-quote {
25
- grid-column: 3 / -3;
24
+ grid-column: var(--kit-narrow);
25
+ justify-self: center;
26
+ max-width: 24em;
26
27
  margin: 0;
28
+ font-size: var(--heading-lg-font-size);
27
29
  }
28
30
 
29
31
  .offering-quote blockquote {
@@ -38,12 +40,13 @@
38
40
 
39
41
  .offering-quote p {
40
42
  font-family: var(--font-serif);
41
- font-size: var(--font-size-3xl);
43
+ font-size: var(--heading-lg-font-size);
42
44
  font-weight: var(--font-weight-normal);
43
45
  color: var(--text-primary);
44
46
  line-height: 1.2;
45
47
  margin: 0;
46
48
  hyphens: none;
49
+ text-wrap: pretty;
47
50
  text-indent: -0.55em;
48
51
  }
49
52
 
@@ -65,29 +68,11 @@
65
68
  margin-top: 0;
66
69
  font-family: var(--font-serif);
67
70
  font-weight: var(--font-weight-normal);
68
- font-size: var(--font-size-3xl);
71
+ font-size: var(--heading-lg-font-size);
69
72
  font-variation-settings: 'opsz' 144, 'SOFT' 100;
70
73
  letter-spacing: 0.01em;
71
74
  color: var(--text-primary);
72
75
  text-align: right;
73
76
  padding-right: 0.4em;
74
77
  }
75
-
76
- @media (max-width: 960px) {
77
- .offering-quote {
78
- grid-column: 2 / -2;
79
- }
80
- .offering-quote p {
81
- font-size: var(--font-size-xl);
82
- }
83
- }
84
-
85
- @media (max-width: 600px) {
86
- .offering-quote {
87
- grid-column: 1 / -1;
88
- }
89
- .offering-quote p {
90
- font-size: var(--font-size-4xl);
91
- }
92
- }
93
78
  </style>
@@ -18,11 +18,8 @@ import { mount, type Component } from 'svelte';
18
18
  import * as cssVarSync from './core/cssVarSync';
19
19
  import * as router from './core/routing/router';
20
20
  import * as columnsOverlay from './overlay/columnsOverlay';
21
- import * as editorStore from './core/store/editorStore';
22
- import * as themeDocumentSync from './core/themes/themeDocumentSync';
23
- import * as liveStateStream from './core/themes/liveStateStream';
24
- import { initializeTheme } from './core/themes/themeInit';
25
- import { registerComponent, type RegisterComponentEntry } from './component-editor/registry';
21
+ import { registerComponent } from './component-editor/registration';
22
+ import type { RegisterComponentEntry } from './component-editor/registry';
26
23
  import { registerSketchStyle, type RegisterSketchStyleInput } from './core/sketch/sketchRegistry';
27
24
 
28
25
  export interface BootLiveTokensOptions {
@@ -43,7 +40,6 @@ export async function bootLiveTokens(
43
40
  cssVarSync.init();
44
41
  router.init();
45
42
  columnsOverlay.init();
46
- editorStore.init();
47
43
 
48
44
  // Before the DEV block: `initializeTheme` recovers the open theme's style by
49
45
  // name against the pool, so the pool has to be whole by the time it runs.
@@ -52,6 +48,16 @@ export async function bootLiveTokens(
52
48
  }
53
49
 
54
50
  if (import.meta.env.DEV) {
51
+ // Imported here, never statically, so a build drops the editor. The
52
+ // registry alone imports every shipped component and editor.
53
+ const [editorStore, themeDocumentSync, liveStateStream, { initializeTheme }] = await Promise.all([
54
+ import('./core/store/editorStore'),
55
+ import('./core/themes/themeDocumentSync'),
56
+ import('./core/themes/liveStateStream'),
57
+ import('./core/themes/themeInit'),
58
+ import('./component-editor/registry'),
59
+ ]);
60
+ editorStore.init();
55
61
  themeDocumentSync.init();
56
62
  liveStateStream.init();
57
63
  if (opts.components) {
@@ -50,7 +50,7 @@
50
50
  import VariantGroup from './scaffolding/VariantGroup.svelte';
51
51
  import ComponentEditorBase from './scaffolding/ComponentEditorBase.svelte';
52
52
  import { buildSiblings } from './scaffolding/siblings';
53
- import demoImageUrl from '../../system/assets/offering.webp';
53
+ import demoImageUrl from '../../system/assets/newspaper.webp';
54
54
  import { editorState, setComponentAlias } from '../core/store/editorStore';
55
55
 
56
56
  let aliases = $derived(
@@ -45,13 +45,12 @@
45
45
  import ImageLightbox from '../../system/components/ImageLightbox.svelte';
46
46
  import VariantGroup from './scaffolding/VariantGroup.svelte';
47
47
  import ComponentEditorBase from './scaffolding/ComponentEditorBase.svelte';
48
- import offeringUrl from '../../system/assets/offering.webp';
49
48
  import newspaperUrl from '../../system/assets/newspaper.webp';
50
49
 
51
50
  const demoImages = [
52
- // Offering carries explicit dimensions (the no-reflow path); Newspaper omits
53
- // them to exercise self-measure from the loaded image.
54
- { src: offeringUrl, alt: 'Offering', width: 1455, height: 970 },
51
+ // The first entry carries explicit dimensions (the no-reflow path); the second
52
+ // omits them to exercise self-measure from the loaded image.
53
+ { src: newspaperUrl, alt: 'Newspaper', width: 2048, height: 1152 },
55
54
  { src: newspaperUrl, alt: 'Newspaper' },
56
55
  ];
57
56
 
@@ -0,0 +1,36 @@
1
+ import type { RegisterComponentEntry } from './registry';
2
+
3
+ /*
4
+ * `registerComponent` lives apart from `registry.ts` so a production build can
5
+ * call it without bundling the registry, which imports every shipped component
6
+ * and editor. Entries wait here until the registry loads, which happens in dev
7
+ * only.
8
+ */
9
+
10
+ type Sink = (entry: RegisterComponentEntry) => void;
11
+
12
+ const pending: RegisterComponentEntry[] = [];
13
+ let sink: Sink | null = null;
14
+
15
+ /**
16
+ * Register a consumer-authored component with the editor. Call from `main.ts`
17
+ * before app mount.
18
+ *
19
+ * Collision rule: if `entry.id` matches a built-in id, a warning is logged and
20
+ * the custom entry wins (the custom editor and schema replace the built-in for
21
+ * the rest of the session).
22
+ *
23
+ * Side effect: registers the schema with the editor store so reset-to-default
24
+ * and sibling-group resolution work for the new component.
25
+ */
26
+ export function registerComponent(entry: RegisterComponentEntry): void {
27
+ if (sink) sink(entry);
28
+ else pending.push(entry);
29
+ }
30
+
31
+ /** Called once by `registry.ts` as it loads. Drains the entries registered
32
+ before it, in call order, and takes every later one directly. */
33
+ export function attachRegistry(next: Sink): void {
34
+ sink = next;
35
+ for (const entry of pending.splice(0)) next(entry);
36
+ }
@@ -1,6 +1,9 @@
1
1
  import type { Component } from 'svelte';
2
2
  import type { CatalogueEntry, Token, IntrinsicSpec } from './scaffolding/types';
3
3
  import { registerComponentSchema } from '../core/store/editorStore';
4
+ import { attachRegistry } from './registration';
5
+
6
+ export { registerComponent } from './registration';
4
7
 
5
8
  import BadgeEditor, { allTokens as badgeTokens } from './BadgeEditor.svelte';
6
9
  import { catalogue as badgeCatalogue } from '../../system/components/Badge.svelte';
@@ -433,18 +436,7 @@ const customRegistry = new Map<string, RegistryEntry>();
433
436
  /** Argument shape for `registerComponent()`. `origin` is set internally to `'custom'`. */
434
437
  export type RegisterComponentEntry = Omit<RegistryEntry, 'origin'>;
435
438
 
436
- /**
437
- * Register a consumer-authored component at runtime. Call from `main.ts`
438
- * before app mount.
439
- *
440
- * Collision rule: if `entry.id` matches a built-in id, a warning is logged and
441
- * the custom entry wins (the custom editor and schema replace the built-in for
442
- * the rest of the session).
443
- *
444
- * Side effect: registers the schema with the editor store so reset-to-default
445
- * and sibling-group resolution work for the new component.
446
- */
447
- export function registerComponent(entry: RegisterComponentEntry): void {
439
+ function addCustomComponent(entry: RegisterComponentEntry): void {
448
440
  if (entry.id in builtInRegistry) {
449
441
  console.warn(
450
442
  `[registerComponent] custom component "${entry.id}" overrides a built-in. The custom editor will be used.`,
@@ -496,6 +488,7 @@ export function getComponentIds(): ReadonlyArray<string> {
496
488
  for (const entry of Object.values(builtInRegistry)) {
497
489
  registerComponentSchema(entry.id, entry.schema);
498
490
  }
491
+ attachRegistry(addCustomComponent);
499
492
 
500
493
  /**
501
494
  * Validate that the server's filesystem scan matches the merged registry's id list.
@@ -83,12 +83,21 @@
83
83
  grid-area: controls;
84
84
  }
85
85
 
86
+ /* Narrow: the preview leads, since it is what the pinned stage is for, and a
87
+ component wider than the stage scrolls inside it. */
86
88
  @container variant-group (max-width: 32rem) {
89
+ /* Holds its height so the pinned stage scrolls to the controls rather than
90
+ squeezing the preview under them. */
87
91
  .shadow-backdrop.with-controls {
92
+ flex-shrink: 0;
88
93
  grid-template-columns: minmax(0, 1fr);
89
94
  grid-template-areas:
90
- "controls"
91
- "preview";
95
+ "preview"
96
+ "controls";
97
+ }
98
+
99
+ .shadow-backdrop-content {
100
+ overflow-x: auto;
92
101
  }
93
102
  }
94
103
  </style>
@@ -268,6 +268,13 @@
268
268
  align-items: stretch;
269
269
  }
270
270
 
271
+ /* Sections stack one per line here, so each heading rule spans the card. */
272
+ @container variant-group (max-width: 32rem) {
273
+ .element-section {
274
+ flex: 1 1 100%;
275
+ }
276
+ }
277
+
271
278
  .element-section .state-type-groups {
272
279
  display: flex;
273
280
  flex-direction: row;
@@ -436,10 +436,9 @@
436
436
 
437
437
  @container variant-group (max-width: 380px) {
438
438
  .token-grid {
439
- grid-template-columns: max-content 1fr;
439
+ grid-template-columns: max-content var(--token-selector-w) minmax(0, 1fr);
440
440
  column-gap: var(--ui-space-6);
441
441
  }
442
- .token-grid :global(.ui-token-selector) { grid-column: 2; }
443
442
  }
444
443
 
445
444
  .zone-divider {
@@ -567,7 +567,7 @@
567
567
  pinned and always leaves the controls a viewport to live in. */
568
568
  .tabs-preview {
569
569
  position: sticky;
570
- top: 0;
570
+ top: var(--editor-sticky-top, 0px);
571
571
  z-index: 2;
572
572
  display: flex;
573
573
  flex-direction: column;
@@ -598,6 +598,20 @@
598
598
  flex: none;
599
599
  }
600
600
 
601
+ /* A capped stage nests a scroller inside the page scroll, which a phone
602
+ handles badly, and an uncapped pinned band covers the controls. The band
603
+ scrolls with the page at full height instead. */
604
+ @media (max-width: 600px) {
605
+ .tabs-preview {
606
+ position: relative;
607
+ max-height: none;
608
+ }
609
+
610
+ .tabs-preview::after {
611
+ display: none;
612
+ }
613
+ }
614
+
601
615
  /* Soft fade at the bottom of the sticky band so property rows scrolling
602
616
  up don't sharply cut against the pinned preview. Greyscale (no accent).
603
617
  Sits below the preview body, above scrolling content. */
@@ -910,8 +924,9 @@
910
924
  /* Direct child of .variant-group; needs its own margin since flex gap doesn't apply across siblings. */
911
925
  .properties-header {
912
926
  display: flex;
927
+ flex-wrap: wrap;
913
928
  align-items: center;
914
- gap: var(--ui-space-16);
929
+ gap: var(--ui-space-8) var(--ui-space-16);
915
930
  margin-top: var(--ui-space-8);
916
931
  margin-bottom: var(--ui-space-8);
917
932
  }
@@ -21,6 +21,7 @@ import { quietGet, quietSet } from '../storage/storage';
21
21
  import { isGradientSlot, makeDefaultGradients } from '../themes/slices/gradients';
22
22
  import { seedShadowsFromDom } from '../themes/slices/shadows';
23
23
  import { makeDefaultWashScale, type WashFamily } from '../themes/slices/washes';
24
+ import { normalizeColumnsState } from '../themes/slices/columns';
24
25
  import { sanitizeHarmonyAxes } from '../palettes/colorHarmony';
25
26
  import {
26
27
  renameBackgroundPaletteKey,
@@ -189,6 +190,10 @@ export function normalizePaletteBasis(state: EditorState): EditorState {
189
190
  return { ...state, palettes: migratePaletteColorsToOklch(raw) };
190
191
  }
191
192
 
193
+ export function normalizeColumns(state: EditorState): EditorState {
194
+ return { ...state, columns: normalizeColumnsState(state.columns) };
195
+ }
196
+
192
197
  export function hydrate(): void {
193
198
  // Corrupt state, missing key, or unavailable storage all return null;
194
199
  // the editor falls through to the empty default in that case.
@@ -200,7 +205,7 @@ export function hydrate(): void {
200
205
  store.set(
201
206
  normalizeHarmonyAxes(
202
207
  normalizeBaseAnchors(
203
- normalizePaletteBasis(normalizePaletteLabels(normalizeComponents(normalizeWashes(migrateGradients(merged))))),
208
+ normalizePaletteBasis(normalizePaletteLabels(normalizeComponents(normalizeColumns(normalizeWashes(migrateGradients(merged)))))),
204
209
  ),
205
210
  ),
206
211
  );
@@ -18,7 +18,6 @@ import { palettesToVars } from '../palettes/paletteDerivation';
18
18
  import {
19
19
  editorState,
20
20
  columnsToVars,
21
- columnsEqualsDefault,
22
21
  washesToVars,
23
22
  shadowsToVars,
24
23
  gradientsToVars,
@@ -27,9 +26,7 @@ import {
27
26
 
28
27
  export function deriveCssVars(state: EditorState): Record<string, string> {
29
28
  const out: Record<string, string> = { ...state.cssVars };
30
- if (!columnsEqualsDefault(state.columns)) {
31
- Object.assign(out, columnsToVars(state.columns));
32
- }
29
+ Object.assign(out, columnsToVars(state.columns));
33
30
  Object.assign(out, washesToVars(state.washes));
34
31
  if (state.shadows.tokens.length > 0) {
35
32
  Object.assign(out, shadowsToVars(state.shadows));
@@ -56,8 +56,7 @@ import {
56
56
  initializeEditorStore,
57
57
  } from './editorPersistence';
58
58
  import {
59
- DEFAULT_COLUMNS,
60
- columnsEqualsDefault,
59
+ defaultColumns,
61
60
  columnsToVars,
62
61
  loadColumnsFromVars,
63
62
  } from '../themes/slices/columns';
@@ -100,7 +99,7 @@ function emptyState(): EditorState {
100
99
  overrides: {},
101
100
  },
102
101
  washes: makeDefaultWashesState(),
103
- columns: { ...DEFAULT_COLUMNS },
102
+ columns: defaultColumns(),
104
103
  components: {},
105
104
  gradients: { tokens: makeDefaultGradients() },
106
105
  harmonyAxes: defaultHarmonyAxes(),
@@ -143,10 +142,9 @@ export {
143
142
 
144
143
  export {
145
144
  columnsToVars,
146
- columnsEqualsDefault,
145
+ columnsFromVars,
147
146
  COLUMN_VAR_NAMES,
148
- DEFAULT_COLUMNS,
149
- parseColumnVars,
147
+ defaultColumns,
150
148
  } from '../themes/slices/columns';
151
149
 
152
150
  export {
@@ -430,9 +428,7 @@ export function loadFromFile(colorsAndType: ColorsAndType): void {
430
428
  */
431
429
  function colorsAndTypeContent(state: EditorState): Omit<ColorsAndType, 'name' | 'createdAt' | 'updatedAt'> {
432
430
  const cssVariables: Record<string, string> = { ...state.cssVars };
433
- if (!columnsEqualsDefault(state.columns)) {
434
- Object.assign(cssVariables, columnsToVars(state.columns));
435
- }
431
+ Object.assign(cssVariables, columnsToVars(state.columns));
436
432
  Object.assign(cssVariables, washesToVars(state.washes));
437
433
  if (state.shadows.tokens.length > 0) {
438
434
  Object.assign(cssVariables, shadowsToVars(state.shadows));
@@ -1,6 +1,7 @@
1
1
  import type { PaletteConfig, FontSource, FontStack } from '../themes/themeTypes';
2
2
  import type { GradientStop, GradientValue, LinearDirection } from '../themes/parsers/gradient';
3
3
  import type { HarmonyAxis } from '../palettes/colorHarmony';
4
+ import type { ColumnViewport, ColumnViewportValues } from '../themes/slices/columns';
4
5
 
5
6
  export interface ShadowGlobals {
6
7
  angle: number;
@@ -40,8 +41,7 @@ export interface WashScale {
40
41
  export interface ColumnsState {
41
42
  count: number;
42
43
  maxWidth: number;
43
- gutter: number;
44
- margin: number;
44
+ viewports: Record<ColumnViewport, ColumnViewportValues>;
45
45
  }
46
46
 
47
47
  /** Gradient render mode.
@@ -0,0 +1,40 @@
1
+ import type { Migration } from './index';
2
+
3
+ /**
4
+ * The column grid's stored gutter and margin move to their desktop names
5
+ * (2026-10-15).
6
+ *
7
+ * tokens.css points `--columns-gutter` and `--columns-margin` at a
8
+ * per-viewport value for the window's width. A theme that stores either
9
+ * overrides every breakpoint, so each moves to its desktop name with its
10
+ * value. `--columns-count` stays one value at every width and keeps its name.
11
+ *
12
+ * A stored value equal to the one every shipped theme held (`12`, `32px`,
13
+ * `0px` or `0`) counts as unset and is dropped, so the theme inherits the
14
+ * tokens.css default. The theme stores no tablet or phone value. The names
15
+ * and values freeze those of 2026-10-15.
16
+ */
17
+ const RENAMES: Record<string, string> = {
18
+ '--columns-gutter': '--columns-desktop-gutter',
19
+ '--columns-margin': '--columns-desktop-margin',
20
+ };
21
+
22
+ const SHIPPED: Record<string, readonly string[]> = {
23
+ '--columns-count': ['12'],
24
+ '--columns-gutter': ['32px'],
25
+ '--columns-margin': ['0px', '0'],
26
+ };
27
+
28
+ export const colorsAndTypeMigration_2026_10_15_responsiveColumns: Migration = {
29
+ id: '2026-10-15-responsive-columns',
30
+ fromVersion: 9,
31
+ toVersion: 10,
32
+ appliesTo: 'colors-and-type',
33
+ apply(rawVars) {
34
+ return Object.fromEntries(
35
+ Object.entries(rawVars)
36
+ .filter(([name, value]) => !SHIPPED[name]?.includes(value.trim()))
37
+ .map(([name, value]) => [RENAMES[name] ?? name, value]),
38
+ );
39
+ },
40
+ };
@@ -82,6 +82,7 @@ import { componentMigration_2026_09_13_sectiondividerSurface } from './2026-09-1
82
82
  import { componentMigration_2026_09_20_imagelightboxScrim } from './2026-09-20-imagelightbox-scrim';
83
83
  import { componentMigration_2026_09_20_scrimColorAndOpacity } from './2026-09-20-scrim-color-and-opacity';
84
84
  import { colorsAndTypeMigration_2026_09_21_washColorAndOpacity } from './2026-09-21-wash-color-and-opacity';
85
+ import { colorsAndTypeMigration_2026_10_15_responsiveColumns } from './2026-10-15-responsive-columns';
85
86
  import { componentMigration_2026_09_21_scrimSurface } from './2026-09-21-scrim-surface';
86
87
  import { componentMigration_2026_09_13_cornerbadgePrefix } from './2026-09-13-cornerbadge-prefix';
87
88
  import { componentMigration_2026_09_13_toggleLabel } from './2026-09-13-toggle-label';
@@ -140,6 +141,7 @@ export const MIGRATIONS: Migration[] = [
140
141
  componentMigration_2026_09_20_scrimColorAndOpacity,
141
142
  colorsAndTypeMigration_2026_09_21_washColorAndOpacity,
142
143
  componentMigration_2026_09_21_scrimSurface,
144
+ colorsAndTypeMigration_2026_10_15_responsiveColumns,
143
145
  ];
144
146
 
145
147
  function countFor(kind: 'colors-and-type' | 'component-config'): number {
@@ -1,59 +1,129 @@
1
1
  /**
2
- * Columns slice — fixed-shape (count/maxWidth/gutter/margin) state, derived to
3
- * four CSS vars. Values default to a 12-column 1440px grid; while state matches
4
- * the default we leave tokens.css in charge so the `clamp()` in
5
- * `--columns-gutter` survives until the editor overrides it.
2
+ * Columns slice. The column grid holds one count and one max width, and a
3
+ * gutter and a margin per viewport. `--columns-gutter` and `--columns-margin`
4
+ * are structural: tokens.css points them at the viewport values for the
5
+ * window's width, and the editor never writes them.
6
6
  */
7
- import type { ColumnsState } from '../../store/editorTypes';
7
+ import type { ColumnsState, EditorState } from '../../store/editorTypes';
8
8
 
9
- export const DEFAULT_COLUMNS: ColumnsState = { count: 12, maxWidth: 1440, gutter: 16, margin: 0 };
9
+ export const COLUMN_VIEWPORTS = ['desktop', 'tablet', 'phone'] as const;
10
+ export type ColumnViewport = (typeof COLUMN_VIEWPORTS)[number];
10
11
 
11
- export const COLUMN_VAR_NAMES = ['--columns-count', '--columns-max-width', '--columns-gutter', '--columns-margin'] as const;
12
+ export interface ColumnViewportValues {
13
+ gutter: number;
14
+ margin: number;
15
+ }
16
+ export type ColumnProperty = keyof ColumnViewportValues;
17
+ export const COLUMN_PROPERTIES: readonly ColumnProperty[] = ['gutter', 'margin'];
12
18
 
13
- export function columnsToVars(c: ColumnsState): Record<string, string> {
14
- return {
15
- '--columns-count': String(c.count),
16
- '--columns-max-width': `${c.maxWidth}px`,
17
- '--columns-gutter': `${c.gutter}px`,
18
- '--columns-margin': `${c.margin}px`,
19
- };
19
+ /** The widest window each narrower viewport covers, as tokens.css's `@media (max-width)` blocks declare it. */
20
+ export const COLUMN_BREAKPOINTS: Record<Exclude<ColumnViewport, 'desktop'>, number> = {
21
+ tablet: 767,
22
+ phone: 480,
23
+ };
24
+
25
+ export const COLUMN_COUNT_NAME = '--columns-count';
26
+ export const COLUMN_MAX_WIDTH_NAME = '--columns-max-width';
27
+
28
+ export function columnVarName(viewport: ColumnViewport, property: ColumnProperty): string {
29
+ return `--columns-${viewport}-${property}`;
20
30
  }
21
31
 
22
- /**
23
- * Only emit column CSS vars once the user has actually modified columns.
24
- * While columns match the default, we leave tokens.css in charge — which
25
- * preserves the `clamp()` in `--columns-gutter` until the editor overrides it.
26
- */
27
- export function columnsEqualsDefault(c: ColumnsState): boolean {
28
- return c.count === DEFAULT_COLUMNS.count
29
- && c.maxWidth === DEFAULT_COLUMNS.maxWidth
30
- && c.gutter === DEFAULT_COLUMNS.gutter
31
- && c.margin === DEFAULT_COLUMNS.margin;
32
- }
33
-
34
- export function parseColumnVars(vars: Record<string, string>): Partial<ColumnsState> {
35
- const out: Partial<ColumnsState> = {};
36
- const count = parseInt(vars['--columns-count'] ?? '', 10);
37
- if (Number.isFinite(count) && count > 0) out.count = count;
38
- const maxWidth = parseFloat(vars['--columns-max-width'] ?? '');
39
- if (Number.isFinite(maxWidth)) out.maxWidth = Math.round(maxWidth);
40
- const gutter = parseFloat(vars['--columns-gutter'] ?? '');
41
- if (Number.isFinite(gutter)) out.gutter = Math.round(gutter);
42
- const margin = parseFloat(vars['--columns-margin'] ?? '');
43
- if (Number.isFinite(margin)) out.margin = Math.round(margin);
32
+ export const VIEWPORT_COLUMN_NAMES: readonly string[] = COLUMN_VIEWPORTS.flatMap((viewport) =>
33
+ COLUMN_PROPERTIES.map((property) => columnVarName(viewport, property)),
34
+ );
35
+
36
+ export const STRUCTURAL_COLUMN_NAMES = ['--columns-gutter', '--columns-margin'] as const;
37
+
38
+ export const COLUMN_VAR_NAMES: readonly string[] = [COLUMN_COUNT_NAME, COLUMN_MAX_WIDTH_NAME, ...VIEWPORT_COLUMN_NAMES];
39
+
40
+ export const COLUMN_DEFAULTS: Readonly<ColumnsState> = {
41
+ count: 12,
42
+ maxWidth: 1440,
43
+ viewports: {
44
+ desktop: { gutter: 32, margin: 32 },
45
+ tablet: { gutter: 24, margin: 24 },
46
+ phone: { gutter: 16, margin: 16 },
47
+ },
48
+ };
49
+
50
+ export function defaultColumns(): ColumnsState {
51
+ return structuredClone(COLUMN_DEFAULTS);
52
+ }
53
+
54
+ /** Each name appears only when its value differs from the tokens.css default. */
55
+ export function columnsToVars(c: ColumnsState): Record<string, string> {
56
+ const out: Record<string, string> = {};
57
+ if (c.count !== COLUMN_DEFAULTS.count) out[COLUMN_COUNT_NAME] = String(c.count);
58
+ if (c.maxWidth !== COLUMN_DEFAULTS.maxWidth) out[COLUMN_MAX_WIDTH_NAME] = `${c.maxWidth}px`;
59
+ for (const viewport of COLUMN_VIEWPORTS) {
60
+ for (const property of COLUMN_PROPERTIES) {
61
+ const value = c.viewports[viewport][property];
62
+ if (value !== COLUMN_DEFAULTS.viewports[viewport][property]) {
63
+ out[columnVarName(viewport, property)] = `${value}px`;
64
+ }
65
+ }
66
+ }
44
67
  return out;
45
68
  }
46
69
 
70
+ function parseLength(raw: string | undefined): number | undefined {
71
+ const length = parseFloat(raw ?? '');
72
+ return Number.isFinite(length) ? Math.round(length) : undefined;
73
+ }
74
+
75
+ /** A name the bag lacks, or holds in a form the editor cannot edit, keeps its default. */
76
+ export function columnsFromVars(vars: Record<string, string>): ColumnsState {
77
+ const columns = defaultColumns();
78
+ const count = parseInt(vars[COLUMN_COUNT_NAME] ?? '', 10);
79
+ if (Number.isFinite(count) && count > 0) columns.count = count;
80
+ const maxWidth = parseLength(vars[COLUMN_MAX_WIDTH_NAME]);
81
+ if (maxWidth !== undefined) columns.maxWidth = maxWidth;
82
+ for (const viewport of COLUMN_VIEWPORTS) {
83
+ for (const property of COLUMN_PROPERTIES) {
84
+ const value = parseLength(vars[columnVarName(viewport, property)]);
85
+ if (value !== undefined) columns.viewports[viewport][property] = value;
86
+ }
87
+ }
88
+ return columns;
89
+ }
90
+
47
91
  /**
48
92
  * Loader: route the relevant entries from a freshly-loaded theme's vars bag
49
93
  * into `next.columns` and remove them from the bag so derivation stays
50
94
  * single-source. Mutates `next` and `rawVars` in place.
51
95
  */
52
- export function loadColumnsFromVars(
53
- next: import('../../store/editorTypes').EditorState,
54
- rawVars: Record<string, string>,
55
- ): void {
56
- const overrides = parseColumnVars(rawVars);
57
- next.columns = { ...DEFAULT_COLUMNS, ...overrides };
96
+ export function loadColumnsFromVars(next: EditorState, rawVars: Record<string, string>): void {
97
+ next.columns = columnsFromVars(rawVars);
58
98
  for (const name of COLUMN_VAR_NAMES) delete rawVars[name];
59
99
  }
100
+
101
+ interface FlatColumnsState {
102
+ count: number;
103
+ maxWidth: number;
104
+ gutter: number;
105
+ margin: number;
106
+ }
107
+
108
+ // The flat state's old defaults rendered nothing, so a session sitting on them
109
+ // takes today's defaults instead of pinning a 16px desktop gutter.
110
+ const FLAT_DEFAULTS: FlatColumnsState = { count: 12, maxWidth: 1440, gutter: 16, margin: 0 };
111
+
112
+ /**
113
+ * A session persisted before the per-viewport state holds one flat set, which
114
+ * the renderer wrote to the names that are now the count, the max width and
115
+ * the desktop gutter and margin. Its `0` margin is the one every shipped theme
116
+ * held, so it takes today's default as a stored theme's does.
117
+ */
118
+ export function normalizeColumnsState(raw: ColumnsState | FlatColumnsState): ColumnsState {
119
+ if ('viewports' in raw) return raw;
120
+ const columns = defaultColumns();
121
+ const isFlatDefault = (Object.keys(FLAT_DEFAULTS) as (keyof FlatColumnsState)[])
122
+ .every((key) => raw[key] === FLAT_DEFAULTS[key]);
123
+ if (isFlatDefault) return columns;
124
+ columns.count = raw.count;
125
+ columns.maxWidth = raw.maxWidth;
126
+ columns.viewports.desktop.gutter = raw.gutter;
127
+ if (raw.margin !== FLAT_DEFAULTS.margin) columns.viewports.desktop.margin = raw.margin;
128
+ return columns;
129
+ }