@uniweb/theming 0.1.1 → 0.1.3

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": "@uniweb/theming",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Theming utilities for the Uniweb platform — shade generation, CSS generation, theme processing",
5
5
  "type": "module",
6
6
  "exports": {
@@ -26,10 +26,10 @@
26
26
  "engines": {
27
27
  "node": ">=20.19"
28
28
  },
29
- "scripts": {
30
- "test": "vitest run"
31
- },
32
29
  "devDependencies": {
33
30
  "vitest": "^3.2.1"
31
+ },
32
+ "scripts": {
33
+ "test": "vitest run"
34
34
  }
35
- }
35
+ }
@@ -26,6 +26,7 @@ const DEFAULT_CONTEXT_TOKENS = {
26
26
  'ring': 'var(--primary-500)',
27
27
  'link': 'var(--primary-600)',
28
28
  'link-hover': 'var(--primary-700)',
29
+ 'accent': 'var(--accent-600)',
29
30
  'primary': 'var(--primary-600)',
30
31
  'primary-foreground': 'white',
31
32
  'primary-hover': 'var(--primary-700)',
@@ -54,6 +55,7 @@ const DEFAULT_CONTEXT_TOKENS = {
54
55
  'ring': 'var(--primary-500)',
55
56
  'link': 'var(--primary-600)',
56
57
  'link-hover': 'var(--primary-700)',
58
+ 'accent': 'var(--accent-600)',
57
59
  'primary': 'var(--primary-600)',
58
60
  'primary-foreground': 'white',
59
61
  'primary-hover': 'var(--primary-700)',
@@ -72,7 +74,7 @@ const DEFAULT_CONTEXT_TOKENS = {
72
74
  'info-subtle': '#eff6ff',
73
75
  },
74
76
  dark: {
75
- 'section': 'var(--neutral-900)',
77
+ 'section': 'var(--neutral-950)',
76
78
  'card': 'var(--neutral-800)',
77
79
  'muted': 'var(--neutral-700)',
78
80
  'body': 'var(--neutral-50)',
@@ -82,6 +84,7 @@ const DEFAULT_CONTEXT_TOKENS = {
82
84
  'ring': 'var(--primary-500)',
83
85
  'link': 'var(--primary-400)',
84
86
  'link-hover': 'var(--primary-300)',
87
+ 'accent': 'var(--accent-400)',
85
88
  'primary': 'var(--primary-500)',
86
89
  'primary-foreground': 'white',
87
90
  'primary-hover': 'var(--primary-400)',
@@ -150,55 +153,36 @@ function generatePaletteVars(palettes) {
150
153
  *
151
154
  * @param {string} context - Context name (light, medium, dark)
152
155
  * @param {Object} tokens - Token overrides
156
+ * @param {Object} colorVars - Context-aware foundation color vars: { light: {...}, dark: {...} }
153
157
  * @returns {string} CSS for context class
154
158
  */
155
- function generateContextCSS(context, tokens = {}) {
159
+ function generateContextCSS(context, tokens = {}, colorVars = {}) {
156
160
  const defaultTokens = DEFAULT_CONTEXT_TOKENS[context] || DEFAULT_CONTEXT_TOKENS.light
157
161
  const mergedTokens = { ...defaultTokens, ...tokens }
158
162
 
159
- const vars = generateVarDeclarations(mergedTokens)
163
+ // Merge context-aware color vars into the context class
164
+ const contextColorVars = colorVars[context] || {}
165
+ const allVars = { ...mergedTokens, ...contextColorVars }
166
+
167
+ const vars = generateVarDeclarations(allVars)
160
168
 
161
- return `.context-${context} {\n${vars}\n background-color: var(--section);\n}`
169
+ return `.context-${context} {\n${vars}\n}`
162
170
  }
163
171
 
164
172
  /**
165
173
  * Generate dark scheme CSS (for site-wide dark mode toggle)
166
174
  *
167
175
  * @param {Object} config - Appearance configuration
176
+ * @param {Object} darkOverrides - Dark context token overrides
177
+ * @param {Object} colorVars - Context-aware foundation color vars: { light: {...}, dark: {...} }
168
178
  * @returns {string} CSS for dark scheme support
169
179
  */
170
- function generateDarkSchemeCSS(config = {}) {
171
- const { respectSystemPreference = true } = config
180
+ function generateDarkSchemeCSS(config = {}, darkOverrides = {}, colorVars = {}) {
181
+ const respectSystemPreference = config.default === 'system'
172
182
 
173
- // Dark scheme tokens - similar to dark context but at root level
174
- const darkTokens = {
175
- 'section': 'var(--neutral-950)',
176
- 'card': 'var(--neutral-900)',
177
- 'muted': 'var(--neutral-800)',
178
- 'body': 'var(--neutral-50)',
179
- 'heading': 'white',
180
- 'subtle': 'var(--neutral-400)',
181
- 'border': 'var(--neutral-800)',
182
- 'ring': 'var(--primary-500)',
183
- 'link': 'var(--primary-400)',
184
- 'link-hover': 'var(--primary-300)',
185
- 'primary': 'var(--primary-500)',
186
- 'primary-foreground': 'white',
187
- 'primary-hover': 'var(--primary-400)',
188
- 'primary-border': 'transparent',
189
- 'secondary': 'var(--neutral-800)',
190
- 'secondary-foreground': 'var(--neutral-100)',
191
- 'secondary-hover': 'var(--neutral-700)',
192
- 'secondary-border': 'var(--neutral-600)',
193
- 'success': '#4ade80',
194
- 'success-subtle': '#052e16',
195
- 'warning': '#fbbf24',
196
- 'warning-subtle': '#451a03',
197
- 'error': '#f87171',
198
- 'error-subtle': '#450a0a',
199
- 'info': '#60a5fa',
200
- 'info-subtle': '#172554',
201
- }
183
+ // Merge default dark tokens with user element overrides and dark color vars
184
+ const darkColorVars = colorVars.dark || {}
185
+ const darkTokens = { ...DEFAULT_CONTEXT_TOKENS.dark, ...darkOverrides, ...darkColorVars }
202
186
 
203
187
  const vars = generateVarDeclarations(darkTokens)
204
188
 
@@ -218,28 +202,177 @@ function generateDarkSchemeCSS(config = {}) {
218
202
  return css
219
203
  }
220
204
 
205
+ // Generic CSS font families that should not trigger font loading
206
+ const GENERIC_FAMILIES = new Set([
207
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
208
+ 'system-ui', 'ui-monospace', 'ui-serif', 'ui-sans-serif', 'ui-rounded',
209
+ 'math', 'emoji', 'fangsong',
210
+ 'inherit', 'initial', 'revert', 'unset',
211
+ ])
212
+
221
213
  /**
222
- * Generate font CSS
214
+ * Extract font family names actually used by body/heading/mono slots.
215
+ *
216
+ * Only considers slots the user explicitly configured (tracked via _userSlots
217
+ * from the processor). Default system font stacks are ignored so they don't
218
+ * trigger unnecessary font loading.
223
219
  *
224
220
  * @param {Object} fonts - Font configuration
225
- * @returns {string} CSS for fonts
221
+ * @returns {Set<string>} Lowercase family names referenced by user-set slots
222
+ */
223
+ function extractUsedFamilies(fonts) {
224
+ const used = new Set()
225
+ const slots = fonts._userSlots || ['body', 'heading', 'mono']
226
+
227
+ for (const slot of slots) {
228
+ const value = fonts[slot]
229
+ if (!value) continue
230
+
231
+ for (const segment of value.split(',')) {
232
+ const name = segment.trim().replace(/^["']|["']$/g, '').toLowerCase()
233
+ if (name && !GENERIC_FAMILIES.has(name)) {
234
+ used.add(name)
235
+ }
236
+ }
237
+ }
238
+
239
+ return used
240
+ }
241
+
242
+ /**
243
+ * Filter a Google Fonts URL to only include families in the used set.
244
+ *
245
+ * @param {string} url - Google Fonts CSS URL
246
+ * @param {Set<string>} usedFamilies - Lowercase family names to keep
247
+ * @returns {string|null} Filtered URL, or null if no families remain
248
+ */
249
+ function filterGoogleFontsUrl(url, usedFamilies) {
250
+ try {
251
+ const parsed = new URL(url)
252
+ const familyParams = parsed.searchParams.getAll('family')
253
+ if (familyParams.length === 0) return url
254
+
255
+ const kept = familyParams.filter((param) => {
256
+ // "Montserrat:wght@400;700" → "montserrat"
257
+ const name = param.split(':')[0].trim().toLowerCase()
258
+ return usedFamilies.has(name)
259
+ })
260
+
261
+ if (kept.length === 0) return null
262
+
263
+ // Rebuild URL with only kept families
264
+ parsed.searchParams.delete('family')
265
+ for (const f of kept) {
266
+ parsed.searchParams.append('family', f)
267
+ }
268
+ return parsed.toString()
269
+ } catch {
270
+ return url // malformed URL — keep as-is
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Check whether a URL points to Google Fonts.
276
+ */
277
+ function isGoogleFontsUrl(url) {
278
+ try {
279
+ return new URL(url).hostname === 'fonts.googleapis.com'
280
+ } catch {
281
+ return false
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Generate font CSS and external link tags.
287
+ *
288
+ * @param {Object} fonts - Font configuration
289
+ * @returns {{ css: string, links: string }} css for <style>, links for <head>
226
290
  */
227
291
  function generateFontCSS(fonts = {}) {
228
- const lines = []
292
+ const cssLines = []
293
+ const linkTags = []
294
+
295
+ const usedFamilies = extractUsedFamilies(fonts)
296
+
297
+ // --- @font-face rules from faces[] (filtered to used families) ---
298
+ // Also generate <link rel="preload"> hints so browsers fetch fonts early
299
+ const PRELOAD_TYPE = { woff2: 'font/woff2', woff: 'font/woff', truetype: 'font/ttf', opentype: 'font/otf' }
300
+
301
+ if (fonts.faces && Array.isArray(fonts.faces)) {
302
+ for (const face of fonts.faces) {
303
+ if (!face.family || !face.src) continue
304
+ if (!usedFamilies.has(face.family.toLowerCase())) continue
305
+
306
+ const format = face.format || (face.src.endsWith('.woff2') ? 'woff2' : 'woff')
307
+ cssLines.push(
308
+ `@font-face {`,
309
+ ` font-family: ${face.family};`,
310
+ ` src: url('${face.src}') format('${format}');`,
311
+ ` font-weight: ${face.weight || 400};`,
312
+ ` font-style: ${face.style || 'normal'};`,
313
+ ` font-display: swap;`,
314
+ `}`,
315
+ )
316
+
317
+ const mimeType = PRELOAD_TYPE[format]
318
+ if (mimeType) {
319
+ linkTags.push(`<link rel="preload" href="${face.src}" as="font" type="${mimeType}" crossorigin>`)
320
+ }
321
+ }
322
+ if (cssLines.length > 0) {
323
+ cssLines.push('') // blank line after @font-face block
324
+ }
325
+ }
326
+
327
+ // --- External imports → <link> tags (filtered to used families) ---
328
+ const googleUrls = []
229
329
 
230
- // Font imports
231
330
  if (fonts.import && Array.isArray(fonts.import)) {
232
- for (const font of fonts.import) {
233
- if (font.url) {
234
- lines.push(`@import url('${font.url}');`)
331
+ for (const entry of fonts.import) {
332
+ if (!entry.url) continue
333
+
334
+ if (isGoogleFontsUrl(entry.url)) {
335
+ const filtered = filterGoogleFontsUrl(entry.url, usedFamilies)
336
+ if (filtered) googleUrls.push(filtered)
337
+ } else {
338
+ linkTags.push(`<link rel="stylesheet" href="${entry.url}">`)
235
339
  }
236
340
  }
237
- if (lines.length > 0) {
238
- lines.push('') // Empty line after imports
341
+ }
342
+
343
+ // Merge Google Fonts into a single <link> with preconnect
344
+ if (googleUrls.length > 0) {
345
+ // Collect all family params from all URLs into one
346
+ const allFamilies = []
347
+ let display = 'swap'
348
+ for (const url of googleUrls) {
349
+ try {
350
+ const parsed = new URL(url)
351
+ allFamilies.push(...parsed.searchParams.getAll('family'))
352
+ if (parsed.searchParams.get('display')) {
353
+ display = parsed.searchParams.get('display')
354
+ }
355
+ } catch {
356
+ // skip malformed
357
+ }
358
+ }
359
+
360
+ if (allFamilies.length > 0) {
361
+ const merged = new URL('https://fonts.googleapis.com/css2')
362
+ for (const f of allFamilies) {
363
+ merged.searchParams.append('family', f)
364
+ }
365
+ merged.searchParams.set('display', display)
366
+
367
+ linkTags.unshift(
368
+ `<link rel="preconnect" href="https://fonts.googleapis.com">`,
369
+ `<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`,
370
+ `<link rel="stylesheet" href="${merged.toString()}">`,
371
+ )
239
372
  }
240
373
  }
241
374
 
242
- // Font family variables
375
+ // --- :root font variables ---
243
376
  const fontVars = []
244
377
  if (fonts.body) {
245
378
  fontVars.push(` --font-body: ${fonts.body};`)
@@ -252,14 +385,33 @@ function generateFontCSS(fonts = {}) {
252
385
  }
253
386
 
254
387
  if (fontVars.length > 0) {
255
- lines.push(':root {')
256
- lines.push(...fontVars)
257
- lines.push('}')
388
+ cssLines.push(':root {')
389
+ cssLines.push(...fontVars)
390
+ cssLines.push('}')
258
391
  }
259
392
 
260
- return lines.join('\n')
393
+ return {
394
+ css: cssLines.join('\n'),
395
+ links: linkTags.join('\n'),
396
+ }
261
397
  }
262
398
 
399
+ // Theme token names reserved by the theming engine — foundation vars must not use these
400
+ const RESERVED_VAR_NAMES = new Set([
401
+ // Semantic color tokens
402
+ 'section', 'card', 'muted', 'body', 'heading', 'subtle',
403
+ 'border', 'ring', 'link', 'link-hover', 'accent',
404
+ // Action tokens
405
+ 'primary', 'primary-foreground', 'primary-hover', 'primary-border',
406
+ 'secondary', 'secondary-foreground', 'secondary-hover', 'secondary-border',
407
+ // Status tokens
408
+ 'success', 'success-subtle', 'warning', 'warning-subtle',
409
+ 'error', 'error-subtle', 'info', 'info-subtle',
410
+ ])
411
+
412
+ // Palette shade pattern: primary-50, neutral-950, accent-300, etc.
413
+ const PALETTE_SHADE_RE = /^(primary|secondary|accent|neutral)-\d+$/
414
+
263
415
  /**
264
416
  * Generate foundation-specific CSS variables
265
417
  *
@@ -274,6 +426,12 @@ function generateFoundationVars(vars = {}) {
274
426
  const declarations = []
275
427
 
276
428
  for (const [name, config] of Object.entries(vars)) {
429
+ if (RESERVED_VAR_NAMES.has(name) || PALETTE_SHADE_RE.test(name)) {
430
+ console.warn(
431
+ `Warning: Foundation var '${name}' collides with a theme token. ` +
432
+ `Rename it to avoid unexpected behavior (e.g., '${name}-value' or '${name}-size').`
433
+ )
434
+ }
277
435
  const value = typeof config === 'object' ? config.default : config
278
436
  if (value !== undefined) {
279
437
  declarations.push(` --${name}: ${value};`)
@@ -296,7 +454,7 @@ function generateFoundationVars(vars = {}) {
296
454
  * @param {Object} config.fonts - Font configuration
297
455
  * @param {Object} config.appearance - Appearance settings (dark mode, etc.)
298
456
  * @param {Object} config.foundationVars - Foundation-specific variables
299
- * @returns {string} Complete CSS string
457
+ * @returns {{ css: string, links: string }} CSS string and font link tags
300
458
  */
301
459
  export function generateThemeCSS(config = {}) {
302
460
  const {
@@ -305,14 +463,15 @@ export function generateThemeCSS(config = {}) {
305
463
  fonts = {},
306
464
  appearance = {},
307
465
  foundationVars = {},
466
+ colorVars = {},
308
467
  } = config
309
468
 
310
469
  const sections = []
311
470
 
312
- // 1. Font imports and variables
313
- const fontCSS = generateFontCSS(fonts)
314
- if (fontCSS) {
315
- sections.push('/* Typography */\n' + fontCSS)
471
+ // 1. Font face rules and variables (links returned separately)
472
+ const fontResult = generateFontCSS(fonts)
473
+ if (fontResult.css) {
474
+ sections.push('/* Typography */\n' + fontResult.css)
316
475
  }
317
476
 
318
477
  // 2. Color palettes
@@ -321,15 +480,17 @@ export function generateThemeCSS(config = {}) {
321
480
  sections.push(`/* Color Palettes */\n:root {\n${paletteVars}\n}`)
322
481
 
323
482
  // 3. Default semantic tokens (applied to :root for global defaults)
324
- const defaultTokens = { ...DEFAULT_CONTEXT_TOKENS.light, ...(contexts.light || {}) }
483
+ // Include light color vars in the :root defaults
484
+ const lightColorVars = colorVars.light || {}
485
+ const defaultTokens = { ...DEFAULT_CONTEXT_TOKENS.light, ...(contexts.light || {}), ...lightColorVars }
325
486
  const defaultVars = generateVarDeclarations(defaultTokens)
326
- sections.push(`/* Default Semantic Tokens */\n:root {\n${defaultVars}\n}`)
487
+ sections.push(`/* Default Semantic Tokens */\n:root {\n${defaultVars}\n}\n\n/* Section backgrounds — applied once, resolves via context tokens */\n[id^="section-"] {\n background-color: var(--section);\n}`)
327
488
 
328
489
  // 4. Context classes
329
490
  const contextCSS = [
330
- generateContextCSS('light', contexts.light),
331
- generateContextCSS('medium', contexts.medium),
332
- generateContextCSS('dark', contexts.dark),
491
+ generateContextCSS('light', contexts.light, colorVars),
492
+ generateContextCSS('medium', contexts.medium, colorVars),
493
+ generateContextCSS('dark', contexts.dark, colorVars),
333
494
  ]
334
495
  sections.push('/* Color Contexts */\n' + contextCSS.join('\n\n'))
335
496
 
@@ -339,9 +500,9 @@ export function generateThemeCSS(config = {}) {
339
500
  sections.push('/* Foundation Variables */\n' + foundationCSS)
340
501
  }
341
502
 
342
- // 6. Dark scheme support (if enabled)
343
- if (appearance.allowToggle || appearance.schemes?.includes('dark')) {
344
- sections.push(generateDarkSchemeCSS(appearance))
503
+ // 6. Dark scheme support (if enabled, default is dark, or follows system preference)
504
+ if (appearance.allowToggle || appearance.default === 'dark' || appearance.default === 'system' || appearance.schemes?.includes('dark')) {
505
+ sections.push(generateDarkSchemeCSS(appearance, contexts.dark, colorVars))
345
506
  }
346
507
 
347
508
  // 7. Site background (if specified in theme.yml)
@@ -364,7 +525,10 @@ export function generateThemeCSS(config = {}) {
364
525
  }
365
526
  }
366
527
 
367
- return sections.join('\n\n')
528
+ return {
529
+ css: sections.join('\n\n'),
530
+ links: fontResult.links,
531
+ }
368
532
  }
369
533
 
370
534
  /**
@@ -391,10 +555,16 @@ export function getDefaultColors() {
391
555
  return { ...DEFAULT_COLORS }
392
556
  }
393
557
 
558
+ /**
559
+ * Extract used font families (useful for testing)
560
+ */
561
+ export { extractUsedFamilies }
562
+
394
563
  export default {
395
564
  generateThemeCSS,
396
565
  generateContextCSS,
397
566
  generatePaletteVars,
398
567
  getDefaultContextTokens,
399
568
  getDefaultColors,
569
+ extractUsedFamilies,
400
570
  }
package/src/index.js CHANGED
@@ -24,6 +24,7 @@ export {
24
24
  generatePaletteVars,
25
25
  getDefaultContextTokens,
26
26
  getDefaultColors,
27
+ extractUsedFamilies,
27
28
  } from './css-generator.js'
28
29
 
29
30
  // Theme processing
@@ -34,6 +35,12 @@ export {
34
35
  foundationHasVars,
35
36
  } from './processor.js'
36
37
 
38
+ // Value normalization
39
+ export { normalizeTokenValue } from './normalize.js'
40
+
41
+ // Section override CSS
42
+ export { buildSectionOverrides } from './section-overrides.js'
43
+
37
44
  // Default export for convenience
38
45
  import { processTheme } from './processor.js'
39
46
  import { generateThemeCSS } from './css-generator.js'
@@ -44,14 +51,15 @@ import { generateThemeCSS } from './css-generator.js'
44
51
  * @param {Object} themeYml - Raw theme.yml content
45
52
  * @param {Object} options - Processing options
46
53
  * @param {Object} options.foundationVars - Foundation variables
47
- * @returns {{ css: string, config: Object, errors: string[], warnings: string[] }}
54
+ * @returns {{ css: string, links: string, config: Object, errors: string[], warnings: string[] }}
48
55
  */
49
56
  export function buildTheme(themeYml = {}, options = {}) {
50
57
  const { config, errors, warnings } = processTheme(themeYml, options)
51
- const css = generateThemeCSS(config)
58
+ const { css, links } = generateThemeCSS(config)
52
59
 
53
60
  return {
54
61
  css,
62
+ links,
55
63
  config,
56
64
  errors,
57
65
  warnings,
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Token Value Normalizer
3
+ *
4
+ * Converts stored token values into valid CSS. This is the single source
5
+ * of truth for normalizing values from all origins:
6
+ *
7
+ * - theme.yml contexts: bare palette refs like `neutral-500`
8
+ * - Section overrides (DB): `rgba(var(--primary-900) / 1.00)`
9
+ * - Frontmatter overrides: bare palette refs like `neutral-900`
10
+ *
11
+ * Also supports the opacity shorthand: `neutral-500/20` → 20% opacity.
12
+ *
13
+ * @module @uniweb/theming/normalize
14
+ */
15
+
16
+ const SHADE_LEVELS = new Set([50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950])
17
+
18
+ // Matches bare palette reference: "primary-500", "neutral-900", "--accent-200"
19
+ const PALETTE_REF_RE = /^([a-z][a-z0-9]*)-(\d+)$/
20
+
21
+ // Matches bare palette reference with opacity: "neutral-500/20", "primary-600/50"
22
+ const PALETTE_REF_OPACITY_RE = /^([a-z][a-z0-9]*-\d+)\/(\d+)$/
23
+
24
+ // Matches rgba(var(--X) / Y) — the DB storage format for token values with opacity
25
+ const RGBA_VAR_RE = /^rgba\(\s*(var\(--[^)]+\))\s*\/\s*([\d.]+)\s*\)$/
26
+
27
+ /**
28
+ * Normalize a token value to valid CSS.
29
+ *
30
+ * Handles all stored formats and produces CSS that works with any color format
31
+ * (OKLCH, RGB, hex) by using var() references and color-mix() for opacity.
32
+ *
33
+ * @param {string} value - The stored token value
34
+ * @returns {string} Valid CSS value
35
+ *
36
+ * @example
37
+ * // Bare palette reference
38
+ * normalizeTokenValue('neutral-500') // → 'var(--neutral-500)'
39
+ *
40
+ * // Palette reference with opacity
41
+ * normalizeTokenValue('neutral-500/20') // → 'color-mix(in srgb, var(--neutral-500) 20%, transparent)'
42
+ *
43
+ * // DB format — full opacity (strip wrapper)
44
+ * normalizeTokenValue('rgba(var(--primary-900) / 1.00)') // → 'var(--primary-900)'
45
+ *
46
+ * // DB format — partial opacity (convert to color-mix)
47
+ * normalizeTokenValue('rgba(var(--primary-900) / 0.50)') // → 'color-mix(in srgb, var(--primary-900) 50%, transparent)'
48
+ *
49
+ * // Already valid CSS — pass through
50
+ * normalizeTokenValue('var(--neutral-500)') // → 'var(--neutral-500)'
51
+ * normalizeTokenValue('white') // → 'white'
52
+ * normalizeTokenValue('#ff0000') // → '#ff0000'
53
+ */
54
+ export function normalizeTokenValue(value) {
55
+ if (typeof value !== 'string') return value
56
+
57
+ // 1. Check for rgba(var(--X) / Y) — DB storage format
58
+ const rgbaMatch = value.match(RGBA_VAR_RE)
59
+ if (rgbaMatch) {
60
+ const varRef = rgbaMatch[1]
61
+ const opacity = parseFloat(rgbaMatch[2])
62
+ if (opacity >= 0.999) return varRef
63
+ return `color-mix(in srgb, ${varRef} ${Math.round(opacity * 100)}%, transparent)`
64
+ }
65
+
66
+ // 2. Already a CSS function (var(), color-mix(), rgb(), etc.) — pass through
67
+ if (value.includes('(')) return value
68
+
69
+ // 3. Hex color — pass through
70
+ if (value.startsWith('#')) return value
71
+
72
+ // 4. Strip leading dashes (--primary-500 → primary-500)
73
+ const bare = value.replace(/^-{0,2}/, '')
74
+
75
+ // 5. Check for palette reference with opacity: "neutral-500/20"
76
+ const opacityMatch = bare.match(PALETTE_REF_OPACITY_RE)
77
+ if (opacityMatch) {
78
+ const ref = opacityMatch[1]
79
+ const pct = parseInt(opacityMatch[2], 10)
80
+ const refMatch = ref.match(PALETTE_REF_RE)
81
+ if (refMatch && SHADE_LEVELS.has(parseInt(refMatch[2], 10))) {
82
+ if (pct >= 100) return `var(--${ref})`
83
+ return `color-mix(in srgb, var(--${ref}) ${pct}%, transparent)`
84
+ }
85
+ }
86
+
87
+ // 6. Check for bare palette reference: "neutral-500", "primary-600"
88
+ const paletteMatch = bare.match(PALETTE_REF_RE)
89
+ if (paletteMatch) {
90
+ const shade = parseInt(paletteMatch[2], 10)
91
+ if (SHADE_LEVELS.has(shade)) {
92
+ return `var(--${bare})`
93
+ }
94
+ }
95
+
96
+ // 7. Anything else — pass through (named colors like 'white', 'transparent', etc.)
97
+ return value
98
+ }
package/src/processor.js CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { isValidColor, generatePalettes } from './shade-generator.js'
12
12
  import { getDefaultColors, getDefaultContextTokens } from './css-generator.js'
13
+ import { normalizeTokenValue } from './normalize.js'
13
14
 
14
15
  /**
15
16
  * Named neutral presets mapping to Tailwind gray families
@@ -31,6 +32,10 @@ const DEFAULT_INLINE = {
31
32
  color: 'var(--link)',
32
33
  'font-weight': '600',
33
34
  },
35
+ callout: {
36
+ color: 'var(--accent)',
37
+ 'font-weight': '600',
38
+ },
34
39
  muted: {
35
40
  color: 'var(--subtle)',
36
41
  },
@@ -85,45 +90,6 @@ const DEFAULT_CODE_THEME = {
85
90
  selection: '#45475a', // Selection background
86
91
  }
87
92
 
88
- /**
89
- * Valid shade levels for palette references
90
- */
91
- const SHADE_LEVELS = new Set([50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950])
92
-
93
- /**
94
- * Resolve context token values to valid CSS.
95
- *
96
- * Content authors write palette references as bare names: `primary-500`,
97
- * `neutral-200`. This is the natural syntax in theme.yml. The processor
98
- * resolves these to `var(--primary-500)` etc. Plain CSS values (hex, var(),
99
- * named colors) pass through as-is — that's the escape hatch.
100
- *
101
- * @param {string} value - The token value from theme.yml
102
- * @returns {string} Valid CSS value
103
- */
104
- function normalizePaletteRef(value) {
105
- if (typeof value !== 'string') return value
106
-
107
- // Already a CSS function (var(), rgb(), etc.) — pass through
108
- if (value.includes('(')) return value
109
-
110
- // Hex color — pass through
111
- if (value.startsWith('#')) return value
112
-
113
- // Bare palette reference: "primary-500", "--primary-500"
114
- const bare = value.replace(/^-{0,2}/, '')
115
- const match = bare.match(/^([a-z][a-z0-9]*)-(\d+)$/)
116
-
117
- if (match) {
118
- const shade = parseInt(match[2], 10)
119
- if (SHADE_LEVELS.has(shade)) {
120
- return `var(--${bare})`
121
- }
122
- }
123
-
124
- return value
125
- }
126
-
127
93
  /**
128
94
  * Validate color configuration
129
95
  *
@@ -287,9 +253,16 @@ function validateCodeTheme(code) {
287
253
  return { valid: errors.length === 0, errors }
288
254
  }
289
255
 
256
+ /**
257
+ * Context-aware var types (stored per light/dark context)
258
+ */
259
+ const CONTEXT_AWARE_TYPES = new Set(['color', 'gradient'])
260
+
290
261
  /**
291
262
  * Validate foundation variables configuration
292
263
  *
264
+ * Accepts enriched var schema: { default, description, label, type, options, group, globalOnly }
265
+ *
293
266
  * @param {Object} vars - Foundation variables
294
267
  * @returns {{ valid: boolean, errors: string[] }}
295
268
  */
@@ -375,30 +348,60 @@ function normalizeAppearance(appearance) {
375
348
  /**
376
349
  * Merge foundation variables with site overrides
377
350
  *
378
- * @param {Object} foundationVars - Variables from foundation vars.js
351
+ * Splits variables into two groups based on their type:
352
+ * - flat: context-independent vars (text, select, untyped) → foundationVars
353
+ * - colorVars: context-aware vars (color, gradient) → stored per light/dark context
354
+ *
355
+ * @param {Object} foundationVars - Variables from foundation vars.js (with enriched schema)
379
356
  * @param {Object} siteVars - Site-level variable overrides
380
- * @returns {Object} Merged variables
357
+ * @returns {{ flat: Object, colorVars: Object }} Split variables
381
358
  */
382
359
  function mergeFoundationVars(foundationVars = {}, siteVars = {}) {
383
- const merged = {}
360
+ const flat = {}
361
+ const colorVars = {}
384
362
 
385
- // Start with foundation defaults
363
+ // Start with foundation defaults, split by type
386
364
  for (const [name, config] of Object.entries(foundationVars)) {
387
- merged[name] = typeof config === 'object' ? { ...config } : { default: config }
365
+ const cfg = typeof config === 'object' ? { ...config } : { default: config }
366
+ const isContextAware = CONTEXT_AWARE_TYPES.has(cfg.type)
367
+
368
+ if (isContextAware) {
369
+ // Default value applies to all contexts
370
+ const defaultVal = cfg.default || ''
371
+ colorVars[name] = { light: defaultVal, dark: defaultVal }
372
+ } else {
373
+ flat[name] = cfg
374
+ }
388
375
  }
389
376
 
390
377
  // Apply site overrides
391
378
  for (const [name, value] of Object.entries(siteVars)) {
392
- if (merged[name]) {
393
- // Override the default value
394
- merged[name].default = value
379
+ // Look up type from foundation schema
380
+ const schemaCfg = foundationVars[name]
381
+ const type = typeof schemaCfg === 'object' ? schemaCfg?.type : undefined
382
+ const isContextAware = CONTEXT_AWARE_TYPES.has(type)
383
+
384
+ if (isContextAware) {
385
+ if (typeof value === 'object' && value !== null && (value.light || value.dark)) {
386
+ // Per-context override: { light: '#fff', dark: '#000' }
387
+ colorVars[name] = { ...(colorVars[name] || {}), ...value }
388
+ } else {
389
+ // Scalar override: apply same value to all contexts
390
+ const strVal = String(value)
391
+ colorVars[name] = { light: strVal, dark: strVal }
392
+ }
395
393
  } else {
396
- // New variable from site
397
- merged[name] = { default: value }
394
+ if (flat[name]) {
395
+ // Override the default value
396
+ flat[name].default = value
397
+ } else {
398
+ // New variable from site
399
+ flat[name] = { default: value }
400
+ }
398
401
  }
399
402
  }
400
403
 
401
- return merged
404
+ return { flat, colorVars }
402
405
  }
403
406
 
404
407
  /**
@@ -469,7 +472,7 @@ export function processTheme(rawConfig = {}, options = {}) {
469
472
  const normalized = {}
470
473
 
471
474
  for (const [token, value] of Object.entries(overrides)) {
472
- normalized[token] = normalizePaletteRef(value)
475
+ normalized[token] = normalizeTokenValue(value)
473
476
  }
474
477
 
475
478
  contexts[name] = { ...defaultContexts[name], ...normalized }
@@ -480,12 +483,17 @@ export function processTheme(rawConfig = {}, options = {}) {
480
483
  ...DEFAULT_FONTS,
481
484
  ...(rawConfig.fonts || {}),
482
485
  }
486
+ // Track which font slots the user explicitly set (vs inherited defaults).
487
+ // Used by extractUsedFamilies to avoid loading fonts referenced only in defaults.
488
+ const userFonts = rawConfig.fonts || {}
489
+ fonts._userSlots = ['body', 'heading', 'mono'].filter((s) => userFonts[s] !== undefined)
483
490
 
484
491
  // Normalize and process appearance
485
492
  const appearance = normalizeAppearance(rawConfig.appearance)
486
493
 
487
494
  // Merge foundation variables with site overrides
488
- const mergedFoundationVars = mergeFoundationVars(
495
+ // Splits into context-independent (flat) and context-aware (colorVars)
496
+ const { flat: mergedFoundationVars, colorVars } = mergeFoundationVars(
489
497
  foundationVars,
490
498
  rawConfig.vars || rawConfig.foundationVars || {}
491
499
  )
@@ -517,7 +525,8 @@ export function processTheme(rawConfig = {}, options = {}) {
517
525
  contexts,
518
526
  fonts,
519
527
  appearance,
520
- foundationVars: mergedFoundationVars,
528
+ foundationVars: mergedFoundationVars, // Context-independent vars only
529
+ colorVars, // Context-aware vars: { light: {...}, dark: {...} }
521
530
  code, // Code block theme for runtime injection
522
531
  background, // Site-level background CSS value
523
532
  inline, // Inline text style definitions
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Section Override CSS Builder
3
+ *
4
+ * Builds a single CSS string from all section-level overrides on a page.
5
+ * Follows the same pattern as buildTheme() for global CSS:
6
+ * all overrides are pre-built into a <style id="uniweb-page-overrides"> tag.
7
+ *
8
+ * @module @uniweb/theming/section-overrides
9
+ */
10
+
11
+ import { generatePalettes } from './shade-generator.js'
12
+ import { normalizeTokenValue } from './normalize.js'
13
+
14
+ /**
15
+ * Check if a block has any section-level overrides worth emitting.
16
+ *
17
+ * @param {Object} standardOptions - Block's standardOptions
18
+ * @returns {boolean}
19
+ */
20
+ function hasOverrides(standardOptions) {
21
+ if (!standardOptions) return false
22
+
23
+ const { colors, foundationStyles } = standardOptions
24
+
25
+ if (foundationStyles && Object.keys(foundationStyles).length > 0) return true
26
+ if (!colors) return false
27
+
28
+ const hasColors =
29
+ colors.colors?.light && Object.keys(colors.colors.light).length > 0
30
+ const hasElements =
31
+ colors.elements &&
32
+ Object.values(colors.elements).some(
33
+ (ctx) => ctx && Object.keys(ctx).length > 0
34
+ )
35
+ return hasColors || hasElements
36
+ }
37
+
38
+ /**
39
+ * Build CSS variable declarations for base palette overrides.
40
+ * Base palette is context-independent — always stored under the 'light' key.
41
+ *
42
+ * @param {Object} baseColors - e.g. { primary: "#3b82f6", accent: "#f59e0b" }
43
+ * @returns {string[]} CSS declaration lines
44
+ */
45
+ function buildPaletteVars(baseColors) {
46
+ if (!baseColors || Object.keys(baseColors).length === 0) return []
47
+
48
+ const vars = []
49
+ const palettes = generatePalettes(baseColors)
50
+
51
+ for (const [name, shades] of Object.entries(palettes)) {
52
+ for (const [level, value] of Object.entries(shades)) {
53
+ vars.push(` --${name}-${level}: ${value};`)
54
+ }
55
+ }
56
+
57
+ return vars
58
+ }
59
+
60
+ /**
61
+ * Build CSS variable declarations for semantic token overrides.
62
+ *
63
+ * @param {Object} elements - e.g. { heading: "rgba(var(--primary-900) / 1.00)", ... }
64
+ * @returns {string[]} CSS declaration lines
65
+ */
66
+ function buildElementVars(elements) {
67
+ if (!elements || Object.keys(elements).length === 0) return []
68
+
69
+ const vars = []
70
+ for (const [token, value] of Object.entries(elements)) {
71
+ if (value === '' || value == null) continue
72
+ vars.push(` --${token}: ${normalizeTokenValue(value)};`)
73
+ }
74
+ return vars
75
+ }
76
+
77
+ /**
78
+ * Build CSS variable declarations for foundation style overrides.
79
+ *
80
+ * @param {Object} styles - e.g. { "border-radius": "0.5rem" }
81
+ * @returns {string[]} CSS declaration lines
82
+ */
83
+ function buildFoundationVars(styles) {
84
+ if (!styles || Object.keys(styles).length === 0) return []
85
+
86
+ const vars = []
87
+ for (const [name, value] of Object.entries(styles)) {
88
+ if (value === '' || value == null || typeof value === 'object') continue
89
+ vars.push(` --${name}: ${value};`)
90
+ }
91
+ return vars
92
+ }
93
+
94
+ /**
95
+ * Split foundation styles into flat (context-independent) and
96
+ * context-keyed (color/gradient) values.
97
+ *
98
+ * Flat values: { "radius-xl": "1.5rem" }
99
+ * Context-keyed values: { "colorful-bg": { light: "linear-gradient(...)", dark: "..." } }
100
+ *
101
+ * @param {Object} styles - Foundation styles object
102
+ * @returns {{ flat: Object, contexts: Object }} - flat styles + { light: {...}, dark: {...} }
103
+ */
104
+ function splitFoundationStyles(styles) {
105
+ if (!styles || Object.keys(styles).length === 0) return { flat: {}, contexts: {} }
106
+
107
+ const flat = {}
108
+ const contexts = {}
109
+
110
+ for (const [name, value] of Object.entries(styles)) {
111
+ if (value == null) continue
112
+ if (typeof value === 'object') {
113
+ // Context-keyed: { light: "...", dark: "..." }
114
+ for (const [ctx, ctxVal] of Object.entries(value)) {
115
+ if (ctxVal === '' || ctxVal == null) continue
116
+ if (!contexts[ctx]) contexts[ctx] = {}
117
+ contexts[ctx][name] = ctxVal
118
+ }
119
+ } else {
120
+ flat[name] = value
121
+ }
122
+ }
123
+
124
+ return { flat, contexts }
125
+ }
126
+
127
+ /**
128
+ * Build a CSS rule for a section with its overrides.
129
+ *
130
+ * @param {string} selector - CSS selector (e.g. "#section-42")
131
+ * @param {string[]} vars - CSS declaration lines
132
+ * @returns {string} CSS rule string (empty if no vars)
133
+ */
134
+ function buildRule(selector, vars) {
135
+ if (vars.length === 0) return ''
136
+ return `${selector} {\n${vars.join('\n')}\n}\n`
137
+ }
138
+
139
+ /**
140
+ * Build section override CSS for all blocks on a page.
141
+ *
142
+ * Takes an array of block-like objects and appearance config,
143
+ * returns a CSS string ready for injection into a <style> tag.
144
+ *
145
+ * Works with both Block instances and plain data objects — only reads:
146
+ * - block.stableId || block.id — for CSS selector
147
+ * - block.themeName — '' for Auto, 'light'/'medium'/'dark' for Pinned
148
+ * - block.standardOptions — { colors, foundationStyles }
149
+ * - block.componentVars — merged meta.js defaults + frontmatter overrides
150
+ *
151
+ * @param {Array<Object>} blocks - Block data objects
152
+ * @param {Object} appearance - Theme appearance config
153
+ * @param {boolean} appearance.allowToggle - Whether scheme toggle is enabled
154
+ * @param {string} appearance.default - Default scheme ('light' or 'dark')
155
+ * @returns {string} CSS string (empty if no overrides)
156
+ */
157
+ export function buildSectionOverrides(blocks, appearance = {}) {
158
+ if (!blocks || blocks.length === 0) return ''
159
+
160
+ let css = ''
161
+
162
+ for (const block of blocks) {
163
+ const hasStandardOverrides = hasOverrides(block.standardOptions)
164
+ const hasComponentVars = block.componentVars && Object.keys(block.componentVars).length > 0
165
+
166
+ if (!hasStandardOverrides && !hasComponentVars) continue
167
+
168
+ const { colors, foundationStyles } = block.standardOptions || {}
169
+ const sectionId = block.stableId || block.id
170
+ const selector = `#section-${sectionId}`
171
+
172
+ const isAuto = !block.themeName
173
+ const hasToggle = appearance.allowToggle
174
+
175
+ // Base palette is context-independent (always under 'light' key)
176
+ const paletteVars = buildPaletteVars(colors?.colors?.light)
177
+
178
+ // Split foundation styles: flat (context-independent) + context-keyed (color/gradient)
179
+ const { flat: flatFoundation, contexts: ctxFoundation } = splitFoundationStyles(foundationStyles)
180
+ const foundationVars = buildFoundationVars(flatFoundation)
181
+
182
+ // Component-level CSS vars are context-independent
183
+ const compVars = buildFoundationVars(block.componentVars)
184
+
185
+ if (isAuto && hasToggle) {
186
+ // Dual rules: light-scoped elements + dark-scoped elements
187
+ const lightElementVars = buildElementVars(colors?.elements?.light)
188
+ const darkElementVars = buildElementVars(colors?.elements?.dark)
189
+
190
+ // Context-aware foundation vars (color/gradient types)
191
+ const lightFoundationCtx = buildFoundationVars(ctxFoundation.light)
192
+ const darkFoundationCtx = buildFoundationVars(ctxFoundation.dark)
193
+
194
+ // Context-independent rule: palette + flat foundation + component vars (apply in both schemes)
195
+ const sharedVars = [...paletteVars, ...foundationVars, ...compVars]
196
+ css += buildRule(selector, sharedVars)
197
+
198
+ // Light-only overrides (elements + context-aware foundation vars)
199
+ css += buildRule(`:root:not(.scheme-dark) ${selector}`, [...lightElementVars, ...lightFoundationCtx])
200
+
201
+ // Dark-only overrides (elements + context-aware foundation vars)
202
+ css += buildRule(`.scheme-dark ${selector}`, [...darkElementVars, ...darkFoundationCtx])
203
+ } else {
204
+ // Single context: when toggle is off, always use 'light' bucket
205
+ // (overrides are context-independent); when toggle is on, use pinned context
206
+ const ctx = hasToggle ? (block.themeName || 'light') : 'light'
207
+ const elementVars = buildElementVars(colors?.elements?.[ctx])
208
+ const ctxFoundationVars = buildFoundationVars(ctxFoundation[ctx])
209
+
210
+ const allVars = [...paletteVars, ...elementVars, ...foundationVars, ...compVars, ...ctxFoundationVars]
211
+ css += buildRule(selector, allVars)
212
+ }
213
+ }
214
+
215
+ return css
216
+ }