@motion-proto/live-tokens 0.94.2 → 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 (108) hide show
  1. package/.claude/skills/live-tokens-create-page/SKILL.md +7 -3
  2. package/CHANGELOG.md +108 -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/ChromeSelect.svelte +111 -0
  23. package/src/demo/Demo.svelte +39 -5
  24. package/src/demo/DemoBar.svelte +33 -14
  25. package/src/demo/FloatingTokenTags.css +33 -4
  26. package/src/demo/FloatingTokenTags.svelte +81 -68
  27. package/src/demo/Section.svelte +1 -7
  28. package/src/demo/SketchSelect.svelte +36 -12
  29. package/src/demo/SourceLinks.svelte +5 -4
  30. package/src/demo/TestingLoops.svelte +128 -19
  31. package/src/demo/ThemeSelect.svelte +34 -11
  32. package/src/demo/evalResults.ts +7 -7
  33. package/src/demo/sections/SectionClaude.svelte +20 -13
  34. package/src/demo/sections/SectionFooter.svelte +4 -5
  35. package/src/demo/sections/SectionGetStarted.svelte +5 -6
  36. package/src/demo/sections/SectionHero.svelte +43 -23
  37. package/src/demo/sections/SectionKit.svelte +141 -45
  38. package/src/demo/sections/SectionLayers.svelte +3 -3
  39. package/src/demo/sections/SectionOffering.svelte +8 -23
  40. package/src/editor/bootstrap.ts +12 -6
  41. package/src/editor/component-editor/ImageEditor.svelte +1 -1
  42. package/src/editor/component-editor/ImageLightboxEditor.svelte +3 -4
  43. package/src/editor/component-editor/registration.ts +36 -0
  44. package/src/editor/component-editor/registry.ts +5 -12
  45. package/src/editor/component-editor/scaffolding/ShadowBackdrop.svelte +11 -2
  46. package/src/editor/component-editor/scaffolding/StateBlock.svelte +7 -0
  47. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +1 -2
  48. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +17 -2
  49. package/src/editor/core/store/editorPersistence.ts +6 -1
  50. package/src/editor/core/store/editorRenderer.ts +1 -4
  51. package/src/editor/core/store/editorStore.ts +5 -9
  52. package/src/editor/core/store/editorTypes.ts +2 -2
  53. package/src/editor/core/themes/migrations/2026-10-15-responsive-columns.ts +40 -0
  54. package/src/editor/core/themes/migrations/index.ts +2 -0
  55. package/src/editor/core/themes/slices/columns.ts +112 -42
  56. package/src/editor/docs/Docs.svelte +25 -22
  57. package/src/editor/docs/chapters.ts +1 -0
  58. package/src/editor/docs/content/editing-tokens.md +5 -3
  59. package/src/editor/docs/content/production-css.md +116 -0
  60. package/src/editor/docs/content/themes-workflow.md +2 -0
  61. package/src/editor/docs/content.generated.ts +3 -2
  62. package/src/editor/index.ts +2 -1
  63. package/src/editor/overlay/LiveEditorOverlay.svelte +67 -19
  64. package/src/editor/overlay/LiveTokensRouter.svelte +8 -4
  65. package/src/editor/pages/Editor.svelte +15 -1
  66. package/src/editor/pages/EditorShell.svelte +312 -99
  67. package/src/editor/skill-atlas/SkillAtlas.svelte +111 -24
  68. package/src/editor/skill-atlas/TreeCanvas.svelte +14 -0
  69. package/src/editor/skill-atlas/skillSources.generated.ts +1 -1
  70. package/src/editor/skill-atlas/trees/create-page.ts +24 -24
  71. package/src/editor/skill-atlas/wireLayout.ts +62 -0
  72. package/src/editor/ui/BezierCurveEditor.svelte +3 -0
  73. package/src/editor/ui/FontStackEditor.svelte +2 -2
  74. package/src/editor/ui/PaletteEditor.svelte +64 -6
  75. package/src/editor/ui/UIPaletteSelector.svelte +2 -0
  76. package/src/editor/ui/colors/ColorsTab.svelte +5 -2
  77. package/src/editor/ui/palette/OverridesPanel.svelte +1 -0
  78. package/src/editor/ui/palette/ScaleCurveEditor.svelte +127 -18
  79. package/src/editor/ui/sections/ColumnsSection.svelte +79 -49
  80. package/src/editor/ui/sections/ShadowsSection.svelte +14 -0
  81. package/src/editor/ui/sections/WashesSection.svelte +11 -0
  82. package/src/editor/ui/sketch/SketchTab.svelte +1 -1
  83. package/src/live-tokens/data/colors-and-type/autumn.json +1 -4
  84. package/src/live-tokens/data/colors-and-type/default.json +1 -4
  85. package/src/live-tokens/data/colors-and-type/halloween.json +1 -4
  86. package/src/live-tokens/data/colors-and-type/midnight-study.json +1 -4
  87. package/src/live-tokens/data/colors-and-type/ocean.json +1 -4
  88. package/src/live-tokens/data/colors-and-type/royal-velvet.json +1 -4
  89. package/src/live-tokens/data/colors-and-type/sketchy.json +1 -4
  90. package/src/live-tokens/data/colors-and-type/spring-meadow.json +1 -4
  91. package/src/live-tokens/data/colors-and-type/sunset.json +1 -4
  92. package/src/live-tokens/data/themes/autumn.json +1 -4
  93. package/src/live-tokens/data/themes/halloween.json +1 -4
  94. package/src/live-tokens/data/themes/midnight-study.json +1 -4
  95. package/src/live-tokens/data/themes/ocean.json +1 -4
  96. package/src/live-tokens/data/themes/royal-velvet.json +1 -4
  97. package/src/live-tokens/data/themes/sketchy.json +1 -4
  98. package/src/live-tokens/data/themes/spring-meadow.json +1 -4
  99. package/src/live-tokens/data/themes/sunset.json +1 -4
  100. package/src/live-tokens/data/tokens.generated.css +1 -4
  101. package/src/system/components/SectionDivider.svelte +4 -0
  102. package/src/system/styles/CONVENTIONS.md +1 -1
  103. package/src/system/styles/tokens.css +30 -4
  104. package/template/src/main.ts +2 -2
  105. package/template/src/pages/Home.svelte +11 -8
  106. package/template/vite.config.ts +7 -3
  107. package/src/system/assets/offering.webp +0 -0
  108. /package/dist-plugin/{chunk-O6GQ6GBH.js → chunk-A4TBP4MM.js} +0 -0
