@commonpub/layer 0.64.1 → 0.66.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/README.md +2 -2
- package/components/admin/theme/AdminThemePreviewPane.vue +3 -1
- package/components/admin/theme/AdminThemeSceneSheet.vue +243 -0
- package/components/admin/theme/studio/AdminThemeStudio.vue +428 -0
- package/composables/useFeatures.ts +4 -1
- package/nuxt.config.ts +1 -0
- package/package.json +8 -7
- package/pages/admin/features.vue +1 -0
- package/pages/admin/theme/edit/[id].vue +165 -1
- package/pages/admin/theme/index.vue +52 -1
- package/plugins/theme.ts +12 -0
- package/server/api/admin/themes/[id].put.ts +2 -0
- package/server/api/admin/themes/index.post.ts +2 -0
- package/server/middleware/theme.ts +5 -0
- package/server/utils/instanceTheme.ts +11 -2
- package/types/theme.ts +5 -0
- package/utils/themeIO.ts +5 -0
package/README.md
CHANGED
|
@@ -79,9 +79,9 @@ Highlights — see `layers/base/composables/*.ts` for the full set:
|
|
|
79
79
|
|
|
80
80
|
The TipTap block editor itself lives in `@commonpub/editor` (composable `useBlockEditor` is imported from there, not declared in the layer).
|
|
81
81
|
|
|
82
|
-
### Server (
|
|
82
|
+
### Server (327 Nitro API routes + 22 ActivityPub/site routes)
|
|
83
83
|
|
|
84
|
-
API routes for all CommonPub features, auth middleware (`requireAdmin`, `requireFeature`), federation endpoints (Fedify
|
|
84
|
+
API routes for all CommonPub features, auth middleware (`requireAdmin`, `requireFeature`), pure-TS ActivityPub federation endpoints (inbox/outbox/.well-known via `@commonpub/protocol`, no Fedify), per-feature audit logging (`cpub.audit.*`), layout-engine CRUD at `/api/admin/layouts/*` (gated on `features.admin` + `features.layoutEngine`), and Nitro plugins for identity startup + feature-flag override.
|
|
85
85
|
|
|
86
86
|
### Layout engine + section registry
|
|
87
87
|
|
|
@@ -23,7 +23,7 @@ const props = defineProps<{
|
|
|
23
23
|
}>();
|
|
24
24
|
|
|
25
25
|
interface SceneOption {
|
|
26
|
-
id: 'gallery' | 'prose' | 'admin';
|
|
26
|
+
id: 'gallery' | 'prose' | 'admin' | 'sheet';
|
|
27
27
|
label: string;
|
|
28
28
|
description: string;
|
|
29
29
|
icon: string;
|
|
@@ -33,6 +33,7 @@ const PREVIEW_SCENES: SceneOption[] = [
|
|
|
33
33
|
{ id: 'gallery', label: 'Components', description: 'Buttons, cards, forms, badges, prose, code', icon: 'fa-th-large' },
|
|
34
34
|
{ id: 'prose', label: 'Article', description: 'Headings, paragraphs, quote, code block, list', icon: 'fa-file-lines' },
|
|
35
35
|
{ id: 'admin', label: 'Admin shell', description: 'Topbar, sidebar, table, stat cards', icon: 'fa-gauge' },
|
|
36
|
+
{ id: 'sheet', label: 'Spec sheet', description: 'Token swatches, contrast, type ladder, spacing', icon: 'fa-swatchbook' },
|
|
36
37
|
];
|
|
37
38
|
|
|
38
39
|
const activeScene = ref<SceneOption['id']>('gallery');
|
|
@@ -140,6 +141,7 @@ const previewStyle = computed(() => {
|
|
|
140
141
|
<AdminThemeSceneGallery v-if="activeScene === 'gallery'" />
|
|
141
142
|
<AdminThemeSceneProse v-else-if="activeScene === 'prose'" />
|
|
142
143
|
<AdminThemeSceneAdmin v-else-if="activeScene === 'admin'" />
|
|
144
|
+
<AdminThemeSceneSheet v-else-if="activeScene === 'sheet'" :tokens="tokens" :mode-key="previewMode" />
|
|
143
145
|
</div>
|
|
144
146
|
</div>
|
|
145
147
|
</template>
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Preview scene: "Spec sheet" — the GAUGE design-system bench, ported to
|
|
4
|
+
* CommonPub's `--*` token namespace. Where the Gallery scene shows real
|
|
5
|
+
* components in context, this scene visualizes the raw tokens: named color
|
|
6
|
+
* swatches with live hex + WCAG readout, a type ladder, spacing bars,
|
|
7
|
+
* radius + shadow tiles, and the four font roles.
|
|
8
|
+
*
|
|
9
|
+
* It reads RESOLVED values via getComputedStyle on its root, so the hex +
|
|
10
|
+
* contrast labels are correct whether a token was set explicitly or is
|
|
11
|
+
* inherited from the parent theme. Re-reads whenever `tokens` or the
|
|
12
|
+
* preview mode changes.
|
|
13
|
+
*/
|
|
14
|
+
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
|
15
|
+
import { contrast, wcag } from '@commonpub/theme-studio';
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
/** The in-progress token override map (used as a change trigger). */
|
|
19
|
+
tokens: Record<string, string>;
|
|
20
|
+
/** Preview mode key — changes when the Light/Dark toggle flips. */
|
|
21
|
+
modeKey: string;
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
const root = ref<HTMLElement | null>(null);
|
|
25
|
+
/** Resolved token values, keyed by token name (no `--`). */
|
|
26
|
+
const resolved = ref<Record<string, string>>({});
|
|
27
|
+
|
|
28
|
+
const SWATCH_GROUPS: { title: string; items: [string, string][] }[] = [
|
|
29
|
+
{
|
|
30
|
+
title: 'Surfaces',
|
|
31
|
+
items: [
|
|
32
|
+
['bg', 'page background'],
|
|
33
|
+
['surface', 'card / panel'],
|
|
34
|
+
['surface2', 'input / hover fill'],
|
|
35
|
+
['surface3', 'deeper fill'],
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
title: 'Text',
|
|
40
|
+
items: [
|
|
41
|
+
['text', 'primary text'],
|
|
42
|
+
['text-dim', 'secondary'],
|
|
43
|
+
['text-faint', 'muted / labels'],
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
title: 'Accent + borders',
|
|
48
|
+
items: [
|
|
49
|
+
['accent', 'primary accent'],
|
|
50
|
+
['color-primary-hover', 'accent hover'],
|
|
51
|
+
['border', 'strong border'],
|
|
52
|
+
['border2', 'soft border'],
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
title: 'Semantic',
|
|
57
|
+
items: [
|
|
58
|
+
['green', 'success'],
|
|
59
|
+
['yellow', 'warning'],
|
|
60
|
+
['red', 'error'],
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const ALL_KEYS = [
|
|
66
|
+
...SWATCH_GROUPS.flatMap((g) => g.items.map(([k]) => k)),
|
|
67
|
+
'font-display',
|
|
68
|
+
'font-body',
|
|
69
|
+
'font-mono',
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const TYPE_STEPS = ['5xl', '4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'base', 'sm', 'xs'] as const;
|
|
73
|
+
const SPACE_STEPS = ['1', '2', '3', '4', '6', '8', '12', '16'] as const;
|
|
74
|
+
const RADIUS_STEPS = ['sm', 'md', 'lg', 'xl'] as const;
|
|
75
|
+
const SHADOW_STEPS = ['sm', 'md', 'lg', 'xl'] as const;
|
|
76
|
+
|
|
77
|
+
function readResolved(): void {
|
|
78
|
+
const el = root.value;
|
|
79
|
+
if (!el || typeof window === 'undefined') return;
|
|
80
|
+
const cs = getComputedStyle(el);
|
|
81
|
+
const next: Record<string, string> = {};
|
|
82
|
+
for (const key of ALL_KEYS) {
|
|
83
|
+
next[key] = cs.getPropertyValue(`--${key}`).trim();
|
|
84
|
+
}
|
|
85
|
+
resolved.value = next;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function refresh(): void {
|
|
89
|
+
void nextTick(() => readResolved());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
onMounted(refresh);
|
|
93
|
+
watch(() => [props.tokens, props.modeKey], refresh, { deep: true });
|
|
94
|
+
|
|
95
|
+
/** Normalize a resolved color to a comparable hex (best-effort). */
|
|
96
|
+
function asHex(v: string): string {
|
|
97
|
+
return v.startsWith('#') ? v.toUpperCase() : v;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const contrastReadout = computed<{ ratio: string; band: string; ok: boolean } | null>(() => {
|
|
101
|
+
const text = resolved.value['text'];
|
|
102
|
+
const bg = resolved.value['bg'];
|
|
103
|
+
if (!text || !bg || !text.startsWith('#') || !bg.startsWith('#')) return null;
|
|
104
|
+
const r = contrast(text, bg);
|
|
105
|
+
const band = wcag(r);
|
|
106
|
+
return { ratio: r.toFixed(2), band, ok: band !== 'FAIL' };
|
|
107
|
+
});
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<template>
|
|
111
|
+
<div ref="root" class="cpub-sheet">
|
|
112
|
+
<header class="cpub-sheet-head">
|
|
113
|
+
<h2 class="cpub-sheet-title">Design tokens</h2>
|
|
114
|
+
<p class="cpub-sheet-sub">Live values for the theme in progress. Built, not generated.</p>
|
|
115
|
+
</header>
|
|
116
|
+
|
|
117
|
+
<!-- Color -->
|
|
118
|
+
<section class="cpub-sheet-sec">
|
|
119
|
+
<div class="cpub-sheet-sec-h"><span class="cpub-sheet-num">01</span><h3>Color</h3></div>
|
|
120
|
+
<div class="cpub-sheet-cgrid">
|
|
121
|
+
<div v-for="grp in SWATCH_GROUPS" :key="grp.title" class="cpub-sheet-cgroup">
|
|
122
|
+
<div class="cpub-sheet-cgroup-t">{{ grp.title }}</div>
|
|
123
|
+
<div class="cpub-sheet-sw-grid">
|
|
124
|
+
<div v-for="[key, role] in grp.items" :key="key" class="cpub-sheet-sw">
|
|
125
|
+
<span class="cpub-sheet-chip" :style="{ background: `var(--${key})` }" />
|
|
126
|
+
<span class="cpub-sheet-sw-meta">
|
|
127
|
+
<span class="cpub-sheet-sw-name">--{{ key }}</span>
|
|
128
|
+
<span class="cpub-sheet-sw-hex">{{ asHex(resolved[key] || '') }}</span>
|
|
129
|
+
<span class="cpub-sheet-sw-role">{{ role }}</span>
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div v-if="contrastReadout" class="cpub-sheet-contrast">
|
|
136
|
+
<span class="cpub-sheet-contrast-tag">contrast · text on bg</span>
|
|
137
|
+
<span class="cpub-sheet-contrast-val">{{ contrastReadout.ratio }}:1</span>
|
|
138
|
+
<span class="cpub-sheet-badge" :class="contrastReadout.ok ? 'ok' : 'err'">{{ contrastReadout.band }}</span>
|
|
139
|
+
</div>
|
|
140
|
+
</section>
|
|
141
|
+
|
|
142
|
+
<!-- Type -->
|
|
143
|
+
<section class="cpub-sheet-sec">
|
|
144
|
+
<div class="cpub-sheet-sec-h"><span class="cpub-sheet-num">02</span><h3>Typography</h3></div>
|
|
145
|
+
<div class="cpub-sheet-roles">
|
|
146
|
+
<div class="cpub-sheet-role" style="font-family: var(--font-display)">
|
|
147
|
+
<span class="cpub-sheet-role-tag">Display</span>
|
|
148
|
+
<span class="cpub-sheet-role-sample" style="font-size: var(--text-3xl)">Forge the type</span>
|
|
149
|
+
</div>
|
|
150
|
+
<div class="cpub-sheet-role" style="font-family: var(--font-body)">
|
|
151
|
+
<span class="cpub-sheet-role-tag">Body</span>
|
|
152
|
+
<span class="cpub-sheet-role-sample" style="font-size: var(--text-md)">Pack my box with five dozen liquor jugs.</span>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="cpub-sheet-role" style="font-family: var(--font-mono)">
|
|
155
|
+
<span class="cpub-sheet-role-tag">Mono</span>
|
|
156
|
+
<span class="cpub-sheet-role-sample" style="font-size: var(--text-sm)">const tokens = build(recipe);</span>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="cpub-sheet-ladder">
|
|
160
|
+
<div v-for="step in TYPE_STEPS" :key="step" class="cpub-sheet-ladder-row">
|
|
161
|
+
<span class="cpub-sheet-ladder-tag">text-{{ step }}</span>
|
|
162
|
+
<span class="cpub-sheet-ladder-sample" :style="{ fontSize: `var(--text-${step})` }">Forge the type</span>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</section>
|
|
166
|
+
|
|
167
|
+
<!-- Spacing / radius / shadow -->
|
|
168
|
+
<section class="cpub-sheet-sec">
|
|
169
|
+
<div class="cpub-sheet-sec-h"><span class="cpub-sheet-num">03</span><h3>Spacing · radius · elevation</h3></div>
|
|
170
|
+
<div class="cpub-sheet-sublabel">Spacing</div>
|
|
171
|
+
<div class="cpub-sheet-space">
|
|
172
|
+
<div v-for="step in SPACE_STEPS" :key="step" class="cpub-sheet-space-row">
|
|
173
|
+
<span class="cpub-sheet-space-tag">space-{{ step }}</span>
|
|
174
|
+
<span class="cpub-sheet-space-bar" :style="{ width: `var(--space-${step})` }" />
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
<div class="cpub-sheet-sublabel">Radius</div>
|
|
178
|
+
<div class="cpub-sheet-tiles">
|
|
179
|
+
<div v-for="step in RADIUS_STEPS" :key="step" class="cpub-sheet-tile" :style="{ borderRadius: `var(--radius-${step})` }">{{ step }}</div>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="cpub-sheet-sublabel">Elevation</div>
|
|
182
|
+
<div class="cpub-sheet-tiles">
|
|
183
|
+
<div v-for="step in SHADOW_STEPS" :key="step" class="cpub-sheet-tile" :style="{ boxShadow: `var(--shadow-${step})` }">{{ step }}</div>
|
|
184
|
+
</div>
|
|
185
|
+
</section>
|
|
186
|
+
</div>
|
|
187
|
+
</template>
|
|
188
|
+
|
|
189
|
+
<style scoped>
|
|
190
|
+
.cpub-sheet {
|
|
191
|
+
font-family: var(--font-body);
|
|
192
|
+
color: var(--text);
|
|
193
|
+
max-width: 880px;
|
|
194
|
+
margin: 0 auto;
|
|
195
|
+
}
|
|
196
|
+
.cpub-sheet-head { margin-bottom: var(--space-6); }
|
|
197
|
+
.cpub-sheet-title { font-family: var(--font-display); font-size: var(--text-3xl); font-weight: var(--font-weight-bold); margin: 0; }
|
|
198
|
+
.cpub-sheet-sub { color: var(--text-dim); font-size: var(--text-sm); margin: var(--space-2) 0 0; }
|
|
199
|
+
|
|
200
|
+
.cpub-sheet-sec { padding: var(--space-6) 0; border-top: var(--border-width-thin) solid var(--border2); }
|
|
201
|
+
.cpub-sheet-sec-h { display: flex; align-items: baseline; gap: var(--space-3); margin-bottom: var(--space-5); }
|
|
202
|
+
.cpub-sheet-num { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--accent); font-weight: var(--font-weight-bold); }
|
|
203
|
+
.cpub-sheet-sec-h h3 { font-family: var(--font-display); font-size: var(--text-xl); margin: 0; font-weight: var(--font-weight-bold); }
|
|
204
|
+
|
|
205
|
+
.cpub-sheet-cgrid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-5); }
|
|
206
|
+
.cpub-sheet-cgroup-t { font-family: var(--font-mono); font-size: var(--text-label); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-dim); margin-bottom: var(--space-3); }
|
|
207
|
+
.cpub-sheet-sw-grid { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
208
|
+
.cpub-sheet-sw { display: flex; align-items: center; gap: var(--space-3); }
|
|
209
|
+
.cpub-sheet-chip { width: 40px; height: 40px; border: var(--border-width-thin) solid var(--border); border-radius: var(--radius-md); flex-shrink: 0; }
|
|
210
|
+
.cpub-sheet-sw-meta { display: flex; flex-direction: column; min-width: 0; }
|
|
211
|
+
.cpub-sheet-sw-name { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: var(--font-weight-semibold); }
|
|
212
|
+
.cpub-sheet-sw-hex { font-family: var(--font-mono); font-size: var(--text-label); color: var(--text-dim); }
|
|
213
|
+
.cpub-sheet-sw-role { font-family: var(--font-mono); font-size: var(--text-label); color: var(--text-faint); }
|
|
214
|
+
|
|
215
|
+
.cpub-sheet-contrast { display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-4); padding-top: var(--space-4); border-top: var(--border-width-thin) solid var(--border2); }
|
|
216
|
+
.cpub-sheet-contrast-tag { font-family: var(--font-mono); font-size: var(--text-label); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-dim); }
|
|
217
|
+
.cpub-sheet-contrast-val { font-family: var(--font-mono); font-size: var(--text-base); font-weight: var(--font-weight-bold); }
|
|
218
|
+
.cpub-sheet-badge { font-family: var(--font-mono); font-size: var(--text-label); font-weight: var(--font-weight-bold); text-transform: uppercase; padding: 2px 8px; border: var(--border-width-thin) solid; }
|
|
219
|
+
.cpub-sheet-badge.ok { color: var(--green); border-color: var(--green); }
|
|
220
|
+
.cpub-sheet-badge.err { color: var(--red); border-color: var(--red); }
|
|
221
|
+
|
|
222
|
+
.cpub-sheet-roles { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); margin-bottom: var(--space-5); }
|
|
223
|
+
.cpub-sheet-role { border: var(--border-width-thin) solid var(--border2); border-left: var(--border-width-thick) solid var(--accent); padding: var(--space-4); background: var(--surface); }
|
|
224
|
+
.cpub-sheet-role-tag { display: block; font-family: var(--font-mono); font-size: var(--text-label); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-dim); margin-bottom: var(--space-3); }
|
|
225
|
+
.cpub-sheet-role-sample { color: var(--text); line-height: var(--leading-tight); word-break: break-word; }
|
|
226
|
+
|
|
227
|
+
.cpub-sheet-ladder { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
228
|
+
.cpub-sheet-ladder-row { display: flex; align-items: baseline; gap: var(--space-4); border-bottom: var(--border-width-thin) dotted var(--border2); padding-bottom: 6px; }
|
|
229
|
+
.cpub-sheet-ladder-tag { font-family: var(--font-mono); font-size: var(--text-label); color: var(--text-faint); width: 80px; flex-shrink: 0; }
|
|
230
|
+
.cpub-sheet-ladder-sample { font-family: var(--font-display); color: var(--text); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: var(--font-weight-bold); }
|
|
231
|
+
|
|
232
|
+
.cpub-sheet-sublabel { font-family: var(--font-mono); font-size: var(--text-label); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); margin: var(--space-4) 0 var(--space-3); }
|
|
233
|
+
.cpub-sheet-space { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
234
|
+
.cpub-sheet-space-row { display: flex; align-items: center; gap: var(--space-4); }
|
|
235
|
+
.cpub-sheet-space-tag { font-family: var(--font-mono); font-size: var(--text-label); color: var(--text-dim); width: 80px; flex-shrink: 0; }
|
|
236
|
+
.cpub-sheet-space-bar { height: 14px; background: var(--accent); min-width: 2px; }
|
|
237
|
+
.cpub-sheet-tiles { display: flex; gap: var(--space-4); flex-wrap: wrap; }
|
|
238
|
+
.cpub-sheet-tile { width: 96px; height: 64px; background: var(--surface); border: var(--border-width-thin) solid var(--border); display: grid; place-items: center; font-family: var(--font-mono); font-size: var(--text-label); text-transform: uppercase; color: var(--text-dim); }
|
|
239
|
+
|
|
240
|
+
@media (max-width: 760px) {
|
|
241
|
+
.cpub-sheet-cgrid, .cpub-sheet-roles { grid-template-columns: 1fr; }
|
|
242
|
+
}
|
|
243
|
+
</style>
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* AdminThemeStudio — the guided theme generator (the "easy mode").
|
|
4
|
+
*
|
|
5
|
+
* A compact 4-step wizard (Color → Type → Shape → Feel) plus a dice roll.
|
|
6
|
+
* It owns a `ThemeRecipe` and, on every change, re-derives the full
|
|
7
|
+
* CommonPub token map via `recipeToTokens` and emits it. The parent editor
|
|
8
|
+
* applies the emitted tokens/recipe/fonts to the SAME draft the granular
|
|
9
|
+
* token editor edits — so Studio is a generator on top of the existing
|
|
10
|
+
* editor, not a separate surface (see [[feedback_reuse_existing_components]]).
|
|
11
|
+
*
|
|
12
|
+
* One-way by design: touching a Studio control regenerates the token set.
|
|
13
|
+
* Manual tweaks made in the advanced editor are overwritten if you change
|
|
14
|
+
* Studio again — the parent shows a warning banner to that effect.
|
|
15
|
+
*/
|
|
16
|
+
import { computed, ref, watch } from 'vue';
|
|
17
|
+
import {
|
|
18
|
+
type ThemeRecipe,
|
|
19
|
+
recipeToTokens,
|
|
20
|
+
randomizeRecipe,
|
|
21
|
+
randomName,
|
|
22
|
+
buildPalette,
|
|
23
|
+
COLOR_VIBES,
|
|
24
|
+
TYPE_VIBES,
|
|
25
|
+
SHAPE_PRESETS,
|
|
26
|
+
SHADOW_PRESETS,
|
|
27
|
+
RATIOS,
|
|
28
|
+
FONTS,
|
|
29
|
+
defaultRecipe,
|
|
30
|
+
type HarmonyScheme,
|
|
31
|
+
} from '@commonpub/theme-studio';
|
|
32
|
+
|
|
33
|
+
const props = defineProps<{ recipe?: ThemeRecipe }>();
|
|
34
|
+
|
|
35
|
+
const emit = defineEmits<{
|
|
36
|
+
generate: [{
|
|
37
|
+
recipe: ThemeRecipe;
|
|
38
|
+
tokens: Record<string, string>;
|
|
39
|
+
fonts: string[];
|
|
40
|
+
parentTheme: 'base' | 'dark';
|
|
41
|
+
isDark: boolean;
|
|
42
|
+
}];
|
|
43
|
+
finish: [];
|
|
44
|
+
roll: [{ name: string }];
|
|
45
|
+
}>();
|
|
46
|
+
|
|
47
|
+
const recipe = ref<ThemeRecipe>(props.recipe ? { ...props.recipe } : defaultRecipe());
|
|
48
|
+
|
|
49
|
+
const STEPS = [
|
|
50
|
+
{ kicker: 'Color', q: 'Pick a vibe, or your colors.' },
|
|
51
|
+
{ kicker: 'Type', q: 'Pick a type vibe, or fonts.' },
|
|
52
|
+
{ kicker: 'Shape', q: 'Rounded or sharp?' },
|
|
53
|
+
{ kicker: 'Feel', q: 'Spacing, density, motion.' },
|
|
54
|
+
] as const;
|
|
55
|
+
const step = ref(0);
|
|
56
|
+
|
|
57
|
+
const colorTab = ref<'vibe' | 'custom'>(props.recipe ? 'custom' : 'vibe');
|
|
58
|
+
const typeTab = ref<'vibe' | 'custom'>(props.recipe ? 'custom' : 'vibe');
|
|
59
|
+
const colorVibe = ref(0);
|
|
60
|
+
const typeVibe = ref(0);
|
|
61
|
+
|
|
62
|
+
// --- Emit on every change ---------------------------------------------
|
|
63
|
+
|
|
64
|
+
function emitGenerate(): void {
|
|
65
|
+
const g = recipeToTokens(recipe.value);
|
|
66
|
+
emit('generate', {
|
|
67
|
+
recipe: { ...recipe.value },
|
|
68
|
+
tokens: g.tokens,
|
|
69
|
+
fonts: g.fonts,
|
|
70
|
+
parentTheme: g.parentTheme,
|
|
71
|
+
isDark: recipe.value.mode === 'dark',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
watch(recipe, emitGenerate, { deep: true });
|
|
75
|
+
|
|
76
|
+
// --- Vibe swatch previews ---------------------------------------------
|
|
77
|
+
|
|
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
|
+
function miniPal(accent: string, scheme: HarmonyScheme, mode: 'light' | 'dark'): string[] {
|
|
85
|
+
const p = buildPalette({ accent, scheme, mode }).sem;
|
|
86
|
+
return [p.accent, p.surface2, p.surface, p.bg];
|
|
87
|
+
}
|
|
88
|
+
function palStrip(accent: string, scheme: HarmonyScheme, mode: 'light' | 'dark'): string[] {
|
|
89
|
+
const p = buildPalette({ accent, scheme, mode }).sem;
|
|
90
|
+
return [p.bg, p.surface, p.surface2, p.accent, p.text];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- Color actions -----------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function applyPalette(accent: string, scheme: HarmonyScheme, mode: 'light' | 'dark'): void {
|
|
96
|
+
recipe.value.accent = accent;
|
|
97
|
+
recipe.value.scheme = scheme;
|
|
98
|
+
recipe.value.mode = mode;
|
|
99
|
+
}
|
|
100
|
+
function onAccentHex(v: string): void {
|
|
101
|
+
if (/^#?[0-9a-fA-F]{6}$/.test(v)) recipe.value.accent = v[0] === '#' ? v : `#${v}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Type actions ------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function applyTypeSet(d: string, b: string, u: string, c: string): void {
|
|
107
|
+
recipe.value.fonts = { display: d, body: b, ui: u, code: c };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Dice --------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function roll(): void {
|
|
113
|
+
const seed = Date.now() >>> 0;
|
|
114
|
+
recipe.value = randomizeRecipe(seed);
|
|
115
|
+
colorTab.value = 'vibe';
|
|
116
|
+
typeTab.value = 'vibe';
|
|
117
|
+
emit('roll', { name: randomName(seed) });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- Nav ---------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
const isLast = computed(() => step.value === STEPS.length - 1);
|
|
123
|
+
function next(): void {
|
|
124
|
+
if (isLast.value) emit('finish');
|
|
125
|
+
else step.value++;
|
|
126
|
+
}
|
|
127
|
+
function back(): void {
|
|
128
|
+
if (step.value > 0) step.value--;
|
|
129
|
+
}
|
|
130
|
+
</script>
|
|
131
|
+
|
|
132
|
+
<template>
|
|
133
|
+
<div class="cpub-studio">
|
|
134
|
+
<header class="cpub-studio-head">
|
|
135
|
+
<div class="cpub-studio-brand">
|
|
136
|
+
<span class="cpub-studio-logo">STUDIO</span>
|
|
137
|
+
<span class="cpub-studio-tagline">guided theme builder</span>
|
|
138
|
+
</div>
|
|
139
|
+
<button type="button" class="cpub-studio-dice" title="Roll a random theme" @click="roll">
|
|
140
|
+
<i class="fa-solid fa-dice" aria-hidden="true" /> Roll
|
|
141
|
+
</button>
|
|
142
|
+
</header>
|
|
143
|
+
|
|
144
|
+
<nav class="cpub-studio-stepper" aria-label="Studio steps">
|
|
145
|
+
<button
|
|
146
|
+
v-for="(s, i) in STEPS"
|
|
147
|
+
:key="s.kicker"
|
|
148
|
+
type="button"
|
|
149
|
+
class="cpub-studio-step"
|
|
150
|
+
:class="{ active: i === step, done: i < step }"
|
|
151
|
+
:aria-current="i === step ? 'step' : undefined"
|
|
152
|
+
@click="step = i"
|
|
153
|
+
>{{ i + 1 }}</button>
|
|
154
|
+
</nav>
|
|
155
|
+
|
|
156
|
+
<div class="cpub-studio-stephead">
|
|
157
|
+
<div class="cpub-studio-kicker">{{ STEPS[step].kicker }}</div>
|
|
158
|
+
<div class="cpub-studio-q">{{ STEPS[step].q }}</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div class="cpub-studio-body">
|
|
162
|
+
<!-- STEP 1: COLOR -->
|
|
163
|
+
<div v-if="step === 0">
|
|
164
|
+
<div class="cpub-studio-tabs">
|
|
165
|
+
<button type="button" :class="{ on: colorTab === 'vibe' }" @click="colorTab = 'vibe'">By vibe</button>
|
|
166
|
+
<button type="button" :class="{ on: colorTab === 'custom' }" @click="colorTab = 'custom'">My colors</button>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<template v-if="colorTab === 'vibe'">
|
|
170
|
+
<div class="cpub-studio-vgrid">
|
|
171
|
+
<button
|
|
172
|
+
v-for="(v, i) in COLOR_VIBES"
|
|
173
|
+
:key="v.name"
|
|
174
|
+
type="button"
|
|
175
|
+
class="cpub-studio-vcard"
|
|
176
|
+
:class="{ on: i === colorVibe }"
|
|
177
|
+
@click="colorVibe = i; applyPalette(v.pals[0].a, v.pals[0].s, v.pals[0].mode)"
|
|
178
|
+
>
|
|
179
|
+
<span class="cpub-studio-vcard-name">{{ v.name }}</span>
|
|
180
|
+
<span class="cpub-studio-dots">
|
|
181
|
+
<span v-for="(d, di) in miniPal(v.pals[0].a, v.pals[0].s, v.pals[0].mode)" :key="di" :style="{ background: d }" />
|
|
182
|
+
</span>
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
<div class="cpub-studio-sublbl">Palettes / {{ COLOR_VIBES[colorVibe].name }}</div>
|
|
186
|
+
<div class="cpub-studio-pallist">
|
|
187
|
+
<button
|
|
188
|
+
v-for="(p, i) in COLOR_VIBES[colorVibe].pals"
|
|
189
|
+
:key="p.n"
|
|
190
|
+
type="button"
|
|
191
|
+
class="cpub-studio-palchip"
|
|
192
|
+
:class="{ on: recipe.accent === p.a && recipe.scheme === p.s && recipe.mode === p.mode }"
|
|
193
|
+
@click="applyPalette(p.a, p.s, p.mode)"
|
|
194
|
+
>
|
|
195
|
+
<span class="cpub-studio-palchip-name">{{ p.n }}</span>
|
|
196
|
+
<span class="cpub-studio-palstrip">
|
|
197
|
+
<span v-for="(c, ci) in palStrip(p.a, p.s, p.mode)" :key="ci" :style="{ background: c }" />
|
|
198
|
+
</span>
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
</template>
|
|
202
|
+
|
|
203
|
+
<template v-else>
|
|
204
|
+
<label class="cpub-studio-field">
|
|
205
|
+
<span class="cpub-studio-lbl">Mode</span>
|
|
206
|
+
<span class="cpub-studio-seg">
|
|
207
|
+
<button type="button" :class="{ on: recipe.mode === 'light' }" @click="recipe.mode = 'light'">Light</button>
|
|
208
|
+
<button type="button" :class="{ on: recipe.mode === 'dark' }" @click="recipe.mode = 'dark'">Dark</button>
|
|
209
|
+
</span>
|
|
210
|
+
</label>
|
|
211
|
+
<label class="cpub-studio-field">
|
|
212
|
+
<span class="cpub-studio-lbl">Accent</span>
|
|
213
|
+
<span class="cpub-studio-colorrow">
|
|
214
|
+
<input type="color" :value="recipe.accent" class="cpub-studio-colorpick" @input="recipe.accent = ($event.target as HTMLInputElement).value" />
|
|
215
|
+
<input type="text" :value="recipe.accent" maxlength="7" class="cpub-studio-input cpub-studio-mono" @input="onAccentHex(($event.target as HTMLInputElement).value)" />
|
|
216
|
+
</span>
|
|
217
|
+
</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
|
+
</template>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<!-- STEP 2: TYPE -->
|
|
227
|
+
<div v-else-if="step === 1">
|
|
228
|
+
<div class="cpub-studio-tabs">
|
|
229
|
+
<button type="button" :class="{ on: typeTab === 'vibe' }" @click="typeTab = 'vibe'">By vibe</button>
|
|
230
|
+
<button type="button" :class="{ on: typeTab === 'custom' }" @click="typeTab = 'custom'">Custom</button>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<template v-if="typeTab === 'vibe'">
|
|
234
|
+
<div class="cpub-studio-vgrid">
|
|
235
|
+
<button
|
|
236
|
+
v-for="(v, i) in TYPE_VIBES"
|
|
237
|
+
:key="v.name"
|
|
238
|
+
type="button"
|
|
239
|
+
class="cpub-studio-vcard"
|
|
240
|
+
:class="{ on: i === typeVibe }"
|
|
241
|
+
@click="typeVibe = i; applyTypeSet(v.sets[0].d, v.sets[0].b, v.sets[0].u, v.sets[0].c)"
|
|
242
|
+
>
|
|
243
|
+
<span class="cpub-studio-vcard-name">{{ v.name }}</span>
|
|
244
|
+
<span class="cpub-studio-vcard-sub">{{ v.sets.length }} sets</span>
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
<div class="cpub-studio-sublbl">Sets / {{ TYPE_VIBES[typeVibe].name }}</div>
|
|
248
|
+
<div class="cpub-studio-setlist">
|
|
249
|
+
<button
|
|
250
|
+
v-for="(s, i) in TYPE_VIBES[typeVibe].sets"
|
|
251
|
+
:key="i"
|
|
252
|
+
type="button"
|
|
253
|
+
class="cpub-studio-setchip"
|
|
254
|
+
:class="{ on: recipe.fonts.display === s.d && recipe.fonts.body === s.b }"
|
|
255
|
+
@click="applyTypeSet(s.d, s.b, s.u, s.c)"
|
|
256
|
+
>
|
|
257
|
+
<span class="cpub-studio-set-disp">{{ s.d }}</span>
|
|
258
|
+
<span class="cpub-studio-set-meta"><span>{{ s.b }}</span><span>{{ s.u }}</span><span>{{ s.c }}</span></span>
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
</template>
|
|
262
|
+
|
|
263
|
+
<template v-else>
|
|
264
|
+
<label v-for="role in ([['display','Display / headlines'],['body','Body / content'],['ui','UI / labels'],['code','Code / data']] as const)" :key="role[0]" class="cpub-studio-field">
|
|
265
|
+
<span class="cpub-studio-lbl">{{ role[1] }}</span>
|
|
266
|
+
<select :value="recipe.fonts[role[0]]" class="cpub-studio-input" @change="recipe.fonts[role[0]] = ($event.target as HTMLSelectElement).value">
|
|
267
|
+
<optgroup v-for="(fams, grp) in FONTS" :key="grp" :label="grp">
|
|
268
|
+
<option v-for="f in fams" :key="f" :value="f">{{ f }}</option>
|
|
269
|
+
</optgroup>
|
|
270
|
+
</select>
|
|
271
|
+
</label>
|
|
272
|
+
</template>
|
|
273
|
+
|
|
274
|
+
<label class="cpub-studio-field">
|
|
275
|
+
<span class="cpub-studio-lbl">Base size <span class="cpub-studio-val">{{ recipe.baseSize }}px</span></span>
|
|
276
|
+
<input type="range" min="13" max="19" :value="recipe.baseSize" class="cpub-studio-range" @input="recipe.baseSize = Number(($event.target as HTMLInputElement).value)" />
|
|
277
|
+
</label>
|
|
278
|
+
<label class="cpub-studio-field">
|
|
279
|
+
<span class="cpub-studio-lbl">Scale ratio</span>
|
|
280
|
+
<span class="cpub-studio-seg cpub-studio-seg-wrap">
|
|
281
|
+
<button v-for="r in RATIOS" :key="r.k" type="button" :class="{ on: recipe.ratio === r.k }" @click="recipe.ratio = r.k">{{ r.label }}</button>
|
|
282
|
+
</span>
|
|
283
|
+
</label>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<!-- STEP 3: SHAPE -->
|
|
287
|
+
<div v-else-if="step === 2">
|
|
288
|
+
<label class="cpub-studio-field">
|
|
289
|
+
<span class="cpub-studio-lbl">Corner language</span>
|
|
290
|
+
<span class="cpub-studio-seg cpub-studio-seg-wrap">
|
|
291
|
+
<button v-for="sp in SHAPE_PRESETS" :key="sp.k" type="button" :class="{ on: recipe.shapeRadius === sp.r }" @click="recipe.shapeRadius = sp.r">{{ sp.label }}<small>{{ sp.sub }}</small></button>
|
|
292
|
+
</span>
|
|
293
|
+
</label>
|
|
294
|
+
<label class="cpub-studio-field">
|
|
295
|
+
<span class="cpub-studio-lbl">Fine radius <span class="cpub-studio-val">{{ recipe.shapeRadius }}px</span></span>
|
|
296
|
+
<input type="range" min="0" max="28" :value="recipe.shapeRadius" class="cpub-studio-range" @input="recipe.shapeRadius = Number(($event.target as HTMLInputElement).value)" />
|
|
297
|
+
</label>
|
|
298
|
+
<label class="cpub-studio-field">
|
|
299
|
+
<span class="cpub-studio-lbl">Border weight</span>
|
|
300
|
+
<span class="cpub-studio-seg">
|
|
301
|
+
<button v-for="bw in [1,2,3,4]" :key="bw" type="button" :class="{ on: recipe.borderWidth === bw }" @click="recipe.borderWidth = bw">{{ bw }}px</button>
|
|
302
|
+
</span>
|
|
303
|
+
</label>
|
|
304
|
+
<label class="cpub-studio-field">
|
|
305
|
+
<span class="cpub-studio-lbl">Shadow style</span>
|
|
306
|
+
<span class="cpub-studio-seg cpub-studio-seg-wrap">
|
|
307
|
+
<button v-for="sh in SHADOW_PRESETS" :key="sh.k" type="button" :class="{ on: recipe.shadowStyle === sh.k }" @click="recipe.shadowStyle = sh.k">{{ sh.label }}<small>{{ sh.sub }}</small></button>
|
|
308
|
+
</span>
|
|
309
|
+
</label>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
<!-- STEP 4: FEEL -->
|
|
313
|
+
<div v-else>
|
|
314
|
+
<label class="cpub-studio-field">
|
|
315
|
+
<span class="cpub-studio-lbl">Spacing base</span>
|
|
316
|
+
<span class="cpub-studio-seg">
|
|
317
|
+
<button type="button" :class="{ on: recipe.spaceBase === 4 }" @click="recipe.spaceBase = 4">4px<small>tight</small></button>
|
|
318
|
+
<button type="button" :class="{ on: recipe.spaceBase === 8 }" @click="recipe.spaceBase = 8">8px<small>airy</small></button>
|
|
319
|
+
</span>
|
|
320
|
+
</label>
|
|
321
|
+
<label class="cpub-studio-field">
|
|
322
|
+
<span class="cpub-studio-lbl">Density</span>
|
|
323
|
+
<span class="cpub-studio-seg">
|
|
324
|
+
<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
|
+
</span>
|
|
326
|
+
</label>
|
|
327
|
+
<label class="cpub-studio-field">
|
|
328
|
+
<span class="cpub-studio-lbl">Motion</span>
|
|
329
|
+
<span class="cpub-studio-seg">
|
|
330
|
+
<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
|
+
</span>
|
|
332
|
+
</label>
|
|
333
|
+
<p class="cpub-studio-note">
|
|
334
|
+
Studio saves a matching light + dark pair, each tuned for its mode. Finish to drop
|
|
335
|
+
into the advanced editor with every token populated; re-open Studio any time, or
|
|
336
|
+
fine-tune individual tokens by hand.
|
|
337
|
+
</p>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<footer class="cpub-studio-foot">
|
|
342
|
+
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="step === 0" @click="back">
|
|
343
|
+
<i class="fa-solid fa-arrow-left" aria-hidden="true" /> Back
|
|
344
|
+
</button>
|
|
345
|
+
<button type="button" class="cpub-btn cpub-btn-sm cpub-btn-primary" @click="next">
|
|
346
|
+
<template v-if="isLast"><i class="fa-solid fa-check" aria-hidden="true" /> Generate & edit</template>
|
|
347
|
+
<template v-else>Next <i class="fa-solid fa-arrow-right" aria-hidden="true" /></template>
|
|
348
|
+
</button>
|
|
349
|
+
</footer>
|
|
350
|
+
</div>
|
|
351
|
+
</template>
|
|
352
|
+
|
|
353
|
+
<style scoped>
|
|
354
|
+
.cpub-studio { display: flex; flex-direction: column; height: 100%; min-height: 0; background: var(--surface); }
|
|
355
|
+
|
|
356
|
+
.cpub-studio-head { display: flex; align-items: center; justify-content: space-between; padding: var(--space-3) var(--space-4); border-bottom: var(--border-width-default) solid var(--border); }
|
|
357
|
+
.cpub-studio-logo { font-family: var(--font-mono); font-weight: var(--font-weight-bold); letter-spacing: var(--tracking-widest); font-size: var(--text-md); }
|
|
358
|
+
.cpub-studio-tagline { font-family: var(--font-mono); font-size: var(--text-label); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); margin-left: var(--space-2); }
|
|
359
|
+
.cpub-studio-dice { display: inline-flex; align-items: center; gap: 6px; background: var(--surface2); border: var(--border-width-thin) solid var(--border2); color: var(--text-dim); font-family: var(--font-mono); font-size: var(--text-label); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 6px 10px; cursor: pointer; }
|
|
360
|
+
.cpub-studio-dice:hover { border-color: var(--accent); color: var(--accent); }
|
|
361
|
+
|
|
362
|
+
.cpub-studio-stepper { display: flex; gap: var(--space-1); padding: var(--space-3) var(--space-4) 0; }
|
|
363
|
+
.cpub-studio-step { flex: 1; height: 28px; background: var(--surface2); border: var(--border-width-thin) solid var(--border2); color: var(--text-faint); font-family: var(--font-mono); font-weight: var(--font-weight-bold); font-size: var(--text-sm); cursor: pointer; }
|
|
364
|
+
.cpub-studio-step.done { color: var(--text-dim); }
|
|
365
|
+
.cpub-studio-step.active { background: var(--accent); color: var(--color-on-accent); border-color: var(--accent); }
|
|
366
|
+
|
|
367
|
+
.cpub-studio-stephead { padding: var(--space-3) var(--space-4) 0; }
|
|
368
|
+
.cpub-studio-kicker { font-family: var(--font-mono); font-size: var(--text-label); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--accent); }
|
|
369
|
+
.cpub-studio-q { font-size: var(--text-md); font-weight: var(--font-weight-semibold); margin-top: 4px; }
|
|
370
|
+
|
|
371
|
+
.cpub-studio-body { flex: 1; overflow-y: auto; min-height: 0; padding: var(--space-3) var(--space-4) var(--space-5); }
|
|
372
|
+
|
|
373
|
+
.cpub-studio-tabs { display: flex; background: var(--surface2); border: var(--border-width-thin) solid var(--border2); padding: 2px; margin-bottom: var(--space-3); }
|
|
374
|
+
.cpub-studio-tabs button { flex: 1; padding: 6px; background: none; border: 0; font-family: var(--font-mono); font-size: var(--text-label); font-weight: var(--font-weight-bold); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); cursor: pointer; }
|
|
375
|
+
.cpub-studio-tabs button.on { background: var(--surface); color: var(--text); }
|
|
376
|
+
|
|
377
|
+
.cpub-studio-vgrid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); }
|
|
378
|
+
.cpub-studio-vcard { display: flex; flex-direction: column; gap: 6px; background: var(--surface2); border: var(--border-width-thin) solid var(--border2); padding: var(--space-2); cursor: pointer; text-align: left; }
|
|
379
|
+
.cpub-studio-vcard:hover { border-color: var(--text-faint); }
|
|
380
|
+
.cpub-studio-vcard.on { border-color: var(--accent); background: var(--accent-bg); }
|
|
381
|
+
.cpub-studio-vcard-name { font-family: var(--font-mono); font-size: var(--text-label); font-weight: var(--font-weight-bold); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text); }
|
|
382
|
+
.cpub-studio-vcard-sub { font-size: var(--text-label); color: var(--text-faint); }
|
|
383
|
+
.cpub-studio-dots { display: flex; gap: 3px; }
|
|
384
|
+
.cpub-studio-dots span { flex: 1; height: 14px; }
|
|
385
|
+
|
|
386
|
+
.cpub-studio-sublbl { font-family: var(--font-mono); font-size: var(--text-label); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); margin: var(--space-4) 0 var(--space-2); }
|
|
387
|
+
.cpub-studio-pallist, .cpub-studio-setlist { display: flex; flex-direction: column; gap: 6px; }
|
|
388
|
+
.cpub-studio-palchip { display: flex; align-items: center; gap: var(--space-2); background: var(--surface2); border: var(--border-width-thin) solid var(--border2); padding: 6px 8px; cursor: pointer; }
|
|
389
|
+
.cpub-studio-palchip:hover { border-color: var(--text-faint); }
|
|
390
|
+
.cpub-studio-palchip.on { border-color: var(--accent); background: var(--accent-bg); }
|
|
391
|
+
.cpub-studio-palchip-name { font-family: var(--font-mono); font-size: var(--text-label); font-weight: var(--font-weight-semibold); text-transform: uppercase; width: 76px; flex-shrink: 0; color: var(--text-dim); }
|
|
392
|
+
.cpub-studio-palstrip { display: flex; flex: 1; height: 20px; overflow: hidden; }
|
|
393
|
+
.cpub-studio-palstrip span { flex: 1; }
|
|
394
|
+
|
|
395
|
+
.cpub-studio-setchip { display: flex; flex-direction: column; gap: 2px; background: var(--surface2); border: var(--border-width-thin) solid var(--border2); padding: var(--space-2); cursor: pointer; text-align: left; }
|
|
396
|
+
.cpub-studio-setchip:hover { border-color: var(--text-faint); }
|
|
397
|
+
.cpub-studio-setchip.on { border-color: var(--accent); background: var(--accent-bg); }
|
|
398
|
+
.cpub-studio-set-disp { font-size: var(--text-md); color: var(--text); }
|
|
399
|
+
.cpub-studio-set-meta { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
400
|
+
.cpub-studio-set-meta span { font-family: var(--font-mono); font-size: var(--text-label); color: var(--text-faint); background: var(--surface3); padding: 1px 5px; }
|
|
401
|
+
|
|
402
|
+
.cpub-studio-field { display: block; margin-top: var(--space-3); }
|
|
403
|
+
.cpub-studio-field:first-child { margin-top: 0; }
|
|
404
|
+
.cpub-studio-lbl { display: block; font-family: var(--font-mono); font-size: var(--text-label); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-dim); margin-bottom: 6px; }
|
|
405
|
+
.cpub-studio-val { float: right; color: var(--accent); text-transform: none; }
|
|
406
|
+
|
|
407
|
+
.cpub-studio-input { width: 100%; background: var(--surface2); border: var(--border-width-thin) solid var(--border2); color: var(--text); font-family: var(--font-body); font-size: var(--text-sm); padding: 7px 9px; }
|
|
408
|
+
.cpub-studio-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
409
|
+
.cpub-studio-mono { font-family: var(--font-mono); }
|
|
410
|
+
|
|
411
|
+
.cpub-studio-colorrow { display: flex; gap: var(--space-2); align-items: center; }
|
|
412
|
+
.cpub-studio-colorpick { width: 40px; height: 36px; padding: 0; border: var(--border-width-thin) solid var(--border2); background: none; cursor: pointer; flex-shrink: 0; }
|
|
413
|
+
|
|
414
|
+
.cpub-studio-seg { display: grid; gap: 4px; grid-auto-flow: column; grid-auto-columns: 1fr; }
|
|
415
|
+
.cpub-studio-seg-wrap { grid-auto-flow: row; grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); }
|
|
416
|
+
.cpub-studio-seg button { background: var(--surface2); border: var(--border-width-thin) solid var(--border2); color: var(--text-dim); font-family: var(--font-mono); font-size: var(--text-label); font-weight: var(--font-weight-semibold); padding: 7px 4px; cursor: pointer; text-align: center; line-height: 1.2; }
|
|
417
|
+
.cpub-studio-seg button small { display: block; font-size: 8px; color: var(--text-faint); font-weight: var(--font-weight-normal); margin-top: 1px; text-transform: uppercase; }
|
|
418
|
+
.cpub-studio-seg button:hover { border-color: var(--text-faint); color: var(--text); }
|
|
419
|
+
.cpub-studio-seg button.on { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); }
|
|
420
|
+
|
|
421
|
+
.cpub-studio-range { width: 100%; }
|
|
422
|
+
.cpub-studio-range:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
423
|
+
|
|
424
|
+
.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
|
+
.cpub-studio-foot { display: flex; gap: var(--space-2); padding: var(--space-3) var(--space-4); border-top: var(--border-width-default) solid var(--border); }
|
|
427
|
+
.cpub-studio-foot .cpub-btn:last-child { margin-left: auto; }
|
|
428
|
+
</style>
|
|
@@ -23,6 +23,8 @@ export interface FeatureFlags {
|
|
|
23
23
|
editorial: boolean;
|
|
24
24
|
federation: boolean;
|
|
25
25
|
admin: boolean;
|
|
26
|
+
/** Guided theme generator (theme-studio) in the admin theme builder. Default ON. */
|
|
27
|
+
themeStudio: boolean;
|
|
26
28
|
emailNotifications: boolean;
|
|
27
29
|
publicApi: boolean;
|
|
28
30
|
contentImport: boolean;
|
|
@@ -64,7 +66,7 @@ let hydrated = false;
|
|
|
64
66
|
export const DEFAULT_FLAGS: FeatureFlags = {
|
|
65
67
|
content: true, social: true, hubs: true, docs: true, video: true,
|
|
66
68
|
contests: false, events: false, learning: true, explainers: true,
|
|
67
|
-
editorial: true, federation: false, admin: false, emailNotifications: false,
|
|
69
|
+
editorial: true, federation: false, admin: false, themeStudio: true, emailNotifications: false,
|
|
68
70
|
publicApi: false, contentImport: true,
|
|
69
71
|
layoutEngine: false,
|
|
70
72
|
rbac: false,
|
|
@@ -169,6 +171,7 @@ export function useFeatures() {
|
|
|
169
171
|
editorial: computed(() => flags.value.editorial),
|
|
170
172
|
federation: computed(() => flags.value.federation),
|
|
171
173
|
admin: computed(() => flags.value.admin),
|
|
174
|
+
themeStudio: computed(() => flags.value.themeStudio),
|
|
172
175
|
emailNotifications: computed(() => flags.value.emailNotifications),
|
|
173
176
|
publicApi: computed(() => flags.value.publicApi),
|
|
174
177
|
contentImport: computed(() => flags.value.contentImport),
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,15 +53,16 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
-
"@commonpub/config": "0.19.0",
|
|
57
56
|
"@commonpub/auth": "0.8.0",
|
|
58
|
-
"@commonpub/
|
|
57
|
+
"@commonpub/config": "0.20.0",
|
|
59
58
|
"@commonpub/docs": "0.6.3",
|
|
60
|
-
"@commonpub/schema": "0.35.0",
|
|
61
|
-
"@commonpub/editor": "0.7.11",
|
|
62
59
|
"@commonpub/protocol": "0.13.0",
|
|
63
|
-
"@commonpub/
|
|
64
|
-
"@commonpub/
|
|
60
|
+
"@commonpub/editor": "0.7.11",
|
|
61
|
+
"@commonpub/explainer": "0.7.15",
|
|
62
|
+
"@commonpub/schema": "0.36.0",
|
|
63
|
+
"@commonpub/ui": "0.11.2",
|
|
64
|
+
"@commonpub/server": "2.83.0",
|
|
65
|
+
"@commonpub/theme-studio": "0.2.0",
|
|
65
66
|
"@commonpub/learning": "0.5.2"
|
|
66
67
|
},
|
|
67
68
|
"devDependencies": {
|
package/pages/admin/features.vue
CHANGED
|
@@ -31,6 +31,7 @@ const flagMeta: Record<string, { label: string; description: string; icon: strin
|
|
|
31
31
|
seamlessFederation: { label: 'Seamless Federation', description: 'Mix federated content into feeds', icon: 'fa-solid fa-arrows-spin' },
|
|
32
32
|
federateHubs: { label: 'Federate Hubs', description: 'Hub federation via AP Groups', icon: 'fa-solid fa-diagram-project' },
|
|
33
33
|
admin: { label: 'Admin Panel', description: 'Admin dashboard and management', icon: 'fa-solid fa-shield-halved' },
|
|
34
|
+
themeStudio: { label: 'Theme Studio', description: 'Guided theme generator in the theme builder', icon: 'fa-solid fa-wand-magic-sparkles' },
|
|
34
35
|
emailNotifications: { label: 'Email Notifications', description: 'Email digests and instant notifications', icon: 'fa-solid fa-envelope' },
|
|
35
36
|
};
|
|
36
37
|
|
|
@@ -17,6 +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
21
|
|
|
21
22
|
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
22
23
|
|
|
@@ -42,6 +43,8 @@ interface DraftTheme {
|
|
|
42
43
|
pairId?: string;
|
|
43
44
|
parentTheme: string;
|
|
44
45
|
tokens: Record<string, string>;
|
|
46
|
+
recipe?: ThemeRecipe;
|
|
47
|
+
fonts?: string[];
|
|
45
48
|
createdAt?: string;
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -55,6 +58,20 @@ const draft = ref<DraftTheme>({
|
|
|
55
58
|
tokens: {},
|
|
56
59
|
});
|
|
57
60
|
|
|
61
|
+
const { themeStudio } = useFeatures();
|
|
62
|
+
/** Which left-pane editor is showing: the Studio wizard or the token grid. */
|
|
63
|
+
const studioMode = ref(false);
|
|
64
|
+
|
|
65
|
+
// Load the draft's chosen Google Fonts while editing so the preview + sheet
|
|
66
|
+
// render them live (mirrors the SSR <link> the active theme gets in prod).
|
|
67
|
+
useHead({
|
|
68
|
+
link: computed(() => {
|
|
69
|
+
const fonts = draft.value.fonts ?? [];
|
|
70
|
+
const href = fonts.length ? googleHref(fonts) : '';
|
|
71
|
+
return href ? [{ key: 'cpub-studio-preview-fonts', rel: 'stylesheet', href }] : [];
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
|
|
58
75
|
// --- Load -------------------------------------------------------------
|
|
59
76
|
|
|
60
77
|
onMounted(async () => {
|
|
@@ -72,7 +89,11 @@ onMounted(async () => {
|
|
|
72
89
|
pairId: seed.pairId,
|
|
73
90
|
parentTheme: seed.parentTheme ?? 'base',
|
|
74
91
|
tokens: seed.tokens ?? {},
|
|
92
|
+
recipe: seed.recipe,
|
|
93
|
+
fonts: seed.fonts,
|
|
75
94
|
};
|
|
95
|
+
// The create chooser flags Guided/Dice seeds to open Studio first.
|
|
96
|
+
if (seed.openStudio && themeStudio.value) studioMode.value = true;
|
|
76
97
|
} catch {
|
|
77
98
|
// Bad seed — start blank
|
|
78
99
|
draft.value.id = 'my-theme';
|
|
@@ -96,6 +117,8 @@ onMounted(async () => {
|
|
|
96
117
|
pairId: theme.pairId,
|
|
97
118
|
parentTheme: theme.parentTheme,
|
|
98
119
|
tokens: { ...theme.tokens },
|
|
120
|
+
recipe: theme.recipe,
|
|
121
|
+
fonts: theme.fonts,
|
|
99
122
|
createdAt: theme.createdAt,
|
|
100
123
|
};
|
|
101
124
|
} catch (err) {
|
|
@@ -133,6 +156,38 @@ function resetToken(key: string): void {
|
|
|
133
156
|
|
|
134
157
|
const modifiedTotal = computed(() => Object.keys(draft.value.tokens).length);
|
|
135
158
|
|
|
159
|
+
// --- Studio (guided generator) ---------------------------------------
|
|
160
|
+
|
|
161
|
+
/** Studio regenerated the whole token set — replace the draft's tokens. */
|
|
162
|
+
function onStudioGenerate(payload: {
|
|
163
|
+
recipe: ThemeRecipe;
|
|
164
|
+
tokens: Record<string, string>;
|
|
165
|
+
fonts: string[];
|
|
166
|
+
parentTheme: 'base' | 'dark';
|
|
167
|
+
isDark: boolean;
|
|
168
|
+
}): void {
|
|
169
|
+
draft.value.tokens = { ...payload.tokens };
|
|
170
|
+
draft.value.recipe = payload.recipe;
|
|
171
|
+
draft.value.fonts = payload.fonts;
|
|
172
|
+
draft.value.parentTheme = payload.parentTheme;
|
|
173
|
+
draft.value.isDark = payload.isDark;
|
|
174
|
+
dirty.value = true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** "Generate & edit" — leave Studio for the granular token editor. */
|
|
178
|
+
function onStudioFinish(): void {
|
|
179
|
+
studioMode.value = false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Dice roll suggests a name; adopt it only if the user hasn't named it. */
|
|
183
|
+
function onStudioRoll(payload: { name: string }): void {
|
|
184
|
+
const cur = draft.value.name.trim();
|
|
185
|
+
if (!cur || cur === 'My theme') {
|
|
186
|
+
draft.value.name = payload.name.charAt(0) + payload.name.slice(1).toLowerCase();
|
|
187
|
+
dirty.value = true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
136
191
|
// --- Metadata edits ---------------------------------------------------
|
|
137
192
|
|
|
138
193
|
function onMetaChange(): void { dirty.value = true; }
|
|
@@ -149,16 +204,61 @@ const pairCandidates = computed(() =>
|
|
|
149
204
|
|
|
150
205
|
// --- Save / cancel / export -----------------------------------------
|
|
151
206
|
|
|
207
|
+
/** The opposite-mode sibling's id for a paired Studio theme. */
|
|
208
|
+
function siblingIdFor(id: string, isDark: boolean): string {
|
|
209
|
+
const base = id.replace(/-(light|dark)$/, '');
|
|
210
|
+
return isDark ? `${base}-light` : `${base}-dark`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Create/update the matching opposite-mode sibling of a Studio theme from the
|
|
215
|
+
* SAME recipe, so every Studio theme is a coherent light+dark pair (linked via
|
|
216
|
+
* pairId, sharing one family). The sibling is recipe-derived — it tracks the
|
|
217
|
+
* recipe, while per-token tweaks live on whichever variant you're editing.
|
|
218
|
+
*/
|
|
219
|
+
async function upsertSibling(recipe: ThemeRecipe, siblingId: string): Promise<void> {
|
|
220
|
+
const siblingDark = !draft.value.isDark;
|
|
221
|
+
const siblingRecipe: ThemeRecipe = { ...recipe, mode: siblingDark ? 'dark' : 'light' };
|
|
222
|
+
const gen = recipeToTokens(siblingRecipe);
|
|
223
|
+
const body = {
|
|
224
|
+
id: siblingId,
|
|
225
|
+
name: draft.value.name,
|
|
226
|
+
description: draft.value.description,
|
|
227
|
+
family: draft.value.family,
|
|
228
|
+
isDark: siblingDark,
|
|
229
|
+
pairId: draft.value.id,
|
|
230
|
+
parentTheme: gen.parentTheme,
|
|
231
|
+
tokens: gen.tokens,
|
|
232
|
+
recipe: siblingRecipe,
|
|
233
|
+
fonts: gen.fonts,
|
|
234
|
+
};
|
|
235
|
+
const put = $fetch as (url: string, opts: Record<string, unknown>) => Promise<unknown>;
|
|
236
|
+
if (themesApi.findCustom(siblingId)) {
|
|
237
|
+
await put(`/api/admin/themes/${siblingId}`, { method: 'PUT', body });
|
|
238
|
+
} else {
|
|
239
|
+
await $fetch('/api/admin/themes', { method: 'POST', body });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
152
243
|
/**
|
|
153
244
|
* Save the draft. If `apply` is true, ALSO set this theme as the
|
|
154
245
|
* instance default in the same await chain — must happen BEFORE the
|
|
155
246
|
* create-mode router.replace, otherwise the navigation could unmount
|
|
156
247
|
* the component mid-PUT and lose the apply.
|
|
248
|
+
*
|
|
249
|
+
* Studio (recipe-driven) themes are saved as a light+dark PAIR: the primary
|
|
250
|
+
* (this draft) plus its recipe-derived opposite-mode sibling, cross-linked
|
|
251
|
+
* via pairId in one family.
|
|
157
252
|
*/
|
|
158
253
|
async function save({ apply = false }: { apply?: boolean } = {}): Promise<void> {
|
|
159
254
|
saving.value = true;
|
|
160
255
|
error.value = null;
|
|
161
256
|
try {
|
|
257
|
+
// Pair bookkeeping: a Studio theme links to its opposite-mode sibling.
|
|
258
|
+
const recipe = draft.value.recipe;
|
|
259
|
+
const siblingId = recipe ? siblingIdFor(draft.value.id, draft.value.isDark) : null;
|
|
260
|
+
if (siblingId) draft.value.pairId = siblingId;
|
|
261
|
+
|
|
162
262
|
const payload = {
|
|
163
263
|
id: draft.value.id,
|
|
164
264
|
name: draft.value.name,
|
|
@@ -168,6 +268,8 @@ async function save({ apply = false }: { apply?: boolean } = {}): Promise<void>
|
|
|
168
268
|
pairId: draft.value.pairId,
|
|
169
269
|
parentTheme: draft.value.parentTheme,
|
|
170
270
|
tokens: draft.value.tokens,
|
|
271
|
+
recipe: draft.value.recipe,
|
|
272
|
+
fonts: draft.value.fonts,
|
|
171
273
|
};
|
|
172
274
|
|
|
173
275
|
let savedId: string;
|
|
@@ -188,6 +290,16 @@ async function save({ apply = false }: { apply?: boolean } = {}): Promise<void>
|
|
|
188
290
|
savedId = draft.value.id;
|
|
189
291
|
}
|
|
190
292
|
|
|
293
|
+
// Create/update the matching opposite-mode sibling (recipe-driven pair).
|
|
294
|
+
// Soft-fail: the primary is already saved; a sibling hiccup shouldn't lose it.
|
|
295
|
+
if (recipe && siblingId) {
|
|
296
|
+
try {
|
|
297
|
+
await upsertSibling(recipe, siblingId);
|
|
298
|
+
} catch {
|
|
299
|
+
notify('Saved, but the matching light/dark variant could not sync', 'error');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
191
303
|
// Apply BEFORE refresh/navigation so the navigate doesn't unmount us
|
|
192
304
|
// mid-PUT (would lose the apply + the success toast).
|
|
193
305
|
if (apply) {
|
|
@@ -230,6 +342,8 @@ function exportTheme(): void {
|
|
|
230
342
|
pairId: draft.value.pairId,
|
|
231
343
|
parentTheme: draft.value.parentTheme,
|
|
232
344
|
tokens: draft.value.tokens,
|
|
345
|
+
recipe: draft.value.recipe,
|
|
346
|
+
fonts: draft.value.fonts,
|
|
233
347
|
createdAt: draft.value.createdAt ?? new Date().toISOString(),
|
|
234
348
|
updatedAt: new Date().toISOString(),
|
|
235
349
|
});
|
|
@@ -343,6 +457,25 @@ onBeforeUnmount(() => {
|
|
|
343
457
|
</div>
|
|
344
458
|
|
|
345
459
|
<div class="theme-editor-actions">
|
|
460
|
+
<div
|
|
461
|
+
v-if="themeStudio && (draft.recipe || studioMode)"
|
|
462
|
+
class="theme-editor-mode-pill"
|
|
463
|
+
role="group"
|
|
464
|
+
aria-label="Editor mode"
|
|
465
|
+
>
|
|
466
|
+
<button
|
|
467
|
+
type="button"
|
|
468
|
+
class="theme-editor-mode-btn"
|
|
469
|
+
:class="{ active: studioMode }"
|
|
470
|
+
@click="studioMode = true"
|
|
471
|
+
><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true" /> Studio</button>
|
|
472
|
+
<button
|
|
473
|
+
type="button"
|
|
474
|
+
class="theme-editor-mode-btn"
|
|
475
|
+
:class="{ active: !studioMode }"
|
|
476
|
+
@click="studioMode = false"
|
|
477
|
+
><i class="fa-solid fa-sliders" aria-hidden="true" /> Advanced</button>
|
|
478
|
+
</div>
|
|
346
479
|
<span v-if="modifiedTotal > 0" class="theme-editor-modified">
|
|
347
480
|
{{ modifiedTotal }} token{{ modifiedTotal === 1 ? '' : 's' }} customized
|
|
348
481
|
</span>
|
|
@@ -377,7 +510,19 @@ onBeforeUnmount(() => {
|
|
|
377
510
|
</p>
|
|
378
511
|
|
|
379
512
|
<div v-else class="theme-editor-body">
|
|
380
|
-
<
|
|
513
|
+
<AdminThemeStudio
|
|
514
|
+
v-if="studioMode"
|
|
515
|
+
class="theme-editor-studio"
|
|
516
|
+
:recipe="draft.recipe"
|
|
517
|
+
@generate="onStudioGenerate"
|
|
518
|
+
@finish="onStudioFinish"
|
|
519
|
+
@roll="onStudioRoll"
|
|
520
|
+
/>
|
|
521
|
+
<section v-else class="theme-editor-tokens" aria-label="Token editor">
|
|
522
|
+
<p v-if="draft.recipe" class="theme-editor-studio-hint">
|
|
523
|
+
<i class="fa-solid fa-circle-info" aria-hidden="true" />
|
|
524
|
+
This theme was built with Studio. Re-opening Studio and changing it overwrites manual token tweaks here.
|
|
525
|
+
</p>
|
|
381
526
|
<AdminThemeTokenGroup
|
|
382
527
|
v-for="group in TOKEN_GROUP_ORDER"
|
|
383
528
|
:key="group"
|
|
@@ -564,6 +709,25 @@ onBeforeUnmount(() => {
|
|
|
564
709
|
min-height: 0;
|
|
565
710
|
}
|
|
566
711
|
|
|
712
|
+
.theme-editor-studio {
|
|
713
|
+
border-right: var(--border-width-default) solid var(--border);
|
|
714
|
+
min-height: 0;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
.theme-editor-studio-hint {
|
|
718
|
+
display: flex;
|
|
719
|
+
align-items: flex-start;
|
|
720
|
+
gap: var(--space-2);
|
|
721
|
+
margin: 0;
|
|
722
|
+
padding: var(--space-3) var(--space-4);
|
|
723
|
+
background: var(--accent-bg);
|
|
724
|
+
border-bottom: var(--border-width-thin) solid var(--accent-border);
|
|
725
|
+
color: var(--text-dim);
|
|
726
|
+
font-size: var(--text-sm);
|
|
727
|
+
line-height: var(--leading-snug);
|
|
728
|
+
}
|
|
729
|
+
.theme-editor-studio-hint i { color: var(--accent); margin-top: 2px; }
|
|
730
|
+
|
|
567
731
|
.theme-editor-preview {
|
|
568
732
|
min-height: 0;
|
|
569
733
|
overflow: hidden;
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* surface.
|
|
22
22
|
*/
|
|
23
23
|
import { onMounted, ref, computed, watch } from 'vue';
|
|
24
|
+
import { recipeToTokens, randomizeRecipe, defaultRecipe, randomName, type ThemeRecipe } from '@commonpub/theme-studio';
|
|
24
25
|
// Auto-imported by Nuxt:
|
|
25
26
|
// useThemeAdmin ← composables/useThemeAdmin.ts
|
|
26
27
|
// parseCustomThemeId ← utils/themeIds.ts
|
|
@@ -34,6 +35,7 @@ useSeoMeta({ title: `Theme, Admin, ${useSiteName()}` });
|
|
|
34
35
|
|
|
35
36
|
const themesApi = useThemeAdmin();
|
|
36
37
|
const router = useRouter();
|
|
38
|
+
const { themeStudio } = useFeatures();
|
|
37
39
|
|
|
38
40
|
const { data: settings, refresh: refreshSettings } = await useFetch<Record<string, unknown>>('/api/admin/settings');
|
|
39
41
|
|
|
@@ -199,6 +201,44 @@ function createBlank(): void {
|
|
|
199
201
|
router.push('/admin/theme/edit/__new');
|
|
200
202
|
}
|
|
201
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Seed a new theme from a theme-studio recipe and open the editor straight
|
|
206
|
+
* into the Studio wizard (the `openStudio` flag). Used by both the guided
|
|
207
|
+
* start (a neutral default recipe) and the dice roll (a random one).
|
|
208
|
+
*/
|
|
209
|
+
function startFromRecipe(recipe: ThemeRecipe, opts: { id: string; name: string }): void {
|
|
210
|
+
const gen = recipeToTokens(recipe);
|
|
211
|
+
const id = nextAvailableId(opts.id);
|
|
212
|
+
const seed = {
|
|
213
|
+
id,
|
|
214
|
+
name: opts.name,
|
|
215
|
+
description: '',
|
|
216
|
+
// Unique family per theme (= the slug) so the picker keeps each Studio
|
|
217
|
+
// theme separate AND can group its light/dark pair together. (A shared
|
|
218
|
+
// 'custom' family would collapse every Studio theme into one card.)
|
|
219
|
+
family: id,
|
|
220
|
+
isDark: recipe.mode === 'dark',
|
|
221
|
+
parentTheme: gen.parentTheme,
|
|
222
|
+
tokens: gen.tokens,
|
|
223
|
+
recipe,
|
|
224
|
+
fonts: gen.fonts,
|
|
225
|
+
openStudio: true,
|
|
226
|
+
};
|
|
227
|
+
sessionStorage.setItem('cpub-theme-editor-seed', JSON.stringify(seed));
|
|
228
|
+
router.push('/admin/theme/edit/__new');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function startGuided(): void {
|
|
232
|
+
startFromRecipe(defaultRecipe(), { id: 'my-theme', name: 'My theme' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function startDice(): void {
|
|
236
|
+
const seed = Date.now() >>> 0;
|
|
237
|
+
const name = randomName(seed);
|
|
238
|
+
const pretty = name.charAt(0) + name.slice(1).toLowerCase();
|
|
239
|
+
startFromRecipe(randomizeRecipe(seed), { id: pretty, name: pretty });
|
|
240
|
+
}
|
|
241
|
+
|
|
202
242
|
function captureCurrent(): void {
|
|
203
243
|
const detected = detectAppliedOverrides();
|
|
204
244
|
if (detected.count === 0) {
|
|
@@ -367,7 +407,18 @@ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
|
|
|
367
407
|
hidden
|
|
368
408
|
@change="onImportFile"
|
|
369
409
|
/>
|
|
370
|
-
<
|
|
410
|
+
<template v-if="themeStudio">
|
|
411
|
+
<button class="cpub-btn" :disabled="saving" title="Roll a random theme" @click="startDice">
|
|
412
|
+
<i class="fa-solid fa-dice" aria-hidden="true" /> Surprise me
|
|
413
|
+
</button>
|
|
414
|
+
<button class="cpub-btn" :disabled="saving" @click="createBlank">
|
|
415
|
+
<i class="fa-solid fa-plus" aria-hidden="true" /> Blank
|
|
416
|
+
</button>
|
|
417
|
+
<button class="cpub-btn cpub-btn-primary" :disabled="saving" @click="startGuided">
|
|
418
|
+
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true" /> Build with Studio
|
|
419
|
+
</button>
|
|
420
|
+
</template>
|
|
421
|
+
<button v-else class="cpub-btn cpub-btn-primary" :disabled="saving" @click="createBlank">
|
|
371
422
|
<i class="fa-solid fa-plus" aria-hidden="true" /> New custom theme
|
|
372
423
|
</button>
|
|
373
424
|
</div>
|
package/plugins/theme.ts
CHANGED
|
@@ -10,6 +10,7 @@ export default defineNuxtPlugin(() => {
|
|
|
10
10
|
const instanceTheme = useState<string>('cpub-instance-theme', () => 'base');
|
|
11
11
|
const isDark = useState<boolean>('cpub-dark-mode', () => false);
|
|
12
12
|
const themeInlineCss = useState<string>('cpub-theme-inline-css', () => '');
|
|
13
|
+
const themeFontHref = useState<string>('cpub-theme-font-href', () => '');
|
|
13
14
|
|
|
14
15
|
if (import.meta.server) {
|
|
15
16
|
const event = useRequestEvent();
|
|
@@ -18,6 +19,7 @@ export default defineNuxtPlugin(() => {
|
|
|
18
19
|
instanceTheme.value = event.context.instanceTheme ?? 'base';
|
|
19
20
|
isDark.value = event.context.isDarkMode ?? false;
|
|
20
21
|
themeInlineCss.value = event.context.themeInlineCss ?? '';
|
|
22
|
+
themeFontHref.value = event.context.themeFontHref ?? '';
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -44,6 +46,16 @@ export default defineNuxtPlugin(() => {
|
|
|
44
46
|
id: 'cpub-theme-inline',
|
|
45
47
|
}];
|
|
46
48
|
}
|
|
49
|
+
if (themeFontHref.value) {
|
|
50
|
+
// Load the active custom theme's Google Fonts (theme-studio). CSP in
|
|
51
|
+
// server/middleware/security.ts already allows fonts.googleapis.com.
|
|
52
|
+
head.link = [{
|
|
53
|
+
key: 'cpub-theme-fonts',
|
|
54
|
+
rel: 'stylesheet',
|
|
55
|
+
href: themeFontHref.value,
|
|
56
|
+
id: 'cpub-theme-fonts',
|
|
57
|
+
}];
|
|
58
|
+
}
|
|
47
59
|
if (Object.keys(head).length > 0) {
|
|
48
60
|
useHead(head);
|
|
49
61
|
}
|
|
@@ -14,6 +14,8 @@ declare module 'h3' {
|
|
|
14
14
|
isDarkMode: boolean;
|
|
15
15
|
/** Inline CSS string to inject (custom theme tokens + instance overrides). Empty if none. */
|
|
16
16
|
themeInlineCss: string;
|
|
17
|
+
/** Google Fonts stylesheet URL for the active custom theme's fonts. Empty if none. */
|
|
18
|
+
themeFontHref: string;
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -45,4 +47,7 @@ export default defineEventHandler(async (event) => {
|
|
|
45
47
|
event.context.themeInlineCss = Object.keys(ctx.injectedTokens).length > 0
|
|
46
48
|
? tokensToCss(':root', ctx.injectedTokens)
|
|
47
49
|
: '';
|
|
50
|
+
|
|
51
|
+
// Google Fonts for the active custom theme (CSP already allows googleapis).
|
|
52
|
+
event.context.themeFontHref = ctx.fontHref;
|
|
48
53
|
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
parseCustomThemeId,
|
|
11
11
|
type CustomThemeRecord,
|
|
12
12
|
} from '@commonpub/server';
|
|
13
|
+
import { googleHref } from '@commonpub/theme-studio';
|
|
13
14
|
import { THEME_TO_FAMILY, FAMILY_VARIANTS, IS_DARK, VALID_THEME_IDS } from '../../utils/themeConfig';
|
|
14
15
|
|
|
15
16
|
const CACHE_TTL = 60_000; // 1 minute, admin changes propagate fast
|
|
@@ -105,6 +106,8 @@ export async function resolveThemeContext(
|
|
|
105
106
|
isDark: boolean;
|
|
106
107
|
/** Token map to inject as inline :root style (custom theme tokens + overrides). Empty when not needed. */
|
|
107
108
|
injectedTokens: Record<string, string>;
|
|
109
|
+
/** Google Fonts stylesheet URL for the active custom theme's fonts. Empty when none. */
|
|
110
|
+
fontHref: string;
|
|
108
111
|
}> {
|
|
109
112
|
const state = await getState();
|
|
110
113
|
|
|
@@ -150,17 +153,23 @@ export async function resolveThemeContext(
|
|
|
150
153
|
// CSS files are already loaded). Custom themes always inject. Token
|
|
151
154
|
// overrides apply on top of whatever theme is active.
|
|
152
155
|
const injectedTokens: Record<string, string> = {};
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
const activeCustom = state.customByAttr.get(resolved);
|
|
157
|
+
if (activeCustom) {
|
|
158
|
+
Object.assign(injectedTokens, activeCustom.tokens);
|
|
155
159
|
}
|
|
156
160
|
// Instance overrides always last so they win
|
|
157
161
|
Object.assign(injectedTokens, state.tokenOverrides);
|
|
158
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
|
+
|
|
159
167
|
return {
|
|
160
168
|
resolvedTheme: resolved,
|
|
161
169
|
instanceTheme: admin,
|
|
162
170
|
isDark,
|
|
163
171
|
injectedTokens,
|
|
172
|
+
fontHref,
|
|
164
173
|
};
|
|
165
174
|
}
|
|
166
175
|
|
package/types/theme.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Node-only server modules into the browser bundle.
|
|
8
8
|
*/
|
|
9
9
|
import type { ThemeDefinition } from '@commonpub/ui';
|
|
10
|
+
import type { ThemeRecipe } from '@commonpub/theme-studio';
|
|
10
11
|
|
|
11
12
|
export interface CustomThemeRecord {
|
|
12
13
|
id: string;
|
|
@@ -17,6 +18,10 @@ export interface CustomThemeRecord {
|
|
|
17
18
|
pairId?: string;
|
|
18
19
|
parentTheme: string;
|
|
19
20
|
tokens: Record<string, string>;
|
|
21
|
+
/** Generator recipe (theme-studio), present when the theme was made/edited in Studio. */
|
|
22
|
+
recipe?: ThemeRecipe;
|
|
23
|
+
/** Google-Font families to load when this theme is active. */
|
|
24
|
+
fonts?: string[];
|
|
20
25
|
createdAt: string;
|
|
21
26
|
updatedAt: string;
|
|
22
27
|
}
|
package/utils/themeIO.ts
CHANGED
|
@@ -56,6 +56,11 @@ export function parseExportFile(text: string): CustomThemeRecord {
|
|
|
56
56
|
pairId: typeof t.pairId === 'string' ? t.pairId : undefined,
|
|
57
57
|
parentTheme: typeof t.parentTheme === 'string' ? t.parentTheme : 'base',
|
|
58
58
|
tokens: (typeof t.tokens === 'object' && t.tokens !== null ? t.tokens : {}) as Record<string, string>,
|
|
59
|
+
recipe:
|
|
60
|
+
typeof t.recipe === 'object' && t.recipe !== null
|
|
61
|
+
? (t.recipe as CustomThemeRecord['recipe'])
|
|
62
|
+
: undefined,
|
|
63
|
+
fonts: Array.isArray(t.fonts) ? (t.fonts.filter((f) => typeof f === 'string') as string[]) : undefined,
|
|
59
64
|
createdAt: typeof t.createdAt === 'string' ? t.createdAt : new Date().toISOString(),
|
|
60
65
|
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : new Date().toISOString(),
|
|
61
66
|
};
|