@commonpub/layer 0.22.0 → 0.23.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.
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Built-in section: hero — banner with title, optional eyebrow + subtitle,
4
+ * up to two CTAs.
5
+ *
6
+ * Phase 1c starter. Variants: `default` (left-aligned, grid backdrop),
7
+ * `compact` (smaller padding, no backdrop), `centered` (centered text
8
+ * + CTAs, grid backdrop).
9
+ *
10
+ * Intentionally does NOT replicate the existing `HomepageHeroSection`'s
11
+ * contest-dispatch logic — that becomes its own data-aware section in
12
+ * Phase 6b (`contest-feature` or similar). This hero is pure
13
+ * config-driven so the editor preview is deterministic.
14
+ *
15
+ * `var(--*)` only.
16
+ */
17
+ import type { SectionRenderProps } from '@commonpub/ui';
18
+
19
+ interface HeroCta {
20
+ label: string;
21
+ href: string;
22
+ variant: 'primary' | 'secondary';
23
+ }
24
+
25
+ interface HeroConfig extends Record<string, unknown> {
26
+ variant: 'default' | 'compact' | 'centered';
27
+ eyebrow: string;
28
+ title: string;
29
+ subtitle: string;
30
+ ctas: HeroCta[];
31
+ }
32
+
33
+ defineProps<SectionRenderProps<HeroConfig>>();
34
+ </script>
35
+
36
+ <template>
37
+ <section
38
+ class="cpub-section-hero"
39
+ :data-variant="config.variant"
40
+ :aria-labelledby="`section-hero-${meta.sectionId}`"
41
+ >
42
+ <div v-if="config.variant !== 'compact'" class="cpub-section-hero-grid-bg" aria-hidden="true" />
43
+ <div class="cpub-section-hero-inner">
44
+ <div class="cpub-section-hero-content">
45
+ <p v-if="config.eyebrow" class="cpub-section-hero-eyebrow">{{ config.eyebrow }}</p>
46
+ <h1
47
+ :id="`section-hero-${meta.sectionId}`"
48
+ class="cpub-section-hero-title"
49
+ >
50
+ {{ config.title }}
51
+ </h1>
52
+ <p v-if="config.subtitle" class="cpub-section-hero-subtitle">{{ config.subtitle }}</p>
53
+ <div v-if="config.ctas.length > 0" class="cpub-section-hero-actions">
54
+ <NuxtLink
55
+ v-for="(cta, i) in config.ctas"
56
+ :key="i"
57
+ :to="cta.href"
58
+ class="cpub-btn"
59
+ :class="cta.variant === 'primary' ? 'cpub-btn-primary' : ''"
60
+ >
61
+ {{ cta.label }}
62
+ </NuxtLink>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </section>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .cpub-section-hero {
71
+ position: relative;
72
+ background: var(--surface);
73
+ border-bottom: var(--border-width-default) solid var(--border);
74
+ overflow: hidden;
75
+ min-height: 180px;
76
+ display: flex;
77
+ align-items: stretch;
78
+ }
79
+ .cpub-section-hero[data-variant='compact'] {
80
+ min-height: 120px;
81
+ border-bottom: none;
82
+ }
83
+
84
+ .cpub-section-hero-grid-bg {
85
+ position: absolute;
86
+ inset: 0;
87
+ background-image:
88
+ linear-gradient(var(--border2) 1px, transparent 1px),
89
+ linear-gradient(90deg, var(--border2) 1px, transparent 1px);
90
+ background-size: 32px 32px;
91
+ opacity: 0.25;
92
+ pointer-events: none;
93
+ }
94
+
95
+ .cpub-section-hero-inner {
96
+ position: relative;
97
+ z-index: 1;
98
+ max-width: 1280px;
99
+ margin: 0 auto;
100
+ padding: var(--space-6) var(--space-6);
101
+ width: 100%;
102
+ display: flex;
103
+ align-items: center;
104
+ }
105
+ .cpub-section-hero[data-variant='compact'] .cpub-section-hero-inner {
106
+ padding: var(--space-4) var(--space-6);
107
+ }
108
+
109
+ .cpub-section-hero-content { flex: 1; min-width: 0; }
110
+
111
+ .cpub-section-hero[data-variant='centered'] .cpub-section-hero-inner {
112
+ justify-content: center;
113
+ }
114
+ .cpub-section-hero[data-variant='centered'] .cpub-section-hero-content {
115
+ text-align: center;
116
+ max-width: 720px;
117
+ }
118
+
119
+ .cpub-section-hero-eyebrow {
120
+ font-family: var(--font-mono);
121
+ font-size: var(--text-xs);
122
+ text-transform: uppercase;
123
+ letter-spacing: 0.1em;
124
+ color: var(--text-faint);
125
+ margin: 0 0 var(--space-2);
126
+ }
127
+
128
+ .cpub-section-hero-title {
129
+ font-size: var(--text-3xl);
130
+ font-weight: 700;
131
+ line-height: 1.2;
132
+ margin: 0 0 var(--space-2);
133
+ color: var(--text);
134
+ }
135
+ .cpub-section-hero[data-variant='compact'] .cpub-section-hero-title {
136
+ font-size: var(--text-2xl);
137
+ }
138
+
139
+ .cpub-section-hero-subtitle {
140
+ font-size: var(--text-md);
141
+ color: var(--text-dim);
142
+ line-height: 1.6;
143
+ margin: 0 0 var(--space-4);
144
+ max-width: 560px;
145
+ }
146
+ .cpub-section-hero[data-variant='centered'] .cpub-section-hero-subtitle {
147
+ margin-inline: auto;
148
+ }
149
+
150
+ .cpub-section-hero-actions {
151
+ display: flex;
152
+ flex-wrap: wrap;
153
+ gap: var(--space-2);
154
+ }
155
+ .cpub-section-hero[data-variant='centered'] .cpub-section-hero-actions {
156
+ justify-content: center;
157
+ }
158
+
159
+ @media (max-width: 640px) {
160
+ .cpub-section-hero-inner { padding: var(--space-4); }
161
+ .cpub-section-hero-title { font-size: var(--text-2xl); }
162
+ .cpub-section-hero-actions { width: 100%; }
163
+ }
164
+ </style>
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Built-in section: image — single image with optional caption + link.
4
+ *
5
+ * Phase 1c starter. Author-provided `src` is rendered as-is; aspect-ratio
6
+ * keeps cards uniform. Lazy-loaded by default for below-fold sections.
7
+ *
8
+ * Phase 3e inspector will swap the `src` text input for an ImageUpload
9
+ * picker via `.describe('image')` Zod metadata.
10
+ *
11
+ * `var(--*)` only.
12
+ */
13
+ import type { SectionRenderProps } from '@commonpub/ui';
14
+
15
+ interface ImageConfig extends Record<string, unknown> {
16
+ src: string;
17
+ alt: string;
18
+ caption: string;
19
+ href: string;
20
+ fit: 'contain' | 'cover';
21
+ aspectRatio: '16/9' | '4/3' | '1/1' | 'auto';
22
+ }
23
+
24
+ defineProps<SectionRenderProps<ImageConfig>>();
25
+ </script>
26
+
27
+ <template>
28
+ <figure
29
+ class="cpub-section-image"
30
+ :data-aspect="config.aspectRatio"
31
+ :data-fit="config.fit"
32
+ >
33
+ <!-- The `href` field is optional — link only when truthy so screen
34
+ readers don't announce a non-interactive image as a link. -->
35
+ <component
36
+ :is="config.href ? 'a' : 'div'"
37
+ :href="config.href || undefined"
38
+ class="cpub-section-image-frame"
39
+ >
40
+ <img
41
+ v-if="config.src"
42
+ :src="config.src"
43
+ :alt="config.alt"
44
+ loading="lazy"
45
+ decoding="async"
46
+ class="cpub-section-image-img"
47
+ />
48
+ <div v-else class="cpub-section-image-placeholder" aria-hidden="true">
49
+ <i class="fa-regular fa-image" />
50
+ </div>
51
+ </component>
52
+ <figcaption v-if="config.caption" class="cpub-section-image-caption">
53
+ {{ config.caption }}
54
+ </figcaption>
55
+ </figure>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .cpub-section-image {
60
+ margin: 0;
61
+ display: flex;
62
+ flex-direction: column;
63
+ gap: var(--space-2);
64
+ }
65
+ .cpub-section-image-frame {
66
+ display: block;
67
+ width: 100%;
68
+ background: var(--surface2);
69
+ border: var(--border-width-default) solid var(--border);
70
+ overflow: hidden;
71
+ text-decoration: none;
72
+ color: inherit;
73
+ }
74
+ .cpub-section-image[data-aspect='16/9'] .cpub-section-image-frame { aspect-ratio: 16 / 9; }
75
+ .cpub-section-image[data-aspect='4/3'] .cpub-section-image-frame { aspect-ratio: 4 / 3; }
76
+ .cpub-section-image[data-aspect='1/1'] .cpub-section-image-frame { aspect-ratio: 1 / 1; }
77
+
78
+ .cpub-section-image-img {
79
+ width: 100%;
80
+ height: 100%;
81
+ display: block;
82
+ }
83
+ .cpub-section-image[data-fit='cover'] .cpub-section-image-img { object-fit: cover; }
84
+ .cpub-section-image[data-fit='contain'] .cpub-section-image-img { object-fit: contain; }
85
+ /* Aspect=auto + no explicit ratio: let the image dictate height */
86
+ .cpub-section-image[data-aspect='auto'] .cpub-section-image-img {
87
+ height: auto;
88
+ }
89
+
90
+ .cpub-section-image-placeholder {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ height: 100%;
95
+ min-height: 120px;
96
+ color: var(--text-faint);
97
+ font-size: var(--text-xl);
98
+ }
99
+ .cpub-section-image-caption {
100
+ font-size: var(--text-sm);
101
+ color: var(--text-dim);
102
+ line-height: 1.5;
103
+ }
104
+ </style>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Built-in section: paragraph — plain prose body.
4
+ *
5
+ * Phase 1c starter. Stores `text: string` and splits on blank lines into
6
+ * paragraphs at render time. The auto-form inspector (Phase 3e) will
7
+ * upgrade this to a TipTap subset via the `.describe('rich')` Zod
8
+ * metadata — at which point `schemaVersion` bumps to 2 with a migration
9
+ * that converts plain text → block tuples.
10
+ *
11
+ * `var(--*)` only.
12
+ */
13
+ import { computed } from 'vue';
14
+ import type { SectionRenderProps } from '@commonpub/ui';
15
+
16
+ interface ParagraphConfig extends Record<string, unknown> {
17
+ text: string;
18
+ align: 'left' | 'center';
19
+ }
20
+
21
+ const props = defineProps<SectionRenderProps<ParagraphConfig>>();
22
+
23
+ // Blank-line split — preserves authored paragraph breaks without needing
24
+ // a rich-text editor. Empty paragraphs are dropped (defensive).
25
+ const paragraphs = computed<string[]>(() =>
26
+ (props.config.text ?? '')
27
+ .split(/\n{2,}/)
28
+ .map((p) => p.trim())
29
+ .filter((p) => p.length > 0),
30
+ );
31
+ </script>
32
+
33
+ <template>
34
+ <div class="cpub-section-paragraph" :data-align="config.align">
35
+ <p v-for="(p, i) in paragraphs" :key="i">{{ p }}</p>
36
+ </div>
37
+ </template>
38
+
39
+ <style scoped>
40
+ .cpub-section-paragraph {
41
+ margin-block: var(--space-3);
42
+ color: var(--text);
43
+ font-size: var(--text-md);
44
+ line-height: 1.7;
45
+ }
46
+ .cpub-section-paragraph[data-align='center'] {
47
+ text-align: center;
48
+ }
49
+ .cpub-section-paragraph p {
50
+ margin: 0 0 var(--space-3);
51
+ }
52
+ .cpub-section-paragraph p:last-child {
53
+ margin-bottom: 0;
54
+ }
55
+ </style>
@@ -26,6 +26,14 @@ export interface FeatureFlags {
26
26
  emailNotifications: boolean;
27
27
  publicApi: boolean;
28
28
  contentImport: boolean;
29
+ /**
30
+ * DB-backed page layout engine. Default OFF — when ON, the homepage
31
+ * (and future layout-bearing pages) render via `<LayoutSlot>` zones
32
+ * resolved from the `layouts` table. Operator MUST run
33
+ * POST /api/admin/layouts/seed-homepage before flipping this on so
34
+ * a default layout exists at scope ('route', '/'). Added session 158.
35
+ */
36
+ layoutEngine: boolean;
29
37
  /**
30
38
  * Cross-instance delegated authorization. All sub-flags default false.
31
39
  * Mirrors `@commonpub/config`'s `IdentityFeatures`. Phase 1b+ — see
@@ -46,6 +54,7 @@ export const DEFAULT_FLAGS: FeatureFlags = {
46
54
  contests: false, events: false, learning: true, explainers: true,
47
55
  editorial: true, federation: false, admin: false, emailNotifications: false,
48
56
  publicApi: false, contentImport: true,
57
+ layoutEngine: false,
49
58
  identity: {
50
59
  linkRemoteAccounts: false,
51
60
  signInWithRemote: false,
@@ -120,6 +129,7 @@ export function useFeatures() {
120
129
  emailNotifications: computed(() => flags.value.emailNotifications),
121
130
  publicApi: computed(() => flags.value.publicApi),
122
131
  contentImport: computed(() => flags.value.contentImport),
132
+ layoutEngine: computed(() => flags.value.layoutEngine),
123
133
  identity: computed(() => flags.value.identity),
124
134
  };
125
135
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Client-side composable for resolving a route's active layout.
3
+ *
4
+ * Wraps `useFetch('/api/layouts/by-route?path=…')` with SSR-safe caching:
5
+ * - Server-side: fetched during SSR, payload is included in the
6
+ * hydration snapshot (Nuxt's `useFetch` default) — same data is
7
+ * re-used on client mount with zero extra requests
8
+ * - Client-side: subsequent navigations to the same path return the
9
+ * cached value (Nuxt's request cache, keyed by `key`)
10
+ *
11
+ * Returns `null` when:
12
+ * - The layout-engine feature is OFF (`/api/layouts/by-route` 404s,
13
+ * which we surface as null so consumers fall back to legacy renderers
14
+ * gracefully)
15
+ * - No layout exists for the route
16
+ *
17
+ * The `<LayoutSlot>` component is the only intended caller in v1; other
18
+ * consumers should use that instead.
19
+ */
20
+ import type { Ref } from 'vue';
21
+
22
+ export interface LayoutSection {
23
+ id: string;
24
+ order: number;
25
+ type: string;
26
+ config: Record<string, unknown>;
27
+ colSpan: number;
28
+ responsive: { sm?: number; md?: number; lg?: number } | null;
29
+ enabled: boolean;
30
+ visibility: { roles?: string[]; features?: string[]; hideAt?: ('sm' | 'md' | 'lg')[] } | null;
31
+ schemaVersion: number;
32
+ }
33
+
34
+ export interface LayoutRow {
35
+ id: string;
36
+ order: number;
37
+ config: {
38
+ gap?: 'none' | 'sm' | 'md' | 'lg';
39
+ align?: 'start' | 'center' | 'stretch';
40
+ background?: string;
41
+ paddingY?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
42
+ } | null;
43
+ sections: LayoutSection[];
44
+ }
45
+
46
+ export interface LayoutZoneClient {
47
+ zone: string;
48
+ rows: LayoutRow[];
49
+ }
50
+
51
+ export interface LayoutPayload {
52
+ zones: LayoutZoneClient[];
53
+ pageMeta: {
54
+ title: string;
55
+ description?: string;
56
+ ogImage?: string;
57
+ noindex?: boolean;
58
+ ogType?: 'website' | 'article' | 'profile';
59
+ access?: 'public' | 'members' | 'admin';
60
+ frame?: 'narrow' | 'wide' | 'two-column' | 'three-column' | 'sidebar-left' | 'sidebar-right';
61
+ } | null;
62
+ state: 'draft' | 'published';
63
+ }
64
+
65
+ export interface UseLayoutResult {
66
+ /** The layout payload, or null if none exists / feature off. */
67
+ layout: Ref<LayoutPayload | null>;
68
+ /** True while the initial fetch is in flight. */
69
+ pending: Ref<boolean>;
70
+ /** Truthy if the fetch errored (incl. 404 for feature off). */
71
+ error: Ref<unknown>;
72
+ /** Re-fetch the layout (after a save / publish). */
73
+ refresh: () => Promise<void>;
74
+ }
75
+
76
+ /**
77
+ * Resolve the layout for a given route path. SSR-safe; caches per path
78
+ * for the request lifetime + survives hydration.
79
+ *
80
+ * Returns a layout=null Ref when the feature is off so consumers can
81
+ * `v-if="layout"` and fall through to a legacy renderer.
82
+ *
83
+ * **Reactivity**: pass a string for the typical case (literal route on
84
+ * a page) — useFetch fires once at setup. For the rare case where the
85
+ * route changes without remount (e.g. a parent component swapping the
86
+ * prop dynamically), pass a Ref or getter — useFetch will refire on
87
+ * change. Without this, a path-prop change on `<LayoutSlot>` would
88
+ * silently leave the stale fetch result in place.
89
+ */
90
+ export function useLayout(path: string | Ref<string> | (() => string)): UseLayoutResult {
91
+ const pathGetter = (): string => (
92
+ typeof path === 'string'
93
+ ? path
94
+ : typeof path === 'function'
95
+ ? path()
96
+ : path.value
97
+ );
98
+
99
+ const { data, pending, error, refresh } = useFetch<LayoutPayload | null>(
100
+ '/api/layouts/by-route',
101
+ {
102
+ // Static key for the literal-string case (so Nuxt's request cache
103
+ // can dedupe across components on the same nav). For the reactive
104
+ // case, omit key so useFetch derives one from the query Ref.
105
+ key: typeof path === 'string' ? `layout:${path}` : undefined,
106
+ // Functional query — useFetch re-evaluates on watched deps change.
107
+ query: { path: pathGetter },
108
+ // Watch the path getter so reactive callers refetch on change.
109
+ // For string callers this is empty array → no extra reactivity.
110
+ watch: typeof path === 'string' ? [] : [pathGetter],
111
+ // 404 from the API (feature off OR route has no layout) is NOT an
112
+ // exceptional case — surface as null. Don't treat as error.
113
+ onResponseError({ response }) {
114
+ if (response.status === 404) {
115
+ // Don't throw; data stays null
116
+ return;
117
+ }
118
+ },
119
+ // Falsy data on 404 maps to null
120
+ transform: (input: LayoutPayload | null | undefined) => input ?? null,
121
+ server: true,
122
+ lazy: false,
123
+ },
124
+ );
125
+
126
+ return {
127
+ layout: data as Ref<LayoutPayload | null>,
128
+ pending,
129
+ error,
130
+ refresh: async () => { await refresh(); },
131
+ };
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
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/config": "0.15.0",
54
+ "@commonpub/explainer": "0.7.15",
53
55
  "@commonpub/editor": "0.7.11",
54
- "@commonpub/docs": "0.6.3",
55
- "@commonpub/learning": "0.5.2",
56
- "@commonpub/config": "0.14.0",
57
- "@commonpub/server": "2.56.0",
58
56
  "@commonpub/auth": "0.6.0",
57
+ "@commonpub/learning": "0.5.2",
58
+ "@commonpub/ui": "0.9.0",
59
59
  "@commonpub/schema": "0.17.0",
60
+ "@commonpub/docs": "0.6.3",
60
61
  "@commonpub/protocol": "0.12.0",
61
- "@commonpub/ui": "0.9.0",
62
- "@commonpub/explainer": "0.7.15"
62
+ "@commonpub/server": "2.57.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;