@commonpub/layer 0.64.1 → 0.65.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 +427 -0
- package/composables/useFeatures.ts +4 -1
- package/nuxt.config.ts +1 -0
- package/package.json +9 -8
- package/pages/admin/features.vue +1 -0
- package/pages/admin/theme/edit/[id].vue +110 -1
- package/pages/admin/theme/index.vue +48 -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,427 @@
|
|
|
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
|
+
Finish to drop into the advanced editor with every token populated. You can re-open
|
|
335
|
+
Studio any time, or fine-tune individual tokens by hand.
|
|
336
|
+
</p>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<footer class="cpub-studio-foot">
|
|
341
|
+
<button type="button" class="cpub-btn cpub-btn-sm" :disabled="step === 0" @click="back">
|
|
342
|
+
<i class="fa-solid fa-arrow-left" aria-hidden="true" /> Back
|
|
343
|
+
</button>
|
|
344
|
+
<button type="button" class="cpub-btn cpub-btn-sm cpub-btn-primary" @click="next">
|
|
345
|
+
<template v-if="isLast"><i class="fa-solid fa-check" aria-hidden="true" /> Generate & edit</template>
|
|
346
|
+
<template v-else>Next <i class="fa-solid fa-arrow-right" aria-hidden="true" /></template>
|
|
347
|
+
</button>
|
|
348
|
+
</footer>
|
|
349
|
+
</div>
|
|
350
|
+
</template>
|
|
351
|
+
|
|
352
|
+
<style scoped>
|
|
353
|
+
.cpub-studio { display: flex; flex-direction: column; height: 100%; min-height: 0; background: var(--surface); }
|
|
354
|
+
|
|
355
|
+
.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); }
|
|
356
|
+
.cpub-studio-logo { font-family: var(--font-mono); font-weight: var(--font-weight-bold); letter-spacing: var(--tracking-widest); font-size: var(--text-md); }
|
|
357
|
+
.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); }
|
|
358
|
+
.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; }
|
|
359
|
+
.cpub-studio-dice:hover { border-color: var(--accent); color: var(--accent); }
|
|
360
|
+
|
|
361
|
+
.cpub-studio-stepper { display: flex; gap: var(--space-1); padding: var(--space-3) var(--space-4) 0; }
|
|
362
|
+
.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; }
|
|
363
|
+
.cpub-studio-step.done { color: var(--text-dim); }
|
|
364
|
+
.cpub-studio-step.active { background: var(--accent); color: var(--color-on-accent); border-color: var(--accent); }
|
|
365
|
+
|
|
366
|
+
.cpub-studio-stephead { padding: var(--space-3) var(--space-4) 0; }
|
|
367
|
+
.cpub-studio-kicker { font-family: var(--font-mono); font-size: var(--text-label); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--accent); }
|
|
368
|
+
.cpub-studio-q { font-size: var(--text-md); font-weight: var(--font-weight-semibold); margin-top: 4px; }
|
|
369
|
+
|
|
370
|
+
.cpub-studio-body { flex: 1; overflow-y: auto; min-height: 0; padding: var(--space-3) var(--space-4) var(--space-5); }
|
|
371
|
+
|
|
372
|
+
.cpub-studio-tabs { display: flex; background: var(--surface2); border: var(--border-width-thin) solid var(--border2); padding: 2px; margin-bottom: var(--space-3); }
|
|
373
|
+
.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; }
|
|
374
|
+
.cpub-studio-tabs button.on { background: var(--surface); color: var(--text); }
|
|
375
|
+
|
|
376
|
+
.cpub-studio-vgrid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); }
|
|
377
|
+
.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; }
|
|
378
|
+
.cpub-studio-vcard:hover { border-color: var(--text-faint); }
|
|
379
|
+
.cpub-studio-vcard.on { border-color: var(--accent); background: var(--accent-bg); }
|
|
380
|
+
.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); }
|
|
381
|
+
.cpub-studio-vcard-sub { font-size: var(--text-label); color: var(--text-faint); }
|
|
382
|
+
.cpub-studio-dots { display: flex; gap: 3px; }
|
|
383
|
+
.cpub-studio-dots span { flex: 1; height: 14px; }
|
|
384
|
+
|
|
385
|
+
.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); }
|
|
386
|
+
.cpub-studio-pallist, .cpub-studio-setlist { display: flex; flex-direction: column; gap: 6px; }
|
|
387
|
+
.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; }
|
|
388
|
+
.cpub-studio-palchip:hover { border-color: var(--text-faint); }
|
|
389
|
+
.cpub-studio-palchip.on { border-color: var(--accent); background: var(--accent-bg); }
|
|
390
|
+
.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); }
|
|
391
|
+
.cpub-studio-palstrip { display: flex; flex: 1; height: 20px; overflow: hidden; }
|
|
392
|
+
.cpub-studio-palstrip span { flex: 1; }
|
|
393
|
+
|
|
394
|
+
.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; }
|
|
395
|
+
.cpub-studio-setchip:hover { border-color: var(--text-faint); }
|
|
396
|
+
.cpub-studio-setchip.on { border-color: var(--accent); background: var(--accent-bg); }
|
|
397
|
+
.cpub-studio-set-disp { font-size: var(--text-md); color: var(--text); }
|
|
398
|
+
.cpub-studio-set-meta { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
399
|
+
.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; }
|
|
400
|
+
|
|
401
|
+
.cpub-studio-field { display: block; margin-top: var(--space-3); }
|
|
402
|
+
.cpub-studio-field:first-child { margin-top: 0; }
|
|
403
|
+
.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; }
|
|
404
|
+
.cpub-studio-val { float: right; color: var(--accent); text-transform: none; }
|
|
405
|
+
|
|
406
|
+
.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; }
|
|
407
|
+
.cpub-studio-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
408
|
+
.cpub-studio-mono { font-family: var(--font-mono); }
|
|
409
|
+
|
|
410
|
+
.cpub-studio-colorrow { display: flex; gap: var(--space-2); align-items: center; }
|
|
411
|
+
.cpub-studio-colorpick { width: 40px; height: 36px; padding: 0; border: var(--border-width-thin) solid var(--border2); background: none; cursor: pointer; flex-shrink: 0; }
|
|
412
|
+
|
|
413
|
+
.cpub-studio-seg { display: grid; gap: 4px; grid-auto-flow: column; grid-auto-columns: 1fr; }
|
|
414
|
+
.cpub-studio-seg-wrap { grid-auto-flow: row; grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); }
|
|
415
|
+
.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; }
|
|
416
|
+
.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; }
|
|
417
|
+
.cpub-studio-seg button:hover { border-color: var(--text-faint); color: var(--text); }
|
|
418
|
+
.cpub-studio-seg button.on { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); }
|
|
419
|
+
|
|
420
|
+
.cpub-studio-range { width: 100%; }
|
|
421
|
+
.cpub-studio-range:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
422
|
+
|
|
423
|
+
.cpub-studio-note { font-size: var(--text-sm); color: var(--text-dim); line-height: var(--leading-snug); margin: var(--space-4) 0 0; }
|
|
424
|
+
|
|
425
|
+
.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); }
|
|
426
|
+
.cpub-studio-foot .cpub-btn:last-child { margin-left: auto; }
|
|
427
|
+
</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.65.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,16 +53,17 @@
|
|
|
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",
|
|
57
|
+
"@commonpub/config": "0.20.0",
|
|
58
|
+
"@commonpub/learning": "0.5.2",
|
|
59
|
+
"@commonpub/protocol": "0.13.0",
|
|
58
60
|
"@commonpub/explainer": "0.7.15",
|
|
59
61
|
"@commonpub/docs": "0.6.3",
|
|
60
|
-
"@commonpub/schema": "0.
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/
|
|
63
|
-
"@commonpub/server": "2.
|
|
64
|
-
"@commonpub/
|
|
65
|
-
"@commonpub/learning": "0.5.2"
|
|
62
|
+
"@commonpub/schema": "0.36.0",
|
|
63
|
+
"@commonpub/theme-studio": "0.1.0",
|
|
64
|
+
"@commonpub/ui": "0.11.2",
|
|
65
|
+
"@commonpub/server": "2.83.0",
|
|
66
|
+
"@commonpub/editor": "0.7.11"
|
|
66
67
|
},
|
|
67
68
|
"devDependencies": {
|
|
68
69
|
"@testing-library/jest-dom": "^6.9.1",
|
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, 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; }
|
|
@@ -168,6 +223,8 @@ async function save({ apply = false }: { apply?: boolean } = {}): Promise<void>
|
|
|
168
223
|
pairId: draft.value.pairId,
|
|
169
224
|
parentTheme: draft.value.parentTheme,
|
|
170
225
|
tokens: draft.value.tokens,
|
|
226
|
+
recipe: draft.value.recipe,
|
|
227
|
+
fonts: draft.value.fonts,
|
|
171
228
|
};
|
|
172
229
|
|
|
173
230
|
let savedId: string;
|
|
@@ -230,6 +287,8 @@ function exportTheme(): void {
|
|
|
230
287
|
pairId: draft.value.pairId,
|
|
231
288
|
parentTheme: draft.value.parentTheme,
|
|
232
289
|
tokens: draft.value.tokens,
|
|
290
|
+
recipe: draft.value.recipe,
|
|
291
|
+
fonts: draft.value.fonts,
|
|
233
292
|
createdAt: draft.value.createdAt ?? new Date().toISOString(),
|
|
234
293
|
updatedAt: new Date().toISOString(),
|
|
235
294
|
});
|
|
@@ -343,6 +402,25 @@ onBeforeUnmount(() => {
|
|
|
343
402
|
</div>
|
|
344
403
|
|
|
345
404
|
<div class="theme-editor-actions">
|
|
405
|
+
<div
|
|
406
|
+
v-if="themeStudio && (draft.recipe || studioMode)"
|
|
407
|
+
class="theme-editor-mode-pill"
|
|
408
|
+
role="group"
|
|
409
|
+
aria-label="Editor mode"
|
|
410
|
+
>
|
|
411
|
+
<button
|
|
412
|
+
type="button"
|
|
413
|
+
class="theme-editor-mode-btn"
|
|
414
|
+
:class="{ active: studioMode }"
|
|
415
|
+
@click="studioMode = true"
|
|
416
|
+
><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true" /> Studio</button>
|
|
417
|
+
<button
|
|
418
|
+
type="button"
|
|
419
|
+
class="theme-editor-mode-btn"
|
|
420
|
+
:class="{ active: !studioMode }"
|
|
421
|
+
@click="studioMode = false"
|
|
422
|
+
><i class="fa-solid fa-sliders" aria-hidden="true" /> Advanced</button>
|
|
423
|
+
</div>
|
|
346
424
|
<span v-if="modifiedTotal > 0" class="theme-editor-modified">
|
|
347
425
|
{{ modifiedTotal }} token{{ modifiedTotal === 1 ? '' : 's' }} customized
|
|
348
426
|
</span>
|
|
@@ -377,7 +455,19 @@ onBeforeUnmount(() => {
|
|
|
377
455
|
</p>
|
|
378
456
|
|
|
379
457
|
<div v-else class="theme-editor-body">
|
|
380
|
-
<
|
|
458
|
+
<AdminThemeStudio
|
|
459
|
+
v-if="studioMode"
|
|
460
|
+
class="theme-editor-studio"
|
|
461
|
+
:recipe="draft.recipe"
|
|
462
|
+
@generate="onStudioGenerate"
|
|
463
|
+
@finish="onStudioFinish"
|
|
464
|
+
@roll="onStudioRoll"
|
|
465
|
+
/>
|
|
466
|
+
<section v-else class="theme-editor-tokens" aria-label="Token editor">
|
|
467
|
+
<p v-if="draft.recipe" class="theme-editor-studio-hint">
|
|
468
|
+
<i class="fa-solid fa-circle-info" aria-hidden="true" />
|
|
469
|
+
This theme was built with Studio. Re-opening Studio and changing it overwrites manual token tweaks here.
|
|
470
|
+
</p>
|
|
381
471
|
<AdminThemeTokenGroup
|
|
382
472
|
v-for="group in TOKEN_GROUP_ORDER"
|
|
383
473
|
:key="group"
|
|
@@ -564,6 +654,25 @@ onBeforeUnmount(() => {
|
|
|
564
654
|
min-height: 0;
|
|
565
655
|
}
|
|
566
656
|
|
|
657
|
+
.theme-editor-studio {
|
|
658
|
+
border-right: var(--border-width-default) solid var(--border);
|
|
659
|
+
min-height: 0;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.theme-editor-studio-hint {
|
|
663
|
+
display: flex;
|
|
664
|
+
align-items: flex-start;
|
|
665
|
+
gap: var(--space-2);
|
|
666
|
+
margin: 0;
|
|
667
|
+
padding: var(--space-3) var(--space-4);
|
|
668
|
+
background: var(--accent-bg);
|
|
669
|
+
border-bottom: var(--border-width-thin) solid var(--accent-border);
|
|
670
|
+
color: var(--text-dim);
|
|
671
|
+
font-size: var(--text-sm);
|
|
672
|
+
line-height: var(--leading-snug);
|
|
673
|
+
}
|
|
674
|
+
.theme-editor-studio-hint i { color: var(--accent); margin-top: 2px; }
|
|
675
|
+
|
|
567
676
|
.theme-editor-preview {
|
|
568
677
|
min-height: 0;
|
|
569
678
|
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,40 @@ 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 seed = {
|
|
212
|
+
id: nextAvailableId(opts.id),
|
|
213
|
+
name: opts.name,
|
|
214
|
+
description: '',
|
|
215
|
+
family: 'custom',
|
|
216
|
+
isDark: recipe.mode === 'dark',
|
|
217
|
+
parentTheme: gen.parentTheme,
|
|
218
|
+
tokens: gen.tokens,
|
|
219
|
+
recipe,
|
|
220
|
+
fonts: gen.fonts,
|
|
221
|
+
openStudio: true,
|
|
222
|
+
};
|
|
223
|
+
sessionStorage.setItem('cpub-theme-editor-seed', JSON.stringify(seed));
|
|
224
|
+
router.push('/admin/theme/edit/__new');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function startGuided(): void {
|
|
228
|
+
startFromRecipe(defaultRecipe(), { id: 'my-theme', name: 'My theme' });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function startDice(): void {
|
|
232
|
+
const seed = Date.now() >>> 0;
|
|
233
|
+
const name = randomName(seed);
|
|
234
|
+
const pretty = name.charAt(0) + name.slice(1).toLowerCase();
|
|
235
|
+
startFromRecipe(randomizeRecipe(seed), { id: pretty, name: pretty });
|
|
236
|
+
}
|
|
237
|
+
|
|
202
238
|
function captureCurrent(): void {
|
|
203
239
|
const detected = detectAppliedOverrides();
|
|
204
240
|
if (detected.count === 0) {
|
|
@@ -367,7 +403,18 @@ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
|
|
|
367
403
|
hidden
|
|
368
404
|
@change="onImportFile"
|
|
369
405
|
/>
|
|
370
|
-
<
|
|
406
|
+
<template v-if="themeStudio">
|
|
407
|
+
<button class="cpub-btn" :disabled="saving" title="Roll a random theme" @click="startDice">
|
|
408
|
+
<i class="fa-solid fa-dice" aria-hidden="true" /> Surprise me
|
|
409
|
+
</button>
|
|
410
|
+
<button class="cpub-btn" :disabled="saving" @click="createBlank">
|
|
411
|
+
<i class="fa-solid fa-plus" aria-hidden="true" /> Blank
|
|
412
|
+
</button>
|
|
413
|
+
<button class="cpub-btn cpub-btn-primary" :disabled="saving" @click="startGuided">
|
|
414
|
+
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true" /> Build with Studio
|
|
415
|
+
</button>
|
|
416
|
+
</template>
|
|
417
|
+
<button v-else class="cpub-btn cpub-btn-primary" :disabled="saving" @click="createBlank">
|
|
371
418
|
<i class="fa-solid fa-plus" aria-hidden="true" /> New custom theme
|
|
372
419
|
</button>
|
|
373
420
|
</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
|
};
|