@commonpub/layer 0.22.0 → 0.22.1

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.
@@ -38,6 +38,37 @@ const PREVIEW_SCENES: SceneOption[] = [
38
38
  const activeScene = ref<SceneOption['id']>('gallery');
39
39
  const previewMode = ref<'light' | 'dark'>(props.isDark ? 'dark' : 'light');
40
40
 
41
+ /**
42
+ * Map every parent-theme id to its family's light + dark variant. Mirrors
43
+ * `layers/base/utils/themeConfig.ts` THEME_TO_FAMILY + FAMILY_VARIANTS,
44
+ * inlined here so the preview pane doesn't need to import the SSR-side
45
+ * utils. Custom-theme parents (`cpub-custom-*`) and any unknown id fall
46
+ * back to the classic family — the user's tokens override on top regardless.
47
+ */
48
+ const FAMILY_VARIANT_OF: Record<string, { light: string; dark: string }> = {
49
+ base: { light: 'base', dark: 'dark' },
50
+ dark: { light: 'base', dark: 'dark' },
51
+ agora: { light: 'agora', dark: 'agora-dark' },
52
+ 'agora-dark': { light: 'agora', dark: 'agora-dark' },
53
+ generics: { light: 'generics', dark: 'generics' },
54
+ };
55
+
56
+ /**
57
+ * The actual `data-theme` attribute applied to the preview surface,
58
+ * resolved from `parentTheme` + `previewMode`. Returns `undefined` for the
59
+ * base/light case (no attribute = `:root` rules apply natively, matching
60
+ * the convention used by `applyThemeToElement` elsewhere).
61
+ *
62
+ * **Bug fix from 0.22.0**: previously `:data-theme="parentTheme"` was
63
+ * hardcoded, so the Light/Dark toggle updated a ref but never re-rendered
64
+ * the preview. Now the toggle actually swaps the rendered theme.
65
+ */
66
+ const effectiveDataTheme = computed<string | undefined>(() => {
67
+ const variants = FAMILY_VARIANT_OF[props.parentTheme] ?? FAMILY_VARIANT_OF.base!;
68
+ const v = previewMode.value === 'dark' ? variants.dark : variants.light;
69
+ return v === 'base' ? undefined : v;
70
+ });
71
+
41
72
  /**
42
73
  * Build the inline style string scoped to the preview surface. We apply
43
74
  * tokens to the wrapper element only — that scopes the in-progress theme
@@ -102,7 +133,7 @@ const previewStyle = computed(() => {
102
133
 
103
134
  <div
104
135
  class="theme-preview-surface"
105
- :data-theme="parentTheme"
136
+ :data-theme="effectiveDataTheme"
106
137
  :style="previewStyle"
107
138
  :data-preview-mode="previewMode"
108
139
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -50,16 +50,16 @@
50
50
  "vue": "^3.4.0",
51
51
  "vue-router": "^4.3.0",
52
52
  "zod": "^4.3.6",
53
- "@commonpub/editor": "0.7.11",
53
+ "@commonpub/auth": "0.6.0",
54
54
  "@commonpub/docs": "0.6.3",
55
- "@commonpub/learning": "0.5.2",
56
55
  "@commonpub/config": "0.14.0",
57
- "@commonpub/server": "2.56.0",
58
- "@commonpub/auth": "0.6.0",
56
+ "@commonpub/editor": "0.7.11",
57
+ "@commonpub/explainer": "0.7.15",
58
+ "@commonpub/learning": "0.5.2",
59
59
  "@commonpub/schema": "0.17.0",
60
60
  "@commonpub/protocol": "0.12.0",
61
61
  "@commonpub/ui": "0.9.0",
62
- "@commonpub/explainer": "0.7.15"
62
+ "@commonpub/server": "2.56.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -149,7 +149,13 @@ const pairCandidates = computed(() =>
149
149
 
150
150
  // --- Save / cancel / export -----------------------------------------
151
151
 
152
- async function save(): Promise<void> {
152
+ /**
153
+ * Save the draft. If `apply` is true, ALSO set this theme as the
154
+ * instance default in the same await chain — must happen BEFORE the
155
+ * create-mode router.replace, otherwise the navigation could unmount
156
+ * the component mid-PUT and lose the apply.
157
+ */
158
+ async function save({ apply = false }: { apply?: boolean } = {}): Promise<void> {
153
159
  saving.value = true;
154
160
  error.value = null;
155
161
  try {
@@ -163,15 +169,14 @@ async function save(): Promise<void> {
163
169
  parentTheme: draft.value.parentTheme,
164
170
  tokens: draft.value.tokens,
165
171
  };
172
+
173
+ let savedId: string;
166
174
  if (isCreating) {
167
175
  const created = await $fetch('/api/admin/themes', {
168
176
  method: 'POST',
169
177
  body: payload,
170
178
  });
171
- notify('Theme created', 'success');
172
- dirty.value = false;
173
- await themesApi.refresh();
174
- router.replace(`/admin/theme/edit/${(created as { id: string }).id}`);
179
+ savedId = (created as { id: string }).id;
175
180
  } else {
176
181
  // Cast: Nuxt's typed-route inference for dynamic URLs picks the
177
182
  // narrowest method overload (GET) — same workaround used in
@@ -180,9 +185,25 @@ async function save(): Promise<void> {
180
185
  `/api/admin/themes/${draft.value.id}`,
181
186
  { method: 'PUT', body: payload },
182
187
  );
183
- notify('Saved', 'success');
184
- dirty.value = false;
185
- await themesApi.refresh();
188
+ savedId = draft.value.id;
189
+ }
190
+
191
+ // Apply BEFORE refresh/navigation so the navigate doesn't unmount us
192
+ // mid-PUT (would lose the apply + the success toast).
193
+ if (apply) {
194
+ await $fetch('/api/admin/settings', {
195
+ method: 'PUT',
196
+ body: { key: 'theme.default', value: `cpub-custom-${savedId}` },
197
+ });
198
+ }
199
+
200
+ notify(apply ? 'Saved & applied' : (isCreating ? 'Theme created' : 'Saved'), 'success');
201
+ dirty.value = false;
202
+ await themesApi.refresh();
203
+
204
+ // Navigate LAST so all the awaits above have observable effects.
205
+ if (isCreating) {
206
+ router.replace(`/admin/theme/edit/${savedId}`);
186
207
  }
187
208
  } catch (err) {
188
209
  const msg = err instanceof Error ? err.message : 'Save failed';
@@ -194,13 +215,7 @@ async function save(): Promise<void> {
194
215
  }
195
216
 
196
217
  async function applyAndSave(): Promise<void> {
197
- await save();
198
- if (error.value) return;
199
- await $fetch('/api/admin/settings', {
200
- method: 'PUT',
201
- body: { key: 'theme.default', value: `cpub-custom-${draft.value.id}` },
202
- });
203
- notify('Saved and applied instance-wide', 'success');
218
+ await save({ apply: true });
204
219
  }
205
220
 
206
221
  function exportTheme(): void {
@@ -248,9 +263,14 @@ onBeforeUnmount(() => {
248
263
  <template>
249
264
  <div class="theme-editor">
250
265
  <header class="theme-editor-toolbar">
251
- <button class="cpub-btn cpub-btn-sm theme-editor-back" @click="cancel">
266
+ <button
267
+ class="cpub-btn cpub-btn-sm theme-editor-back"
268
+ :title="dirty ? 'You have unsaved changes' : 'Back to themes list'"
269
+ @click="cancel"
270
+ >
252
271
  <i class="fa-solid fa-arrow-left" aria-hidden="true" />
253
272
  <span>Themes</span>
273
+ <span v-if="dirty" class="theme-editor-dirty-dot" aria-label="unsaved changes"></span>
254
274
  </button>
255
275
 
256
276
  <div class="theme-editor-meta">
@@ -329,11 +349,13 @@ onBeforeUnmount(() => {
329
349
  <button class="cpub-btn cpub-btn-sm" @click="exportTheme" title="Download .cpub-theme.json">
330
350
  <i class="fa-solid fa-file-export" aria-hidden="true" /> Export
331
351
  </button>
332
- <button class="cpub-btn cpub-btn-sm" :disabled="saving || !dirty" @click="save">
333
- <i class="fa-solid fa-floppy-disk" aria-hidden="true" /> Save
352
+ <button class="cpub-btn cpub-btn-sm" :disabled="saving || !dirty" @click="() => save()">
353
+ <i :class="['fa-solid', saving ? 'fa-circle-notch fa-spin' : 'fa-floppy-disk']" aria-hidden="true" />
354
+ {{ saving ? 'Saving…' : 'Save' }}
334
355
  </button>
335
356
  <button class="cpub-btn cpub-btn-sm cpub-btn-primary" :disabled="saving" @click="applyAndSave">
336
- <i class="fa-solid fa-rocket" aria-hidden="true" /> Save &amp; apply
357
+ <i :class="['fa-solid', saving ? 'fa-circle-notch fa-spin' : 'fa-rocket']" aria-hidden="true" />
358
+ {{ saving ? 'Applying…' : 'Save & apply' }}
337
359
  </button>
338
360
  </div>
339
361
  </header>
@@ -406,7 +428,33 @@ onBeforeUnmount(() => {
406
428
  flex-wrap: wrap;
407
429
  }
408
430
 
409
- .theme-editor-back { flex-shrink: 0; }
431
+ .theme-editor-back {
432
+ flex-shrink: 0;
433
+ position: relative;
434
+ }
435
+
436
+ .theme-editor-dirty-dot {
437
+ display: inline-block;
438
+ width: 6px;
439
+ height: 6px;
440
+ background: var(--accent);
441
+ border-radius: var(--radius-full);
442
+ margin-left: 4px;
443
+ /* Subtle pulse so it draws the eye without being noisy */
444
+ animation: theme-editor-dirty-pulse 2s ease-in-out infinite;
445
+ }
446
+
447
+ @keyframes theme-editor-dirty-pulse {
448
+ 0%, 100% { opacity: 1; }
449
+ 50% { opacity: 0.4; }
450
+ }
451
+ @media (prefers-reduced-motion: reduce) {
452
+ .theme-editor-dirty-dot { animation: none; }
453
+ }
454
+
455
+ .theme-editor-input-name {
456
+ font-weight: var(--font-weight-semibold);
457
+ }
410
458
 
411
459
  .theme-editor-meta {
412
460
  display: flex;
@@ -244,6 +244,28 @@ function recheckDiscovery(): void {
244
244
  discovery.value = detectAppliedOverrides();
245
245
  }
246
246
 
247
+ /**
248
+ * Only show the "your site has a custom theme" banner when the detected
249
+ * overrides are LIKELY from a CSS file shipped by the layer app — NOT from
250
+ * a custom theme the admin has already saved (which would also appear as
251
+ * :root token overrides because the SSR middleware injects them there).
252
+ *
253
+ * Gating rules:
254
+ * - hide when the active default is already a cpub-custom-* theme
255
+ * - hide when instance-wide token overrides are set (those tokens explain
256
+ * the diff; the banner would confuse the admin into re-capturing them)
257
+ * - hide when no overrides were detected
258
+ *
259
+ * If admins want to re-capture from a fresh :root state, they can revert
260
+ * to the base theme, clear overrides, then the banner will reappear.
261
+ */
262
+ const showDiscoveryBanner = computed<boolean>(() => {
263
+ if (discovery.value.count === 0) return false;
264
+ if (instanceDefault.value.startsWith('cpub-custom-')) return false;
265
+ if (Object.keys(initialOverrides.value).length > 0) return false;
266
+ return true;
267
+ });
268
+
247
269
  // --- Token overrides (legacy / quick tweaks) ---
248
270
  // State + UI live in <AdminThemeOverridesPanel>; this page only persists
249
271
  // what the panel emits.
@@ -304,9 +326,12 @@ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
304
326
  {{ toast.msg }}
305
327
  </div>
306
328
 
307
- <!-- Discovery banner -->
329
+ <!-- Discovery banner — only when the overrides are from CSS (not from
330
+ a custom theme this admin already saved or instance-wide overrides).
331
+ Without this gate, the banner would re-appear after capture since
332
+ the custom theme it created now appears as a token override on :root. -->
308
333
  <section
309
- v-if="discovery.count > 0"
334
+ v-if="showDiscoveryBanner"
310
335
  class="admin-theme-discovery"
311
336
  role="region"
312
337
  aria-label="Discovered theme tokens"