@motion-proto/live-tokens 0.5.0 → 0.6.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.
@@ -1,30 +1,70 @@
1
1
  <script lang="ts">
2
2
  import { stopPropagation } from 'svelte/legacy';
3
3
 
4
- import { onMount } from 'svelte';
5
- import type { PresetMeta } from '../lib/themeTypes';
4
+ import { onMount, onDestroy } from 'svelte';
5
+ import type { Preset, PresetMeta } from '../lib/themeTypes';
6
6
  import {
7
7
  listPresets,
8
8
  deletePreset,
9
9
  captureCurrentAsPreset,
10
+ captureProductionAsPreset,
10
11
  applyPreset,
12
+ applyPresetToProduction,
13
+ compareActiveToProduction,
11
14
  getActivePreset,
12
15
  } from '../lib/presetService';
13
16
  import { sanitizeFileName } from '../lib/themeService';
14
- import { dirty } from '../lib/editorStore';
17
+ import { dirty, componentDirty } from '../lib/editorStore';
18
+ import { productionRevision, presetProductionComparison } from '../lib/productionPulse';
15
19
  import UIDialog from './UIDialog.svelte';
20
+ import UnsavedComponentsDialog from './UnsavedComponentsDialog.svelte';
21
+ import SaveAsDialog from '../component-editor/scaffolding/SaveAsDialog.svelte';
16
22
 
17
23
  let files: PresetMeta[] = $state([]);
18
24
  let showFileList = $state(false);
19
- let saveAsEditing = $state(false);
20
- let saveAsName = $state('');
21
- let saveAsInput: HTMLInputElement | undefined = $state();
25
+ let saveAsDialog = $state(false);
26
+ let captureProdDialog = $state(false);
22
27
 
23
28
  let activeFileName = $state('default');
24
- let currentDisplayName = $state('Default');
29
+ let currentDisplayName = $state('Default Preset');
30
+ let activePreset = $state<Preset | null>(null);
25
31
 
26
32
  let saveStatus: 'idle' | 'saving' | 'saved' | 'error' = $state('idle');
27
33
  let applyStatus: 'idle' | 'applying' = $state('idle');
34
+ let prodApplyStatus: 'idle' | 'applying' | 'done' | 'error' = $state('idle');
35
+ let prodCaptureStatus: 'idle' | 'saving' | 'saved' | 'error' = $state('idle');
36
+
37
+ let unsavedDialog = $state(false);
38
+ /** Pending capture target while UnsavedComponentsDialog is open. The dialog
39
+ * resolves to either "save the preset using current on-disk files" (proceed)
40
+ * or "user cancelled / closed". */
41
+ let pendingCapture: { fileName: string; displayName: string } | null = null;
42
+
43
+ let infoOpen = $state(false);
44
+ let infoBtnEl = $state<HTMLButtonElement | undefined>(undefined);
45
+ let infoPopoverEl = $state<HTMLDivElement | undefined>(undefined);
46
+ let infoPopoverReady = $state(false);
47
+
48
+ let dirtyComponentIds = $derived(
49
+ Object.entries($componentDirty)
50
+ .filter(([, isDirty]) => isDirty)
51
+ .map(([id]) => id),
52
+ );
53
+
54
+ /** True when there are unsaved theme/component edits that won't make it into
55
+ * the next preset Save (capture reads on-disk files, not the editor). */
56
+ let presetStale = $derived(dirtyComponentIds.length > 0 || $dirty);
57
+
58
+ let isDefaultActive = $derived(activeFileName === 'default');
59
+
60
+ let prodStatus = $derived($presetProductionComparison?.status ?? 'editor-only');
61
+ let prodPresetName = $derived($presetProductionComparison?.productionPreset?.name ?? '—');
62
+ let prodIsInSync = $derived(prodStatus === 'in-production');
63
+ let prodIsDiverged = $derived(prodStatus === 'diverged');
64
+ /** Editor row is "live" when this preset IS the one in production AND
65
+ * nothing's pending — mirrors the cfm-row-editor.applied rule so the green
66
+ * dot vocabulary is consistent across components and presets. */
67
+ let editorIsApplied = $derived(prodIsInSync && !presetStale);
28
68
 
