@motion-proto/live-tokens 0.6.0 → 0.6.1
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/dist-plugin/index.cjs +77 -3
- package/dist-plugin/index.js +77 -3
- package/package.json +1 -1
- package/src/component-editor/scaffolding/ComponentFileManager.svelte +3 -1
- package/src/component-editor/scaffolding/SaveAsDialog.svelte +24 -7
- package/src/lib/ColumnsOverlay.svelte +1 -1
- package/src/lib/componentPersist.ts +65 -0
- package/src/lib/presetService.ts +121 -1
- package/src/lib/productionPulse.ts +32 -0
- package/src/pages/ComponentEditorPage.svelte +25 -0
- package/src/pages/EditorShell.svelte +25 -0
- package/src/ui/PresetFileManager.svelte +763 -216
- package/src/ui/ThemeFileManager.svelte +557 -307
- package/src/ui/UnsavedComponentsDialog.svelte +315 -0
package/dist-plugin/index.cjs
CHANGED
|
@@ -168,7 +168,7 @@ function themeFileApi(opts) {
|
|
|
168
168
|
themesResource.ensureDir();
|
|
169
169
|
if (!import_fs2.default.existsSync(import_path2.default.join(THEMES_DIR, "default.json"))) {
|
|
170
170
|
const defaultTheme = {
|
|
171
|
-
name: "Default",
|
|
171
|
+
name: "Default Theme",
|
|
172
172
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
173
173
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
174
174
|
editorConfigs: {},
|
|
@@ -369,7 +369,7 @@ ${newVars}
|
|
|
369
369
|
}
|
|
370
370
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
371
371
|
const defaultPreset = {
|
|
372
|
-
name: "Default",
|
|
372
|
+
name: "Default Preset",
|
|
373
373
|
createdAt: now,
|
|
374
374
|
updatedAt: now,
|
|
375
375
|
theme: themesResource.getActiveName(),
|
|
@@ -444,6 +444,7 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
444
444
|
const COMPONENT_CONFIGS_ROUTE = `${API_BASE}/component-configs`;
|
|
445
445
|
const PRESETS_ROUTE = `${API_BASE}/presets`;
|
|
446
446
|
const PRESETS_ACTIVE_ROUTE = `${API_BASE}/presets/active`;
|
|
447
|
+
const PRESETS_PRODUCTION_ROUTE = `${API_BASE}/presets/production`;
|
|
447
448
|
const THEME_BY_NAME_REGEX = new RegExp(`^${escapedBase}/themes/([a-z0-9\\-_]+)$`);
|
|
448
449
|
const COMP_LIST_REGEX = new RegExp(`^${escapedBase}/component-configs/([a-z0-9\\-_]+)$`);
|
|
449
450
|
const COMP_ACTIVE_REGEX = new RegExp(`^${escapedBase}/component-configs/([a-z0-9\\-_]+)/active$`);
|
|
@@ -754,6 +755,73 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
754
755
|
presetsResource.setActiveName(fileName);
|
|
755
756
|
jsonResponse(res, 200, { ok: true, activeFile: fileName });
|
|
756
757
|
}
|
|
758
|
+
async function handleGetProductionPreset({ res }) {
|
|
759
|
+
const prodFile = presetsResource.getProductionName();
|
|
760
|
+
const filePath = presetsResource.filePath(prodFile);
|
|
761
|
+
if (!import_fs2.default.existsSync(filePath)) {
|
|
762
|
+
jsonResponse(res, 200, {
|
|
763
|
+
fileName: prodFile,
|
|
764
|
+
name: prodFile,
|
|
765
|
+
theme: "default",
|
|
766
|
+
componentConfigs: {},
|
|
767
|
+
updatedAt: ""
|
|
768
|
+
});
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const data = JSON.parse(import_fs2.default.readFileSync(filePath, "utf-8"));
|
|
772
|
+
jsonResponse(res, 200, {
|
|
773
|
+
fileName: prodFile,
|
|
774
|
+
name: data.name || prodFile,
|
|
775
|
+
theme: data.theme || "default",
|
|
776
|
+
componentConfigs: data.componentConfigs || {},
|
|
777
|
+
updatedAt: data.updatedAt || ""
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
async function handleSetProductionPreset({ req, res }) {
|
|
781
|
+
const body = JSON.parse(await readBody(req));
|
|
782
|
+
const fileName = sanitizeFileName(body.name || "default");
|
|
783
|
+
const presetPath = presetsResource.filePath(fileName);
|
|
784
|
+
if (!import_fs2.default.existsSync(presetPath)) {
|
|
785
|
+
jsonResponse(res, 404, { error: "Preset not found" });
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const preset = JSON.parse(import_fs2.default.readFileSync(presetPath, "utf-8"));
|
|
789
|
+
const themeName = sanitizeFileName(preset.theme || "default");
|
|
790
|
+
if (!import_fs2.default.existsSync(themesResource.filePath(themeName))) {
|
|
791
|
+
jsonResponse(res, 422, { error: `Preset references missing theme: ${themeName}` });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const knownComponents = new Set(listComponentNames());
|
|
795
|
+
const componentConfigs = preset.componentConfigs ?? {};
|
|
796
|
+
const apply = [];
|
|
797
|
+
for (const [comp, configFile] of Object.entries(componentConfigs)) {
|
|
798
|
+
if (!knownComponents.has(comp)) continue;
|
|
799
|
+
const sanitized = sanitizeFileName(String(configFile) || "default");
|
|
800
|
+
if (!import_fs2.default.existsSync(componentResource(comp).filePath(sanitized))) {
|
|
801
|
+
jsonResponse(res, 422, {
|
|
802
|
+
error: `Preset references missing config: ${comp}/${sanitized}`
|
|
803
|
+
});
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
apply.push([comp, sanitized]);
|
|
807
|
+
}
|
|
808
|
+
themesResource.setProductionName(themeName);
|
|
809
|
+
for (const [comp, configFile] of apply) {
|
|
810
|
+
componentResource(comp).setProductionName(configFile);
|
|
811
|
+
}
|
|
812
|
+
presetsResource.setProductionName(fileName);
|
|
813
|
+
syncTokensToCss(themeName);
|
|
814
|
+
syncFontsToCss(themeName);
|
|
815
|
+
syncComponentsToCss();
|
|
816
|
+
jsonResponse(res, 200, {
|
|
817
|
+
ok: true,
|
|
818
|
+
fileName,
|
|
819
|
+
name: preset.name || fileName,
|
|
820
|
+
theme: themeName,
|
|
821
|
+
componentConfigs: Object.fromEntries(apply),
|
|
822
|
+
updatedAt: preset.updatedAt || ""
|
|
823
|
+
});
|
|
824
|
+
}
|
|
757
825
|
async function handlePresetByName({ params, req, res }) {
|
|
758
826
|
const [fileName] = params;
|
|
759
827
|
const filePath = presetsResource.filePath(fileName);
|
|
@@ -768,6 +836,10 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
768
836
|
return;
|
|
769
837
|
}
|
|
770
838
|
if (req.method === "PUT") {
|
|
839
|
+
if (fileName === "default") {
|
|
840
|
+
jsonResponse(res, 403, { error: "Cannot overwrite the default preset" });
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
771
843
|
const body = JSON.parse(await readBody(req));
|
|
772
844
|
body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
773
845
|
if (import_fs2.default.existsSync(filePath)) {
|
|
@@ -884,10 +956,12 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
884
956
|
{ method: "GET", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
885
957
|
{ method: "PUT", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
886
958
|
{ method: "DELETE", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
887
|
-
// Presets — list / active are exact strings, must run before regexes
|
|
959
|
+
// Presets — list / active / production are exact strings, must run before regexes
|
|
888
960
|
{ method: "GET", pattern: PRESETS_ROUTE, handler: handleListPresets },
|
|
889
961
|
{ method: "GET", pattern: PRESETS_ACTIVE_ROUTE, handler: handleGetActivePreset },
|
|
890
962
|
{ method: "PUT", pattern: PRESETS_ACTIVE_ROUTE, handler: handleSetActivePreset },
|
|
963
|
+
{ method: "GET", pattern: PRESETS_PRODUCTION_ROUTE, handler: handleGetProductionPreset },
|
|
964
|
+
{ method: "PUT", pattern: PRESETS_PRODUCTION_ROUTE, handler: handleSetProductionPreset },
|
|
891
965
|
// Presets — :name/apply (more specific than :name)
|
|
892
966
|
{ method: "PUT", pattern: PRESET_APPLY_REGEX, handler: handleApplyPreset },
|
|
893
967
|
{ method: "POST", pattern: PRESET_APPLY_REGEX, handler: methodNotAllowed },
|
package/dist-plugin/index.js
CHANGED
|
@@ -132,7 +132,7 @@ function themeFileApi(opts) {
|
|
|
132
132
|
themesResource.ensureDir();
|
|
133
133
|
if (!fs2.existsSync(path2.join(THEMES_DIR, "default.json"))) {
|
|
134
134
|
const defaultTheme = {
|
|
135
|
-
name: "Default",
|
|
135
|
+
name: "Default Theme",
|
|
136
136
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
137
137
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
138
138
|
editorConfigs: {},
|
|
@@ -333,7 +333,7 @@ ${newVars}
|
|
|
333
333
|
}
|
|
334
334
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
335
335
|
const defaultPreset = {
|
|
336
|
-
name: "Default",
|
|
336
|
+
name: "Default Preset",
|
|
337
337
|
createdAt: now,
|
|
338
338
|
updatedAt: now,
|
|
339
339
|
theme: themesResource.getActiveName(),
|
|
@@ -408,6 +408,7 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
408
408
|
const COMPONENT_CONFIGS_ROUTE = `${API_BASE}/component-configs`;
|
|
409
409
|
const PRESETS_ROUTE = `${API_BASE}/presets`;
|
|
410
410
|
const PRESETS_ACTIVE_ROUTE = `${API_BASE}/presets/active`;
|
|
411
|
+
const PRESETS_PRODUCTION_ROUTE = `${API_BASE}/presets/production`;
|
|
411
412
|
const THEME_BY_NAME_REGEX = new RegExp(`^${escapedBase}/themes/([a-z0-9\\-_]+)$`);
|
|
412
413
|
const COMP_LIST_REGEX = new RegExp(`^${escapedBase}/component-configs/([a-z0-9\\-_]+)$`);
|
|
413
414
|
const COMP_ACTIVE_REGEX = new RegExp(`^${escapedBase}/component-configs/([a-z0-9\\-_]+)/active$`);
|
|
@@ -718,6 +719,73 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
718
719
|
presetsResource.setActiveName(fileName);
|
|
719
720
|
jsonResponse(res, 200, { ok: true, activeFile: fileName });
|
|
720
721
|
}
|
|
722
|
+
async function handleGetProductionPreset({ res }) {
|
|
723
|
+
const prodFile = presetsResource.getProductionName();
|
|
724
|
+
const filePath = presetsResource.filePath(prodFile);
|
|
725
|
+
if (!fs2.existsSync(filePath)) {
|
|
726
|
+
jsonResponse(res, 200, {
|
|
727
|
+
fileName: prodFile,
|
|
728
|
+
name: prodFile,
|
|
729
|
+
theme: "default",
|
|
730
|
+
componentConfigs: {},
|
|
731
|
+
updatedAt: ""
|
|
732
|
+
});
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const data = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
|
|
736
|
+
jsonResponse(res, 200, {
|
|
737
|
+
fileName: prodFile,
|
|
738
|
+
name: data.name || prodFile,
|
|
739
|
+
theme: data.theme || "default",
|
|
740
|
+
componentConfigs: data.componentConfigs || {},
|
|
741
|
+
updatedAt: data.updatedAt || ""
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
async function handleSetProductionPreset({ req, res }) {
|
|
745
|
+
const body = JSON.parse(await readBody(req));
|
|
746
|
+
const fileName = sanitizeFileName(body.name || "default");
|
|
747
|
+
const presetPath = presetsResource.filePath(fileName);
|
|
748
|
+
if (!fs2.existsSync(presetPath)) {
|
|
749
|
+
jsonResponse(res, 404, { error: "Preset not found" });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const preset = JSON.parse(fs2.readFileSync(presetPath, "utf-8"));
|
|
753
|
+
const themeName = sanitizeFileName(preset.theme || "default");
|
|
754
|
+
if (!fs2.existsSync(themesResource.filePath(themeName))) {
|
|
755
|
+
jsonResponse(res, 422, { error: `Preset references missing theme: ${themeName}` });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const knownComponents = new Set(listComponentNames());
|
|
759
|
+
const componentConfigs = preset.componentConfigs ?? {};
|
|
760
|
+
const apply = [];
|
|
761
|
+
for (const [comp, configFile] of Object.entries(componentConfigs)) {
|
|
762
|
+
if (!knownComponents.has(comp)) continue;
|
|
763
|
+
const sanitized = sanitizeFileName(String(configFile) || "default");
|
|
764
|
+
if (!fs2.existsSync(componentResource(comp).filePath(sanitized))) {
|
|
765
|
+
jsonResponse(res, 422, {
|
|
766
|
+
error: `Preset references missing config: ${comp}/${sanitized}`
|
|
767
|
+
});
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
apply.push([comp, sanitized]);
|
|
771
|
+
}
|
|
772
|
+
themesResource.setProductionName(themeName);
|
|
773
|
+
for (const [comp, configFile] of apply) {
|
|
774
|
+
componentResource(comp).setProductionName(configFile);
|
|
775
|
+
}
|
|
776
|
+
presetsResource.setProductionName(fileName);
|
|
777
|
+
syncTokensToCss(themeName);
|
|
778
|
+
syncFontsToCss(themeName);
|
|
779
|
+
syncComponentsToCss();
|
|
780
|
+
jsonResponse(res, 200, {
|
|
781
|
+
ok: true,
|
|
782
|
+
fileName,
|
|
783
|
+
name: preset.name || fileName,
|
|
784
|
+
theme: themeName,
|
|
785
|
+
componentConfigs: Object.fromEntries(apply),
|
|
786
|
+
updatedAt: preset.updatedAt || ""
|
|
787
|
+
});
|
|
788
|
+
}
|
|
721
789
|
async function handlePresetByName({ params, req, res }) {
|
|
722
790
|
const [fileName] = params;
|
|
723
791
|
const filePath = presetsResource.filePath(fileName);
|
|
@@ -732,6 +800,10 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
732
800
|
return;
|
|
733
801
|
}
|
|
734
802
|
if (req.method === "PUT") {
|
|
803
|
+
if (fileName === "default") {
|
|
804
|
+
jsonResponse(res, 403, { error: "Cannot overwrite the default preset" });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
735
807
|
const body = JSON.parse(await readBody(req));
|
|
736
808
|
body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
737
809
|
if (fs2.existsSync(filePath)) {
|
|
@@ -848,10 +920,12 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
848
920
|
{ method: "GET", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
849
921
|
{ method: "PUT", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
850
922
|
{ method: "DELETE", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
851
|
-
// Presets — list / active are exact strings, must run before regexes
|
|
923
|
+
// Presets — list / active / production are exact strings, must run before regexes
|
|
852
924
|
{ method: "GET", pattern: PRESETS_ROUTE, handler: handleListPresets },
|
|
853
925
|
{ method: "GET", pattern: PRESETS_ACTIVE_ROUTE, handler: handleGetActivePreset },
|
|
854
926
|
{ method: "PUT", pattern: PRESETS_ACTIVE_ROUTE, handler: handleSetActivePreset },
|
|
927
|
+
{ method: "GET", pattern: PRESETS_PRODUCTION_ROUTE, handler: handleGetProductionPreset },
|
|
928
|
+
{ method: "PUT", pattern: PRESETS_PRODUCTION_ROUTE, handler: handleSetProductionPreset },
|
|
855
929
|
// Presets — :name/apply (more specific than :name)
|
|
856
930
|
{ method: "PUT", pattern: PRESET_APPLY_REGEX, handler: handleApplyPreset },
|
|
857
931
|
{ method: "POST", pattern: PRESET_APPLY_REGEX, handler: methodNotAllowed },
|
package/package.json
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
loadComponentActive,
|
|
22
22
|
markComponentSaved,
|
|
23
23
|
} from '../../lib/editorStore';
|
|
24
|
+
import { bumpProductionRevision } from '../../lib/productionPulse';
|
|
24
25
|
import { CURRENT_COMPONENT_SCHEMA_VERSION } from '../../lib/migrations';
|
|
25
26
|
import type { CssVarRef } from '../../lib/editorTypes';
|
|
26
27
|
import { safeFetch } from '../../lib/storage';
|
|
@@ -295,6 +296,7 @@
|
|
|
295
296
|
}
|
|
296
297
|
await setComponentProductionFile(component, activeFileName);
|
|
297
298
|
await refreshProduction();
|
|
299
|
+
bumpProductionRevision();
|
|
298
300
|
adoptFeedback = wasDirty
|
|
299
301
|
? `Saved "${adoptingName}" and adopted`
|
|
300
302
|
: `Adopted "${adoptingName}"`;
|
|
@@ -518,7 +520,7 @@
|
|
|
518
520
|
display: inline-flex;
|
|
519
521
|
align-items: center;
|
|
520
522
|
gap: var(--ui-space-6);
|
|
521
|
-
margin-left:
|
|
523
|
+
margin-left: 2.5rem;
|
|
522
524
|
height: 26px;
|
|
523
525
|
padding: 0 14px;
|
|
524
526
|
font-size: var(--ui-font-size-xs);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { run } from 'svelte/legacy';
|
|
3
3
|
|
|
4
|
-
import type { ComponentConfigMeta } from '../../lib/themeTypes';
|
|
5
4
|
import { sanitizeFileName } from '../../lib/themeService';
|
|
6
5
|
import UIDialog from '../../ui/UIDialog.svelte';
|
|
7
6
|
|
|
@@ -10,12 +9,30 @@
|
|
|
10
9
|
show?: boolean;
|
|
11
10
|
/** Display name to seed the input with when the dialog opens. */
|
|
12
11
|
currentDisplayName?: string;
|
|
13
|
-
/** Existing files used by the increment helper to find the next available `_NN` suffix.
|
|
14
|
-
|
|
12
|
+
/** Existing files used by the increment helper to find the next available `_NN` suffix.
|
|
13
|
+
* Only `fileName` is read, so this accepts any shape with that field
|
|
14
|
+
* (ComponentConfigMeta, PresetMeta, …). */
|
|
15
|
+
files?: { fileName: string }[];
|
|
16
|
+
/** Dialog title — defaults to "Save As". Overridable so callers can use
|
|
17
|
+
* context-specific framing (e.g. "Save Preset As"). */
|
|
18
|
+
title?: string;
|
|
19
|
+
/** Placeholder shown in the empty input. */
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
/** Error message shown when the user types the reserved "default" name.
|
|
22
|
+
* Default copy references components; presets should override. */
|
|
23
|
+
reservedNameMessage?: string;
|
|
15
24
|
onsave?: (payload: { displayName: string; fileName: string }) => void;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
|
-
let {
|
|
27
|
+
let {
|
|
28
|
+
show = $bindable(false),
|
|
29
|
+
currentDisplayName = '',
|
|
30
|
+
files = [],
|
|
31
|
+
title = 'Save As',
|
|
32
|
+
placeholder = 'Config name…',
|
|
33
|
+
reservedNameMessage = 'The name "default" is reserved for the core component definition.',
|
|
34
|
+
onsave,
|
|
35
|
+
}: Props = $props();
|
|
19
36
|
|
|
20
37
|
let saveAsName = $state('');
|
|
21
38
|
let saveAsInput: HTMLInputElement | undefined = $state();
|
|
@@ -73,7 +90,7 @@
|
|
|
73
90
|
const trimmed = saveAsName.trim();
|
|
74
91
|
if (!trimmed) return '';
|
|
75
92
|
if (sanitizeFileName(trimmed) === 'default') {
|
|
76
|
-
return
|
|
93
|
+
return reservedNameMessage;
|
|
77
94
|
}
|
|
78
95
|
return '';
|
|
79
96
|
})());
|
|
@@ -81,7 +98,7 @@
|
|
|
81
98
|
|
|
82
99
|
<UIDialog
|
|
83
100
|
bind:show
|
|
84
|
-
title
|
|
101
|
+
{title}
|
|
85
102
|
cancelLabel="Cancel"
|
|
86
103
|
confirmLabel="Save"
|
|
87
104
|
confirmDisabled={!saveAsName.trim() || !!saveAsError}
|
|
@@ -97,7 +114,7 @@
|
|
|
97
114
|
bind:value={saveAsName}
|
|
98
115
|
bind:this={saveAsInput}
|
|
99
116
|
onkeydown={handleKeydown}
|
|
100
|
-
placeholder
|
|
117
|
+
{placeholder}
|
|
101
118
|
/>
|
|
102
119
|
<button
|
|
103
120
|
type="button"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { get } from 'svelte/store';
|
|
2
|
+
import type { ComponentConfig } from './themeTypes';
|
|
3
|
+
import { editorState, markComponentSaved } from './editorStore';
|
|
4
|
+
import type { CssVarRef } from './editorTypes';
|
|
5
|
+
import { CURRENT_COMPONENT_SCHEMA_VERSION } from './migrations';
|
|
6
|
+
import {
|
|
7
|
+
listComponentConfigs,
|
|
8
|
+
saveComponentConfig,
|
|
9
|
+
setActiveComponentFile,
|
|
10
|
+
} from './componentConfigService';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Save the current in-memory state of a component to its active file. Mirrors
|
|
14
|
+
* the `persist` flow inside `ComponentFileManager.svelte` so callers without a
|
|
15
|
+
* file-manager instance (e.g. the unsaved-components dialog in PresetFileManager)
|
|
16
|
+
* can save a dirty component without duplicating the schema-version + aliases
|
|
17
|
+
* stringification logic.
|
|
18
|
+
*
|
|
19
|
+
* Refuses to overwrite `default` — that's the protected source-derived snapshot.
|
|
20
|
+
* Callers that hit `{ ok: false, reason: 'default' }` should prompt for a new
|
|
21
|
+
* name and re-route to a Save-As flow.
|
|
22
|
+
*/
|
|
23
|
+
export type SaveActiveComponentResult =
|
|
24
|
+
| { ok: true; fileName: string; displayName: string }
|
|
25
|
+
| { ok: false; reason: 'default' | 'no-state' | 'error'; error?: unknown };
|
|
26
|
+
|
|
27
|
+
function refToString(ref: CssVarRef): string {
|
|
28
|
+
return ref.kind === 'token' ? ref.name : ref.value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function saveActiveComponentConfig(
|
|
32
|
+
component: string,
|
|
33
|
+
): Promise<SaveActiveComponentResult> {
|
|
34
|
+
const slice = get(editorState).components[component];
|
|
35
|
+
if (!slice) return { ok: false, reason: 'no-state' };
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const list = await listComponentConfigs(component);
|
|
39
|
+
const fileName = list.activeFile;
|
|
40
|
+
if (fileName === 'default') return { ok: false, reason: 'default' };
|
|
41
|
+
|
|
42
|
+
const active = list.files.find((f) => f.fileName === fileName);
|
|
43
|
+
const displayName = active?.name ?? fileName;
|
|
44
|
+
|
|
45
|
+
const now = new Date().toISOString();
|
|
46
|
+
const aliases: Record<string, string> = {};
|
|
47
|
+
for (const [k, ref] of Object.entries(slice.aliases)) aliases[k] = refToString(ref);
|
|
48
|
+
|
|
49
|
+
const data: ComponentConfig = {
|
|
50
|
+
name: displayName,
|
|
51
|
+
component,
|
|
52
|
+
createdAt: now,
|
|
53
|
+
updatedAt: now,
|
|
54
|
+
aliases,
|
|
55
|
+
config: { ...slice.config },
|
|
56
|
+
schemaVersion: CURRENT_COMPONENT_SCHEMA_VERSION,
|
|
57
|
+
};
|
|
58
|
+
await saveComponentConfig(component, fileName, data);
|
|
59
|
+
await setActiveComponentFile(component, fileName);
|
|
60
|
+
markComponentSaved(component);
|
|
61
|
+
return { ok: true, fileName, displayName };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return { ok: false, reason: 'error', error };
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/lib/presetService.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Preset, PresetMeta, Theme, ComponentConfig } from './themeTypes';
|
|
2
2
|
import { versionedFileResource } from './files/versionedFileResource';
|
|
3
3
|
import { listComponents } from './componentConfigService';
|
|
4
|
-
import { getActiveTheme } from './themeService';
|
|
4
|
+
import { getActiveTheme, getProductionInfo } from './themeService';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* REST client for preset (bundle) manifest files. Each preset file references
|
|
@@ -92,3 +92,123 @@ export async function captureCurrentAsPreset(
|
|
|
92
92
|
await savePreset(fileName, manifest);
|
|
93
93
|
await setActivePreset(fileName);
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
// ── Production preset ──────────────────────────────────────────────────────
|
|
97
|
+
//
|
|
98
|
+
// The production preset is the manifest whose references describe what
|
|
99
|
+
// tokens.css + the component overrides block currently encode. A separate
|
|
100
|
+
// pointer (`presets/_production.json`) from the active preset lets the editor
|
|
101
|
+
// run experiments without disturbing what end users see. Applying a preset to
|
|
102
|
+
// production flips every per-artifact `_production.json` pointer to match the
|
|
103
|
+
// manifest and re-bakes css.
|
|
104
|
+
|
|
105
|
+
export interface PresetProductionInfo {
|
|
106
|
+
fileName: string;
|
|
107
|
+
name: string;
|
|
108
|
+
theme: string;
|
|
109
|
+
componentConfigs: Record<string, string>;
|
|
110
|
+
updatedAt: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function getProductionPreset(): Promise<PresetProductionInfo | null> {
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch('/api/presets/production');
|
|
116
|
+
if (!res.ok) return null;
|
|
117
|
+
return res.json();
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function applyPresetToProduction(fileName: string): Promise<PresetProductionInfo> {
|
|
124
|
+
const res = await fetch('/api/presets/production', {
|
|
125
|
+
method: 'PUT',
|
|
126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({ name: fileName }),
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
const err = await res.json().catch(() => ({ error: 'Apply to production failed' }));
|
|
131
|
+
throw new Error(err.error || 'Apply to production failed');
|
|
132
|
+
}
|
|
133
|
+
return res.json();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Snapshot whatever each artifact's `_production.json` currently points at
|
|
138
|
+
* into a new preset manifest. Recovery path when individual component Adopts
|
|
139
|
+
* have drifted production away from the production preset and the user wants
|
|
140
|
+
* to preserve that state under a name rather than re-converge to a preset.
|
|
141
|
+
*
|
|
142
|
+
* Does NOT flip `presets/_production.json` — the new preset's references
|
|
143
|
+
* already match production by construction, but tagging it as the production
|
|
144
|
+
* preset is a separate UX decision left to the caller.
|
|
145
|
+
*/
|
|
146
|
+
export async function captureProductionAsPreset(
|
|
147
|
+
fileName: string,
|
|
148
|
+
displayName: string,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const themeInfo = await getProductionInfo();
|
|
151
|
+
const components = await listComponents();
|
|
152
|
+
const componentConfigs: Record<string, string> = {};
|
|
153
|
+
for (const c of components) {
|
|
154
|
+
componentConfigs[c.name] = c.productionFile || 'default';
|
|
155
|
+
}
|
|
156
|
+
const now = new Date().toISOString();
|
|
157
|
+
const manifest: Preset = {
|
|
158
|
+
name: displayName,
|
|
159
|
+
createdAt: now,
|
|
160
|
+
updatedAt: now,
|
|
161
|
+
theme: themeInfo.fileName,
|
|
162
|
+
componentConfigs,
|
|
163
|
+
};
|
|
164
|
+
await savePreset(fileName, manifest);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Three states comparing the active preset against current production:
|
|
169
|
+
* - 'in-production' — active preset IS the production preset AND every
|
|
170
|
+
* manifest reference matches the per-artifact production pointer.
|
|
171
|
+
* - 'editor-only' — the production preset is a different preset; applying
|
|
172
|
+
* this one would flip pointers.
|
|
173
|
+
* - 'diverged' — this IS the production preset, but per-artifact
|
|
174
|
+
* production has drifted (individual component Adopt clicks since the
|
|
175
|
+
* last apply). Two recovery paths: re-apply to converge, or capture
|
|
176
|
+
* production as a new preset to name the divergence.
|
|
177
|
+
*/
|
|
178
|
+
export type ProductionPresetStatus = 'in-production' | 'editor-only' | 'diverged';
|
|
179
|
+
|
|
180
|
+
export interface ProductionComparison {
|
|
181
|
+
status: ProductionPresetStatus;
|
|
182
|
+
productionPreset: PresetProductionInfo | null;
|
|
183
|
+
themeDrift: boolean;
|
|
184
|
+
driftedComponents: string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function compareActiveToProduction(
|
|
188
|
+
activePreset: Preset,
|
|
189
|
+
): Promise<ProductionComparison> {
|
|
190
|
+
const [productionPreset, themeInfo, components] = await Promise.all([
|
|
191
|
+
getProductionPreset(),
|
|
192
|
+
getProductionInfo(),
|
|
193
|
+
listComponents(),
|
|
194
|
+
]);
|
|
195
|
+
const componentProdMap = new Map(components.map((c) => [c.name, c.productionFile]));
|
|
196
|
+
const themeDrift = themeInfo.fileName !== activePreset.theme;
|
|
197
|
+
const driftedComponents: string[] = [];
|
|
198
|
+
for (const [comp, file] of Object.entries(activePreset.componentConfigs)) {
|
|
199
|
+
const prod = componentProdMap.get(comp);
|
|
200
|
+
if (prod !== file) driftedComponents.push(comp);
|
|
201
|
+
}
|
|
202
|
+
const manifestMatches = !themeDrift && driftedComponents.length === 0;
|
|
203
|
+
const activeFile = activePreset._fileName;
|
|
204
|
+
const isActiveProductionPreset =
|
|
205
|
+
!!activeFile && !!productionPreset && productionPreset.fileName === activeFile;
|
|
206
|
+
|
|
207
|
+
let status: ProductionPresetStatus;
|
|
208
|
+
if (!isActiveProductionPreset) {
|
|
209
|
+
status = 'editor-only';
|
|
210
|
+
} else {
|
|
211
|
+
status = manifestMatches ? 'in-production' : 'diverged';
|
|
212
|
+
}
|
|
213
|
+
return { status, productionPreset, themeDrift, driftedComponents };
|
|
214
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { writable } from 'svelte/store';
|
|
2
|
+
import type { ProductionInfo } from './themeService';
|
|
3
|
+
import type { ProductionComparison } from './presetService';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Monotonic counter that ticks every time a production pointer flips —
|
|
7
|
+
* theme production, a component's production, or a preset applied to
|
|
8
|
+
* production. UI surfaces that compare against current production state
|
|
9
|
+
* (notably `PresetFileManager`) subscribe to this so an Adopt click in a
|
|
10
|
+
* sibling manager refreshes their derived status without per-pair wiring.
|
|
11
|
+
*
|
|
12
|
+
* Bumpers: `ThemeFileManager.handleApplyToProduction`,
|
|
13
|
+
* `ComponentFileManager.handleUpdateProduction`,
|
|
14
|
+
* `presetService.applyPresetToProduction` (called from `PresetFileManager`).
|
|
15
|
+
* Anyone setting `_production.json` should bump.
|
|
16
|
+
*/
|
|
17
|
+
export const productionRevision = writable(0);
|
|
18
|
+
|
|
19
|
+
export function bumpProductionRevision(): void {
|
|
20
|
+
productionRevision.update((n) => n + 1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cached production-state stores. The Theme and Preset file managers live in
|
|
25
|
+
* the sidebar footer, swapping in/out of the DOM as the user toggles between
|
|
26
|
+
* the tokens and components views. Keeping the last-known production state in
|
|
27
|
+
* module-level Svelte stores means a remount renders the correct Adopt-button
|
|
28
|
+
* state on the first frame instead of flashing through "not in sync" while a
|
|
29
|
+
* fresh fetch resolves.
|
|
30
|
+
*/
|
|
31
|
+
export const themeProductionInfo = writable<ProductionInfo | null>(null);
|
|
32
|
+
export const presetProductionComparison = writable<ProductionComparison | null>(null);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { componentRegistryEntries, validateRegistryAgainstServerScan } from '../component-editor/registry';
|
|
9
9
|
import { listComponents } from '../lib/componentConfigService';
|
|
10
10
|
import { selectedComponent } from '../lib/editorViewStore';
|
|
11
|
+
import { componentDirty } from '../lib/editorStore';
|
|
11
12
|
// Editor chrome + form controls + icon font must be JS imports (not @import
|
|
12
13
|
// inside the style block) so Vite resolves them via the module graph
|
|
13
14
|
// regardless of how the consumer compiles Svelte CSS (external ?lang.css vs
|
|
@@ -149,12 +150,16 @@
|
|
|
149
150
|
<button
|
|
150
151
|
class="nav-item"
|
|
151
152
|
class:active={$selectedComponent === item.id}
|
|
153
|
+
class:dirty={$componentDirty[item.id]}
|
|
152
154
|
onmouseenter={(e) => showHint(item.label, e.currentTarget)}
|
|
153
155
|
onmouseleave={hideHint}
|
|
154
156
|
onclick={() => selectComponent(item.id)}
|
|
155
157
|
>
|
|
156
158
|
<i class={item.icon}></i>
|
|
157
159
|
<span class="rail-label">{item.label}</span>
|
|
160
|
+
{#if $componentDirty[item.id]}
|
|
161
|
+
<span class="dirty-dot" aria-label="Unsaved changes" title="Unsaved changes"></span>
|
|
162
|
+
{/if}
|
|
158
163
|
</button>
|
|
159
164
|
{/each}
|
|
160
165
|
</div>
|
|
@@ -334,6 +339,7 @@
|
|
|
334
339
|
}
|
|
335
340
|
|
|
336
341
|
.nav-item {
|
|
342
|
+
position: relative;
|
|
337
343
|
display: grid;
|
|
338
344
|
grid-template-columns: 48px 1fr;
|
|
339
345
|
align-items: center;
|
|
@@ -369,6 +375,25 @@
|
|
|
369
375
|
opacity: 0.85;
|
|
370
376
|
}
|
|
371
377
|
|
|
378
|
+
/* Amber dot indicating unsaved changes. Anchored to the top-right of the
|
|
379
|
+
icon column so it stays visible whether the rail is collapsed (icon-only)
|
|
380
|
+
or expanded (icon + label). */
|
|
381
|
+
.dirty-dot {
|
|
382
|
+
position: absolute;
|
|
383
|
+
top: 8px;
|
|
384
|
+
left: 30px;
|
|
385
|
+
width: 7px;
|
|
386
|
+
height: 7px;
|
|
387
|
+
border-radius: 50%;
|
|
388
|
+
background: var(--ui-highlight);
|
|
389
|
+
box-shadow: 0 0 0 2px black;
|
|
390
|
+
pointer-events: none;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.nav-item.dirty {
|
|
394
|
+
color: var(--ui-text-secondary);
|
|
395
|
+
}
|
|
396
|
+
|
|
372
397
|
.content {
|
|
373
398
|
padding: 0 var(--ui-space-32);
|
|
374
399
|
background: black;
|