@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">Mode</span>
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>
@@ -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
- // Custom theme: just persist preference; server will pick the variant on next request
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.67.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/theme-studio": "0.3.0",
66
- "@commonpub/learning": "0.5.2"
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
- <label class="theme-editor-field theme-editor-field-toggle">
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 was built with Studio. Re-opening Studio and changing it overwrites manual token tweaks here.
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. We scope tokens to `:root` so they
44
- // override the loaded theme CSS but lose to inline element styles.
45
- // Token overrides are added last (already merged into injectedTokens
46
- // in resolveThemeContext) so they win.
47
- event.context.themeInlineCss = Object.keys(ctx.injectedTokens).length > 0
48
- ? tokensToCss(':root', ctx.injectedTokens)
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
- /** Token map to inject as inline :root style (custom theme tokens + overrides). Empty when not needed. */
108
- injectedTokens: Record<string, string>;
109
- /** Google Fonts stylesheet URL for the active custom theme's fonts. Empty when none. */
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
- // Light/dark resolution. We only flip variants for built-in family pairs;
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
- if (userScheme !== null) {
121
- // Built-in family flip
122
- if (VALID_THEME_IDS.has(admin)) {
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
- injectedTokens,
169
+ themeVariants,
170
+ overrides: { ...state.tokenOverrides },
171
+ pair,
172
172
  fontHref,
173
173
  };
174
174
  }