@motion-proto/live-tokens 0.47.1 → 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 (116) 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 +270 -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 +849 -531
  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 -1132
  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 +83 -13
  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/FileLoadList.svelte +67 -7
  88. package/src/editor/ui/GradientEditor.svelte +3 -3
  89. package/src/editor/ui/ThemePanel.svelte +1067 -0
  90. package/src/editor/ui/UIDialog.svelte +11 -9
  91. package/src/editor/ui/index.ts +4 -2
  92. package/src/live-tokens/data/colors-and-type/autumn.json +2490 -0
  93. package/src/live-tokens/data/{themes → colors-and-type}/default.json +962 -549
  94. package/src/live-tokens/data/colors-and-type/halloween.json +2529 -0
  95. package/src/live-tokens/data/colors-and-type/midnight-study.json +2536 -0
  96. package/src/live-tokens/data/colors-and-type/ocean.json +2489 -0
  97. package/src/live-tokens/data/colors-and-type/royal-velvet.json +2520 -0
  98. package/src/live-tokens/data/colors-and-type/spring-meadow.json +2476 -0
  99. package/src/live-tokens/data/colors-and-type/sunset.json +2528 -0
  100. package/src/live-tokens/data/themes/autumn.json +3918 -0
  101. package/src/live-tokens/data/themes/halloween.json +3992 -0
  102. package/src/live-tokens/data/themes/midnight-study.json +3932 -0
  103. package/src/live-tokens/data/themes/ocean.json +3917 -0
  104. package/src/live-tokens/data/themes/royal-velvet.json +3983 -0
  105. package/src/live-tokens/data/themes/spring-meadow.json +3904 -0
  106. package/src/live-tokens/data/themes/sunset.json +3931 -0
  107. package/src/live-tokens/data/tokens.generated.css +129 -140
  108. package/src/system/styles/CONVENTIONS.md +1 -1
  109. package/src/system/styles/fonts.css +1 -1
  110. package/template/README.md +7 -5
  111. package/template/_gitignore +0 -6
  112. package/src/editor/core/components/componentPersist.ts +0 -62
  113. package/src/editor/core/manifests/manifestService.ts +0 -172
  114. package/src/editor/ui/ManifestFileManager.svelte +0 -446
  115. package/src/editor/ui/ThemeFileManager.svelte +0 -785
  116. package/src/live-tokens/data/manifests/default.json +0 -35
