@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 +34 -0
- package/dist-plugin/index.cjs +30 -6
- package/dist-plugin/index.js +30 -6
- package/package.json +1 -1
- package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +7 -17
- package/src/editor/core/components/componentConfigService.ts +2 -2
- package/src/editor/core/productionPulse.ts +52 -19
- package/src/editor/core/sketch/sketchStore.ts +5 -5
- package/src/editor/core/themes/colorsAndTypeService.ts +2 -2
- package/src/editor/core/themes/liveStateStream.ts +5 -1
- package/src/editor/core/themes/themeDocumentSync.ts +2 -2
- package/src/editor/core/themes/themeService.ts +22 -15
- package/src/editor/core/themes/themeSummary.ts +14 -12
- package/src/editor/core/themes/themeTypes.ts +3 -0
- package/src/editor/ui/PaletteEditor.svelte +35 -1
- package/src/editor/ui/ThemePanel.svelte +8 -35
- package/src/editor/ui/colors/ColorsTab.svelte +15 -24
- package/src/editor/ui/colors/paletteBaseColor.ts +13 -6
- package/src/editor/ui/palette/OverridesPanel.svelte +2 -0
- package/src/editor/ui/palette/PaletteBase.svelte +49 -0
- package/src/editor/ui/swatchClipboard.ts +35 -0
- package/src/live-tokens/data/tokens.generated.css +1 -0
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
|
package/dist-plugin/index.cjs
CHANGED
|
@@ -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
|
|
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,
|
|
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();
|
package/dist-plugin/index.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
@@ -23,20 +23,19 @@
|
|
|
23
23
|
} from '../../core/store/editorStore';
|
|
24
24
|
import {
|
|
25
25
|
bumpComponentActiveRevision,
|
|
26
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
38
|
-
* `writeWorkingComponentConfig`,
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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 {
|
|
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))
|
|
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))
|
|
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))
|
|
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))
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
|
|
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
|
|
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
|
|
28
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
38
|
+
production,
|
|
39
|
+
unsaved,
|
|
39
40
|
}: ThemeProductionInput): ThemeProductionState {
|
|
40
|
-
const unknown =
|
|
41
|
-
const themeOff = !unknown &&
|
|
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
|
-
|
|
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
|
|
190
|
-
// component summary
|
|
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
|
-
|
|
309
|
-
|
|
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()
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
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
|
-
|
|
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.
|
|
212
|
-
*
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
}
|