@koehler8/cms 1.0.0-beta.7 → 1.0.0-beta.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-beta.8",
4
4
  "description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/main.js CHANGED
@@ -39,12 +39,26 @@ const extractThemeKey = (siteData) => {
39
39
  return '';
40
40
  };
41
41
 
42
- const applySiteTheme = (themeKey) => {
43
- if (typeof document === 'undefined') return;
42
+ const applySiteTheme = (themeKey, head) => {
44
43
  const normalized = normalizeThemeKey(themeKey);
45
44
  const resolved = normalized || 'base';
46
- document.documentElement.dataset.siteTheme = resolved;
47
- applyThemeVariables(resolved);
45
+
46
+ // Push the html attribute via @unhead so `data-site-theme="X"` lands
47
+ // in the SSR-rendered HTML *before* Vue hydrates. Theme CSS selectors
48
+ // (`:root[data-site-theme="X"]`) then apply during the very first
49
+ // paint, eliminating the need for site-side critical-CSS overrides.
50
+ if (head && typeof head.push === 'function') {
51
+ head.push({ htmlAttrs: { 'data-site-theme': resolved } });
52
+ }
53
+
54
+ // Client only: also set the attribute imperatively (so runtime theme
55
+ // switching is reflected immediately) and inline the CSS variable map
56
+ // for themes that ship JS-only design tokens (e.g. the bundled `base`
57
+ // theme has no theme.css).
58
+ if (typeof document !== 'undefined') {
59
+ document.documentElement.dataset.siteTheme = resolved;
60
+ applyThemeVariables(resolved);
61
+ }
48
62
  };
49
63
 
50
64
  export function createCmsApp() {
@@ -74,9 +88,7 @@ export function createCmsApp() {
74
88
 
75
89
  if (initialState.siteTheme) {
76
90
  setActiveThemeKey(initialState.siteTheme);
77
- if (typeof document !== 'undefined') {
78
- applySiteTheme(initialState.siteTheme);
79
- }
91
+ applySiteTheme(initialState.siteTheme, ctx.head);
80
92
  }
81
93
 
82
94
  applyRouterGuards(router);
@@ -130,7 +142,7 @@ export function createCmsApp() {
130
142
  initialState.siteTheme ?? normalizeThemeKey(extractThemeKey(initialState.siteConfig));
131
143
  initialState.siteTheme = existingTheme;
132
144
  setActiveThemeKey(existingTheme);
133
- applySiteTheme(existingTheme);
145
+ applySiteTheme(existingTheme, ctx.head);
134
146
  return initialState.siteConfig;
135
147
  }
136
148
 
@@ -140,7 +152,7 @@ export function createCmsApp() {
140
152
  initialState.siteConfigLocale = localeKey;
141
153
  initialState.siteTheme = themeKey;
142
154
  setActiveThemeKey(themeKey);
143
- applySiteTheme(themeKey);
155
+ applySiteTheme(themeKey, ctx.head);
144
156
  return siteData;
145
157
  };
146
158
 
@@ -0,0 +1,246 @@
1
+ // Pure CSS-variable derivation from a theme manifest. Lives in its own
2
+ // file (with no Vite-specific imports) so it can be called from both
3
+ // the runtime themeManager AND the build-time vite plugin, which uses
4
+ // it to emit `virtual:cms-theme-vars.css` — a synchronously-linked
5
+ // stylesheet containing every registered theme's tokens. That, combined
6
+ // with the SSR-rendered `data-site-theme` attribute (see main.js
7
+ // applySiteTheme), eliminates the FOUC dark-themed sites used to suffer.
8
+
9
+ const toKebab = (value = '') =>
10
+ value
11
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
12
+ .replace(/[\s_]+/g, '-')
13
+ .toLowerCase();
14
+
15
+ const withPx = (value) => {
16
+ if (value === undefined || value === null) return undefined;
17
+ if (typeof value === 'number') return `${value}px`;
18
+ return value;
19
+ };
20
+
21
+ const setVar = (vars, name, value) => {
22
+ if (value === undefined || value === null || value === '') return;
23
+ vars[name] = String(value);
24
+ };
25
+
26
+ const assignPrefixed = (vars, entries = {}, prefix = '') => {
27
+ Object.entries(entries).forEach(([key, value]) => {
28
+ setVar(vars, `${prefix}${key}`, value);
29
+ });
30
+ };
31
+
32
+ function setSurfaceVars(vars, prefix, surface = {}) {
33
+ if (!surface) return;
34
+ setVar(vars, `${prefix}-bg`, surface.background);
35
+ setVar(vars, `${prefix}-before`, surface.before);
36
+ setVar(vars, `${prefix}-after`, surface.after);
37
+ }
38
+
39
+ function coerceSurface(value, defaults = {}) {
40
+ if (value && typeof value === 'object') return value;
41
+ if (!value) return defaults;
42
+ return { ...defaults, background: value };
43
+ }
44
+
45
+ function coerceArray(value) {
46
+ if (!value) return undefined;
47
+ return Array.isArray(value) ? value.filter(Boolean).join(', ') : value;
48
+ }
49
+
50
+ export function buildCssVarMap(manifest) {
51
+ if (!manifest) return {};
52
+ const vars = {};
53
+ const tokens = manifest.tokens || {};
54
+ const palette = tokens.palette || {};
55
+ const text = tokens.text || {};
56
+ const surfaces = tokens.surfaces || {};
57
+ const helper = surfaces.helper || {};
58
+ const tabs = surfaces.tabs || {};
59
+ const field = surfaces.field || {};
60
+ const strip = surfaces.strip || {};
61
+ const chrome = surfaces.chrome || {};
62
+ const heroSurface = surfaces.backdropPrimary || {};
63
+ const promoSurface = surfaces.backdropSecondary || {};
64
+ const typography = tokens.typography || {};
65
+ const ctas = tokens.ctas || {};
66
+ const chips = tokens.chips || {};
67
+ const focus = tokens.focus || {};
68
+ const radii = tokens.radii || {};
69
+ const elevation = tokens.elevation || {};
70
+ const utility = tokens.utility || {};
71
+
72
+ assignPrefixed(
73
+ vars,
74
+ Object.fromEntries(
75
+ Object.entries(palette).map(([key, value]) => [`--brand-${toKebab(key)}`, value])
76
+ )
77
+ );
78
+
79
+ setVar(vars, '--brand-accent-electric', palette.primary || palette.accent);
80
+ setVar(vars, '--brand-accent-electric-soft', palette.accentSoft);
81
+ setVar(vars, '--brand-accent-warm', palette.accent || palette.warning);
82
+
83
+ setVar(vars, '--brand-fg-100', text.primary);
84
+ setVar(vars, '--brand-fg-200', text.muted);
85
+ setVar(vars, '--brand-fg-300', text.mutedStrong);
86
+ setVar(vars, '--brand-fg-050', text.inverse);
87
+ setVar(vars, '--brand-bg-900', surfaces.base);
88
+ setVar(vars, '--brand-bg-800', surfaces.baseAlt);
89
+ setVar(vars, '--brand-bg-700', surfaces.raised);
90
+ setVar(vars, '--brand-bg-600', surfaces.sunken);
91
+
92
+ Object.entries(radii).forEach(([key, value]) => {
93
+ setVar(vars, `--brand-radius-${toKebab(key)}`, withPx(value));
94
+ });
95
+ setVar(vars, '--brand-card-radius', withPx(radii.lg || radii.md));
96
+ setVar(vars, '--brand-button-radius', withPx(radii.md || radii.sm));
97
+
98
+ setVar(vars, '--brand-shadow-glow', elevation.raised);
99
+ setVar(vars, '--brand-shadow-glow-strong', elevation.overlay);
100
+ setVar(vars, '--brand-card-shadow', elevation.raised);
101
+ setVar(vars, '--brand-surface-card-shadow', elevation.raised);
102
+
103
+ const cardTextColor = text.card || text.primary;
104
+ setVar(vars, '--brand-card-soft', surfaces.card);
105
+ setVar(vars, '--brand-surface-card-bg', surfaces.card);
106
+ setVar(vars, '--brand-card-border', surfaces.border);
107
+ setVar(vars, '--brand-surface-card-border', surfaces.border);
108
+ setVar(vars, '--brand-card-text', cardTextColor);
109
+ setVar(vars, '--brand-surface-helper-bg', helper.background || surfaces.callout);
110
+ setVar(vars, '--brand-surface-helper-hover-bg', helper.hover || helper.background);
111
+ setVar(vars, '--brand-icon-badge-bg', surfaces.chipAccent || surfaces.chip);
112
+ setVar(vars, '--brand-icon-badge-color', palette.primary || text.primary);
113
+
114
+ setVar(vars, '--brand-border-highlight', surfaces.border);
115
+ setVar(vars, '--brand-border-glow', surfaces.border);
116
+
117
+ setVar(vars, '--brand-status-success', palette.success);
118
+ setVar(vars, '--brand-status-error', palette.critical);
119
+ setVar(vars, '--brand-status-error-soft', palette.criticalSoft);
120
+
121
+ setVar(vars, '--brand-input-bg', field.background || surfaces.input);
122
+ setVar(vars, '--brand-input-border', field.border || utility.inputBorder);
123
+ setVar(vars, '--brand-input-border-active', focus.ring);
124
+ setVar(vars, '--brand-input-text', field.inputColor || utility.inputText || text.primary);
125
+ setVar(
126
+ vars,
127
+ '--brand-input-placeholder',
128
+ field.inputPlaceholder || utility.inputPlaceholder || text.muted
129
+ );
130
+ setVar(vars, '--brand-focus-ring', focus.ring);
131
+ setVar(vars, '--brand-focus-glow', focus.shadowInset);
132
+
133
+ setVar(vars, '--brand-modal-backdrop', surfaces.backdrop);
134
+ setVar(vars, '--brand-modal-surface', utility.modalSurface);
135
+ setVar(vars, '--brand-modal-border', utility.modalBorder);
136
+ setVar(vars, '--brand-modal-shadow', utility.modalShadow);
137
+ setVar(vars, '--brand-modal-radius', withPx(utility.modalRadius));
138
+
139
+ setVar(vars, '--brand-chart-track', utility.chartTrack);
140
+ setVar(vars, '--brand-chart-center-text', utility.chartCenterText);
141
+
142
+ setVar(vars, '--brand-header-bg', chrome.background);
143
+ setVar(vars, '--brand-header-text', chrome.text);
144
+ setVar(vars, '--site-header-shadow', chrome.shadow);
145
+ setVar(vars, '--site-header-shadow-compact', chrome.compactShadow);
146
+
147
+ const primaryCta = ctas.primary || {};
148
+ setVar(vars, '--brand-primary-cta-gradient', primaryCta.bg);
149
+ setVar(vars, '--brand-primary-cta-text', primaryCta.text);
150
+ setVar(vars, '--brand-primary-cta-border', primaryCta.border);
151
+ setVar(vars, '--brand-primary-cta-shadow', primaryCta.shadow);
152
+ setVar(vars, '--brand-primary-cta-hover-shadow', primaryCta.shadow);
153
+ setVar(vars, '--brand-primary-cta-hover-translate', 'translateY(-1px)');
154
+ setVar(vars, '--brand-cta-text', primaryCta.text);
155
+
156
+ setVar(vars, '--helper-strip-bg', helper.background || surfaces.callout);
157
+ setVar(vars, '--helper-strip-border', helper.border || surfaces.border);
158
+ setVar(vars, '--helper-strip-color', helper.text || text.primary);
159
+ setVar(vars, '--helper-strip-hover-bg', helper.hover || helper.background);
160
+ setVar(vars, '--helper-strip-hover-color', helper.hoverColor || helper.text || text.primary);
161
+ setVar(vars, '--helper-strip-link-hover', helper.linkHover || palette.primary);
162
+ setVar(vars, '--helper-strip-heading-color', helper.heading || text.primary);
163
+ setVar(vars, '--helper-strip-body-color', helper.body || text.mutedStrong || text.muted);
164
+
165
+ setVar(vars, '--tabs-bg', tabs.background);
166
+ setVar(vars, '--tabs-border', tabs.border || surfaces.border);
167
+ setVar(vars, '--tabs-shadow', tabs.shadow || elevation.flat);
168
+ setVar(vars, '--tab-color', tabs.tabColor || text.muted);
169
+ setVar(vars, '--tab-active-bg', tabs.activeBackground || palette.primary);
170
+ setVar(vars, '--tab-active-color', tabs.activeColor || palette.inverse || '#fff');
171
+ setVar(vars, '--tab-active-shadow', tabs.activeShadow || elevation.raised);
172
+ setVar(vars, '--tab-step-bg', tabs.stepBackground || helper.background);
173
+ setVar(vars, '--tab-step-border', tabs.stepBorder || helper.border);
174
+ setVar(vars, '--tab-step-color', tabs.stepColor || palette.primary);
175
+ setVar(vars, '--tab-active-step-bg', tabs.activeStepBackground || palette.primary);
176
+ setVar(vars, '--tab-active-step-border', tabs.activeStepBorder || tabs.stepBorder);
177
+ setVar(vars, '--tab-active-step-color', tabs.activeStepColor || palette.inverse || '#fff');
178
+
179
+ setVar(vars, '--field-bg', field.background);
180
+ setVar(vars, '--field-border', field.border || utility.inputBorder);
181
+ setVar(vars, '--field-shadow', field.shadow || elevation.flat);
182
+ setVar(vars, '--field-addon-bg', field.addonBackground || helper.background);
183
+ setVar(vars, '--field-addon-border', field.addonBorder || helper.border);
184
+ setVar(vars, '--field-addon-color', field.addonColor || helper.text || text.primary);
185
+ setVar(vars, '--field-input-color', field.inputColor || text.primary);
186
+ setVar(vars, '--field-input-placeholder', field.inputPlaceholder || text.muted);
187
+
188
+ setVar(vars, '--community-strip-bg', strip.background || surfaces.card);
189
+ setVar(vars, '--community-strip-border', strip.border || surfaces.border);
190
+ setVar(vars, '--community-strip-color', strip.text || text.primary);
191
+
192
+ setVar(vars, '--ui-text-primary', text.primary);
193
+ setVar(vars, '--ui-text-muted', text.muted);
194
+ setVar(vars, '--ui-field-label', text.muted);
195
+ setVar(vars, '--ui-field-value', text.primary);
196
+ setVar(vars, '--ui-status-heading-color', text.accent || palette.primary);
197
+
198
+ setVar(vars, '--brand-countdown-digit', palette.primary);
199
+ setVar(vars, '--brand-countdown-label', text.muted);
200
+
201
+ const secondaryCta = ctas.secondary || {};
202
+ setVar(vars, '--brand-pill-gradient', primaryCta.bg);
203
+ setVar(vars, '--brand-pill-alt-gradient', secondaryCta.bg || primaryCta.bg);
204
+ setVar(vars, '--brand-pill-contrast', primaryCta.text);
205
+
206
+ setVar(vars, '--brand-chip-neutral-bg', chips.neutral?.bg);
207
+ setVar(vars, '--brand-chip-neutral-color', chips.neutral?.text);
208
+ setVar(vars, '--brand-chip-neutral-border', chips.neutral?.border);
209
+
210
+ const heroBg = coerceArray(utility.gradientHero || heroSurface.background);
211
+ const promoBg = coerceArray(utility.gradientPromo || promoSurface.background);
212
+ if (heroBg) setVar(vars, '--brand-gradient-hero', heroBg);
213
+ if (promoBg) setVar(vars, '--brand-gradient-promo', promoBg);
214
+
215
+ setSurfaceVars(vars, '--hero-surface', heroSurface);
216
+ setSurfaceVars(vars, '--promo-surface', promoSurface);
217
+ const bodyBackground = coerceArray(utility.bodyBackground);
218
+ if (bodyBackground) {
219
+ setVar(vars, '--theme-body-background', bodyBackground);
220
+ }
221
+
222
+ const statusHeadline = coerceSurface(utility.statusHeadline, {
223
+ color: palette.primary || text.primary,
224
+ shadow: elevation.raised,
225
+ });
226
+
227
+ setVar(vars, '--status-headline-bg', statusHeadline.background);
228
+ setVar(vars, '--status-headline-color', statusHeadline.color);
229
+ setVar(vars, '--status-headline-shadow', statusHeadline.shadow);
230
+
231
+ setVar(vars, '--status-price-value-color', palette.accent || palette.primary || text.primary);
232
+ setVar(vars, '--status-price-value-shadow', elevation.raised);
233
+
234
+ return vars;
235
+ }
236
+
237
+ /**
238
+ * Render the var map as a CSS rule body.
239
+ * @param {object} vars Map of CSS custom property name → value
240
+ */
241
+ export function renderCssVarRule(selector, vars) {
242
+ const declarations = Object.entries(vars)
243
+ .map(([name, value]) => ` ${name}: ${value};`)
244
+ .join('\n');
245
+ return `${selector} {\n${declarations}\n}`;
246
+ }
@@ -71,6 +71,36 @@ if (!catalog.base) {
71
71
  * Called by the Vite plugin for each theme passed via the `themes` option.
72
72
  * Must be called before the first render (during app initialisation).
73
73
  *
74
+ * Two patterns for shipping theme CSS:
75
+ *
76
+ * 1. **Side-effect import (preferred).** The theme's `index.js` imports
77
+ * its `theme.css` for side effects:
78
+ *
79
+ * import manifest from './theme.config.js';
80
+ * import './theme.css';
81
+ * export default { manifest };
82
+ *
83
+ * Vite bundles `theme.css` into the main entry chunk and emits a
84
+ * synchronous `<link rel="stylesheet">` in `<head>`, so theme rules
85
+ * apply during the initial paint. Combined with the SSR-rendered
86
+ * `data-site-theme` attribute (set in `applySiteTheme`), this
87
+ * eliminates the FOUC that JS-injected theme CSS used to cause.
88
+ *
89
+ * 2. **Inline string (legacy).** The theme's `index.js` imports the CSS
90
+ * with `?inline` and exports it as a `css` string:
91
+ *
92
+ * import manifest from './theme.config.js';
93
+ * import css from './theme.css?inline';
94
+ * export default { manifest, css };
95
+ *
96
+ * The CSS is injected via a runtime `<style>` tag (handled below).
97
+ * This works but applies after Vue hydrates, so dark themes will
98
+ * flash a light first paint unless the site provides critical CSS.
99
+ *
100
+ * Existing themes (cms-theme-neon, -frog, -aurora, -southpark, -swamp)
101
+ * still use pattern 2 and are supported indefinitely; new themes should
102
+ * prefer pattern 1.
103
+ *
74
104
  * @param {object} themeModule - Default export from a @koehler8/cms-theme-* package
75
105
  * Expected shape: { manifest, css? } or just the manifest object directly.
76
106
  */
@@ -85,7 +115,9 @@ export function registerTheme(themeModule) {
85
115
 
86
116
  addToCatalog(manifest, sourceId, undefined);
87
117
 
88
- // If the theme package shipped inline CSS, inject it into the document head.
118
+ // Legacy pattern 2: theme shipped inline CSS, inject it into the document head.
119
+ // For pattern 1 themes, `cssContent` is undefined and this block is skipped —
120
+ // the CSS is already loaded via Vite's bundling.
89
121
  if (cssContent && typeof cssContent === 'string' && typeof document !== 'undefined') {
90
122
  const slug = normalizeSlug(manifest.slug);
91
123
  const existingStyle = document.getElementById(`theme-css-${slug}`);
@@ -1,232 +1,9 @@
1
1
  import { resolveThemeManifest } from './themeLoader.js';
2
+ import { buildCssVarMap } from './buildCssVarMap.js';
2
3
 
3
- const toKebab = (value = '') =>
4
- value
5
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
6
- .replace(/[\s_]+/g, '-')
7
- .toLowerCase();
8
-
9
- const withPx = (value) => {
10
- if (value === undefined || value === null) return undefined;
11
- if (typeof value === 'number') return `${value}px`;
12
- return value;
13
- };
14
-
15
- const setVar = (vars, name, value) => {
16
- if (value === undefined || value === null || value === '') return;
17
- vars[name] = String(value);
18
- };
19
-
20
- const assignPrefixed = (vars, entries = {}, prefix) => {
21
- Object.entries(entries).forEach(([key, value]) => {
22
- setVar(vars, `${prefix}${key}`, value);
23
- });
24
- };
25
-
26
- function setSurfaceVars(vars, prefix, surface = {}) {
27
- if (!surface) return;
28
- setVar(vars, `${prefix}-bg`, surface.background);
29
- setVar(vars, `${prefix}-before`, surface.before);
30
- setVar(vars, `${prefix}-after`, surface.after);
31
- }
32
-
33
- function coerceSurface(value, defaults = {}) {
34
- if (value && typeof value === 'object') return value;
35
- if (!value) return defaults;
36
- return { ...defaults, background: value };
37
- }
38
-
39
- function coerceArray(value) {
40
- if (!value) return undefined;
41
- return Array.isArray(value) ? value.filter(Boolean).join(', ') : value;
42
- }
43
-
44
- function buildCssVarMap(manifest) {
45
- if (!manifest) return {};
46
- const vars = {};
47
- const tokens = manifest.tokens || {};
48
- const palette = tokens.palette || {};
49
- const text = tokens.text || {};
50
- const surfaces = tokens.surfaces || {};
51
- const helper = surfaces.helper || {};
52
- const tabs = surfaces.tabs || {};
53
- const field = surfaces.field || {};
54
- const strip = surfaces.strip || {};
55
- const chrome = surfaces.chrome || {};
56
- const heroSurface = surfaces.backdropPrimary || {};
57
- const promoSurface = surfaces.backdropSecondary || {};
58
- const typography = tokens.typography || {};
59
- const ctas = tokens.ctas || {};
60
- const chips = tokens.chips || {};
61
- const focus = tokens.focus || {};
62
- const radii = tokens.radii || {};
63
- const elevation = tokens.elevation || {};
64
- const utility = tokens.utility || {};
65
-
66
- assignPrefixed(
67
- vars,
68
- Object.fromEntries(
69
- Object.entries(palette).map(([key, value]) => [`--brand-${toKebab(key)}`, value])
70
- )
71
- );
72
-
73
- setVar(vars, '--brand-accent-electric', palette.primary || palette.accent);
74
- setVar(vars, '--brand-accent-electric-soft', palette.accentSoft);
75
- setVar(vars, '--brand-accent-warm', palette.accent || palette.warning);
76
-
77
- setVar(vars, '--brand-fg-100', text.primary);
78
- setVar(vars, '--brand-fg-200', text.muted);
79
- setVar(vars, '--brand-fg-300', text.mutedStrong);
80
- setVar(vars, '--brand-fg-050', text.inverse);
81
- setVar(vars, '--brand-bg-900', surfaces.base);
82
- setVar(vars, '--brand-bg-800', surfaces.baseAlt);
83
- setVar(vars, '--brand-bg-700', surfaces.raised);
84
- setVar(vars, '--brand-bg-600', surfaces.sunken);
85
-
86
- Object.entries(radii).forEach(([key, value]) => {
87
- setVar(vars, `--brand-radius-${toKebab(key)}`, withPx(value));
88
- });
89
- setVar(vars, '--brand-card-radius', withPx(radii.lg || radii.md));
90
- setVar(vars, '--brand-button-radius', withPx(radii.md || radii.sm));
91
-
92
- setVar(vars, '--brand-shadow-glow', elevation.raised);
93
- setVar(vars, '--brand-shadow-glow-strong', elevation.overlay);
94
- setVar(vars, '--brand-card-shadow', elevation.raised);
95
- setVar(vars, '--brand-surface-card-shadow', elevation.raised);
96
-
97
- const cardTextColor = text.card || text.primary;
98
- setVar(vars, '--brand-card-soft', surfaces.card);
99
- setVar(vars, '--brand-surface-card-bg', surfaces.card);
100
- setVar(vars, '--brand-card-border', surfaces.border);
101
- setVar(vars, '--brand-surface-card-border', surfaces.border);
102
- setVar(vars, '--brand-card-text', cardTextColor);
103
- setVar(vars, '--brand-surface-helper-bg', helper.background || surfaces.callout);
104
- setVar(vars, '--brand-surface-helper-hover-bg', helper.hover || helper.background);
105
- setVar(vars, '--brand-icon-badge-bg', surfaces.chipAccent || surfaces.chip);
106
- setVar(vars, '--brand-icon-badge-color', palette.primary || text.primary);
107
-
108
- setVar(vars, '--brand-border-highlight', surfaces.border);
109
- setVar(vars, '--brand-border-glow', surfaces.border);
110
-
111
- setVar(vars, '--brand-status-success', palette.success);
112
- setVar(vars, '--brand-status-error', palette.critical);
113
- setVar(vars, '--brand-status-error-soft', palette.criticalSoft);
114
-
115
- setVar(vars, '--brand-input-bg', field.background || surfaces.input);
116
- setVar(vars, '--brand-input-border', field.border || utility.inputBorder);
117
- setVar(vars, '--brand-input-border-active', focus.ring);
118
- setVar(vars, '--brand-input-text', field.inputColor || utility.inputText || text.primary);
119
- setVar(
120
- vars,
121
- '--brand-input-placeholder',
122
- field.inputPlaceholder || utility.inputPlaceholder || text.muted
123
- );
124
- setVar(vars, '--brand-focus-ring', focus.ring);
125
- setVar(vars, '--brand-focus-glow', focus.shadowInset);
126
-
127
- setVar(vars, '--brand-modal-backdrop', surfaces.backdrop);
128
- setVar(vars, '--brand-modal-surface', utility.modalSurface);
129
- setVar(vars, '--brand-modal-border', utility.modalBorder);
130
- setVar(vars, '--brand-modal-shadow', utility.modalShadow);
131
- setVar(vars, '--brand-modal-radius', withPx(utility.modalRadius));
132
-
133
- setVar(vars, '--brand-chart-track', utility.chartTrack);
134
- setVar(vars, '--brand-chart-center-text', utility.chartCenterText);
135
-
136
- setVar(vars, '--brand-header-bg', chrome.background);
137
- setVar(vars, '--brand-header-text', chrome.text);
138
- setVar(vars, '--site-header-shadow', chrome.shadow);
139
- setVar(vars, '--site-header-shadow-compact', chrome.compactShadow);
140
-
141
- const primaryCta = ctas.primary || {};
142
- setVar(vars, '--brand-primary-cta-gradient', primaryCta.bg);
143
- setVar(vars, '--brand-primary-cta-text', primaryCta.text);
144
- setVar(vars, '--brand-primary-cta-border', primaryCta.border);
145
- setVar(vars, '--brand-primary-cta-shadow', primaryCta.shadow);
146
- setVar(vars, '--brand-primary-cta-hover-shadow', primaryCta.shadow);
147
- setVar(vars, '--brand-primary-cta-hover-translate', 'translateY(-1px)');
148
- setVar(vars, '--brand-cta-text', primaryCta.text);
149
-
150
- setVar(vars, '--helper-strip-bg', helper.background || surfaces.callout);
151
- setVar(vars, '--helper-strip-border', helper.border || surfaces.border);
152
- setVar(vars, '--helper-strip-color', helper.text || text.primary);
153
- setVar(vars, '--helper-strip-hover-bg', helper.hover || helper.background);
154
- setVar(vars, '--helper-strip-hover-color', helper.hoverColor || helper.text || text.primary);
155
- setVar(vars, '--helper-strip-link-hover', helper.linkHover || palette.primary);
156
- setVar(vars, '--helper-strip-heading-color', helper.heading || text.primary);
157
- setVar(vars, '--helper-strip-body-color', helper.body || text.mutedStrong || text.muted);
158
-
159
- setVar(vars, '--tabs-bg', tabs.background);
160
- setVar(vars, '--tabs-border', tabs.border || surfaces.border);
161
- setVar(vars, '--tabs-shadow', tabs.shadow || elevation.flat);
162
- setVar(vars, '--tab-color', tabs.tabColor || text.muted);
163
- setVar(vars, '--tab-active-bg', tabs.activeBackground || palette.primary);
164
- setVar(vars, '--tab-active-color', tabs.activeColor || palette.inverse || '#fff');
165
- setVar(vars, '--tab-active-shadow', tabs.activeShadow || elevation.raised);
166
- setVar(vars, '--tab-step-bg', tabs.stepBackground || helper.background);
167
- setVar(vars, '--tab-step-border', tabs.stepBorder || helper.border);
168
- setVar(vars, '--tab-step-color', tabs.stepColor || palette.primary);
169
- setVar(vars, '--tab-active-step-bg', tabs.activeStepBackground || palette.primary);
170
- setVar(vars, '--tab-active-step-border', tabs.activeStepBorder || tabs.stepBorder);
171
- setVar(vars, '--tab-active-step-color', tabs.activeStepColor || palette.inverse || '#fff');
172
-
173
- setVar(vars, '--field-bg', field.background);
174
- setVar(vars, '--field-border', field.border || utility.inputBorder);
175
- setVar(vars, '--field-shadow', field.shadow || elevation.flat);
176
- setVar(vars, '--field-addon-bg', field.addonBackground || helper.background);
177
- setVar(vars, '--field-addon-border', field.addonBorder || helper.border);
178
- setVar(vars, '--field-addon-color', field.addonColor || helper.text || text.primary);
179
- setVar(vars, '--field-input-color', field.inputColor || text.primary);
180
- setVar(vars, '--field-input-placeholder', field.inputPlaceholder || text.muted);
181
-
182
- setVar(vars, '--community-strip-bg', strip.background || surfaces.card);
183
- setVar(vars, '--community-strip-border', strip.border || surfaces.border);
184
- setVar(vars, '--community-strip-color', strip.text || text.primary);
185
-
186
- setVar(vars, '--ui-text-primary', text.primary);
187
- setVar(vars, '--ui-text-muted', text.muted);
188
- setVar(vars, '--ui-field-label', text.muted);
189
- setVar(vars, '--ui-field-value', text.primary);
190
- setVar(vars, '--ui-status-heading-color', text.accent || palette.primary);
191
-
192
- setVar(vars, '--brand-countdown-digit', palette.primary);
193
- setVar(vars, '--brand-countdown-label', text.muted);
194
-
195
- const secondaryCta = ctas.secondary || {};
196
- setVar(vars, '--brand-pill-gradient', primaryCta.bg);
197
- setVar(vars, '--brand-pill-alt-gradient', secondaryCta.bg || primaryCta.bg);
198
- setVar(vars, '--brand-pill-contrast', primaryCta.text);
199
-
200
- setVar(vars, '--brand-chip-neutral-bg', chips.neutral?.bg);
201
- setVar(vars, '--brand-chip-neutral-color', chips.neutral?.text);
202
- setVar(vars, '--brand-chip-neutral-border', chips.neutral?.border);
203
-
204
- const heroBg = coerceArray(utility.gradientHero || heroSurface.background);
205
- const promoBg = coerceArray(utility.gradientPromo || promoSurface.background);
206
- if (heroBg) setVar(vars, '--brand-gradient-hero', heroBg);
207
- if (promoBg) setVar(vars, '--brand-gradient-promo', promoBg);
208
-
209
- setSurfaceVars(vars, '--hero-surface', heroSurface);
210
- setSurfaceVars(vars, '--promo-surface', promoSurface);
211
- const bodyBackground = coerceArray(utility.bodyBackground);
212
- if (bodyBackground) {
213
- setVar(vars, '--theme-body-background', bodyBackground);
214
- }
215
-
216
- const statusHeadline = coerceSurface(utility.statusHeadline, {
217
- color: palette.primary || text.primary,
218
- shadow: elevation.raised,
219
- });
220
-
221
- setVar(vars, '--status-headline-bg', statusHeadline.background);
222
- setVar(vars, '--status-headline-color', statusHeadline.color);
223
- setVar(vars, '--status-headline-shadow', statusHeadline.shadow);
224
-
225
- setVar(vars, '--status-price-value-color', palette.accent || palette.primary || text.primary);
226
- setVar(vars, '--status-price-value-shadow', elevation.raised);
227
-
228
- return vars;
229
- }
4
+ // buildCssVarMap was extracted to its own pure module so the build-time
5
+ // vite plugin can call it (to emit `virtual:cms-theme-vars.css`) without
6
+ // pulling in this file's runtime-only imports.
230
7
 
231
8
  export function applyThemeVariables(themeKey) {
232
9
  const manifest = resolveThemeManifest(themeKey);
package/vite-plugin.js CHANGED
@@ -8,7 +8,8 @@
8
8
 
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
- import { fileURLToPath } from 'node:url';
11
+ import { createRequire } from 'node:module';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
13
  import vue from '@vitejs/plugin-vue';
13
14
 
14
15
  import { SUPPORTED_LOCALES } from './src/constants/locales.js';
@@ -99,8 +100,13 @@ const __dirname = path.dirname(__filename);
99
100
  const VIRTUAL_CONFIG = 'virtual:cms-config-loader';
100
101
  const VIRTUAL_STYLES = 'virtual:cms-site-styles';
101
102
  const VIRTUAL_ASSETS = 'virtual:cms-asset-resolver';
103
+ // Synthetic stylesheet built from each registered theme's manifest at build
104
+ // time. Importing this from the generated entry causes Vite to emit a real
105
+ // <link rel="stylesheet"> in <head>, so theme CSS variables apply during the
106
+ // initial paint instead of after Vue hydrates.
107
+ const VIRTUAL_THEME_VARS = 'virtual:cms-theme-vars.css';
102
108
 
103
- const VIRTUAL_IDS = new Set([VIRTUAL_CONFIG, VIRTUAL_STYLES, VIRTUAL_ASSETS]);
109
+ const VIRTUAL_IDS = new Set([VIRTUAL_CONFIG, VIRTUAL_STYLES, VIRTUAL_ASSETS, VIRTUAL_THEME_VARS]);
104
110
 
105
111
  // Entry file name (written to site repo root, gitignored, cleaned up in buildEnd)
106
112
  const ENTRY_FILENAME = '.cms-entry.js';
@@ -148,6 +154,7 @@ ${extImports}
148
154
  import * as __cmsConfig from '${VIRTUAL_CONFIG}';
149
155
  import * as __cmsStyles from '${VIRTUAL_STYLES}';
150
156
  import * as __cmsAssets from '${VIRTUAL_ASSETS}';
157
+ import '${VIRTUAL_THEME_VARS}';
151
158
 
152
159
  setConfigLoader(__cmsConfig);
153
160
  setSiteStyleLoader(__cmsStyles);
@@ -479,10 +486,66 @@ export default function cmsPlugin(options = {}) {
479
486
  return null;
480
487
  },
481
488
 
482
- load(id) {
489
+ async load(id) {
483
490
  if (!isResolvedVirtual(id)) return null;
484
491
  const virtualId = id.slice(1); // strip leading \0
485
492
 
493
+ if (virtualId === VIRTUAL_THEME_VARS) {
494
+ // Build a stylesheet by deriving CSS variables from each registered
495
+ // theme's manifest at build time. We resolve the theme package via
496
+ // the site's node_modules (createRequire from siteRoot) and import
497
+ // its pure manifest through the package's `./config` exports field
498
+ // (which points at theme.config.js — sidestepping any Vite-only
499
+ // imports the theme's index.js may have, e.g. `?inline` CSS or a
500
+ // side-effect `import './theme.css'`). The bundled `base` theme is
501
+ // always included so sites without a `themes` option still get a
502
+ // sync theme.
503
+ const { buildCssVarMap, renderCssVarRule } = await import(
504
+ './src/themes/buildCssVarMap.js'
505
+ );
506
+ const baseManifestModule = await import('./themes/base/theme.config.js');
507
+ const baseManifest = baseManifestModule.default || baseManifestModule;
508
+
509
+ const requireFromSite = createRequire(path.join(siteRoot, 'package.json'));
510
+
511
+ const blocks = [];
512
+ const seen = new Set();
513
+
514
+ const addManifest = (manifest, sourceLabel) => {
515
+ if (!manifest || typeof manifest !== 'object') return;
516
+ const slug = typeof manifest.slug === 'string' ? manifest.slug.trim().toLowerCase() : '';
517
+ if (!slug || seen.has(slug)) return;
518
+ seen.add(slug);
519
+ const vars = buildCssVarMap(manifest);
520
+ if (Object.keys(vars).length === 0) return;
521
+ const selector = `:root[data-site-theme="${slug}"]`;
522
+ blocks.push(`/* ${sourceLabel} */\n${renderCssVarRule(selector, vars)}`);
523
+ };
524
+
525
+ addManifest(baseManifest, '@koehler8/cms (base)');
526
+
527
+ for (const pkg of themePackages) {
528
+ let manifestPath;
529
+ try {
530
+ manifestPath = requireFromSite.resolve(`${pkg}/config`);
531
+ } catch (err) {
532
+ this.warn(`[cms-vite-plugin] Theme "${pkg}" does not expose a "./config" export pointing at theme.config.js; skipping. (${err.message})`);
533
+ continue;
534
+ }
535
+ let manifest;
536
+ try {
537
+ const mod = await import(pathToFileURL(manifestPath).href);
538
+ manifest = mod.default || mod.manifest || mod;
539
+ } catch (err) {
540
+ this.warn(`[cms-vite-plugin] Failed to load theme manifest from "${pkg}": ${err.message}`);
541
+ continue;
542
+ }
543
+ addManifest(manifest, pkg);
544
+ }
545
+
546
+ return blocks.join('\n\n') + '\n';
547
+ }
548
+
486
549
  if (virtualId === VIRTUAL_CONFIG) {
487
550
  return `
488
551
  import { createConfigLoader } from '@koehler8/cms/utils/loadConfig';