@motion-proto/live-tokens 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-plugin/index.cjs +80 -4
- package/dist-plugin/index.js +80 -4
- package/package.json +2 -2
- package/src/component-editor/scaffolding/ComponentFileManager.svelte +3 -1
- package/src/component-editor/scaffolding/SaveAsDialog.svelte +24 -7
- package/src/lib/ColumnsOverlay.svelte +1 -1
- package/src/lib/componentPersist.ts +65 -0
- package/src/lib/presetService.ts +121 -1
- package/src/lib/productionPulse.ts +32 -0
- package/src/pages/ComponentEditorPage.svelte +25 -0
- package/src/pages/EditorShell.svelte +25 -0
- package/src/ui/PresetFileManager.svelte +763 -216
- package/src/ui/ThemeFileManager.svelte +557 -307
- package/src/ui/UnsavedComponentsDialog.svelte +315 -0
|
@@ -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
|
|
20
|
-
let
|
|
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.
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
84
|
-
if (!
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
268
|
+
function openSaveAs() {
|
|
269
|
+
showFileList = false;
|
|
270
|
+
saveAsDialog = true;
|
|
130
271
|
}
|
|
131
272
|
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
if (
|
|
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="
|
|
180
|
-
<span class="
|
|
181
|
-
<
|
|
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="
|
|
185
|
-
<div
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
class=
|
|
197
|
-
|
|
198
|
-
class
|
|
199
|
-
|
|
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
|
-
</
|
|
206
|
-
<
|
|
207
|
-
class="pfm-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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="
|
|
229
|
-
onclick={
|
|
230
|
-
disabled={
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
.
|
|
592
|
+
.pfm-header {
|
|
299
593
|
display: flex;
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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-
|
|
635
|
+
gap: var(--ui-space-6);
|
|
322
636
|
}
|
|
323
637
|
|
|
324
|
-
.pfm-
|
|
638
|
+
.pfm-card {
|
|
639
|
+
position: relative;
|
|
325
640
|
display: flex;
|
|
326
|
-
|
|
327
|
-
gap: var(--ui-space-
|
|
328
|
-
|
|
329
|
-
|
|
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-
|
|
341
|
-
|
|
342
|
-
|
|
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-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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-
|
|
352
|
-
|
|
353
|
-
|
|
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-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
.
|
|
388
|
-
|
|
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
|
-
.
|
|
393
|
-
|
|
740
|
+
.pfm-pill.diverged {
|
|
741
|
+
border-color: color-mix(in srgb, var(--ui-highlight) 50%, var(--ui-border-subtle));
|
|
394
742
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
.
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
429
|
-
|
|
819
|
+
.pfm-adopt-btn i {
|
|
820
|
+
width: 1rem;
|
|
821
|
+
text-align: center;
|
|
822
|
+
font-size: 0.85em;
|
|
430
823
|
}
|
|
431
824
|
|
|
432
|
-
.
|
|
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
|
-
.
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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-
|
|
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
|
-
.
|
|
454
|
-
|
|
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
|
-
.
|
|
460
|
-
opacity: 0.
|
|
879
|
+
.pfm-btn:disabled {
|
|
880
|
+
opacity: 0.45;
|
|
461
881
|
cursor: not-allowed;
|
|
462
882
|
}
|
|
463
883
|
|
|
464
|
-
.
|
|
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);
|