29
69
  async function refreshFiles() {
30
70
  try {
@@ -43,6 +83,7 @@
43
83
  try {
44
84
  const active = await getActivePreset();
45
85
  if (active) {
86
+ activePreset = active;
46
87
  activeFileName = active._fileName ?? activeFileName;
47
88
  currentDisplayName = active.name;
48
89
  }
@@ -51,18 +92,117 @@
51
92
  }
52
93
  }
53
94
 
95
+ async function refreshProductionComparison() {
96
+ if (!activePreset) return;
97
+ try {
98
+ $presetProductionComparison = await compareActiveToProduction(activePreset);
99
+ } catch {
100
+ // silent — leave previous comparison in place
101
+ }
102
+ }
103
+
54
104
  onMount(async () => {
55
105
  await refreshActive();
56
106
  await refreshFiles();
107
+ await refreshProductionComparison();
108
+ window.addEventListener('keydown', handleKeydown);
109
+ document.addEventListener('mousedown', handleDocumentMousedown, true);
110
+ });
111
+
112
+ // Re-compare when any production pointer ticks (per-component Adopt,
113
+ // theme production change, or our own apply). Skip the initial tick on
114
+ // mount — refreshProductionComparison runs there already. Plain closure
115
+ // variable (not $state) so writing it doesn't add a reactive dependency
116
+ // back to the effect.
117
+ let pulseInitialised = false;
118
+ $effect(() => {
119
+ void $productionRevision;
120
+ if (!pulseInitialised) {
121
+ pulseInitialised = true;
122
+ return;
123
+ }
124
+ refreshProductionComparison();
125
+ });
126
+
127
+ onDestroy(() => {
128
+ window.removeEventListener('keydown', handleKeydown);
129
+ document.removeEventListener('mousedown', handleDocumentMousedown, true);
130
+ });
131
+
132
+ function handleKeydown(e: KeyboardEvent) {
133
+ if (e.key === 'Escape' && infoOpen) {
134
+ infoOpen = false;
135
+ }
136
+ }
137
+
138
+ function handleDocumentMousedown(e: MouseEvent) {
139
+ if (!infoOpen) return;
140
+ const target = e.target as Element | null;
141
+ if (target && !target.closest('.pfm-info-btn, .pfm-info-popover')) {
142
+ infoOpen = false;
143
+ }
144
+ }
145
+
146
+ /** Anchor the fixed-position popover relative to the info button. Lives in
147
+ * the sidebar footer, so flip-up when there's no room below. */
148
+ function positionInfoPopover(): void {
149
+ const btn = infoBtnEl;
150
+ const pop = infoPopoverEl;
151
+ if (!btn || !pop) return;
152
+ const br = btn.getBoundingClientRect();
153
+ const pr = pop.getBoundingClientRect();
154
+ const margin = 8;
155
+ const vw = window.innerWidth;
156
+ const vh = window.innerHeight;
157
+ let left = br.right + margin;
158
+ if (left + pr.width > vw - margin) {
159
+ left = br.left + br.width / 2 - pr.width / 2;
160
+ if (left < margin) left = margin;
161
+ if (left + pr.width > vw - margin) left = vw - margin - pr.width;
162
+ }
163
+ let top = br.bottom + margin;
164
+ if (top + pr.height > vh - margin) {
165
+ top = br.top - margin - pr.height;
166
+ if (top < margin) top = margin;
167
+ }
168
+ pop.style.left = `${left}px`;
169
+ pop.style.top = `${top}px`;
170
+ infoPopoverReady = true;
171
+ }
172
+
173
+ $effect(() => {
174
+ if (!infoOpen) {
175
+ infoPopoverReady = false;
176
+ return;
177
+ }
178
+ let raf1 = requestAnimationFrame(() => {
179
+ raf1 = requestAnimationFrame(positionInfoPopover);
180
+ });
181
+ window.addEventListener('scroll', positionInfoPopover, true);
182
+ window.addEventListener('resize', positionInfoPopover);
183
+ return () => {
184
+ cancelAnimationFrame(raf1);
185
+ window.removeEventListener('scroll', positionInfoPopover, true);
186
+ window.removeEventListener('resize', positionInfoPopover);
187
+ };
57
188
  });
58
189
 
59
- /** Standard "save dirty editor first?" gate. Returns true if the caller
60
- * should proceed with capturing from disk; false to abort. */
61
- function confirmDirtyCapture(): boolean {
62
- if (!$dirty) return true;
63
- return window.confirm(
64
- 'You have unsaved changes in the editor. They will not be included in the preset (presets are built from the named files on disk). Continue saving the preset anyway?',
65
- );
190
+ /** Standard "save dirty editor first?" gate. If any component has unsaved
191
+ * edits, defer to UnsavedComponentsDialog (which can save them in place);
192
+ * callers receive `false` and the dialog's "proceed" path re-invokes the
193
+ * capture with the pending args. Returns `true` if the caller can capture
194
+ * immediately (no dirty components). */
195
+ function gateDirtyCapture(fileName: string, displayName: string): boolean {
196
+ if (dirtyComponentIds.length === 0 && !$dirty) return true;
197
+ pendingCapture = { fileName, displayName };
198
+ unsavedDialog = true;
199
+ return false;
200
+ }
201
+
202
+ function handleUnsavedProceed() {
203
+ const target = pendingCapture;
204
+ pendingCapture = null;
205
+ if (target) doCapture(target.fileName, target.displayName);
66
206
  }
67
207
 
68
208
  async function doCapture(fileName: string, displayName: string) {
@@ -74,64 +214,66 @@
74
214
  saveStatus = 'saved';
75
215
  setTimeout(() => { saveStatus = 'idle'; }, 2000);
76
216
  await refreshFiles();
217
+ await refreshActive();
218
+ await refreshProductionComparison();
77
219
  } catch {
78
220
  saveStatus = 'error';
79
221
  setTimeout(() => { saveStatus = 'idle'; }, 3000);
80
222
  }
81
223
  }
82
224
 
83
- async function handleSave() {
84
- if (!confirmDirtyCapture()) return;
85
- await doCapture(activeFileName, currentDisplayName);
225
+ async function handleApplyToProduction() {
226
+ if (!activeFileName) return;
227
+ prodApplyStatus = 'applying';
228
+ try {
229
+ await applyPresetToProduction(activeFileName);
230
+ // applyPresetToProduction bakes css; refresh comparison so the
231
+ // Production card flips to the live state.
232
+ await refreshProductionComparison();
233
+ prodApplyStatus = 'done';
234
+ setTimeout(() => { prodApplyStatus = 'idle'; }, 2000);
235
+ } catch {
236
+ prodApplyStatus = 'error';
237
+ setTimeout(() => { prodApplyStatus = 'idle'; }, 3000);
238
+ }
86
239
  }
87
240
 
88
- async function handleSaveIncrement() {
89
- if (!confirmDirtyCapture()) return;
90
- const baseName = currentDisplayName.replace(/_\d+$/, '');
91
- const baseFileName = sanitizeFileName(baseName);
92
- const existingNums = files
93
- .filter(
94
- (f) => f.fileName === baseFileName || f.fileName.match(new RegExp(`^${baseFileName}_\\d+$`)),
95
- )
96
- .map((f) => {
97
- const m = f.fileName.match(/_(\d+)$/);
98
- return m ? parseInt(m[1], 10) : 0;
99
- });
100
- const next = (existingNums.length > 0 ? Math.max(...existingNums) : 0) + 1;
101
- const suffix = String(next).padStart(2, '0');
102
- const displayName = `${baseName}_${suffix}`;
103
- const fileName = `${baseFileName}_${suffix}`;
104
- await doCapture(fileName, displayName);
241
+ function openCaptureFromProduction() {
242
+ captureProdDialog = true;
105
243
  }
106
244
 
107
- function openSaveAs() {
108
- saveAsName = currentDisplayName;
109
- saveAsEditing = true;
110
- showFileList = false;
111
- setTimeout(() => saveAsInput?.select(), 0);
245
+ async function confirmCaptureFromProduction(detail: { displayName: string; fileName: string }) {
246
+ const { displayName, fileName } = detail;
247
+ prodCaptureStatus = 'saving';
248
+ try {
249
+ await captureProductionAsPreset(fileName, displayName);
250
+ await refreshFiles();
251
+ prodCaptureStatus = 'saved';
252
+ setTimeout(() => { prodCaptureStatus = 'idle'; }, 2000);
253
+ } catch {
254
+ prodCaptureStatus = 'error';
255
+ setTimeout(() => { prodCaptureStatus = 'idle'; }, 3000);
256
+ }
112
257
  }
113
258
 
114
- async function confirmSaveAs() {
115
- const displayName = saveAsName.trim();
116
- if (!displayName) return;
117
- const fileName = sanitizeFileName(displayName);
118
- if (fileName === 'default') {
119
- saveAsName = '';
120
- return;
121
- }
122
- if (!confirmDirtyCapture()) return;
123
- saveAsEditing = false;
124
- await doCapture(fileName, displayName);
259
+ async function handleSave() {
260
+ // Default is the protected initial-distribution preset — disabled in the
261
+ // UI when active, but guard here too. Callers in that state should route
262
+ // through Save As (which seeds an incremented name).
263
+ if (isDefaultActive) return;
264
+ if (!gateDirtyCapture(activeFileName, currentDisplayName)) return;
265
+ await doCapture(activeFileName, currentDisplayName);
125
266
  }
126
267
 
127
- function cancelSaveAs() {
128
- saveAsEditing = false;
129
- saveAsName = '';
268
+ function openSaveAs() {
269
+ showFileList = false;
270
+ saveAsDialog = true;
130
271
  }
131
272
 
132
- function handleSaveAsKeydown(e: KeyboardEvent) {
133
- if (e.key === 'Enter') confirmSaveAs();
134
- if (e.key === 'Escape') cancelSaveAs();
273
+ async function confirmSaveAs(detail: { displayName: string; fileName: string }) {
274
+ const { displayName, fileName } = detail;
275
+ if (!gateDirtyCapture(fileName, displayName)) return;
276
+ await doCapture(fileName, displayName);
135
277
  }
136
278
 
137
279
  async function handleApply(file: PresetMeta) {
@@ -161,7 +303,7 @@
161
303
  await refreshFiles();
162
304
  if (file.fileName === activeFileName) {
163
305
  activeFileName = 'default';
164
- currentDisplayName = 'Default';
306
+ currentDisplayName = 'Default Preset';
165
307
  }
166
308
  } catch {
167
309
  // silent
@@ -170,90 +312,168 @@
170
312
 
171
313
  function toggleFileList() {
172
314
  showFileList = !showFileList;
173
- saveAsEditing = false;
174
315
  if (showFileList) refreshFiles();
175
316
  }
176
317
  </script>
177
318
 
178
319
  <div class="preset-file-manager">
179
- <div class="active-file">
180
- <span class="active-label">Preset</span>
181
- <span class="active-name">{currentDisplayName}</span>
320
+ <div class="pfm-header">
321
+ <span class="pfm-header-label">Preset</span>
322
+ <button
323
+ type="button"
324
+ class="pfm-info-btn"
325
+ aria-label="About presets"
326
+ aria-expanded={infoOpen}
327
+ bind:this={infoBtnEl}
328
+ onclick={() => (infoOpen = !infoOpen)}
329
+ >
330
+ <i class="fas fa-circle-info"></i>
331
+ </button>
182
332
  </div>
183
333
 
184
- <div class="button-grid">
185
- <div class="save-row">
186
- <button
187
- class="pfm-btn save-btn"
188
- class:saving={saveStatus === 'saving'}
189
- class:saved={saveStatus === 'saved'}
190
- class:error={saveStatus === 'error'}
191
- onclick={handleSave}
192
- disabled={saveStatus === 'saving' || applyStatus === 'applying'}
193
- title="Capture the current theme + component configs into this preset"
194
- >
195
- <i
196
- class="fas"
197
- class:fa-save={saveStatus === 'idle'}
198
- class:fa-spinner={saveStatus === 'saving'}
199
- class:fa-check={saveStatus === 'saved'}
200
- class:fa-times={saveStatus === 'error'}
201
- ></i>
202
- <span>
203
- {#if saveStatus === 'idle'}Save{:else if saveStatus === 'saving'}Saving{:else if saveStatus === 'saved'}Saved{:else}Error{/if}
334
+ <div class="pfm-cards" class:in-sync={prodIsInSync}>
335
+ <div
336
+ class="pfm-card pfm-card-editor"
337
+ class:dirty={presetStale}
338
+ class:applied={editorIsApplied}
339
+ >
340
+ <span class="pfm-rail" aria-hidden="true"></span>
341
+ <div class="pfm-card-head">
342
+ <span class="pfm-card-label">Editor</span>
343
+ <span
344
+ class="pfm-card-status"
345
+ class:dirty={presetStale}
346
+ class:applied={editorIsApplied}
347
+ >
348
+ <i class="pfm-status-dot" aria-hidden="true"></i>
349
+ <span>{presetStale ? 'unsaved' : editorIsApplied ? 'live' : 'saved'}</span>
204
350
  </span>
205
- </button>
206
- <button
207
- class="pfm-btn increment-btn"
208
- onclick={handleSaveIncrement}
209
- disabled={saveStatus === 'saving' || applyStatus === 'applying'}
210
- title="Save as incremented preset"
211
- >
212
- <i class="fas fa-plus"></i>
213
- </button>
351
+ </div>
352
+ <div class="pfm-pill" class:dirty={presetStale} class:applied={editorIsApplied}>
353
+ <span class="pfm-pill-name" title={currentDisplayName}>{currentDisplayName}</span>
354
+ </div>
355
+ {#if presetStale}
356
+ <span class="pfm-stale-note" aria-live="polite">unsaved edits won't be captured</span>
357
+ {/if}
358
+ <div class="pfm-card-actions pfm-card-actions-stack">
359
+ <button
360
+ class="pfm-btn pfm-btn-row save-btn"
361
+ class:saving={saveStatus === 'saving'}
362
+ class:saved={saveStatus === 'saved'}
363
+ class:error={saveStatus === 'error'}
364
+ onclick={handleSave}
365
+ disabled={saveStatus === 'saving' || applyStatus === 'applying' || isDefaultActive}
366
+ title={isDefaultActive
367
+ ? 'Default is read-only — use Save As to capture under a new name'
368
+ : 'Capture the current theme + component configs into this preset'}
369
+ >
370
+ <i
371
+ class="fas"
372
+ class:fa-save={saveStatus === 'idle'}
373
+ class:fa-spinner={saveStatus === 'saving'}
374
+ class:fa-check={saveStatus === 'saved'}
375
+ class:fa-times={saveStatus === 'error'}
376
+ ></i>
377
+ <span>
378
+ {#if saveStatus === 'idle'}Save{:else if saveStatus === 'saving'}Saving{:else if saveStatus === 'saved'}Saved{:else}Error{/if}
379
+ </span>
380
+ </button>
381
+ <button class="pfm-btn pfm-btn-row" onclick={openSaveAs} title="Save as new preset">
382
+ <i class="fas fa-copy"></i>
383
+ <span>Save As…</span>
384
+ </button>
385
+ <button
386
+ class="pfm-btn pfm-btn-row"
387
+ class:active={showFileList}
388
+ onclick={toggleFileList}
389
+ disabled={applyStatus === 'applying'}
390
+ title="Load a preset"
391
+ >
392
+ <i class="fas fa-folder-open"></i>
393
+ <span>Load…</span>
394
+ </button>
395
+ </div>
214
396
  </div>
215
397
 
216
- {#if saveAsEditing}
217
- <div class="save-as-inline">
218
- <input
219
- class="save-as-input"
220
- type="text"
221
- bind:value={saveAsName}
222
- bind:this={saveAsInput}
223
- onkeydown={handleSaveAsKeydown}
224
- placeholder="Preset name..."
225
- />
226
- <div class="save-as-actions">
398
+ <button
399
+ class="pfm-adopt-btn"
400
+ class:saving={prodApplyStatus === 'applying'}
401
+ class:saved={prodApplyStatus === 'done'}
402
+ class:error={prodApplyStatus === 'error'}
403
+ class:in-sync={prodIsInSync}
404
+ onclick={handleApplyToProduction}
405
+ disabled={prodApplyStatus === 'applying' || prodIsInSync}
406
+ title={prodIsInSync
407
+ ? 'This preset is already in production'
408
+ : prodIsDiverged
409
+ ? `Re-adopt "${currentDisplayName}" — discards individual component adopts`
410
+ : `Adopt "${currentDisplayName}" as the production preset`}
411
+ >
412
+ <i
413
+ class="fas"
414
+ class:fa-arrow-down={prodApplyStatus === 'idle'}
415
+ class:fa-spinner={prodApplyStatus === 'applying'}
416
+ class:fa-check={prodApplyStatus === 'done'}
417
+ class:fa-xmark={prodApplyStatus === 'error'}
418
+ ></i>
419
+ <span>
420
+ {#if prodApplyStatus === 'idle'}Adopt{:else if prodApplyStatus === 'applying'}Adopting{:else if prodApplyStatus === 'done'}Adopted{:else}Error{/if}
421
+ </span>
422
+ </button>
423
+
424
+ <div
425
+ class="pfm-card pfm-card-production"
426
+ class:in-sync={prodIsInSync}
427
+ class:diverged={prodIsDiverged}
428
+ >
429
+ <span class="pfm-rail" aria-hidden="true"></span>
430
+ <div class="pfm-card-head">
431
+ <span class="pfm-card-label">Production</span>
432
+ <span
433
+ class="pfm-card-status"
434
+ class:applied={prodIsInSync}
435
+ class:diverged={prodIsDiverged}
436
+ >
437
+ <i class="pfm-status-dot" aria-hidden="true"></i>
438
+ <span>
439
+ {#if prodIsInSync}live{:else if prodIsDiverged}diverged{:else}out of sync{/if}
440
+ </span>
441
+ </span>
442
+ </div>
443
+ <div class="pfm-pill" class:applied={prodIsInSync} class:diverged={prodIsDiverged}>
444
+ <span class="pfm-pill-name" title={prodPresetName}>{prodPresetName}</span>
445
+ </div>
446
+ {#if prodIsDiverged && $presetProductionComparison}
447
+ <span class="pfm-diverged-note" aria-live="polite">
448
+ {$presetProductionComparison.driftedComponents.length > 0
449
+ ? `${$presetProductionComparison.driftedComponents.length} component${
450
+ $presetProductionComparison.driftedComponents.length === 1 ? '' : 's'
451
+ } adopted individually`
452
+ : 'theme production differs from this preset'}
453
+ </span>
454
+ {/if}
455
+ {#if prodIsDiverged}
456
+ <div class="pfm-card-actions">
227
457
  <button
228
- class="inline-btn confirm-btn"
229
- onclick={confirmSaveAs}
230
- disabled={!saveAsName.trim()}
231
- title="Save"
458
+ class="pfm-btn pfm-btn-wide"
459
+ onclick={openCaptureFromProduction}
460
+ disabled={prodCaptureStatus === 'saving'}
461
+ title="Save the current production state as a new named preset"
232
462
  >
233
- <i class="fas fa-check"></i>
234
- </button>
235
- <button class="inline-btn cancel-btn" onclick={cancelSaveAs} title="Cancel">
236
- <i class="fas fa-times"></i>
463
+ <i
464
+ class="fas"
465
+ class:fa-bookmark={prodCaptureStatus === 'idle'}
466
+ class:fa-spinner={prodCaptureStatus === 'saving'}
467
+ class:fa-check={prodCaptureStatus === 'saved'}
468
+ class:fa-times={prodCaptureStatus === 'error'}
469
+ ></i>
470
+ <span>
471
+ {#if prodCaptureStatus === 'idle'}Capture as preset{:else if prodCaptureStatus === 'saving'}Saving{:else if prodCaptureStatus === 'saved'}Saved{:else}Error{/if}
472
+ </span>
237
473
  </button>
238
474
  </div>
239
- </div>
240
- {:else}
241
- <button class="pfm-btn" onclick={openSaveAs} title="Save as new preset">
242
- <i class="fas fa-copy"></i>
243
- <span>Save As</span>
244
- </button>
245
- {/if}
246
-
247
- <button
248
- class="pfm-btn"
249
- class:active={showFileList}
250
- onclick={toggleFileList}
251
- disabled={applyStatus === 'applying'}
252
- title="Load a preset"
253
- >
254
- <i class="fas fa-folder-open"></i>
255
- <span>Load</span>
256
- </button>
475
+ {/if}
476
+ </div>
257
477
  </div>
258
478
 
259
479
  {#if applyStatus === 'applying'}
@@ -261,6 +481,49 @@
261
481
  {/if}
262
482
  </div>
263
483
 
484
+ {#if infoOpen}
485
+ <div
486
+ class="pfm-info-popover"
487
+ class:ready={infoPopoverReady}
488
+ role="dialog"
489
+ aria-label="About presets"
490
+ bind:this={infoPopoverEl}
491
+ >
492
+ <header class="pfm-info-header">
493
+ <span class="pfm-info-title">Presets</span>
494
+ <button
495
+ type="button"
496
+ class="pfm-info-close"
497
+ aria-label="Close"
498
+ onclick={() => (infoOpen = false)}
499
+ >
500
+ <i class="fas fa-xmark"></i>
501
+ </button>
502
+ </header>
503
+ <div class="pfm-info-body">
504
+ <p>
505
+ A <strong>preset</strong> is a manifest naming one theme file and one
506
+ config file per component.
507
+ </p>
508
+ <p>
509
+ The <strong>Editor</strong> row is the preset you're working under.
510
+ <strong>Save</strong> captures the currently active files into its
511
+ manifest.
512
+ </p>
513
+ <p>
514
+ The <strong>Production</strong> row is the preset currently baked into
515
+ <code>tokens.css</code>. <strong>Adopt</strong> sets every component's
516
+ production file from the editor preset.
517
+ </p>
518
+ <p>
519
+ Adopting a single component elsewhere can leave production
520
+ <em>diverged</em> from any preset. <strong>Capture</strong> saves that
521
+ state under a new name.
522
+ </p>
523
+ </div>
524
+ </div>
525
+ {/if}
526
+
264
527
  <UIDialog bind:show={showFileList} title="Load Preset" cancelLabel="Close" width="420px">
265
528
  <div class="load-list">
266
529
  {#each files as file}
@@ -288,185 +551,369 @@
288
551
  </div>
289
552
  </UIDialog>
290
553
 
554
+ <SaveAsDialog
555
+ bind:show={saveAsDialog}
556
+ {currentDisplayName}
557
+ {files}
558
+ title="Save Preset As"
559
+ placeholder="Preset name…"
560
+ reservedNameMessage='The name "default" is reserved for the initial distribution.'
561
+ onsave={confirmSaveAs}
562
+ />
563
+
564
+ <SaveAsDialog
565
+ bind:show={captureProdDialog}
566
+ currentDisplayName={prodPresetName}
567
+ {files}
568
+ title="Capture Production as Preset"
569
+ placeholder="Preset name…"
570
+ reservedNameMessage='The name "default" is reserved for the initial distribution.'
571
+ onsave={confirmCaptureFromProduction}
572
+ />
573
+
574
+ <UnsavedComponentsDialog
575
+ bind:show={unsavedDialog}
576
+ dirtyComponents={dirtyComponentIds}
577
+ onproceed={handleUnsavedProceed}
578
+ />
579
+
291
580
  <style>
292
581
  .preset-file-manager {
582
+ --pfm-applied: #5aa85e;
583
+ --pfm-rail-neutral: var(--ui-border-default);
584
+ --pfm-rail-dirty: var(--ui-highlight);
585
+ --pfm-rail-applied: var(--pfm-applied);
586
+
293
587
  display: flex;
294
588
  flex-direction: column;
295
589
  gap: var(--ui-space-8);
296
590
  }
297
591
 
298
- .active-file {
592
+ .pfm-header {
299
593
  display: flex;
300
- flex-direction: column;
301
- gap: var(--ui-space-2);
594
+ align-items: center;
595
+ justify-content: space-between;
596
+ gap: var(--ui-space-4);
302
597
  padding: 0 var(--ui-space-4);
303
598
  }
304
599
 
305
- .active-label {
600
+ .pfm-header-label {
306
601
  font-size: var(--ui-font-size-xs);
307
602
  color: var(--ui-text-secondary);
308
603
  text-transform: uppercase;
309
604
  letter-spacing: 0.05em;
310
605
  }
311
606
 
312
- .active-name {
313
- font-size: var(--ui-font-size-md);
314
- font-weight: var(--ui-font-weight-semibold);
607
+ /* Naked icon button — same affordance language as cfm-info-btn. */
608
+ .pfm-info-btn {
609
+ display: inline-flex;
610
+ align-items: center;
611
+ justify-content: center;
612
+ width: 22px;
613
+ height: 22px;
614
+ padding: 0;
615
+ background: transparent;
616
+ border: 0;
617
+ color: var(--ui-text-tertiary);
618
+ font-size: 0.95rem;
619
+ line-height: 1;
620
+ cursor: pointer;
621
+ transition: color var(--ui-transition-fast);
622
+ }
623
+
624
+ .pfm-info-btn:hover,
625
+ .pfm-info-btn[aria-expanded='true'] {
315
626
  color: var(--ui-text-primary);
316
627
  }
317
628
 
318
- .button-grid {
629
+ /* ── two-card pipeline (Editor → Production) ─────────────────────────────
630
+ Mirrors the cfm-rows pattern in ComponentFileManager so the preset reads
631
+ as the same kind of artifact as themes and components, just one level up. */
632
+ .pfm-cards {
319
633
  display: flex;
320
634
  flex-direction: column;
321
- gap: var(--ui-space-4);
635
+ gap: var(--ui-space-6);
322
636
  }
323
637
 
324
- .pfm-btn {
638
+ .pfm-card {
639
+ position: relative;
325
640
  display: flex;
326
- align-items: center;
327
- gap: var(--ui-space-4);
328
- width: 100%;
329
- padding: var(--ui-space-6) var(--ui-space-8);
330
- background: var(--ui-surface-low);
641
+ flex-direction: column;
642
+ gap: var(--ui-space-6);
643
+ padding: var(--ui-space-8) var(--ui-space-10) var(--ui-space-10) var(--ui-space-16);
644
+ background: var(--ui-surface-lower);
331
645
  border: 1px solid var(--ui-border-subtle);
332
646
  border-radius: var(--ui-radius-md);
333
- color: var(--ui-text-secondary);
334
- font-size: var(--ui-font-size-md);
335
- cursor: pointer;
336
- transition: all var(--ui-transition-fast);
337
- white-space: nowrap;
338
647
  }
339
648
 
340
- .pfm-btn i {
341
- width: 1rem;
342
- text-align: center;
649
+ .pfm-rail {
650
+ position: absolute;
651
+ left: 0;
652
+ top: 0;
653
+ bottom: 0;
654
+ width: 3px;
655
+ border-radius: var(--ui-radius-md) 0 0 var(--ui-radius-md);
656
+ background: var(--pfm-rail-neutral);
657
+ transition: background var(--ui-transition-base);
343
658
  }
344
659
 
345
- .pfm-btn:hover:not(:disabled) {
346
- background: var(--ui-surface);
347
- color: var(--ui-text-primary);
348
- border-color: var(--ui-border-default);
349
- }
660
+ .pfm-card-editor.dirty .pfm-rail { background: var(--pfm-rail-dirty); }
661
+ .pfm-card-editor.applied .pfm-rail { background: var(--pfm-rail-applied); }
662
+ .pfm-card-production.in-sync .pfm-rail { background: var(--pfm-rail-applied); }
663
+ .pfm-card-production.diverged .pfm-rail { background: var(--ui-highlight); }
350
664
 
351
- .pfm-btn:disabled {
352
- opacity: 0.5;
353
- cursor: not-allowed;
665
+ .pfm-card-head {
666
+ display: flex;
667
+ align-items: baseline;
668
+ justify-content: space-between;
669
+ gap: var(--ui-space-8);
354
670
  }
355
671
 
356
- .pfm-btn.active {
357
- background: var(--ui-surface);
358
- border-color: var(--ui-border-default);
359
- color: var(--ui-text-primary);
672
+ .pfm-card-label {
673
+ font-size: var(--ui-font-size-xs);
674
+ font-weight: var(--ui-font-weight-semibold);
675
+ text-transform: uppercase;
676
+ letter-spacing: 0.08em;
677
+ color: var(--ui-text-secondary);
678
+ line-height: 1.1;
360
679
  }
361
680
 
362
- .save-row {
363
- display: flex;
681
+ .pfm-card-status {
682
+ display: inline-flex;
683
+ align-items: center;
364
684
  gap: var(--ui-space-4);
685
+ font-size: 0.7rem;
686
+ letter-spacing: 0.02em;
687
+ color: var(--ui-text-muted);
688
+ line-height: 1;
365
689
  }
366
690
 
367
- .save-row .save-btn {
368
- flex: 1;
369
- min-width: 0;
691
+ .pfm-status-dot {
692
+ width: 5px;
693
+ height: 5px;
694
+ border-radius: 50%;
695
+ background: currentColor;
696
+ opacity: 0.7;
697
+ flex-shrink: 0;
370
698
  }
371
699
 
372
- .increment-btn {
373
- flex: 0 0 auto;
374
- width: 34px;
375
- padding: 0;
700
+ .pfm-card-status.dirty,
701
+ .pfm-card-status.diverged {
702
+ color: var(--ui-highlight);
703
+ }
704
+
705
+ .pfm-card-status.dirty .pfm-status-dot,
706
+ .pfm-card-status.diverged .pfm-status-dot {
707
+ opacity: 1;
708
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-highlight) 22%, transparent);
709
+ animation: pfm-pulse 1.6s ease-in-out infinite;
710
+ }
711
+
712
+ .pfm-card-status.applied {
713
+ color: var(--pfm-applied);
714
+ }
715
+ .pfm-card-status.applied .pfm-status-dot {
716
+ opacity: 1;
717
+ }
718
+
719
+ /* filename pill — matches the cfm-pill vocabulary so the editor side and
720
+ the preset side use one visual idiom for "this is a named file". */
721
+ .pfm-pill {
376
722
  display: flex;
377
723
  align-items: center;
378
- justify-content: center;
724
+ padding: var(--ui-space-6) var(--ui-space-10);
725
+ background: var(--ui-surface-lowest);
726
+ border: 1px solid var(--ui-border-subtle);
727
+ border-radius: var(--ui-radius-md);
728
+ transition: border-color var(--ui-transition-fast), box-shadow var(--ui-transition-fast);
379
729
  }
380
730
 
381
- .save-btn {
382
- background: var(--ui-surface-high);
383
- border-color: var(--ui-border-medium);
384
- color: var(--ui-text-primary);
731
+ .pfm-pill.dirty {
732
+ border-color: color-mix(in srgb, var(--ui-highlight) 60%, var(--ui-border-subtle));
733
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ui-highlight) 35%, transparent);
385
734
  }
386
735
 
387
- .save-btn:hover:not(:disabled) {
388
- background: var(--ui-surface-higher);
389
- border-color: var(--ui-border-strong);
736
+ .pfm-pill.applied {
737
+ border-color: color-mix(in srgb, var(--pfm-applied) 50%, var(--ui-border-subtle));
390
738
  }
391
739
 
392
- .save-btn.saving i {
393
- animation: spin 1s linear infinite;
740
+ .pfm-pill.diverged {
741
+ border-color: color-mix(in srgb, var(--ui-highlight) 50%, var(--ui-border-subtle));
394
742
  }
395
- .save-btn.saved {
396
- background: var(--ui-surface-highest);
397
- color: var(--ui-text-success);
743
+
744
+ .pfm-pill-name {
745
+ flex: 1;
746
+ min-width: 0;
747
+ font-size: var(--ui-font-size-md);
748
+ font-weight: var(--ui-font-weight-semibold);
749
+ color: var(--ui-text-primary);
750
+ white-space: nowrap;
751
+ overflow: hidden;
752
+ text-overflow: ellipsis;
398
753
  }
399
- .save-btn.error {
400
- background: var(--ui-surface-high);
401
- color: var(--ui-text-muted);
754
+
755
+ .pfm-stale-note,
756
+ .pfm-diverged-note {
757
+ font-size: 0.7rem;
758
+ letter-spacing: 0.02em;
759
+ color: var(--ui-highlight);
760
+ line-height: 1.2;
402
761
  }
403
762
 
404
- .save-as-inline {
763
+ .pfm-card-actions {
405
764
  display: flex;
406
- flex-direction: column;
407
765
  gap: var(--ui-space-4);
766
+ flex-wrap: wrap;
408
767
  }
409
768
 
410
- .save-as-actions {
411
- display: flex;
412
- gap: var(--ui-space-4);
413
- justify-content: flex-end;
769
+ /* Stack variant — Save / Save As / Load as left-aligned rows. */
770
+ .pfm-card-actions-stack {
771
+ flex-direction: column;
414
772
  }
415
773
 
416
- .save-as-input {
417
- flex: 1;
418
- min-width: 0;
419
- padding: var(--ui-space-6) var(--ui-space-8);
420
- background: var(--ui-surface-lowest);
421
- border: 1px solid var(--ui-border-subtle);
774
+ .pfm-btn-row {
775
+ width: 100%;
776
+ justify-content: flex-start;
777
+ gap: var(--ui-space-8);
778
+ flex: 0 0 auto;
779
+ text-align: left;
780
+ }
781
+
782
+ .pfm-btn-row i {
783
+ width: 1rem;
784
+ text-align: center;
785
+ flex: 0 0 auto;
786
+ }
787
+
788
+ .pfm-btn-row span {
789
+ flex: 1 1 auto;
790
+ text-align: left;
791
+ }
792
+
793
+ /* Bridge button — lives between the Editor and Production cards so the
794
+ promote action visually IS the arrow that connects them. Matches the
795
+ .cfm-btn.primary geometry (--ui-radius-md, --ui-space-6/12 padding) so
796
+ it reads as the same affordance as the per-component Adopt. */
797
+ .pfm-adopt-btn {
798
+ align-self: stretch;
799
+ width: 100%;
800
+ display: flex;
801
+ align-items: center;
802
+ justify-content: center;
803
+ gap: var(--ui-space-6);
804
+ margin: calc(var(--ui-space-2) * -1) 0;
805
+ padding: var(--ui-space-6) var(--ui-space-12);
806
+ background: color-mix(in srgb, var(--pfm-applied) 18%, var(--ui-surface-high));
807
+ border: 1px solid color-mix(in srgb, var(--pfm-applied) 45%, var(--ui-border-medium));
422
808
  border-radius: var(--ui-radius-md);
423
809
  color: var(--ui-text-primary);
424
810
  font-size: var(--ui-font-size-md);
425
- outline: none;
811
+ font-weight: var(--ui-font-weight-medium);
812
+ cursor: pointer;
813
+ transition: all var(--ui-transition-fast);
814
+ white-space: nowrap;
815
+ position: relative;
816
+ z-index: 1;
426
817
  }
427
818
 
428
- .save-as-input:focus {
429
- border-color: var(--ui-border-medium);
819
+ .pfm-adopt-btn i {
820
+ width: 1rem;
821
+ text-align: center;
822
+ font-size: 0.85em;
430
823
  }
431
824
 
432
- .save-as-input::placeholder {
825
+ .pfm-adopt-btn:hover:not(:disabled) {
826
+ background: color-mix(in srgb, var(--pfm-applied) 30%, var(--ui-surface-higher));
827
+ border-color: color-mix(in srgb, var(--pfm-applied) 70%, var(--ui-border-strong));
828
+ }
829
+
830
+ .pfm-adopt-btn:disabled {
831
+ cursor: not-allowed;
832
+ }
833
+
834
+ .pfm-adopt-btn.in-sync {
835
+ background: transparent;
836
+ border-color: var(--ui-border-subtle);
433
837
  color: var(--ui-text-muted);
838
+ opacity: 0.7;
434
839
  }
435
840
 
436
- .inline-btn {
437
- flex: 0 0 auto;
438
- display: flex;
841
+ .pfm-adopt-btn.saving i { animation: spin 1s linear infinite; }
842
+ .pfm-adopt-btn.saved {
843
+ background: color-mix(in srgb, var(--pfm-applied) 30%, var(--ui-surface-high));
844
+ color: var(--pfm-applied);
845
+ }
846
+ .pfm-adopt-btn.error { color: var(--ui-text-muted); }
847
+
848
+ .pfm-btn {
849
+ display: inline-flex;
439
850
  align-items: center;
440
851
  justify-content: center;
441
- width: 30px;
442
- height: 30px;
443
- padding: 0;
444
- background: var(--ui-surface-low);
852
+ gap: var(--ui-space-4);
853
+ padding: var(--ui-space-6) var(--ui-space-10);
854
+ background: var(--ui-surface);
445
855
  border: 1px solid var(--ui-border-subtle);
446
856
  border-radius: var(--ui-radius-md);
447
857
  color: var(--ui-text-secondary);
448
- font-size: var(--ui-font-size-sm);
858
+ font-size: var(--ui-font-size-md);
859
+ font-weight: var(--ui-font-weight-medium);
449
860
  cursor: pointer;
450
861
  transition: all var(--ui-transition-fast);
862
+ white-space: nowrap;
863
+ flex: 1 1 0;
864
+ min-width: 0;
451
865
  }
452
866
 
453
- .inline-btn:hover:not(:disabled) {
454
- background: var(--ui-surface);
867
+ .pfm-btn i {
868
+ width: 1rem;
869
+ text-align: center;
870
+ font-size: 0.85em;
871
+ }
872
+
873
+ .pfm-btn:hover:not(:disabled) {
874
+ background: var(--ui-surface-high);
455
875
  color: var(--ui-text-primary);
456
876
  border-color: var(--ui-border-default);
457
877
  }
458
878
 
459
- .inline-btn:disabled {
460
- opacity: 0.5;
879
+ .pfm-btn:disabled {
880
+ opacity: 0.45;
461
881
  cursor: not-allowed;
462
882
  }
463
883
 
464
- .inline-btn.confirm-btn {
884
+ .pfm-btn.active {
885
+ background: var(--ui-surface);
886
+ border-color: var(--ui-border-default);
887
+ color: var(--ui-text-primary);
888
+ }
889
+
890
+ .pfm-btn-wide {
891
+ flex: 1 1 100%;
892
+ width: 100%;
893
+ }
894
+
895
+ .save-btn {
465
896
  background: var(--ui-surface-high);
466
897
  border-color: var(--ui-border-medium);
467
898
  color: var(--ui-text-primary);
468
899
  }
469
900
 
901
+ .save-btn:hover:not(:disabled) {
902
+ background: var(--ui-surface-higher);
903
+ border-color: var(--ui-border-strong);
904
+ }
905
+
906
+ .save-btn.saving i { animation: spin 1s linear infinite; }
907
+ .save-btn.saved {
908
+ background: var(--ui-surface-highest);
909
+ color: var(--ui-text-success);
910
+ }
911
+ .save-btn.error {
912
+ background: var(--ui-surface-high);
913
+ color: var(--ui-text-muted);
914
+ }
915
+
916
+
470
917
  .load-list {
471
918
  display: flex;
472
919
  flex-direction: column;
@@ -558,6 +1005,106 @@
558
1005
  letter-spacing: 0.02em;
559
1006
  }
560
1007
 
1008
+ /* Info popover — fixed positioning escapes the sidebar's overflow and any
1009
+ parent stacking context. JS in this file anchors it to the right of the
1010
+ info button (the sidebar is on the left, so there's room to flow into
1011
+ the main content area without obscuring the button). */
1012
+ .pfm-info-popover {
1013
+ position: fixed;
1014
+ top: 0;
1015
+ left: 0;
1016
+ width: 22rem;
1017
+ max-width: calc(100vw - var(--ui-space-24));
1018
+ padding: 0;
1019
+ background: var(--ui-surface-higher);
1020
+ border: 1px solid var(--ui-border-medium);
1021
+ border-radius: var(--ui-radius-lg);
1022
+ box-shadow: var(--ui-shadow-lg);
1023
+ z-index: 1000;
1024
+ color: var(--ui-text-secondary);
1025
+ font-family: var(--ui-font-family, system-ui, sans-serif);
1026
+ overflow: hidden;
1027
+ visibility: hidden;
1028
+ animation: pfm-info-in 140ms ease-out;
1029
+ }
1030
+
1031
+ .pfm-info-popover.ready {
1032
+ visibility: visible;
1033
+ }
1034
+
1035
+ .pfm-info-header {
1036
+ display: flex;
1037
+ align-items: center;
1038
+ justify-content: space-between;
1039
+ gap: var(--ui-space-8);
1040
+ padding: var(--ui-space-10) var(--ui-space-12) var(--ui-space-10) var(--ui-space-16);
1041
+ border-bottom: 1px solid var(--ui-border-subtle);
1042
+ }
1043
+
1044
+ .pfm-info-title {
1045
+ color: var(--ui-text-primary);
1046
+ font-size: var(--ui-font-size-sm);
1047
+ font-weight: var(--ui-font-weight-semibold);
1048
+ letter-spacing: -0.01em;
1049
+ line-height: 1.2;
1050
+ }
1051
+
1052
+ .pfm-info-close {
1053
+ display: inline-flex;
1054
+ align-items: center;
1055
+ justify-content: center;
1056
+ width: var(--ui-space-24);
1057
+ height: var(--ui-space-24);
1058
+ padding: 0;
1059
+ background: transparent;
1060
+ border: 0;
1061
+ border-radius: var(--ui-radius-sm);
1062
+ color: var(--ui-text-tertiary);
1063
+ font-size: var(--ui-font-size-xs);
1064
+ line-height: 1;
1065
+ cursor: pointer;
1066
+ transition: color var(--ui-transition-fast), background var(--ui-transition-fast);
1067
+ }
1068
+
1069
+ .pfm-info-close:hover {
1070
+ color: var(--ui-text-primary);
1071
+ background: var(--ui-hover);
1072
+ }
1073
+
1074
+ .pfm-info-body {
1075
+ padding: var(--ui-space-16);
1076
+ }
1077
+
1078
+ .pfm-info-popover p {
1079
+ margin: 0 0 var(--ui-space-12) 0;
1080
+ font-size: var(--ui-font-size-xs);
1081
+ line-height: 1.55;
1082
+ }
1083
+
1084
+ .pfm-info-popover p:last-child {
1085
+ margin-bottom: 0;
1086
+ }
1087
+
1088
+ .pfm-info-popover strong {
1089
+ color: var(--ui-text-primary);
1090
+ font-weight: var(--ui-font-weight-semibold);
1091
+ }
1092
+
1093
+ .pfm-info-popover em {
1094
+ font-style: italic;
1095
+ color: var(--ui-text-primary);
1096
+ }
1097
+
1098
+ @keyframes pfm-pulse {
1099
+ 0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-highlight) 22%, transparent); }
1100
+ 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--ui-highlight) 10%, transparent); }
1101
+ }
1102
+
1103
+ @keyframes pfm-info-in {
1104
+ from { opacity: 0; transform: translateY(-3px); }
1105
+ to { opacity: 1; transform: translateY(0); }
1106
+ }
1107
+
561
1108
  @keyframes spin {
562
1109
  from {
563
1110
  transform: rotate(0deg);