@motion-proto/live-tokens 0.5.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/README.md CHANGED
@@ -127,13 +127,56 @@ The components carry their own design-token aliases (declared inside each `.svel
127
127
 
128
128
  ### Styles
129
129
 
130
+ Editor chrome (`ui-editor.css`, `ui-form-controls.css`) and the icon font are
131
+ **auto-loaded by the editor pages themselves**; you don't import them. The
132
+ only stylesheet a consumer needs is a `tokens.css` declaring the design-token
133
+ CSS variables on `:root`.
134
+
135
+ You can use the package's default as a starting point:
136
+
137
+ ```ts
138
+ import '@motion-proto/live-tokens/starter/tokens.css';
139
+ import '@motion-proto/live-tokens/starter/site.css'; // optional: themed h1/p/a styles
140
+ import '@motion-proto/live-tokens/starter/fonts.css'; // optional: Fraunces + Manrope @font-face
141
+ ```
142
+
143
+ …or copy `node_modules/@motion-proto/live-tokens/src/styles/tokens.css` into
144
+ your project and edit. The editor will seed `themes/default.json` on first
145
+ run and you can promote your edits back into the file.
146
+
147
+ ## Consuming live-tokens from scratch
148
+
149
+ The minimum a consumer needs after `npm install @motion-proto/live-tokens`:
150
+
151
+ ```ts
152
+ // src/main.ts
153
+ import '@motion-proto/live-tokens/starter/tokens.css';
154
+ import { mount } from 'svelte';
155
+ import App from './App.svelte';
156
+
157
+ mount(App, { target: document.getElementById('app')! });
158
+ ```
159
+
160
+ ```svelte
161
+ <!-- src/App.svelte -->
162
+ <script lang="ts">
163
+ import Editor from '@motion-proto/live-tokens/editor';
164
+ </script>
165
+
166
+ <Editor />
167
+ ```
168
+
130
169
  ```ts
131
- import '@motion-proto/live-tokens/styles/ui-editor.css';
132
- import '@motion-proto/live-tokens/styles/form-controls.css';
133
- import '@motion-proto/live-tokens/styles/fonts.css';
170
+ // vite.config.ts
171
+ import { defineConfig } from 'vite';
172
+ import { svelte } from '@sveltejs/vite-plugin-svelte';
173
+
174
+ export default defineConfig({
175
+ plugins: [svelte()],
176
+ });
134
177
  ```
135
178
 
136
- You'll also need your own `src/styles/tokens.css` declaring your design tokens as CSS variables on `:root`. Start from the package's default (`node_modules/@motion-proto/live-tokens/src/styles/tokens.css`) and overlay your overrides or let the editor seed `themes/default.json` on first run and promote it.
179
+ No `css: 'injected'` workaround, no `optimizeDeps` excludes `vite build` works as-is. (You'll want the full `themeFileApi` plugin from the Quick install section above when you're ready to persist edits to disk.)
137
180
 
138
181
  ## Greenfield? Use the starter
139
182
 
@@ -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 },
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 6/7.",
6
6
  "keywords": [
@@ -29,11 +29,12 @@
29
29
  "src/pages/ComponentEditorPage.svelte",
30
30
  "src/pages/ComponentEditorPage.svelte.d.ts",
31
31
  "src/styles/ui-editor.css",
32
- "src/styles/form-controls.css",
32
+ "src/styles/ui-form-controls.css",
33
33
  "src/styles/fonts.css",
34
34
  "src/styles/fonts",
35
35
  "src/styles/_padding.scss",
36
36
  "src/styles/tokens.css",
37
+ "src/styles/site.css",
37
38
  "src/data",
38
39
  "src/assets",
39
40
  "dist-plugin",
@@ -77,8 +78,9 @@
77
78
  "require": "./dist-plugin/index.cjs"
78
79
  },
79
80
  "./styles/ui-editor.css": "./src/styles/ui-editor.css",
80
- "./styles/form-controls.css": "./src/styles/form-controls.css",
81
- "./styles/fonts.css": "./src/styles/fonts.css"
81
+ "./starter/tokens.css": "./src/styles/tokens.css",
82
+ "./starter/site.css": "./src/styles/site.css",
83
+ "./starter/fonts.css": "./src/styles/fonts.css"
82
84
  },
83
85
  "scripts": {
84
86
  "dev": "vite",
@@ -90,7 +92,10 @@
90
92
  "build:plugin": "tsup src/vite-plugin/index.ts --out-dir dist-plugin --format esm,cjs --dts --external vite --platform node --clean",
91
93
  "build:lib": "npm run build:plugin",
92
94
  "deploy:local": "bash scripts/deploy-local.sh",
93
- "prepublishOnly": "npm run build:lib"
95
+ "check:no-style-imports": "node scripts/check-no-style-imports.mjs",
96
+ "check:editor-font-isolation": "node scripts/check-editor-font-isolation.mjs",
97
+ "check:smoke-install": "bash scripts/smoke-install.sh",
98
+ "prepublishOnly": "npm run check:no-style-imports && npm run check:editor-font-isolation && npm run build:lib && npm run check:smoke-install"
94
99
  },
95
100
  "peerDependencies": {
96
101
  "sass": "^1.0",
@@ -117,7 +117,7 @@
117
117
 
118
118
  <label>
119
119
  <span>Cancel button (left)</span>
120
- <select class="form-select" value={cancelVariant} onchange={setCancelVariant}>
120
+ <select class="ui-form-select" value={cancelVariant} onchange={setCancelVariant}>
121
121
  {#each BUTTON_VARIANTS as v}
122
122
  <option value={v}>{variantLabel(v)}</option>
123
123
  {/each}
@@ -125,7 +125,7 @@
125
125
  </label>
126
126
  <label>
127
127
  <span>Confirm button (right)</span>
128
- <select class="form-select" value={confirmVariant} onchange={setConfirmVariant}>
128
+ <select class="ui-form-select" value={confirmVariant} onchange={setConfirmVariant}>
129
129
  {#each BUTTON_VARIANTS as v}
130
130
  <option value={v}>{variantLabel(v)}</option>
131
131
  {/each}
@@ -122,7 +122,7 @@
122
122
  </label>
123
123
  <label>
124
124
  <span>Right button</span>
125
- <select class="form-select" bind:value={rightOption}>
125
+ <select class="ui-form-select" bind:value={rightOption}>
126
126
  {#each BUTTON_VARIANT_OPTIONS as v}
127
127
  <option value={v}>{variantLabel(v)}</option>
128
128
  {/each}
@@ -130,7 +130,7 @@
130
130
  </label>
131
131
  <label>
132
132
  <span>Left button</span>
133
- <select class="form-select" bind:value={leftOption}>
133
+ <select class="ui-form-select" bind:value={leftOption}>
134
134
  {#each BUTTON_VARIANT_OPTIONS as v}
135
135
  <option value={v}>{variantLabel(v)}</option>
136
136
  {/each}
@@ -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}"`;
@@ -499,7 +501,7 @@
499
501
 
500
502
  .cfm-title-row {
501
503
  display: flex;
502
- align-items: baseline;
504
+ align-items: center;
503
505
  gap: var(--ui-space-12);
504
506
  flex-wrap: wrap;
505
507
  }
@@ -518,6 +520,7 @@
518
520
  display: inline-flex;
519
521
  align-items: center;
520
522
  gap: var(--ui-space-6);
523
+ margin-left: 2.5rem;
521
524
  height: 26px;
522
525
  padding: 0 14px;
523
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
- files?: ComponentConfigMeta[];
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 { show = $bindable(false), currentDisplayName = '', files = [], onsave }: Props = $props();
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 'The name "default" is reserved for the core component definition.';
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="Save As"
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="Config name…"
117
+ {placeholder}
101
118
  />
102
119
  <button
103
120
  type="button"
@@ -104,7 +104,7 @@
104
104
 
105
105
  .columns-overlay__info {
106
106
  position: fixed;
107
- left: 12px;
107
+ right: 12px;
108
108
  bottom: 12px;
109
109
  display: inline-flex;
110
110
  align-items: center;
@@ -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
+ }