@commonpub/layer 0.65.0 → 0.67.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/app.vue +14 -0
- package/components/admin/theme/studio/AdminThemeStudio.vue +170 -29
- package/package.json +7 -7
- package/pages/admin/theme/edit/[id].vue +135 -6
- package/pages/admin/theme/index.vue +89 -25
- package/theme/base.css +10 -0
- package/theme/components.css +9 -0
package/app.vue
CHANGED
|
@@ -4,4 +4,18 @@
|
|
|
4
4
|
<NuxtLayout>
|
|
5
5
|
<NuxtPage />
|
|
6
6
|
</NuxtLayout>
|
|
7
|
+
<!-- Film-grain overlay. Invisible unless the active theme sets --grain > 0 (default 0). -->
|
|
8
|
+
<div class="cpub-grain" aria-hidden="true" />
|
|
7
9
|
</template>
|
|
10
|
+
|
|
11
|
+
<style>
|
|
12
|
+
.cpub-grain {
|
|
13
|
+
position: fixed;
|
|
14
|
+
inset: 0;
|
|
15
|
+
pointer-events: none;
|
|
16
|
+
z-index: 9999;
|
|
17
|
+
opacity: var(--grain, 0);
|
|
18
|
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
|
19
|
+
}
|
|
20
|
+
@media print { .cpub-grain { display: none; } }
|
|
21
|
+
</style>
|
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
randomizeRecipe,
|
|
21
21
|
randomName,
|
|
22
22
|
buildPalette,
|
|
23
|
+
harmonyColors,
|
|
24
|
+
contrast,
|
|
25
|
+
wcag,
|
|
23
26
|
COLOR_VIBES,
|
|
24
27
|
TYPE_VIBES,
|
|
25
28
|
SHAPE_PRESETS,
|
|
@@ -30,7 +33,7 @@ import {
|
|
|
30
33
|
type HarmonyScheme,
|
|
31
34
|
} from '@commonpub/theme-studio';
|
|
32
35
|
|
|
33
|
-
const props = defineProps<{ recipe?: ThemeRecipe }>();
|
|
36
|
+
const props = defineProps<{ recipe?: ThemeRecipe; name?: string }>();
|
|
34
37
|
|
|
35
38
|
const emit = defineEmits<{
|
|
36
39
|
generate: [{
|
|
@@ -40,8 +43,9 @@ const emit = defineEmits<{
|
|
|
40
43
|
parentTheme: 'base' | 'dark';
|
|
41
44
|
isDark: boolean;
|
|
42
45
|
}];
|
|
43
|
-
finish: [];
|
|
46
|
+
finish: [{ apply: boolean }];
|
|
44
47
|
roll: [{ name: string }];
|
|
48
|
+
rename: [string];
|
|
45
49
|
}>();
|
|
46
50
|
|
|
47
51
|
const recipe = ref<ThemeRecipe>(props.recipe ? { ...props.recipe } : defaultRecipe());
|
|
@@ -50,7 +54,8 @@ const STEPS = [
|
|
|
50
54
|
{ kicker: 'Color', q: 'Pick a vibe, or your colors.' },
|
|
51
55
|
{ kicker: 'Type', q: 'Pick a type vibe, or fonts.' },
|
|
52
56
|
{ kicker: 'Shape', q: 'Rounded or sharp?' },
|
|
53
|
-
{ kicker: 'Feel', q: 'Spacing, density, motion.' },
|
|
57
|
+
{ kicker: 'Feel', q: 'Spacing, density, texture, motion.' },
|
|
58
|
+
{ kicker: 'Finish', q: 'Name it and save.' },
|
|
54
59
|
] as const;
|
|
55
60
|
const step = ref(0);
|
|
56
61
|
|
|
@@ -58,6 +63,17 @@ const colorTab = ref<'vibe' | 'custom'>(props.recipe ? 'custom' : 'vibe');
|
|
|
58
63
|
const typeTab = ref<'vibe' | 'custom'>(props.recipe ? 'custom' : 'vibe');
|
|
59
64
|
const colorVibe = ref(0);
|
|
60
65
|
const typeVibe = ref(0);
|
|
66
|
+
const useSecondary = ref(Boolean(props.recipe?.secondary));
|
|
67
|
+
const applyDefault = ref(false);
|
|
68
|
+
|
|
69
|
+
const SCHEMES: { val: HarmonyScheme; label: string }[] = [
|
|
70
|
+
{ val: 'analogous', label: 'Analogous' },
|
|
71
|
+
{ val: 'complementary', label: 'Complement' },
|
|
72
|
+
{ val: 'triadic', label: 'Triadic' },
|
|
73
|
+
{ val: 'split', label: 'Split' },
|
|
74
|
+
{ val: 'tetradic', label: 'Tetradic' },
|
|
75
|
+
{ val: 'monochrome', label: 'Mono' },
|
|
76
|
+
];
|
|
61
77
|
|
|
62
78
|
// --- Emit on every change ---------------------------------------------
|
|
63
79
|
|
|
@@ -73,22 +89,32 @@ function emitGenerate(): void {
|
|
|
73
89
|
}
|
|
74
90
|
watch(recipe, emitGenerate, { deep: true });
|
|
75
91
|
|
|
76
|
-
// ---
|
|
92
|
+
// --- Live WCAG audit (reassurance + catch extreme hand-picks) ----------
|
|
93
|
+
|
|
94
|
+
const auditTokens = computed(() => recipeToTokens(recipe.value).tokens);
|
|
95
|
+
function band(fg: string, bg: string): { ratio: string; band: string; ok: boolean } {
|
|
96
|
+
const r = contrast(fg, bg);
|
|
97
|
+
const b = wcag(r);
|
|
98
|
+
return { ratio: r.toFixed(1), band: b, ok: b !== 'FAIL' };
|
|
99
|
+
}
|
|
100
|
+
const auditText = computed(() => band(auditTokens.value['text']!, auditTokens.value['bg']!));
|
|
101
|
+
const auditLink = computed(() => band(auditTokens.value['color-link']!, auditTokens.value['bg']!));
|
|
102
|
+
|
|
103
|
+
// --- Vibe swatch + harmony-family previews ----------------------------
|
|
77
104
|
|
|
78
|
-
// The emitted CommonPub token set derives entirely from accent (hue+sat) +
|
|
79
|
-
// mode + the scale/shape/feel knobs — neutrals/text are accent-tinted. The
|
|
80
|
-
// harmony scheme only seeds the preview swatch family below; it does NOT
|
|
81
|
-
// change the generated theme (CommonPub has no secondary-accent token), so
|
|
82
|
-
// the wizard doesn't expose a scheme/secondary control. `scheme` stays on the
|
|
83
|
-
// recipe (from the vibe presets) for forward-compat.
|
|
84
105
|
function miniPal(accent: string, scheme: HarmonyScheme, mode: 'light' | 'dark'): string[] {
|
|
85
106
|
const p = buildPalette({ accent, scheme, mode }).sem;
|
|
86
|
-
return [p.accent, p.
|
|
107
|
+
return [p.accent, p.secondary, p.surface2, p.bg];
|
|
87
108
|
}
|
|
88
109
|
function palStrip(accent: string, scheme: HarmonyScheme, mode: 'light' | 'dark'): string[] {
|
|
89
110
|
const p = buildPalette({ accent, scheme, mode }).sem;
|
|
90
|
-
return [p.bg, p.surface, p.
|
|
111
|
+
return [p.bg, p.surface, p.accent, p.secondary, p.text];
|
|
91
112
|
}
|
|
113
|
+
/** The accent + its harmony companions — the "suggested family" strip. */
|
|
114
|
+
const familyStrip = computed<string[]>(() => [
|
|
115
|
+
recipe.value.accent,
|
|
116
|
+
...harmonyColors(recipe.value.accent, recipe.value.scheme),
|
|
117
|
+
]);
|
|
92
118
|
|
|
93
119
|
// --- Color actions -----------------------------------------------------
|
|
94
120
|
|
|
@@ -96,10 +122,52 @@ function applyPalette(accent: string, scheme: HarmonyScheme, mode: 'light' | 'da
|
|
|
96
122
|
recipe.value.accent = accent;
|
|
97
123
|
recipe.value.scheme = scheme;
|
|
98
124
|
recipe.value.mode = mode;
|
|
125
|
+
recipe.value.secondary = undefined;
|
|
126
|
+
useSecondary.value = false;
|
|
99
127
|
}
|
|
100
128
|
function onAccentHex(v: string): void {
|
|
101
129
|
if (/^#?[0-9a-fA-F]{6}$/.test(v)) recipe.value.accent = v[0] === '#' ? v : `#${v}`;
|
|
102
130
|
}
|
|
131
|
+
function onSecondaryHex(v: string): void {
|
|
132
|
+
if (/^#?[0-9a-fA-F]{6}$/.test(v)) recipe.value.secondary = v[0] === '#' ? v : `#${v}`;
|
|
133
|
+
}
|
|
134
|
+
function toggleSecondary(): void {
|
|
135
|
+
useSecondary.value = !useSecondary.value;
|
|
136
|
+
recipe.value.secondary = useSecondary.value ? recipe.value.secondary ?? '#8b5cf6' : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Pull a dominant, vivid-ish accent out of an uploaded image (client-only). */
|
|
140
|
+
function onImagePick(e: Event): void {
|
|
141
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
142
|
+
if (!file || typeof document === 'undefined') return;
|
|
143
|
+
const url = URL.createObjectURL(file);
|
|
144
|
+
const img = new Image();
|
|
145
|
+
img.onload = (): void => {
|
|
146
|
+
const cv = document.createElement('canvas');
|
|
147
|
+
const w = (cv.width = 48);
|
|
148
|
+
const h = (cv.height = 48);
|
|
149
|
+
const ctx = cv.getContext('2d');
|
|
150
|
+
URL.revokeObjectURL(url);
|
|
151
|
+
if (!ctx) return;
|
|
152
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
153
|
+
const d = ctx.getImageData(0, 0, w, h).data;
|
|
154
|
+
let best: [number, number, number] | null = null;
|
|
155
|
+
let bestScore = -1;
|
|
156
|
+
for (let i = 0; i < d.length; i += 4) {
|
|
157
|
+
const r = d[i]!, g = d[i + 1]!, b = d[i + 2]!, a = d[i + 3]!;
|
|
158
|
+
if (a < 200) continue;
|
|
159
|
+
const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
|
|
160
|
+
const l = (mx + mn) / 2 / 255;
|
|
161
|
+
const sat = mx === mn ? 0 : (mx - mn) / 255;
|
|
162
|
+
const score = sat * (1 - Math.abs(l - 0.5) * 1.1); // saturated + mid-light wins
|
|
163
|
+
if (score > bestScore) { bestScore = score; best = [r, g, b]; }
|
|
164
|
+
}
|
|
165
|
+
if (best) {
|
|
166
|
+
recipe.value.accent = '#' + best.map((c) => c.toString(16).padStart(2, '0')).join('');
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
img.src = url;
|
|
170
|
+
}
|
|
103
171
|
|
|
104
172
|
// --- Type actions ------------------------------------------------------
|
|
105
173
|
|
|
@@ -114,6 +182,7 @@ function roll(): void {
|
|
|
114
182
|
recipe.value = randomizeRecipe(seed);
|
|
115
183
|
colorTab.value = 'vibe';
|
|
116
184
|
typeTab.value = 'vibe';
|
|
185
|
+
useSecondary.value = Boolean(recipe.value.secondary);
|
|
117
186
|
emit('roll', { name: randomName(seed) });
|
|
118
187
|
}
|
|
119
188
|
|
|
@@ -121,12 +190,14 @@ function roll(): void {
|
|
|
121
190
|
|
|
122
191
|
const isLast = computed(() => step.value === STEPS.length - 1);
|
|
123
192
|
function next(): void {
|
|
124
|
-
if (isLast.value)
|
|
125
|
-
else step.value++;
|
|
193
|
+
if (!isLast.value) step.value++;
|
|
126
194
|
}
|
|
127
195
|
function back(): void {
|
|
128
196
|
if (step.value > 0) step.value--;
|
|
129
197
|
}
|
|
198
|
+
function finishWith(apply: boolean): void {
|
|
199
|
+
emit('finish', { apply });
|
|
200
|
+
}
|
|
130
201
|
</script>
|
|
131
202
|
|
|
132
203
|
<template>
|
|
@@ -209,17 +280,38 @@ function back(): void {
|
|
|
209
280
|
</span>
|
|
210
281
|
</label>
|
|
211
282
|
<label class="cpub-studio-field">
|
|
212
|
-
<span class="cpub-studio-lbl">Accent
|
|
283
|
+
<span class="cpub-studio-lbl">Accent <span class="cpub-studio-hint">from image…</span></span>
|
|
213
284
|
<span class="cpub-studio-colorrow">
|
|
214
285
|
<input type="color" :value="recipe.accent" class="cpub-studio-colorpick" @input="recipe.accent = ($event.target as HTMLInputElement).value" />
|
|
215
286
|
<input type="text" :value="recipe.accent" maxlength="7" class="cpub-studio-input cpub-studio-mono" @input="onAccentHex(($event.target as HTMLInputElement).value)" />
|
|
287
|
+
<label class="cpub-studio-imgbtn" title="Extract an accent from an image or logo">
|
|
288
|
+
<i class="fa-solid fa-image" aria-hidden="true" />
|
|
289
|
+
<input type="file" accept="image/*" hidden @change="onImagePick" />
|
|
290
|
+
</label>
|
|
291
|
+
</span>
|
|
292
|
+
</label>
|
|
293
|
+
<label class="cpub-studio-field">
|
|
294
|
+
<span class="cpub-studio-lbl">Color family <span class="cpub-studio-hint">harmony</span></span>
|
|
295
|
+
<span class="cpub-studio-seg cpub-studio-seg-wrap">
|
|
296
|
+
<button v-for="sc in SCHEMES" :key="sc.val" type="button" :class="{ on: recipe.scheme === sc.val }" @click="recipe.scheme = sc.val">{{ sc.label }}</button>
|
|
297
|
+
</span>
|
|
298
|
+
</label>
|
|
299
|
+
<div class="cpub-studio-field">
|
|
300
|
+
<span class="cpub-studio-lbl">Suggested family</span>
|
|
301
|
+
<span class="cpub-studio-family">
|
|
302
|
+
<span v-for="(c, i) in familyStrip" :key="i" :style="{ background: c }" :title="c" />
|
|
303
|
+
</span>
|
|
304
|
+
</div>
|
|
305
|
+
<div class="cpub-studio-toggle-line">
|
|
306
|
+
<span class="cpub-studio-lbl">Hand-pick secondary</span>
|
|
307
|
+
<button type="button" class="cpub-studio-switch" :class="{ on: useSecondary }" :aria-pressed="useSecondary" @click="toggleSecondary" />
|
|
308
|
+
</div>
|
|
309
|
+
<label v-if="useSecondary" class="cpub-studio-field">
|
|
310
|
+
<span class="cpub-studio-colorrow">
|
|
311
|
+
<input type="color" :value="recipe.secondary" class="cpub-studio-colorpick" @input="recipe.secondary = ($event.target as HTMLInputElement).value" />
|
|
312
|
+
<input type="text" :value="recipe.secondary" maxlength="7" class="cpub-studio-input cpub-studio-mono" @input="onSecondaryHex(($event.target as HTMLInputElement).value)" />
|
|
216
313
|
</span>
|
|
217
314
|
</label>
|
|
218
|
-
<p class="cpub-studio-note">
|
|
219
|
-
Surfaces, text, borders, and states are derived from your accent and mode. Switch
|
|
220
|
-
Light / Dark in the preview to see both. Fine-tune any individual color later in
|
|
221
|
-
the advanced editor.
|
|
222
|
-
</p>
|
|
223
315
|
</template>
|
|
224
316
|
</div>
|
|
225
317
|
|
|
@@ -310,7 +402,7 @@ function back(): void {
|
|
|
310
402
|
</div>
|
|
311
403
|
|
|
312
404
|
<!-- STEP 4: FEEL -->
|
|
313
|
-
<div v-else>
|
|
405
|
+
<div v-else-if="step === 3">
|
|
314
406
|
<label class="cpub-studio-field">
|
|
315
407
|
<span class="cpub-studio-lbl">Spacing base</span>
|
|
316
408
|
<span class="cpub-studio-seg">
|
|
@@ -324,16 +416,41 @@ function back(): void {
|
|
|
324
416
|
<button v-for="d in (['compact','balanced','spacious'] as const)" :key="d" type="button" :class="{ on: recipe.density === d }" @click="recipe.density = d">{{ d }}</button>
|
|
325
417
|
</span>
|
|
326
418
|
</label>
|
|
419
|
+
<label class="cpub-studio-field">
|
|
420
|
+
<span class="cpub-studio-lbl">Grain <span class="cpub-studio-val">{{ Math.round(recipe.texture * 100) }}%</span></span>
|
|
421
|
+
<input type="range" min="0" max="12" :value="Math.round(recipe.texture * 100)" class="cpub-studio-range" @input="recipe.texture = Number(($event.target as HTMLInputElement).value) / 100" />
|
|
422
|
+
</label>
|
|
327
423
|
<label class="cpub-studio-field">
|
|
328
424
|
<span class="cpub-studio-lbl">Motion</span>
|
|
329
425
|
<span class="cpub-studio-seg">
|
|
330
426
|
<button v-for="m in (['sharp','snappy','smooth'] as const)" :key="m" type="button" :class="{ on: recipe.motion === m }" @click="recipe.motion = m">{{ m }}</button>
|
|
331
427
|
</span>
|
|
332
428
|
</label>
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
<!-- STEP 5: FINISH -->
|
|
432
|
+
<div v-else>
|
|
433
|
+
<label class="cpub-studio-field">
|
|
434
|
+
<span class="cpub-studio-lbl">Theme name</span>
|
|
435
|
+
<input
|
|
436
|
+
type="text"
|
|
437
|
+
:value="props.name"
|
|
438
|
+
class="cpub-studio-input"
|
|
439
|
+
placeholder="My theme"
|
|
440
|
+
@input="emit('rename', ($event.target as HTMLInputElement).value)"
|
|
441
|
+
/>
|
|
442
|
+
</label>
|
|
443
|
+
<div class="cpub-studio-toggle-line">
|
|
444
|
+
<span class="cpub-studio-lbl">Save & apply as the site default</span>
|
|
445
|
+
<button type="button" class="cpub-studio-switch" :class="{ on: applyDefault }" :aria-pressed="applyDefault" @click="applyDefault = !applyDefault" />
|
|
446
|
+
</div>
|
|
333
447
|
<p class="cpub-studio-note">
|
|
334
|
-
|
|
335
|
-
Studio any time, or fine-tune individual tokens
|
|
448
|
+
Studio saves a matching light + dark pair, each tuned for its mode. After saving you can
|
|
449
|
+
re-open Studio any time, or fine-tune individual tokens in the advanced editor.
|
|
336
450
|
</p>
|
|
451
|
+
<button type="button" class="cpub-btn cpub-btn-primary cpub-studio-save" @click="finishWith(applyDefault)">
|
|
452
|
+
<i class="fa-solid fa-floppy-disk" aria-hidden="true" /> {{ applyDefault ? 'Save & apply theme' : 'Save theme' }}
|
|
453
|
+
</button>
|
|
337
454
|
</div>
|
|
338
455
|
</div>
|
|
339
456
|
|
|
@@ -341,9 +458,12 @@ function back(): void {
|
|
|
341
458
|
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="step === 0" @click="back">
|
|
342
459
|
<i class="fa-solid fa-arrow-left" aria-hidden="true" /> Back
|
|
343
460
|
</button>
|
|
344
|
-
<
|
|
345
|
-
<
|
|
346
|
-
<
|
|
461
|
+
<span class="cpub-studio-audit" :title="`text ${auditText.ratio}:1, links ${auditLink.ratio}:1`">
|
|
462
|
+
<span class="cpub-studio-chip" :class="auditText.ok ? 'ok' : 'bad'">text {{ auditText.band }}</span>
|
|
463
|
+
<span class="cpub-studio-chip" :class="auditLink.ok ? 'ok' : 'bad'">links {{ auditLink.band }}</span>
|
|
464
|
+
</span>
|
|
465
|
+
<button v-if="!isLast" type="button" class="cpub-btn cpub-btn-sm cpub-btn-primary" @click="next">
|
|
466
|
+
Next <i class="fa-solid fa-arrow-right" aria-hidden="true" />
|
|
347
467
|
</button>
|
|
348
468
|
</footer>
|
|
349
469
|
</div>
|
|
@@ -421,7 +541,28 @@ function back(): void {
|
|
|
421
541
|
.cpub-studio-range:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
422
542
|
|
|
423
543
|
.cpub-studio-note { font-size: var(--text-sm); color: var(--text-dim); line-height: var(--leading-snug); margin: var(--space-4) 0 0; }
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
.cpub-studio-
|
|
544
|
+
.cpub-studio-hint { float: right; color: var(--text-faint); text-transform: none; letter-spacing: 0; }
|
|
545
|
+
|
|
546
|
+
.cpub-studio-imgbtn { display: inline-grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; border: var(--border-width-thin) solid var(--border2); background: var(--surface2); color: var(--text-dim); cursor: pointer; }
|
|
547
|
+
.cpub-studio-imgbtn:hover { border-color: var(--accent); color: var(--accent); }
|
|
548
|
+
|
|
549
|
+
.cpub-studio-family { display: flex; height: 26px; border: var(--border-width-thin) solid var(--border2); overflow: hidden; }
|
|
550
|
+
.cpub-studio-family span { flex: 1; }
|
|
551
|
+
|
|
552
|
+
/* hand-pick secondary toggle */
|
|
553
|
+
.cpub-studio-toggle-line { display: flex; align-items: center; justify-content: space-between; margin-top: var(--space-3); }
|
|
554
|
+
.cpub-studio-toggle-line .cpub-studio-lbl { margin-bottom: 0; }
|
|
555
|
+
.cpub-studio-switch { position: relative; width: 38px; height: 20px; background: var(--surface3); border: var(--border-width-thin) solid var(--border2); cursor: pointer; flex-shrink: 0; }
|
|
556
|
+
.cpub-studio-switch::after { content: ''; position: absolute; top: 1px; left: 1px; width: 14px; height: 14px; background: var(--text-faint); transition: transform var(--transition-fast); }
|
|
557
|
+
.cpub-studio-switch.on { background: var(--accent-bg); border-color: var(--accent); }
|
|
558
|
+
.cpub-studio-switch.on::after { transform: translateX(18px); background: var(--accent); }
|
|
559
|
+
|
|
560
|
+
.cpub-studio-save { width: 100%; justify-content: center; margin-top: var(--space-4); }
|
|
561
|
+
|
|
562
|
+
.cpub-studio-foot { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-3) var(--space-4); border-top: var(--border-width-default) solid var(--border); }
|
|
563
|
+
.cpub-studio-audit { display: flex; gap: 4px; margin-left: auto; }
|
|
564
|
+
.cpub-studio-chip { font-family: var(--font-mono); font-size: 9px; font-weight: var(--font-weight-bold); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 6px; border: var(--border-width-thin) solid; }
|
|
565
|
+
.cpub-studio-chip.ok { color: var(--green); border-color: var(--green); }
|
|
566
|
+
.cpub-studio-chip.bad { color: var(--red); border-color: var(--red); }
|
|
567
|
+
.cpub-studio-foot .cpub-btn-primary { margin-left: var(--space-2); }
|
|
427
568
|
</style>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -55,15 +55,15 @@
|
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
56
|
"@commonpub/auth": "0.8.0",
|
|
57
57
|
"@commonpub/config": "0.20.0",
|
|
58
|
-
"@commonpub/
|
|
59
|
-
"@commonpub/protocol": "0.13.0",
|
|
58
|
+
"@commonpub/editor": "0.7.11",
|
|
60
59
|
"@commonpub/explainer": "0.7.15",
|
|
61
60
|
"@commonpub/docs": "0.6.3",
|
|
62
|
-
"@commonpub/schema": "0.
|
|
63
|
-
"@commonpub/
|
|
64
|
-
"@commonpub/ui": "0.
|
|
61
|
+
"@commonpub/schema": "0.37.0",
|
|
62
|
+
"@commonpub/protocol": "0.13.0",
|
|
63
|
+
"@commonpub/ui": "0.12.0",
|
|
65
64
|
"@commonpub/server": "2.83.0",
|
|
66
|
-
"@commonpub/
|
|
65
|
+
"@commonpub/theme-studio": "0.3.0",
|
|
66
|
+
"@commonpub/learning": "0.5.2"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
|
|
19
19
|
import { TOKEN_GROUP_LABELS, TOKEN_GROUP_ORDER, tokensByGroup } from '@commonpub/ui';
|
|
20
|
-
import { googleHref, type ThemeRecipe } from '@commonpub/theme-studio';
|
|
20
|
+
import { googleHref, recipeToTokens, buildBrief, buildTokensJson, type ThemeRecipe } from '@commonpub/theme-studio';
|
|
21
21
|
|
|
22
22
|
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
23
23
|
|
|
@@ -174,9 +174,17 @@ function onStudioGenerate(payload: {
|
|
|
174
174
|
dirty.value = true;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
/**
|
|
178
|
-
|
|
177
|
+
/** Wizard "Save" — persist (+ sibling pair) and optionally apply as default,
|
|
178
|
+
* then drop into the advanced editor. */
|
|
179
|
+
function onStudioFinish(payload: { apply: boolean }): void {
|
|
179
180
|
studioMode.value = false;
|
|
181
|
+
void save({ apply: payload.apply });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Name typed in the wizard's finish step. */
|
|
185
|
+
function onStudioRename(name: string): void {
|
|
186
|
+
draft.value.name = name;
|
|
187
|
+
dirty.value = true;
|
|
180
188
|
}
|
|
181
189
|
|
|
182
190
|
/** Dice roll suggests a name; adopt it only if the user hasn't named it. */
|
|
@@ -204,16 +212,61 @@ const pairCandidates = computed(() =>
|
|
|
204
212
|
|
|
205
213
|
// --- Save / cancel / export -----------------------------------------
|
|
206
214
|
|
|
215
|
+
/** The opposite-mode sibling's id for a paired Studio theme. */
|
|
216
|
+
function siblingIdFor(id: string, isDark: boolean): string {
|
|
217
|
+
const base = id.replace(/-(light|dark)$/, '');
|
|
218
|
+
return isDark ? `${base}-light` : `${base}-dark`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Create/update the matching opposite-mode sibling of a Studio theme from the
|
|
223
|
+
* SAME recipe, so every Studio theme is a coherent light+dark pair (linked via
|
|
224
|
+
* pairId, sharing one family). The sibling is recipe-derived — it tracks the
|
|
225
|
+
* recipe, while per-token tweaks live on whichever variant you're editing.
|
|
226
|
+
*/
|
|
227
|
+
async function upsertSibling(recipe: ThemeRecipe, siblingId: string): Promise<void> {
|
|
228
|
+
const siblingDark = !draft.value.isDark;
|
|
229
|
+
const siblingRecipe: ThemeRecipe = { ...recipe, mode: siblingDark ? 'dark' : 'light' };
|
|
230
|
+
const gen = recipeToTokens(siblingRecipe);
|
|
231
|
+
const body = {
|
|
232
|
+
id: siblingId,
|
|
233
|
+
name: draft.value.name,
|
|
234
|
+
description: draft.value.description,
|
|
235
|
+
family: draft.value.family,
|
|
236
|
+
isDark: siblingDark,
|
|
237
|
+
pairId: draft.value.id,
|
|
238
|
+
parentTheme: gen.parentTheme,
|
|
239
|
+
tokens: gen.tokens,
|
|
240
|
+
recipe: siblingRecipe,
|
|
241
|
+
fonts: gen.fonts,
|
|
242
|
+
};
|
|
243
|
+
const put = $fetch as (url: string, opts: Record<string, unknown>) => Promise<unknown>;
|
|
244
|
+
if (themesApi.findCustom(siblingId)) {
|
|
245
|
+
await put(`/api/admin/themes/${siblingId}`, { method: 'PUT', body });
|
|
246
|
+
} else {
|
|
247
|
+
await $fetch('/api/admin/themes', { method: 'POST', body });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
207
251
|
/**
|
|
208
252
|
* Save the draft. If `apply` is true, ALSO set this theme as the
|
|
209
253
|
* instance default in the same await chain — must happen BEFORE the
|
|
210
254
|
* create-mode router.replace, otherwise the navigation could unmount
|
|
211
255
|
* the component mid-PUT and lose the apply.
|
|
256
|
+
*
|
|
257
|
+
* Studio (recipe-driven) themes are saved as a light+dark PAIR: the primary
|
|
258
|
+
* (this draft) plus its recipe-derived opposite-mode sibling, cross-linked
|
|
259
|
+
* via pairId in one family.
|
|
212
260
|
*/
|
|
213
261
|
async function save({ apply = false }: { apply?: boolean } = {}): Promise<void> {
|
|
214
262
|
saving.value = true;
|
|
215
263
|
error.value = null;
|
|
216
264
|
try {
|
|
265
|
+
// Pair bookkeeping: a Studio theme links to its opposite-mode sibling.
|
|
266
|
+
const recipe = draft.value.recipe;
|
|
267
|
+
const siblingId = recipe ? siblingIdFor(draft.value.id, draft.value.isDark) : null;
|
|
268
|
+
if (siblingId) draft.value.pairId = siblingId;
|
|
269
|
+
|
|
217
270
|
const payload = {
|
|
218
271
|
id: draft.value.id,
|
|
219
272
|
name: draft.value.name,
|
|
@@ -245,6 +298,16 @@ async function save({ apply = false }: { apply?: boolean } = {}): Promise<void>
|
|
|
245
298
|
savedId = draft.value.id;
|
|
246
299
|
}
|
|
247
300
|
|
|
301
|
+
// Create/update the matching opposite-mode sibling (recipe-driven pair).
|
|
302
|
+
// Soft-fail: the primary is already saved; a sibling hiccup shouldn't lose it.
|
|
303
|
+
if (recipe && siblingId) {
|
|
304
|
+
try {
|
|
305
|
+
await upsertSibling(recipe, siblingId);
|
|
306
|
+
} catch {
|
|
307
|
+
notify('Saved, but the matching light/dark variant could not sync', 'error');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
248
311
|
// Apply BEFORE refresh/navigation so the navigate doesn't unmount us
|
|
249
312
|
// mid-PUT (would lose the apply + the success toast).
|
|
250
313
|
if (apply) {
|
|
@@ -275,6 +338,34 @@ async function applyAndSave(): Promise<void> {
|
|
|
275
338
|
await save({ apply: true });
|
|
276
339
|
}
|
|
277
340
|
|
|
341
|
+
/** Download a text artifact (brief / tokens) built from the current draft. */
|
|
342
|
+
function downloadText(filename: string, content: string, mime: string): void {
|
|
343
|
+
if (typeof document === 'undefined') return;
|
|
344
|
+
const blob = new Blob([content], { type: mime });
|
|
345
|
+
const url = URL.createObjectURL(blob);
|
|
346
|
+
const a = document.createElement('a');
|
|
347
|
+
a.href = url;
|
|
348
|
+
a.download = filename;
|
|
349
|
+
document.body.appendChild(a);
|
|
350
|
+
a.click();
|
|
351
|
+
a.remove();
|
|
352
|
+
URL.revokeObjectURL(url);
|
|
353
|
+
}
|
|
354
|
+
function exportBrief(): void {
|
|
355
|
+
downloadText(
|
|
356
|
+
`${draft.value.id}-brief.md`,
|
|
357
|
+
buildBrief({ name: draft.value.name, description: draft.value.description }, draft.value.tokens),
|
|
358
|
+
'text/markdown',
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
function exportTokens(): void {
|
|
362
|
+
downloadText(
|
|
363
|
+
`${draft.value.id}.tokens.json`,
|
|
364
|
+
buildTokensJson({ name: draft.value.name }, draft.value.tokens),
|
|
365
|
+
'application/json',
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
278
369
|
function exportTheme(): void {
|
|
279
370
|
// Snapshot the in-progress draft (unsaved tokens included) so the
|
|
280
371
|
// admin can export-while-editing without committing first.
|
|
@@ -424,9 +515,16 @@ onBeforeUnmount(() => {
|
|
|
424
515
|
<span v-if="modifiedTotal > 0" class="theme-editor-modified">
|
|
425
516
|
{{ modifiedTotal }} token{{ modifiedTotal === 1 ? '' : 's' }} customized
|
|
426
517
|
</span>
|
|
427
|
-
<
|
|
428
|
-
<
|
|
429
|
-
|
|
518
|
+
<details class="theme-editor-export">
|
|
519
|
+
<summary class="cpub-btn cpub-btn-sm">
|
|
520
|
+
<i class="fa-solid fa-file-export" aria-hidden="true" /> Export
|
|
521
|
+
</summary>
|
|
522
|
+
<div class="theme-editor-export-menu">
|
|
523
|
+
<button type="button" @click="exportTheme"><i class="fa-solid fa-file-code" aria-hidden="true" /> Theme (.cpub-theme.json)</button>
|
|
524
|
+
<button type="button" @click="exportBrief"><i class="fa-solid fa-robot" aria-hidden="true" /> AI brief (.md)</button>
|
|
525
|
+
<button type="button" @click="exportTokens"><i class="fa-solid fa-file-lines" aria-hidden="true" /> Tokens (.json)</button>
|
|
526
|
+
</div>
|
|
527
|
+
</details>
|
|
430
528
|
<button class="cpub-btn cpub-btn-sm" :disabled="saving || !dirty" @click="() => save()">
|
|
431
529
|
<i :class="['fa-solid', saving ? 'fa-circle-notch fa-spin' : 'fa-floppy-disk']" aria-hidden="true" />
|
|
432
530
|
{{ saving ? 'Saving…' : 'Save' }}
|
|
@@ -459,8 +557,10 @@ onBeforeUnmount(() => {
|
|
|
459
557
|
v-if="studioMode"
|
|
460
558
|
class="theme-editor-studio"
|
|
461
559
|
:recipe="draft.recipe"
|
|
560
|
+
:name="draft.name"
|
|
462
561
|
@generate="onStudioGenerate"
|
|
463
562
|
@finish="onStudioFinish"
|
|
563
|
+
@rename="onStudioRename"
|
|
464
564
|
@roll="onStudioRoll"
|
|
465
565
|
/>
|
|
466
566
|
<section v-else class="theme-editor-tokens" aria-label="Token editor">
|
|
@@ -614,6 +714,35 @@ onBeforeUnmount(() => {
|
|
|
614
714
|
color: var(--accent);
|
|
615
715
|
}
|
|
616
716
|
|
|
717
|
+
.theme-editor-export { position: relative; }
|
|
718
|
+
.theme-editor-export > summary { list-style: none; cursor: pointer; }
|
|
719
|
+
.theme-editor-export > summary::-webkit-details-marker { display: none; }
|
|
720
|
+
.theme-editor-export-menu {
|
|
721
|
+
position: absolute;
|
|
722
|
+
right: 0;
|
|
723
|
+
top: calc(100% + 4px);
|
|
724
|
+
z-index: var(--z-dropdown);
|
|
725
|
+
display: flex;
|
|
726
|
+
flex-direction: column;
|
|
727
|
+
min-width: 220px;
|
|
728
|
+
background: var(--surface);
|
|
729
|
+
border: var(--border-width-default) solid var(--border);
|
|
730
|
+
box-shadow: var(--shadow-md);
|
|
731
|
+
}
|
|
732
|
+
.theme-editor-export-menu button {
|
|
733
|
+
display: flex;
|
|
734
|
+
align-items: center;
|
|
735
|
+
gap: var(--space-2);
|
|
736
|
+
padding: var(--space-2) var(--space-3);
|
|
737
|
+
background: none;
|
|
738
|
+
border: 0;
|
|
739
|
+
text-align: left;
|
|
740
|
+
font-size: var(--text-sm);
|
|
741
|
+
color: var(--text);
|
|
742
|
+
cursor: pointer;
|
|
743
|
+
}
|
|
744
|
+
.theme-editor-export-menu button:hover { background: var(--surface2); }
|
|
745
|
+
|
|
617
746
|
.theme-editor-error {
|
|
618
747
|
margin: 0;
|
|
619
748
|
padding: var(--space-3) var(--space-4);
|
|
@@ -37,6 +37,13 @@ const themesApi = useThemeAdmin();
|
|
|
37
37
|
const router = useRouter();
|
|
38
38
|
const { themeStudio } = useFeatures();
|
|
39
39
|
|
|
40
|
+
/** The "New theme" dropdown (<details>); closed after a choice is picked. */
|
|
41
|
+
const newMenu = ref<HTMLDetailsElement | null>(null);
|
|
42
|
+
function pick(action: () => void): void {
|
|
43
|
+
newMenu.value?.removeAttribute('open');
|
|
44
|
+
action();
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
const { data: settings, refresh: refreshSettings } = await useFetch<Record<string, unknown>>('/api/admin/settings');
|
|
41
48
|
|
|
42
49
|
const saving = ref(false);
|
|
@@ -168,11 +175,14 @@ function createBlank(): void {
|
|
|
168
175
|
if (customId && themesApi.data.value) {
|
|
169
176
|
const src = themesApi.data.value.custom.find((t) => t.id === customId);
|
|
170
177
|
if (src) {
|
|
178
|
+
const copyId = nextAvailableId(`${src.id}-copy`);
|
|
171
179
|
const seed = {
|
|
172
|
-
id:
|
|
180
|
+
id: copyId,
|
|
173
181
|
name: `${src.name} (copy)`,
|
|
174
182
|
description: src.description ?? '',
|
|
175
|
-
family
|
|
183
|
+
// Unique family (= the slug) so each new theme is its OWN picker card
|
|
184
|
+
// instead of collapsing into a shared "custom" family.
|
|
185
|
+
family: copyId,
|
|
176
186
|
isDark: src.isDark,
|
|
177
187
|
parentTheme: src.parentTheme,
|
|
178
188
|
tokens: { ...src.tokens },
|
|
@@ -188,11 +198,12 @@ function createBlank(): void {
|
|
|
188
198
|
// so a complete capture is what keeps it from falling back to Classic).
|
|
189
199
|
const detected = detectAppliedOverrides();
|
|
190
200
|
const isBuiltInParent = themesApi.data.value?.builtIn.some((t) => t.id === active) ?? false;
|
|
201
|
+
const blankId = nextAvailableId('my-theme');
|
|
191
202
|
const seed = {
|
|
192
|
-
id:
|
|
203
|
+
id: blankId,
|
|
193
204
|
name: 'My theme',
|
|
194
205
|
description: detected.count ? `Forked from the active theme (${detected.count} tokens).` : '',
|
|
195
|
-
family:
|
|
206
|
+
family: blankId,
|
|
196
207
|
isDark: detected.isDark,
|
|
197
208
|
parentTheme: isBuiltInParent ? active : 'base',
|
|
198
209
|
tokens: detected.tokens,
|
|
@@ -208,11 +219,15 @@ function createBlank(): void {
|
|
|
208
219
|
*/
|
|
209
220
|
function startFromRecipe(recipe: ThemeRecipe, opts: { id: string; name: string }): void {
|
|
210
221
|
const gen = recipeToTokens(recipe);
|
|
222
|
+
const id = nextAvailableId(opts.id);
|
|
211
223
|
const seed = {
|
|
212
|
-
id
|
|
224
|
+
id,
|
|
213
225
|
name: opts.name,
|
|
214
226
|
description: '',
|
|
215
|
-
family
|
|
227
|
+
// Unique family per theme (= the slug) so the picker keeps each Studio
|
|
228
|
+
// theme separate AND can group its light/dark pair together. (A shared
|
|
229
|
+
// 'custom' family would collapse every Studio theme into one card.)
|
|
230
|
+
family: id,
|
|
216
231
|
isDark: recipe.mode === 'dark',
|
|
217
232
|
parentTheme: gen.parentTheme,
|
|
218
233
|
tokens: gen.tokens,
|
|
@@ -241,11 +256,12 @@ function captureCurrent(): void {
|
|
|
241
256
|
notify('No custom tokens detected at :root', 'error');
|
|
242
257
|
return;
|
|
243
258
|
}
|
|
259
|
+
const capturedId = nextAvailableId(`captured-${new Date().toISOString().slice(0, 10)}`);
|
|
244
260
|
const seed = {
|
|
245
|
-
id:
|
|
261
|
+
id: capturedId,
|
|
246
262
|
name: 'Captured current site theme',
|
|
247
263
|
description: `Auto-captured from the live :root on ${new Date().toLocaleDateString()}, ${detected.count} tokens.`,
|
|
248
|
-
family:
|
|
264
|
+
family: capturedId,
|
|
249
265
|
isDark: detected.isDark,
|
|
250
266
|
parentTheme: detected.isDark ? 'dark' : 'base',
|
|
251
267
|
tokens: detected.tokens,
|
|
@@ -393,9 +409,6 @@ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
|
|
|
393
409
|
</p>
|
|
394
410
|
</div>
|
|
395
411
|
<div class="admin-theme-actions">
|
|
396
|
-
<button class="cpub-btn" :disabled="saving" @click="openImportDialog">
|
|
397
|
-
<i class="fa-solid fa-file-import" aria-hidden="true" /> Import…
|
|
398
|
-
</button>
|
|
399
412
|
<input
|
|
400
413
|
ref="importFileInput"
|
|
401
414
|
type="file"
|
|
@@ -403,20 +416,36 @@ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
|
|
|
403
416
|
hidden
|
|
404
417
|
@change="onImportFile"
|
|
405
418
|
/>
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
<i class="fa-solid fa-
|
|
412
|
-
</
|
|
413
|
-
<
|
|
414
|
-
<
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
419
|
+
<!-- One clear entry point. Each option creates a SEPARATE theme (its own
|
|
420
|
+
family/card) — you can make as many as you like. -->
|
|
421
|
+
<details ref="newMenu" class="admin-theme-new">
|
|
422
|
+
<summary class="cpub-btn cpub-btn-primary">
|
|
423
|
+
<i class="fa-solid fa-plus" aria-hidden="true" /> New theme
|
|
424
|
+
<i class="fa-solid fa-chevron-down admin-theme-new-caret" aria-hidden="true" />
|
|
425
|
+
</summary>
|
|
426
|
+
<div class="admin-theme-new-menu" role="menu">
|
|
427
|
+
<button v-if="themeStudio" type="button" role="menuitem" @click="pick(startGuided)">
|
|
428
|
+
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true" />
|
|
429
|
+
<span><b>Build with Studio</b><small>Guided: pick a vibe + colors, get a light/dark pair</small></span>
|
|
430
|
+
</button>
|
|
431
|
+
<button v-if="themeStudio" type="button" role="menuitem" @click="pick(startDice)">
|
|
432
|
+
<i class="fa-solid fa-dice" aria-hidden="true" />
|
|
433
|
+
<span><b>Surprise me</b><small>Roll a random coherent theme to start from</small></span>
|
|
434
|
+
</button>
|
|
435
|
+
<button type="button" role="menuitem" @click="pick(createBlank)">
|
|
436
|
+
<i class="fa-solid fa-plus" aria-hidden="true" />
|
|
437
|
+
<span><b>Blank</b><small>Fork the current look, edit tokens by hand</small></span>
|
|
438
|
+
</button>
|
|
439
|
+
<button v-if="discovery.count > 0" type="button" role="menuitem" @click="pick(captureCurrent)">
|
|
440
|
+
<i class="fa-solid fa-camera" aria-hidden="true" />
|
|
441
|
+
<span><b>Capture current</b><small>Save your layer app's CSS theme as editable</small></span>
|
|
442
|
+
</button>
|
|
443
|
+
<button type="button" role="menuitem" @click="pick(openImportDialog)">
|
|
444
|
+
<i class="fa-solid fa-file-import" aria-hidden="true" />
|
|
445
|
+
<span><b>Import</b><small>Load a .cpub-theme.json file</small></span>
|
|
446
|
+
</button>
|
|
447
|
+
</div>
|
|
448
|
+
</details>
|
|
420
449
|
</div>
|
|
421
450
|
</header>
|
|
422
451
|
|
|
@@ -492,6 +521,41 @@ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
|
|
|
492
521
|
.admin-page-desc { font-size: var(--text-sm); color: var(--text-dim); margin: 0; max-width: 560px; line-height: var(--leading-snug); }
|
|
493
522
|
.admin-theme-actions { display: flex; gap: var(--space-2); margin-left: auto; }
|
|
494
523
|
|
|
524
|
+
.admin-theme-new { position: relative; }
|
|
525
|
+
.admin-theme-new > summary { list-style: none; cursor: pointer; }
|
|
526
|
+
.admin-theme-new > summary::-webkit-details-marker { display: none; }
|
|
527
|
+
.admin-theme-new-caret { font-size: 9px; margin-left: 2px; }
|
|
528
|
+
.admin-theme-new-menu {
|
|
529
|
+
position: absolute;
|
|
530
|
+
right: 0;
|
|
531
|
+
top: calc(100% + 4px);
|
|
532
|
+
z-index: var(--z-dropdown);
|
|
533
|
+
display: flex;
|
|
534
|
+
flex-direction: column;
|
|
535
|
+
width: 300px;
|
|
536
|
+
max-width: 80vw;
|
|
537
|
+
background: var(--surface);
|
|
538
|
+
border: var(--border-width-default) solid var(--border);
|
|
539
|
+
box-shadow: var(--shadow-md);
|
|
540
|
+
}
|
|
541
|
+
.admin-theme-new-menu button {
|
|
542
|
+
display: flex;
|
|
543
|
+
align-items: flex-start;
|
|
544
|
+
gap: var(--space-3);
|
|
545
|
+
padding: var(--space-3);
|
|
546
|
+
background: none;
|
|
547
|
+
border: 0;
|
|
548
|
+
border-bottom: var(--border-width-thin) solid var(--border2);
|
|
549
|
+
text-align: left;
|
|
550
|
+
color: var(--text);
|
|
551
|
+
cursor: pointer;
|
|
552
|
+
}
|
|
553
|
+
.admin-theme-new-menu button:last-child { border-bottom: 0; }
|
|
554
|
+
.admin-theme-new-menu button:hover { background: var(--surface2); }
|
|
555
|
+
.admin-theme-new-menu button > i { color: var(--accent); margin-top: 3px; width: 16px; text-align: center; }
|
|
556
|
+
.admin-theme-new-menu button b { display: block; font-size: var(--text-sm); }
|
|
557
|
+
.admin-theme-new-menu button small { display: block; font-size: var(--text-label); color: var(--text-dim); line-height: var(--leading-snug); margin-top: 2px; }
|
|
558
|
+
|
|
495
559
|
.admin-theme-toast {
|
|
496
560
|
position: fixed;
|
|
497
561
|
top: calc(var(--nav-height) + var(--space-4));
|
package/theme/base.css
CHANGED
|
@@ -61,6 +61,16 @@
|
|
|
61
61
|
--color-accent-bg: var(--accent-bg);
|
|
62
62
|
--color-accent-border: var(--accent-border);
|
|
63
63
|
|
|
64
|
+
/* === SECONDARY ACCENT (violet) === */
|
|
65
|
+
--secondary: #8b5cf6;
|
|
66
|
+
--secondary-hover: #7c3aed;
|
|
67
|
+
--secondary-bg: rgba(139, 92, 246, 0.08);
|
|
68
|
+
--secondary-border: rgba(139, 92, 246, 0.25);
|
|
69
|
+
--color-on-secondary: #ffffff;
|
|
70
|
+
|
|
71
|
+
/* Film-grain overlay opacity (0 = off; app.vue mounts the overlay) */
|
|
72
|
+
--grain: 0;
|
|
73
|
+
|
|
64
74
|
/* === SEMANTIC COLORS === */
|
|
65
75
|
--green: #22c55e;
|
|
66
76
|
--green-bg: rgba(34, 197, 94, 0.08);
|
package/theme/components.css
CHANGED
|
@@ -36,6 +36,15 @@
|
|
|
36
36
|
|
|
37
37
|
.cpub-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
38
38
|
|
|
39
|
+
.cpub-btn-secondary {
|
|
40
|
+
background: var(--secondary);
|
|
41
|
+
color: var(--color-on-secondary);
|
|
42
|
+
border-color: var(--secondary);
|
|
43
|
+
box-shadow: 4px 4px 0 var(--border);
|
|
44
|
+
}
|
|
45
|
+
.cpub-btn-secondary:hover { background: var(--secondary-hover); box-shadow: 2px 2px 0 var(--border); }
|
|
46
|
+
.cpub-btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
47
|
+
|
|
39
48
|
.cpub-btn-sm { padding: 4px 10px; font-size: 11px; min-height: 44px; }
|
|
40
49
|
.cpub-btn-lg { padding: 12px 24px; font-size: 14px; font-weight: 600; }
|
|
41
50
|
|