@commonpub/layer 0.21.22 → 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.
@@ -0,0 +1,278 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * One token row in the editor. Picks the input control based on the token
4
+ * spec's `kind` and emits updates upward. Shows:
5
+ * • token name (mono)
6
+ * • description (faint, on its own line)
7
+ * • the appropriate input
8
+ * • a "reset to default" button when the value differs
9
+ *
10
+ * No prop drilling, no internal state — this is a pure controlled component.
11
+ */
12
+ import type { TokenSpec } from '@commonpub/ui';
13
+ import { computed } from 'vue';
14
+
15
+ const props = defineProps<{
16
+ spec: TokenSpec;
17
+ value: string;
18
+ /** Resolved value (after CSS resolution) for color preview when `value` is a var()
19
+ * or rgba expression. Optional — falls back to `value`. */
20
+ resolvedValue?: string;
21
+ }>();
22
+
23
+ const emit = defineEmits<{
24
+ update: [value: string];
25
+ reset: [];
26
+ }>();
27
+
28
+ const isModified = computed(() => props.value !== props.spec.default && props.value !== '');
29
+
30
+ /** Returns a hex/rgb color that <input type="color"> understands; null if it can't. */
31
+ const colorPickerValue = computed<string | null>(() => {
32
+ const v = (props.value || props.resolvedValue || props.spec.default).trim();
33
+ if (/^#[0-9a-f]{3}$/i.test(v)) {
34
+ // Expand 3-digit hex to 6-digit
35
+ return '#' + v.slice(1).split('').map((c) => c + c).join('');
36
+ }
37
+ if (/^#[0-9a-f]{6}$/i.test(v)) return v.toLowerCase();
38
+ // rgb/rgba: extract first three numbers
39
+ const m = v.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
40
+ if (m) {
41
+ const hex = '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('');
42
+ return hex;
43
+ }
44
+ return null;
45
+ });
46
+
47
+ function onColorPick(e: Event): void {
48
+ const next = (e.target as HTMLInputElement).value;
49
+ emit('update', next);
50
+ }
51
+
52
+ function onTextChange(e: Event): void {
53
+ emit('update', (e.target as HTMLInputElement | HTMLSelectElement).value);
54
+ }
55
+
56
+ // Number tokens (lengths) split into magnitude + unit for nicer editing
57
+ const NUMBER_UNITS = ['rem', 'px', 'em', '%', 'vh', 'vw', 'ch'] as const;
58
+ type LengthUnit = typeof NUMBER_UNITS[number] | '';
59
+ const lengthParts = computed<{ num: string; unit: LengthUnit }>(() => {
60
+ const v = (props.value || props.spec.default).trim();
61
+ const m = v.match(/^(-?\d*\.?\d+)\s*(rem|px|em|%|vh|vw|ch)?$/);
62
+ if (m) return { num: m[1] ?? '', unit: (m[2] ?? '') as LengthUnit };
63
+ return { num: '', unit: '' };
64
+ });
65
+
66
+ function commitLengthParts(num: string, unit: LengthUnit): void {
67
+ if (num === '') return;
68
+ emit('update', unit === '' ? num : `${num}${unit}`);
69
+ }
70
+
71
+ // Shadow tokens — exposed as raw string editing (composer is future work)
72
+ // Font weights — restricted dropdown
73
+ const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900'];
74
+ </script>
75
+
76
+ <template>
77
+ <div class="token-row" :class="{ 'is-modified': isModified }">
78
+ <div class="token-row-head">
79
+ <code class="token-name">--{{ spec.key }}</code>
80
+ <button
81
+ v-if="isModified"
82
+ type="button"
83
+ class="token-reset"
84
+ :title="`Reset to ${spec.default}`"
85
+ @click="emit('reset')"
86
+ >
87
+ <i class="fa-solid fa-rotate-left" aria-hidden="true" />
88
+ </button>
89
+ </div>
90
+
91
+ <p v-if="spec.description" class="token-desc">{{ spec.description }}</p>
92
+
93
+ <!-- COLOR -->
94
+ <div v-if="spec.kind === 'color'" class="token-input-color">
95
+ <input
96
+ v-if="colorPickerValue"
97
+ type="color"
98
+ class="token-color-swatch"
99
+ :value="colorPickerValue"
100
+ :aria-label="`${spec.key} color`"
101
+ @input="onColorPick"
102
+ />
103
+ <div
104
+ v-else
105
+ class="token-color-swatch-fallback"
106
+ :style="{ background: value || spec.default }"
107
+ :title="value || spec.default"
108
+ aria-hidden="true"
109
+ />
110
+ <input
111
+ class="token-input"
112
+ type="text"
113
+ :value="value"
114
+ :placeholder="spec.default"
115
+ @input="onTextChange"
116
+ />
117
+ </div>
118
+
119
+ <!-- LENGTH -->
120
+ <div v-else-if="spec.kind === 'length'" class="token-input-length">
121
+ <input
122
+ class="token-input token-input-num"
123
+ type="text"
124
+ inputmode="decimal"
125
+ :value="lengthParts.num"
126
+ :placeholder="spec.default"
127
+ @change="(e) => commitLengthParts((e.target as HTMLInputElement).value, lengthParts.unit)"
128
+ />
129
+ <select
130
+ class="token-input token-input-unit"
131
+ :value="lengthParts.unit"
132
+ @change="(e) => commitLengthParts(lengthParts.num, (e.target as HTMLSelectElement).value as never)"
133
+ >
134
+ <option v-for="u in NUMBER_UNITS" :key="u" :value="u">{{ u }}</option>
135
+ <option value="">—</option>
136
+ </select>
137
+ <input
138
+ class="token-input token-input-raw"
139
+ type="text"
140
+ :value="value"
141
+ :placeholder="spec.default"
142
+ title="Or enter raw CSS"
143
+ @change="onTextChange"
144
+ />
145
+ </div>
146
+
147
+ <!-- NUMBER (unitless: weight, z-index, leading) -->
148
+ <input
149
+ v-else-if="spec.kind === 'number'"
150
+ class="token-input"
151
+ type="text"
152
+ inputmode="decimal"
153
+ :value="value"
154
+ :placeholder="spec.default"
155
+ @input="onTextChange"
156
+ />
157
+
158
+ <!-- FONT WEIGHT -->
159
+ <select
160
+ v-else-if="spec.kind === 'font-weight'"
161
+ class="token-input"
162
+ :value="value || spec.default"
163
+ @change="onTextChange"
164
+ >
165
+ <option v-for="w in WEIGHTS" :key="w" :value="w">{{ w }}</option>
166
+ </select>
167
+
168
+ <!-- FONT FAMILY -->
169
+ <input
170
+ v-else-if="spec.kind === 'font-family'"
171
+ class="token-input token-input-font"
172
+ type="text"
173
+ :value="value"
174
+ :placeholder="spec.default"
175
+ :style="{ fontFamily: value || spec.default }"
176
+ @input="onTextChange"
177
+ />
178
+
179
+ <!-- SHADOW / TRANSITION / STRING — raw text -->
180
+ <input
181
+ v-else
182
+ class="token-input token-input-mono"
183
+ type="text"
184
+ :value="value"
185
+ :placeholder="spec.default"
186
+ @input="onTextChange"
187
+ />
188
+ </div>
189
+ </template>
190
+
191
+ <style scoped>
192
+ .token-row {
193
+ display: flex;
194
+ flex-direction: column;
195
+ gap: 4px;
196
+ padding: var(--space-2) var(--space-3);
197
+ border-bottom: var(--border-width-thin) solid var(--border2);
198
+ }
199
+ .token-row:last-child { border-bottom: 0; }
200
+ .token-row.is-modified { background: var(--accent-bg); }
201
+
202
+ .token-row-head {
203
+ display: flex;
204
+ align-items: center;
205
+ gap: var(--space-2);
206
+ }
207
+
208
+ .token-name {
209
+ flex: 1;
210
+ font-family: var(--font-mono);
211
+ font-size: var(--text-sm);
212
+ color: var(--text);
213
+ font-weight: var(--font-weight-medium);
214
+ }
215
+
216
+ .token-reset {
217
+ width: 22px;
218
+ height: 22px;
219
+ background: none;
220
+ border: var(--border-width-thin) solid var(--border2);
221
+ color: var(--text-dim);
222
+ cursor: pointer;
223
+ display: inline-flex;
224
+ align-items: center;
225
+ justify-content: center;
226
+ font-size: 10px;
227
+ border-radius: 0;
228
+ }
229
+ .token-reset:hover { color: var(--accent); border-color: var(--accent); }
230
+
231
+ .token-desc {
232
+ font-size: var(--text-xs);
233
+ color: var(--text-faint);
234
+ margin: 0;
235
+ line-height: var(--leading-snug);
236
+ }
237
+
238
+ .token-input {
239
+ background: var(--surface2);
240
+ color: var(--text);
241
+ border: var(--border-width-thin) solid var(--border2);
242
+ padding: 6px 8px;
243
+ font-size: var(--text-sm);
244
+ font-family: var(--font-mono);
245
+ width: 100%;
246
+ }
247
+ .token-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
248
+
249
+ .token-input-color { display: flex; align-items: center; gap: var(--space-2); }
250
+ .token-color-swatch {
251
+ width: 36px;
252
+ height: 30px;
253
+ border: var(--border-width-thin) solid var(--border2);
254
+ padding: 0;
255
+ background: transparent;
256
+ cursor: pointer;
257
+ flex-shrink: 0;
258
+ }
259
+ .token-color-swatch-fallback {
260
+ width: 36px;
261
+ height: 30px;
262
+ border: var(--border-width-thin) solid var(--border2);
263
+ flex-shrink: 0;
264
+ background-image:
265
+ linear-gradient(45deg, var(--border2) 25%, transparent 25%),
266
+ linear-gradient(-45deg, var(--border2) 25%, transparent 25%),
267
+ linear-gradient(45deg, transparent 75%, var(--border2) 75%),
268
+ linear-gradient(-45deg, transparent 75%, var(--border2) 75%);
269
+ background-size: 8px 8px;
270
+ background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
271
+ }
272
+
273
+ .token-input-length { display: grid; grid-template-columns: 1fr 70px; gap: 4px; }
274
+ .token-input-length .token-input-raw { grid-column: 1 / -1; font-size: var(--text-xs); }
275
+
276
+ .token-input-font { font-size: var(--text-sm); }
277
+ .token-input-mono { font-family: var(--font-mono); font-size: var(--text-xs); }
278
+ </style>
@@ -10,6 +10,11 @@ import { THEME_TO_FAMILY, FAMILY_VARIANTS } from '../utils/themeConfig';
10
10
  *
11
11
  * The dark mode preference cookie (`cpub-color-scheme`) is only persisted
12
12
  * when the user has accepted functional cookies via the consent banner.
13
+ *
14
+ * Custom themes (`cpub-custom-*`) and code-registered themes pass through —
15
+ * the user's cookie toggle is recorded but the server picks the actual variant
16
+ * using the custom theme's `pairId` (if declared). For built-in family pairs,
17
+ * the variant flip happens client-side immediately for snappy UX.
13
18
  */
14
19
  export function useTheme(): {
15
20
  /** Current active theme ID (resolved from instance default + dark mode) */
@@ -39,23 +44,28 @@ export function useTheme(): {
39
44
  schemeCookie.value = dark ? 'dark' : 'light';
40
45
  }
41
46
 
42
- // Resolve the correct variant for this family + mode
43
- const family = THEME_TO_FAMILY[instanceDefault.value] ?? 'classic';
44
- const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
45
- const newTheme = dark ? variants.dark : variants.light;
46
-
47
- themeId.value = newTheme;
48
-
49
- if (import.meta.client) {
50
- applyThemeToElement(document.documentElement, newTheme);
47
+ // Built-in family flip is purely client-side for snappy UX.
48
+ // Custom/registered themes need a server round-trip on next nav
49
+ // (the server reads the new cookie and picks the right pair).
50
+ if (THEME_TO_FAMILY[instanceDefault.value]) {
51
+ const family = THEME_TO_FAMILY[instanceDefault.value]!;
52
+ const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
53
+ const newTheme = dark ? variants.dark : variants.light;
54
+ themeId.value = newTheme;
51
55
 
52
- // Persist to DB for cross-device sync (fire-and-forget, cookie is primary)
56
+ if (import.meta.client) {
57
+ applyThemeToElement(document.documentElement, newTheme);
58
+ $fetch('/api/profile/theme', {
59
+ method: 'PUT',
60
+ body: { themeId: newTheme },
61
+ }).catch(() => {});
62
+ }
63
+ } else if (import.meta.client) {
64
+ // Custom theme: just persist preference; server will pick the variant on next request
53
65
  $fetch('/api/profile/theme', {
54
66
  method: 'PUT',
55
- body: { themeId: newTheme },
56
- }).catch(() => {
57
- // Not logged in or network error — cookie preference is sufficient
58
- });
67
+ body: { themeId: instanceDefault.value },
68
+ }).catch(() => {});
59
69
  }
60
70
  }
