@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,13 +1,14 @@
1
1
  <script lang="ts">
2
2
  import { stopPropagation } from 'svelte/legacy';
3
3
 
4
- import { onMount } from 'svelte';
4
+ import { onMount, onDestroy } from 'svelte';
5
5
  import type { ThemeMeta } from '../lib/themeTypes';
6
- import { listThemes, deleteTheme, setActiveFile, sanitizeFileName, getProductionInfo, setProductionFile } from '../lib/themeService';
7
- import type { ProductionInfo } from '../lib/themeService';
6
+ import { listThemes, deleteTheme, setActiveFile, getProductionInfo, setProductionFile } from '../lib/themeService';
8
7
  import { activeFileName } from '../lib/editorConfigStore';
9
8
  import { dirty } from '../lib/editorStore';
9
+ import { productionRevision, bumpProductionRevision, themeProductionInfo } from '../lib/productionPulse';
10
10
  import UIDialog from './UIDialog.svelte';
11
+ import SaveAsDialog from '../component-editor/scaffolding/SaveAsDialog.svelte';
11
12
 
12
13
  interface Props {
13
14
  saveStatus?: 'idle' | 'saving' | 'saved' | 'error';
@@ -19,19 +20,26 @@
19
20
 
20
21
  let files: ThemeMeta[] = $state([]);
21
22
  let showFileList = $state(false);
22
- let saveAsEditing = $state(false);
23
- let saveAsName = $state('');
24
- let saveAsInput: HTMLInputElement | undefined = $state();
25
- let currentDisplayName = $state('Default');
23
+ let saveAsDialog = $state(false);
24
+ let currentDisplayName = $state('Default Theme');
26
25
 
27
- // --- Production state ---
28
- let productionInfo: ProductionInfo | null = $state(null);
29
- let productionUpdateStatus: 'idle' | 'updating' | 'done' | 'error' = $state('idle');
26
+ let prodApplyStatus: 'idle' | 'applying' | 'done' | 'error' = $state('idle');
27
+
28
+ let infoOpen = $state(false);
29
+ let infoBtnEl = $state<HTMLButtonElement | undefined>(undefined);
30
+ let infoPopoverEl = $state<HTMLDivElement | undefined>(undefined);
31
+ let infoPopoverReady = $state(false);
32
+
33
+ let prodIsInSync = $derived($themeProductionInfo?.fileName === $activeFileName);
34
+ let editorIsApplied = $derived(prodIsInSync && !$dirty);
35
+ let prodName = $derived($themeProductionInfo?.name ?? '—');
36
+
37
+ let isDefaultActive = $derived($activeFileName === 'default');
30
38
 
31
39
  async function refreshFiles() {
32
40
  try {
33
41
  files = await listThemes();
34
- const active = files.find(f => f.isActive);
42
+ const active = files.find((f) => f.isActive);
35
43
  if (active) {
36
44
  $activeFileName = active.fileName;
37
45
  currentDisplayName = active.name;
@@ -43,90 +51,142 @@
43
51
 
44
52
  async function refreshProduction() {
45
53
  try {
46
- productionInfo = await getProductionInfo();
47
- } catch {
48
- // silent
49
- }
50
- }
51
-
52
- async function handleUpdateProduction() {
53
- productionUpdateStatus = 'updating';
54
- try {
55
- await setProductionFile($activeFileName);
56
- await refreshProduction();
57
- productionUpdateStatus = 'done';
58
- setTimeout(() => { productionUpdateStatus = 'idle'; }, 2000);
54
+ const info = await getProductionInfo();
55
+ themeProductionInfo.set(info);
59
56
  } catch {
60
- productionUpdateStatus = 'error';
61
- setTimeout(() => { productionUpdateStatus = 'idle'; }, 2000);
57
+ // silent — leave cached value in place
62
58
  }
63
59
  }
64
60
 
65
61
  onMount(async () => {
66
62
  await refreshFiles();
67
63
  await refreshProduction();
64
+ window.addEventListener('keydown', handleKeydown);
65
+ document.addEventListener('mousedown', handleDocumentMousedown, true);
68
66
  });
69
67
 
70
- function handleSave() {
71
- onsave?.({ fileName: $activeFileName, displayName: currentDisplayName });
68
+ onDestroy(() => {
69
+ window.removeEventListener('keydown', handleKeydown);
70
+ document.removeEventListener('mousedown', handleDocumentMousedown, true);
71
+ });
72
+
73
+ function handleKeydown(e: KeyboardEvent) {
74
+ if (e.key === 'Escape' && infoOpen) {
75
+ infoOpen = false;
76
+ }
72
77
  }
73
78
 
74
- function handleSaveIncrement() {
75
- // Strip any existing _NN suffix, then find the next available number
76
- const baseName = currentDisplayName.replace(/_\d+$/, '');
77
- const baseFileName = sanitizeFileName(baseName);
78
- const existingNums = files
79
- .filter(f => f.fileName === baseFileName || f.fileName.match(new RegExp(`^${baseFileName}_\\d+$`)))
80
- .map(f => {
81
- const m = f.fileName.match(/_(\d+)$/);
82
- return m ? parseInt(m[1], 10) : 0;
83
- });
84
- const next = (existingNums.length > 0 ? Math.max(...existingNums) : 0) + 1;
85
- const suffix = String(next).padStart(2, '0');
86
- const displayName = `${baseName}_${suffix}`;
87
- const fileName = `${baseFileName}_${suffix}`;
79
+ function handleDocumentMousedown(e: MouseEvent) {
80
+ if (!infoOpen) return;
81
+ const target = e.target as Element | null;
82
+ if (target && !target.closest('.tfm-info-btn, .tfm-info-popover')) {
83
+ infoOpen = false;
84
+ }
85
+ }
88
86
 
89
- onsave?.({ fileName, displayName });
90
- $activeFileName = fileName;
91
- currentDisplayName = displayName;
92
- setTimeout(() => refreshFiles(), 500);
87
+ /** Anchor the fixed-position popover to the right of the info button. The
88
+ * sidebar lives on the left, so flow into the content area; flip up if the
89
+ * button is near the bottom of the viewport. Mirrors the preset popover so
90
+ * the two info surfaces feel identical. */
91
+ function positionInfoPopover(): void {
92
+ const btn = infoBtnEl;
93
+ const pop = infoPopoverEl;
94
+ if (!btn || !pop) return;
95
+ const br = btn.getBoundingClientRect();
96
+ const pr = pop.getBoundingClientRect();
97
+ const margin = 8;
98
+ const vw = window.innerWidth;
99
+ const vh = window.innerHeight;
100
+ let left = br.right + margin;
101
+ if (left + pr.width > vw - margin) {
102
+ left = br.left + br.width / 2 - pr.width / 2;
103
+ if (left < margin) left = margin;
104
+ if (left + pr.width > vw - margin) left = vw - margin - pr.width;
105
+ }
106
+ let top = br.bottom + margin;
107
+ if (top + pr.height > vh - margin) {
108
+ top = br.top - margin - pr.height;
109
+ if (top < margin) top = margin;
110
+ }
111
+ pop.style.left = `${left}px`;
112
+ pop.style.top = `${top}px`;
113
+ infoPopoverReady = true;
114
+ }
115
+
116
+ $effect(() => {
117
+ if (!infoOpen) {
118
+ infoPopoverReady = false;
119
+ return;
120
+ }
121
+ let raf1 = requestAnimationFrame(() => {
122
+ raf1 = requestAnimationFrame(positionInfoPopover);
123
+ });
124
+ window.addEventListener('scroll', positionInfoPopover, true);
125
+ window.addEventListener('resize', positionInfoPopover);
126
+ return () => {
127
+ cancelAnimationFrame(raf1);
128
+ window.removeEventListener('scroll', positionInfoPopover, true);
129
+ window.removeEventListener('resize', positionInfoPopover);
130
+ };
131
+ });
132
+
133
+ // Refresh production state when any production pointer flips (e.g. a preset
134
+ // is adopted elsewhere). Skip the initial tick — onMount already loaded it.
135
+ let pulseInitialised = false;
136
+ $effect(() => {
137
+ void $productionRevision;
138
+ if (!pulseInitialised) {
139
+ pulseInitialised = true;
140
+ return;
141
+ }
142
+ refreshProduction();
143
+ });
144
+
145
+ function handleSave() {
146
+ if (isDefaultActive) return;
147
+ onsave?.({ fileName: $activeFileName, displayName: currentDisplayName });
93
148
  }
94
149
 
95
150
  function openSaveAs() {
96
- saveAsName = currentDisplayName;
97
- saveAsEditing = true;
98
151
  showFileList = false;
99
- // Focus the input after Svelte renders it
100
- setTimeout(() => saveAsInput?.select(), 0);
152
+ saveAsDialog = true;
101
153
  }
102
154
 
103
- function confirmSaveAs() {
104
- const displayName = saveAsName.trim();
105
- if (!displayName) return;
106
- const fileName = sanitizeFileName(displayName);
107
- if (fileName === 'default') {
108
- saveAsName = '';
109
- return;
110
- }
111
- saveAsEditing = false;
155
+ function confirmSaveAs(detail: { displayName: string; fileName: string }) {
156
+ const { displayName, fileName } = detail;
112
157
  onsave?.({ fileName, displayName });
113
158
  $activeFileName = fileName;
114
159
  currentDisplayName = displayName;
115
160
  setTimeout(() => refreshFiles(), 500);
116
161
  }
117
162
 
118
- function cancelSaveAs() {
119
- saveAsEditing = false;
120
- saveAsName = '';
163
+ async function handleApplyToProduction() {
164
+ if (prodIsInSync) return;
165
+ prodApplyStatus = 'applying';
166
+ try {
167
+ await setProductionFile($activeFileName);
168
+ await refreshProduction();
169
+ bumpProductionRevision();
170
+ prodApplyStatus = 'done';
171
+ setTimeout(() => { prodApplyStatus = 'idle'; }, 2000);
172
+ } catch {
173
+ prodApplyStatus = 'error';
174
+ setTimeout(() => { prodApplyStatus = 'idle'; }, 3000);
175
+ }
121
176
  }
122
177
 
123
178
  async function handleLoad(file: ThemeMeta) {
179
+ if ($dirty) {
180
+ const ok = window.confirm(
181
+ 'Loading a theme will discard unsaved changes. Continue?',
182
+ );
183
+ if (!ok) return;
184
+ }
124
185
  showFileList = false;
125
186
  await setActiveFile(file.fileName);
126
187
  $activeFileName = file.fileName;
127
188
  currentDisplayName = file.name;
128
189
  onload?.({ fileName: file.fileName });
129
- // editorStore.loadFromFile clears history and resets dirty — no snapshot needed here.
130
190
  }
131
191
 
132
192
  async function handleDelete(file: ThemeMeta) {
@@ -134,10 +194,9 @@
134
194
  try {
135
195
  await deleteTheme(file.fileName);
136
196
  await refreshFiles();
137
- // If we deleted the active file, it reverts to default on the server
138
197
  if (file.fileName === $activeFileName) {
139
198
  $activeFileName = 'default';
140
- currentDisplayName = 'Default';
199
+ currentDisplayName = 'Default Theme';
141
200
  onload?.({ fileName: 'default' });
142
201
  }
143
202
  } catch {
@@ -145,14 +204,8 @@
145
204
  }
146
205
  }
147
206
 
148
- function handleSaveAsKeydown(e: KeyboardEvent) {
149
- if (e.key === 'Enter') confirmSaveAs();
150
- if (e.key === 'Escape') cancelSaveAs();
151
- }
152
-
153
207
  function toggleFileList() {
154
208
  showFileList = !showFileList;
155
- saveAsEditing = false;
156
209
  if (showFileList) refreshFiles();
157
210
  }
158
211
 
@@ -179,7 +232,6 @@
179
232
  sortDir = sortDir === 'asc' ? 'desc' : 'asc';
180
233
  } else {
181
234
  sortKey = key;
182
- // Default: names asc, dates desc (most-recent first)
183
235
  sortDir = key === 'name' ? 'asc' : 'desc';
184
236
  }
185
237
  }
@@ -196,119 +248,153 @@
196
248
  </script>
197
249
 
198
250
  <div class="theme-file-manager">
199
- <div class="active-file">
200
- <span class="active-label">Theme</span>
201
- <span class="active-name">{currentDisplayName}</span>
202
- </div>
203
-
204
-
205
- <div class="button-grid">
206
- <div class="save-row">
207
- <button
208
- class="tfm-btn save-btn"
209
- class:saving={saveStatus === 'saving'}
210
- class:saved={saveStatus === 'saved'}
211
- class:error={saveStatus === 'error'}
212
- onclick={handleSave}
213
- disabled={saveStatus === 'saving'}
214
- title="Save to current file"
215
- >
216
- <i class="fas" class:fa-save={saveStatus === 'idle'} class:fa-spinner={saveStatus === 'saving'} class:fa-check={saveStatus === 'saved'} class:fa-times={saveStatus === 'error'}></i>
217
- <span>
218
- {#if saveStatus === 'idle'}Save{:else if saveStatus === 'saving'}Saving{:else if saveStatus === 'saved'}Saved{:else}Error{/if}
219
- </span>
220
- </button>
221
- <button
222
- class="tfm-btn increment-btn"
223
- onclick={handleSaveIncrement}
224
- disabled={saveStatus === 'saving'}
225
- title="Save as incremented copy"
226
- >
227
- <i class="fas fa-plus"></i>
228
- </button>
229
- </div>
230
-
231
- {#if saveAsEditing}
232
- <div class="save-as-inline">
233
- <input
234
- class="save-as-input"
235
- type="text"
236
- bind:value={saveAsName}
237
- bind:this={saveAsInput}
238
- onkeydown={handleSaveAsKeydown}
239
- placeholder="Theme name..."
240
- />
241
- <div class="save-as-actions">
242
- <button class="inline-btn confirm-btn" onclick={confirmSaveAs} disabled={!saveAsName.trim()} title="Save">
243
- <i class="fas fa-check"></i>
244
- </button>
245
- <button class="inline-btn cancel-btn" onclick={cancelSaveAs} title="Cancel">
246
- <i class="fas fa-times"></i>
247
- </button>
248
- </div>
249
- </div>
250
- {:else}
251
- <button
252
- class="tfm-btn"
253
- onclick={openSaveAs}
254
- title="Save as new file"
255
- >
256
- <i class="fas fa-copy"></i>
257
- <span>Save As</span>
258
- </button>
259
- {/if}
260
-
251
+ <div class="tfm-header">
252
+ <span class="tfm-header-label">Theme</span>
261
253
  <button
262
- class="tfm-btn"
263
- class:active={showFileList}
264
- onclick={toggleFileList}
265
- title="Load a theme"
254
+ type="button"
255
+ class="tfm-info-btn"
256
+ aria-label="About themes"
257
+ aria-expanded={infoOpen}
258
+ bind:this={infoBtnEl}
259
+ onclick={() => (infoOpen = !infoOpen)}
266
260
  >
267
- <i class="fas fa-folder-open"></i>
268
- <span>Load</span>
261
+ <i class="fas fa-circle-info"></i>
269
262
  </button>
270
-
271
-
272
263
  </div>
273
264
 
274
- <div class="status-labels">
275
- <span class="status-label" class:dirty={$dirty} class:clean={!$dirty}>
276
- {$dirty ? 'Unsaved changes' : 'Saved'}
277
- </span>
278
- </div>
279
-
280
- <div class="production-section">
281
- <div class="production-header">
282
- <span class="production-label">Production</span>
283
- {#if productionInfo}
284
- <span class="production-name">{productionInfo.name}</span>
285
- {/if}
265
+ <div class="tfm-cards" class:in-sync={prodIsInSync}>
266
+ <div
267
+ class="tfm-card tfm-card-editor"
268
+ class:dirty={$dirty}
269
+ class:applied={editorIsApplied}
270
+ >
271
+ <span class="tfm-rail" aria-hidden="true"></span>
272
+ <div class="tfm-card-head">
273
+ <span class="tfm-card-label">Editor</span>
274
+ <span
275
+ class="tfm-card-status"
276
+ class:dirty={$dirty}
277
+ class:applied={editorIsApplied}
278
+ >
279
+ <i class="tfm-status-dot" aria-hidden="true"></i>
280
+ <span>{$dirty ? 'unsaved' : editorIsApplied ? 'live' : 'saved'}</span>
281
+ </span>
282
+ </div>
283
+ <div class="tfm-pill" class:dirty={$dirty} class:applied={editorIsApplied}>
284
+ <span class="tfm-pill-name" title={currentDisplayName}>{currentDisplayName}</span>
285
+ </div>
286
+ <div class="tfm-card-actions tfm-card-actions-stack">
287
+ <button
288
+ class="tfm-btn tfm-btn-row save-btn"
289
+ class:saving={saveStatus === 'saving'}
290
+ class:saved={saveStatus === 'saved'}
291
+ class:error={saveStatus === 'error'}
292
+ onclick={handleSave}
293
+ disabled={saveStatus === 'saving' || isDefaultActive}
294
+ title={isDefaultActive
295
+ ? 'Default is read-only — use Save As to capture under a new name'
296
+ : 'Save to current file'}
297
+ >
298
+ <i
299
+ class="fas"
300
+ class:fa-save={saveStatus === 'idle'}
301
+ class:fa-spinner={saveStatus === 'saving'}
302
+ class:fa-check={saveStatus === 'saved'}
303
+ class:fa-times={saveStatus === 'error'}
304
+ ></i>
305
+ <span>
306
+ {#if saveStatus === 'idle'}Save{:else if saveStatus === 'saving'}Saving{:else if saveStatus === 'saved'}Saved{:else}Error{/if}
307
+ </span>
308
+ </button>
309
+ <button class="tfm-btn tfm-btn-row" onclick={openSaveAs} title="Save as new theme">
310
+ <i class="fas fa-copy"></i>
311
+ <span>Save As…</span>
312
+ </button>
313
+ <button
314
+ class="tfm-btn tfm-btn-row"
315
+ class:active={showFileList}
316
+ onclick={toggleFileList}
317
+ title="Load a theme"
318
+ >
319
+ <i class="fas fa-folder-open"></i>
320
+ <span>Load…</span>
321
+ </button>
322
+ </div>
286
323
  </div>
287
324
 
288
325
  <button
289
- class="tfm-btn production-update-btn"
290
- class:updating={productionUpdateStatus === 'updating'}
291
- class:done={productionUpdateStatus === 'done'}
292
- class:error={productionUpdateStatus === 'error'}
293
- onclick={handleUpdateProduction}
294
- disabled={productionUpdateStatus === 'updating' || (productionInfo?.fileName === $activeFileName)}
295
- title={productionInfo?.fileName === $activeFileName ? 'Already in production' : `Set "${currentDisplayName}" as production`}
326
+ class="tfm-adopt-btn"
327
+ class:saving={prodApplyStatus === 'applying'}
328
+ class:saved={prodApplyStatus === 'done'}
329
+ class:error={prodApplyStatus === 'error'}
330
+ class:in-sync={prodIsInSync}
331
+ onclick={handleApplyToProduction}
332
+ disabled={prodApplyStatus === 'applying' || prodIsInSync}
333
+ title={prodIsInSync
334
+ ? 'This theme is already in production'
335
+ : `Adopt "${currentDisplayName}" as the production theme`}
296
336
  >
297
- <i class="fas" class:fa-upload={productionUpdateStatus === 'idle'} class:fa-spinner={productionUpdateStatus === 'updating'} class:fa-check={productionUpdateStatus === 'done'} class:fa-times={productionUpdateStatus === 'error'}></i>
337
+ <i
338
+ class="fas"
339
+ class:fa-arrow-down={prodApplyStatus === 'idle'}
340
+ class:fa-spinner={prodApplyStatus === 'applying'}
341
+ class:fa-check={prodApplyStatus === 'done'}
342
+ class:fa-xmark={prodApplyStatus === 'error'}
343
+ ></i>
298
344
  <span>
299
- {#if productionUpdateStatus === 'idle'}Apply Theme{:else if productionUpdateStatus === 'updating'}Applying{:else if productionUpdateStatus === 'done'}Applied{:else}Error{/if}
345
+ {#if prodApplyStatus === 'idle'}Adopt{:else if prodApplyStatus === 'applying'}Adopting{:else if prodApplyStatus === 'done'}Adopted{:else}Error{/if}
300
346
  </span>
301
347
  </button>
302
348
 
303
- {#if productionInfo?.fileName === $activeFileName}
304
- <span class="production-match">Active theme matches production</span>
305
- {:else}
306
- <span class="production-diff">Active theme differs from production</span>
307
- {/if}
349
+ <div
350
+ class="tfm-card tfm-card-production"
351
+ class:in-sync={prodIsInSync}
352
+ >
353
+ <span class="tfm-rail" aria-hidden="true"></span>
354
+ <div class="tfm-card-head">
355
+ <span class="tfm-card-label">Production</span>
356
+ <span
357
+ class="tfm-card-status"
358
+ class:applied={prodIsInSync}
359
+ >
360
+ <i class="tfm-status-dot" aria-hidden="true"></i>
361
+ <span>{prodIsInSync ? 'live' : 'out of sync'}</span>
362
+ </span>
363
+ </div>
364
+ <div class="tfm-pill" class:applied={prodIsInSync}>
365
+ <span class="tfm-pill-name" title={prodName}>{prodName}</span>
366
+ </div>
367
+ </div>
308
368
  </div>
309
-
310
369
  </div>
311
370
 
371
+ {#if infoOpen}
372
+ <div
373
+ class="tfm-info-popover"
374
+ class:ready={infoPopoverReady}
375
+ role="dialog"
376
+ aria-label="About themes"
377
+ bind:this={infoPopoverEl}
378
+ >
379
+ <header class="tfm-info-header">
380
+ <span class="tfm-info-title">Themes</span>
381
+ <button
382
+ type="button"
383
+ class="tfm-info-close"
384
+ aria-label="Close"
385
+ onclick={() => (infoOpen = false)}
386
+ >
387
+ <i class="fas fa-xmark"></i>
388
+ </button>
389
+ </header>
390
+ <div class="tfm-info-body">
391
+ <p>
392
+ A <strong>theme</strong> saves the design tokens for a site, components use these tokens to define their appearance.
393
+ </p>
394
+ </div>
395
+ </div>
396
+ {/if}
397
+
312
398
  <UIDialog
313
399
  bind:show={showFileList}
314
400
  title="Load Theme"
@@ -365,178 +451,325 @@
365
451
  </div>
366
452
  </UIDialog>
367
453
 
454
+ <SaveAsDialog
455
+ bind:show={saveAsDialog}
456
+ {currentDisplayName}
457
+ {files}
458
+ title="Save Theme As"
459
+ placeholder="Theme name…"
460
+ reservedNameMessage='The name "default" is reserved for the initial distribution.'
461
+ onsave={confirmSaveAs}
462
+ />
368
463
 
369
464
  <style>
370
465
  .theme-file-manager {
466
+ --tfm-applied: #5aa85e;
467
+ --tfm-rail-neutral: var(--ui-border-default);
468
+ --tfm-rail-dirty: var(--ui-highlight);
469
+ --tfm-rail-applied: var(--tfm-applied);
470
+
371
471
  display: flex;
372
472
  flex-direction: column;
373
473
  gap: var(--ui-space-8);
374
474
  }
375
475
 
376
- .active-file {
476
+ .tfm-header {
377
477
  display: flex;
378
- flex-direction: column;
379
- gap: var(--ui-space-2);
478
+ align-items: center;
479
+ justify-content: space-between;
480
+ gap: var(--ui-space-4);
380
481
  padding: 0 var(--ui-space-4);
381
482
  }
382
483
 
383
- .active-label {
484
+ .tfm-header-label {
384
485
  font-size: var(--ui-font-size-xs);
385
486
  color: var(--ui-text-secondary);
386
487
  text-transform: uppercase;
387
488
  letter-spacing: 0.05em;
388
489
  }
389
490
 
390
- .active-name {
391
- font-size: var(--ui-font-size-md);
392
- font-weight: var(--ui-font-weight-semibold);
491
+ .tfm-info-btn {
492
+ display: inline-flex;
493
+ align-items: center;
494
+ justify-content: center;
495
+ width: 22px;
496
+ height: 22px;
497
+ padding: 0;
498
+ background: transparent;
499
+ border: 0;
500
+ color: var(--ui-text-tertiary);
501
+ font-size: 0.95rem;
502
+ line-height: 1;
503
+ cursor: pointer;
504
+ transition: color var(--ui-transition-fast);
505
+ }
506
+
507
+ .tfm-info-btn:hover,
508
+ .tfm-info-btn[aria-expanded='true'] {
393
509
  color: var(--ui-text-primary);
394
510
  }
395
511
 
396
- .button-grid {
512
+ /* Two-card pipeline (Editor → Production) — mirrors PresetFileManager so
513
+ theme and preset surfaces share one visual idiom. */
514
+ .tfm-cards {
397
515
  display: flex;
398
516
  flex-direction: column;
399
- gap: var(--ui-space-4);
517
+ gap: var(--ui-space-6);
400
518
  }
401
519
 
402
- .tfm-btn {
520
+ .tfm-card {
521
+ position: relative;
403
522
  display: flex;
404
- align-items: center;
405
- gap: var(--ui-space-4);
406
- width: 100%;
407
- padding: var(--ui-space-6) var(--ui-space-8);
408
- background: var(--ui-surface-low);
523
+ flex-direction: column;
524
+ gap: var(--ui-space-6);
525
+ padding: var(--ui-space-8) var(--ui-space-10) var(--ui-space-10) var(--ui-space-16);
526
+ background: var(--ui-surface-lower);
409
527
  border: 1px solid var(--ui-border-subtle);
410
528
  border-radius: var(--ui-radius-md);
411
- color: var(--ui-text-secondary);
412
- font-size: var(--ui-font-size-md);
413
- cursor: pointer;
414
- transition: all var(--ui-transition-fast);
415
- white-space: nowrap;
416
529
  }
417
530
 
418
- .tfm-btn i {
419
- width: 1rem;
420
- text-align: center;
531
+ .tfm-rail {
532
+ position: absolute;
533
+ left: 0;
534
+ top: 0;
535
+ bottom: 0;
536
+ width: 3px;
537
+ border-radius: var(--ui-radius-md) 0 0 var(--ui-radius-md);
538
+ background: var(--tfm-rail-neutral);
539
+ transition: background var(--ui-transition-base);
421
540
  }
422
541
 
423
- .tfm-btn:hover:not(:disabled) {
424
- background: var(--ui-surface);
425
- color: var(--ui-text-primary);
426
- border-color: var(--ui-border-default);
427
- }
542
+ .tfm-card-editor.dirty .tfm-rail { background: var(--tfm-rail-dirty); }
543
+ .tfm-card-editor.applied .tfm-rail { background: var(--tfm-rail-applied); }
544
+ .tfm-card-production.in-sync .tfm-rail { background: var(--tfm-rail-applied); }
428
545
 
429
- .tfm-btn:disabled {
430
- opacity: 0.5;
431
- cursor: not-allowed;
546
+ .tfm-card-head {
547
+ display: flex;
548
+ align-items: baseline;
549
+ justify-content: space-between;
550
+ gap: var(--ui-space-8);
432
551
  }
433
552
 
434
- .tfm-btn.active {
435
- background: var(--ui-surface);
436
- border-color: var(--ui-border-default);
437
- color: var(--ui-text-primary);
553
+ .tfm-card-label {
554
+ font-size: var(--ui-font-size-xs);
555
+ font-weight: var(--ui-font-weight-semibold);
556
+ text-transform: uppercase;
557
+ letter-spacing: 0.08em;
558
+ color: var(--ui-text-secondary);
559
+ line-height: 1.1;
438
560
  }
439
561
 
440
- .save-row {
441
- display: flex;
562
+ .tfm-card-status {
563
+ display: inline-flex;
564
+ align-items: center;
442
565
  gap: var(--ui-space-4);
566
+ font-size: 0.7rem;
567
+ letter-spacing: 0.02em;
568
+ color: var(--ui-text-muted);
569
+ line-height: 1;
443
570
  }
444
571
 
445
- .save-row .save-btn {
446
- flex: 1;
447
- min-width: 0;
572
+ .tfm-status-dot {
573
+ width: 5px;
574
+ height: 5px;
575
+ border-radius: 50%;
576
+ background: currentColor;
577
+ opacity: 0.7;
578
+ flex-shrink: 0;
448
579
  }
449
580
 
450
- .increment-btn {
451
- flex: 0 0 auto;
452
- width: 34px;
453
- padding: 0;
581
+ .tfm-card-status.dirty {
582
+ color: var(--ui-highlight);
583
+ }
584
+
585
+ .tfm-card-status.dirty .tfm-status-dot {
586
+ opacity: 1;
587
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-highlight) 22%, transparent);
588
+ animation: tfm-pulse 1.6s ease-in-out infinite;
589
+ }
590
+
591
+ .tfm-card-status.applied {
592
+ color: var(--tfm-applied);
593
+ }
594
+ .tfm-card-status.applied .tfm-status-dot {
595
+ opacity: 1;
596
+ }
597
+
598
+ .tfm-pill {
454
599
  display: flex;
455
600
  align-items: center;
456
- justify-content: center;
601
+ padding: var(--ui-space-6) var(--ui-space-10);
602
+ background: var(--ui-surface-lowest);
603
+ border: 1px solid var(--ui-border-subtle);
604
+ border-radius: var(--ui-radius-md);
605
+ transition: border-color var(--ui-transition-fast), box-shadow var(--ui-transition-fast);
457
606
  }
458
607
 
459
- .save-btn {
460
- background: var(--ui-surface-high);
461
- border-color: var(--ui-border-medium);
462
- color: var(--ui-text-primary);
608
+ .tfm-pill.dirty {
609
+ border-color: color-mix(in srgb, var(--ui-highlight) 60%, var(--ui-border-subtle));
610
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ui-highlight) 35%, transparent);
463
611
  }
464
612
 
465
- .save-btn:hover:not(:disabled) {
466
- background: var(--ui-surface-higher);
467
- border-color: var(--ui-border-strong);
613
+ .tfm-pill.applied {
614
+ border-color: color-mix(in srgb, var(--tfm-applied) 50%, var(--ui-border-subtle));
468
615
  }
469
616
 
470
- .save-btn.saving i { animation: spin 1s linear infinite; }
471
- .save-btn.saved { background: var(--ui-surface-highest); color: var(--ui-text-success); }
472
- .save-btn.error { background: var(--ui-surface-high); color: var(--ui-text-muted); }
617
+ .tfm-pill-name {
618
+ flex: 1;
619
+ min-width: 0;
620
+ font-size: var(--ui-font-size-md);
621
+ font-weight: var(--ui-font-weight-semibold);
622
+ color: var(--ui-text-primary);
623
+ white-space: nowrap;
624
+ overflow: hidden;
625
+ text-overflow: ellipsis;
626
+ }
473
627
 
474
- .save-as-inline {
628
+ .tfm-card-actions {
475
629
  display: flex;
476
- flex-direction: column;
477
630
  gap: var(--ui-space-4);
631
+ flex-wrap: wrap;
478
632
  }
479
633
 
480
- .save-as-actions {
481
- display: flex;
482
- gap: var(--ui-space-4);
483
- justify-content: flex-end;
634
+ .tfm-card-actions-stack {
635
+ flex-direction: column;
484
636
  }
485
637
 
486
- .save-as-input {
487
- flex: 1;
488
- min-width: 0;
489
- padding: var(--ui-space-6) var(--ui-space-8);
490
- background: var(--ui-surface-lowest);
491
- border: 1px solid var(--ui-border-subtle);
638
+ .tfm-btn-row {
639
+ width: 100%;
640
+ justify-content: flex-start;
641
+ gap: var(--ui-space-8);
642
+ flex: 0 0 auto;
643
+ text-align: left;
644
+ }
645
+
646
+ .tfm-btn-row i {
647
+ width: 1rem;
648
+ text-align: center;
649
+ flex: 0 0 auto;
650
+ }
651
+
652
+ .tfm-btn-row span {
653
+ flex: 1 1 auto;
654
+ text-align: left;
655
+ }
656
+
657
+ /* Bridge button — sits between Editor and Production cards as the arrow that
658
+ promotes the editor theme into production. */
659
+ .tfm-adopt-btn {
660
+ align-self: stretch;
661
+ width: 100%;
662
+ display: flex;
663
+ align-items: center;
664
+ justify-content: center;
665
+ gap: var(--ui-space-6);
666
+ margin: calc(var(--ui-space-2) * -1) 0;
667
+ padding: var(--ui-space-6) var(--ui-space-12);
668
+ background: color-mix(in srgb, var(--tfm-applied) 18%, var(--ui-surface-high));
669
+ border: 1px solid color-mix(in srgb, var(--tfm-applied) 45%, var(--ui-border-medium));
492
670
  border-radius: var(--ui-radius-md);
493
671
  color: var(--ui-text-primary);
494
672
  font-size: var(--ui-font-size-md);
495
- outline: none;
673
+ font-weight: var(--ui-font-weight-medium);
674
+ cursor: pointer;
675
+ transition: all var(--ui-transition-fast);
676
+ white-space: nowrap;
677
+ position: relative;
678
+ z-index: 1;
496
679
  }
497
680
 
498
- .save-as-input:focus {
499
- border-color: var(--ui-border-medium);
681
+ .tfm-adopt-btn i {
682
+ width: 1rem;
683
+ text-align: center;
684
+ font-size: 0.85em;
685
+ }
686
+
687
+ .tfm-adopt-btn:hover:not(:disabled) {
688
+ background: color-mix(in srgb, var(--tfm-applied) 30%, var(--ui-surface-higher));
689
+ border-color: color-mix(in srgb, var(--tfm-applied) 70%, var(--ui-border-strong));
500
690
  }
501
691
 
502
- .save-as-input::placeholder {
692
+ .tfm-adopt-btn:disabled {
693
+ cursor: not-allowed;
694
+ }
695
+
696
+ .tfm-adopt-btn.in-sync {
697
+ background: transparent;
698
+ border-color: var(--ui-border-subtle);
503
699
  color: var(--ui-text-muted);
700
+ opacity: 0.7;
504
701
  }
505
702
 
506
- .inline-btn {
507
- flex: 0 0 auto;
508
- display: flex;
703
+ .tfm-adopt-btn.saving i { animation: spin 1s linear infinite; }
704
+ .tfm-adopt-btn.saved {
705
+ background: color-mix(in srgb, var(--tfm-applied) 30%, var(--ui-surface-high));
706
+ color: var(--tfm-applied);
707
+ }
708
+ .tfm-adopt-btn.error { color: var(--ui-text-muted); }
709
+
710
+ .tfm-btn {
711
+ display: inline-flex;
509
712
  align-items: center;
510
713
  justify-content: center;
511
- width: 30px;
512
- height: 30px;
513
- padding: 0;
514
- background: var(--ui-surface-low);
714
+ gap: var(--ui-space-4);
715
+ padding: var(--ui-space-6) var(--ui-space-10);
716
+ background: var(--ui-surface);
515
717
  border: 1px solid var(--ui-border-subtle);
516
718
  border-radius: var(--ui-radius-md);
517
719
  color: var(--ui-text-secondary);
518
- font-size: var(--ui-font-size-sm);
720
+ font-size: var(--ui-font-size-md);
721
+ font-weight: var(--ui-font-weight-medium);
519
722
  cursor: pointer;
520
723
  transition: all var(--ui-transition-fast);
724
+ white-space: nowrap;
725
+ flex: 1 1 0;
726
+ min-width: 0;
521
727
  }
522
728
 
523
- .inline-btn:hover:not(:disabled) {
524
- background: var(--ui-surface);
729
+ .tfm-btn i {
730
+ width: 1rem;
731
+ text-align: center;
732
+ font-size: 0.85em;
733
+ }
734
+
735
+ .tfm-btn:hover:not(:disabled) {
736
+ background: var(--ui-surface-high);
525
737
  color: var(--ui-text-primary);
526
738
  border-color: var(--ui-border-default);
527
739
  }
528
740
 
529
- .inline-btn:disabled {
530
- opacity: 0.5;
741
+ .tfm-btn:disabled {
742
+ opacity: 0.45;
531
743
  cursor: not-allowed;
532
744
  }
533
745
 
534
- .inline-btn.confirm-btn {
746
+ .tfm-btn.active {
747
+ background: var(--ui-surface);
748
+ border-color: var(--ui-border-default);
749
+ color: var(--ui-text-primary);
750
+ }
751
+
752
+ .save-btn {
535
753
  background: var(--ui-surface-high);
536
754
  border-color: var(--ui-border-medium);
537
755
  color: var(--ui-text-primary);
538
756
  }
539
757
 
758
+ .save-btn:hover:not(:disabled) {
759
+ background: var(--ui-surface-higher);
760
+ border-color: var(--ui-border-strong);
761
+ }
762
+
763
+ .save-btn.saving i { animation: spin 1s linear infinite; }
764
+ .save-btn.saved {
765
+ background: var(--ui-surface-highest);
766
+ color: var(--ui-text-success);
767
+ }
768
+ .save-btn.error {
769
+ background: var(--ui-surface-high);
770
+ color: var(--ui-text-muted);
771
+ }
772
+
540
773
  .load-list {
541
774
  display: flex;
542
775
  flex-direction: column;
@@ -597,7 +830,7 @@
597
830
 
598
831
  .header-spacer {
599
832
  flex-shrink: 0;
600
- width: 24px; /* reserve space matching .file-delete-btn */
833
+ width: 24px;
601
834
  }
602
835
 
603
836
  .load-item {
@@ -685,82 +918,99 @@
685
918
  color: #ccc;
686
919
  }
687
920
 
688
- .status-labels {
689
- display: flex;
690
- gap: var(--ui-space-8);
691
- padding: 0 var(--ui-space-4);
692
- }
693
-
694
- .status-label {
695
- font-size: var(--ui-font-size-xs);
696
- letter-spacing: 0.02em;
697
- }
698
-
699
- .status-label.clean {
921
+ /* Info popover — fixed positioning escapes the sidebar's overflow and any
922
+ parent stacking context. JS in this file anchors it to the right of the
923
+ info button (the sidebar is on the left, so there's room to flow into
924
+ the main content area without obscuring the button). */
925
+ .tfm-info-popover {
926
+ position: fixed;
927
+ top: 0;
928
+ left: 0;
929
+ width: 22rem;
930
+ max-width: calc(100vw - var(--ui-space-24));
931
+ padding: 0;
932
+ background: var(--ui-surface-higher);
933
+ border: 1px solid var(--ui-border-medium);
934
+ border-radius: var(--ui-radius-lg);
935
+ box-shadow: var(--ui-shadow-lg);
936
+ z-index: 1000;
700
937
  color: var(--ui-text-secondary);
938
+ font-family: var(--ui-font-family, system-ui, sans-serif);
939
+ overflow: hidden;
940
+ visibility: hidden;
941
+ animation: tfm-info-in 140ms ease-out;
701
942
  }
702
943
 
703
- .status-label.dirty {
704
- color: var(--ui-highlight);
944
+ .tfm-info-popover.ready {
945
+ visibility: visible;
705
946
  }
706
947
 
707
- /* ── Production section ── */
708
-
709
- .production-section {
948
+ .tfm-info-header {
710
949
  display: flex;
711
- flex-direction: column;
712
- gap: var(--ui-space-4);
713
- padding-top: var(--ui-space-8);
714
- border-top: 1px solid var(--ui-border-subtle);
950
+ align-items: center;
951
+ justify-content: space-between;
952
+ gap: var(--ui-space-8);
953
+ padding: var(--ui-space-10) var(--ui-space-12) var(--ui-space-10) var(--ui-space-16);
954
+ border-bottom: 1px solid var(--ui-border-subtle);
715
955
  }
716
956
 
717
- .production-header {
718
- display: flex;
719
- flex-direction: column;
720
- gap: var(--ui-space-2);
721
- padding: 0 var(--ui-space-4);
957
+ .tfm-info-title {
958
+ color: var(--ui-text-primary);
959
+ font-size: var(--ui-font-size-sm);
960
+ font-weight: var(--ui-font-weight-semibold);
961
+ letter-spacing: -0.01em;
962
+ line-height: 1.2;
722
963
  }
723
964
 
724
- .production-label {
965
+ .tfm-info-close {
966
+ display: inline-flex;
967
+ align-items: center;
968
+ justify-content: center;
969
+ width: var(--ui-space-24);
970
+ height: var(--ui-space-24);
971
+ padding: 0;
972
+ background: transparent;
973
+ border: 0;
974
+ border-radius: var(--ui-radius-sm);
975
+ color: var(--ui-text-tertiary);
725
976
  font-size: var(--ui-font-size-xs);
726
- color: var(--ui-text-secondary);
727
- text-transform: uppercase;
728
- letter-spacing: 0.05em;
977
+ line-height: 1;
978
+ cursor: pointer;
979
+ transition: color var(--ui-transition-fast), background var(--ui-transition-fast);
729
980
  }
730
981
 
731
- .production-name {
732
- font-size: var(--ui-font-size-md);
733
- font-weight: var(--ui-font-weight-semibold);
982
+ .tfm-info-close:hover {
734
983
  color: var(--ui-text-primary);
984
+ background: var(--ui-hover);
735
985
  }
736
986
 
737
- .production-update-btn {
738
- background: var(--ui-surface-high);
739
- border-color: var(--ui-border-medium);
740
- color: var(--ui-text-primary);
987
+ .tfm-info-body {
988
+ padding: var(--ui-space-16);
741
989
  }
742
990
 
743
- .production-update-btn:hover:not(:disabled) {
744
- background: var(--ui-surface-higher);
745
- border-color: var(--ui-border-strong);
991
+ .tfm-info-popover p {
992
+ margin: 0 0 var(--ui-space-12) 0;
993
+ font-size: var(--ui-font-size-xs);
994
+ line-height: 1.55;
746
995
  }
747
996
 
748
- .production-update-btn.updating i { animation: spin 1s linear infinite; }
749
- .production-update-btn.done { color: var(--ui-text-success); }
750
- .production-update-btn.error { color: var(--ui-text-muted); }
997
+ .tfm-info-popover p:last-child {
998
+ margin-bottom: 0;
999
+ }
751
1000
 
752
- .production-match {
753
- font-size: var(--ui-font-size-xs);
754
- color: var(--ui-text-secondary);
755
- padding: 0 var(--ui-space-4);
756
- letter-spacing: 0.02em;
1001
+ .tfm-info-popover strong {
1002
+ color: var(--ui-text-primary);
1003
+ font-weight: var(--ui-font-weight-semibold);
757
1004
  }
758
1005
 
759
- .production-diff {
760
- font-size: var(--ui-font-size-xs);
761
- color: var(--ui-highlight);
762
- padding: 0 var(--ui-space-4);
763
- letter-spacing: 0.02em;
1006
+ @keyframes tfm-info-in {
1007
+ from { opacity: 0; transform: translateY(-3px); }
1008
+ to { opacity: 1; transform: translateY(0); }
1009
+ }
1010
+
1011
+ @keyframes tfm-pulse {
1012
+ 0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-highlight) 22%, transparent); }
1013
+ 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--ui-highlight) 10%, transparent); }
764
1014
  }
765
1015
 
766
1016
  @keyframes spin {