@motion-proto/live-tokens 0.94.0 → 0.94.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.94.1 — Adopt after a save
4
+
5
+ ### Fixed
6
+
7
+ - **Adopt stays available after you save the production theme.** Saving the
8
+ theme production ships leaves `tokens.generated.css` behind until the next
9
+ Adopt. The editor knew that only in memory, so a reload or a theme load
10
+ showed "in production" and disabled Adopt over a stale bake. Each bake now
11
+ records a fingerprint of what it rendered. The production read reports
12
+ `_baked: false` when a fresh render of the saved theme would differ, and the
13
+ Theme panel and component bars read "out of sync" until Adopt runs.
14
+ - **The Theme panel's Active name stays inside its card.** The pill counted
15
+ its padding outside its width and ran past the sidebar edge. A long theme
16
+ name now ends in an ellipsis.
17
+
3
18
  ## 0.94.0 — Tokens-view swatch swap and copy-paste
4
19
 
5
20
  ### Added
@@ -39,6 +39,7 @@ module.exports = __toCommonJS(index_exports);
39
39
 
40
40
  // vite-plugin/themeFileApi.ts
41
41
  var import_fs5 = __toESM(require("fs"), 1);
42
+ var import_crypto = require("crypto");
42
43
  var import_path5 = __toESM(require("path"), 1);
43
44
 
44
45
  // src/editor/core/themes/parsers/globalRootBlock.ts
@@ -5243,11 +5244,8 @@ function themeFileApi(opts) {
5243
5244
  }
5244
5245
  return out;
5245
5246
  }
5246
- function regenerateTokensCss() {
5247
+ function renderTokensCss() {
5247
5248
  const lines = [];
5248
- lines.push("/* Generated by themeFileApi from the production theme. Do not edit. */");
5249
- lines.push("/* tokens.css holds developer-authored defaults; this file holds editor overrides. */");
5250
- lines.push("");
5251
5249
  const productionThemeName = themesResource.getProductionName();
5252
5250
  const productionTheme = readTheme(productionThemeName)?.theme ?? null;
5253
5251
  if (themesResource.existingPath(productionThemeName) !== null && !productionTheme?.colorsAndType) {
@@ -5322,14 +5320,40 @@ function themeFileApi(opts) {
5322
5320
  lines.push("");
5323
5321
  }
5324
5322
  }
5323
+ return { body: lines.join("\n"), colorsAndTypeVarCount, componentOverrideCount };
5324
+ }
5325
+ const bakeFingerprint = (body) => (0, import_crypto.createHash)("sha256").update(body).digest("hex").slice(0, 16);
5326
+ const BAKE_FINGERPRINT_LINE = /^\/\* Bake fingerprint: ([0-9a-f]+) \*\/$/m;
5327
+ function regenerateTokensCss() {
5328
+ const { body, colorsAndTypeVarCount, componentOverrideCount } = renderTokensCss();
5329
+ const header = [
5330
+ "/* Generated by themeFileApi from the production theme. Do not edit. */",
5331
+ "/* tokens.css holds developer-authored defaults; this file holds editor overrides. */",
5332
+ `/* Bake fingerprint: ${bakeFingerprint(body)} */`,
5333
+ ""
5334
+ ];
5325
5335
  if (!import_fs5.default.existsSync(import_path5.default.dirname(GENERATED_CSS_PATH))) {
5326
5336
  import_fs5.default.mkdirSync(import_path5.default.dirname(GENERATED_CSS_PATH), { recursive: true });
5327
5337
  }
5328
- import_fs5.default.writeFileSync(GENERATED_CSS_PATH, lines.join("\n"));
5338
+ import_fs5.default.writeFileSync(GENERATED_CSS_PATH, [...header, body].join("\n"));
5329
5339
  console.log(
5330
5340
  `[regenerateTokensCss] Wrote ${import_path5.default.basename(GENERATED_CSS_PATH)} (${colorsAndTypeVarCount} theme vars, ${componentOverrideCount} component overrides)`
5331
5341
  );
5332
5342
  }
5343
+ function bakeIsCurrent() {
5344
+ let baked;
5345
+ try {
5346
+ baked = import_fs5.default.readFileSync(GENERATED_CSS_PATH, "utf-8");
5347
+ } catch {
5348
+ return false;
5349
+ }
5350
+ const recorded = BAKE_FINGERPRINT_LINE.exec(baked)?.[1];
5351
+ try {
5352
+ return recorded === bakeFingerprint(renderTokensCss().body);
5353
+ } catch {
5354
+ return false;
5355
+ }
5356
+ }
5333
5357
  function syncFontsToCss(colorsAndTypeData) {
5334
5358
  if (!colorsAndTypeData) return;
5335
5359
  const sources = colorsAndTypeData.fontSources;
@@ -6025,7 +6049,7 @@ ${lines.join("\n")}
6025
6049
  respondUnreadableTheme(res, fileName, "Production theme not found");
6026
6050
  return;
6027
6051
  }
6028
- jsonResponse(res, 200, { ...read.theme, _fileName: fileName });
6052
+ jsonResponse(res, 200, { ...read.theme, _fileName: fileName, _baked: bakeIsCurrent() });
6029
6053
  }
