@commonpub/layer 0.66.0 → 0.67.0

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