@motion-proto/live-tokens 0.49.1 → 0.50.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.
- package/CHANGELOG.md +27 -0
- package/dist-plugin/generateColorsAndType/index.cjs +13 -2
- package/dist-plugin/generateColorsAndType/index.js +13 -2
- package/package.json +6 -1
- package/src/editor/bootstrap.ts +2 -0
- package/src/editor/component-editor/ButtonEditor.svelte +1 -1
- package/src/editor/component-editor/CardEditor.svelte +5 -2
- package/src/editor/component-editor/IconButtonEditor.svelte +1 -1
- package/src/editor/component-editor/ImageEditor.svelte +1 -1
- package/src/editor/component-editor/SectionDividerEditor.svelte +4 -1
- package/src/editor/component-editor/SideNavigationEditor.svelte +6 -0
- package/src/editor/component-editor/TabBarEditor.svelte +1 -5
- package/src/editor/component-editor/scaffolding/ComponentEditorBase.svelte +8 -1
- package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +10 -32
- package/src/editor/component-editor/scaffolding/StateBlock.svelte +2 -0
- package/src/editor/component-editor/scaffolding/TypeEditor.svelte +3 -1
- package/src/editor/component-editor/scaffolding/types.ts +3 -0
- package/src/editor/core/components/componentConfigService.ts +25 -0
- package/src/editor/core/cssVarSync.ts +65 -7
- package/src/editor/core/fonts/fontLoader.ts +12 -3
- package/src/editor/core/fonts/fontWeightAvailability.ts +97 -0
- package/src/editor/core/preview/lookPreview.ts +23 -18
- package/src/editor/core/store/editorRenderer.ts +22 -7
- package/src/editor/core/store/editorStore.ts +31 -0
- package/src/editor/core/store/gradientSource.ts +5 -0
- package/src/editor/core/themes/colorsAndTypeService.ts +2 -12
- package/src/editor/core/themes/migrations/2026-05-20-sectiondivider-slim-variants.ts +4 -1
- package/src/editor/core/themes/migrations/2026-05-21-sectiondivider-spacing-to-padding.ts +4 -1
- package/src/editor/core/themes/migrations/2026-05-25-cornerbadge-flatten-variants.ts +8 -0
- package/src/editor/core/themes/slices/fonts.ts +4 -5
- package/src/editor/core/themes/themeDocumentSync.ts +66 -0
- package/src/editor/core/themes/themeInit.ts +7 -10
- package/src/editor/core/themes/themeService.ts +10 -3
- package/src/editor/docs/content/themes-workflow.md +14 -11
- package/src/editor/docs/content/where-themes-live.md +2 -2
- package/src/editor/docs/content.generated.ts +2 -2
- package/src/editor/ui/FileLoadList.svelte +1 -0
- package/src/editor/ui/FontStackEditor.svelte +1 -2
- package/src/editor/ui/GradientEditor.svelte +5 -1
- package/src/editor/ui/ProjectFontsSection.svelte +0 -5
- package/src/editor/ui/ThemePanel.svelte +206 -42
- package/src/editor/ui/UIDialog.svelte +7 -1
- package/src/editor/ui/UIFontFamilySelector.svelte +13 -13
- package/src/editor/ui/UIFontSizeSelector.svelte +5 -5
- package/src/editor/ui/UIFontWeightSelector.svelte +52 -2
- package/src/editor/ui/UIOptionItem.svelte +14 -1
- package/src/editor/ui/UIPaddingSelector.svelte +4 -3
- package/src/editor/ui/UIPaletteSelector.svelte +20 -13
- package/src/editor/ui/UIRelinkConfirmDialog.svelte +18 -3
- package/src/editor/ui/UITextTransformSelector.svelte +5 -5
- package/src/editor/ui/UITokenSelector.svelte +29 -7
- package/src/live-tokens/data/themes/spring-meadow.json +14 -12
- package/src/system/components/Button.svelte +2 -1
- package/src/system/components/FloatingTokenTags.css +2 -1
- package/src/system/components/FloatingTokenTags.svelte +11 -0
- package/src/system/components/IconButton.svelte +2 -1
- package/src/system/components/Image.svelte +9 -3
- package/src/system/components/ImageLightbox.svelte +2 -0
- package/src/system/components/SectionDivider.svelte +20 -2
- package/src/system/components/SideNavigation.svelte +15 -1
- package/src/system/styles/fonts.css +6 -6
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { FontFamily, FontSource, FontStack } from '../themes/themeTypes';
|
|
2
|
+
|
|
3
|
+
export interface FontWeightAvailability {
|
|
4
|
+
familyName: string;
|
|
5
|
+
weights: ReadonlySet<number>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Typography tokens consistently pair `-font-weight` with `-font-family`. */
|
|
9
|
+
export function inferFontFamilyVariable(weightVariable: string): string | null {
|
|
10
|
+
return weightVariable.endsWith('-font-weight')
|
|
11
|
+
? `${weightVariable.slice(0, -'-font-weight'.length)}-font-family`
|
|
12
|
+
: null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeFamilyName(value: string): string {
|
|
16
|
+
let name = value.trim();
|
|
17
|
+
// Imported font metadata sometimes retains more than one layer of wrapping
|
|
18
|
+
// quotes (for example `"Comfortaa"`). Peel all balanced wrappers so it can
|
|
19
|
+
// still be compared with a computed font-family value.
|
|
20
|
+
while (
|
|
21
|
+
name.length >= 2
|
|
22
|
+
&& ((name.startsWith('"') && name.endsWith('"'))
|
|
23
|
+
|| (name.startsWith("'") && name.endsWith("'")))
|
|
24
|
+
) {
|
|
25
|
+
name = name.slice(1, -1).trim();
|
|
26
|
+
}
|
|
27
|
+
return name.toLocaleLowerCase();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function firstFamily(value: string): string {
|
|
31
|
+
let quote: '"' | "'" | null = null;
|
|
32
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
33
|
+
const char = value[i];
|
|
34
|
+
if (quote) {
|
|
35
|
+
if (char === quote && value[i - 1] !== '\\') quote = null;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (char === '"' || char === "'") {
|
|
39
|
+
quote = char;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (char === ',') return value.slice(0, i);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function familyMap(sources: FontSource[]): Map<string, FontFamily> {
|
|
48
|
+
const families = new Map<string, FontFamily>();
|
|
49
|
+
for (const source of sources) {
|
|
50
|
+
for (const family of source.families) {
|
|
51
|
+
families.set(normalizeFamilyName(family.cssName), family);
|
|
52
|
+
families.set(normalizeFamilyName(family.name), family);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return families;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function knownAvailability(family: FontFamily | undefined): FontWeightAvailability | null {
|
|
59
|
+
if (!family?.weights?.length) return null;
|
|
60
|
+
return {
|
|
61
|
+
familyName: family.name,
|
|
62
|
+
weights: new Set(family.weights),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Find the declared weights for the primary face in a CSS font-family value.
|
|
68
|
+
* Returns null for system fonts and sources whose available weights are not
|
|
69
|
+
* known, so the editor never disables an option on incomplete metadata.
|
|
70
|
+
*/
|
|
71
|
+
export function fontWeightAvailability(
|
|
72
|
+
fontFamilyValue: string,
|
|
73
|
+
sources: FontSource[],
|
|
74
|
+
stacks: FontStack[],
|
|
75
|
+
): FontWeightAvailability | null {
|
|
76
|
+
const families = familyMap(sources);
|
|
77
|
+
const stackRef = fontFamilyValue.match(/^\s*var\(\s*(--font-(?:display|sans|serif|mono))\b/);
|
|
78
|
+
|
|
79
|
+
if (stackRef) {
|
|
80
|
+
const stack = stacks.find((candidate) => candidate.variable === stackRef[1]);
|
|
81
|
+
for (const slot of stack?.slots ?? []) {
|
|
82
|
+
if (slot.kind === 'project') {
|
|
83
|
+
const family = sources.flatMap((source) => source.families)
|
|
84
|
+
.find((candidate) => candidate.id === slot.familyId);
|
|
85
|
+
if (!family) continue;
|
|
86
|
+
return knownAvailability(family);
|
|
87
|
+
}
|
|
88
|
+
// A system or generic slot is a real primary face, but has no reliable
|
|
89
|
+
// per-weight metadata. Do not make assumptions from later fallbacks.
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const name = normalizeFamilyName(firstFamily(fontFamilyValue));
|
|
96
|
+
return knownAvailability(families.get(name));
|
|
97
|
+
}
|
|
@@ -2,8 +2,8 @@ import { get } from 'svelte/store';
|
|
|
2
2
|
import type { FontSource, Theme, ColorsAndType } from '../themes/themeTypes';
|
|
3
3
|
import { editorState, colorsAndTypeToState, toComponentSlice } from '../store/editorStore';
|
|
4
4
|
import { deriveCssVars } from '../store/editorRenderer';
|
|
5
|
-
import { setCssVar, removeCssVar } from '../cssVarSync';
|
|
6
|
-
import { applyFontSources
|
|
5
|
+
import { batchCssVarChanges, setCssVar, removeCssVar } from '../cssVarSync';
|
|
6
|
+
import { applyFontSources } from '../fonts/fontLoader';
|
|
7
7
|
import { migrateColorsAndTypeFonts } from '../fonts/fontMigration';
|
|
8
8
|
import { loadTheme } from '../themes/themeService';
|
|
9
9
|
|
|
@@ -50,7 +50,6 @@ export function themeLook(theme: Theme, defaults: Theme): RenderedLook {
|
|
|
50
50
|
state.components[comp] = toComponentSlice(comp, config.aliases, config.config, config.schemaVersion);
|
|
51
51
|
}
|
|
52
52
|
const vars = deriveCssVars(state);
|
|
53
|
-
Object.assign(vars, resolveFontStackValues(colorsAndType.fontStacks ?? [], colorsAndType.fontSources ?? []));
|
|
54
53
|
return { vars, fontSources: colorsAndType.fontSources ?? [] };
|
|
55
54
|
}
|
|
56
55
|
|
|
@@ -65,7 +64,6 @@ export function colorsAndTypeLook(colorsAndType: ColorsAndType): RenderedLook {
|
|
|
65
64
|
const next = structuredClone(colorsAndType);
|
|
66
65
|
migrateColorsAndTypeFonts(next);
|
|
67
66
|
const vars = deriveCssVars(colorsAndTypeToState(next));
|
|
68
|
-
Object.assign(vars, resolveFontStackValues(next.fontStacks ?? [], next.fontSources ?? []));
|
|
69
67
|
return { vars, fontSources: next.fontSources ?? [] };
|
|
70
68
|
}
|
|
71
69
|
|
|
@@ -73,7 +71,6 @@ export function colorsAndTypeLook(colorsAndType: ColorsAndType): RenderedLook {
|
|
|
73
71
|
export function liveLook(): RenderedLook {
|
|
74
72
|
const state = get(editorState);
|
|
75
73
|
const vars = deriveCssVars(state);
|
|
76
|
-
Object.assign(vars, resolveFontStackValues(state.fonts.stacks, state.fonts.sources));
|
|
77
74
|
return { vars, fontSources: state.fonts.sources };
|
|
78
75
|
}
|
|
79
76
|
|
|
@@ -91,24 +88,24 @@ function loadDefaults(): Promise<Theme> {
|
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
function paint(next: RenderedLook, from: RenderedLook): void {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
batchCssVarChanges(() => {
|
|
92
|
+
for (const [name, value] of Object.entries(next.vars)) {
|
|
93
|
+
if (from.vars[name] !== value) setCssVar(name, value);
|
|
94
|
+
}
|
|
95
|
+
for (const name of Object.keys(from.vars)) {
|
|
96
|
+
if (!(name in next.vars)) removeCssVar(name);
|
|
97
|
+
}
|
|
98
|
+
applyFontSources(next.fontSources);
|
|
99
|
+
});
|
|
101
100
|
}
|
|
102
101
|
|
|
103
102
|
/**
|
|
104
|
-
* Show `look` on the page. Re-entrant
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* The two passes run in one task, so the browser never paints the intermediate.
|
|
103
|
+
* Show `look` on the page. Re-entrant previews diff directly from the look
|
|
104
|
+
* already painted; only Cancel restores the live store projection. This avoids
|
|
105
|
+
* repainting the live theme as an invisible intermediate on every picker row.
|
|
108
106
|
*/
|
|
109
107
|
function applyPreview(look: RenderedLook): void {
|
|
110
|
-
|
|
111
|
-
paint(look, liveLook());
|
|
108
|
+
paint(look, livePreview ?? liveLook());
|
|
112
109
|
livePreview = look;
|
|
113
110
|
}
|
|
114
111
|
|
|
@@ -130,6 +127,14 @@ export function revertPreview(): void {
|
|
|
130
127
|
livePreview = null;
|
|
131
128
|
}
|
|
132
129
|
|
|
130
|
+
/** Release the preview without repainting. The caller must immediately load
|
|
131
|
+
* the exact look being previewed into the store. This is the Save handoff: the
|
|
132
|
+
* selected theme is already on screen, so restoring the old live look before
|
|
133
|
+
* applying it would add work and create a visible flash across the request. */
|
|
134
|
+
export function commitPreview(): void {
|
|
135
|
+
livePreview = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
133
138
|
export function isPreviewing(): boolean {
|
|
134
139
|
return livePreview !== null;
|
|
135
140
|
}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { EditorState } from './editorTypes';
|
|
15
|
-
import { setCssVar, removeCssVar } from '../cssVarSync';
|
|
15
|
+
import { batchCssVarChanges, setCssVar, removeCssVar } from '../cssVarSync';
|
|
16
|
+
import { applyFontSources, resolveFontStackValues } from '../fonts/fontLoader';
|
|
16
17
|
import { palettesToVars } from '../palettes/paletteDerivation';
|
|
17
18
|
import {
|
|
18
19
|
editorState,
|
|
@@ -38,10 +39,12 @@ export function deriveCssVars(state: EditorState): Record<string, string> {
|
|
|
38
39
|
}
|
|
39
40
|
Object.assign(out, palettesToVars(state.palettes));
|
|
40
41
|
Object.assign(out, componentsToVars(state.components));
|
|
42
|
+
Object.assign(out, resolveFontStackValues(state.fonts.stacks, state.fonts.sources));
|
|
41
43
|
return out;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
let lastApplied: Record<string, string> = {};
|
|
47
|
+
let lastFontSources = '';
|
|
45
48
|
let installed = false;
|
|
46
49
|
|
|
47
50
|
/**
|
|
@@ -55,17 +58,29 @@ export function installRenderer(): void {
|
|
|
55
58
|
if (typeof window === 'undefined') return;
|
|
56
59
|
editorState.subscribe((state) => {
|
|
57
60
|
const next = deriveCssVars(state);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
batchCssVarChanges(() => {
|
|
62
|
+
for (const name in next) {
|
|
63
|
+
if (next[name] !== lastApplied[name]) setCssVar(name, next[name]);
|
|
64
|
+
}
|
|
65
|
+
for (const name in lastApplied) {
|
|
66
|
+
if (!(name in next)) removeCssVar(name);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
64
69
|
lastApplied = next;
|
|
70
|
+
|
|
71
|
+
// Font source nodes are DOM state just like root custom properties. Keep
|
|
72
|
+
// them behind the same authoritative store subscription so load,
|
|
73
|
+
// undo/redo, reset, and every future mutation path all reconcile them.
|
|
74
|
+
const nextFontSources = JSON.stringify(state.fonts.sources);
|
|
75
|
+
if (nextFontSources !== lastFontSources) {
|
|
76
|
+
applyFontSources(state.fonts.sources);
|
|
77
|
+
lastFontSources = nextFontSources;
|
|
78
|
+
}
|
|
65
79
|
});
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
/** Test-only: clear the diff cache so independent test runs don't leak applied vars. */
|
|
69
83
|
export function __resetRendererCacheForTests(): void {
|
|
70
84
|
lastApplied = {};
|
|
85
|
+
lastFontSources = '';
|
|
71
86
|
}
|
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
} from '../themes/slices/gradients';
|
|
77
77
|
import {
|
|
78
78
|
componentBaseline,
|
|
79
|
+
getComponentOwnedVarNames,
|
|
79
80
|
loadComponentsFromVars,
|
|
80
81
|
notifyComponentSavedChanged,
|
|
81
82
|
setSavedComponentBaseline,
|
|
@@ -510,6 +511,36 @@ export function seedComponentsFromApi(
|
|
|
510
511
|
schedulePersist();
|
|
511
512
|
}
|
|
512
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Replace colors/type and every component slice as one theme transaction.
|
|
516
|
+
* Theme application used to call `loadFromFile` and `seedComponentsFromApi`
|
|
517
|
+
* separately, causing two complete renderer passes and briefly exposing a
|
|
518
|
+
* mixed old-components/new-tokens state. Build the final state first and emit
|
|
519
|
+
* it once instead.
|
|
520
|
+
*/
|
|
521
|
+
export function loadThemeFromApi(
|
|
522
|
+
colorsAndType: ColorsAndType,
|
|
523
|
+
configs: Record<string, ComponentSeed>,
|
|
524
|
+
): void {
|
|
525
|
+
const next = colorsAndTypeToState(colorsAndType);
|
|
526
|
+
next.components = {};
|
|
527
|
+
for (const [comp, cfg] of Object.entries(configs)) {
|
|
528
|
+
const split = toComponentSlice(comp, cfg.aliases, cfg.config, cfg.schemaVersion ?? 0);
|
|
529
|
+
next.components[comp] = { aliases: { ...split.aliases }, config: { ...split.config } };
|
|
530
|
+
setSavedComponentBaseline(comp, componentBaseline(split));
|
|
531
|
+
}
|
|
532
|
+
// Defensive legacy cleanup: a colors-and-type payload may still carry
|
|
533
|
+
// component-owned variables in its catch-all bag. The component slice is
|
|
534
|
+
// authoritative for a whole-theme load.
|
|
535
|
+
for (const name of getComponentOwnedVarNames(next)) delete next.cssVars[name];
|
|
536
|
+
|
|
537
|
+
resetHistoryForLoad();
|
|
538
|
+
store.set(next);
|
|
539
|
+
markColorsAndTypeSaved(next);
|
|
540
|
+
notifyComponentSavedChanged();
|
|
541
|
+
schedulePersist();
|
|
542
|
+
}
|
|
543
|
+
|
|
513
544
|
// ── Colors and type load / save ────────────────────────────────────────────
|
|
514
545
|
|
|
515
546
|
/**
|
|
@@ -40,6 +40,9 @@ export interface GradientSourceSnapshot {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface GradientSource {
|
|
43
|
+
/** CSS variable ultimately written by this source. Exposed for editor
|
|
44
|
+
* semantics and automated control-to-runtime contract discovery. */
|
|
45
|
+
targetVariable: string;
|
|
43
46
|
/** Reactive readable of the current gradient value (or undefined if missing). */
|
|
44
47
|
current: Readable<GradientSourceSnapshot | undefined>;
|
|
45
48
|
setAll(next: GradientSourceSnapshot): void;
|
|
@@ -67,6 +70,7 @@ export function colorsAndTypeGradientSource(variable: string): GradientSource {
|
|
|
67
70
|
};
|
|
68
71
|
});
|
|
69
72
|
return {
|
|
73
|
+
targetVariable: variable,
|
|
70
74
|
current,
|
|
71
75
|
setAll: (next) => setGradient(variable, next),
|
|
72
76
|
setType: (t) => setGradientType(variable, t),
|
|
@@ -158,6 +162,7 @@ export function componentGradientSource(component: string, varName: string): Gra
|
|
|
158
162
|
});
|
|
159
163
|
};
|
|
160
164
|
return {
|
|
165
|
+
targetVariable: varName,
|
|
161
166
|
current,
|
|
162
167
|
setAll: (next) => writeComponentGradient(component, varName, {
|
|
163
168
|
type: next.type,
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
import { API_BASE } from '../storage/apiBase';
|
|
9
9
|
import { liveMovedSinceBake } from '../productionPulse';
|
|
10
10
|
import { loadFromFile as loadEditorState, toColorsAndType, markSaved, markColorsAndTypeSaved } from '../store/editorStore';
|
|
11
|
-
import { applyFontSources, applyFontStacks } from '../fonts/fontLoader';
|
|
12
11
|
import { migrateColorsAndTypeFonts } from '../fonts/fontMigration';
|
|
13
12
|
|
|
14
13
|
// ── API helpers ──────────────────────────────────────────────
|
|
@@ -78,18 +77,9 @@ export async function persistColorsAndType(
|
|
|
78
77
|
markColorsAndTypeSaved(state);
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
/** Load colors and type into the editor state
|
|
82
|
-
*
|
|
80
|
+
/** Load colors and type into the editor state. The store renderer projects
|
|
81
|
+
* font sources, stacks, and all other CSS variables in one reactive pass. */
|
|
83
82
|
export function hydrateColorsAndType(colorsAndType: ColorsAndType): void {
|
|
84
83
|
migrateColorsAndTypeFonts(colorsAndType);
|
|
85
84
|
loadEditorState(colorsAndType);
|
|
86
|
-
// Font data is in state.fonts via loadEditorState; the DOM-side-effect
|
|
87
|
-
// helpers still need to run so @font-face rules and --font-* CSS vars
|
|
88
|
-
// land on :root.
|
|
89
|
-
if (colorsAndType.fontSources && colorsAndType.fontSources.length > 0) {
|
|
90
|
-
applyFontSources(colorsAndType.fontSources);
|
|
91
|
-
}
|
|
92
|
-
if (colorsAndType.fontStacks && colorsAndType.fontStacks.length > 0) {
|
|
93
|
-
applyFontStacks(colorsAndType.fontStacks, colorsAndType.fontSources ?? []);
|
|
94
|
-
}
|
|
95
85
|
}
|
|
@@ -28,7 +28,10 @@ const VARIANTS = ['lg', 'md', 'sm'] as const;
|
|
|
28
28
|
const SUFFIX_RENAMES: Array<[RegExp, string]> = [
|
|
29
29
|
[/-title-border-width$/, '-title-outline-width'],
|
|
30
30
|
[/-title-stroke-color$/, '-title-outline-color'],
|
|
31
|
-
[
|
|
31
|
+
[
|
|
32
|
+
/^--sectiondivider-(canvas|neutral|alternate|primary|accent|special)-padding$/,
|
|
33
|
+
'--sectiondivider-$1-spacing',
|
|
34
|
+
],
|
|
32
35
|
];
|
|
33
36
|
|
|
34
37
|
function renameKeySuffix(key: string): string {
|
|
@@ -16,7 +16,10 @@ export const componentMigration_2026_05_21_sectiondividerSpacingToPadding: Migra
|
|
|
16
16
|
if (meta.component !== 'sectiondivider') return { ...rawVars };
|
|
17
17
|
const out: Record<string, string> = {};
|
|
18
18
|
for (const [key, value] of Object.entries(rawVars)) {
|
|
19
|
-
const renamed = key.replace(
|
|
19
|
+
const renamed = key.replace(
|
|
20
|
+
/^--sectiondivider-(lg|md|sm)-spacing$/,
|
|
21
|
+
'--sectiondivider-$1-padding',
|
|
22
|
+
);
|
|
20
23
|
out[renamed] = value;
|
|
21
24
|
}
|
|
22
25
|
return out;
|
|
@@ -16,6 +16,7 @@ import type { Migration } from './index';
|
|
|
16
16
|
*/
|
|
17
17
|
const VARIANTS = ['primary', 'accent', 'neutral', 'alternate', 'canvas', 'special', 'success', 'warning', 'danger', 'info'] as const;
|
|
18
18
|
const RE = new RegExp(`^--corner-badge-(${VARIANTS.join('|')})-(.+)$`);
|
|
19
|
+
const CURRENT_COLOR_PROPS = new Set(['surface', 'border', 'text']);
|
|
19
20
|
|
|
20
21
|
function flattenVariants(rawVars: Record<string, string>, meta: { component?: string }): Record<string, string> {
|
|
21
22
|
if (meta.component !== 'cornerbadge') return rawVars;
|
|
@@ -25,6 +26,13 @@ function flattenVariants(rawVars: Record<string, string>, meta: { component?: st
|
|
|
25
26
|
const m = key.match(RE);
|
|
26
27
|
if (m) {
|
|
27
28
|
const prop = m[2];
|
|
29
|
+
// The current component composes Badge's per-variant colors under these
|
|
30
|
+
// same prefixes. They were introduced after this migration and must be
|
|
31
|
+
// left intact when an otherwise-current unversioned snapshot is loaded.
|
|
32
|
+
if (CURRENT_COLOR_PROPS.has(prop)) {
|
|
33
|
+
out[key] = value;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
28
36
|
if (!collected.has(prop)) collected.set(prop, value);
|
|
29
37
|
continue;
|
|
30
38
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fonts slice — sources + stacks.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* after mutating.
|
|
2
|
+
* Fonts slice — sources + stacks. The editor renderer projects both from the
|
|
3
|
+
* store: stacks become `--font-*` vars and sources become mirrored
|
|
4
|
+
* <link>/<style> nodes. Callers only mutate data; undo/redo and hydration then
|
|
5
|
+
* use the exact same reactive path as the initial edit.
|
|
7
6
|
*/
|
|
8
7
|
import type { FontSource, FontStack } from '../themeTypes';
|
|
9
8
|
import { store, mutate, persist } from '../../store/editorCore';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { liveMovedSinceBake } from '../productionPulse';
|
|
2
|
+
import { openThemeSlug } from '../store/editorConfigStore';
|
|
3
|
+
import { loadThemeFromApi } from '../store/editorStore';
|
|
4
|
+
import { migrateColorsAndTypeFonts } from '../fonts/fontMigration';
|
|
5
|
+
import type { ApplyThemeResult } from './themeService';
|
|
6
|
+
|
|
7
|
+
const CHANNEL_NAME = 'live-tokens:active-theme:v1';
|
|
8
|
+
export const THEME_APPLIED_EVENT = 'live-tokens:theme-applied';
|
|
9
|
+
|
|
10
|
+
export interface AppliedThemeDetail {
|
|
11
|
+
fileName: string;
|
|
12
|
+
result: ApplyThemeResult;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface AppliedThemeMessage {
|
|
16
|
+
type: 'theme-applied';
|
|
17
|
+
fileName: string;
|
|
18
|
+
result: ApplyThemeResult;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let channel: BroadcastChannel | null = null;
|
|
22
|
+
|
|
23
|
+
function isAppliedThemeMessage(value: unknown): value is AppliedThemeMessage {
|
|
24
|
+
if (!value || typeof value !== 'object') return false;
|
|
25
|
+
const message = value as Partial<AppliedThemeMessage>;
|
|
26
|
+
return message.type === 'theme-applied'
|
|
27
|
+
&& typeof message.fileName === 'string'
|
|
28
|
+
&& !!message.result
|
|
29
|
+
&& typeof message.result === 'object'
|
|
30
|
+
&& !!message.result.colorsAndType
|
|
31
|
+
&& typeof message.result.componentConfigs === 'object';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Replace this document's editor state with the complete state returned by
|
|
36
|
+
* the theme apply endpoint. Each same-origin document owns its own Svelte
|
|
37
|
+
* store, so writing CSS into another document is not enough: the receiving
|
|
38
|
+
* editor must hydrate the typed palette and component layers as well.
|
|
39
|
+
*/
|
|
40
|
+
export function hydrateAppliedTheme(fileName: string, result: ApplyThemeResult): void {
|
|
41
|
+
const colorsAndType = structuredClone(result.colorsAndType);
|
|
42
|
+
migrateColorsAndTypeFonts(colorsAndType);
|
|
43
|
+
loadThemeFromApi(colorsAndType, structuredClone(result.componentConfigs));
|
|
44
|
+
openThemeSlug.set(fileName);
|
|
45
|
+
liveMovedSinceBake.set(false);
|
|
46
|
+
if (typeof document !== 'undefined') {
|
|
47
|
+
document.dispatchEvent(new CustomEvent<AppliedThemeDetail>(THEME_APPLIED_EVENT, {
|
|
48
|
+
detail: { fileName, result },
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Install the same-origin theme bridge for this window or iframe. */
|
|
54
|
+
export function init(): void {
|
|
55
|
+
if (channel || typeof BroadcastChannel === 'undefined') return;
|
|
56
|
+
channel = new BroadcastChannel(CHANNEL_NAME);
|
|
57
|
+
channel.addEventListener('message', (event: MessageEvent<unknown>) => {
|
|
58
|
+
if (!isAppliedThemeMessage(event.data)) return;
|
|
59
|
+
hydrateAppliedTheme(event.data.fileName, event.data.result);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Notify every already-open editor/host document after the server switches. */
|
|
64
|
+
export function broadcastAppliedTheme(fileName: string, result: ApplyThemeResult): void {
|
|
65
|
+
channel?.postMessage({ type: 'theme-applied', fileName, result } satisfies AppliedThemeMessage);
|
|
66
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { AliasDiskValue, ColorsAndType } from './themeTypes';
|
|
2
2
|
import { openThemeSlug } from '../store/editorConfigStore';
|
|
3
3
|
import { migrateColorsAndTypeFonts } from '../fonts/fontMigration';
|
|
4
|
-
import { applyFontSources, applyFontStacks } from '../fonts/fontLoader';
|
|
5
4
|
import { loadFromFile, seedComponentsFromApi } from '../store/editorStore';
|
|
6
5
|
import { getActiveComponentConfig, type ComponentSummary } from '../components/componentConfigService';
|
|
7
6
|
import { safeFetch } from '../storage/storage';
|
|
@@ -33,12 +32,6 @@ export async function initializeTheme(): Promise<void> {
|
|
|
33
32
|
if (colorsAndType) {
|
|
34
33
|
migrateColorsAndTypeFonts(colorsAndType);
|
|
35
34
|
loadFromFile(colorsAndType);
|
|
36
|
-
if (colorsAndType.fontSources && colorsAndType.fontSources.length > 0) {
|
|
37
|
-
applyFontSources(colorsAndType.fontSources);
|
|
38
|
-
}
|
|
39
|
-
if (colorsAndType.fontStacks && colorsAndType.fontStacks.length > 0) {
|
|
40
|
-
applyFontStacks(colorsAndType.fontStacks, colorsAndType.fontSources ?? []);
|
|
41
|
-
}
|
|
42
35
|
openThemeSlug.set(colorsAndType._fileName || 'default');
|
|
43
36
|
}
|
|
44
37
|
|
|
@@ -48,6 +41,7 @@ export async function initializeTheme(): Promise<void> {
|
|
|
48
41
|
string,
|
|
49
42
|
{ aliases: Record<string, AliasDiskValue>; config?: Record<string, unknown>; schemaVersion?: number }
|
|
50
43
|
> = {};
|
|
44
|
+
let componentReadFailed = false;
|
|
51
45
|
await Promise.all(
|
|
52
46
|
list.components.map(async (c) => {
|
|
53
47
|
const cfg = await getActiveComponentConfig(c.name);
|
|
@@ -57,11 +51,14 @@ export async function initializeTheme(): Promise<void> {
|
|
|
57
51
|
config: cfg.config,
|
|
58
52
|
schemaVersion: cfg.schemaVersion,
|
|
59
53
|
};
|
|
54
|
+
} else {
|
|
55
|
+
componentReadFailed = true;
|
|
60
56
|
}
|
|
61
57
|
}),
|
|
62
58
|
);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
// A successful empty list is authoritative. A partial read is not: avoid
|
|
60
|
+
// replacing the visible design system with a mixture of active configs
|
|
61
|
+
// and CSS defaults because one request happened to fail during boot.
|
|
62
|
+
if (!componentReadFailed) seedComponentsFromApi(configs);
|
|
66
63
|
}
|
|
67
64
|
}
|
|
@@ -4,6 +4,7 @@ import { API_BASE } from '../storage/apiBase';
|
|
|
4
4
|
import { liveMovedSinceBake } from '../productionPulse';
|
|
5
5
|
import { listComponents, getActiveComponentConfig } from '../components/componentConfigService';
|
|
6
6
|
import { getActiveColorsAndType } from './colorsAndTypeService';
|
|
7
|
+
import { broadcastAppliedTheme, hydrateAppliedTheme } from './themeDocumentSync';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* REST client for theme files, the documents of the editor. A theme carries a
|
|
@@ -67,8 +68,11 @@ export interface ApplyThemeResult {
|
|
|
67
68
|
* `themes/_active.json` at it, and returns the resolved state in one payload.
|
|
68
69
|
* Live reads then fall through to the theme's embedded layers.
|
|
69
70
|
* Production is untouched, so trying a look cannot change what the site ships.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
71
|
+
* The resolved response hydrates the caller immediately, then broadcasts the
|
|
72
|
+
* same typed state to every already-open same-origin host/editor document.
|
|
73
|
+
* Each store renderer projects that state into its own document, so a theme
|
|
74
|
+
* loaded from either side keeps the controls, aliases, and visible components
|
|
75
|
+
* on the same design system without a reload.
|
|
72
76
|
*/
|
|
73
77
|
export async function applyTheme(fileName: string): Promise<ApplyThemeResult> {
|
|
74
78
|
const res = await fetch(`${API_BASE}/themes/${encodeURIComponent(fileName)}/apply`, {
|
|
@@ -78,7 +82,10 @@ export async function applyTheme(fileName: string): Promise<ApplyThemeResult> {
|
|
|
78
82
|
const err = await res.json().catch(() => ({ error: 'Apply failed' }));
|
|
79
83
|
throw new Error(err.error || 'Apply failed');
|
|
80
84
|
}
|
|
81
|
-
|
|
85
|
+
const result = await res.json() as ApplyThemeResult;
|
|
86
|
+
hydrateAppliedTheme(fileName, result);
|
|
87
|
+
broadcastAppliedTheme(fileName, result);
|
|
88
|
+
return result;
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
export interface AdoptLookResult {
|
|
@@ -30,7 +30,8 @@ A theme is a document, and the editor works the way any editor does.
|
|
|
30
30
|
one slot per part of the look. **Save** captures that buffer into the open
|
|
31
31
|
theme.
|
|
32
32
|
- **The production theme** is the one your site ships, named in
|
|
33
|
-
`themes/_production.json`.
|
|
33
|
+
`themes/_production.json`. **Adopt** changes it; saving a preset in the Theme
|
|
34
|
+
Picker performs that Adopt for you.
|
|
34
35
|
|
|
35
36
|
Absence is the answer for anything untouched: a buffer exists only where the
|
|
36
37
|
live look diverges from the active theme, so a newly opened theme has none.
|
|
@@ -43,9 +44,11 @@ In the Theme panel:
|
|
|
43
44
|
go in as part of it, so there is nothing to save first.
|
|
44
45
|
- **Save As** names a new theme. Use it for your first save and for forking.
|
|
45
46
|
|
|
46
|
-
Component
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
Component editors keep their own unsaved state. If one or more components are
|
|
48
|
+
waiting when you use **Save**, **Save As**, or **Adopt**, the Theme panel offers
|
|
49
|
+
to save all of them before continuing. You can accept once instead of visiting
|
|
50
|
+
each component, or cancel to review them individually. A component editor's
|
|
51
|
+
**Save As** creates a reusable component preset.
|
|
49
52
|
|
|
50
53
|
Names are tidied to lowercase with hyphens, so "My Brand!" becomes `my-brand`,
|
|
51
54
|
and a leading underscore is dropped: those names are reserved for the buffer.
|
|
@@ -54,13 +57,13 @@ to it, and the editor never overwrites it, so start your own with **Save As**.
|
|
|
54
57
|
|
|
55
58
|
## Switching
|
|
56
59
|
|
|
57
|
-
**Load
|
|
58
|
-
it on the page as a preview with nothing written to disk, so you can
|
|
59
|
-
look and compare. **Save** in that window opens
|
|
60
|
-
pointer changes, the buffers clear, components it
|
|
61
|
-
to their defaults,
|
|
62
|
-
you to where you were.
|
|
63
|
-
|
|
60
|
+
**Load**—or clicking the active theme's name—opens the Theme Picker. Picking a
|
|
61
|
+
theme shows it on the page as a preview with nothing written to disk, so you can
|
|
62
|
+
try each look and compare. **Save** in that window opens and adopts the previewed
|
|
63
|
+
theme in one step: the active pointer changes, the buffers clear, components it
|
|
64
|
+
does not carry fall through to their defaults, the editor works on it, and
|
|
65
|
+
production ships it. **Cancel** returns you to where you were. Previewing alone
|
|
66
|
+
never changes what your site ships.
|
|
64
67
|
|
|
65
68
|
**Colors and type only. Keep my shapes.** narrows the load to the palette and
|
|
66
69
|
the fonts: your component settings stay as they are and the theme you have open
|
|
@@ -34,8 +34,8 @@ deleting anything else never breaks it.
|
|
|
34
34
|
|
|
35
35
|
- **Editing** changes the page through CSS variables. The editor keeps your
|
|
36
36
|
edits in the browser as you work and writes them to the `_working.json`
|
|
37
|
-
buffers when you save a component.
|
|
38
|
-
|
|
37
|
+
buffers when you save a component. When the Theme panel finds several dirty
|
|
38
|
+
components, **Save all** writes those buffers together.
|
|
39
39
|
- **Save** captures the buffers into the open theme's file. That file is the
|
|
40
40
|
durable copy of your look; matching buffers are then removed.
|
|
41
41
|
- **Load** clears the buffers and points `themes/_active.json` at the theme you
|