6030
6054
  async function handleGetActiveTheme({ res }) {
6031
6055
  const activeFile = themesResource.getActiveName();
@@ -37,6 +37,7 @@ import {
37
37
 
38
38
  // vite-plugin/themeFileApi.ts
39
39
  import fs2 from "fs";
40
+ import { createHash } from "crypto";
40
41
  import path3 from "path";
41
42
 
42
43
  // src/editor/core/themes/migrations/2026-07-21-palette-oklch-basis.ts
@@ -445,11 +446,8 @@ function themeFileApi(opts) {
445
446
  }
446
447
  return out;
447
448
  }
448
- function regenerateTokensCss() {
449
+ function renderTokensCss() {
449
450
  const lines = [];
450
- lines.push("/* Generated by themeFileApi from the production theme. Do not edit. */");
451
- lines.push("/* tokens.css holds developer-authored defaults; this file holds editor overrides. */");
452
- lines.push("");
453
451
  const productionThemeName = themesResource.getProductionName();
454
452
  const productionTheme = readTheme(productionThemeName)?.theme ?? null;
455
453
  if (themesResource.existingPath(productionThemeName) !== null && !productionTheme?.colorsAndType) {
@@ -524,14 +522,40 @@ function themeFileApi(opts) {
524
522
  lines.push("");
525
523
  }
526
524
  }
525
+ return { body: lines.join("\n"), colorsAndTypeVarCount, componentOverrideCount };
526
+ }
527
+ const bakeFingerprint = (body) => createHash("sha256").update(body).digest("hex").slice(0, 16);
528
+ const BAKE_FINGERPRINT_LINE = /^\/\* Bake fingerprint: ([0-9a-f]+) \*\/$/m;
529
+ function regenerateTokensCss() {
530
+ const { body, colorsAndTypeVarCount, componentOverrideCount } = renderTokensCss();
531
+ const header = [
532
+ "/* Generated by themeFileApi from the production theme. Do not edit. */",
533
+ "/* tokens.css holds developer-authored defaults; this file holds editor overrides. */",
534
+ `/* Bake fingerprint: ${bakeFingerprint(body)} */`,
535
+ ""
536
+ ];
527
537
  if (!fs2.existsSync(path3.dirname(GENERATED_CSS_PATH))) {
528
538
  fs2.mkdirSync(path3.dirname(GENERATED_CSS_PATH), { recursive: true });
529
539
  }
530
- fs2.writeFileSync(GENERATED_CSS_PATH, lines.join("\n"));
540
+ fs2.writeFileSync(GENERATED_CSS_PATH, [...header, body].join("\n"));
531
541
  console.log(
532
542
  `[regenerateTokensCss] Wrote ${path3.basename(GENERATED_CSS_PATH)} (${colorsAndTypeVarCount} theme vars, ${componentOverrideCount} component overrides)`
533
543
  );
534
544
  }
545
+ function bakeIsCurrent() {
546
+ let baked;
547
+ try {
548
+ baked = fs2.readFileSync(GENERATED_CSS_PATH, "utf-8");
549
+ } catch {
550
+ return false;
551
+ }
552
+ const recorded = BAKE_FINGERPRINT_LINE.exec(baked)?.[1];
553
+ try {
554
+ return recorded === bakeFingerprint(renderTokensCss().body);
555
+ } catch {
556
+ return false;
557
+ }
558
+ }
535
559
  function syncFontsToCss(colorsAndTypeData) {
536
560
  if (!colorsAndTypeData) return;
537
561
  const sources = colorsAndTypeData.fontSources;
@@ -1227,7 +1251,7 @@ ${lines.join("\n")}
1227
1251
  respondUnreadableTheme(res, fileName, "Production theme not found");
1228
1252
  return;
1229
1253
  }
1230
- jsonResponse(res, 200, { ...read.theme, _fileName: fileName });
1254
+ jsonResponse(res, 200, { ...read.theme, _fileName: fileName, _baked: bakeIsCurrent() });
1231
1255
  }
1232
1256
  async function handleGetActiveTheme({ res }) {
1233
1257
  const activeFile = themesResource.getActiveName();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.94.0",
3
+ "version": "0.94.1",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -23,20 +23,19 @@
23
23
  } from '../../core/store/editorStore';
24
24
  import {
25
25
  bumpComponentActiveRevision,
26
- bumpProductionRevision,
27
- liveMovedSinceBake,
26
+ liveUnsaved,
28
27
  productionRevision,
29
28
  productionTheme,
30
29
  } from '../../core/productionPulse';
31
30
  import {
32
31
  adoptTheme,
33
32
  getActiveTheme,
34
- getProductionTheme,
35
33
  listThemes,
36
34
  saveActiveTheme,
37
35
  saveAsTheme,
38
36
  } from '../../core/themes/themeService';
39
37
  import type { ThemeMeta } from '../../core/themes/themeTypes';
38
+ import { themeProductionState } from '../../core/themes/themeSummary';
40
39
  import { safeFetch } from '../../core/storage/storage';
41
40
  import { API_BASE } from '../../core/storage/apiBase';
42
41
  import { flashStatus } from '../../core/flashStatus';
@@ -101,7 +100,11 @@
101
100
  // still the published one, so only the shared signal can say the config on
102
101
  // screen is not what production runs.
103
102
  let isApplied = $derived(
104
- productionDoc !== null && productionDoc === editorDoc && !compDirty && !$liveMovedSinceBake,
103
+ themeProductionState({
104
+ openTheme: editorDoc,
105
+ production: $productionTheme,
106
+ unsaved: compDirty || $liveUnsaved,
107
+ }).inProduction,
105
108
  );
106
109
  let resetDirty = $derived(!!resetVariables && compDirty);
107
110
 
@@ -124,20 +127,9 @@
124
127
  }
