@commonpub/layer 0.67.0 → 0.68.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.
|
@@ -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.0",
|
|
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",
|
|
59
|
+
"@commonpub/explainer": "0.7.15",
|
|
60
|
+
"@commonpub/editor": "0.7.11",
|
|
61
|
+
"@commonpub/learning": "0.5.2",
|
|
61
62
|
"@commonpub/schema": "0.37.0",
|
|
62
63
|
"@commonpub/protocol": "0.13.0",
|
|
63
|
-
"@commonpub/ui": "0.12.0",
|
|
64
64
|
"@commonpub/server": "2.83.0",
|
|
65
|
-
"@commonpub/
|
|
66
|
-
"@commonpub/
|
|
65
|
+
"@commonpub/ui": "0.12.0",
|
|
66
|
+
"@commonpub/theme-studio": "0.3.0"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -465,7 +465,10 @@ onBeforeUnmount(() => {
|
|
|
465
465
|
</select>
|
|
466
466
|
</label>
|
|
467
467
|
|
|
468
|
-
|
|
468
|
+
<!-- Mode pill only for hand-authored themes. Studio themes are a managed
|
|
469
|
+
light+dark pair — there's no single "mode" to pick, and the site's
|
|
470
|
+
Light/Dark toggle switches the pair for visitors. -->
|
|
471
|
+
<label v-if="!draft.recipe" class="theme-editor-field theme-editor-field-toggle">
|
|
469
472
|
<span class="theme-editor-field-label">Mode</span>
|
|
470
473
|
<div class="theme-editor-mode-pill" role="group">
|
|
471
474
|
<button
|
|
@@ -483,7 +486,7 @@ onBeforeUnmount(() => {
|
|
|
483
486
|
</div>
|
|
484
487
|
</label>
|
|
485
488
|
|
|
486
|
-
<label v-if="pairCandidates.length" class="theme-editor-field">
|
|
489
|
+
<label v-if="!draft.recipe && pairCandidates.length" class="theme-editor-field">
|
|
487
490
|
<span class="theme-editor-field-label">Pair with</span>
|
|
488
491
|
<select v-model="draft.pairId" class="theme-editor-input" @change="onMetaChange">
|
|
489
492
|
<option :value="undefined">- none -</option>
|
|
@@ -566,7 +569,9 @@ onBeforeUnmount(() => {
|
|
|
566
569
|
<section v-else class="theme-editor-tokens" aria-label="Token editor">
|
|
567
570
|
<p v-if="draft.recipe" class="theme-editor-studio-hint">
|
|
568
571
|
<i class="fa-solid fa-circle-info" aria-hidden="true" />
|
|
569
|
-
This theme
|
|
572
|
+
This theme is a light + dark pair (one card in the picker). Visitors switch between them
|
|
573
|
+
with the site's Light/Dark toggle. Re-opening Studio and changing it overwrites manual
|
|
574
|
+
token tweaks here.
|
|
570
575
|
</p>
|
|
571
576
|
<AdminThemeTokenGroup
|
|
572
577
|
v-for="group in TOKEN_GROUP_ORDER"
|
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
|
}
|