@commonpub/layer 0.21.22 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/admin/theme/AdminThemeFamilyCard.vue +277 -0
- package/components/admin/theme/AdminThemeOverridesPanel.vue +222 -0
- package/components/admin/theme/AdminThemePreviewPane.vue +187 -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 +547 -0
- package/pages/admin/theme/index.vue +424 -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,547 @@
|
|
|
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
|
+
async function save(): Promise<void> {
|
|
153
|
+
saving.value = true;
|
|
154
|
+
error.value = null;
|
|
155
|
+
try {
|
|
156
|
+
const payload = {
|
|
157
|
+
id: draft.value.id,
|
|
158
|
+
name: draft.value.name,
|
|
159
|
+
description: draft.value.description,
|
|
160
|
+
family: draft.value.family,
|
|
161
|
+
isDark: draft.value.isDark,
|
|
162
|
+
pairId: draft.value.pairId,
|
|
163
|
+
parentTheme: draft.value.parentTheme,
|
|
164
|
+
tokens: draft.value.tokens,
|
|
165
|
+
};
|
|
166
|
+
if (isCreating) {
|
|
167
|
+
const created = await $fetch('/api/admin/themes', {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
body: payload,
|
|
170
|
+
});
|
|
171
|
+
notify('Theme created', 'success');
|
|
172
|
+
dirty.value = false;
|
|
173
|
+
await themesApi.refresh();
|
|
174
|
+
router.replace(`/admin/theme/edit/${(created as { id: string }).id}`);
|
|
175
|
+
} else {
|
|
176
|
+
// Cast: Nuxt's typed-route inference for dynamic URLs picks the
|
|
177
|
+
// narrowest method overload (GET) — same workaround used in
|
|
178
|
+
// learn/[slug]/edit.vue.
|
|
179
|
+
await ($fetch as (url: string, opts: Record<string, unknown>) => Promise<unknown>)(
|
|
180
|
+
`/api/admin/themes/${draft.value.id}`,
|
|
181
|
+
{ method: 'PUT', body: payload },
|
|
182
|
+
);
|
|
183
|
+
notify('Saved', 'success');
|
|
184
|
+
dirty.value = false;
|
|
185
|
+
await themesApi.refresh();
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const msg = err instanceof Error ? err.message : 'Save failed';
|
|
189
|
+
error.value = msg;
|
|
190
|
+
notify(msg, 'error');
|
|
191
|
+
} finally {
|
|
192
|
+
saving.value = false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function applyAndSave(): Promise<void> {
|
|
197
|
+
await save();
|
|
198
|
+
if (error.value) return;
|
|
199
|
+
await $fetch('/api/admin/settings', {
|
|
200
|
+
method: 'PUT',
|
|
201
|
+
body: { key: 'theme.default', value: `cpub-custom-${draft.value.id}` },
|
|
202
|
+
});
|
|
203
|
+
notify('Saved and applied instance-wide', 'success');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function exportTheme(): void {
|
|
207
|
+
// Snapshot the in-progress draft (unsaved tokens included) so the
|
|
208
|
+
// admin can export-while-editing without committing first.
|
|
209
|
+
downloadThemeFile({
|
|
210
|
+
id: draft.value.id,
|
|
211
|
+
name: draft.value.name,
|
|
212
|
+
description: draft.value.description,
|
|
213
|
+
family: draft.value.family,
|
|
214
|
+
isDark: draft.value.isDark,
|
|
215
|
+
pairId: draft.value.pairId,
|
|
216
|
+
parentTheme: draft.value.parentTheme,
|
|
217
|
+
tokens: draft.value.tokens,
|
|
218
|
+
createdAt: draft.value.createdAt ?? new Date().toISOString(),
|
|
219
|
+
updatedAt: new Date().toISOString(),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function cancel(): void {
|
|
224
|
+
if (dirty.value && !confirm('Discard unsaved changes?')) return;
|
|
225
|
+
router.push('/admin/theme');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function notify(msg: string, tone: 'success' | 'error'): void {
|
|
229
|
+
toast.value = { msg, tone };
|
|
230
|
+
setTimeout(() => { toast.value = null; }, 2400);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Browser leave guard — warn before refresh/close when there are unsaved changes
|
|
234
|
+
function beforeUnloadGuard(e: BeforeUnloadEvent): void {
|
|
235
|
+
if (dirty.value) {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
e.returnValue = '';
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
onMounted(() => {
|
|
241
|
+
if (typeof window !== 'undefined') window.addEventListener('beforeunload', beforeUnloadGuard);
|
|
242
|
+
});
|
|
243
|
+
onBeforeUnmount(() => {
|
|
244
|
+
if (typeof window !== 'undefined') window.removeEventListener('beforeunload', beforeUnloadGuard);
|
|
245
|
+
});
|
|
246
|
+
</script>
|
|
247
|
+
|
|
248
|
+
<template>
|
|
249
|
+
<div class="theme-editor">
|
|
250
|
+
<header class="theme-editor-toolbar">
|
|
251
|
+
<button class="cpub-btn cpub-btn-sm theme-editor-back" @click="cancel">
|
|
252
|
+
<i class="fa-solid fa-arrow-left" aria-hidden="true" />
|
|
253
|
+
<span>Themes</span>
|
|
254
|
+
</button>
|
|
255
|
+
|
|
256
|
+
<div class="theme-editor-meta">
|
|
257
|
+
<label class="theme-editor-field theme-editor-field-name">
|
|
258
|
+
<span class="theme-editor-field-label">Name</span>
|
|
259
|
+
<input
|
|
260
|
+
v-model="draft.name"
|
|
261
|
+
class="theme-editor-input theme-editor-input-name"
|
|
262
|
+
type="text"
|
|
263
|
+
placeholder="My theme"
|
|
264
|
+
@input="onMetaChange"
|
|
265
|
+
/>
|
|
266
|
+
</label>
|
|
267
|
+
|
|
268
|
+
<label class="theme-editor-field">
|
|
269
|
+
<span class="theme-editor-field-label">ID</span>
|
|
270
|
+
<input
|
|
271
|
+
v-model="draft.id"
|
|
272
|
+
class="theme-editor-input theme-editor-input-id"
|
|
273
|
+
type="text"
|
|
274
|
+
placeholder="my-theme"
|
|
275
|
+
:disabled="!isCreating"
|
|
276
|
+
@input="onMetaChange"
|
|
277
|
+
/>
|
|
278
|
+
</label>
|
|
279
|
+
|
|
280
|
+
<label class="theme-editor-field">
|
|
281
|
+
<span class="theme-editor-field-label">Family</span>
|
|
282
|
+
<input
|
|
283
|
+
v-model="draft.family"
|
|
284
|
+
class="theme-editor-input theme-editor-input-family"
|
|
285
|
+
type="text"
|
|
286
|
+
placeholder="custom"
|
|
287
|
+
@input="onMetaChange"
|
|
288
|
+
/>
|
|
289
|
+
</label>
|
|
290
|
+
|
|
291
|
+
<label class="theme-editor-field">
|
|
292
|
+
<span class="theme-editor-field-label">Inherits from</span>
|
|
293
|
+
<select v-model="draft.parentTheme" class="theme-editor-input" @change="onMetaChange">
|
|
294
|
+
<option v-for="p in parentOptions" :key="p.id" :value="p.id">{{ p.name }}</option>
|
|
295
|
+
</select>
|
|
296
|
+
</label>
|
|
297
|
+
|
|
298
|
+
<label class="theme-editor-field theme-editor-field-toggle">
|
|
299
|
+
<span class="theme-editor-field-label">Mode</span>
|
|
300
|
+
<div class="theme-editor-mode-pill" role="group">
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
class="theme-editor-mode-btn"
|
|
304
|
+
:class="{ active: !draft.isDark }"
|
|
305
|
+
@click="(() => { draft.isDark = false; onMetaChange(); })()"
|
|
306
|
+
>Light</button>
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
class="theme-editor-mode-btn"
|
|
310
|
+
:class="{ active: draft.isDark }"
|
|
311
|
+
@click="(() => { draft.isDark = true; onMetaChange(); })()"
|
|
312
|
+
>Dark</button>
|
|
313
|
+
</div>
|
|
314
|
+
</label>
|
|
315
|
+
|
|
316
|
+
<label v-if="pairCandidates.length" class="theme-editor-field">
|
|
317
|
+
<span class="theme-editor-field-label">Pair with</span>
|
|
318
|
+
<select v-model="draft.pairId" class="theme-editor-input" @change="onMetaChange">
|
|
319
|
+
<option :value="undefined">— none —</option>
|
|
320
|
+
<option v-for="p in pairCandidates" :key="p.id" :value="p.id">{{ p.name }}</option>
|
|
321
|
+
</select>
|
|
322
|
+
</label>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<div class="theme-editor-actions">
|
|
326
|
+
<span v-if="modifiedTotal > 0" class="theme-editor-modified">
|
|
327
|
+
{{ modifiedTotal }} token{{ modifiedTotal === 1 ? '' : 's' }} customized
|
|
328
|
+
</span>
|
|
329
|
+
<button class="cpub-btn cpub-btn-sm" @click="exportTheme" title="Download .cpub-theme.json">
|
|
330
|
+
<i class="fa-solid fa-file-export" aria-hidden="true" /> Export
|
|
331
|
+
</button>
|
|
332
|
+
<button class="cpub-btn cpub-btn-sm" :disabled="saving || !dirty" @click="save">
|
|
333
|
+
<i class="fa-solid fa-floppy-disk" aria-hidden="true" /> Save
|
|
334
|
+
</button>
|
|
335
|
+
<button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="saving" @click="applyAndSave">
|
|
336
|
+
<i class="fa-solid fa-rocket" aria-hidden="true" /> Save & apply
|
|
337
|
+
</button>
|
|
338
|
+
</div>
|
|
339
|
+
</header>
|
|
340
|
+
|
|
341
|
+
<p v-if="error" class="theme-editor-error">
|
|
342
|
+
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true" /> {{ error }}
|
|
343
|
+
</p>
|
|
344
|
+
|
|
345
|
+
<textarea
|
|
346
|
+
v-model="draft.description"
|
|
347
|
+
class="theme-editor-description"
|
|
348
|
+
placeholder="Description — shown on the theme list (optional)"
|
|
349
|
+
rows="2"
|
|
350
|
+
@input="onMetaChange"
|
|
351
|
+
/>
|
|
352
|
+
|
|
353
|
+
<p v-if="loading" class="admin-empty">
|
|
354
|
+
<i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" /> Loading editor…
|
|
355
|
+
</p>
|
|
356
|
+
|
|
357
|
+
<div v-else class="theme-editor-body">
|
|
358
|
+
<section class="theme-editor-tokens" aria-label="Token editor">
|
|
359
|
+
<AdminThemeTokenGroup
|
|
360
|
+
v-for="group in TOKEN_GROUP_ORDER"
|
|
361
|
+
:key="group"
|
|
362
|
+
:group="group"
|
|
363
|
+
:label="TOKEN_GROUP_LABELS[group].label"
|
|
364
|
+
:icon="TOKEN_GROUP_LABELS[group].icon"
|
|
365
|
+
:description="TOKEN_GROUP_LABELS[group].description"
|
|
366
|
+
:specs="groups[group]"
|
|
367
|
+
:tokens="draft.tokens"
|
|
368
|
+
:open="group === 'surfaces' || group === 'accent'"
|
|
369
|
+
@update="updateToken"
|
|
370
|
+
@reset="resetToken"
|
|
371
|
+
/>
|
|
372
|
+
</section>
|
|
373
|
+
|
|
374
|
+
<AdminThemePreviewPane
|
|
375
|
+
class="theme-editor-preview"
|
|
376
|
+
:tokens="draft.tokens"
|
|
377
|
+
:parent-theme="draft.parentTheme"
|
|
378
|
+
:is-dark="draft.isDark"
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
<div v-if="toast" class="admin-theme-toast" :class="`tone-${toast.tone}`">
|
|
383
|
+
<i :class="['fa-solid', toast.tone === 'success' ? 'fa-check' : 'fa-triangle-exclamation']" aria-hidden="true" />
|
|
384
|
+
{{ toast.msg }}
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
</template>
|
|
388
|
+
|
|
389
|
+
<style scoped>
|
|
390
|
+
.theme-editor {
|
|
391
|
+
display: flex;
|
|
392
|
+
flex-direction: column;
|
|
393
|
+
margin: calc(-1 * var(--space-6));
|
|
394
|
+
min-height: calc(100vh - var(--nav-height));
|
|
395
|
+
background: var(--bg);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* Toolbar */
|
|
399
|
+
.theme-editor-toolbar {
|
|
400
|
+
display: flex;
|
|
401
|
+
align-items: center;
|
|
402
|
+
gap: var(--space-3);
|
|
403
|
+
padding: var(--space-3) var(--space-4);
|
|
404
|
+
background: var(--surface);
|
|
405
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
406
|
+
flex-wrap: wrap;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.theme-editor-back { flex-shrink: 0; }
|
|
410
|
+
|
|
411
|
+
.theme-editor-meta {
|
|
412
|
+
display: flex;
|
|
413
|
+
align-items: flex-end;
|
|
414
|
+
gap: var(--space-3);
|
|
415
|
+
flex-wrap: wrap;
|
|
416
|
+
flex: 1;
|
|
417
|
+
min-width: 0;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.theme-editor-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
421
|
+
.theme-editor-field-label {
|
|
422
|
+
font-family: var(--font-mono);
|
|
423
|
+
font-size: 10px;
|
|
424
|
+
letter-spacing: var(--tracking-wide);
|
|
425
|
+
text-transform: uppercase;
|
|
426
|
+
color: var(--text-faint);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.theme-editor-input {
|
|
430
|
+
background: var(--surface2);
|
|
431
|
+
color: var(--text);
|
|
432
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
433
|
+
padding: 6px 10px;
|
|
434
|
+
font-size: var(--text-sm);
|
|
435
|
+
font-family: var(--font-body);
|
|
436
|
+
min-width: 0;
|
|
437
|
+
}
|
|
438
|
+
.theme-editor-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
|
|
439
|
+
.theme-editor-input:disabled { color: var(--text-faint); background: var(--surface); }
|
|
440
|
+
|
|
441
|
+
.theme-editor-input-name { min-width: 160px; }
|
|
442
|
+
.theme-editor-input-id { font-family: var(--font-mono); min-width: 120px; max-width: 180px; }
|
|
443
|
+
.theme-editor-input-family { font-family: var(--font-mono); min-width: 100px; max-width: 140px; }
|
|
444
|
+
|
|
445
|
+
.theme-editor-mode-pill {
|
|
446
|
+
display: inline-flex;
|
|
447
|
+
background: var(--surface2);
|
|
448
|
+
border: var(--border-width-thin) solid var(--border2);
|
|
449
|
+
padding: 2px;
|
|
450
|
+
}
|
|
451
|
+
.theme-editor-mode-btn {
|
|
452
|
+
background: none;
|
|
453
|
+
border: 0;
|
|
454
|
+
padding: 4px 10px;
|
|
455
|
+
font-family: var(--font-mono);
|
|
456
|
+
font-size: 11px;
|
|
457
|
+
letter-spacing: var(--tracking-wide);
|
|
458
|
+
text-transform: uppercase;
|
|
459
|
+
color: var(--text-dim);
|
|
460
|
+
cursor: pointer;
|
|
461
|
+
}
|
|
462
|
+
.theme-editor-mode-btn.active { background: var(--surface); color: var(--accent); }
|
|
463
|
+
|
|
464
|
+
.theme-editor-actions {
|
|
465
|
+
display: flex;
|
|
466
|
+
align-items: center;
|
|
467
|
+
gap: var(--space-2);
|
|
468
|
+
margin-left: auto;
|
|
469
|
+
flex-wrap: wrap;
|
|
470
|
+
}
|
|
471
|
+
.theme-editor-modified {
|
|
472
|
+
font-family: var(--font-mono);
|
|
473
|
+
font-size: var(--text-label);
|
|
474
|
+
letter-spacing: var(--tracking-wide);
|
|
475
|
+
text-transform: uppercase;
|
|
476
|
+
color: var(--accent);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
.theme-editor-error {
|
|
480
|
+
margin: 0;
|
|
481
|
+
padding: var(--space-3) var(--space-4);
|
|
482
|
+
background: var(--red-bg);
|
|
483
|
+
border-bottom: var(--border-width-default) solid var(--red);
|
|
484
|
+
color: var(--red);
|
|
485
|
+
font-size: var(--text-sm);
|
|
486
|
+
display: flex;
|
|
487
|
+
align-items: center;
|
|
488
|
+
gap: var(--space-2);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.theme-editor-description {
|
|
492
|
+
background: var(--surface);
|
|
493
|
+
border: 0;
|
|
494
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
495
|
+
padding: var(--space-3) var(--space-4);
|
|
496
|
+
font-size: var(--text-sm);
|
|
497
|
+
font-family: var(--font-body);
|
|
498
|
+
color: var(--text);
|
|
499
|
+
resize: vertical;
|
|
500
|
+
width: 100%;
|
|
501
|
+
}
|
|
502
|
+
.theme-editor-description:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
503
|
+
|
|
504
|
+
.theme-editor-body {
|
|
505
|
+
display: grid;
|
|
506
|
+
grid-template-columns: minmax(320px, 380px) 1fr;
|
|
507
|
+
flex: 1;
|
|
508
|
+
min-height: 0;
|
|
509
|
+
overflow: hidden;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.theme-editor-tokens {
|
|
513
|
+
background: var(--surface);
|
|
514
|
+
border-right: var(--border-width-default) solid var(--border);
|
|
515
|
+
overflow: auto;
|
|
516
|
+
min-height: 0;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.theme-editor-preview {
|
|
520
|
+
min-height: 0;
|
|
521
|
+
overflow: hidden;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/* Toast reuses the list page's style */
|
|
525
|
+
.admin-theme-toast {
|
|
526
|
+
position: fixed;
|
|
527
|
+
top: calc(var(--nav-height) + var(--space-4));
|
|
528
|
+
right: var(--space-4);
|
|
529
|
+
padding: var(--space-2) var(--space-4);
|
|
530
|
+
font-size: var(--text-sm);
|
|
531
|
+
font-weight: var(--font-weight-semibold);
|
|
532
|
+
z-index: var(--z-toast);
|
|
533
|
+
border: var(--border-width-default) solid var(--border);
|
|
534
|
+
box-shadow: var(--shadow-md);
|
|
535
|
+
display: flex;
|
|
536
|
+
align-items: center;
|
|
537
|
+
gap: var(--space-2);
|
|
538
|
+
color: var(--color-text-inverse);
|
|
539
|
+
}
|
|
540
|
+
.admin-theme-toast.tone-success { background: var(--green); }
|
|
541
|
+
.admin-theme-toast.tone-error { background: var(--red); }
|
|
542
|
+
|
|
543
|
+
@media (max-width: 900px) {
|
|
544
|
+
.theme-editor-body { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
|
|
545
|
+
.theme-editor-tokens { border-right: 0; border-bottom: var(--border-width-default) solid var(--border); }
|
|
546
|
+
}
|
|
547
|
+
</style>
|