125
128
  }
126
129
 
127
- async function refreshProduction() {
128
- // Preserve the last answer on a transient failure rather than clobbering
129
- // the store to null, which would read as "production unknown".
130
- try {
131
- productionTheme.set(await getProductionTheme());
132
- } catch {
133
- // silent
134
- }
135
- }
136
-
137
130
  onMount(async () => {
138
131
  await refreshFiles();
139
132
  await refreshLive();
140
- await refreshProduction();
141
133
  window.addEventListener('keydown', handleKeydown);
142
134
  });
143
135
 
@@ -262,8 +254,6 @@
262
254
  if (wasDirty) await persist();
263
255
  await saveActiveTheme();
264
256
  await adoptTheme();
265
- await refreshProduction();
266
- bumpProductionRevision();
267
257
  adoptFeedback = wasDirty
268
258
  ? `Saved "${adoptingName}" and adopted`
269
259
  : `Adopted "${adoptingName}"`;
@@ -2,7 +2,7 @@ import type { ComponentConfig, ComponentConfigMeta, LiveSource } from '../themes
2
2
  import type { EditorState } from '../store/editorTypes';
3
3
  import { versionedFileResource } from '../storage/files/versionedFileResourceClient';
4
4
  import { API_BASE } from '../storage/apiBase';
5
- import { liveMovedSinceBake } from '../productionPulse';
5
+ import { liveUnsaved } from '../productionPulse';
6
6
  import { CURRENT_COMPONENT_SCHEMA_VERSION } from '../themes/migrations';
7
7
  import { refToDiskValue } from '../store/cssVarRef';
8
8
 
@@ -98,5 +98,5 @@ export async function writeWorkingComponentConfig(
98
98
  const err = await res.json().catch(() => ({ error: 'Write failed' }));
99
99
  throw new Error(err.error || 'Write failed');
100
100
  }
101
- liveMovedSinceBake.set(true);
101
+ liveUnsaved.set(true);
102
102
  }
@@ -1,4 +1,5 @@
1
1
  import { writable } from 'svelte/store';
2
+ import { API_BASE } from './storage/apiBase';
2
3
  import type { Theme } from './themes/themeTypes';
3
4
 
4
5
  /**
@@ -6,7 +7,7 @@ import type { Theme } from './themes/themeTypes';
6
7
  * surfaces that need to react to a sibling Adopt subscribe to this so they
7
8
  * refresh without per-pair wiring.
8
9
  *
9
- * Bumpers: `ThemePanel.runAdopt`, `ComponentFileManager.handleAdopt`.
10
+ * Bumped by `adoptTheme`, the one door every Adopt goes through.
10
11
  */
11
12
  export const productionRevision = writable(0);
12
13
 
