@motion-proto/live-tokens 0.47.0 → 0.48.0

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.
Files changed (124) hide show
  1. package/.claude/skills/live-tokens-adjust-shape-space/SKILL.md +68 -0
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +2 -2
  3. package/.claude/skills/live-tokens-generate-theme/SKILL.md +152 -0
  4. package/CHANGELOG.md +292 -0
  5. package/README.md +37 -26
  6. package/bin/adjust.mjs +254 -0
  7. package/bin/cli.mjs +94 -7
  8. package/bin/generate-theme.mjs +251 -0
  9. package/bin/migrate.mjs +95 -6
  10. package/dist-plugin/adjust/index.cjs +259 -0
  11. package/dist-plugin/adjust/index.d.cts +45 -0
  12. package/dist-plugin/adjust/index.d.ts +45 -0
  13. package/dist-plugin/adjust/index.js +176 -0
  14. package/dist-plugin/{chunk-H4TRUINI.js → chunk-44RSTAII.js} +0 -48
  15. package/dist-plugin/chunk-6OZFXIQI.js +316 -0
  16. package/dist-plugin/chunk-76TFDTJO.js +556 -0
  17. package/dist-plugin/chunk-D3ZVKOR4.js +52 -0
  18. package/dist-plugin/dataPaths-DBN0RPuT.d.cts +54 -0
  19. package/dist-plugin/dataPaths-DBN0RPuT.d.ts +54 -0
  20. package/dist-plugin/generateColorsAndType/index.cjs +1781 -0
  21. package/dist-plugin/generateColorsAndType/index.d.cts +81 -0
  22. package/dist-plugin/generateColorsAndType/index.d.ts +81 -0
  23. package/dist-plugin/generateColorsAndType/index.js +1210 -0
  24. package/dist-plugin/index.cjs +901 -533
  25. package/dist-plugin/index.d.cts +3 -2
  26. package/dist-plugin/index.d.ts +3 -2
  27. package/dist-plugin/index.js +658 -1082
  28. package/dist-plugin/migrateData/index.cjs +728 -0
  29. package/dist-plugin/migrateData/index.d.cts +60 -0
  30. package/dist-plugin/migrateData/index.d.ts +60 -0
  31. package/dist-plugin/migrateData/index.js +348 -0
  32. package/dist-plugin/themeTypes-DMHZOnUn.d.cts +211 -0
  33. package/dist-plugin/themeTypes-DMHZOnUn.d.ts +211 -0
  34. package/dist-plugin/tokensCssMigrations/index.cjs +2 -1
  35. package/dist-plugin/tokensCssMigrations/index.d.cts +3 -15
  36. package/dist-plugin/tokensCssMigrations/index.d.ts +3 -15
  37. package/dist-plugin/tokensCssMigrations/index.js +4 -2
  38. package/package.json +18 -4
  39. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +183 -158
  40. package/src/editor/component-editor/scaffolding/ComponentFileMenu.svelte +0 -4
  41. package/src/editor/component-editor/scaffolding/SaveAsDialog.svelte +4 -4
  42. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +2 -59
  43. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +1 -1
  44. package/src/editor/core/components/adjustAliases.ts +180 -0
  45. package/src/editor/core/components/aliasKinds.ts +73 -0
  46. package/src/editor/core/components/componentConfigService.ts +26 -38
  47. package/src/editor/core/flashStatus.ts +1 -1
  48. package/src/editor/core/fonts/fontMigration.ts +13 -13
  49. package/src/editor/core/fonts/fontPairing.ts +35 -0
  50. package/src/editor/core/palettes/paletteDerivation.ts +128 -16
  51. package/src/editor/core/preview/lookPreview.ts +141 -0
  52. package/src/editor/core/productionPulse.ts +34 -21
  53. package/src/editor/core/storage/files/versionedFileResourceClient.ts +18 -45
  54. package/src/editor/core/store/editorConfigStore.ts +2 -2
  55. package/src/editor/core/store/editorPersistence.ts +0 -1
  56. package/src/editor/core/store/editorStore.ts +114 -51
  57. package/src/editor/core/store/editorTypes.ts +0 -1
  58. package/src/editor/core/store/gradientSource.ts +2 -2
  59. package/src/editor/core/themes/colorsAndTypeService.ts +95 -0
  60. package/src/editor/core/themes/generateColorsAndType.ts +433 -0
  61. package/src/editor/core/themes/loadRows.ts +64 -0
  62. package/src/editor/core/themes/lookSummary.ts +75 -0
  63. package/src/editor/core/themes/migrations/2026-04-24-legacy-keys-and-bg-to-canvas.ts +3 -3
  64. package/src/editor/core/themes/migrations/2026-05-13-primary-to-brand.ts +2 -2
  65. package/src/editor/core/themes/migrations/2026-05-26-drop-overlay-extra-stops.ts +3 -3
  66. package/src/editor/core/themes/migrations/2026-08-13-drop-legacy-shape-space-keys.ts +38 -0
  67. package/src/editor/core/themes/migrations/2026-08-15-line-height-scale-rename.ts +57 -0
  68. package/src/editor/core/themes/migrations/index.ts +19 -11
  69. package/src/editor/core/themes/slices/components.ts +4 -4
  70. package/src/editor/core/themes/slices/fonts.ts +1 -1
  71. package/src/editor/core/themes/slices/gradients.ts +22 -0
  72. package/src/editor/core/themes/slices/palettes.ts +2 -2
  73. package/src/editor/core/themes/themeInit.ts +21 -29
  74. package/src/editor/core/themes/themeService.ts +218 -78
  75. package/src/editor/core/themes/themeTypes.ts +94 -51
  76. package/src/editor/docs/Docs.svelte +1 -0
  77. package/src/editor/docs/chapters.ts +1 -0
  78. package/src/editor/docs/content/01-overview.md +1 -1
  79. package/src/editor/docs/content/editing-tokens.md +6 -6
  80. package/src/editor/docs/content/getting-started.md +9 -7
  81. package/src/editor/docs/content/themes-workflow.md +74 -34
  82. package/src/editor/docs/content/where-themes-live.md +61 -0
  83. package/src/editor/docs/content.generated.ts +5 -4
  84. package/src/editor/index.ts +27 -27
  85. package/src/editor/pages/ComponentEditorPage.svelte +2 -2
  86. package/src/editor/pages/EditorShell.svelte +4 -30
  87. package/src/editor/ui/BezierCurveEditor.svelte +2 -2
  88. package/src/editor/ui/ColorEditPanel.svelte +1 -1
  89. package/src/editor/ui/FileLoadList.svelte +67 -7
  90. package/src/editor/ui/GradientEditor.svelte +3 -3
  91. package/src/editor/ui/PaletteEditor.svelte +26 -12
  92. package/src/editor/ui/ThemePanel.svelte +1067 -0
  93. package/src/editor/ui/UIDialog.svelte +11 -9
  94. package/src/editor/ui/UIPillButton.svelte +7 -2
  95. package/src/editor/ui/colors/ColorWheel.svelte +34 -1
  96. package/src/editor/ui/curveEngine.ts +48 -0
  97. package/src/editor/ui/index.ts +4 -2
  98. package/src/editor/ui/palette/PaletteBase.svelte +75 -51
  99. package/src/editor/ui/palette/PaletteJumpButton.svelte +3 -8
  100. package/src/live-tokens/data/colors-and-type/autumn.json +2490 -0
  101. package/src/live-tokens/data/{themes → colors-and-type}/default.json +816 -293
  102. package/src/live-tokens/data/colors-and-type/halloween.json +2529 -0
  103. package/src/live-tokens/data/colors-and-type/midnight-study.json +2536 -0
  104. package/src/live-tokens/data/colors-and-type/ocean.json +2489 -0
  105. package/src/live-tokens/data/colors-and-type/royal-velvet.json +2520 -0
  106. package/src/live-tokens/data/colors-and-type/spring-meadow.json +2476 -0
  107. package/src/live-tokens/data/colors-and-type/sunset.json +2528 -0
  108. package/src/live-tokens/data/themes/autumn.json +3918 -0
  109. package/src/live-tokens/data/themes/halloween.json +3992 -0
  110. package/src/live-tokens/data/themes/midnight-study.json +3932 -0
  111. package/src/live-tokens/data/themes/ocean.json +3917 -0
  112. package/src/live-tokens/data/themes/royal-velvet.json +3983 -0
  113. package/src/live-tokens/data/themes/spring-meadow.json +3904 -0
  114. package/src/live-tokens/data/themes/sunset.json +3931 -0
  115. package/src/live-tokens/data/tokens.generated.css +106 -117
  116. package/src/system/styles/CONVENTIONS.md +1 -1
  117. package/src/system/styles/fonts.css +1 -1
  118. package/template/README.md +7 -5
  119. package/template/_gitignore +0 -6
  120. package/src/editor/core/components/componentPersist.ts +0 -62
  121. package/src/editor/core/manifests/manifestService.ts +0 -172
  122. package/src/editor/ui/ManifestFileManager.svelte +0 -446
  123. package/src/editor/ui/ThemeFileManager.svelte +0 -785
  124. package/src/live-tokens/data/manifests/default.json +0 -35
