@commonpub/layer 0.66.0 → 0.68.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 +171 -31
- package/composables/useTheme.ts +17 -3
- package/package.json +7 -7
- package/pages/admin/theme/edit/[id].vue +88 -9
- package/pages/admin/theme/index.vue +83 -23
- package/plugins/theme.ts +4 -0
- package/server/middleware/theme.ts +20 -7
- package/server/utils/instanceTheme.ts +46 -46
- 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>
|
|
@@ -202,24 +273,45 @@ function back(): void {
|
|
|
202
273
|
|
|
203
274
|
<template v-else>
|
|
204
275
|
<label class="cpub-studio-field">
|
|
205
|
-
<span class="cpub-studio-lbl">
|
|
276
|
+
<span class="cpub-studio-lbl">Default mode <span class="cpub-studio-hint">both are saved</span></span>
|
|
206
277
|
<span class="cpub-studio-seg">
|
|
207
278
|
<button type="button" :class="{ on: recipe.mode === 'light' }" @click="recipe.mode = 'light'">Light</button>
|
|
208
279
|
<button type="button" :class="{ on: recipe.mode === 'dark' }" @click="recipe.mode = 'dark'">Dark</button>
|
|
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,17 +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
|
-
Studio saves a matching light + dark pair, each tuned for its mode.
|
|
335
|
-
|
|
336
|
-
fine-tune individual tokens by hand.
|
|
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.
|
|
337
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>
|
|
338
454
|
</div>
|
|
339
455
|
</div>
|
|
340
456
|
|
|
@@ -342,9 +458,12 @@ function back(): void {
|
|
|
342
458
|
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="step === 0" @click="back">
|
|
343
459
|
<i class="fa-solid fa-arrow-left" aria-hidden="true" /> Back
|
|
344
460
|
</button>
|
|
345
|
-
<
|
|
346
|
-
<
|
|
347
|
-
<
|
|
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" />
|
|
348
467
|
</button>
|
|
349
468
|
</footer>
|
|
350
469
|
</div>
|
|
@@ -422,7 +541,28 @@ function back(): void {
|
|
|
422
541
|
.cpub-studio-range:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
423
542
|
|
|
424
543
|
.cpub-studio-note { font-size: var(--text-sm); color: var(--text-dim); line-height: var(--leading-snug); margin: var(--space-4) 0 0; }
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
.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); }
|
|
428
568
|
</style>
|
package/composables/useTheme.ts
CHANGED
|
@@ -29,6 +29,7 @@ export function useTheme(): {
|
|
|
29
29
|
const themeId = useState<string>('cpub-theme', () => 'base');
|
|
30
30
|
const instanceDefault = useState<string>('cpub-instance-theme', () => 'base');
|
|
31
31
|
const isDark = useState<boolean>('cpub-dark-mode', () => false);
|
|
32
|
+
const themePair = useState<{ lightAttr: string; darkAttr: string } | null>('cpub-theme-pair', () => null);
|
|
32
33
|
const schemeCookie = useCookie('cpub-color-scheme', {
|
|
33
34
|
maxAge: 31536000,
|
|
34
35
|
path: '/',
|
|
@@ -44,9 +45,21 @@ export function useTheme(): {
|
|
|
44
45
|
schemeCookie.value = dark ? 'dark' : 'light';
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// Custom light/dark PAIR: both variants' tokens are injected (scoped to
|
|
49
|
+
// their data-theme attr), so flip the attribute client-side for an instant
|
|
50
|
+
// switch — exactly like built-in families. (This is the fix for "the site
|
|
51
|
+
// light/dark toggle didn't switch a custom theme".)
|
|
52
|
+
if (themePair.value) {
|
|
53
|
+
const newTheme = dark ? themePair.value.darkAttr : themePair.value.lightAttr;
|
|
54
|
+
themeId.value = newTheme;
|
|
55
|
+
if (import.meta.client) {
|
|
56
|
+
document.documentElement.setAttribute('data-theme', newTheme);
|
|
57
|
+
$fetch('/api/profile/theme', { method: 'PUT', body: { themeId: newTheme } }).catch(() => {});
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
// Built-in family flip is purely client-side for snappy UX.
|
|
48
|
-
// Custom/registered themes need a server round-trip on next nav
|
|
49
|
-
// (the server reads the new cookie and picks the right pair).
|
|
50
63
|
if (THEME_TO_FAMILY[instanceDefault.value]) {
|
|
51
64
|
const family = THEME_TO_FAMILY[instanceDefault.value]!;
|
|
52
65
|
const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
|
|
@@ -61,7 +74,8 @@ export function useTheme(): {
|
|
|
61
74
|
}).catch(() => {});
|
|
62
75
|
}
|
|
63
76
|
} else if (import.meta.client) {
|
|
64
|
-
//
|
|
77
|
+
// Single custom / registered theme with no pair: persist preference only;
|
|
78
|
+
// the server picks any declared variant on the next request.
|
|
65
79
|
$fetch('/api/profile/theme', {
|
|
66
80
|
method: 'PUT',
|
|
67
81
|
body: { themeId: instanceDefault.value },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -56,14 +56,14 @@
|
|
|
56
56
|
"@commonpub/auth": "0.8.0",
|
|
57
57
|
"@commonpub/config": "0.20.0",
|
|
58
58
|
"@commonpub/docs": "0.6.3",
|
|
59
|
-
"@commonpub/protocol": "0.13.0",
|
|
60
|
-
"@commonpub/editor": "0.7.11",
|
|
61
59
|
"@commonpub/explainer": "0.7.15",
|
|
62
|
-
"@commonpub/
|
|
63
|
-
"@commonpub/
|
|
60
|
+
"@commonpub/editor": "0.7.11",
|
|
61
|
+
"@commonpub/learning": "0.5.2",
|
|
62
|
+
"@commonpub/schema": "0.37.0",
|
|
63
|
+
"@commonpub/protocol": "0.13.0",
|
|
64
64
|
"@commonpub/server": "2.83.0",
|
|
65
|
-
"@commonpub/
|
|
66
|
-
"@commonpub/
|
|
65
|
+
"@commonpub/ui": "0.12.0",
|
|
66
|
+
"@commonpub/theme-studio": "0.3.0"
|
|
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, recipeToTokens, 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. */
|
|
@@ -330,6 +338,34 @@ async function applyAndSave(): Promise<void> {
|
|
|
330
338
|
await save({ apply: true });
|
|
331
339
|
}
|
|
332
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
|
+
|
|
333
369
|
function exportTheme(): void {
|
|
334
370
|
// Snapshot the in-progress draft (unsaved tokens included) so the
|
|
335
371
|
// admin can export-while-editing without committing first.
|
|
@@ -429,7 +465,10 @@ onBeforeUnmount(() => {
|
|
|
429
465
|
</select>
|
|
430
466
|
</label>
|
|
431
467
|
|
|
432
|
-
|
|
468
|
+
<!-- Mode pill only for hand-authored themes. Studio themes are a managed
|
|
469
|
+
light+dark pair — there's no single "mode" to pick, and the site's
|
|
470
|
+
Light/Dark toggle switches the pair for visitors. -->
|
|
471
|
+
<label v-if="!draft.recipe" class="theme-editor-field theme-editor-field-toggle">
|
|
433
472
|
<span class="theme-editor-field-label">Mode</span>
|
|
434
473
|
<div class="theme-editor-mode-pill" role="group">
|
|
435
474
|
<button
|
|
@@ -447,7 +486,7 @@ onBeforeUnmount(() => {
|
|
|
447
486
|
</div>
|
|
448
487
|
</label>
|
|
449
488
|
|
|
450
|
-
<label v-if="pairCandidates.length" class="theme-editor-field">
|
|
489
|
+
<label v-if="!draft.recipe && pairCandidates.length" class="theme-editor-field">
|
|
451
490
|
<span class="theme-editor-field-label">Pair with</span>
|
|
452
491
|
<select v-model="draft.pairId" class="theme-editor-input" @change="onMetaChange">
|
|
453
492
|
<option :value="undefined">- none -</option>
|
|
@@ -479,9 +518,16 @@ onBeforeUnmount(() => {
|
|
|
479
518
|
<span v-if="modifiedTotal > 0" class="theme-editor-modified">
|
|
480
519
|
{{ modifiedTotal }} token{{ modifiedTotal === 1 ? '' : 's' }} customized
|
|
481
520
|
</span>
|
|
482
|
-
<
|
|
483
|
-
<
|
|
484
|
-
|
|
521
|
+
<details class="theme-editor-export">
|
|
522
|
+
<summary class="cpub-btn cpub-btn-sm">
|
|
523
|
+
<i class="fa-solid fa-file-export" aria-hidden="true" /> Export
|
|
524
|
+
</summary>
|
|
525
|
+
<div class="theme-editor-export-menu">
|
|
526
|
+
<button type="button" @click="exportTheme"><i class="fa-solid fa-file-code" aria-hidden="true" /> Theme (.cpub-theme.json)</button>
|
|
527
|
+
<button type="button" @click="exportBrief"><i class="fa-solid fa-robot" aria-hidden="true" /> AI brief (.md)</button>
|
|
528
|
+
<button type="button" @click="exportTokens"><i class="fa-solid fa-file-lines" aria-hidden="true" /> Tokens (.json)</button>
|
|
529
|
+
</div>
|
|
530
|
+
</details>
|
|
485
531
|
<button class="cpub-btn cpub-btn-sm" :disabled="saving || !dirty" @click="() => save()">
|
|
486
532
|
<i :class="['fa-solid', saving ? 'fa-circle-notch fa-spin' : 'fa-floppy-disk']" aria-hidden="true" />
|
|
487
533
|
{{ saving ? 'Saving…' : 'Save' }}
|
|
@@ -514,14 +560,18 @@ onBeforeUnmount(() => {
|
|
|
514
560
|
v-if="studioMode"
|
|
515
561
|
class="theme-editor-studio"
|
|
516
562
|
:recipe="draft.recipe"
|
|
563
|
+
:name="draft.name"
|
|
517
564
|
@generate="onStudioGenerate"
|
|
518
565
|
@finish="onStudioFinish"
|
|
566
|
+
@rename="onStudioRename"
|
|
519
567
|
@roll="onStudioRoll"
|
|
520
568
|
/>
|
|
521
569
|
<section v-else class="theme-editor-tokens" aria-label="Token editor">
|
|
522
570
|
<p v-if="draft.recipe" class="theme-editor-studio-hint">
|
|
523
571
|
<i class="fa-solid fa-circle-info" aria-hidden="true" />
|
|
524
|
-
This theme
|
|
572
|
+
This theme is a light + dark pair (one card in the picker). Visitors switch between them
|
|
573
|
+
with the site's Light/Dark toggle. Re-opening Studio and changing it overwrites manual
|
|
574
|
+
token tweaks here.
|
|
525
575
|
</p>
|
|
526
576
|
<AdminThemeTokenGroup
|
|
527
577
|
v-for="group in TOKEN_GROUP_ORDER"
|
|
@@ -669,6 +719,35 @@ onBeforeUnmount(() => {
|
|
|
669
719
|
color: var(--accent);
|
|
670
720
|
}
|
|
671
721
|
|
|
722
|
+
.theme-editor-export { position: relative; }
|
|
723
|
+
.theme-editor-export > summary { list-style: none; cursor: pointer; }
|
|
724
|
+
.theme-editor-export > summary::-webkit-details-marker { display: none; }
|
|
725
|
+
.theme-editor-export-menu {
|
|
726
|
+
position: absolute;
|
|
727
|
+
right: 0;
|
|
728
|
+
top: calc(100% + 4px);
|
|
729
|
+
z-index: var(--z-dropdown);
|
|
730
|
+
display: flex;
|
|
731
|
+
flex-direction: column;
|
|
732
|
+
min-width: 220px;
|
|
733
|
+
background: var(--surface);
|
|
734
|
+
border: var(--border-width-default) solid var(--border);
|
|
735
|
+
box-shadow: var(--shadow-md);
|
|
736
|
+
}
|
|
737
|
+
.theme-editor-export-menu button {
|
|
738
|
+
display: flex;
|
|
739
|
+
align-items: center;
|
|
740
|
+
gap: var(--space-2);
|
|
741
|
+
padding: var(--space-2) var(--space-3);
|
|
742
|
+
background: none;
|
|
743
|
+
border: 0;
|
|
744
|
+
text-align: left;
|
|
745
|
+
font-size: var(--text-sm);
|
|
746
|
+
color: var(--text);
|
|
747
|
+
cursor: pointer;
|
|
748
|
+
}
|
|
749
|
+
.theme-editor-export-menu button:hover { background: var(--surface2); }
|
|
750
|
+
|
|
672
751
|
.theme-editor-error {
|
|
673
752
|
margin: 0;
|
|
674
753
|
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,
|
|
@@ -245,11 +256,12 @@ function captureCurrent(): void {
|
|
|
245
256
|
notify('No custom tokens detected at :root', 'error');
|
|
246
257
|
return;
|
|
247
258
|
}
|
|
259
|
+
const capturedId = nextAvailableId(`captured-${new Date().toISOString().slice(0, 10)}`);
|
|
248
260
|
const seed = {
|
|
249
|
-
id:
|
|
261
|
+
id: capturedId,
|
|
250
262
|
name: 'Captured current site theme',
|
|
251
263
|
description: `Auto-captured from the live :root on ${new Date().toLocaleDateString()}, ${detected.count} tokens.`,
|
|
252
|
-
family:
|
|
264
|
+
family: capturedId,
|
|
253
265
|
isDark: detected.isDark,
|
|
254
266
|
parentTheme: detected.isDark ? 'dark' : 'base',
|
|
255
267
|
tokens: detected.tokens,
|
|
@@ -397,9 +409,6 @@ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
|
|
|
397
409
|
</p>
|
|
398
410
|
</div>
|
|
399
411
|
<div class="admin-theme-actions">
|
|
400
|
-
<button class="cpub-btn" :disabled="saving" @click="openImportDialog">
|
|
401
|
-
<i class="fa-solid fa-file-import" aria-hidden="true" /> Import…
|
|
402
|
-
</button>
|
|
403
412
|
<input
|
|
404
413
|
ref="importFileInput"
|
|
405
414
|
type="file"
|
|
@@ -407,20 +416,36 @@ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
|
|
|
407
416
|
hidden
|
|
408
417
|
@change="onImportFile"
|
|
409
418
|
/>
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
<i class="fa-solid fa-
|
|
416
|
-
</
|
|
417
|
-
<
|
|
418
|
-
<
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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>
|
|
424
449
|
</div>
|
|
425
450
|
</header>
|
|
426
451
|
|
|
@@ -496,6 +521,41 @@ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
|
|
|
496
521
|
.admin-page-desc { font-size: var(--text-sm); color: var(--text-dim); margin: 0; max-width: 560px; line-height: var(--leading-snug); }
|
|
497
522
|
.admin-theme-actions { display: flex; gap: var(--space-2); margin-left: auto; }
|
|
498
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
|
+
|
|
499
559
|
.admin-theme-toast {
|
|
500
560
|
position: fixed;
|
|
501
561
|
top: calc(var(--nav-height) + var(--space-4));
|
package/plugins/theme.ts
CHANGED
|
@@ -11,6 +11,9 @@ export default defineNuxtPlugin(() => {
|
|
|
11
11
|
const isDark = useState<boolean>('cpub-dark-mode', () => false);
|
|
12
12
|
const themeInlineCss = useState<string>('cpub-theme-inline-css', () => '');
|
|
13
13
|
const themeFontHref = useState<string>('cpub-theme-font-href', () => '');
|
|
14
|
+
// Light/dark attrs of a custom pair — lets the user toggle flip data-theme
|
|
15
|
+
// instantly client-side (both variants' tokens are injected below).
|
|
16
|
+
const themePair = useState<{ lightAttr: string; darkAttr: string } | null>('cpub-theme-pair', () => null);
|
|
14
17
|
|
|
15
18
|
if (import.meta.server) {
|
|
16
19
|
const event = useRequestEvent();
|
|
@@ -20,6 +23,7 @@ export default defineNuxtPlugin(() => {
|
|
|
20
23
|
isDark.value = event.context.isDarkMode ?? false;
|
|
21
24
|
themeInlineCss.value = event.context.themeInlineCss ?? '';
|
|
22
25
|
themeFontHref.value = event.context.themeFontHref ?? '';
|
|
26
|
+
themePair.value = event.context.themePair ?? null;
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -16,6 +16,8 @@ declare module 'h3' {
|
|
|
16
16
|
themeInlineCss: string;
|
|
17
17
|
/** Google Fonts stylesheet URL for the active custom theme's fonts. Empty if none. */
|
|
18
18
|
themeFontHref: string;
|
|
19
|
+
/** Light/dark data-theme attrs of a custom pair (for the client toggle). */
|
|
20
|
+
themePair: { lightAttr: string; darkAttr: string } | null;
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -40,14 +42,25 @@ export default defineEventHandler(async (event) => {
|
|
|
40
42
|
event.context.resolvedTheme = ctx.resolvedTheme;
|
|
41
43
|
event.context.isDarkMode = ctx.isDark;
|
|
42
44
|
|
|
43
|
-
// Build the inline style block.
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
// Build the inline style block.
|
|
46
|
+
// - Custom theme(s): one block per variant, scoped to its `[data-theme]`
|
|
47
|
+
// attr, with instance overrides merged in (so overrides win + apply in
|
|
48
|
+
// every mode). A light/dark PAIR injects BOTH, so the client toggle can
|
|
49
|
+
// flip `data-theme` and switch instantly — no server round-trip.
|
|
50
|
+
// - Built-in / registered: only instance overrides at `:root` (their CSS
|
|
51
|
+
// files already handle light/dark).
|
|
52
|
+
if (ctx.themeVariants.length > 0) {
|
|
53
|
+
event.context.themeInlineCss = ctx.themeVariants
|
|
54
|
+
.map((v) => tokensToCss(`:root[data-theme="${v.attr}"]`, { ...v.tokens, ...ctx.overrides }))
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.join('\n');
|
|
57
|
+
} else {
|
|
58
|
+
event.context.themeInlineCss = Object.keys(ctx.overrides).length > 0
|
|
59
|
+
? tokensToCss(':root', ctx.overrides)
|
|
60
|
+
: '';
|
|
61
|
+
}
|
|
50
62
|
|
|
51
63
|
// Google Fonts for the active custom theme (CSP already allows googleapis).
|
|
52
64
|
event.context.themeFontHref = ctx.fontHref;
|
|
65
|
+
event.context.themePair = ctx.pair;
|
|
53
66
|
});
|
|
@@ -104,9 +104,18 @@ export async function resolveThemeContext(
|
|
|
104
104
|
instanceTheme: string;
|
|
105
105
|
/** Whether the resolved theme is dark */
|
|
106
106
|
isDark: boolean;
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Custom-theme token blocks to inject, one per variant, each scoped to its
|
|
109
|
+
* own `[data-theme]` selector. For a light/dark PAIR this is BOTH variants,
|
|
110
|
+
* so the client can flip `data-theme` and switch instantly (no round-trip).
|
|
111
|
+
* Empty for built-in / registered themes (their CSS files handle modes).
|
|
112
|
+
*/
|
|
113
|
+
themeVariants: Array<{ attr: string; tokens: Record<string, string> }>;
|
|
114
|
+
/** Instance-wide token overrides (apply in every mode). */
|
|
115
|
+
overrides: Record<string, string>;
|
|
116
|
+
/** Light/dark attrs of a custom pair, so the client toggle can flip instantly. */
|
|
117
|
+
pair: { lightAttr: string; darkAttr: string } | null;
|
|
118
|
+
/** Google Fonts stylesheet URL for the active custom theme(s) fonts. Empty when none. */
|
|
110
119
|
fontHref: string;
|
|
111
120
|
}> {
|
|
112
121
|
const state = await getState();
|
|
@@ -114,61 +123,52 @@ export async function resolveThemeContext(
|
|
|
114
123
|
// Validate the admin's choice — fall back to base if missing/unknown
|
|
115
124
|
const admin = isKnownThemeId(state.defaultTheme, state, registeredIds) ? state.defaultTheme : 'base';
|
|
116
125
|
|
|
117
|
-
|
|
118
|
-
// custom themes use their declared pair if present, otherwise stay put.
|
|
126
|
+
const activeCustom = state.customByAttr.get(admin);
|
|
119
127
|
let resolved = admin;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
let isDark = false;
|
|
129
|
+
let themeVariants: Array<{ attr: string; tokens: Record<string, string> }> = [];
|
|
130
|
+
let pair: { lightAttr: string; darkAttr: string } | null = null;
|
|
131
|
+
let fontHref = '';
|
|
132
|
+
|
|
133
|
+
if (activeCustom) {
|
|
134
|
+
// Gather the pair members (the default + its sibling, if both exist).
|
|
135
|
+
const members: Array<{ attr: string; rec: CustomThemeRecord }> = [{ attr: admin, rec: activeCustom }];
|
|
136
|
+
if (activeCustom.pairId) {
|
|
137
|
+
const sibAttr = `cpub-custom-${activeCustom.pairId}`;
|
|
138
|
+
const sib = state.customByAttr.get(sibAttr);
|
|
139
|
+
if (sib) members.push({ attr: sibAttr, rec: sib });
|
|
140
|
+
}
|
|
141
|
+
themeVariants = members.map((m) => ({ attr: m.attr, tokens: m.rec.tokens }));
|
|
142
|
+
const lightM = members.find((m) => !m.rec.isDark);
|
|
143
|
+
const darkM = members.find((m) => m.rec.isDark);
|
|
144
|
+
if (members.length === 2 && lightM && darkM) {
|
|
145
|
+
pair = { lightAttr: lightM.attr, darkAttr: darkM.attr };
|
|
146
|
+
}
|
|
147
|
+
// <html data-theme> = the variant matching the user's scheme (else the default).
|
|
148
|
+
if (userScheme === 'dark' && darkM) resolved = darkM.attr;
|
|
149
|
+
else if (userScheme === 'light' && lightM) resolved = lightM.attr;
|
|
150
|
+
else resolved = admin;
|
|
151
|
+
isDark = state.customByAttr.get(resolved)?.isDark ?? activeCustom.isDark;
|
|
152
|
+
// Load every variant's fonts so a client-side flip already has them.
|
|
153
|
+
const allFonts = [...new Set(members.flatMap((m) => m.rec.fonts ?? []))];
|
|
154
|
+
fontHref = allFonts.length ? googleHref(allFonts) : '';
|
|
155
|
+
} else {
|
|
156
|
+
// Built-in / registered: flip via the family's CSS variants on round-trip.
|
|
157
|
+
if (userScheme !== null && VALID_THEME_IDS.has(admin)) {
|
|
123
158
|
const family = THEME_TO_FAMILY[admin] ?? 'classic';
|
|
124
159
|
const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
|
|
125
160
|
resolved = userScheme === 'dark' ? variants.dark : variants.light;
|
|
126
|
-
} else {
|
|
127
|
-
// Custom theme — use pairId if defined
|
|
128
|
-
const custom = state.customByAttr.get(admin);
|
|
129
|
-
if (custom?.pairId) {
|
|
130
|
-
const pairAttr = `cpub-custom-${custom.pairId}`;
|
|
131
|
-
const pair = state.customByAttr.get(pairAttr);
|
|
132
|
-
if (pair && pair.isDark === (userScheme === 'dark')) {
|
|
133
|
-
resolved = pairAttr;
|
|
134
|
-
} else if (custom.isDark === (userScheme === 'dark')) {
|
|
135
|
-
resolved = admin;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
// For registered themes (no pair info available server-side), we leave it alone
|
|
139
|
-
// — the layer-app author can declare a pair via the future RegisteredTheme.pairId
|
|
140
161
|
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// isDark detection
|
|
144
|
-
let isDark = false;
|
|
145
|
-
if (VALID_THEME_IDS.has(resolved)) {
|
|
146
162
|
isDark = IS_DARK[resolved] ?? false;
|
|
147
|
-
} else {
|
|
148
|
-
const custom = state.customByAttr.get(resolved);
|
|
149
|
-
if (custom) isDark = custom.isDark;
|
|
150
163
|
}
|
|
151
164
|
|
|
152
|
-
// Tokens to inject inline. Built-in themes don't need injection (their
|
|
153
|
-
// CSS files are already loaded). Custom themes always inject. Token
|
|
154
|
-
// overrides apply on top of whatever theme is active.
|
|
155
|
-
const injectedTokens: Record<string, string> = {};
|
|
156
|
-
const activeCustom = state.customByAttr.get(resolved);
|
|
157
|
-
if (activeCustom) {
|
|
158
|
-
Object.assign(injectedTokens, activeCustom.tokens);
|
|
159
|
-
}
|
|
160
|
-
// Instance overrides always last so they win
|
|
161
|
-
Object.assign(injectedTokens, state.tokenOverrides);
|
|
162
|
-
|
|
163
|
-
// Google Fonts for the active custom theme (theme-studio sets `fonts`).
|
|
164
|
-
const fontHref =
|
|
165
|
-
activeCustom?.fonts && activeCustom.fonts.length > 0 ? googleHref(activeCustom.fonts) : '';
|
|
166
|
-
|
|
167
165
|
return {
|
|
168
166
|
resolvedTheme: resolved,
|
|
169
167
|
instanceTheme: admin,
|
|
170
168
|
isDark,
|
|
171
|
-
|
|
169
|
+
themeVariants,
|
|
170
|
+
overrides: { ...state.tokenOverrides },
|
|
171
|
+
pair,
|
|
172
172
|
fontHref,
|
|
173
173
|
};
|
|
174
174
|
}
|
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
|
|