@@ -28,27 +29,59 @@ export function bumpComponentActiveRevision(): void {
28
29
  }
29
30
 
30
31
  /**
31
- * The live theme has moved past the last bake. Only Adopt bakes the CSS, so a
32
- * buffer write or a theme save after one leaves production a version behind,
33
- * and no pointer file records when the bake happened. Every surface reads the
34
- * one signal: a component save has to reach the theme panel's Adopt, and a
35
- * component editor's Adopt has to clear it for the panel.
32
+ * The live state holds changes the open theme's file does not: a buffer
33
+ * written, or a sketch gesture that changes what the effect paints. Saving
34
+ * clears it, because a save captures the live state into the file. Whether that
35
+ * file has reached production since is the server's answer, carried on
36
+ * `productionTheme._baked`, so this flag never stands in for it.
36
37
  *
37
- * Set by the client writes that move the live theme (`writeWorkingColorsAndType`,
38
- * `writeWorkingComponentConfig`, `saveActiveTheme`, `saveAsTheme`, and every
39
- * sketch gesture that changes what the effect paints: `setSketchEnabled`,
40
- * `updateSketchSettings`, `selectSketchStyle`);
41
- * cleared by `adoptTheme` and by `hydrateAppliedTheme`, on this document and on
42
- * every peer an Apply broadcasts to. Module-level, so it survives the
43
- * remounts a view switch causes.
38
+ * Set by the client writes that move the live state (`writeWorkingColorsAndType`,
39
+ * `writeWorkingComponentConfig`, and every sketch gesture that changes what the
40
+ * effect paints: `setSketchEnabled`, `updateSketchSettings`, `selectSketchStyle`);
41
+ * cleared by `saveActiveTheme` and `saveAsTheme`; recomputed from the server's layer sources by
42
+ * `hydrateAppliedTheme`, on this document and on every peer an Apply broadcasts
43
+ * to. Module-level, so it survives the remounts a view switch causes.
44
44
  */
45
- export const liveMovedSinceBake = writable(false);
45
+ export const liveUnsaved = writable(false);
46
+
47
+ /** The published theme, served whole so the client can tell what production
48
+ * runs from the document itself. */
49
+ export async function getProductionTheme(): Promise<Theme> {
50
+ const res = await fetch(`${API_BASE}/themes/production`);
51
+ if (!res.ok) throw new Error('Failed to read the production theme');
52
+ return res.json();
53
+ }
54
+
55
+ const PRODUCTION_RETRY_MS = 3000;
56
+ let latestProductionRead = 0;
57
+ let pendingRetry: ReturnType<typeof setTimeout> | undefined;
46
58
 
47
59
  /**
48
- * Last-read production theme — the document `tokens.generated.css` was baked
49
- * from. The Theme panel and the component file managers live in surfaces that
50
- * swap in and out of the DOM, so keeping the last answer in a module-level
51
- * store means a remount renders the correct Adopt state on the first frame
60
+ * Last-read production theme, `_baked` included. Every surface renders from
61
+ * this store and none reads the server itself. The first subscriber starts a
62
+ * read; after that, the writes that can move the answer refresh it: `saveTheme`,
63
+ * `adoptTheme`, and a live-state frame from an outside writer.
64
+ *
65
+ * Module-level, so a remount renders the last answer on its first frame
52
66
  * instead of flashing through "not in sync" while a fresh fetch resolves.
53
67
  */
54
- export const productionTheme = writable<Theme | null>(null);
68
+ export const productionTheme = writable<Theme | null>(null, () => {
69
+ void refreshProductionTheme();
70
+ });
71
+
72
+ /** Re-read the production theme into `productionTheme`. Never throws: a failed
73
+ * read keeps the last answer and retries once, since nothing else asks again
74
+ * until the next write. A newer read supersedes a pending retry. */
75
+ export async function refreshProductionTheme(retry = true): Promise<void> {
76
+ clearTimeout(pendingRetry);
77
+ const read = ++latestProductionRead;
78
+ try {
79
+ const theme = await getProductionTheme();
80
+ // Reads can land out of order; only the latest one speaks for the server.
81
+ if (read === latestProductionRead) productionTheme.set(theme);
82
+ } catch {
83
+ if (retry && read === latestProductionRead) {
84
+ pendingRetry = setTimeout(() => void refreshProductionTheme(false), PRODUCTION_RETRY_MS);
85
+ }
86
+ }
87
+ }
@@ -14,7 +14,7 @@ import {
14
14
  setSketchScope,
15
15
  sketchLayerInstalled,
16
16
  } from './sketchLayer';