@@ -0,0 +1,1067 @@
1
+ <script lang="ts">
2
+ // The root of the look hierarchy: one Theme panel that owns the open theme,
3
+ // with the colors and type and the components as parts that report rather
4
+ // than manage.
5
+ //
6
+ // It is the only save, load and ship surface. Colors-and-type files are
7
+ // presets: listed here so they stay loadable, never written by a save.
8
+ import { onDestroy, onMount } from 'svelte';
9
+ import { get } from 'svelte/store';
10
+ import type { Theme, ThemeMeta, ColorsAndType, ColorsAndTypeMeta } from '../core/themes/themeTypes';
11
+ import {
12
+ listThemes,
13
+ deleteTheme,
14
+ getActiveTheme,
15
+ getProductionTheme,
16
+ loadTheme,
17
+ applyTheme,
18
+ adoptLook,
19
+ freshName,
20
+ saveAsTheme,
21
+ saveActiveTheme,
22
+ exportTheme,
23
+ importTheme,
24
+ } from '../core/themes/themeService';
25
+ import { countComponentsOffLook, lookProductionState } from '../core/themes/lookSummary';
26
+ import { previewTheme, previewColorsAndType, revertPreview } from '../core/preview/lookPreview';
27
+ import {
28
+ deleteColorsAndType,
29
+ hydrateColorsAndType,
30
+ listColorsAndType,
31
+ loadColorsAndType,
32
+ persistColorsAndType,
33
+ writeWorkingColorsAndType,
34
+ } from '../core/themes/colorsAndTypeService';
35
+ import {
36
+ buildLoadRows,
37
+ colorsOnlyIsForced,
38
+ isColorsOnly,
39
+ loadRowId,
40
+ type LoadRow,
41
+ } from '../core/themes/loadRows';
42
+ import {
43
+ listComponents,
44
+ type ComponentSummary,
45
+ } from '../core/components/componentConfigService';
46
+ import { fontPairingLabel } from '../core/fonts/fontPairing';
47
+ import { componentDirty, editorState, colorsAndTypeDirty } from '../core/store/editorStore';
48
+ import { openThemeSlug } from '../core/store/editorConfigStore';
49
+ import { editorView } from '../core/store/editorViewStore';
50
+ import {
51
+ componentActiveRevision,
52
+ productionRevision,
53
+ bumpProductionRevision,
54
+ liveMovedSinceBake,
55
+ productionTheme,
56
+ } from '../core/productionPulse';
57
+ import { flashStatus } from '../core/flashStatus';
58
+ import UIInfoPopover from './UIInfoPopover.svelte';
59
+ import UIPillButton from './UIPillButton.svelte';
60
+ import FileLoadList from './FileLoadList.svelte';
61
+ import FilePill from './FilePill.svelte';
62
+ import SaveAsDialog from '../component-editor/scaffolding/SaveAsDialog.svelte';
63
+
64
+ interface Props {
65
+ /** False on the components page, which is the surface the link opens. */
66
+ showComponentsLink?: boolean;
67
+ }
68
+
69
+ let { showComponentsLink = true }: Props = $props();
70
+
71
+ let canOpenComponents = $derived(showComponentsLink && $editorView !== 'components');
72
+
73
+ let files: ThemeMeta[] = $state([]);
74
+ let colorsFiles: ColorsAndTypeMeta[] = $state([]);
75
+ let rows = $derived(buildLoadRows(files, colorsFiles));
76
+ let showFileList = $state(false);
77
+ let saveAsDialog = $state(false);
78
+ let saveStatus: 'idle' | 'saving' | 'saved' | 'error' = $state('idle');
79
+
80
+ let currentDisplayName = $state('Motion Proto');
81
+ let activeIsProtected = $derived($openThemeSlug === 'default');
82
+ let activeRowId = $derived(loadRowId('look', $openThemeSlug));
83
+
84
+ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
85
+ const setSaveStatus = (s: SaveState) => (saveStatus = s);
86
+
87
+ let dirtyComponentCount = $derived(
88
+ Object.values($componentDirty).filter(Boolean).length,
89
+ );
90
+ let unsavedEdits = $derived($colorsAndTypeDirty || dirtyComponentCount > 0);
91
+
92
+ async function refreshFiles() {
93
+ try {
94
+ [files, colorsFiles] = await Promise.all([listThemes(), listColorsAndType()]);
95
+ } catch {
96
+ // silent — empty list
97
+ }
98
+ }
99
+
100
+ async function refreshActive() {
101
+ try {
102
+ const active = await getActiveTheme();
103
+ if (active) {
104
+ openThemeSlug.set(active._fileName ?? 'default');
105
+ currentDisplayName = active.name ?? $openThemeSlug;
106
+ lookConfigs = active.componentConfigs;
107
+ }
108
+ } catch {
109
+ // silent
110
+ }
111
+ }
112
+
113
+ // A read that never lands leaves the production state unknown, and the panel
114
+ // has nothing else that asks on its own between pulses. One delayed retry is
115
+ // the way out of it.
116
+ const PRODUCTION_RETRY_MS = 3000;
117
+ let productionRetry: ReturnType<typeof setTimeout> | undefined;
118
+
119
+ async function refreshProduction(retry = true) {
120
+ try {
121
+ productionTheme.set(await getProductionTheme());
122
+ } catch {
123
+ if (retry) productionRetry = setTimeout(() => refreshProduction(false), PRODUCTION_RETRY_MS);
124
+ }
125
+ }
126
+
127
+ // The store it writes outlives the panel, so a pending retry has to go with
128
+ // the panel rather than land on a later mount's state.
129
+ onDestroy(() => {
130
+ if (productionRetry !== undefined) clearTimeout(productionRetry);
131
+ });
132
+
133
+ // ── Components ────────────────────────────────────────────────────────
134
+ //
135
+ // The per-component file managers live in the component editors; the list
136
+ // here answers one question — how many components run something the open
137
+ // theme does not carry.
138
+
139
+ let components: ComponentSummary[] = $state([]);
140
+ let lookConfigs: Theme['componentConfigs'] | null = $state(null);
141
+
142
+ let componentsOffLook = $derived(
143
+ lookConfigs ? countComponentsOffLook(components, lookConfigs, activeIsProtected) : 0,
144
+ );
145
+
146
+ async function refreshComponents() {
147
+ try {
148
+ components = await listComponents();
149
+ } catch {
150
+ // silent — keep the last answer rather than showing a wrong zero
151
+ }
152
+ }
153
+
154
+ // Two signals move the components on their own: a component settling (saving
155
+ // one writes its buffer without pulsing production), and a component editor
156
+ // loading a preset with no edit in play, which only the component pulse
157
+ // reports. Adopts arrive on the production pulse below.
158
+ $effect(() => {
159
+ void dirtyComponentCount;
160
+ void $componentActiveRevision;
161
+ refreshComponents();
162
+ });
163
+
164
+ function openComponents() {
165
+ editorView.set('components');
166
+ }
167
+
168
+ onMount(async () => {
169
+ await refreshFiles();
170
+ await refreshActive();
171
+ await refreshProduction();
172
+ });
173
+
174
+ // Re-read whenever an Adopt fires, here or in a component editor: it saves
175
+ // the open theme and moves the production pointer, so the identity, the
176
+ // component summary and the production state shown here all need to track.
177
+ // Skip the first tick (mount ran them already).
178
+ let pulseInitialised = false;
179
+ $effect(() => {
180
+ void $productionRevision;
181
+ if (!pulseInitialised) {
182
+ pulseInitialised = true;
183
+ return;
184
+ }
185
+ refreshActive();
186
+ refreshProduction();
187
+ refreshFiles();
188
+ refreshComponents();
189
+ });
190
+
191
+ // A capture reads the server's live state, so the colors and type on screen
192
+ // go to their buffer first — that is what makes Save and Adopt mean the look
193
+ // in front of you. A component's edits live in its own editor's state, which
194
+ // this panel cannot write, so those are the only ones a capture leaves behind.
195
+ //
196
+ // The gate is `colorsAndTypeDirty`, not history: history counts every entry,
197
+ // components included, so it would flush the colors over component-only work.
198
+ async function flushColors(): Promise<void> {
199
+ if (!$colorsAndTypeDirty) return;
200
+ await persistColorsAndType(get(editorState), currentDisplayName);
201
+ }
202
+
203
+ function confirmUnsavedComponents(action: string): boolean {
204
+ if (dirtyComponentCount === 0) return true;
205
+ const n = dirtyComponentCount;
206
+ return window.confirm(
207
+ `${n === 1 ? '1 component has' : `${n} components have`} unsaved edits. Those stay out `
208
+ + `until you save them in the component editor. ${action}`,
209
+ );
210
+ }
211
+
212
+ async function handleSave() {
213
+ if (activeIsProtected) return;
214
+ if (!confirmUnsavedComponents('Save the theme anyway?')) return;
215
+ saveStatus = 'saving';
216
+ try {
217
+ await flushColors();
218
+ await saveActiveTheme(currentDisplayName);
219
+ await refreshActive();
220
+ flashStatus(setSaveStatus, 'saved');
221
+ } catch {
222
+ flashStatus(setSaveStatus, 'error');
223
+ }
224
+ }
225
+
226
+ function openSaveAs() {
227
+ if (!confirmUnsavedComponents('Save the theme anyway?')) return;
228
+ showFileList = false;
229
+ saveAsDialog = true;
230
+ }
231
+
232
+ async function confirmSaveAs(detail: { displayName: string; fileName: string }) {
233
+ saveStatus = 'saving';
234
+ try {
235
+ await flushColors();
236
+ await saveAsTheme(detail.fileName, detail.displayName);
237
+ await refreshFiles();
238
+ await refreshActive();
239
+ flashStatus(setSaveStatus, 'saved');
240
+ } catch {
241
+ flashStatus(setSaveStatus, 'error');
242
+ }
243
+ }
244
+
245
+ // ── Adopt: ship the whole look ────────────────────────────────────────
246
+ //
247
+ // One action at the root, because production is one saved theme: Adopt saves
248
+ // the open one and publishes it whole. The component editors' Adopt runs the
249
+ // same flow, so a component can never ship on its own.
250
+
251
+ type AdoptStatus = 'idle' | 'adopting' | 'done' | 'error';
252
+ let adoptStatus: AdoptStatus = $state('idle');
253
+ const setAdoptStatus = (s: AdoptStatus) => (adoptStatus = s);
254
+
255
+ let production = $derived(
256
+ lookProductionState({
257
+ openTheme: $openThemeSlug,
258
+ productionTheme: $productionTheme?._fileName ?? null,
259
+ unpublished: unsavedEdits || $liveMovedSinceBake,
260
+ }),
261
+ );
262
+
263
+ let adoptTitle = $derived.by(() => {
264
+ if (production.inProduction) return 'Production is running this theme';
265
+ if (production.unknown || production.themeOff) return 'Ship this theme to production';
266
+ return 'Save this theme and ship it to production';
267
+ });
268
+
269
+ async function handleAdopt() {
270
+ if (production.inProduction || adoptStatus === 'adopting') return;
271
+ if (!confirmUnsavedComponents('Ship the theme anyway?')) return;
272
+ await runAdopt();
273
+ }
274
+
275
+ /**
276
+ * Adopt publishes what is saved, so the flow is flush, save, ship. The
277
+ * protected Default theme cannot record what shipped: the user clicked
278
+ * Adopt, and which file holds the look is bookkeeping they should not have
279
+ * to think about, so it forks to a theme of their own first.
280
+ */
281
+ async function runAdopt() {
282
+ adoptStatus = 'adopting';
283
+ try {
284
+ await flushColors();
285
+ if (activeIsProtected) {
286
+ const taken = new Set((await listThemes()).map((m) => m.fileName));
287
+ await saveAsTheme(freshName('my-theme', taken), 'My Theme');
288
+ await refreshFiles();
289
+ await refreshActive();
290
+ } else {
291
+ await saveActiveTheme(currentDisplayName);
292
+ }
293
+ await adoptLook();
294
+ // The panel's own production pulse re-reads identity, the production
295
+ // theme and the component summary.
296
+ bumpProductionRevision();
297
+ flashStatus(setAdoptStatus, 'done');
298
+ } catch {
299
+ flashStatus(setAdoptStatus, 'error', { durationMs: 3000 });
300
+ }
301
+ }
302
+
303
+ // ── Colors & Type ─────────────────────────────────────────────────────
304
+ //
305
+ // Read-only identity: the two faces the theme is recognised by. The colors
306
+ // and type belong to the theme, so nothing here names a file.
307
+
308
+ let pairing = $derived(fontPairingLabel($editorState.fonts.stacks, $editorState.fonts.sources));
309
+
310
+ // ── Preview ───────────────────────────────────────────────────────────
311
+ //
312
+ // Selecting a row paints that theme on the page and leaves the window open;
313
+ // nothing is written until Save. Cancelling — or closing the window by any
314
+ // route — repaints the user's live state, unsaved edits included.
315
+
316
+ let previewRow: LoadRow | null = $state(null);
317
+ // `$state.raw`: the preview engine structuredClones these, and a deep `$state`
318
+ // proxy is not cloneable. Both are only ever reassigned whole.
319
+ let previewLook: Theme | null = $state.raw(null);
320
+ let previewLayer: ColorsAndType | null = $state.raw(null);
321
+ let colorsOnly = $state(false);
322
+ let effectiveColorsOnly = $derived(isColorsOnly(previewRow, colorsOnly));
323
+ let colorsOnlyLocked = $derived(colorsOnlyIsForced(previewRow));
324
+
325
+ function cancelPreview() {
326
+ revertPreview();
327
+ previewRow = null;
328
+ previewLook = null;
329
+ previewLayer = null;
330
+ }
331
+
332
+ /** Paint whatever is selected, through the engine the mode calls for. A
333
+ * theme's colors and type are the slice embedded in it, so one look
334
+ * previews either way. */
335
+ async function repaint(): Promise<void> {
336
+ if (previewLayer) {
337
+ previewColorsAndType(previewLayer);
338
+ return;
339
+ }
340
+ if (!previewLook) return;
341
+ if (effectiveColorsOnly) previewColorsAndType(previewLook.colorsAndType);
342
+ else await previewTheme(previewLook);
343
+ }
344
+
345
+ async function handleSelect(row: LoadRow) {
346
+ if (row.fileName === previewRow?.fileName) return;
347
+ // The open theme in whole-look mode is what the page already shows. In
348
+ // colors-only mode it is a real operation: it puts the theme's own colors
349
+ // back over whatever the user has edited.
350
+ if (row.kind === 'look' && row.slug === $openThemeSlug && !isColorsOnly(row, colorsOnly)) {
351
+ cancelPreview();
352
+ return;
353
+ }
354
+ try {
355
+ const look = row.kind === 'look' ? await loadTheme(row.slug) : null;
356
+ const layer = row.kind === 'layer' ? await loadColorsAndType(row.slug) : null;
357
+ // The window may have closed during the fetch; painting then would leave
358
+ // a preview on screen with no Save or Cancel in sight.
359
+ if (!showFileList) return;
360
+ previewRow = row;
361
+ previewLook = look;
362
+ previewLayer = layer;
363
+ await repaint();
364
+ } catch (err) {
365
+ window.alert(`Failed to preview theme: ${(err as Error).message}`);
366
+ cancelPreview();
367
+ }
368
+ }
369
+
370
+ async function handleToggleColorsOnly(next: boolean) {
371
+ colorsOnly = next;
372
+ if (previewRow) await repaint();
373
+ }
374
+
375
+ async function handleSavePreview() {
376
+ if (!previewRow) return;
377
+ if (effectiveColorsOnly) {
378
+ await commitColorsOnly();
379
+ return;
380
+ }
381
+ await commitWholeLook();
382
+ }
383
+
384
+ async function commitWholeLook() {
385
+ const row = previewRow;
386
+ if (!row) return;
387
+ if (unsavedEdits) {
388
+ const ok = window.confirm(
389
+ 'Loading a theme will reload the editor and discard unsaved changes. Continue?',
390
+ );
391
+ if (!ok) return;
392
+ }
393
+ // The window stays open until the page reloads: closing it would revert the
394
+ // preview and flash the outgoing look while Apply is in flight.
395
+ try {
396
+ const result = await applyTheme(row.slug);
397
+ if (result.skippedComponents.length > 0) {
398
+ window.alert(
399
+ `Loaded "${row.name}". These components are not installed here, so their `
400
+ + `saved settings were skipped:\n\n${result.skippedComponents.join(', ')}`,
401
+ );
402
+ }
403
+ // applyTheme opens the theme: it fills the working buffers and points
404
+ // `themes/_active.json` at it. Reload to rehydrate the editor from those.
405
+ window.location.reload();
406
+ } catch (err) {
407
+ window.alert(`Failed to load theme: ${(err as Error).message}`);
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Colors and type alone: the components stay as they are, so this loads the
413
+ * colors half into the open theme rather than opening a new one. The picked
414
+ * copy goes to the working buffer and onto the page; the theme itself is
415
+ * unchanged until Save.
416
+ */
417
+ async function commitColorsOnly() {
418
+ const row = previewRow;
419
+ // Read the payload before the revert clears it. The read doors mark where
420
+ // they answered from; the buffer holds content alone.
421
+ const picked = previewLayer ?? previewLook?.colorsAndType ?? null;
422
+ if (!row || !picked) return;
423
+ const { _fileName, _source, ...buffer } = picked;
424
+ if (unsavedEdits) {
425
+ const ok = window.confirm(
426
+ 'Loading colors and type will discard unsaved changes. Continue?',
427
+ );
428
+ if (!ok) return;
429
+ }
430
+ // Hand the page back to the store before loading. The renderer diffs
431
+ // against its own last-applied set, which never saw the preview's direct
432
+ // writes; starting from the live look makes the load land exactly as it
433
+ // does with no preview in play.
434
+ cancelPreview();
435
+ showFileList = false;
436
+ try {
437
+ await writeWorkingColorsAndType(buffer);
438
+ hydrateColorsAndType(structuredClone(buffer));
439
+ } catch (err) {
440
+ window.alert(`Failed to load colors and type: ${(err as Error).message}`);
441
+ }
442
+ }
443
+
444
+ // Closing the window is cancelling: the X, the backdrop and the Cancel button
445
+ // all just flip `show`, so watching it covers every exit.
446
+ $effect(() => {
447
+ if (!showFileList) cancelPreview();
448
+ });
449
+
450
+ onDestroy(cancelPreview);
451
+
452
+ async function handleExport(row: LoadRow) {
453
+ try {
454
+ await exportTheme(row.slug);
455
+ } catch (err) {
456
+ window.alert(`Failed to export: ${(err as Error).message}`);
457
+ }
458
+ }
459
+
460
+ let importInput: HTMLInputElement | null = $state(null);
461
+
462
+ function openImport() {
463
+ importInput?.click();
464
+ }
465
+
466
+ async function handleImportFile(event: Event) {
467
+ const input = event.target as HTMLInputElement;
468
+ const file = input.files?.[0];
469
+ input.value = ''; // allow re-picking the same file later
470
+ if (!file) return;
471
+ let bundle: any;
472
+ try {
473
+ bundle = JSON.parse(await file.text());
474
+ } catch {
475
+ window.alert('Selected file is not valid JSON.');
476
+ return;
477
+ }
478
+ // Kind and version travel as a pair: bundles exported before the rename say
479
+ // `manifest-bundle` and are always v1. The server re-checks.
480
+ const known =
481
+ (bundle?.kind === 'manifest-bundle' && bundle.schemaVersion === 1) ||
482
+ (bundle?.kind === 'theme-bundle' && bundle.schemaVersion === 3);
483
+ if (!known) {
484
+ window.alert('That file is not an exported theme.');
485
+ return;
486
+ }
487
+ try {
488
+ const result = await importTheme(bundle);
489
+ await refreshFiles();
490
+ const notes: string[] = [];
491
+ const renameCount = Object.keys(result.renames).length;
492
+ if (renameCount > 0) {
493
+ const summary = Object.entries(result.renames)
494
+ .map(([k, v]) => `${k} → ${v}`)
495
+ .join('\n');
496
+ notes.push(`${renameCount} file(s) renamed to avoid collisions:\n${summary}`);
497
+ }
498
+ if (result.dropped.length > 0) {
499
+ notes.push(
500
+ `The file was missing this data, which fell back to the default:\n${result.dropped.join('\n')}`,
501
+ );
502
+ }
503
+ if (notes.length > 0) {
504
+ window.alert(`Imported as "${result.theme}".\n\n${notes.join('\n\n')}`);
505
+ }
506
+ } catch (err) {
507
+ window.alert(`Failed to import: ${(err as Error).message}`);
508
+ }
509
+ }
510
+
511
+ async function handleDelete(row: LoadRow) {
512
+ if (row.isProtected) return;
513
+ if (row.kind === 'layer') return deleteColorsFile(row);
514
+ // Deleting the open theme is legal: the working buffer survives its
515
+ // document, so the look on screen stays. Where open lands depends on
516
+ // something the client can't see — deleting a local copy that shadows a
517
+ // shipped theme restores the shipped one and keeps naming it, while
518
+ // deleting a local-only theme sends open back to Motion Proto.
519
+ const wasActive = row.slug === $openThemeSlug;
520
+ const ok = window.confirm(
521
+ wasActive
522
+ ? `Delete theme "${row.name}"? The editor moves to the version shipped with the package if there is one, otherwise Motion Proto. The look on screen stays as it is.`
523
+ : `Delete theme "${row.name}"?`,
524
+ );
525
+ if (!ok) return;
526
+ try {
527
+ await deleteTheme(row.slug);
528
+ if (row.fileName === previewRow?.fileName) cancelPreview();
529
+ await refreshFiles();
530
+ if (wasActive) await refreshActive();
531
+ } catch (err) {
532
+ window.alert(`Failed to delete: ${(err as Error).message}`);
533
+ }
534
+ }
535
+
536
+ /** Colors and type files are presets: nothing live points at one, and the
537
+ * themes that carry those colors keep their own copy. */
538
+ async function deleteColorsFile(row: LoadRow) {
539
+ const ok = window.confirm(
540
+ `Delete the colors and type file "${row.name}"? Your themes keep their own copies.`,
541
+ );
542
+ if (!ok) return;
543
+ try {
544
+ if (row.fileName === previewRow?.fileName) cancelPreview();
545
+ await deleteColorsAndType(row.slug);
546
+ await refreshFiles();
547
+ } catch (err) {
548
+ window.alert(`Failed to delete: ${(err as Error).message}`);
549
+ }
550
+ }
551
+
552
+ function toggleFileList() {
553
+ showFileList = !showFileList;
554
+ if (showFileList) refreshFiles();
555
+ }
556
+
557
+ function rowBadge(row: LoadRow) {
558
+ return row.kind === 'layer'
559
+ ? { label: 'colors & type', title: 'Holds colors and type only, no shapes' }
560
+ : null;
561
+ }
562
+ </script>
563
+
564
+ <div class="look-panel">
565
+ <div class="mfm-header">
566
+ <span class="mfm-header-label">Theme</span>
567
+ <UIInfoPopover title="Theme" ariaLabel="About the theme">
568
+ <p>
569
+ A <strong>theme</strong> is a whole look in one file: the colors and type plus a setting for every component you changed.
570
+ </p>
571
+ <p>
572
+ It holds its own copy of that data, so the theme you open is the whole look, and one theme can never break another.
573
+ </p>
574
+ <p>
575
+ <strong>Load</strong> opens the list. Picking a theme shows it on the page as a preview, so you can try each look with nothing written to disk. Pick another to compare, or <strong>Cancel</strong> to go back to where you were.
576
+ </p>
577
+ <p>
578
+ <strong>Save</strong> opens the previewed theme: the editor works on it from then on, and components it does not carry go back to their defaults. Trying a theme never changes what your site ships.
579
+ </p>
580
+ <p>
581
+ <strong>Colors and type only</strong> in that window takes the palette and the fonts and leaves your shapes and component settings alone.
582
+ </p>
583
+ <p>
584
+ The <strong>active</strong> theme is the one the editor has open. <strong>Adopt</strong> saves it and ships it to production, colors and type plus every component you changed. The line under the name says whether production is running this theme.
585
+ </p>
586
+ <p>
587
+ Save and Adopt both take the colors and type on screen as they are. Component edits are the exception: save those in the component's own editor first.
588
+ </p>
589
+ <p>
590
+ <strong>Motion Proto</strong> is the protected baseline. To start customizing, <strong>Save As</strong> a new theme first.
591
+ </p>
592
+ </UIInfoPopover>
593
+ </div>
594
+
595
+ <div class="mfm-card" class:protected={activeIsProtected}>
596
+ <span class="mfm-rail" aria-hidden="true"></span>
597
+ <div class="mfm-card-head">
598
+ <span class="mfm-card-label">Active</span>
599
+ {#if activeIsProtected}
600
+ <span class="mfm-badge protected" title="The default theme is read-only">
601
+ <i class="fas fa-lock" aria-hidden="true"></i>
602
+ <span>protected</span>
603
+ </span>
604
+ {/if}
605
+ </div>
606
+ <FilePill
607
+ name={currentDisplayName}
608
+ isProtected={activeIsProtected}
609
+ protectedTitle="Protected default theme"
610
+ title={currentDisplayName}
611
+ style="display: flex;"
612
+ />
613
+ <span class="mfm-prod-status" class:applied={production.inProduction}>
614
+ <i class="mfm-status-dot" aria-hidden="true"></i>
615
+ <span>
616
+ {#if production.unknown}production unknown{:else if production.inProduction}in production{:else}out of sync{/if}
617
+ </span>
618
+ </span>
619
+ <button
620
+ class="mfm-adopt-btn"
621
+ class:adopting={adoptStatus === 'adopting'}
622
+ class:adopted={adoptStatus === 'done'}
623
+ class:error={adoptStatus === 'error'}
624
+ class:in-sync={production.inProduction}
625
+ onclick={handleAdopt}
626
+ disabled={adoptStatus === 'adopting' || production.inProduction}
627
+ title={adoptTitle}
628
+ >
629
+ <i
630
+ class="fas"
631
+ class:fa-arrow-down={adoptStatus === 'idle'}
632
+ class:fa-spinner={adoptStatus === 'adopting'}
633
+ class:fa-check={adoptStatus === 'done'}
634
+ class:fa-xmark={adoptStatus === 'error'}
635
+ ></i>
636
+ <span>
637
+ {#if adoptStatus === 'idle'}Adopt{:else if adoptStatus === 'adopting'}Adopting{:else if adoptStatus === 'done'}Adopted{:else}Error{/if}
638
+ </span>
639
+ </button>
640
+ <div class="mfm-card-actions">
641
+ <button
642
+ class="mfm-btn mfm-btn-row save-btn"
643
+ class:saving={saveStatus === 'saving'}
644
+ class:saved={saveStatus === 'saved'}
645
+ class:error={saveStatus === 'error'}
646
+ onclick={handleSave}
647
+ disabled={activeIsProtected || saveStatus === 'saving'}
648
+ title={activeIsProtected
649
+ ? 'Motion Proto is read-only. Use Save As to capture under a new name.'
650
+ : 'Update this theme from the look on screen'}
651
+ >
652
+ <i
653
+ class="fas"
654
+ class:fa-save={saveStatus === 'idle'}
655
+ class:fa-spinner={saveStatus === 'saving'}
656
+ class:fa-check={saveStatus === 'saved'}
657
+ class:fa-times={saveStatus === 'error'}
658
+ ></i>
659
+ <span>
660
+ {#if saveStatus === 'idle'}Save{:else if saveStatus === 'saving'}Saving{:else if saveStatus === 'saved'}Saved{:else}Error{/if}
661
+ </span>
662
+ </button>
663
+ <button class="mfm-btn mfm-btn-row" onclick={openSaveAs} title="Save the current look as a new theme">
664
+ <i class="fas fa-copy"></i>
665
+ <span>Save As…</span>
666
+ </button>
667
+ <button
668
+ class="mfm-btn mfm-btn-row"
669
+ class:active={showFileList}
670
+ onclick={toggleFileList}
671
+ title="Preview a theme, then save it to load it"
672
+ >
673
+ <i class="fas fa-folder-open"></i>
674
+ <span>Load…</span>
675
+ </button>
676
+ <button
677
+ class="mfm-btn mfm-btn-row"
678
+ onclick={openImport}
679
+ title="Import a theme someone shared"
680
+ >
681
+ <i class="fas fa-file-import"></i>
682
+ <span>Import…</span>
683
+ </button>
684
+ </div>
685
+ </div>
686
+
687
+ <!-- Parts of the look: what each holds now, no lifecycle of its own. -->
688
+ <div class="look-parts">
689
+ <div class="part-head part-static">
690
+ <span class="part-label">Colors &amp; Type</span>
691
+ <span class="part-summary">
692
+ {#if pairing}
693
+ <span class="part-summary-sep">·</span>
694
+ <span class="part-summary-text" title={pairing}>{pairing}</span>
695
+ {/if}
696
+ </span>
697
+ <UIInfoPopover title="Colors &amp; Type" ariaLabel="About colors and type">
698
+ <p>
699
+ <strong>Colors &amp; type</strong> is the part of the theme your design tokens live in. Components read those tokens for their own appearance.
700
+ </p>
701
+ <p>
702
+ The two faces named here are the heading and body fonts the page is showing.
703
+ </p>
704
+ <p>
705
+ To load colors and type without touching your shapes, open <strong>Load</strong> above and turn on <strong>Colors and type only</strong>.
706
+ </p>
707
+ </UIInfoPopover>
708
+ </div>
709
+
710
+ <div class="part-head part-static">
711
+ <span class="part-label">Components</span>
712
+ <span class="part-summary">
713
+ <span class="part-summary-sep">·</span>
714
+ {#if componentsOffLook > 0}
715
+ <span class="part-summary-count">{componentsOffLook}</span>
716
+ <span>off the theme</span>
717
+ {:else}
718
+ <span>in sync</span>
719
+ {/if}
720
+ </span>
721
+ {#if canOpenComponents}
722
+ <UIPillButton
723
+ size="compact"
724
+ icon="fa-cubes"
725
+ title="Open the component editors"
726
+ onclick={openComponents}
727
+ >
728
+ Open
729
+ </UIPillButton>
730
+ {/if}
731
+ </div>
732
+ </div>
733
+ </div>
734
+
735
+ <!-- Hidden file input for Import; clicked via openImport(). -->
736
+ <input
737
+ bind:this={importInput}
738
+ type="file"
739
+ accept=".json,application/json"
740
+ onchange={handleImportFile}
741
+ style="display: none;"
742
+ />
743
+
744
+ <FileLoadList
745
+ bind:show={showFileList}
746
+ title="Load Theme"
747
+ files={rows}
748
+ activeFileName={activeRowId}
749
+ selectedFileName={previewRow?.fileName ?? null}
750
+ selectedBadge={{ label: 'Preview', title: 'Shown on the page now. Save to keep it.' }}
751
+ {rowBadge}
752
+ cancelLabel={previewRow ? 'Cancel' : 'Close'}
753
+ confirmLabel={previewRow ? 'Save' : ''}
754
+ onconfirm={handleSavePreview}
755
+ onload={handleSelect}
756
+ ondelete={handleDelete}
757
+ onexport={(row) => handleExport(row)}
758
+ canExport={(row) => row.kind === 'look'}
759
+ exportTitle={(row) => `Export "${row.name}" as a file you can share`}
760
+ >
761
+ {#snippet options()}
762
+ <label
763
+ class="colors-only"
764
+ class:locked={colorsOnlyLocked}
765
+ title={colorsOnlyLocked ? 'This file holds colors and type, so that is all it can load.' : ''}
766
+ >
767
+ <input
768
+ class="ui-form-checkbox"
769
+ type="checkbox"
770
+ checked={effectiveColorsOnly}
771
+ disabled={colorsOnlyLocked}
772
+ onchange={(e) => handleToggleColorsOnly(e.currentTarget.checked)}
773
+ />
774
+ <span>Colors and type only. Keep my shapes.</span>
775
+ </label>
776
+ {/snippet}
777
+ </FileLoadList>
778
+
779
+ <SaveAsDialog
780
+ bind:show={saveAsDialog}
781
+ {currentDisplayName}
782
+ {files}
783
+ title="Save Theme As"
784
+ placeholder="Theme name…"
785
+ reservedNameMessage='The name "default" is reserved for the protected baseline.'
786
+ branchFromDefaultName="My Theme"
787
+ onsave={confirmSaveAs}
788
+ />
789
+
790
+ <style>
791
+ .look-panel {
792
+ --mfm-active: #5aa85e;
793
+ --mfm-rail-neutral: var(--ui-border);
794
+ --mfm-rail-active: var(--mfm-active);
795
+
796
+ display: flex;
797
+ flex-direction: column;
798
+ gap: var(--ui-space-8);
799
+ }
800
+
801
+ .mfm-header {
802
+ display: flex;
803
+ align-items: center;
804
+ justify-content: space-between;
805
+ gap: var(--ui-space-4);
806
+ padding: 0 var(--ui-space-4);
807
+ }
808
+
809
+ .mfm-header-label {
810
+ font-size: var(--ui-font-size-xs);
811
+ color: var(--ui-text-secondary);
812
+ }
813
+
814
+ .mfm-card {
815
+ position: relative;
816
+ display: flex;
817
+ flex-direction: column;
818
+ gap: var(--ui-space-6);
819
+ padding: var(--ui-space-8) var(--ui-space-10) var(--ui-space-10) var(--ui-space-16);
820
+ background: var(--ui-surface-lower);
821
+ border: 1px solid var(--ui-border-low);
822
+ border-radius: var(--ui-radius-md);
823
+ }
824
+
825
+ .mfm-rail {
826
+ position: absolute;
827
+ left: 0;
828
+ top: 0;
829
+ bottom: 0;
830
+ width: 3px;
831
+ border-radius: var(--ui-radius-md) 0 0 var(--ui-radius-md);
832
+ background: var(--mfm-rail-active);
833
+ transition: background var(--ui-transition-base);
834
+ }
835
+
836
+ .mfm-card.protected .mfm-rail {
837
+ background: var(--mfm-rail-neutral);
838
+ }
839
+
840
+ .mfm-card-head {
841
+ display: flex;
842
+ align-items: baseline;
843
+ justify-content: space-between;
844
+ gap: var(--ui-space-8);
845
+ }
846
+
847
+ .mfm-card-label {
848
+ font-size: var(--ui-font-size-xs);
849
+ font-weight: var(--ui-font-weight-semibold);
850
+ color: var(--ui-text-tertiary);
851
+ }
852
+
853
+ .mfm-badge {
854
+ display: inline-flex;
855
+ align-items: center;
856
+ gap: var(--ui-space-4);
857
+ font-size: var(--ui-font-size-xs);
858
+ color: var(--ui-text-tertiary);
859
+ }
860
+
861
+ .mfm-badge.protected i {
862
+ font-size: 0.8em;
863
+ }
864
+
865
+ /* Production state of the whole look, under the name it ships as. */
866
+ .mfm-prod-status {
867
+ display: inline-flex;
868
+ align-items: center;
869
+ gap: var(--ui-space-4);
870
+ font-size: 0.7rem;
871
+ color: var(--ui-text-muted);
872
+ line-height: 1;
873
+ }
874
+
875
+ .mfm-status-dot {
876
+ width: 5px;
877
+ height: 5px;
878
+ border-radius: 50%;
879
+ background: currentColor;
880
+ opacity: 0.7;
881
+ flex-shrink: 0;
882
+ }
883
+
884
+ .mfm-prod-status.applied {
885
+ color: var(--mfm-active);
886
+ }
887
+
888
+ .mfm-prod-status.applied .mfm-status-dot {
889
+ opacity: 1;
890
+ }
891
+
892
+ /* The ship action. Green is the file-state exception to the greyscale rule,
893
+ shared with the component editors' own Adopt. */
894
+ .mfm-adopt-btn {
895
+ display: flex;
896
+ align-items: center;
897
+ justify-content: center;
898
+ gap: var(--ui-space-6);
899
+ width: 100%;
900
+ padding: var(--ui-space-6) var(--ui-space-12);
901
+ background: color-mix(in srgb, var(--mfm-active) 18%, var(--ui-surface-high));
902
+ border: 1px solid color-mix(in srgb, var(--mfm-active) 45%, var(--ui-border-high));
903
+ border-radius: var(--ui-radius-md);
904
+ color: var(--ui-text-primary);
905
+ font-family: inherit;
906
+ font-size: var(--ui-font-size-md);
907
+ font-weight: var(--ui-font-weight-medium);
908
+ cursor: pointer;
909
+ transition: all var(--ui-transition-fast);
910
+ white-space: nowrap;
911
+ }
912
+
913
+ .mfm-adopt-btn i {
914
+ width: 1rem;
915
+ text-align: center;
916
+ font-size: 0.85em;
917
+ }
918
+
919
+ .mfm-adopt-btn:hover:not(:disabled) {
920
+ background: color-mix(in srgb, var(--mfm-active) 30%, var(--ui-surface-higher));
921
+ border-color: color-mix(in srgb, var(--mfm-active) 70%, var(--ui-border-higher));
922
+ }
923
+
924
+ .mfm-adopt-btn:disabled {
925
+ cursor: not-allowed;
926
+ }
927
+
928
+ .mfm-adopt-btn.in-sync {
929
+ background: transparent;
930
+ border-color: var(--ui-border-low);
931
+ color: var(--ui-text-muted);
932
+ opacity: 0.7;
933
+ }
934
+
935
+ .mfm-adopt-btn.adopting i {
936
+ animation: mfm-spin 0.8s linear infinite;
937
+ }
938
+
939
+ .mfm-adopt-btn.adopted {
940
+ background: color-mix(in srgb, var(--mfm-active) 30%, var(--ui-surface-high));
941
+ color: var(--mfm-active);
942
+ }
943
+
944
+ .mfm-adopt-btn.error {
945
+ color: var(--ui-text-muted);
946
+ }
947
+
948
+ .mfm-card-actions {
949
+ display: flex;
950
+ flex-direction: column;
951
+ gap: var(--ui-space-4);
952
+ }
953
+
954
+ .mfm-btn {
955
+ display: inline-flex;
956
+ align-items: center;
957
+ justify-content: flex-start;
958
+ gap: var(--ui-space-8);
959
+ padding: var(--ui-space-6) var(--ui-space-10);
960
+ background: var(--ui-surface-high);
961
+ border: 1px solid var(--ui-border-low);
962
+ border-radius: var(--ui-radius-sm);
963
+ color: var(--ui-text-primary);
964
+ font-family: inherit;
965
+ font-size: var(--ui-font-size-sm);
966
+ line-height: 1;
967
+ cursor: pointer;
968
+ transition: background var(--ui-transition-fast), color var(--ui-transition-fast);
969
+ }
970
+
971
+ .mfm-btn:hover:not(:disabled) {
972
+ background: var(--ui-hover);
973
+ }
974
+
975
+ .mfm-btn:disabled {
976
+ cursor: not-allowed;
977
+ opacity: 0.5;
978
+ }
979
+
980
+ .mfm-btn.active {
981
+ background: var(--ui-active);
982
+ }
983
+
984
+ .mfm-btn.saved { color: var(--mfm-active); }
985
+ .mfm-btn.error { color: var(--ui-error, #c0392b); }
986
+
987
+ .mfm-btn .fa-spinner {
988
+ animation: mfm-spin 0.8s linear infinite;
989
+ }
990
+
991
+ @keyframes mfm-spin {
992
+ from { transform: rotate(0deg); }
993
+ to { transform: rotate(360deg); }
994
+ }
995
+
996
+ /* Parts. Each reads as a row belonging to the card above, so they sit inside
997
+ the panel with the same left inset the card's rail gives its content. */
998
+ .look-parts {
999
+ display: flex;
1000
+ flex-direction: column;
1001
+ gap: var(--ui-space-4);
1002
+ padding: 0 var(--ui-space-4);
1003
+ }
1004
+
1005
+ .part-head {
1006
+ display: flex;
1007
+ align-items: center;
1008
+ gap: var(--ui-space-6);
1009
+ min-height: 24px;
1010
+ }
1011
+
1012
+ .part-static {
1013
+ padding-left: var(--ui-space-8);
1014
+ }
1015
+
1016
+ .part-label {
1017
+ font-size: var(--ui-font-size-sm);
1018
+ color: var(--ui-text-secondary);
1019
+ flex-shrink: 0;
1020
+ }
1021
+
1022
+ .part-summary {
1023
+ display: inline-flex;
1024
+ align-items: center;
1025
+ gap: var(--ui-space-4);
1026
+ flex: 1 1 auto;
1027
+ min-width: 0;
1028
+ font-size: var(--ui-font-size-xs);
1029
+ color: var(--ui-text-tertiary);
1030
+ }
1031
+
1032
+ .part-summary-sep {
1033
+ color: var(--ui-border);
1034
+ }
1035
+
1036
+ .part-summary-count {
1037
+ font-family: var(--ui-font-mono);
1038
+ font-variant-numeric: tabular-nums;
1039
+ color: var(--ui-text-secondary);
1040
+ }
1041
+
1042
+ .part-summary-text {
1043
+ overflow: hidden;
1044
+ text-overflow: ellipsis;
1045
+ white-space: nowrap;
1046
+ }
1047
+
1048
+ /* Rendered inside the Load window, so it carries this panel's scope. */
1049
+ .colors-only {
1050
+ display: flex;
1051
+ align-items: center;
1052
+ gap: var(--ui-space-8);
1053
+ font-size: var(--ui-font-size-sm);
1054
+ color: var(--ui-text-secondary);
1055
+ cursor: pointer;
1056
+ user-select: none;
1057
+ }
1058
+
1059
+ .colors-only:hover {
1060
+ color: var(--ui-text-primary);
1061
+ }
1062
+
1063
+ .colors-only.locked {
1064
+ color: var(--ui-text-muted);
1065
+ cursor: default;
1066
+ }
1067
+ </style>