@motion-proto/live-tokens 0.6.0 → 0.6.2
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 +80 -4
- package/dist-plugin/index.js +80 -4
- package/package.json +2 -2
- 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
|
@@ -143,6 +143,7 @@ async function dispatch(req, res, routes) {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
// src/vite-plugin/themeFileApi.ts
|
|
146
|
+
var PKG_VERSION = true ? "0.6.2" : "";
|
|
146
147
|
function themeFileApi(opts) {
|
|
147
148
|
const THEMES_DIR = import_path2.default.resolve(opts.themesDir);
|
|
148
149
|
const CSS_PATH = import_path2.default.resolve(opts.tokensCssPath);
|
|
@@ -168,7 +169,7 @@ function themeFileApi(opts) {
|
|
|
168
169
|
themesResource.ensureDir();
|
|
169
170
|
if (!import_fs2.default.existsSync(import_path2.default.join(THEMES_DIR, "default.json"))) {
|
|
170
171
|
const defaultTheme = {
|
|
171
|
-
name: "Default",
|
|
172
|
+
name: "Default Theme",
|
|
172
173
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
173
174
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
174
175
|
editorConfigs: {},
|
|
@@ -369,7 +370,7 @@ ${newVars}
|
|
|
369
370
|
}
|
|
370
371
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
371
372
|
const defaultPreset = {
|
|
372
|
-
name: "Default",
|
|
373
|
+
name: "Default Preset",
|
|
373
374
|
createdAt: now,
|
|
374
375
|
updatedAt: now,
|
|
375
376
|
theme: themesResource.getActiveName(),
|
|
@@ -444,6 +445,7 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
444
445
|
const COMPONENT_CONFIGS_ROUTE = `${API_BASE}/component-configs`;
|
|
445
446
|
const PRESETS_ROUTE = `${API_BASE}/presets`;
|
|
446
447
|
const PRESETS_ACTIVE_ROUTE = `${API_BASE}/presets/active`;
|
|
448
|
+
const PRESETS_PRODUCTION_ROUTE = `${API_BASE}/presets/production`;
|
|
447
449
|
const THEME_BY_NAME_REGEX = new RegExp(`^${escapedBase}/themes/([a-z0-9\\-_]+)$`);
|
|
448
450
|
const COMP_LIST_REGEX = new RegExp(`^${escapedBase}/component-configs/([a-z0-9\\-_]+)$`);
|
|
449
451
|
const COMP_ACTIVE_REGEX = new RegExp(`^${escapedBase}/component-configs/([a-z0-9\\-_]+)/active$`);
|
|
@@ -754,6 +756,73 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
754
756
|
presetsResource.setActiveName(fileName);
|
|
755
757
|
jsonResponse(res, 200, { ok: true, activeFile: fileName });
|
|
756
758
|
}
|
|
759
|
+
async function handleGetProductionPreset({ res }) {
|
|
760
|
+
const prodFile = presetsResource.getProductionName();
|
|
761
|
+
const filePath = presetsResource.filePath(prodFile);
|
|
762
|
+
if (!import_fs2.default.existsSync(filePath)) {
|
|
763
|
+
jsonResponse(res, 200, {
|
|
764
|
+
fileName: prodFile,
|
|
765
|
+
name: prodFile,
|
|
766
|
+
theme: "default",
|
|
767
|
+
componentConfigs: {},
|
|
768
|
+
updatedAt: ""
|
|
769
|
+
});
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const data = JSON.parse(import_fs2.default.readFileSync(filePath, "utf-8"));
|
|
773
|
+
jsonResponse(res, 200, {
|
|
774
|
+
fileName: prodFile,
|
|
775
|
+
name: data.name || prodFile,
|
|
776
|
+
theme: data.theme || "default",
|
|
777
|
+
componentConfigs: data.componentConfigs || {},
|
|
778
|
+
updatedAt: data.updatedAt || ""
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
async function handleSetProductionPreset({ req, res }) {
|
|
782
|
+
const body = JSON.parse(await readBody(req));
|
|
783
|
+
const fileName = sanitizeFileName(body.name || "default");
|
|
784
|
+
const presetPath = presetsResource.filePath(fileName);
|
|
785
|
+
if (!import_fs2.default.existsSync(presetPath)) {
|
|
786
|
+
jsonResponse(res, 404, { error: "Preset not found" });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const preset = JSON.parse(import_fs2.default.readFileSync(presetPath, "utf-8"));
|
|
790
|
+
const themeName = sanitizeFileName(preset.theme || "default");
|
|
791
|
+
if (!import_fs2.default.existsSync(themesResource.filePath(themeName))) {
|
|
792
|
+
jsonResponse(res, 422, { error: `Preset references missing theme: ${themeName}` });
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const knownComponents = new Set(listComponentNames());
|
|
796
|
+
const componentConfigs = preset.componentConfigs ?? {};
|
|
797
|
+
const apply = [];
|
|
798
|
+
for (const [comp, configFile] of Object.entries(componentConfigs)) {
|
|
799
|
+
if (!knownComponents.has(comp)) continue;
|
|
800
|
+
const sanitized = sanitizeFileName(String(configFile) || "default");
|
|
801
|
+
if (!import_fs2.default.existsSync(componentResource(comp).filePath(sanitized))) {
|
|
802
|
+
jsonResponse(res, 422, {
|
|
803
|
+
error: `Preset references missing config: ${comp}/${sanitized}`
|
|
804
|
+
});
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
apply.push([comp, sanitized]);
|
|
808
|
+
}
|
|
809
|
+
themesResource.setProductionName(themeName);
|
|
810
|
+
for (const [comp, configFile] of apply) {
|
|
811
|
+
componentResource(comp).setProductionName(configFile);
|
|
812
|
+
}
|
|
813
|
+
presetsResource.setProductionName(fileName);
|
|
814
|
+
syncTokensToCss(themeName);
|
|
815
|
+
syncFontsToCss(themeName);
|
|
816
|
+
syncComponentsToCss();
|
|
817
|
+
jsonResponse(res, 200, {
|
|
818
|
+
ok: true,
|
|
819
|
+
fileName,
|
|
820
|
+
name: preset.name || fileName,
|
|
821
|
+
theme: themeName,
|
|
822
|
+
componentConfigs: Object.fromEntries(apply),
|
|
823
|
+
updatedAt: preset.updatedAt || ""
|
|
824
|
+
});
|
|
825
|
+
}
|
|
757
826
|
async function handlePresetByName({ params, req, res }) {
|
|
758
827
|
const [fileName] = params;
|
|
759
828
|
const filePath = presetsResource.filePath(fileName);
|
|
@@ -768,6 +837,10 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
768
837
|
return;
|
|
769
838
|
}
|
|
770
839
|
if (req.method === "PUT") {
|
|
840
|
+
if (fileName === "default") {
|
|
841
|
+
jsonResponse(res, 403, { error: "Cannot overwrite the default preset" });
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
771
844
|
const body = JSON.parse(await readBody(req));
|
|
772
845
|
body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
773
846
|
if (import_fs2.default.existsSync(filePath)) {
|
|
@@ -884,10 +957,12 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
884
957
|
{ method: "GET", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
885
958
|
{ method: "PUT", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
886
959
|
{ method: "DELETE", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
887
|
-
// Presets — list / active are exact strings, must run before regexes
|
|
960
|
+
// Presets — list / active / production are exact strings, must run before regexes
|
|
888
961
|
{ method: "GET", pattern: PRESETS_ROUTE, handler: handleListPresets },
|
|
889
962
|
{ method: "GET", pattern: PRESETS_ACTIVE_ROUTE, handler: handleGetActivePreset },
|
|
890
963
|
{ method: "PUT", pattern: PRESETS_ACTIVE_ROUTE, handler: handleSetActivePreset },
|
|
964
|
+
{ method: "GET", pattern: PRESETS_PRODUCTION_ROUTE, handler: handleGetProductionPreset },
|
|
965
|
+
{ method: "PUT", pattern: PRESETS_PRODUCTION_ROUTE, handler: handleSetProductionPreset },
|
|
891
966
|
// Presets — :name/apply (more specific than :name)
|
|
892
967
|
{ method: "PUT", pattern: PRESET_APPLY_REGEX, handler: handleApplyPreset },
|
|
893
968
|
{ method: "POST", pattern: PRESET_APPLY_REGEX, handler: methodNotAllowed },
|
|
@@ -903,7 +978,8 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
903
978
|
config() {
|
|
904
979
|
return {
|
|
905
980
|
define: {
|
|
906
|
-
__PROJECT_ROOT__: JSON.stringify(process.cwd())
|
|
981
|
+
__PROJECT_ROOT__: JSON.stringify(process.cwd()),
|
|
982
|
+
__APP_VERSION__: JSON.stringify(PKG_VERSION)
|
|
907
983
|
}
|
|
908
984
|
};
|
|
909
985
|
},
|
package/dist-plugin/index.js
CHANGED
|
@@ -107,6 +107,7 @@ async function dispatch(req, res, routes) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
// src/vite-plugin/themeFileApi.ts
|
|
110
|
+
var PKG_VERSION = true ? "0.6.2" : "";
|
|
110
111
|
function themeFileApi(opts) {
|
|
111
112
|
const THEMES_DIR = path2.resolve(opts.themesDir);
|
|
112
113
|
const CSS_PATH = path2.resolve(opts.tokensCssPath);
|
|
@@ -132,7 +133,7 @@ function themeFileApi(opts) {
|
|
|
132
133
|
themesResource.ensureDir();
|
|
133
134
|
if (!fs2.existsSync(path2.join(THEMES_DIR, "default.json"))) {
|
|
134
135
|
const defaultTheme = {
|
|
135
|
-
name: "Default",
|
|
136
|
+
name: "Default Theme",
|
|
136
137
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
137
138
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
138
139
|
editorConfigs: {},
|
|
@@ -333,7 +334,7 @@ ${newVars}
|
|
|
333
334
|
}
|
|
334
335
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
335
336
|
const defaultPreset = {
|
|
336
|
-
name: "Default",
|
|
337
|
+
name: "Default Preset",
|
|
337
338
|
createdAt: now,
|
|
338
339
|
updatedAt: now,
|
|
339
340
|
theme: themesResource.getActiveName(),
|
|
@@ -408,6 +409,7 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
408
409
|
const COMPONENT_CONFIGS_ROUTE = `${API_BASE}/component-configs`;
|
|
409
410
|
const PRESETS_ROUTE = `${API_BASE}/presets`;
|
|
410
411
|
const PRESETS_ACTIVE_ROUTE = `${API_BASE}/presets/active`;
|
|
412
|
+
const PRESETS_PRODUCTION_ROUTE = `${API_BASE}/presets/production`;
|
|
411
413
|
const THEME_BY_NAME_REGEX = new RegExp(`^${escapedBase}/themes/([a-z0-9\\-_]+)$`);
|
|
412
414
|
const COMP_LIST_REGEX = new RegExp(`^${escapedBase}/component-configs/([a-z0-9\\-_]+)$`);
|
|
413
415
|
const COMP_ACTIVE_REGEX = new RegExp(`^${escapedBase}/component-configs/([a-z0-9\\-_]+)/active$`);
|
|
@@ -718,6 +720,73 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
718
720
|
presetsResource.setActiveName(fileName);
|
|
719
721
|
jsonResponse(res, 200, { ok: true, activeFile: fileName });
|
|
720
722
|
}
|
|
723
|
+
async function handleGetProductionPreset({ res }) {
|
|
724
|
+
const prodFile = presetsResource.getProductionName();
|
|
725
|
+
const filePath = presetsResource.filePath(prodFile);
|
|
726
|
+
if (!fs2.existsSync(filePath)) {
|
|
727
|
+
jsonResponse(res, 200, {
|
|
728
|
+
fileName: prodFile,
|
|
729
|
+
name: prodFile,
|
|
730
|
+
theme: "default",
|
|
731
|
+
componentConfigs: {},
|
|
732
|
+
updatedAt: ""
|
|
733
|
+
});
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const data = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
|
|
737
|
+
jsonResponse(res, 200, {
|
|
738
|
+
fileName: prodFile,
|
|
739
|
+
name: data.name || prodFile,
|
|
740
|
+
theme: data.theme || "default",
|
|
741
|
+
componentConfigs: data.componentConfigs || {},
|
|
742
|
+
updatedAt: data.updatedAt || ""
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
async function handleSetProductionPreset({ req, res }) {
|
|
746
|
+
const body = JSON.parse(await readBody(req));
|
|
747
|
+
const fileName = sanitizeFileName(body.name || "default");
|
|
748
|
+
const presetPath = presetsResource.filePath(fileName);
|
|
749
|
+
if (!fs2.existsSync(presetPath)) {
|
|
750
|
+
jsonResponse(res, 404, { error: "Preset not found" });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const preset = JSON.parse(fs2.readFileSync(presetPath, "utf-8"));
|
|
754
|
+
const themeName = sanitizeFileName(preset.theme || "default");
|
|
755
|
+
if (!fs2.existsSync(themesResource.filePath(themeName))) {
|
|
756
|
+
jsonResponse(res, 422, { error: `Preset references missing theme: ${themeName}` });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const knownComponents = new Set(listComponentNames());
|
|
760
|
+
const componentConfigs = preset.componentConfigs ?? {};
|
|
761
|
+
const apply = [];
|
|
762
|
+
for (const [comp, configFile] of Object.entries(componentConfigs)) {
|
|
763
|
+
if (!knownComponents.has(comp)) continue;
|
|
764
|
+
const sanitized = sanitizeFileName(String(configFile) || "default");
|
|
765
|
+
if (!fs2.existsSync(componentResource(comp).filePath(sanitized))) {
|
|
766
|
+
jsonResponse(res, 422, {
|
|
767
|
+
error: `Preset references missing config: ${comp}/${sanitized}`
|
|
768
|
+
});
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
apply.push([comp, sanitized]);
|
|
772
|
+
}
|
|
773
|
+
themesResource.setProductionName(themeName);
|
|
774
|
+
for (const [comp, configFile] of apply) {
|
|
775
|
+
componentResource(comp).setProductionName(configFile);
|
|
776
|
+
}
|
|
777
|
+
presetsResource.setProductionName(fileName);
|
|
778
|
+
syncTokensToCss(themeName);
|
|
779
|
+
syncFontsToCss(themeName);
|
|
780
|
+
syncComponentsToCss();
|
|
781
|
+
jsonResponse(res, 200, {
|
|
782
|
+
ok: true,
|
|
783
|
+
fileName,
|
|
784
|
+
name: preset.name || fileName,
|
|
785
|
+
theme: themeName,
|
|
786
|
+
componentConfigs: Object.fromEntries(apply),
|
|
787
|
+
updatedAt: preset.updatedAt || ""
|
|
788
|
+
});
|
|
789
|
+
}
|
|
721
790
|
async function handlePresetByName({ params, req, res }) {
|
|
722
791
|
const [fileName] = params;
|
|
723
792
|
const filePath = presetsResource.filePath(fileName);
|
|
@@ -732,6 +801,10 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
732
801
|
return;
|
|
733
802
|
}
|
|
734
803
|
if (req.method === "PUT") {
|
|
804
|
+
if (fileName === "default") {
|
|
805
|
+
jsonResponse(res, 403, { error: "Cannot overwrite the default preset" });
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
735
808
|
const body = JSON.parse(await readBody(req));
|
|
736
809
|
body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
737
810
|
if (fs2.existsSync(filePath)) {
|
|
@@ -848,10 +921,12 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
848
921
|
{ method: "GET", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
849
922
|
{ method: "PUT", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
850
923
|
{ method: "DELETE", pattern: THEME_BY_NAME_REGEX, handler: handleThemeByName },
|
|
851
|
-
// Presets — list / active are exact strings, must run before regexes
|
|
924
|
+
// Presets — list / active / production are exact strings, must run before regexes
|
|
852
925
|
{ method: "GET", pattern: PRESETS_ROUTE, handler: handleListPresets },
|
|
853
926
|
{ method: "GET", pattern: PRESETS_ACTIVE_ROUTE, handler: handleGetActivePreset },
|
|
854
927
|
{ method: "PUT", pattern: PRESETS_ACTIVE_ROUTE, handler: handleSetActivePreset },
|
|
928
|
+
{ method: "GET", pattern: PRESETS_PRODUCTION_ROUTE, handler: handleGetProductionPreset },
|
|
929
|
+
{ method: "PUT", pattern: PRESETS_PRODUCTION_ROUTE, handler: handleSetProductionPreset },
|
|
855
930
|
// Presets — :name/apply (more specific than :name)
|
|
856
931
|
{ method: "PUT", pattern: PRESET_APPLY_REGEX, handler: handleApplyPreset },
|
|
857
932
|
{ method: "POST", pattern: PRESET_APPLY_REGEX, handler: methodNotAllowed },
|
|
@@ -867,7 +942,8 @@ ${COMPONENT_OVERRIDES_END}
|
|
|
867
942
|
config() {
|
|
868
943
|
return {
|
|
869
944
|
define: {
|
|
870
|
-
__PROJECT_ROOT__: JSON.stringify(process.cwd())
|
|
945
|
+
__PROJECT_ROOT__: JSON.stringify(process.cwd()),
|
|
946
|
+
__APP_VERSION__: JSON.stringify(PKG_VERSION)
|
|
871
947
|
}
|
|
872
948
|
};
|
|
873
949
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motion-proto/live-tokens",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 6/7.",
|
|
6
6
|
"keywords": [
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
90
90
|
"test": "vitest run",
|
|
91
91
|
"test:watch": "vitest",
|
|
92
|
-
"build:plugin": "tsup
|
|
92
|
+
"build:plugin": "tsup",
|
|
93
93
|
"build:lib": "npm run build:plugin",
|
|
94
94
|
"deploy:local": "bash scripts/deploy-local.sh",
|
|
95
95
|
"check:no-style-imports": "node scripts/check-no-style-imports.mjs",
|
|
@@ -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);
|