@@ -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
+ }
@@ -94,16 +94,7 @@
94
94
  {
95
95
  path: 'guide',
96
96
  title: 'Guide',
97
- items: [
98
- { path: '01-overview', title: 'Overview' },
99
- { path: 'getting-started', title: 'Getting started' },
100
- { path: 'editing-tokens', title: 'Editing tokens' },
101
- { path: 'sketch-mode', title: 'Sketch mode' },
102
- { path: 'themes-workflow', title: 'Themes' },
103
- { path: 'where-themes-live', title: 'Where themes live' },
104
- { path: 'light-and-dark', title: 'Light and dark' },
105
- { path: 'creating-components', title: 'Creating components' },
106
- ],
97
+ items: chapters.map((c) => ({ path: c.id, title: c.title })),
107
98
  },
108
99
  ];
109
100
 
@@ -368,18 +359,27 @@
368
359
  color: var(--text-primary);
369
360
  min-height: 100vh;
370
361
  }
362
+ /* The open rail spans three columns of the page grid and the closed rail one.
363
+ A percentage resolves against the shell's content box, and the rail's own
364
+ width tokens resolve 100cqw against the same box, so track and rail tween
365
+ together. The rail's width excludes its border-right. */
371
366
  .docs-shell {
367
+ --docs-column: calc((100% - (var(--columns-count) - 1) * var(--columns-gutter)) / var(--columns-count));
368
+ --docs-rail-column: calc((100cqw - (var(--columns-count) - 1) * var(--columns-gutter)) / var(--columns-count));
369
+ --sidenavigation-panel-open-width: calc(3 * var(--docs-rail-column) + 2 * var(--columns-gutter) - var(--sidenavigation-panel-border-width));
370
+ --sidenavigation-panel-closed-width: calc(var(--docs-rail-column) - var(--sidenavigation-panel-border-width));
371
+ container-type: inline-size;
372
372
  display: grid;
373
- grid-template-columns: var(--sidenavigation-panel-open-width, 16rem) minmax(0, 1fr);
373
+ grid-template-columns: calc(3 * var(--docs-column) + 2 * var(--columns-gutter)) minmax(0, 1fr);
374
+ column-gap: var(--columns-gutter);
375
+ box-sizing: content-box;
374
376
  max-width: var(--columns-max-width, 1440px);
375
377
  margin: 0 auto;
376
- padding: 0 var(--space-32, 2rem);
378
+ padding-inline: var(--columns-margin);
377
379
  transition: grid-template-columns var(--duration-200, 200ms) var(--ease-in-out-sine, ease);
378
380
  }
