@commonpub/layer 0.64.0 → 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 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 (~300 Nitro API routes)
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-mounted), 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.
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 &amp; 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
@@ -107,6 +107,7 @@ export default defineNuxtConfig({
107
107
  federateHubs: false,
108
108
  seamlessFederation: false,
109
109
  admin: false,
110
+ themeStudio: true,
110
111
  emailNotifications: false,
111
112
  publicApi: false,
112
113
  contentImport: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -54,15 +54,16 @@
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
56
  "@commonpub/auth": "0.8.0",
57
- "@commonpub/editor": "0.7.11",
58
- "@commonpub/config": "0.19.0",
59
- "@commonpub/docs": "0.6.3",
57
+ "@commonpub/config": "0.20.0",
60
58
  "@commonpub/learning": "0.5.2",
61
- "@commonpub/server": "2.82.0",
62
59
  "@commonpub/protocol": "0.13.0",
63
60
  "@commonpub/explainer": "0.7.15",
64
- "@commonpub/ui": "0.11.0",
65
- "@commonpub/schema": "0.35.0"
61
+ "@commonpub/docs": "0.6.3",
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",
@@ -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
- <section class="theme-editor-tokens" aria-label="Token editor">
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
- <button class="cpub-btn cpub-btn-primary" :disabled="saving" @click="createBlank">
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
  }
@@ -35,6 +35,8 @@ export default defineEventHandler(async (event) => {
35
35
  pairId: input.pairId,
36
36
  parentTheme: input.parentTheme,
37
37
  tokens: input.tokens ?? {},
38
+ recipe: input.recipe,
39
+ fonts: input.fonts,
38
40
  createdAt: existing.createdAt,
39
41
  },
40
42
  admin.id,
@@ -37,6 +37,8 @@ export default defineEventHandler(async (event) => {
37
37
  pairId: input.pairId,
38
38
  parentTheme: input.parentTheme,
39
39
  tokens: input.tokens ?? {},
40
+ recipe: input.recipe,
41
+ fonts: input.fonts,
40
42
  },
41
43
  admin.id,
42
44
  );
@@ -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
- if (state.customByAttr.has(resolved)) {
154
- Object.assign(injectedTokens, state.customByAttr.get(resolved)!.tokens);
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/theme/stoa.css CHANGED
@@ -340,6 +340,40 @@
340
340
  }
341
341
 
342
342
 
343
+ /* ═══════════════════════════════════════════
344
+ UNIVERSAL-RADIUS GUARD
345
+ base.css applies `* { border-radius: var(--radius) }`. With Stoa's rounded
346
+ --radius that rounds things that must stay sharp — most visibly it clipped
347
+ the Town Square logo SVG into a blob. Keep SVG marks / icons / illustrations
348
+ square (avatars use <img> + --radius-full, so they're unaffected). Also pin
349
+ media inside cards to the card's own corners so the rounded, overflow-hidden
350
+ card clips them cleanly instead of leaving wedge gaps.
351
+ ═══════════════════════════════════════════ */
352
+
353
+ [data-theme="stoa"] svg,
354
+ [data-theme="stoa-dark"] svg { border-radius: 0; }
355
+
356
+ [data-theme="stoa"] .cpub-content-card img,
357
+ [data-theme="stoa-dark"] .cpub-content-card img,
358
+ [data-theme="stoa"] .cpub-card-cover,
359
+ [data-theme="stoa-dark"] .cpub-card-cover { border-radius: 0; }
360
+
361
+
362
+ /* ═══════════════════════════════════════════
363
+ EDGE DEFINITION
364
+ Slightly firmer card/panel borders so cards read as objects on the warm
365
+ grounds rather than floating tints.
366
+ ═══════════════════════════════════════════ */
367
+
368
+ [data-theme="stoa"] .cpub-content-card,
369
+ [data-theme="stoa"] .cpub-sb-card,
370
+ [data-theme="stoa"] .cpub-stat-card { border-color: rgba(42, 38, 32, 0.18); }
371
+
372
+ [data-theme="stoa-dark"] .cpub-content-card,
373
+ [data-theme="stoa-dark"] .cpub-sb-card,
374
+ [data-theme="stoa-dark"] .cpub-stat-card { border-color: rgba(241, 235, 218, 0.16); }
375
+
376
+
343
377
  /* ═══════════════════════════════════════════
344
378
  LOGO SWITCH
345
379
  Stoa shares Agora's Town Square mark (hide
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
  };