61
71
 
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Shared client-side state for the admin theme system.
3
+ *
4
+ * Singleton — both the list page (`/admin/theme`) and the editor
5
+ * (`/admin/theme/edit/[id]`) read the same `data`/`families` refs so
6
+ * a save in one place propagates to the other without refetching.
7
+ *
8
+ * Discovery helpers live in `utils/themeDiscovery.ts`; import/export
9
+ * in `utils/themeIO.ts`; id helpers in `utils/themeIds.ts`; types in
10
+ * `types/theme.ts`. This file orchestrates them into one composable.
11
+ */
12
+ import { computed, ref } from 'vue';
13
+ import { BUILT_IN_THEMES, previewFromTokens } from '@commonpub/ui';
14
+ import type { CustomThemeRecord, ThemesPayload, ThemeFamilyView } from '../types/theme';
15
+
16
+ // ---- Family display metadata for built-in themes ------------------------
17
+
18
+ const BUILT_IN_FAMILY_META: Record<string, { name: string; description: string }> = {
19
+ classic: { name: 'Classic', description: 'Sharp corners, offset shadows, blue accent — the original CommonPub look' },
20
+ agora: { name: 'Agora', description: 'Warm parchment tones, green accent, serif display font — institutional warmth' },
21
+ generics: { name: 'Generics', description: 'Minimal dark aesthetic with soft glow shadows' },
22
+ };
23
+
24
+ const BUILT_IN_PREVIEWS: Record<string, { bg: string; surface: string; accent: string; text: string; border: string }> = {
25
+ base: { bg: '#fafaf9', surface: '#ffffff', accent: '#5b9cf6', text: '#1a1a1a', border: '#1a1a1a' },
26
+ dark: { bg: '#111111', surface: '#1a1a1a', accent: '#5b9cf6', text: '#e5e5e3', border: '#444440' },
27
+ generics: { bg: '#0c0c0b', surface: '#141413', accent: '#5b9cf6', text: '#d8d5cf', border: '#272725' },
28
+ agora: { bg: '#f7f4ed', surface: '#faf8f3', accent: '#3d8b5e', text: '#1a1a1a', border: '#1a1a1a' },
29
+ 'agora-dark': { bg: '#0d1a12', surface: '#141f17', accent: '#4aa06e', text: '#e8e8e2', border: '#3a4f40' },
30
+ };
31
+
32
+ // ---- Singleton state ----------------------------------------------------
33
+
34
+ const data = ref<ThemesPayload | null>(null);
35
+ const loading = ref(false);
36
+ const error = ref<string | null>(null);
37
+
38
+ async function refresh(): Promise<void> {
39
+ loading.value = true;
40
+ error.value = null;
41
+ try {
42
+ data.value = await $fetch<ThemesPayload>('/api/admin/themes');
43
+ } catch (err) {
44
+ error.value = err instanceof Error ? err.message : 'Failed to load themes';
45
+ } finally {
46
+ loading.value = false;
47
+ }
48
+ }
49
+
50
+ // ---- Family view-model builder ------------------------------------------
51
+
52
+ /**
53
+ * Merge built-in + registered + custom themes into one family-grouped
54
+ * list. The same family slug across sources collapses into a single
55
+ * entry; later sources overwrite earlier display metadata (custom >
56
+ * registered > built-in).
57
+ */
58
+ function buildFamilies(payload: ThemesPayload): ThemeFamilyView[] {
59
+ const map = new Map<string, ThemeFamilyView>();
60
+
61
+ // Built-in first
62
+ for (const t of payload.builtIn) {
63
+ const meta = BUILT_IN_FAMILY_META[t.family] ?? { name: t.family, description: '' };
64
+ const fam = ensureFamily(map, t.family, {
65
+ name: meta.name,
66
+ description: meta.description,
67
+ source: 'builtin',
68
+ });
69
+ const preview = BUILT_IN_PREVIEWS[t.id] ?? (t.isDark ? BUILT_IN_PREVIEWS.dark! : BUILT_IN_PREVIEWS.base!);
70
+ placeVariant(fam, t.id, t.name, t.isDark, preview);
71
+ }
72
+
73
+ // Registered themes — promote source if family was previously built-in
74
+ for (const t of payload.registered) {
75
+ const fallback = t.isDark ? BUILT_IN_PREVIEWS.dark! : BUILT_IN_PREVIEWS.base!;
76
+ const preview = {
77
+ bg: t.preview?.bg ?? fallback.bg,
78
+ surface: t.preview?.surface ?? fallback.surface,
79
+ accent: t.preview?.accent ?? fallback.accent,
80
+ text: t.preview?.text ?? fallback.text,
81
+ border: t.preview?.border ?? fallback.border,
82
+ };
83
+ const fam = ensureFamily(map, t.family, {
84
+ name: t.name,
85
+ description: t.description ?? `Code-registered theme from this app's commonpub.config.ts`,
86
+ source: 'registered',
87
+ });
88
+ if (fam.source === 'builtin') fam.source = 'registered';
89
+ placeVariant(fam, t.id, t.name, t.isDark, preview);
90
+ }
91
+
92
+ // Custom (DB-stored) — these win the meta tug-of-war
93
+ for (const t of payload.custom) {
94
+ const dataAttr = `cpub-custom-${t.id}`;
95
+ const preview = previewFromTokens(t.tokens, t.isDark);
96
+ const fam = ensureFamily(map, t.family, {
97
+ name: t.name,
98
+ description: t.description || 'Custom theme',
99
+ source: 'custom',
100
+ });
101
+ fam.source = 'custom';
102
+ fam.name = t.name;
103
+ if (t.description) fam.description = t.description;
104
+ placeVariant(fam, dataAttr, t.name, t.isDark, preview);
105
+ }
106
+
107
+ return [...map.values()];
108
+ }
109
+
110
+ function ensureFamily(
111
+ map: Map<string, ThemeFamilyView>,
112
+ id: string,
113
+ init: { name: string; description: string; source: ThemeFamilyView['source'] },
114
+ ): ThemeFamilyView {
115
+ let fam = map.get(id);
116
+ if (!fam) {
117
+ fam = {
118
+ id,
119
+ name: init.name,
120
+ description: init.description,
121
+ source: init.source,
122
+ light: null,
123
+ dark: null,
124
+ preview: { light: BUILT_IN_PREVIEWS.base!, dark: BUILT_IN_PREVIEWS.dark! },
125
+ };
126
+ map.set(id, fam);
127
+ }
128
+ return fam;
129
+ }
130
+
131
+ function placeVariant(
132
+ fam: ThemeFamilyView,
133
+ themeId: string,
134
+ themeName: string,
135
+ isDark: boolean,
136
+ preview: { bg: string; surface: string; accent: string; text: string; border: string },
137
+ ): void {
138
+ if (isDark) {
139
+ fam.dark = { id: themeId, name: themeName };
140
+ fam.preview.dark = preview;
141
+ } else {
142
+ fam.light = { id: themeId, name: themeName };
143
+ fam.preview.light = preview;
144
+ }
145
+ }
146
+
147
+ // ---- Composable surface -------------------------------------------------
148
+
149
+ export function useThemeAdmin(): {
150
+ data: typeof data;
151
+ loading: typeof loading;
152
+ error: typeof error;
153
+ families: import('vue').ComputedRef<ThemeFamilyView[]>;
154
+ refresh: typeof refresh;
155
+ /** Find a custom theme by id in the current payload. Returns null if absent. */
156
+ findCustom: (id: string) => CustomThemeRecord | null;
157
+ } {
158
+ const families = computed<ThemeFamilyView[]>(() => (data.value ? buildFamilies(data.value) : []));
159
+ return {
160
+ data,
161
+ loading,
162
+ error,
163
+ families,
164
+ refresh,
165
+ findCustom: (id: string) => data.value?.custom.find((t) => t.id === id) ?? null,
166
+ };
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.21.22",
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/config": "0.13.0",
54
53
  "@commonpub/auth": "0.6.0",
55
- "@commonpub/explainer": "0.7.15",
56
- "@commonpub/editor": "0.7.11",
57
54
  "@commonpub/docs": "0.6.3",
55
+ "@commonpub/config": "0.14.0",
56
+ "@commonpub/editor": "0.7.11",
57
+ "@commonpub/explainer": "0.7.15",
58
58
  "@commonpub/learning": "0.5.2",
59
- "@commonpub/ui": "0.8.5",
60
- "@commonpub/server": "2.55.0",
59
+ "@commonpub/schema": "0.17.0",
61
60
  "@commonpub/protocol": "0.12.0",
62
- "@commonpub/schema": "0.16.0"
61
+ "@commonpub/ui": "0.9.0",
62
+ "@commonpub/server": "2.56.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",