@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.
@@ -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.6.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": [
@@ -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: auto;
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
- 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
+ }
@@ -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;