17
- import { liveMovedSinceBake } from '../productionPulse';
17
+ import { liveUnsaved } from '../productionPulse';
18
18
  import {
19
19
  deleteSketchStyle,
20
20
  listSketchStyles,
@@ -171,7 +171,7 @@ export function liveSketchSettings(): SketchStyleSettings | undefined {
171
171
  made a sketch decision the same as any other control does. */
172
172
  export function setSketchEnabled(enabled: boolean): void {
173
173
  markSketchTouched();
174
- if (enabled !== get(sketchEnabled)) liveMovedSinceBake.set(true);
174
+ if (enabled !== get(sketchEnabled)) liveUnsaved.set(true);
175
175
  sketchEnabled.set(enabled);
176
176
  }
177
177
 
@@ -236,7 +236,7 @@ export function selectUnsavedSketchStyle(): void {
236
236
  const style = get(themeSketchSettings);
237
237
  if (!style) return;
238
238
  markSketchTouched();
239
- if (get(sketchEnabled)) liveMovedSinceBake.set(true);
239
+ if (get(sketchEnabled)) liveUnsaved.set(true);
240
240
  selectedSketchStyleId.set(THEME_SKETCH_ID);
241
241
  sketchBaseline.set({ ...style });
242
242
  sketchSettings.set({ ...style });
@@ -262,7 +262,7 @@ export function selectSketchStyle(id: string): void {
262
262
  const style = sketchStyleById(id);
263
263
  if (!style) return;
264
264
  markSketchTouched();
265
- if (get(sketchEnabled)) liveMovedSinceBake.set(true);
265
+ if (get(sketchEnabled)) liveUnsaved.set(true);
266
266
  selectedSketchStyleId.set(id);
267
267
  sketchBaseline.set({ ...style.settings });
268
268
  sketchSettings.set({ ...style.settings });
@@ -356,7 +356,7 @@ export async function deleteSavedSketchStyle(fileName: string): Promise<void> {
356
356
  the drift. Save writes a new sketchstyle rather than overwriting the base. */
357
357
  export function updateSketchSettings(patch: Partial<SketchStyleSettings>): void {
358
358
  markSketchTouched();
359
- if (get(sketchEnabled)) liveMovedSinceBake.set(true);
359
+ if (get(sketchEnabled)) liveUnsaved.set(true);
360
360
  sketchSettings.update((s) => ({ ...s, ...patch }));
361
361
  }
362
362
 
@@ -6,7 +6,7 @@ import {
6
6
  sanitizeFileName as sanitizeFileNameImpl,
7
7
  } from '../storage/files/versionedFileResourceClient';
8
8
  import { API_BASE } from '../storage/apiBase';
9
- import { liveMovedSinceBake } from '../productionPulse';
9
+ import { liveUnsaved } from '../productionPulse';
10
10
  import { loadFromFile as loadEditorState, toColorsAndType, markSaved, markColorsAndTypeSaved } from '../store/editorStore';
11
11
  import { migrateColorsAndTypeFonts } from '../fonts/fontMigration';
12
12
 
@@ -48,7 +48,7 @@ export async function writeWorkingColorsAndType(data: ColorsAndType): Promise<vo
48
48
  const err = await res.json().catch(() => ({ error: 'Write failed' }));
49
49
  throw new Error(err.error || 'Write failed');
50
50
  }
51
- liveMovedSinceBake.set(true);
51
+ liveUnsaved.set(true);
52
52
  }
53
53
 
54
54
  /** Sanitize a display name to a safe file name. Re-exported from the shared
@@ -1,4 +1,5 @@
1
1
  import { API_BASE } from '../storage/apiBase';
2
+ import { refreshProductionTheme } from '../productionPulse';
2
3
  import { hydrateAppliedTheme } from './themeDocumentSync';
3
4
  import type { LiveState } from './themeService';
4
5
 
@@ -9,7 +10,9 @@ let source: EventSource | null = null;
9
10
  /**
10
11
  * Follow the dev server's live state. A buffer or the active pointer written
11
12
  * from outside the page (a CLI verb, a branch switch) arrives as one frame,
12
- * and this document hydrates from it the way it does from an Apply.
13
+ * and this document hydrates from it the way it does from an Apply. An outside
14
+ * writer can also save the production theme past its bake (`save-theme` into
15
+ * the open, published theme), so production is re-read too.
13
16
  */
14
17
  export function init(): void {
15
18
  if (source || typeof EventSource === 'undefined') return;
@@ -17,6 +20,7 @@ export function init(): void {
17
20
  source.addEventListener(LIVE_STATE_EVENT, (event) => {
18
21
  const state = JSON.parse((event as MessageEvent<string>).data) as LiveState;
19
22
  hydrateAppliedTheme(state.fileName, state);
23
+ void refreshProductionTheme();
20
24
  });
21
25
  }
22
26
 
@@ -1,4 +1,4 @@
1
- import { liveMovedSinceBake } from '../productionPulse';
1
+ import { liveUnsaved } from '../productionPulse';
2
2
  import { openThemeSlug } from '../store/editorConfigStore';
3
3
  import { loadThemeFromApi } from '../store/editorStore';
4
4
  import { migrateColorsAndTypeFonts } from '../fonts/fontMigration';
@@ -55,7 +55,7 @@ export function hydrateAppliedTheme(fileName: string, result: LiveState): void {
55
55
  );
56
56
  openThemeSlug.set(fileName);
57
57
  openThemeSketchSettings(result.theme.sketchSettings);
58
- liveMovedSinceBake.set(hasUnsavedLayer(result));
58
+ liveUnsaved.set(hasUnsavedLayer(result));
59
59
  if (typeof document !== 'undefined') {
60
60
  document.dispatchEvent(new CustomEvent<AppliedThemeDetail>(THEME_APPLIED_EVENT, {
61
61
  detail: { fileName, result },
@@ -1,7 +1,11 @@
1
1
  import type { Theme, ThemeMeta, ThemeBundle, ColorsAndType, ComponentConfig, ThemeFillReport } from './themeTypes';
2
2
  import { versionedFileResource } from '../storage/files/versionedFileResourceClient';
3
3
  import { API_BASE } from '../storage/apiBase';
4
- import { liveMovedSinceBake } from '../productionPulse';
4
+ import {
5
+ bumpProductionRevision,
6
+ liveUnsaved,
7
+ refreshProductionTheme,
8
+ } from '../productionPulse';
5
9
  import { listComponents, getActiveComponentConfig } from '../components/componentConfigService';
6
10
  import { getActiveColorsAndType } from './colorsAndTypeService';
7
11
  import { broadcastAppliedTheme, hydrateAppliedTheme } from './themeDocumentSync';
@@ -33,21 +37,21 @@ export const listThemes = async (): Promise<ThemeMeta[]> => {
33
37
 
34
38
  export const loadTheme = (fileName: string): Promise<Theme> =>
35
39
  themesResource.load(fileName);
36
- export const saveTheme = (fileName: string, data: Theme): Promise<void> =>
37
- themesResource.save(fileName, data);
40
+
41
+ /** Every theme write goes through here. A write can move the production theme
42
+ * past its bake, which only the server can tell, so this re-reads production. */
43
+ export async function saveTheme(fileName: string, data: Theme): Promise<void> {
44
+ await themesResource.save(fileName, data);
45
+ await refreshProductionTheme();
46
+ }
47
+
38
48
  export const deleteTheme = (fileName: string): Promise<void> =>
39
49
  themesResource.remove(fileName);
40
50
  export const getActiveTheme = (): Promise<Theme | null> => themesResource.getActive();
41
51
  export const setActiveTheme = (fileName: string): Promise<void> =>
42
52
  themesResource.setActive(fileName);
43
53
 
44
- /** The published theme, served whole so the client can tell what production
45
- * runs from the document itself. */
46
- export async function getProductionTheme(): Promise<Theme> {
47
- const res = await fetch(`${API_BASE}/themes/production`);
48
- if (!res.ok) throw new Error('Failed to read the production theme');
49
- return res.json();
50
- }
54
+ export { getProductionTheme } from '../productionPulse';
51
55
 
52
56
  /** Step past the theme names already on disk rather than clobbering one. */
53
57
  export function freshName(base: string, taken: Set<string>): string {
@@ -116,7 +120,8 @@ export interface AdoptThemeResult {
116
120
  * the server has not been handed is invisible from here.
117
121
  *
118
122
  * Answers 409 `ACTIVE_IS_PROTECTED` while the Default theme is open, which the
119
- * caller recovers from by forking it under a name of the user's own.
123
+ * caller recovers from by forking it under a name of the user's own. On success
124
+ * it re-reads production and ticks `productionRevision`, so no caller does either.
120
125
  */
121
126
  export async function adoptTheme(): Promise<AdoptThemeResult> {
122
127
  const res = await fetch(`${API_BASE}/production`, { method: 'PUT' });
@@ -130,8 +135,10 @@ export async function adoptTheme(): Promise<AdoptThemeResult> {
130
135
  if (body.code) err.code = body.code;
131
136
  throw err;
132
137
  }
133
- liveMovedSinceBake.set(false);
134
- return res.json();
138
+ const result = await res.json() as AdoptThemeResult;
139
+ await refreshProductionTheme();
140
+ bumpProductionRevision();
141
+ return result;
135
142
  }
136
143
 
137
144
  /** `_fileName` and `_source` mark which document and which layer a read door
@@ -189,8 +196,8 @@ export async function saveAsTheme(
189
196
  ...content,
190
197
  });
191
198
  themeSketchSettings.set(content.sketchSettings);
192
- liveMovedSinceBake.set(true);
193
199
  await setActiveTheme(fileName);
200
+ liveUnsaved.set(false);
194
201
  }
195
202
 
196
203
  /**
@@ -213,7 +220,7 @@ export async function saveActiveTheme(displayName?: string): Promise<void> {
213
220
  ...content,
214
221
  });
215
222
  themeSketchSettings.set(content.sketchSettings);
216
- liveMovedSinceBake.set(true);
223
+ liveUnsaved.set(false);
217
224
  }
218
225
 
219
226
  export interface ImportThemeResult {
@@ -1,13 +1,13 @@
1
1
  import type { ComponentSummary } from '../components/componentConfigService';
2
+ import type { Theme } from './themeTypes';
2
3
 
3
4
  export interface ThemeProductionInput {
4
5
  /** Slug of the theme the editor has open. */
5
6
  openTheme: string;
6
- /** Slug of the published theme, or null while the read has not landed. */
7
- productionTheme: string | null;
8
- /** Live theme moved past what the published theme holds: unsaved edits, or a
9
- * save since the last Adopt. */
10
- unpublished: boolean;
7
+ /** The production read, or null while it has not landed. */
8
+ production: Pick<Theme, '_fileName' | '_baked'> | null;
9
+ /** The live state holds changes the open theme's file does not. */
10
+ unsaved: boolean;
11
11
  }
12
12
 
13
13
  export interface ThemeProductionState {
@@ -17,15 +17,16 @@ export interface ThemeProductionState {
17
17
  unknown: boolean;
18
18
  /** True when production ships a theme other than the open one. */
19
19
  themeOff: boolean;
20
- /** True when the open theme has moved since it was published. */
20
+ /** True when the live state is ahead of what production ships: unsaved
21
+ * changes, or a save the bake has not seen. */
21
22
  unpublished: boolean;
22
23
  }
23
24
 
24
25
  /**
25
26
  * Whether production is running the theme on screen. Production is one saved
26
27
  * theme, so the first half is an identity check against the open one; the
27
- * second half is the live theme sitting ahead of what was published, which
28
- * `unpublished` carries.
28
+ * second half is the live theme sitting ahead of what was published: unsaved
29
+ * changes, or a saved theme the server reports the bake has not caught up with.
29
30
  *
30
31
  * A null production read is not an answer, so it is neither state: `unknown`
31
32
  * says so and `inProduction` stays false. Callers render that as its own
@@ -34,11 +35,12 @@ export interface ThemeProductionState {
34
35
  */
35
36
  export function themeProductionState({
36
37
  openTheme,
37
- productionTheme,
38
- unpublished,
38
+ production,
39
+ unsaved,
39
40
  }: ThemeProductionInput): ThemeProductionState {
40
- const unknown = productionTheme === null;
41
- const themeOff = !unknown && productionTheme !== openTheme;
41
+ const unknown = production === null;
42
+ const themeOff = !unknown && production._fileName !== openTheme;
43
+ const unpublished = unsaved || (!unknown && production._baked !== true);
42
44
  return {
43
45
  unknown,
44
46
  themeOff,
@@ -288,6 +288,9 @@ export interface Theme {
288
288
  sketchSettings?: SketchStyleSettings;
289
289
  /** Server-attached file-name marker. Same role as `ColorsAndType._fileName`. */
290
290
  _fileName?: string;
291
+ /** Server-attached on the production read only: `tokens.generated.css` holds
292
+ * this theme as saved, so Adopt would change nothing. */
293
+ _baked?: boolean;
291
294
  }
292
295
 
293
296
  /**
@@ -12,7 +12,6 @@
12
12
  listThemes,
13
13
  deleteTheme,
14
14
  getActiveTheme,
15
- getProductionTheme,
16
15
  loadTheme,
17
16
  applyTheme,
18
17
  adoptTheme,
@@ -62,8 +61,7 @@
62
61
  import {
63
62
  componentActiveRevision,
64
63
  productionRevision,
65
- bumpProductionRevision,
66
- liveMovedSinceBake,
64
+ liveUnsaved,
67
65
  productionTheme,
68
66
  bumpComponentActiveRevision,
69
67
  } from '../core/productionPulse';
@@ -120,26 +118,6 @@
120
118
  }
121
119
  }
122
120
 
123
- // A read that never lands leaves the production state unknown, and the panel
124
- // has nothing else that asks on its own between pulses. One delayed retry is
125
- // the way out of it.
126
- const PRODUCTION_RETRY_MS = 3000;
127
- let productionRetry: ReturnType<typeof setTimeout> | undefined;
128
-
129
- async function refreshProduction(retry = true) {
130
- try {
131
- productionTheme.set(await getProductionTheme());
132
- } catch {
133
- if (retry) productionRetry = setTimeout(() => refreshProduction(false), PRODUCTION_RETRY_MS);
134
- }
135
- }
136
-
137
- // The store it writes outlives the panel, so a pending retry has to go with
138
- // the panel rather than land on a later mount's state.
139
- onDestroy(() => {
140
- if (productionRetry !== undefined) clearTimeout(productionRetry);
141
- });
142
-
143
121
  // ── Components ────────────────────────────────────────────────────────
144
122
  //
145
123
  // The per-component file managers live in the component editors; the list
@@ -170,7 +148,6 @@
170
148
  onMount(async () => {
171
149
  await refreshFiles();
172
150
  await refreshActive();
173
- await refreshProduction();
174
151
  });
175
152
 
176
153
  // A consumer can load a theme from the host while this iframe stays open.
@@ -186,9 +163,9 @@
186
163
  });
187
164
 
188
165
  // Re-read whenever an Adopt fires, here or in a component editor: it saves
189
- // the open theme and moves the production pointer, so the identity, the
190
- // component summary and the production state shown here all need to track.
191
- // Skip the first tick (mount ran them already).
166
+ // the open theme and can fork it, so the identity, the file list and the
167
+ // component summary shown here all need to track. The production state rides
168
+ // on its own store. Skip the first tick (mount ran them already).
192
169
  let pulseInitialised = false;
193
170
  $effect(() => {
194
171
  void $productionRevision;
@@ -197,7 +174,6 @@
197
174
  return;
198
175
  }
199
176
  refreshActive();
200
- refreshProduction();
201
177
  refreshFiles();
202
178
  refreshComponents();
203
179
  });
@@ -305,8 +281,8 @@
305
281
  let production = $derived(
306
282
  themeProductionState({
307
283
  openTheme: $openThemeSlug,
308
- productionTheme: $productionTheme?._fileName ?? null,
309
- unpublished: unsavedEdits || $liveMovedSinceBake,
284
+ production: $productionTheme,
285
+ unsaved: unsavedEdits || $liveUnsaved,
310
286
  }),
311
287
  );
312
288
 
@@ -341,9 +317,6 @@
341
317
  await saveActiveTheme(currentDisplayName);
342
318
  }
343
319
  await adoptTheme();
344
- // The panel's own production pulse re-reads identity, the production
345
- // theme and the component summary.
346
- bumpProductionRevision();
347
320
  flashStatus(setAdoptStatus, 'done');
348
321
  } catch {
349
322
  flashStatus(setAdoptStatus, 'error', { durationMs: 3000 });
@@ -496,13 +469,12 @@
496
469
  await refreshActive();
497
470
  }
498
471
  await adoptTheme();
499
- bumpProductionRevision();
500
472
  flashStatus(setAdoptStatus, 'done');
501
473
  } catch (err) {
502
474
  window.alert(`Theme loaded, but could not be adopted: ${(err as Error).message}`);
503
475
  flashStatus(setAdoptStatus, 'error', { durationMs: 3000 });
504
476
  }
505
- await Promise.all([refreshFiles(), refreshComponents(), refreshProduction()]);
477
+ await Promise.all([refreshFiles(), refreshComponents()]);
506
478
  }
507
479
 
508
480
  /**
@@ -1009,6 +981,7 @@
1009
981
  }
1010
982
 
1011
983
  .theme-name-trigger :global(.file-pill) {
984
+ box-sizing: border-box;
1012
985
  width: 100%;
1013
986
  }
1014
987
 
@@ -1,5 +1,6 @@
1
1
  /* Generated by themeFileApi from the production theme. Do not edit. */
2
2
  /* tokens.css holds developer-authored defaults; this file holds editor overrides. */
3
+ /* Bake fingerprint: 2c683a9cbf8a9e66 */
3
4
 
4
5
  /* Production theme: default */
5
6
  :root:root {