@commonpub/layer 0.21.22 → 0.22.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.
- package/components/admin/theme/AdminThemeFamilyCard.vue +277 -0
- package/components/admin/theme/AdminThemeOverridesPanel.vue +222 -0
- package/components/admin/theme/AdminThemePreviewPane.vue +218 -0
- package/components/admin/theme/AdminThemeSceneAdmin.vue +189 -0
- package/components/admin/theme/AdminThemeSceneGallery.vue +353 -0
- package/components/admin/theme/AdminThemeSceneProse.vue +140 -0
- package/components/admin/theme/AdminThemeTokenGroup.vue +98 -0
- package/components/admin/theme/AdminThemeTokenInput.vue +278 -0
- package/composables/useTheme.ts +24 -14
- package/composables/useThemeAdmin.ts +167 -0
- package/package.json +7 -7
- package/pages/admin/theme/edit/[id].vue +595 -0
- package/pages/admin/theme/index.vue +449 -0
- package/plugins/theme.ts +25 -7
- package/server/api/admin/themes/[id].delete.ts +40 -0
- package/server/api/admin/themes/[id].get.ts +20 -0
- package/server/api/admin/themes/[id].put.ts +45 -0
- package/server/api/admin/themes/discover.get.ts +22 -0
- package/server/api/admin/themes/index.get.ts +40 -0
- package/server/api/admin/themes/index.post.ts +46 -0
- package/server/api/profile/theme.put.ts +2 -1
- package/server/middleware/theme.ts +23 -9
- package/server/utils/instanceTheme.ts +145 -25
- package/types/theme.ts +54 -0
- package/utils/themeDiscovery.ts +67 -0
- package/utils/themeIO.ts +79 -0
- package/utils/themeIds.ts +25 -0
- package/pages/admin/theme.vue +0 -502
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* /admin/theme/edit/[id] — full theme editor.
|
|
4
|
+
*
|
|
5
|
+
* Layout:
|
|
6
|
+
* ┌─────────────────────────────────────────────────────────────┐
|
|
7
|
+
* │ Toolbar: name • family • parent • dark? • save • export │
|
|
8
|
+
* ├──────────────────────┬──────────────────────────────────────┤
|
|
9
|
+
* │ Token editor pane │ Preview pane │
|
|
10
|
+
* │ (grouped, collapsible│ (scene picker, live tokens applied)│
|
|
11
|
+
* │ token rows) │ │
|
|
12
|
+
* └───────────────────────┴─────────────────────────────────────┘
|
|
13
|
+
*
|
|
14
|
+
* Special URL: /admin/theme/edit/__new
|
|
15
|
+
* The list page stashes a seed in sessionStorage and pushes the user here.
|
|
16
|
+
* On save, the seed is POSTed and the user is redirected to the real ID.
|
|
17
|
+
*/
|
|
18
|
+
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
|
|
19
|
+
import { TOKEN_GROUP_LABELS, TOKEN_GROUP_ORDER, tokensByGroup } from '@commonpub/ui';
|
|
20
|
+
|
|
21
|
+
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
22
|
+
|
|
23
|
+
const route = useRoute();
|
|
24
|
+
const router = useRouter();
|
|
25
|
+
const themesApi = useThemeAdmin();
|
|
26
|
+
|
|
27
|
+
const rawId = String(route.params.id ?? '');
|
|
28
|
+
const isCreating = rawId === '__new';
|
|
29
|
+
|
|
30
|
+
const loading = ref(true);
|
|
31
|
+
const saving = ref(false);
|
|
32
|
+
const dirty = ref(false);
|
|
33
|
+
const error = ref<string | null>(null);
|
|
34
|
+
const toast = ref<{ msg: string; tone: 'success' | 'error' } | null>(null);
|
|
35
|
+
|
|
36
|
+
interface DraftTheme {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
description: string;
|
|
40
|
+
family: string;
|
|
41
|
+
isDark: boolean;
|
|
42
|
+
pairId?: string;
|
|
43
|
+
parentTheme: string;
|
|
44
|
+
tokens: Record<string, string>;
|
|
45
|
+
createdAt?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const draft = ref<DraftTheme>({
|
|
49
|
+
id: '',
|
|
50
|
+
name: '',
|
|
51
|
+
description: '',
|
|
52
|
+
family: 'custom',
|
|
53
|
+
isDark: false,
|
|
54
|
+
parentTheme: 'base',
|
|
55
|
+
tokens: {},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// --- Load -------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
onMounted(async () => {
|
|
61
|
+
if (isCreating) {
|
|
62
|
+
const raw = sessionStorage.getItem('cpub-theme-editor-seed');
|
|
63
|
+
if (raw) {
|
|
64
|
+
try {
|
|
65
|
+
const seed = JSON.parse(raw);
|
|
66
|
+
draft.value = {
|
|
67
|
+
id: seed.id ?? '',
|
|
68
|
+
name: seed.name ?? 'My theme',
|
|
69
|
+
description: seed.description ?? '',
|
|
70
|
+
family: seed.family ?? 'custom',
|
|
71
|
+
isDark: Boolean(seed.isDark),
|
|
72
|
+
pairId: seed.pairId,
|
|
73
|
+
parentTheme: seed.parentTheme ?? 'base',
|
|
74
|
+
tokens: seed.tokens ?? {},
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
// Bad seed — start blank
|
|
78
|
+
draft.value.id = 'my-theme';
|
|
79
|
+
draft.value.name = 'My theme';
|
|
80
|
+
}
|
|
81
|
+
sessionStorage.removeItem('cpub-theme-editor-seed');
|
|
82
|
+
} else {
|
|
83
|
+
// Direct navigation to /__new with no seed — give a blank draft
|
|
84
|
+
draft.value.id = 'my-theme';
|
|
85
|
+
draft.value.name = 'My theme';
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
try {
|
|
89
|
+
const theme = await $fetch<DraftTheme>(`/api/admin/themes/${rawId}`);
|
|
90
|
+
draft.value = {
|
|
91
|
+
id: theme.id,
|
|
92
|
+
name: theme.name,
|
|
93
|
+
description: theme.description ?? '',
|
|
94
|
+
family: theme.family,
|
|
95
|
+
isDark: theme.isDark,
|
|
96
|
+
pairId: theme.pairId,
|
|
97
|
+
parentTheme: theme.parentTheme,
|
|
98
|
+
tokens: { ...theme.tokens },
|
|
99
|
+
createdAt: theme.createdAt,
|
|
100
|
+
};
|
|
101
|
+
} catch (err) {
|
|
102
|
+
error.value = err instanceof Error ? err.message : 'Failed to load theme';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Load themes for the parent/pair pickers
|
|
107
|
+
if (!themesApi.data.value) await themesApi.refresh();
|
|
108
|
+
loading.value = false;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// --- Token editing ----------------------------------------------------
|
|
112
|
+
|
|
113
|
+
const groups = computed(() => tokensByGroup());
|
|
114
|
+
|
|
115
|
+
function updateToken(key: string, value: string): void {
|
|
116
|
+
if (value === '') {
|
|
117
|
+
// Empty input — treat as "use parent's value" (delete the override)
|
|
118
|
+
const next = { ...draft.value.tokens };
|
|
119
|
+
delete next[key];
|
|
120
|
+
draft.value.tokens = next;
|
|
121
|
+
} else {
|
|
122
|
+
draft.value.tokens = { ...draft.value.tokens, [key]: value };
|
|
123
|
+
}
|
|
124
|
+
dirty.value = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resetToken(key: string): void {
|
|
128
|
+
const next = { ...draft.value.tokens };
|
|
129
|
+
delete next[key];
|
|
130
|
+
draft.value.tokens = next;
|
|
131
|
+
dirty.value = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const modifiedTotal = computed(() => Object.keys(draft.value.tokens).length);
|
|
135
|
+
|
|
136
|
+
// --- Metadata edits ---------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function onMetaChange(): void { dirty.value = true; }
|
|
139
|
+
|
|
140
|
+
// Available parent themes — built-in only (custom-as-parent gets complex)
|
|
141
|
+
const parentOptions = computed(() => themesApi.data.value?.builtIn.map((t) => ({ id: t.id, name: t.name })) ?? []);
|
|
142
|
+
|
|
143
|
+
// Available pair candidates — same family, opposite mode, custom themes only
|
|
144
|
+
const pairCandidates = computed(() =>
|
|
145
|
+
(themesApi.data.value?.custom ?? []).filter(
|
|
146
|
+
(t) => t.family === draft.value.family && t.isDark !== draft.value.isDark && t.id !== draft.value.id,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// --- Save / cancel / export -----------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Save the draft. If `apply` is true, ALSO set this theme as the
|
|
154
|
+
* instance default in the same await chain — must happen BEFORE the
|
|
155
|
+
* create-mode router.replace, otherwise the navigation could unmount
|
|
156
|
+
* the component mid-PUT and lose the apply.
|
|
157
|
+
*/
|
|
158
|
+
async function save({ apply = false }: { apply?: boolean } = {}): Promise<void> {
|
|
159
|
+
saving.value = true;
|
|
160
|
+
error.value = null;
|
|
161
|
+
try {
|
|
162
|
+
const payload = {
|
|
163
|
+
id: draft.value.id,
|
|
164
|
+
name: draft.value.name,
|
|
165
|
+
description: draft.value.description,
|
|
166
|
+
family: draft.value.family,
|
|
167
|
+
isDark: draft.value.isDark,
|
|
168
|
+
pairId: draft.value.pairId,
|
|
169
|
+
parentTheme: draft.value.parentTheme,
|
|
170
|
+
tokens: draft.value.tokens,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
let savedId: string;
|
|
174
|
+
if (isCreating) {
|
|
175
|
+
const created = await $fetch('/api/admin/themes', {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
body: payload,
|
|
178
|
+
});
|
|
179
|
+
savedId = (created as { id: string }).id;
|
|
180
|
+
} else {
|
|
181
|
+
// Cast: Nuxt's typed-route inference for dynamic URLs picks the
|
|
182
|
+
// narrowest method overload (GET) — same workaround used in
|
|
183
|
+
// learn/[slug]/edit.vue.
|
|
184
|
+
await ($fetch as (url: string, opts: Record<string, unknown>) => Promise<unknown>)(
|
|
185
|
+
`/api/admin/themes/${draft.value.id}`,
|
|
186
|
+
{ method: 'PUT', body: payload },
|
|
187
|
+
);
|
|
188
|
+
savedId = draft.value.id;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Apply BEFORE refresh/navigation so the navigate doesn't unmount us
|
|
192
|
+
// mid-PUT (would lose the apply + the success toast).
|
|
193
|
+
if (apply) {
|
|
194
|
+
await $fetch('/api/admin/settings', {
|
|
195
|
+
method: 'PUT',
|
|
196
|
+
body: { key: 'theme.default', value: `cpub-custom-${savedId}` },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
notify(apply ? 'Saved & applied' : (isCreating ? 'Theme created' : 'Saved'), 'success');
|
|
201
|
+
dirty.value = false;
|
|
202
|
+
await themesApi.refresh();
|
|
203
|
+
|
|
204
|
+
// Navigate LAST so all the awaits above have observable effects.
|
|
205
|
+
if (isCreating) {
|
|
206
|
+
router.replace(`/admin/theme/edit/${savedId}`);
|
|
207
|
+
}
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const msg = err instanceof Error ? err.message : 'Save failed';
|
|
210
|
+
error.value = msg;
|
|
211
|
+
notify(msg, 'error');
|
|
212
|
+
} finally {
|
|
213
|
+
saving.value = false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function applyAndSave(): Promise<void> {
|
|
218
|
+
await save({ apply: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function exportTheme(): void {
|
|
222
|
+
// Snapshot the in-progress draft (unsaved tokens included) so the
|
|
223
|
+
// admin can export-while-editing without committing first.
|
|
224
|
+
downloadThemeFile({
|
|
225
|
+
id: draft.value.id,
|
|
226
|
+
name: draft.value.name,
|
|
227
|
+
description: draft.value.description,
|
|
228
|
+
family: draft.value.family,
|
|
229
|
+
isDark: draft.value.isDark,
|
|
230
|
+
pairId: draft.value.pairId,
|
|
231
|
+
parentTheme: draft.value.parentTheme,
|
|
232
|
+
tokens: draft.value.tokens,
|
|
233
|
+
createdAt: draft.value.createdAt ?? new Date().toISOString(),
|
|
234
|
+
updatedAt: new Date().toISOString(),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function cancel(): void {
|
|
239
|
+
if (dirty.value && !confirm('Discard unsaved changes?')) return;
|
|
240
|
+
router.push('/admin/theme');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function notify(msg: string, tone: 'success' | 'error'): void {
|
|
244
|
+
toast.value = { msg, tone };
|
|
245
|
+
setTimeout(() => { toast.value = null; }, 2400);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Browser leave guard — warn before refresh/close when there are unsaved changes
|
|
249
|
+
function beforeUnloadGuard(e: BeforeUnloadEvent): void {
|
|
250
|
+
if (dirty.value) {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
e.returnValue = '';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
onMounted(() => {
|
|
256
|
+
if (typeof window !== 'undefined') window.addEventListener('beforeunload', beforeUnloadGuard);
|
|
257
|
+
});
|
|
258
|
+
onBeforeUnmount(() => {
|
|
259
|
+
if (typeof window !== 'undefined') window.removeEventListener('beforeunload', beforeUnloadGuard);
|
|
260
|
+
});
|
|
261
|
+
</script>
|
|
262
|
+
|
|
263
|
+
<template>
|
|
264
|
+
<div class="theme-editor">
|
|
265
|
+
<header class="theme-editor-toolbar">
|
|
266
|
+
<button
|
|
267
|
+
class="cpub-btn cpub-btn-sm theme-editor-back"
|
|
268
|
+
:title="dirty ? 'You have unsaved changes' : 'Back to themes list'"
|
|
269
|
+
@click="cancel"
|
|
270
|
+
>
|
|
271
|
+
<i class="fa-solid fa-arrow-left" aria-hidden="true" />
|
|
272
|
+
<span>Themes</span>
|
|
273
|
+
<span v-if="dirty" class="theme-editor-dirty-dot" aria-label="unsaved changes"></span>
|
|
274
|
+
</button>
|
|
275
|
+
|
|
276
|
+
<div class="theme-editor-meta">
|
|
277
|
+
<label class="theme-editor-field theme-editor-field-name">
|
|
278
|
+
<span class="theme-editor-field-label">Name</span>
|
|
279
|
+
<input
|
|
280
|
+
v-model="draft.name"
|
|
281
|
+
class="theme-editor-input theme-editor-input-name"
|
|
282
|
+
type="text"
|
|
283
|
+
placeholder="My theme"
|
|
284
|
+
@input="onMetaChange"
|
|
285
|
+
/>
|
|
286
|
+
</label>
|
|
287
|
+
|
|
288
|
+
<label class="theme-editor-field">
|
|
289
|
+
<span class="theme-editor-field-label">ID</span>
|
|
290
|
+
<input
|
|
291
|
+
v-model="draft.id"
|
|
292
|
+
class="theme-editor-input theme-editor-input-id"
|
|
293
|
+
type="text"
|
|
294
|
+
placeholder="my-theme"
|
|
295
|
+
:disabled="!isCreating"
|
|
296
|
+
@input="onMetaChange"
|
|
297
|
+
/>
|
|
298
|
+
</label>
|
|
299
|
+
|
|
300
|
+
<label class="theme-editor-field">
|
|
301
|
+
<span class="theme-editor-field-label">Family</span>
|
|
302
|
+
<input
|
|
303
|
+
v-model="draft.family"
|
|
304
|
+
class="theme-editor-input theme-editor-input-family"
|
|
305
|
+
type="text"
|
|
306
|
+
placeholder="custom"
|
|
307
|
+
@input="onMetaChange"
|
|
308
|
+
/>
|
|
309
|
+
</label>
|
|
310
|
+
|
|
311
|
+
<label class="theme-editor-field">
|
|
312
|
+
<span class="theme-editor-field-label">Inherits from</span>
|
|
313
|
+
<select v-model="draft.parentTheme" class="theme-editor-input" @change="onMetaChange">
|
|
314
|
+
<option v-for="p in parentOptions" :key="p.id" :value="p.id">{{ p.name }}</option>
|
|
315
|
+
</select>
|
|
316
|
+
</label>
|
|
317
|
+
|
|
318
|
+
<label class="theme-editor-field theme-editor-field-toggle">
|
|
319
|
+
<span class="theme-editor-field-label">Mode</span>
|
|
320
|
+
<div class="theme-editor-mode-pill" role="group">
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
class="theme-editor-mode-btn"
|
|
324
|
+
:class="{ active: !draft.isDark }"
|
|
325
|
+
@click="(() => { draft.isDark = false; onMetaChange(); })()"
|
|
326
|
+
>Light</button>
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
class="theme-editor-mode-btn"
|
|
330
|
+
:class="{ active: draft.isDark }"
|
|
331
|
+
@click="(() => { draft.isDark = true; onMetaChange(); })()"
|
|
332
|
+
>Dark</button>
|
|
333
|
+
</div>
|
|
334
|
+
</label>
|
|
335
|
+
|
|
336
|
+
<label v-if="pairCandidates.length" class="theme-editor-field">
|
|
337
|
+
<span class="theme-editor-field-label">Pair with</span>
|
|
338
|
+
<select v-model="draft.pairId" class="theme-editor-input" @change="onMetaChange">
|
|
339
|
+
<option :value="undefined">— none —</option>
|
|
340
|
+
<option v-for="p in pairCandidates" :key="p.id" :value="p.id">{{ p.name }}</option>
|
|
341
|
+
</select>
|
|
342
|
+
</label>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<div class="theme-editor-actions">
|
|
346
|
+
<span v-if="modifiedTotal > 0" class="theme-editor-modified">
|
|
347
|
+
{{ modifiedTotal }} token{{ modifiedTotal === 1 ? '' : 's' }} customized
|
|
348
|
+
</span>
|
|
349
|
+
<button class="cpub-btn cpub-btn-sm" @click="exportTheme" title="Download .cpub-theme.json">
|
|
350
|
+
<i class="fa-solid fa-file-export" aria-hidden="true" /> Export
|
|
351
|
+
</button>
|
|
352
|
+
<button class="cpub-btn cpub-btn-sm" :disabled="saving || !dirty" @click="() => save()">
|
|
353
|
+
<i :class="['fa-solid', saving ? 'fa-circle-notch fa-spin' : 'fa-floppy-disk']" aria-hidden="true" />
|
|
354
|
+
{{ saving ? 'Saving…' : 'Save' }}
|
|
355
|
+
</button>
|
|
356
|
+
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="saving" @click="applyAndSave">
|
|
357
|
+
<i :class="['fa-solid', saving ? 'fa-circle-notch fa-spin' : 'fa-rocket']" aria-hidden="true" />
|
|
358
|
+
{{ saving ? 'Applying…' : 'Save & apply' }}
|
|
359
|
+
</button>
|
|
360
|
+
</div>
|
|
361
|
+
</header>
|
|
362
|
+
|
|
363
|
+
<p v-if="error" class="theme-editor-error">
|
|
364
|
+
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true" /> {{ error }}
|
|
365
|
+
</p>
|
|
366
|
+
|
|
367
|
+
<textarea
|
|
368
|
+
v-model="draft.description"
|
|
369
|
+
class="theme-editor-description"
|
|
370
|
+
placeholder="Description — shown on the theme list (optional)"
|
|
371
|
+
rows="2"
|
|
372
|
+
@input="onMetaChange"
|
|
373
|
+
/>
|
|
374
|
+
|
|
375
|
+
<p v-if="loading" class="admin-empty">
|
|
376
|
+
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" /> Loading editor…
|
|
377
|
+
</p>
|
|
378
|
+
|
|
379
|
+
<div v-else class="theme-editor-body">
|
|
380
|
+
<section class="theme-editor-tokens" aria-label="Token editor">
|
|
381
|
+
<AdminThemeTokenGroup
|
|
382
|
+
v-for="group in TOKEN_GROUP_ORDER"
|
|
383
|
+
:key="group"
|
|
384
|
+
:group="group"
|
|
385
|
+
:label="TOKEN_GROUP_LABELS[group].label"
|
|
386
|
+
:icon="TOKEN_GROUP_LABELS[group].icon"
|
|
387
|
+
:description="TOKEN_GROUP_LABELS[group].description"
|
|
388
|
+
:specs="groups[group]"
|
|
389
|
+
:tokens="draft.tokens"
|
|
390
|
+
:open="group === 'surfaces' || group === 'accent'"
|
|
391
|
+
@update="updateToken"
|
|
392
|
+
@reset="resetToken"
|
|
393
|
+
/>
|
|
394
|
+
</section>
|
|
395
|
+
|
|
396
|
+
<AdminThemePreviewPane
|
|
397
|
+
class="theme-editor-preview"
|
|
398
|
+
:tokens="draft.tokens"
|
|
399
|
+
:parent-theme="draft.parentTheme"
|
|
400
|
+
:is-dark="draft.isDark"
|
|
401
|
+
/>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<div v-if="toast" class="admin-theme-toast" :class="`tone-${toast.tone}`">
|
|
405
|
+
<i :class="['fa-solid', toast.tone === 'success' ? 'fa-check' : 'fa-triangle-exclamation']" aria-hidden="true" />
|
|
406
|
+
{{ toast.msg }}
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
</template>
|
|
410
|
+
|
|
411
|
+
<style scoped>
|
|
412
|
+
.theme-editor {
|
|
413
|
+
display: flex;
|
|
414
|
+
flex-direction: column;
|
|
415
|
+
margin: calc(-1 * var(--space-6));
|
|
416
|
+
min-height: calc(100vh - var(--nav-height));
|
|
417
|
+
background: var(--bg);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* Toolbar */
|
|
421
|
+
.theme-editor-toolbar {
|
|
422
|
+
display: flex;
|
|
423
|
+
align-items: center;
|
|
424
|
+
gap: var(--space-3);
|
|
425
|
+
padding: var(--space-3) var(--space-4);
|
|
426
|
+
background: var(--surface);
|
|
427
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
428
|
+
flex-wrap: wrap;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.theme-editor-back {
|
|
432
|
+
flex-shrink: 0;
|
|
433
|
+
position: relative;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.theme-editor-dirty-dot {
|
|
437
|
+
display: inline-block;
|
|
438
|
+
width: 6px;
|
|
439
|
+
height: 6px;
|
|
440
|
+
background: var(--accent);
|
|
441
|
+
border-radius: var(--radius-full);
|
|
442
|
+
margin-left: 4px;
|
|
443
|
+
/* Subtle pulse so it draws the eye without being noisy */
|
|
444
|
+
animation: theme-editor-dirty-pulse 2s ease-in-out infinite;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
@keyframes theme-editor-dirty-pulse {
|
|
448
|
+
0%, 100% { opacity: 1; }
|
|
449
|
+
50% { opacity: 0.4; }
|
|
450
|
+
}
|
|
451
|
+
@media (prefers-reduced-motion: reduce) {
|
|
452
|
+
.theme-editor-dirty-dot { animation: none; }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.theme-editor-input-name {
|
|
456
|
+
font-weight: var(--font-weight-semibold);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.theme-editor-meta {
|
|
460
|
+
display: flex;
|
|
461
|
+
align-items: flex-end;
|
|
462
|
+
gap: var(--space-3);
|
|
463
|
+
flex-wrap: wrap;
|
|
464
|
+
flex: 1;
|
|
465
|
+
min-width: 0;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.theme-editor-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
469
|
+
.theme-editor-field-label {
|
|
470
|
+
font-family: var(--font-mono);
|
|
471
|
+
font-size: 10px;
|
|
472
|
+
letter-spacing: var(--tracking-wide);
|
|
473
|
+
text-transform: uppercase;
|
|
474
|
+
color: var(--text-faint);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.theme-editor-input {
|
|
478
|
+
background: var(--surface2);
|
|
479
|
+
color: var(--text);
|
|
480
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
481
|
+
padding: 6px 10px;
|
|
482
|
+
font-size: var(--text-sm);
|
|
483
|
+
font-family: var(--font-body);
|
|
484
|
+
min-width: 0;
|
|
485
|
+
}
|
|
486
|
+
.theme-editor-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
|
|
487
|
+
.theme-editor-input:disabled { color: var(--text-faint); background: var(--surface); }
|
|
488
|
+
|
|
489
|
+
.theme-editor-input-name { min-width: 160px; }
|
|
490
|
+
.theme-editor-input-id { font-family: var(--font-mono); min-width: 120px; max-width: 180px; }
|
|
491
|
+
.theme-editor-input-family { font-family: var(--font-mono); min-width: 100px; max-width: 140px; }
|
|
492
|
+
|
|
493
|
+
.theme-editor-mode-pill {
|
|
494
|
+
display: inline-flex;
|
|
495
|
+
background: var(--surface2);
|
|
496
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
497
|
+
padding: 2px;
|
|
498
|
+
}
|
|
499
|
+
.theme-editor-mode-btn {
|
|
500
|
+
background: none;
|
|
501
|
+
border: 0;
|
|
502
|
+
padding: 4px 10px;
|
|
503
|
+
font-family: var(--font-mono);
|
|
504
|
+
font-size: 11px;
|
|
505
|
+
letter-spacing: var(--tracking-wide);
|
|
506
|
+
text-transform: uppercase;
|
|
507
|
+
color: var(--text-dim);
|
|
508
|
+
cursor: pointer;
|
|
509
|
+
}
|
|
510
|
+
.theme-editor-mode-btn.active { background: var(--surface); color: var(--accent); }
|
|
511
|
+
|
|
512
|
+
.theme-editor-actions {
|
|
513
|
+
display: flex;
|
|
514
|
+
align-items: center;
|
|
515
|
+
gap: var(--space-2);
|
|
516
|
+
margin-left: auto;
|
|
517
|
+
flex-wrap: wrap;
|
|
518
|
+
}
|
|
519
|
+
.theme-editor-modified {
|
|
520
|
+
font-family: var(--font-mono);
|
|
521
|
+
font-size: var(--text-label);
|
|
522
|
+
letter-spacing: var(--tracking-wide);
|
|
523
|
+
text-transform: uppercase;
|
|
524
|
+
color: var(--accent);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.theme-editor-error {
|
|
528
|
+
margin: 0;
|
|
529
|
+
padding: var(--space-3) var(--space-4);
|
|
530
|
+
background: var(--red-bg);
|
|
531
|
+
border-bottom: var(--border-width-default) solid var(--red);
|
|
532
|
+
color: var(--red);
|
|
533
|
+
font-size: var(--text-sm);
|
|
534
|
+
display: flex;
|
|
535
|
+
align-items: center;
|
|
536
|
+
gap: var(--space-2);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.theme-editor-description {
|
|
540
|
+
background: var(--surface);
|
|
541
|
+
border: 0;
|
|
542
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
543
|
+
padding: var(--space-3) var(--space-4);
|
|
544
|
+
font-size: var(--text-sm);
|
|
545
|
+
font-family: var(--font-body);
|
|
546
|
+
color: var(--text);
|
|
547
|
+
resize: vertical;
|
|
548
|
+
width: 100%;
|
|
549
|
+
}
|
|
550
|
+
.theme-editor-description:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
551
|
+
|
|
552
|
+
.theme-editor-body {
|
|
553
|
+
display: grid;
|
|
554
|
+
grid-template-columns: minmax(320px, 380px) 1fr;
|
|
555
|
+
flex: 1;
|
|
556
|
+
min-height: 0;
|
|
557
|
+
overflow: hidden;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
.theme-editor-tokens {
|
|
561
|
+
background: var(--surface);
|
|
562
|
+
border-right: var(--border-width-default) solid var(--border);
|
|
563
|
+
overflow: auto;
|
|
564
|
+
min-height: 0;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.theme-editor-preview {
|
|
568
|
+
min-height: 0;
|
|
569
|
+
overflow: hidden;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/* Toast reuses the list page's style */
|
|
573
|
+
.admin-theme-toast {
|
|
574
|
+
position: fixed;
|
|
575
|
+
top: calc(var(--nav-height) + var(--space-4));
|
|
576
|
+
right: var(--space-4);
|
|
577
|
+
padding: var(--space-2) var(--space-4);
|
|
578
|
+
font-size: var(--text-sm);
|
|
579
|
+
font-weight: var(--font-weight-semibold);
|
|
580
|
+
z-index: var(--z-toast);
|
|
581
|
+
border: var(--border-width-default) solid var(--border);
|
|
582
|
+
box-shadow: var(--shadow-md);
|
|
583
|
+
display: flex;
|
|
584
|
+
align-items: center;
|
|
585
|
+
gap: var(--space-2);
|
|
586
|
+
color: var(--color-text-inverse);
|
|
587
|
+
}
|
|
588
|
+
.admin-theme-toast.tone-success { background: var(--green); }
|
|
589
|
+
.admin-theme-toast.tone-error { background: var(--red); }
|
|
590
|
+
|
|
591
|
+
@media (max-width: 900px) {
|
|
592
|
+
.theme-editor-body { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
|
|
593
|
+
.theme-editor-tokens { border-right: 0; border-bottom: var(--border-width-default) solid var(--border); }
|
|
594
|
+
}
|
|
595
|
+
</style>
|