379
- /* The rail's own width tween (its width tokens) drives the column; keep the
380
- grid track locked to the closed width so content reflows in step. */
381
381
  .docs-shell.collapsed {
382
- grid-template-columns: var(--sidenavigation-panel-closed-width, 3rem) minmax(0, 1fr);
382
+ grid-template-columns: var(--docs-column) minmax(0, 1fr);
383
383
  }
384
384
 
385
385
  .docs-main {
@@ -390,7 +390,7 @@
390
390
  /* -------------------------------------------------------------- Top header */
391
391
  .docs-page-header {
392
392
  border-bottom: var(--border-width-1, 1px) solid var(--border-neutral-faint, #1c2327);
393
- padding: var(--space-48, 3rem) 0 var(--space-32, 2rem) var(--space-32, 2rem);
393
+ padding: var(--space-48, 3rem) 0 var(--space-32, 2rem);
394
394
  background: linear-gradient(
395
395
  180deg,
396
396
  color-mix(in srgb, var(--surface-neutral-lower, #162027) 50%, transparent),
@@ -471,7 +471,7 @@
471
471
  min-width: 0;
472
472
  max-width: 760px;
473
473
  outline: none;
474
- padding: var(--space-32, 2rem) 0 0 var(--space-32, 2rem);
474
+ padding: var(--space-32, 2rem) 0 0;
475
475
  }
476
476
 
477
477
  .loading-row {
@@ -664,12 +664,15 @@
664
664
  .docs-shell :global(.docs-sidebar) { height: 100%; }
665
665
  }
666
666
 
667
- /* Below 960px the rail stacks above the content; the page scrolls naturally. */
667
+ /* Below 960px the open rail stacks full width above the content; the page
668
+ scrolls naturally. */
668
669
  @media (max-width: 960px) {
670
+ .docs-shell { --sidenavigation-panel-open-width: calc(100cqw - var(--sidenavigation-panel-border-width)); }
669
671
  .docs-shell,
670
- .docs-shell.collapsed { grid-template-columns: 1fr; }
671
- }
672
- @media (max-width: 600px) {
673
- .docs-shell { padding: 0 var(--space-16, 1rem); }
672
+ .docs-shell.collapsed {
673
+ grid-template-columns: repeat(var(--columns-count), minmax(0, 1fr));
674
+ }
675
+ .docs-shell > :global(.docs-sidebar),
676
+ .docs-main { grid-column: 1 / -1; }
674
677
  }
675
678
  </style>
@@ -18,6 +18,7 @@ export const chapters: Chapter[] = [
18
18
  { id: 'where-themes-live', title: 'Where themes live' },
19
19
  { id: 'light-and-dark', title: 'Light and dark' },
20
20
  { id: 'creating-components', title: 'Creating components' },
21
+ { id: 'production-css', title: 'Production CSS' },
21
22
  ];
22
23
 
23
24
  export const chapterIds = chapters.map((c) => c.id);
@@ -68,9 +68,11 @@ Change a step and every element using it repaints.
68
68
 
69
69
  ## Columns
70
70
 
71
- The page-grid overlay. Set column count, gutter, and outer margin, and toggle
72
- the visual guide with `Cmd/Ctrl+G`. Pages built on the column system reflow
73
- live.
71
+ Pages lay out on the column grid. The column count and the max width hold at
72
+ every width. Desktop, Tablet, and Phone each set a gutter and an outer margin,
73
+ and the grid takes the tablet values at 767px and narrower and the phone values
74
+ at 480px and narrower. The columns button in the editor bar draws the grid over
75
+ the page, and a page on the grid reflows as you edit.
74
76
 
75
77
  ## Saving
76
78
 
@@ -0,0 +1,116 @@
1
+ # Production CSS
2
+
3
+ In dev, every design token sits on `:root` as a CSS variable, because the
4
+ editor can rewrite any of them while the page runs. A production build runs no
5
+ editor, so most of those values never change. `vite build` rewrites the CSS it
6
+ emits to match: a token stays a variable only where its value can change at
7
+ runtime, and every other `var()` gives way to the value it reads.
8
+
9
+ The dev server keeps every variable. Your source files keep every token too:
10
+ the build rewrites only the files it emits, so `tokens.css`,
11
+ `tokens.generated.css` and your components still name each token, and the
12
+ token names stay the API you write against.
13
+
14
+ ## The build step
15
+
16
+ `themeFileApi` runs the step at the end of every `vite build`, after Vite has
17
+ bundled and minified the CSS. It reads every stylesheet and script in the
18
+ bundle, including lazy chunks, and then:
19
+
20
+ - **removes** each custom property that no rule reads, directly or through
21
+ another property;
22
+ - **substitutes** each `var()` that reads a static property with the text of
23
+ that property's value, so `calc()` and `color-mix()` still resolve on the
24
+ element that uses them;
25
+ - **declares** each remaining property once on `:root`, with the value that
26
+ wins the cascade, so DevTools lists it once.
27
+
28
+ The build prints one line with the result:
29
+
30
+ ```
31
+ live-tokens: the production CSS declares 76 of 848 custom properties and is 61.3 kB smaller.
32
+ ```
33
+
34
+ A file's hashed name follows its content. Each stylesheet the build rewrites
35
+ takes a new hash, and so does each script that loads it, so a cache that keeps
36
+ hashed files forever serves each release its own files. A file name pattern
37
+ without `[hash]` keeps every name.
38
+
39
+ ## The dynamic rule
40
+
41
+ A property stays a variable when its value can change while the page runs, or
42
+ when the build cannot prove it never does. A property that changes and loses
43
+ its variable breaks a page, and an extra variable costs a few bytes, so a case
44
+ that could fall either way stays a variable. A property is **dynamic** when any
45
+ of these hold:
46
+
47
+ - A rule declares it under a selector besides a root selector such as `:root`
48
+ or `:root:root`, or inside `@media`, `@supports` or `@container`. The size
49
+ tokens and the column gutter and margin in the three responsive `@media`
50
+ blocks of `tokens.css` stay variables this way.
51
+ - Its name appears in a script or HTML page the build emits. That covers
52
+ `style:--name` directives, `setProperty`, `getPropertyValue` and inline
53
+ `style` attributes. Card sets `--card-hover-border-enabled` through a
54
+ `style:` directive, so the token stays a variable.
55
+ - Its name appears in an at-rule's prelude, such as a `style()` container
56
+ query.
57
+ - An `@property` rule registers it.
58
+ - The `keep` option lists it.
59
+ - Its value reads a dynamic property.
60
+ - Its winning value is a CSS-wide keyword such as `initial`, or it reads
61
+ itself through a loop of `var()` references.
62
+ - Two stylesheets give it different `:root` values.
63
+ - Its value reads a name that no stylesheet declares and no script or `keep`
64
+ entry names.
65
+ - Only a lazily loaded stylesheet declares it, and another stylesheet reads
66
+ it.
67
+
68
+ Every other property is **static**: one value on `:root` for the life of the
69
+ page.
70
+
71
+ ## Computed names
72
+
73
+ The build finds a name in a script only when the script spells it out. A
74
+ script that assembles a name while the page runs, such as
75
+ `` `--color-${family}-500` ``, hides it. The build then substitutes or removes
76
+ that token, so a read returns an empty string and a write changes nothing. List
77
+ each such name under `keep`:
78
+
79
+ ```ts
80
+ themeFileApi({
81
+ tokensCssPath: 'src/system/styles/tokens.css',
82
+ productionCss: { keep: ['--color-brand-500', '--color-accent-500'] },
83
+ }),
84
+ ```
85
+
86
+ `keep` takes exact names. The same goes for a stylesheet outside the bundle,
87
+ such as one in `public/`: the build never reads it, so list each token it
88
+ reads under `keep`.
89
+
90
+ ## Options
91
+
92
+ `productionCss` on `themeFileApi` sets the step:
93
+
94
+ - **Default.** The build removes, substitutes and declares, as above.
95
+ - **`productionCss: { keep: [...] }`.** Each listed name stays a variable.
96
+ - **`productionCss: { substitute: false }`.** The build removes properties no
97
+ rule reads and keeps every other `var()`, so DevTools shows token names while
98
+ you debug. It still declares each property once on `:root`.
99
+ - **`productionCss: false`.** The build keeps every variable, as dev does.
100
+
101
+ `keep` and `substitute` combine in one object. When a page renders differently
102
+ in production, build it with `productionCss: false`. A difference that goes
103
+ away points at a token the rule missed, and `keep` fixes it.
104
+
105
+ ## Limits
106
+
107
+ - **Values a property rejects.** In dev, `width: var(--color-brand-500)` passes
108
+ the parser, and when the color reaches `width` the browser treats the
109
+ declaration as `unset`. After substitution, `width` holds a color, the parser
110
+ drops the declaration, and an earlier `width` rule applies. Keep each token's
111
+ value valid for every property that reads it.
112
+ - **Fixed stylesheet names.** The build renames each stylesheet it rewrites
113
+ through the hash in the build's `assetFileNames` pattern. A plugin that emits
114
+ a stylesheet under a fixed `fileName` breaks that pattern, and the build
115
+ stops with `live-tokens: <file> does not follow the output pattern
116
+ "<pattern>"`. Set `productionCss: false` for that build.