@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.
@@ -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
  },
@@ -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.0",
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 src/vite-plugin/index.ts --out-dir dist-plugin --format esm,cjs --dts --external vite --platform node --clean",
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: 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);