@motion-proto/live-tokens 0.93.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,39 @@
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
+
18
+ ## 0.94.0 — Tokens-view swatch swap and copy-paste
19
+
20
+ ### Added
21
+
22
+ - **The Tokens view swaps two palettes' base colors by drag and drop.** Drop
23
+ one family's header swatch on another's and the two trade seed colors in one
24
+ undo step. A swap with a functional family leaves any harmony axis on the
25
+ family that held it, and the axis follows the new hue.
26
+ - **Every Tokens-view swatch copies and pastes its color.** Click a header,
27
+ palette step, or override swatch open, and ⌘C (Ctrl+C) copies its color as
28
+ `oklch()` text. ⌘V (Ctrl+V) pastes a hex or `oklch()` color into the open
29
+ swatch, inside its edit session, so Cancel takes the paste back.
30
+
31
+ ### Fixed
32
+
33
+ - **Pasting a swatch color needs no clipboard permission.** Paste reads the
34
+ browser's paste event, so Chrome no longer asks for clipboard access and
35
+ Safari no longer shows its Paste confirmation before ⌘V lands.
36
+
3
37
  ## 0.93.0 — Swatch swap and copy-paste
4
38
 
5
39
  ### 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.93.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
  /**
@@ -30,9 +30,16 @@
30
30
  import { editorState, mutate, setPaletteConfig, beginSliderGesture, beginScope, commitScope, cancelScope, type Scope } from '../core/store/editorStore';
31
31
  // Base-color edits route through the shared setter so a bound harmony axis
32
32
  // follows the hue (invariant 1); the local `edit` would silently detach it.
33
- import { setBaseColor } from './colors/paletteBaseColor';
33
+ import { setBaseColor, swapBaseColors } from './colors/paletteBaseColor';
34
34
  import { pendingPaletteFocus } from '../core/store/paletteFocus';
35
35
  import { showCopyPopover } from './copyPopover';
36
+ import {
37
+ activeSwatchFamily,
38
+ isSwatchCopy,
39
+ swatchPasteColor,
40
+ copySwatchColor,
41
+ showSwatchColor,
42
+ } from './swatchClipboard';
36
43
 
37
44
  // Full sRGB chroma range (gamutClamp trims per hue/lightness). Neutrals default
38
45
  // low but are not capped; their calm character comes from defaults, not a ceiling.
@@ -565,6 +572,29 @@
565
572
  if (!panelOpen || isEditingBase || !editingColor) return;
566
573
  dock = { title: editPanelTitle, color: editingColor, paletteIndex: editingPaletteIndex };
567
574
  });
575
+ $effect(() => {
576
+ if (editingKey !== null) activeSwatchFamily.set(label);
577
+ });
578
+
579
+ let ownsClipboard = $derived($activeSwatchFamily === label && editingKey !== null);
580
+ let openSwatchEl = $derived(rootEl?.querySelector(`[data-swatch-key="${editingKey}"]`) ?? null);
581
+
582
+ function onWindowKeydown(e: KeyboardEvent) {
583
+ if (!ownsClipboard || !isSwatchCopy(e)) return;
584
+ e.preventDefault();
585
+ copySwatchColor(editing.kind === 'editingStep' ? editing.draft : baseColor, openSwatchEl);
586
+ }
587
+
588
+ // Paste lands in the open session like a slider edit: Cancel takes it back.
589
+ function onWindowPaste(e: ClipboardEvent) {
590
+ const color = ownsClipboard ? swatchPasteColor(e) : null;
591
+ if (color === null) return;
592
+ e.preventDefault();
593
+ if (isEditingBase) setBaseColor(label, color);
594
+ else handleColorChange(color);
595
+ showSwatchColor(color, openSwatchEl);
596
+ }
597
+
568
598
  $effect(() => {
569
599
  // Touch each input so the effect tracks them (resnapScales reads indirectly).
570
600
  void baseColor;
@@ -595,6 +625,8 @@
595
625
  {/if}
596
626
  {/snippet}
597
627
 
628
+ <svelte:window onkeydown={onWindowKeydown} onpaste={onWindowPaste} />
629
+
598
630
  <div class="palette-editor" bind:this={rootEl} style="--editor-base: {toHex(baseColor)}">
599
631
  <PaletteBase
600
632
  {label}
@@ -606,6 +638,7 @@
606
638
  {copiedKey}
607
639
  onStartEdit={startBaseEdit}
608
640
  onCopyBaseHex={copyHex}
641
+ onSwapFrom={(family) => swapBaseColors(family, label)}
609
642
  >
610
643
  {#snippet setting()}
611
644
  <!-- The family's own swatch and name sit alongside, so the label drops
@@ -687,6 +720,7 @@
687
720
  class:anchored={ps.anchored}
688
721
  title={ps.anchored ? 'Base color' : undefined}
689
722
  style="background: {ps.effective}"
723
+ data-swatch-key={ps.key}
690
724
  onclick={() => handlePaletteClick(ps)}
691
725
  role="button"
692
726
  tabindex="0"
@@ -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,9 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { cubicOut } from 'svelte/easing';
3
- import { cssColorToOklch, oklchToCss, oklchToHexClamped } from '../../core/palettes/oklch';
3
+ import { oklchToHexClamped } from '../../core/palettes/oklch';
4
4
  import { PALETTE_SPECS } from '../../core/palettes/paletteDerivation';
5
5
  import { editorState, beginSliderGesture } from '../../core/store/editorStore';
6
- import { isEditable } from '../../core/store/editorKeybindings';
7
6
  import { selectedPalette } from '../../core/store/paletteFocus';
8
7
  import { applyHarmonyToAxes, axisStatuses, HARMONY_ELIGIBLE, type HarmonyMode } from '../../core/palettes/colorHarmony';
9
8
  import AxisNumeral from './AxisNumeral.svelte';
@@ -14,7 +13,7 @@
14
13
  import PaletteStepStrip from './PaletteStepStrip.svelte';
15
14
  import HarmonyAxesList from './HarmonyAxesList.svelte';
16
15
  import PaletteJumpButton from '../palette/PaletteJumpButton.svelte';
17
- import { showCopyPopover } from '../copyPopover';
16
+ import { isSwatchCopy, swatchPasteColor, copySwatchColor, showSwatchColor } from '../swatchClipboard';
18
17
  import {
19
18
  setBaseColor,
20
19
  setAxisHues,
@@ -157,31 +156,21 @@
157
156
  // Copy and paste act on the selected swatch, never the focused one: Safari
158
157
  // and macOS Firefox do not focus a button on click.
159
158
  function onWindowKeydown(e: KeyboardEvent) {
160
- if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return;
161
- if (e.target instanceof HTMLElement && isEditable(e.target)) return;
162
- const sw = swatches.find((s) => s.label === selected);
163
- if (!sw) return;
164
- const key = e.key.toLowerCase();
165
- if (key === 'c') {
166
- // Selected page text keeps the browser's own copy.
167
- if (window.getSelection()?.isCollapsed === false) return;
168
- e.preventDefault();
169
- const css = oklchToCss(sw.color.l, sw.color.c, sw.color.h);
170
- navigator.clipboard?.writeText(css);
171
- showCopyPopover(css, swatchEls[sw.label]);
172
- } else if (key === 'v') {
173
- e.preventDefault();
174
- pasteInto(sw, swatchEls[sw.label]);
175
- }
159
+ const sw = selectedSwatch;
160
+ if (!sw || !isSwatchCopy(e)) return;
161
+ e.preventDefault();
162
+ copySwatchColor(sw.color, swatchEls[sw.label]);
176
163
  }
177
164
 
178
- async function pasteInto(sw: Swatch, anchor: EventTarget | null) {
179
- const color = cssColorToOklch(await navigator.clipboard.readText());
180
- if (!color) return;
165
+ function onWindowPaste(e: ClipboardEvent) {
166
+ const sw = selectedSwatch;
167
+ const color = sw ? swatchPasteColor(e) : null;
168
+ if (!sw || color === null) return;
169
+ e.preventDefault();
181
170
  // A bound color drags its axis along, so an applied harmony no longer holds.
182
171
  if (sw.axis && color.h !== sw.color.h) activeMode = 'custom';
183
172
  setBaseColor(sw.label, color);
184
- showCopyPopover(oklchToCss(color.l, color.c, color.h), anchor);
173
+ showSwatchColor(color, swatchEls[sw.label]);
185
174
  }
186
175
 
187
176
  let fullPalettes = $derived(palettesWithDefaults($editorState.palettes));
@@ -204,6 +193,8 @@
204
193
 
205
194
  type Swatch = (typeof swatches)[number];
206
195
 
196
+ let selectedSwatch = $derived(swatches.find((s) => s.label === selected));
197
+
207
198
  const FUNCTIONAL_FAMILIES = new Set(['Info', 'Success', 'Warning', 'Danger']);
208
199
  // Declaration order, never a computed one: sorting by luminance made the row
209
200
  // reshuffle under the pointer while a lightness edit was in flight.
@@ -229,7 +220,7 @@
229
220
  }
230
221
  </script>
231
222
 
232
- <svelte:window onkeydown={onWindowKeydown} />
223
+ <svelte:window onkeydown={onWindowKeydown} onpaste={onWindowPaste} />
233
224
 
234
225
  <div class="colors-tab">
235
226
  <div class="pane">
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { get } from 'svelte/store';
21
21
  import type { Oklch } from '../../core/palettes/oklch';
22
- import { axisLabel } from '../../core/palettes/colorHarmony';
22
+ import { axisLabel, HARMONY_ELIGIBLE } from '../../core/palettes/colorHarmony';
23
23
  import { PALETTE_SPECS, syncBaseAnchor, type PaletteSpec } from '../../core/palettes/paletteDerivation';
24
24
  import { defaultPaletteConfig } from '../palette/paletteMath';
25
25
  import { mutate, transaction, editorState } from '../../core/store/editorStore';
@@ -208,8 +208,10 @@ function giveAxisHue(s: EditorState, index: number, family: string): boolean {
208
208
  return false;
209
209
  }
210
210
 
211
- /** Two families trade seed colors. Each axis binding travels with the color it
212
- * held, so every axis keeps its hue and an applied harmony stays true. */
211
+ /** Two families trade seed colors. Between two families an axis can hold, each
212
+ * binding travels with its color, so every axis keeps its hue and an applied
213
+ * harmony stays true. Otherwise a bound family keeps its axis, which follows
214
+ * the new hue as it does on any direct color edit. */
213
215
  export function swapBaseColors(a: string, b: string): void {
214
216
  if (a === b) return;
215
217
  mutate(`colors: swap ${a} and ${b}`, (s) => {
@@ -218,9 +220,14 @@ export function swapBaseColors(a: string, b: string): void {
218
220
  [ca.baseColor, cb.baseColor] = [cb.baseColor, ca.baseColor];
219
221
  syncBaseAnchor(ca);
220
222
  syncBaseAnchor(cb);
221
- for (const axis of s.harmonyAxes) {
222
- if (axis.family === a) axis.family = b;
223
- else if (axis.family === b) axis.family = a;
223
+ if (HARMONY_ELIGIBLE.includes(a) && HARMONY_ELIGIBLE.includes(b)) {
224
+ for (const axis of s.harmonyAxes) {
225
+ if (axis.family === a) axis.family = b;
226
+ else if (axis.family === b) axis.family = a;
227
+ }
228
+ } else {
229
+ syncBoundAxisHue(s, a);
230
+ syncBoundAxisHue(s, b);
224
231
  }
225
232
  });
226
233
  }
@@ -180,6 +180,7 @@
180
180
  class:active={editingKey === k}
181
181
  class:populated={k in overrides}
182
182
  class:matching={k in overrides && overrides[k] === dHex}
183
+ data-swatch-key={k}
183
184
  onclick={() => onOverrideClick(k, step, scale.title)}
184
185
  role="button"
185
186
  tabindex="0"
@@ -212,6 +213,7 @@
212
213
  class:active={editingKey === k || snapPickerKey === k}
213
214
  class:populated={k in overrides}
214
215
  class:matching={k in overrides && overrides[k] === dHex}
216
+ data-swatch-key={k}
215
217
  onclick={() => snapped ? onSnappedClick(k) : onOverrideClick(k, step, scale.title)}
216
218
  role="button"
217
219
  tabindex="0"
@@ -1,6 +1,14 @@
1
+ <script module lang="ts">
2
+ // Every family's header reads one drag source, because dataTransfer data is
3
+ // unreadable during dragover.
4
+ let swapSource: string | null = null;
5
+ const SWAP_DRAG_TYPE = 'application/x-palette-family';
6
+ </script>
7
+
1
8
  <script lang="ts">
2
9
  import type { Snippet } from 'svelte';
3
10
  import { oklchToHexClamped, type Oklch } from '../../core/palettes/oklch';
11
+ import { BASE_KEY } from './paletteEditorState';
4
12
 
5
13
  interface Props {
6
14
  /**
@@ -20,6 +28,8 @@
20
28
  copiedKey: string | null;
21
29
  onStartEdit: () => void;
22
30
  onCopyBaseHex: (key: string, hex: string, event?: MouseEvent) => void;
31
+ /** Another family's header swatch dropped on this one. */
32
+ onSwapFrom: (family: string) => void;
23
33
  actions?: Snippet;
24
34
  /** A family-level setting, shown beside the name while this family's
25
35
  * editor is open. Collapsed families keep the band to identity + actions. */
@@ -36,10 +46,34 @@
36
46
  copiedKey,
37
47
  onStartEdit,
38
48
  onCopyBaseHex,
49
+ onSwapFrom,
39
50
  actions,
40
51
  setting
41
52
  }: Props = $props();
42
53
 
54
+ let swapHover = $state(false);
55
+
56
+ function onDragStart(e: DragEvent) {
57
+ if (!e.dataTransfer) return;
58
+ e.dataTransfer.effectAllowed = 'move';
59
+ e.dataTransfer.setData(SWAP_DRAG_TYPE, label);
60
+ swapSource = label;
61
+ }
62
+
63
+ function onDragOver(e: DragEvent) {
64
+ if (swapSource === null || swapSource === label) return;
65
+ e.preventDefault();
66
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
67
+ swapHover = true;
68
+ }
69
+
70
+ function onDrop(e: DragEvent) {
71
+ swapHover = false;
72
+ if (swapSource === null || swapSource === label) return;
73
+ e.preventDefault();
74
+ onSwapFrom(swapSource);
75
+ }
76
+
43
77
  let baseHex = $derived(oklchToHexClamped(baseColor.l, baseColor.c, baseColor.h));
44
78
 
45
79
  // `pinnedOpen || isEditingBase` is exactly when the base panel shows: editing
@@ -54,11 +88,19 @@
54
88
  class="header-swatch"
55
89
  class:active={isEditingBase || pinnedOpen}
56
90
  class:selected
91
+ class:swap-target={swapHover}
57
92
  style="background: {baseHex}"
93
+ data-swatch-key={BASE_KEY}
94
+ draggable="true"
58
95
  onclick={onStartEdit}
59
96
  role="button"
60
97
  tabindex="0"
61
98
  onkeydown={(e) => e.key === 'Enter' && onStartEdit()}
99
+ ondragstart={onDragStart}
100
+ ondragend={() => (swapSource = null)}
101
+ ondragover={onDragOver}
102
+ ondragleave={() => (swapHover = false)}
103
+ ondrop={onDrop}
62
104
  ></div>
63
105
  <div class="primary-info">
64
106
  <span class="editor-label">{displayLabel ?? label}</span>
@@ -163,6 +205,13 @@
163
205
  inset 0 0 0 4px var(--ui-surface-lowest);
164
206
  }
165
207
 
208
+ /* About to receive a swap: the axis row's doubled drop ring. */
209
+ .header-swatch.swap-target {
210
+ border-color: var(--ui-text-primary);
211
+ outline: 1px solid var(--ui-text-primary);
212
+ outline-offset: 1px;
213
+ }
214
+
166
215
  .editor-label {
167
216
  font-size: var(--ui-font-size-xl);
168
217
  font-weight: var(--ui-font-weight-semibold);
@@ -0,0 +1,35 @@
1
+ import { writable } from 'svelte/store';
2
+ import { cssColorToOklch, oklchToCss, type Oklch } from '../core/palettes/oklch';
3
+ import { isEditable } from '../core/store/editorKeybindings';
4
+ import { showCopyPopover } from './copyPopover';
5
+
6
+ /** The Tokens-view family whose swatch opened last. Every family can hold an
7
+ * open swatch at once, and copy and paste act on this one alone. */
8
+ export const activeSwatchFamily = writable<string | null>(null);
9
+
10
+ const inTextField = (e: Event) => e.target instanceof HTMLElement && isEditable(e.target);
11
+
12
+ /** ⌘C on a swatch view. A text field or selected page text keeps the browser's
13
+ * own copy. Copy rides the key rather than the copy event because Safari fires
14
+ * no copy event without a text selection. */
15
+ export function isSwatchCopy(e: KeyboardEvent): boolean {
16
+ if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'c') return false;
17
+ return !inTextField(e) && window.getSelection()?.isCollapsed !== false;
18
+ }
19
+
20
+ /** The color a paste carries to a swatch, or null when a text field keeps the
21
+ * paste or the clipboard holds no hex or `oklch()` color. Paste rides the
22
+ * event because its clipboardData needs no read permission. */
23
+ export function swatchPasteColor(e: ClipboardEvent): Oklch | null {
24
+ if (inTextField(e)) return null;
25
+ return cssColorToOklch(e.clipboardData?.getData('text/plain') ?? '');
26
+ }
27
+
28
+ export function showSwatchColor(color: Oklch, anchor: Element | null): void {
29
+ showCopyPopover(oklchToCss(color.l, color.c, color.h), anchor);
30
+ }
31
+
32
+ export function copySwatchColor(color: Oklch, anchor: Element | null): void {
33
+ navigator.clipboard?.writeText(oklchToCss(color.l, color.c, color.h));
34
+ showSwatchColor(color, anchor);
35
+ }
@@ -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 {