@commonpub/layer 0.67.0 → 0.68.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.
- package/components/admin/theme/AdminThemeFamilyCard.vue +18 -5
- package/components/admin/theme/AdminThemePreviewPane.vue +14 -1
- package/components/admin/theme/studio/AdminThemeStudio.vue +1 -1
- package/composables/useTheme.ts +17 -3
- package/package.json +7 -7
- package/pages/admin/theme/edit/[id].vue +32 -6
- package/pages/admin/theme/index.vue +16 -5
- package/plugins/theme.ts +4 -0
- package/server/middleware/theme.ts +20 -7
- package/server/utils/instanceTheme.ts +46 -46
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* Stays presentational — all wiring (select / edit / duplicate / delete /
|
|
10
10
|
* export) is emitted up so the page owns the state machine.
|
|
11
11
|
*/
|
|
12
|
+
import { computed } from 'vue';
|
|
12
13
|
import type { ThemeFamilyView } from '../../../types/theme';
|
|
13
14
|
|
|
14
|
-
defineProps<{
|
|
15
|
+
const props = defineProps<{
|
|
15
16
|
family: ThemeFamilyView;
|
|
16
17
|
active: boolean;
|
|
17
18
|
saving: boolean;
|
|
@@ -25,6 +26,18 @@ const emit = defineEmits<{
|
|
|
25
26
|
remove: [themeId: string];
|
|
26
27
|
}>();
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The "primary" record of the family for edit/duplicate/export/delete. For a
|
|
31
|
+
* Studio light/dark PAIR the primary is the variant WITHOUT a `-light`/`-dark`
|
|
32
|
+
* suffix (the original slug); its sibling is the suffixed one. Targeting the
|
|
33
|
+
* primary fixes a bug where dark-primary pairs edited/deleted the wrong
|
|
34
|
+
* (auto-generated) record. Falls back to whichever variant exists.
|
|
35
|
+
*/
|
|
36
|
+
const primaryId = computed<string>(() => {
|
|
37
|
+
const ids = [props.family.light?.id, props.family.dark?.id].filter((x): x is string => Boolean(x));
|
|
38
|
+
return ids.find((id) => !/-(?:light|dark)$/.test(id)) ?? ids[0]!;
|
|
39
|
+
});
|
|
40
|
+
|
|
28
41
|
function variantBoxStyle(v: { bg: string; surface: string; accent: string; text: string; border: string }) {
|
|
29
42
|
return {
|
|
30
43
|
backgroundColor: v.bg,
|
|
@@ -102,7 +115,7 @@ function badge(family: ThemeFamilyView): { label: string; tone: 'builtin' | 'reg
|
|
|
102
115
|
v-if="family.source === 'custom'"
|
|
103
116
|
type="button"
|
|
104
117
|
class="cpub-btn cpub-btn-sm"
|
|
105
|
-
@click="emit('edit',
|
|
118
|
+
@click="emit('edit', primaryId)"
|
|
106
119
|
>
|
|
107
120
|
<i class="fa-solid fa-pen-to-square" aria-hidden="true" /> Edit
|
|
108
121
|
</button>
|
|
@@ -110,7 +123,7 @@ function badge(family: ThemeFamilyView): { label: string; tone: 'builtin' | 'reg
|
|
|
110
123
|
type="button"
|
|
111
124
|
class="cpub-btn cpub-btn-sm"
|
|
112
125
|
:title="family.source === 'custom' ? 'Duplicate this theme' : 'Fork to a new editable custom theme'"
|
|
113
|
-
@click="emit('duplicate',
|
|
126
|
+
@click="emit('duplicate', primaryId)"
|
|
114
127
|
>
|
|
115
128
|
<i class="fa-solid fa-copy" aria-hidden="true" /> {{ family.source === 'custom' ? 'Duplicate' : 'Fork' }}
|
|
116
129
|
</button>
|
|
@@ -118,7 +131,7 @@ function badge(family: ThemeFamilyView): { label: string; tone: 'builtin' | 'reg
|
|
|
118
131
|
v-if="family.source === 'custom'"
|
|
119
132
|
type="button"
|
|
120
133
|
class="cpub-btn cpub-btn-sm"
|
|
121
|
-
@click="emit('exportTheme',
|
|
134
|
+
@click="emit('exportTheme', primaryId)"
|
|
122
135
|
>
|
|
123
136
|
<i class="fa-solid fa-file-export" aria-hidden="true" /> Export
|
|
124
137
|
</button>
|
|
@@ -126,7 +139,7 @@ function badge(family: ThemeFamilyView): { label: string; tone: 'builtin' | 'reg
|
|
|
126
139
|
v-if="family.source === 'custom'"
|
|
127
140
|
type="button"
|
|
128
141
|
class="cpub-btn cpub-btn-sm theme-family-action-danger"
|
|
129
|
-
@click="emit('remove',
|
|
142
|
+
@click="emit('remove', primaryId)"
|
|
130
143
|
>
|
|
131
144
|
<i class="fa-solid fa-trash" aria-hidden="true" /> Delete
|
|
132
145
|
</button>
|
|
@@ -20,8 +20,14 @@ const props = defineProps<{
|
|
|
20
20
|
/** The base theme whose CSS file provides inherited defaults (via data-theme). */
|
|
21
21
|
parentTheme: string;
|
|
22
22
|
isDark: boolean;
|
|
23
|
+
/** Optional controlled preview mode. When provided, the Light/Dark toggle
|
|
24
|
+
* emits `update:mode` and the parent owns the state (so it can swap the
|
|
25
|
+
* previewed variant's tokens). Uncontrolled (internal) when omitted. */
|
|
26
|
+
mode?: 'light' | 'dark';
|
|
23
27
|
}>();
|
|
24
28
|
|
|
29
|
+
const emit = defineEmits<{ 'update:mode': ['light' | 'dark'] }>();
|
|
30
|
+
|
|
25
31
|
interface SceneOption {
|
|
26
32
|
id: 'gallery' | 'prose' | 'admin' | 'sheet';
|
|
27
33
|
label: string;
|
|
@@ -37,7 +43,14 @@ const PREVIEW_SCENES: SceneOption[] = [
|
|
|
37
43
|
];
|
|
38
44
|
|
|
39
45
|
const activeScene = ref<SceneOption['id']>('gallery');
|
|
40
|
-
const
|
|
46
|
+
const internalMode = ref<'light' | 'dark'>(props.isDark ? 'dark' : 'light');
|
|
47
|
+
const previewMode = computed<'light' | 'dark'>({
|
|
48
|
+
get: () => props.mode ?? internalMode.value,
|
|
49
|
+
set: (v) => {
|
|
50
|
+
internalMode.value = v;
|
|
51
|
+
emit('update:mode', v);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
41
54
|
|
|
42
55
|
/**
|
|
43
56
|
* Map every parent-theme id to its family's light + dark variant. Mirrors
|
|
@@ -273,7 +273,7 @@ function finishWith(apply: boolean): void {
|
|
|
273
273
|
|
|
274
274
|
<template v-else>
|
|
275
275
|
<label class="cpub-studio-field">
|
|
276
|
-
<span class="cpub-studio-lbl">
|
|
276
|
+
<span class="cpub-studio-lbl">Default mode <span class="cpub-studio-hint">both are saved</span></span>
|
|
277
277
|
<span class="cpub-studio-seg">
|
|
278
278
|
<button type="button" :class="{ on: recipe.mode === 'light' }" @click="recipe.mode = 'light'">Light</button>
|
|
279
279
|
<button type="button" :class="{ on: recipe.mode === 'dark' }" @click="recipe.mode = 'dark'">Dark</button>
|
package/composables/useTheme.ts
CHANGED
|
@@ -29,6 +29,7 @@ export function useTheme(): {
|
|
|
29
29
|
const themeId = useState<string>('cpub-theme', () => 'base');
|
|
30
30
|
const instanceDefault = useState<string>('cpub-instance-theme', () => 'base');
|
|
31
31
|
const isDark = useState<boolean>('cpub-dark-mode', () => false);
|
|
32
|
+
const themePair = useState<{ lightAttr: string; darkAttr: string } | null>('cpub-theme-pair', () => null);
|
|
32
33
|
const schemeCookie = useCookie('cpub-color-scheme', {
|
|
33
34
|
maxAge: 31536000,
|
|
34
35
|
path: '/',
|
|
@@ -44,9 +45,21 @@ export function useTheme(): {
|
|
|
44
45
|
schemeCookie.value = dark ? 'dark' : 'light';
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// Custom light/dark PAIR: both variants' tokens are injected (scoped to
|
|
49
|
+
// their data-theme attr), so flip the attribute client-side for an instant
|
|
50
|
+
// switch — exactly like built-in families. (This is the fix for "the site
|
|
51
|
+
// light/dark toggle didn't switch a custom theme".)
|
|
52
|
+
if (themePair.value) {
|
|
53
|
+
const newTheme = dark ? themePair.value.darkAttr : themePair.value.lightAttr;
|
|
54
|
+
themeId.value = newTheme;
|
|
55
|
+
if (import.meta.client) {
|
|
56
|
+
document.documentElement.setAttribute('data-theme', newTheme);
|
|
57
|
+
$fetch('/api/profile/theme', { method: 'PUT', body: { themeId: newTheme } }).catch(() => {});
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
// 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
63
|
if (THEME_TO_FAMILY[instanceDefault.value]) {
|
|
51
64
|
const family = THEME_TO_FAMILY[instanceDefault.value]!;
|
|
52
65
|
const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
|
|
@@ -61,7 +74,8 @@ export function useTheme(): {
|
|
|
61
74
|
}).catch(() => {});
|
|
62
75
|
}
|
|
63
76
|
} else if (import.meta.client) {
|
|
64
|
-
//
|
|
77
|
+
// Single custom / registered theme with no pair: persist preference only;
|
|
78
|
+
// the server picks any declared variant on the next request.
|
|
65
79
|
$fetch('/api/profile/theme', {
|
|
66
80
|
method: 'PUT',
|
|
67
81
|
body: { themeId: instanceDefault.value },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -55,15 +55,15 @@
|
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
56
|
"@commonpub/auth": "0.8.0",
|
|
57
57
|
"@commonpub/config": "0.20.0",
|
|
58
|
-
"@commonpub/editor": "0.7.11",
|
|
59
|
-
"@commonpub/explainer": "0.7.15",
|
|
60
58
|
"@commonpub/docs": "0.6.3",
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/
|
|
63
|
-
"@commonpub/
|
|
59
|
+
"@commonpub/learning": "0.5.2",
|
|
60
|
+
"@commonpub/explainer": "0.7.15",
|
|
61
|
+
"@commonpub/editor": "0.7.11",
|
|
64
62
|
"@commonpub/server": "2.83.0",
|
|
63
|
+
"@commonpub/schema": "0.37.0",
|
|
65
64
|
"@commonpub/theme-studio": "0.3.0",
|
|
66
|
-
"@commonpub/
|
|
65
|
+
"@commonpub/ui": "0.12.0",
|
|
66
|
+
"@commonpub/protocol": "0.13.0"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -156,6 +156,25 @@ function resetToken(key: string): void {
|
|
|
156
156
|
|
|
157
157
|
const modifiedTotal = computed(() => Object.keys(draft.value.tokens).length);
|
|
158
158
|
|
|
159
|
+
// --- Preview light/dark ------------------------------------------------
|
|
160
|
+
// The preview pane's Light/Dark toggle is controlled here so a Studio theme
|
|
161
|
+
// shows its ACTUAL light + dark variants (regenerated from the recipe per
|
|
162
|
+
// mode) — not the primary variant's tokens in both positions. (Fixes "the
|
|
163
|
+
// editor light/dark buttons don't do anything".)
|
|
164
|
+
const previewMode = ref<'light' | 'dark'>('light');
|
|
165
|
+
const previewTokens = computed<Record<string, string>>(() => {
|
|
166
|
+
if (!draft.value.recipe) return draft.value.tokens;
|
|
167
|
+
// The variant being edited (primary) shows the LIVE draft tokens (so hand
|
|
168
|
+
// tweaks appear); the other variant shows the recipe-derived sibling (what
|
|
169
|
+
// will be saved for it).
|
|
170
|
+
const primaryMode = draft.value.isDark ? 'dark' : 'light';
|
|
171
|
+
if (previewMode.value === primaryMode) return draft.value.tokens;
|
|
172
|
+
return recipeToTokens({ ...draft.value.recipe, mode: previewMode.value }).tokens;
|
|
173
|
+
});
|
|
174
|
+
const previewParent = computed<string>(() =>
|
|
175
|
+
draft.value.recipe ? (previewMode.value === 'dark' ? 'dark' : 'base') : draft.value.parentTheme,
|
|
176
|
+
);
|
|
177
|
+
|
|
159
178
|
// --- Studio (guided generator) ---------------------------------------
|
|
160
179
|
|
|
161
180
|
/** Studio regenerated the whole token set — replace the draft's tokens. */
|
|
@@ -465,7 +484,10 @@ onBeforeUnmount(() => {
|
|
|
465
484
|
</select>
|
|
466
485
|
</label>
|
|
467
486
|
|
|
468
|
-
|
|
487
|
+
<!-- Mode pill only for hand-authored themes. Studio themes are a managed
|
|
488
|
+
light+dark pair — there's no single "mode" to pick, and the site's
|
|
489
|
+
Light/Dark toggle switches the pair for visitors. -->
|
|
490
|
+
<label v-if="!draft.recipe" class="theme-editor-field theme-editor-field-toggle">
|
|
469
491
|
<span class="theme-editor-field-label">Mode</span>
|
|
470
492
|
<div class="theme-editor-mode-pill" role="group">
|
|
471
493
|
<button
|
|
@@ -483,7 +505,7 @@ onBeforeUnmount(() => {
|
|
|
483
505
|
</div>
|
|
484
506
|
</label>
|
|
485
507
|
|
|
486
|
-
<label v-if="pairCandidates.length" class="theme-editor-field">
|
|
508
|
+
<label v-if="!draft.recipe && pairCandidates.length" class="theme-editor-field">
|
|
487
509
|
<span class="theme-editor-field-label">Pair with</span>
|
|
488
510
|
<select v-model="draft.pairId" class="theme-editor-input" @change="onMetaChange">
|
|
489
511
|
<option :value="undefined">- none -</option>
|
|
@@ -566,7 +588,9 @@ onBeforeUnmount(() => {
|
|
|
566
588
|
<section v-else class="theme-editor-tokens" aria-label="Token editor">
|
|
567
589
|
<p v-if="draft.recipe" class="theme-editor-studio-hint">
|
|
568
590
|
<i class="fa-solid fa-circle-info" aria-hidden="true" />
|
|
569
|
-
This theme
|
|
591
|
+
This theme is a light + dark pair (one card in the picker). Visitors switch between them
|
|
592
|
+
with the site's Light/Dark toggle. Re-opening Studio and changing it overwrites manual
|
|
593
|
+
token tweaks here.
|
|
570
594
|
</p>
|
|
571
595
|
<AdminThemeTokenGroup
|
|
572
596
|
v-for="group in TOKEN_GROUP_ORDER"
|
|
@@ -585,9 +609,11 @@ onBeforeUnmount(() => {
|
|
|
585
609
|
|
|
586
610
|
<AdminThemePreviewPane
|
|
587
611
|
class="theme-editor-preview"
|
|
588
|
-
:tokens="
|
|
589
|
-
:parent-theme="
|
|
590
|
-
:is-dark="
|
|
612
|
+
:tokens="previewTokens"
|
|
613
|
+
:parent-theme="previewParent"
|
|
614
|
+
:is-dark="previewMode === 'dark'"
|
|
615
|
+
:mode="previewMode"
|
|
616
|
+
@update:mode="previewMode = $event"
|
|
591
617
|
/>
|
|
592
618
|
</div>
|
|
593
619
|
|
|
@@ -146,14 +146,25 @@ async function duplicateTheme(themeId: string): Promise<void> {
|
|
|
146
146
|
async function removeTheme(themeId: string): Promise<void> {
|
|
147
147
|
const customId = parseCustomThemeId(themeId);
|
|
148
148
|
if (!customId) return;
|
|
149
|
-
|
|
149
|
+
// Delete the whole pair: the theme + its linked light/dark sibling, so a
|
|
150
|
+
// Studio pair (one card) goes away cleanly instead of orphaning one record.
|
|
151
|
+
const src = themesApi.data.value?.custom.find((t) => t.id === customId);
|
|
152
|
+
const ids = [customId];
|
|
153
|
+
if (src?.pairId && src.pairId !== customId) ids.push(src.pairId);
|
|
154
|
+
const label = ids.length > 1 ? `theme "${customId}" and its light/dark pair` : `custom theme "${customId}"`;
|
|
155
|
+
if (!confirm(`Delete ${label}? This cannot be undone.`)) return;
|
|
150
156
|
saving.value = true;
|
|
151
157
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
158
|
+
let resetDefault = false;
|
|
159
|
+
for (const id of ids) {
|
|
160
|
+
const res = await ($fetch as (u: string, o: Record<string, unknown>) => Promise<{ resetDefault?: boolean }>)(
|
|
161
|
+
`/api/admin/themes/${id}`,
|
|
162
|
+
{ method: 'DELETE' },
|
|
163
|
+
);
|
|
164
|
+
if (res?.resetDefault) resetDefault = true;
|
|
165
|
+
}
|
|
155
166
|
await Promise.all([themesApi.refresh(), refreshSettings()]);
|
|
156
|
-
notify(
|
|
167
|
+
notify(resetDefault ? 'Theme deleted, default reset to Classic' : 'Theme deleted', 'success');
|
|
157
168
|
} catch (err) {
|
|
158
169
|
notify(err instanceof Error ? err.message : 'Failed to delete', 'error');
|
|
159
170
|
} finally {
|
package/plugins/theme.ts
CHANGED
|
@@ -11,6 +11,9 @@ export default defineNuxtPlugin(() => {
|
|
|
11
11
|
const isDark = useState<boolean>('cpub-dark-mode', () => false);
|
|
12
12
|
const themeInlineCss = useState<string>('cpub-theme-inline-css', () => '');
|
|
13
13
|
const themeFontHref = useState<string>('cpub-theme-font-href', () => '');
|
|
14
|
+
// Light/dark attrs of a custom pair — lets the user toggle flip data-theme
|
|
15
|
+
// instantly client-side (both variants' tokens are injected below).
|
|
16
|
+
const themePair = useState<{ lightAttr: string; darkAttr: string } | null>('cpub-theme-pair', () => null);
|
|
14
17
|
|
|
15
18
|
if (import.meta.server) {
|
|
16
19
|
const event = useRequestEvent();
|
|
@@ -20,6 +23,7 @@ export default defineNuxtPlugin(() => {
|
|
|
20
23
|
isDark.value = event.context.isDarkMode ?? false;
|
|
21
24
|
themeInlineCss.value = event.context.themeInlineCss ?? '';
|
|
22
25
|
themeFontHref.value = event.context.themeFontHref ?? '';
|
|
26
|
+
themePair.value = event.context.themePair ?? null;
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -16,6 +16,8 @@ declare module 'h3' {
|
|
|
16
16
|
themeInlineCss: string;
|
|
17
17
|
/** Google Fonts stylesheet URL for the active custom theme's fonts. Empty if none. */
|
|
18
18
|
themeFontHref: string;
|
|
19
|
+
/** Light/dark data-theme attrs of a custom pair (for the client toggle). */
|
|
20
|
+
themePair: { lightAttr: string; darkAttr: string } | null;
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -40,14 +42,25 @@ export default defineEventHandler(async (event) => {
|
|
|
40
42
|
event.context.resolvedTheme = ctx.resolvedTheme;
|
|
41
43
|
event.context.isDarkMode = ctx.isDark;
|
|
42
44
|
|
|
43
|
-
// Build the inline style block.
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
// Build the inline style block.
|
|
46
|
+
// - Custom theme(s): one block per variant, scoped to its `[data-theme]`
|
|
47
|
+
// attr, with instance overrides merged in (so overrides win + apply in
|
|
48
|
+
// every mode). A light/dark PAIR injects BOTH, so the client toggle can
|
|
49
|
+
// flip `data-theme` and switch instantly — no server round-trip.
|
|
50
|
+
// - Built-in / registered: only instance overrides at `:root` (their CSS
|
|
51
|
+
// files already handle light/dark).
|
|
52
|
+
if (ctx.themeVariants.length > 0) {
|
|
53
|
+
event.context.themeInlineCss = ctx.themeVariants
|
|
54
|
+
.map((v) => tokensToCss(`:root[data-theme="${v.attr}"]`, { ...v.tokens, ...ctx.overrides }))
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.join('\n');
|
|
57
|
+
} else {
|
|
58
|
+
event.context.themeInlineCss = Object.keys(ctx.overrides).length > 0
|
|
59
|
+
? tokensToCss(':root', ctx.overrides)
|
|
60
|
+
: '';
|
|
61
|
+
}
|
|
50
62
|
|
|
51
63
|
// Google Fonts for the active custom theme (CSP already allows googleapis).
|
|
52
64
|
event.context.themeFontHref = ctx.fontHref;
|
|
65
|
+
event.context.themePair = ctx.pair;
|
|
53
66
|
});
|
|
@@ -104,9 +104,18 @@ export async function resolveThemeContext(
|
|
|
104
104
|
instanceTheme: string;
|
|
105
105
|
/** Whether the resolved theme is dark */
|
|
106
106
|
isDark: boolean;
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Custom-theme token blocks to inject, one per variant, each scoped to its
|
|
109
|
+
* own `[data-theme]` selector. For a light/dark PAIR this is BOTH variants,
|
|
110
|
+
* so the client can flip `data-theme` and switch instantly (no round-trip).
|
|
111
|
+
* Empty for built-in / registered themes (their CSS files handle modes).
|
|
112
|
+
*/
|
|
113
|
+
themeVariants: Array<{ attr: string; tokens: Record<string, string> }>;
|
|
114
|
+
/** Instance-wide token overrides (apply in every mode). */
|
|
115
|
+
overrides: Record<string, string>;
|
|
116
|
+
/** Light/dark attrs of a custom pair, so the client toggle can flip instantly. */
|
|
117
|
+
pair: { lightAttr: string; darkAttr: string } | null;
|
|
118
|
+
/** Google Fonts stylesheet URL for the active custom theme(s) fonts. Empty when none. */
|
|
110
119
|
fontHref: string;
|
|
111
120
|
}> {
|
|
112
121
|
const state = await getState();
|
|
@@ -114,61 +123,52 @@ export async function resolveThemeContext(
|
|
|
114
123
|
// Validate the admin's choice — fall back to base if missing/unknown
|
|
115
124
|
const admin = isKnownThemeId(state.defaultTheme, state, registeredIds) ? state.defaultTheme : 'base';
|
|
116
125
|
|
|
117
|
-
|
|
118
|
-
// custom themes use their declared pair if present, otherwise stay put.
|
|
126
|
+
const activeCustom = state.customByAttr.get(admin);
|
|
119
127
|
let resolved = admin;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
let isDark = false;
|
|
129
|
+
let themeVariants: Array<{ attr: string; tokens: Record<string, string> }> = [];
|
|
130
|
+
let pair: { lightAttr: string; darkAttr: string } | null = null;
|
|
131
|
+
let fontHref = '';
|
|
132
|
+
|
|
133
|
+
if (activeCustom) {
|
|
134
|
+
// Gather the pair members (the default + its sibling, if both exist).
|
|
135
|
+
const members: Array<{ attr: string; rec: CustomThemeRecord }> = [{ attr: admin, rec: activeCustom }];
|
|
136
|
+
if (activeCustom.pairId) {
|
|
137
|
+
const sibAttr = `cpub-custom-${activeCustom.pairId}`;
|
|
138
|
+
const sib = state.customByAttr.get(sibAttr);
|
|
139
|
+
if (sib) members.push({ attr: sibAttr, rec: sib });
|
|
140
|
+
}
|
|
141
|
+
themeVariants = members.map((m) => ({ attr: m.attr, tokens: m.rec.tokens }));
|
|
142
|
+
const lightM = members.find((m) => !m.rec.isDark);
|
|
143
|
+
const darkM = members.find((m) => m.rec.isDark);
|
|
144
|
+
if (members.length === 2 && lightM && darkM) {
|
|
145
|
+
pair = { lightAttr: lightM.attr, darkAttr: darkM.attr };
|
|
146
|
+
}
|
|
147
|
+
// <html data-theme> = the variant matching the user's scheme (else the default).
|
|
148
|
+
if (userScheme === 'dark' && darkM) resolved = darkM.attr;
|
|
149
|
+
else if (userScheme === 'light' && lightM) resolved = lightM.attr;
|
|
150
|
+
else resolved = admin;
|
|
151
|
+
isDark = state.customByAttr.get(resolved)?.isDark ?? activeCustom.isDark;
|
|
152
|
+
// Load every variant's fonts so a client-side flip already has them.
|
|
153
|
+
const allFonts = [...new Set(members.flatMap((m) => m.rec.fonts ?? []))];
|
|
154
|
+
fontHref = allFonts.length ? googleHref(allFonts) : '';
|
|
155
|
+
} else {
|
|
156
|
+
// Built-in / registered: flip via the family's CSS variants on round-trip.
|
|
157
|
+
if (userScheme !== null && VALID_THEME_IDS.has(admin)) {
|
|
123
158
|
const family = THEME_TO_FAMILY[admin] ?? 'classic';
|
|
124
159
|
const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
|
|
125
160
|
resolved = userScheme === 'dark' ? variants.dark : variants.light;
|
|
126
|
-
} else {
|
|
127
|
-
// Custom theme — use pairId if defined
|
|
128
|
-
const custom = state.customByAttr.get(admin);
|
|
129
|
-
if (custom?.pairId) {
|
|
130
|
-
const pairAttr = `cpub-custom-${custom.pairId}`;
|
|
131
|
-
const pair = state.customByAttr.get(pairAttr);
|
|
132
|
-
if (pair && pair.isDark === (userScheme === 'dark')) {
|
|
133
|
-
resolved = pairAttr;
|
|
134
|
-
} else if (custom.isDark === (userScheme === 'dark')) {
|
|
135
|
-
resolved = admin;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
// For registered themes (no pair info available server-side), we leave it alone
|
|
139
|
-
// — the layer-app author can declare a pair via the future RegisteredTheme.pairId
|
|
140
161
|
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// isDark detection
|
|
144
|
-
let isDark = false;
|
|
145
|
-
if (VALID_THEME_IDS.has(resolved)) {
|
|
146
162
|
isDark = IS_DARK[resolved] ?? false;
|
|
147
|
-
} else {
|
|
148
|
-
const custom = state.customByAttr.get(resolved);
|
|
149
|
-
if (custom) isDark = custom.isDark;
|
|
150
163
|
}
|
|
151
164
|
|
|
152
|
-
// Tokens to inject inline. Built-in themes don't need injection (their
|
|
153
|
-
// CSS files are already loaded). Custom themes always inject. Token
|
|
154
|
-
// overrides apply on top of whatever theme is active.
|
|
155
|
-
const injectedTokens: Record<string, string> = {};
|
|
156
|
-
const activeCustom = state.customByAttr.get(resolved);
|
|
157
|
-
if (activeCustom) {
|
|
158
|
-
Object.assign(injectedTokens, activeCustom.tokens);
|
|
159
|
-
}
|
|
160
|
-
// Instance overrides always last so they win
|
|
161
|
-
Object.assign(injectedTokens, state.tokenOverrides);
|
|
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
|
-
|
|
167
165
|
return {
|
|
168
166
|
resolvedTheme: resolved,
|
|
169
167
|
instanceTheme: admin,
|
|
170
168
|
isDark,
|
|
171
|
-
|
|
169
|
+
themeVariants,
|
|
170
|
+
overrides: { ...state.tokenOverrides },
|
|
171
|
+
pair,
|
|
172
172
|
fontHref,
|
|
173
173
|
};
|
|
174
174
|
}
|