@@ -1,785 +0,0 @@
1
- <script lang="ts">
2
- import { onMount } from 'svelte';
3
- import type { ThemeMeta } from '../core/themes/themeTypes';
4
- import { listThemes, deleteTheme, setActiveFile, getProductionInfo, setProductionFile, sanitizeFileName } from '../core/themes/themeService';
5
- import { listManifests, saveAsManifest } from '../core/manifests/manifestService';
6
- import { activeFileName } from '../core/store/editorConfigStore';
7
- import { dirty } from '../core/store/editorStore';
8
- import { productionRevision, bumpProductionRevision, themeProductionInfo } from '../core/productionPulse';
9
- import { flashStatus } from '../core/flashStatus';
10
- import UIInfoPopover from './UIInfoPopover.svelte';
11
- import FileLoadList from './FileLoadList.svelte';
12
- import FilePill from './FilePill.svelte';
13
- import SaveAsDialog from '../component-editor/scaffolding/SaveAsDialog.svelte';
14
-
15
- interface Props {
16
- saveStatus?: 'idle' | 'saving' | 'saved' | 'error';
17
- onsave?: (payload: { fileName: string; displayName: string }) => void | Promise<void>;
18
- onload?: (payload: { fileName: string }) => void;
19
- }
20
-
21
- let { saveStatus = 'idle', onsave, onload }: Props = $props();
22
-
23
- let files: ThemeMeta[] = $state([]);
24
- let showFileList = $state(false);
25
- let saveAsDialog = $state(false);
26
- let currentDisplayName = $state('Default Theme');
27
-
28
- type ProdApplyStatus = 'idle' | 'applying' | 'done' | 'error';
29
- let prodApplyStatus: ProdApplyStatus = $state('idle');
30
- const setProdApplyStatus = (s: ProdApplyStatus) => (prodApplyStatus = s);
31
-
32
- // Set when Adopt is clicked on a dirty+default theme: the SaveAs dialog opens
33
- // for the user to name their theme, and on confirm the Adopt resumes
34
- // automatically so the user gets one flow ("save and adopt") from one click.
35
- let adoptAfterSave = false;
36
-
37
- let prodIsInSync = $derived($themeProductionInfo?.fileName === $activeFileName);
38
- let editorIsApplied = $derived(prodIsInSync && !$dirty);
39
- let prodName = $derived($themeProductionInfo?.name ?? '—');
40
-
41
- let isDefaultActive = $derived($activeFileName === 'default');
42
-
43
- // Display names already in use by protected files. Passed to SaveAsDialog so
44
- // a user can't shadow the default by typing its label ("Default Theme"),
45
- // which sanitizes to "default-theme" and would otherwise slip past the
46
- // filename-only check.
47
- let protectedDisplayNames = $derived(
48
- files.filter((f) => f.fileName === 'default').map((f) => f.name),
49
- );
50
-
51
- async function refreshFiles() {
52
- try {
53
- files = await listThemes();
54
- const active = files.find((f) => f.isActive);
55
- if (active) {
56
- $activeFileName = active.fileName;
57
- currentDisplayName = active.name;
58
- }
59
- } catch {
60
- // silent — will show empty list
61
- }
62
- }
63
-
64
- async function refreshProduction() {
65
- try {
66
- const info = await getProductionInfo();
67
- themeProductionInfo.set(info);
68
- } catch {
69
- // silent — leave cached value in place
70
- }
71
- }
72
-
73
- onMount(async () => {
74
- await refreshFiles();
75
- await refreshProduction();
76
- });
77
-
78
- // Refresh production state when any production pointer flips (e.g. a manifest
79
- // is adopted elsewhere). Skip the initial tick — onMount already loaded it.
80
- let pulseInitialised = false;
81
- $effect(() => {
82
- void $productionRevision;
83
- if (!pulseInitialised) {
84
- pulseInitialised = true;
85
- return;
86
- }
87
- refreshProduction();
88
- });
89
-
90
- function handleSave() {
91
- // Default is read-only — quietly redirect the first Save into Save As so the
92
- // user gets one motion ("name it, save it") instead of bumping into a
93
- // disabled button and having to find Save As themselves.
94
- if (isDefaultActive) {
95
- openSaveAs();
96
- return;
97
- }
98
- onsave?.({ fileName: $activeFileName, displayName: currentDisplayName });
99
- }
100
-
101
- function openSaveAs() {
102
- showFileList = false;
103
- // Standalone Save As must not piggyback on an Adopt that was cancelled
104
- // earlier — clear the latch so confirmSaveAs doesn't fire a stale Adopt.
105
- adoptAfterSave = false;
106
- saveAsDialog = true;
107
- }
108
-
109
- async function confirmSaveAs(detail: { displayName: string; fileName: string }) {
110
- const { displayName, fileName } = detail;
111
- await onsave?.({ fileName, displayName });
112
- $activeFileName = fileName;
113
- currentDisplayName = displayName;
114
- await refreshFiles();
115
- if (adoptAfterSave) {
116
- adoptAfterSave = false;
117
- await handleApplyToProduction();
118
- }
119
- }
120
-
121
- // Pick a manifest filename that doesn't collide with anything on disk so
122
- // auto-creating doesn't clobber a manifest the user customized earlier.
123
- async function pickFreshManifestName(base: string): Promise<string> {
124
- const baseFile = sanitizeFileName(base);
125
- let manifests: { fileName: string }[];
126
- try {
127
- manifests = await listManifests();
128
- } catch {
129
- return baseFile;
130
- }
131
- const taken = new Set(manifests.map((m) => m.fileName));
132
- if (!taken.has(baseFile)) return baseFile;
133
- for (let n = 1; n < 1000; n++) {
134
- const suffix = String(n).padStart(2, '0');
135
- const candidate = `${baseFile}_${suffix}`;
136
- if (!taken.has(candidate)) return candidate;
137
- }
138
- return `${baseFile}_${Date.now()}`;
139
- }
140
-
141
- async function handleApplyToProduction() {
142
- // Dirty edits on the protected default theme can't be saved to that file,
143
- // and adopting the on-disk default would silently strand the user's
144
- // changes. Open the theme SaveAs dialog and resume Adopt after save.
145
- if (isDefaultActive && $dirty) {
146
- adoptAfterSave = true;
147
- saveAsDialog = true;
148
- return;
149
- }
150
-
151
- // Already adopted with nothing unsaved to push.
152
- if (prodIsInSync && !$dirty) return;
153
-
154
- prodApplyStatus = 'applying';
155
- try {
156
- // Flush unsaved edits to the active theme file first, so production adopts
157
- // the current editor state rather than a stale on-disk snapshot.
158
- if ($dirty) await onsave?.({ fileName: $activeFileName, displayName: currentDisplayName });
159
- await setProductionFile($activeFileName);
160
- await refreshProduction();
161
- bumpProductionRevision();
162
- flashStatus(setProdApplyStatus, 'done');
163
- } catch (err) {
164
- const e = err as Error & { code?: string };
165
- if (e.code === 'ACTIVE_IS_PROTECTED') {
166
- // Default manifest is active — auto-create a user manifest and retry.
167
- // No second dialog: the user clicked one button (Adopt) and gave the
168
- // theme a name; the manifest is bookkeeping they shouldn't have to
169
- // think about.
170
- prodApplyStatus = 'idle';
171
- try {
172
- const targetName = await pickFreshManifestName('my-manifest');
173
- await saveAsManifest(targetName, targetName);
174
- } catch {
175
- flashStatus(setProdApplyStatus, 'error', { durationMs: 3000 });
176
- return;
177
- }
178
- await handleApplyToProduction();
179
- return;
180
- }
181
- flashStatus(setProdApplyStatus, 'error', { durationMs: 3000 });
182
- }
183
- }
184
-
185
- async function handleLoad(file: ThemeMeta) {
186
- if ($dirty) {
187
- const ok = window.confirm(
188
- 'Loading a theme will discard unsaved changes. Continue?',
189
- );
190
- if (!ok) return;
191
- }
192
- showFileList = false;
193
- await setActiveFile(file.fileName);
194
- $activeFileName = file.fileName;
195
- currentDisplayName = file.name;
196
- onload?.({ fileName: file.fileName });
197
- }
198
-
199
- // Two-step arm pattern. First click flips `revertArmed` so the button
200
- // morphs into a "Discard?" affordance; second click commits. Click-out,
201
- // Escape, a 3s timeout, or the dirty flag clearing all disarm.
202
- let revertArmed = $state(false);
203
- let revertTimer: ReturnType<typeof setTimeout> | null = null;
204
-
205
- function disarmRevert() {
206
- revertArmed = false;
207
- if (revertTimer) {
208
- clearTimeout(revertTimer);
209
- revertTimer = null;
210
- }
211
- }
212
-
213
- function handleRevert() {
214
- if (!$dirty) return;
215
- if (!revertArmed) {
216
- revertArmed = true;
217
- revertTimer = setTimeout(disarmRevert, 3000);
218
- return;
219
- }
220
- disarmRevert();
221
- onload?.({ fileName: $activeFileName });
222
- }
223
-
224
- $effect(() => {
225
- if (!$dirty && revertArmed) disarmRevert();
226
- });
227
-
228
- $effect(() => {
229
- if (!revertArmed) return;
230
- const onDocClick = (e: MouseEvent) => {
231
- if (!(e.target as HTMLElement).closest('.tfm-revert-btn')) disarmRevert();
232
- };
233
- const onKey = (e: KeyboardEvent) => {
234
- if (e.key === 'Escape') disarmRevert();
235
- };
236
- document.addEventListener('click', onDocClick, true);
237
- document.addEventListener('keydown', onKey);
238
- return () => {
239
- document.removeEventListener('click', onDocClick, true);
240
- document.removeEventListener('keydown', onKey);
241
- };
242
- });
243
-
244
- async function handleDelete(file: ThemeMeta) {
245
- if (file.fileName === 'default') return;
246
- if (file.fileName === $themeProductionInfo?.fileName) return;
247
- try {
248
- // Capture before refreshFiles() reads the server's reverted active back
249
- // into local state — otherwise the "was this the active file?" check
250
- // below sees the post-revert value and skips the reload.
251
- const wasActive = file.fileName === $activeFileName;
252
- await deleteTheme(file.fileName);
253
- await refreshFiles();
254
- if (wasActive) {
255
- $activeFileName = 'default';
256
- currentDisplayName = 'Default Theme';
257
- onload?.({ fileName: 'default' });
258
- }
259
- } catch {
260
- // silent
261
- }
262
- }
263
-
264
- function toggleFileList() {
265
- showFileList = !showFileList;
266
- if (showFileList) refreshFiles();
267
- }
268
-
269
- </script>
270
-
271
- <div class="theme-file-manager">
272
- <div class="tfm-header">
273
- <span class="tfm-header-label">Theme</span>
274
- <UIInfoPopover title="Themes" ariaLabel="About themes">
275
- <p>
276
- A <strong>theme</strong> saves the design tokens for a site, components use these tokens to define their appearance.
277
- </p>
278
- </UIInfoPopover>
279
- </div>
280
-
281
- <div class="tfm-cards" class:in-sync={prodIsInSync}>
282
- <div
283
- class="tfm-card tfm-card-editor"
284
- class:dirty={$dirty}
285
- class:applied={editorIsApplied}
286
- >
287
- <span class="tfm-rail" aria-hidden="true"></span>
288
- <div class="tfm-card-head">
289
- <span class="tfm-card-label">Editor</span>
290
- <span
291
- class="tfm-card-status"
292
- class:dirty={$dirty}
293
- class:applied={editorIsApplied}
294
- >
295
- <i class="tfm-status-dot" aria-hidden="true"></i>
296
- <span>{$dirty ? 'unsaved' : editorIsApplied ? 'live' : 'saved'}</span>
297
- {#if $dirty}
298
- <button
299
- type="button"
300
- class="tfm-revert-btn"
301
- class:armed={revertArmed}
302
- onclick={handleRevert}
303
- title={revertArmed
304
- ? 'Click again to discard unsaved changes'
305
- : 'Revert to last saved version'}
306
- aria-label={revertArmed ? 'Confirm discard unsaved changes' : 'Revert unsaved changes'}
307
- >
308
- <i class="fas fa-rotate-left" aria-hidden="true"></i>
309
- {#if revertArmed}<span class="tfm-revert-label">Discard?</span>{/if}
310
- </button>
311
- {/if}
312
- </span>
313
- </div>
314
- <FilePill
315
- name={currentDisplayName}
316
- isProtected={isDefaultActive}
317
- dirty={$dirty}
318
- applied={editorIsApplied}
319
- protectedTitle="Protected system theme"
320
- title={currentDisplayName}
321
- style="display: flex;"
322
- />
323
- <div class="tfm-card-actions tfm-card-actions-stack">
324
- <button
325
- class="tfm-btn tfm-btn-row save-btn"
326
- class:saving={saveStatus === 'saving'}
327
- class:saved={saveStatus === 'saved'}
328
- class:error={saveStatus === 'error'}
329
- onclick={handleSave}
330
- disabled={saveStatus === 'saving'}
331
- title={isDefaultActive
332
- ? 'Save to a new theme file'
333
- : 'Save to current file'}
334
- >
335
- <i
336
- class="fas"
337
- class:fa-save={saveStatus === 'idle'}
338
- class:fa-spinner={saveStatus === 'saving'}
339
- class:fa-check={saveStatus === 'saved'}
340
- class:fa-times={saveStatus === 'error'}
341
- ></i>
342
- <span>
343
- {#if saveStatus === 'idle'}Save{:else if saveStatus === 'saving'}Saving{:else if saveStatus === 'saved'}Saved{:else}Error{/if}
344
- </span>
345
- </button>
346
- <button class="tfm-btn tfm-btn-row" onclick={openSaveAs} title="Save as new theme">
347
- <i class="fas fa-copy"></i>
348
- <span>Save As…</span>
349
- </button>
350
- <button
351
- class="tfm-btn tfm-btn-row"
352
- class:active={showFileList}
353
- onclick={toggleFileList}
354
- title="Load a theme"
355
- >
356
- <i class="fas fa-folder-open"></i>
357
- <span>Load…</span>
358
- </button>
359
- </div>
360
- </div>
361
-
362
- <button
363
- class="tfm-adopt-btn"
364
- class:saving={prodApplyStatus === 'applying'}
365
- class:saved={prodApplyStatus === 'done'}
366
- class:error={prodApplyStatus === 'error'}
367
- class:in-sync={prodIsInSync}
368
- onclick={handleApplyToProduction}
369
- disabled={prodApplyStatus === 'applying' || prodIsInSync}
370
- title={prodIsInSync
371
- ? 'This theme is already in production'
372
- : `Adopt "${currentDisplayName}" as the production theme`}
373
- >
374
- <i
375
- class="fas"
376
- class:fa-arrow-down={prodApplyStatus === 'idle'}
377
- class:fa-spinner={prodApplyStatus === 'applying'}
378
- class:fa-check={prodApplyStatus === 'done'}
379
- class:fa-xmark={prodApplyStatus === 'error'}
380
- ></i>
381
- <span>
382
- {#if prodApplyStatus === 'idle'}Adopt{:else if prodApplyStatus === 'applying'}Adopting{:else if prodApplyStatus === 'done'}Adopted{:else}Error{/if}
383
- </span>
384
- </button>
385
-
386
- <div
387
- class="tfm-card tfm-card-production"
388
- class:in-sync={prodIsInSync}
389
- >
390
- <span class="tfm-rail" aria-hidden="true"></span>
391
- <div class="tfm-card-head">
392
- <span class="tfm-card-label">Production</span>
393
- <span
394
- class="tfm-card-status"
395
- class:applied={prodIsInSync}
396
- >
397
- <i class="tfm-status-dot" aria-hidden="true"></i>
398
- <span>{prodIsInSync ? 'live' : 'out of sync'}</span>
399
- </span>
400
- </div>
401
- <FilePill
402
- name={prodName}
403
- isProtected={$themeProductionInfo?.fileName === 'default'}
404
- applied={prodIsInSync}
405
- protectedTitle="Protected system theme"
406
- title={prodName}
407
- style="display: flex;"
408
- />
409
- </div>
410
- </div>
411
- </div>
412
-
413
- <FileLoadList
414
- bind:show={showFileList}
415
- title="Load Theme"
416
- {files}
417
- activeFileName={$activeFileName}
418
- sortable
419
- showUpdatedAt
420
- systemBadge={{ label: 'System', title: 'Protected system theme' }}
421
- emptyMessage="No saved files"
422
- canDelete={(file) => file.fileName !== 'default' && file.fileName !== $themeProductionInfo?.fileName}
423
- onload={handleLoad}
424
- ondelete={handleDelete}
425
- />
426
-
427
- <SaveAsDialog
428
- bind:show={saveAsDialog}
429
- {currentDisplayName}
430
- {files}
431
- currentFileName={$activeFileName}
432
- reservedDisplayNames={protectedDisplayNames}
433
- title="Save Theme As"
434
- placeholder="Theme name…"
435
- reservedNameMessage='That name is reserved for the protected default theme.'
436
- branchFromDefaultName="My Theme"
437
- onsave={confirmSaveAs}
438
- />
439
-
440
- <style>
441
- .theme-file-manager {
442
- --tfm-applied: #5aa85e;
443
- --tfm-rail-neutral: var(--ui-border);
444
- --tfm-rail-dirty: var(--ui-highlight);
445
- --tfm-rail-applied: var(--tfm-applied);
446
-
447
- display: flex;
448
- flex-direction: column;
449
- gap: var(--ui-space-8);
450
- }
451
-
452
- .tfm-header {
453
- display: flex;
454
- align-items: center;
455
- justify-content: space-between;
456
- gap: var(--ui-space-4);
457
- padding: 0 var(--ui-space-4);
458
- }
459
-
460
- .tfm-header-label {
461
- font-size: var(--ui-font-size-xs);
462
- color: var(--ui-text-secondary);
463
- }
464
-
465
- /* Two-card pipeline (Editor → Production) — theme card + production card
466
- surface the per-artifact pipeline. The manifest panel sits one level up
467
- and tracks active vs default rather than editor vs production. */
468
- .tfm-cards {
469
- display: flex;
470
- flex-direction: column;
471
- gap: var(--ui-space-6);
472
- }
473
-
474
- .tfm-card {
475
- position: relative;
476
- display: flex;
477
- flex-direction: column;
478
- gap: var(--ui-space-6);
479
- padding: var(--ui-space-8) var(--ui-space-10) var(--ui-space-10) var(--ui-space-16);
480
- background: var(--ui-surface-lower);
481
- border: 1px solid var(--ui-border-low);
482
- border-radius: var(--ui-radius-md);
483
- }
484
-
485
- .tfm-rail {
486
- position: absolute;
487
- left: 0;
488
- top: 0;
489
- bottom: 0;
490
- width: 3px;
491
- border-radius: var(--ui-radius-md) 0 0 var(--ui-radius-md);
492
- background: var(--tfm-rail-neutral);
493
- transition: background var(--ui-transition-base);
494
- }
495
-
496
- .tfm-card-editor.dirty .tfm-rail { background: var(--tfm-rail-dirty); }
497
- .tfm-card-editor.applied .tfm-rail { background: var(--tfm-rail-applied); }
498
- .tfm-card-production.in-sync .tfm-rail { background: var(--tfm-rail-applied); }
499
-
500
- .tfm-card-head {
501
- display: flex;
502
- align-items: baseline;
503
- justify-content: space-between;
504
- gap: var(--ui-space-8);
505
- }
506
-
507
- .tfm-card-label {
508
- font-size: var(--ui-font-size-xs);
509
- font-weight: var(--ui-font-weight-semibold);
510
- color: var(--ui-text-secondary);
511
- line-height: 1.1;
512
- }
513
-
514
- .tfm-card-status {
515
- display: inline-flex;
516
- align-items: center;
517
- gap: var(--ui-space-4);
518
- font-size: 0.7rem;
519
- color: var(--ui-text-muted);
520
- line-height: 1;
521
- }
522
-
523
- .tfm-status-dot {
524
- width: 5px;
525
- height: 5px;
526
- border-radius: 50%;
527
- background: currentColor;
528
- opacity: 0.7;
529
- flex-shrink: 0;
530
- }
531
-
532
- .tfm-card-status.dirty {
533
- color: var(--ui-highlight);
534
- }
535
-
536
- .tfm-card-status.dirty .tfm-status-dot {
537
- opacity: 1;
538
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-highlight) 22%, transparent);
539
- animation: tfm-pulse 1.6s ease-in-out infinite;
540
- }
541
-
542
- .tfm-card-status.applied {
543
- color: var(--tfm-applied);
544
- }
545
- .tfm-card-status.applied .tfm-status-dot {
546
- opacity: 1;
547
- }
548
-
549
- /* Revert button — paired with the "unsaved" status label. Lives only when
550
- dirty, in the highlight color so it reads as part of the status group
551
- rather than a peer of the action stack below. Two-step arm: first click
552
- adds .armed (label appears, fills with highlight), second click commits. */
553
- .tfm-revert-btn {
554
- position: relative;
555
- display: inline-flex;
556
- align-items: center;
557
- justify-content: center;
558
- gap: 4px;
559
- height: 18px;
560
- margin-left: 2px;
561
- padding: 0 4px;
562
- background: transparent;
563
- border: 1px solid color-mix(in srgb, var(--ui-highlight) 35%, transparent);
564
- border-radius: 4px;
565
- color: var(--ui-highlight);
566
- font-size: 0.65rem;
567
- line-height: 1;
568
- cursor: pointer;
569
- opacity: 0.85;
570
- overflow: hidden;
571
- transition:
572
- background var(--ui-transition-fast),
573
- border-color var(--ui-transition-fast),
574
- color var(--ui-transition-fast),
575
- opacity var(--ui-transition-fast),
576
- padding var(--ui-transition-fast);
577
- }
578
-
579
- .tfm-revert-btn:hover {
580
- background: color-mix(in srgb, var(--ui-highlight) 18%, transparent);
581
- border-color: color-mix(in srgb, var(--ui-highlight) 65%, transparent);
582
- opacity: 1;
583
- }
584
-
585
- .tfm-revert-btn:focus-visible {
586
- outline: 2px solid color-mix(in srgb, var(--ui-highlight) 60%, transparent);
587
- outline-offset: 1px;
588
- }
589
-
590
- .tfm-revert-btn i {
591
- font-size: 0.65rem;
592
- transition: transform var(--ui-transition-fast);
593
- }
594
-
595
- .tfm-revert-btn.armed {
596
- background: var(--ui-highlight);
597
- border-color: var(--ui-highlight);
598
- color: var(--ui-surface-lowest, #111);
599
- opacity: 1;
600
- padding: 0 6px;
601
- }
602
-
603
- .tfm-revert-btn.armed i {
604
- transform: rotate(-35deg);
605
- }
606
-
607
- .tfm-revert-label {
608
- font-weight: var(--ui-font-weight-semibold, 600);
609
- white-space: nowrap;
610
- }
611
-
612
- /* Time-window cue — thin bar drains across the bottom edge as the 3s
613
- auto-disarm window elapses. Pure CSS, runs once per arm cycle. */
614
- .tfm-revert-btn.armed::after {
615
- content: '';
616
- position: absolute;
617
- left: 0;
618
- bottom: 0;
619
- height: 2px;
620
- width: 100%;
621
- background: color-mix(in srgb, var(--ui-surface-lowest, #111) 70%, transparent);
622
- transform-origin: left center;
623
- animation: tfm-revert-drain 3s linear forwards;
624
- }
625
-
626
- @keyframes tfm-revert-drain {
627
- from { transform: scaleX(1); }
628
- to { transform: scaleX(0); }
629
- }
630
-
631
- .tfm-card-actions {
632
- display: flex;
633
- gap: var(--ui-space-4);
634
- flex-wrap: wrap;
635
- }
636
-
637
- .tfm-card-actions-stack {
638
- flex-direction: column;
639
- }
640
-
641
- .tfm-btn-row {
642
- width: 100%;
643
- justify-content: flex-start;
644
- gap: var(--ui-space-8);
645
- flex: 0 0 auto;
646
- text-align: left;
647
- }
648
-
649
- .tfm-btn-row i {
650
- width: 1rem;
651
- text-align: center;
652
- flex: 0 0 auto;
653
- }
654
-
655
- .tfm-btn-row span {
656
- flex: 1 1 auto;
657
- text-align: left;
658
- }
659
-
660
- /* Bridge button — sits between Editor and Production cards as the arrow that
661
- promotes the editor theme into production. */
662
- .tfm-adopt-btn {
663
- align-self: stretch;
664
- width: 100%;
665
- display: flex;
666
- align-items: center;
667
- justify-content: center;
668
- gap: var(--ui-space-6);
669
- margin: calc(var(--ui-space-2) * -1) 0;
670
- padding: var(--ui-space-6) var(--ui-space-12);
671
- background: color-mix(in srgb, var(--tfm-applied) 18%, var(--ui-surface-high));
672
- border: 1px solid color-mix(in srgb, var(--tfm-applied) 45%, var(--ui-border-high));
673
- border-radius: var(--ui-radius-md);
674
- color: var(--ui-text-primary);
675
- font-size: var(--ui-font-size-md);
676
- font-weight: var(--ui-font-weight-medium);
677
- cursor: pointer;
678
- transition: all var(--ui-transition-fast);
679
- white-space: nowrap;
680
- position: relative;
681
- z-index: 1;
682
- }
683
-
684
- .tfm-adopt-btn i {
685
- width: 1rem;
686
- text-align: center;
687
- font-size: 0.85em;
688
- }
689
-
690
- .tfm-adopt-btn:hover:not(:disabled) {
691
- background: color-mix(in srgb, var(--tfm-applied) 30%, var(--ui-surface-higher));
692
- border-color: color-mix(in srgb, var(--tfm-applied) 70%, var(--ui-border-higher));
693
- }
694
-
695
- .tfm-adopt-btn:disabled {
696
- cursor: not-allowed;
697
- }
698
-
699
- .tfm-adopt-btn.in-sync {
700
- background: transparent;
701
- border-color: var(--ui-border-low);
702
- color: var(--ui-text-muted);
703
- opacity: 0.7;
704
- }
705
-
706
- .tfm-adopt-btn.saving i { animation: spin 1s linear infinite; }
707
- .tfm-adopt-btn.saved {
708
- background: color-mix(in srgb, var(--tfm-applied) 30%, var(--ui-surface-high));
709
- color: var(--tfm-applied);
710
- }
711
- .tfm-adopt-btn.error { color: var(--ui-text-muted); }
712
-
713
- .tfm-btn {
714
- display: inline-flex;
715
- align-items: center;
716
- justify-content: center;
717
- gap: var(--ui-space-4);
718
- padding: var(--ui-space-6) var(--ui-space-10);
719
- background: var(--ui-surface);
720
- border: 1px solid var(--ui-border-low);
721
- border-radius: var(--ui-radius-md);
722
- color: var(--ui-text-secondary);
723
- font-size: var(--ui-font-size-md);
724
- font-weight: var(--ui-font-weight-medium);
725
- cursor: pointer;
726
- transition: all var(--ui-transition-fast);
727
- white-space: nowrap;
728
- flex: 1 1 0;
729
- min-width: 0;
730
- }
731
-
732
- .tfm-btn i {
733
- width: 1rem;
734
- text-align: center;
735
- font-size: 0.85em;
736
- }
737
-
738
- .tfm-btn:hover:not(:disabled) {
739
- background: var(--ui-surface-high);
740
- color: var(--ui-text-primary);
741
- border-color: var(--ui-border);
742
- }
743
-
744
- .tfm-btn:disabled {
745
- opacity: 0.45;
746
- cursor: not-allowed;
747
- }
748
-
749
- .tfm-btn.active {
750
- background: var(--ui-surface);
751
- border-color: var(--ui-border);
752
- color: var(--ui-text-primary);
753
- }
754
-
755
- .save-btn {
756
- background: var(--ui-surface-high);
757
- border-color: var(--ui-border-high);
758
- color: var(--ui-text-primary);
759
- }
760
-
761
- .save-btn:hover:not(:disabled) {
762
- background: var(--ui-surface-higher);
763
- border-color: var(--ui-border-higher);
764
- }
765
-
766
- .save-btn.saving i { animation: spin 1s linear infinite; }
767
- .save-btn.saved {
768
- background: var(--ui-surface-highest);
769
- color: var(--ui-text-success);
770
- }
771
- .save-btn.error {
772
- background: var(--ui-surface-high);
773
- color: var(--ui-text-muted);
774
- }
775
-
776
- @keyframes tfm-pulse {
777
- 0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-highlight) 22%, transparent); }
778
- 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--ui-highlight) 10%, transparent); }
779
- }
780
-
781
- @keyframes spin {
782
- from { transform: rotate(0deg); }
783
- to { transform: rotate(360deg); }
784
- }
785
- </style>