@uniweb/theming 0.1.5 → 0.1.7

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.5",
3
+ "version": "0.1.7",
4
4
  "description": "Theming utilities for the Uniweb platform — shade generation, CSS generation, theme processing",
5
5
  "type": "module",
6
6
  "exports": {
@@ -210,32 +210,38 @@ const GENERIC_FAMILIES = new Set([
210
210
  'inherit', 'initial', 'revert', 'unset',
211
211
  ])
212
212
 
213
+ // Font-role classification (isFontVar, NON_FAMILY_FONT_VARS) and the default
214
+ // role selectors now live in processor.js, which resolves the whole font-role
215
+ // namespace (built-in roles + foundation redefinitions/additions) into config.
216
+
213
217
  /**
214
- * Extract font family names actually used by body/heading/mono slots.
218
+ * Extract the font family names to load, from a resolved font-role map.
215
219
  *
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.
220
+ * A role contributes its family only when the processor flagged it `load`
221
+ * (site-set or foundation-declared — not a bare framework default like
222
+ * `system-ui`). Generic keywords and numeric tokens are skipped, so a role's
223
+ * fallback stack never triggers an unnecessary fetch.
219
224
  *
220
- * @param {Object} fonts - Font configuration
221
- * @returns {Set<string>} Lowercase family names referenced by user-set slots
225
+ * @param {Object} fontRoles - Resolved roles: name → { value, applyTo, apply, load }
226
+ * @returns {Set<string>} Lowercase family names to load (@font-face / import filter)
222
227
  */
223
- function extractUsedFamilies(fonts) {
228
+ function extractUsedFamilies(fontRoles = {}) {
224
229
  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
230
 
231
+ const addFamiliesFrom = (value) => {
232
+ if (typeof value !== 'string') return
231
233
  for (const segment of value.split(',')) {
232
234
  const name = segment.trim().replace(/^["']|["']$/g, '').toLowerCase()
233
- if (name && !GENERIC_FAMILIES.has(name)) {
234
- used.add(name)
235
- }
235
+ // Skip generics, empties, and numeric/length tokens.
236
+ if (!name || GENERIC_FAMILIES.has(name) || /^\d/.test(name)) continue
237
+ used.add(name)
236
238
  }
237
239
  }
238
240
 
241
+ for (const role of Object.values(fontRoles)) {
242
+ if (role && role.load) addFamiliesFrom(role.value)
243
+ }
244
+
239
245
  return used
240
246
  }
241
247
 
@@ -285,14 +291,15 @@ function isGoogleFontsUrl(url) {
285
291
  /**
286
292
  * Generate font CSS and external link tags.
287
293
  *
288
- * @param {Object} fonts - Font configuration
294
+ * @param {Object} fontRoles - Resolved font roles (name → { value, applyTo, apply, load })
295
+ * @param {Object} fonts - Font loading config (import[] / faces[])
289
296
  * @returns {{ css: string, links: string }} css for <style>, links for <head>
290
297
  */
291
- function generateFontCSS(fonts = {}) {
298
+ function generateFontCSS(fontRoles = {}, fonts = {}) {
292
299
  const cssLines = []
293
300
  const linkTags = []
294
301
 
295
- const usedFamilies = extractUsedFamilies(fonts)
302
+ const usedFamilies = extractUsedFamilies(fontRoles)
296
303
 
297
304
  // --- @font-face rules from faces[] (filtered to used families) ---
298
305
  // Also generate <link rel="preload"> hints so browsers fetch fonts early
@@ -378,16 +385,12 @@ function generateFontCSS(fonts = {}) {
378
385
  }
379
386
  }
380
387
 
381
- // --- :root font variables ---
388
+ // --- :root font variables (one --font-<name> per role, emitted once) ---
382
389
  const fontVars = []
383
- if (fonts.body) {
384
- fontVars.push(` --font-body: ${fonts.body};`)
385
- }
386
- if (fonts.heading) {
387
- fontVars.push(` --font-heading: ${fonts.heading};`)
388
- }
389
- if (fonts.mono) {
390
- fontVars.push(` --font-mono: ${fonts.mono};`)
390
+ for (const [name, role] of Object.entries(fontRoles)) {
391
+ if (role.value) {
392
+ fontVars.push(` --font-${name}: ${role.value};`)
393
+ }
391
394
  }
392
395
 
393
396
  if (fontVars.length > 0) {
@@ -397,26 +400,18 @@ function generateFontCSS(fonts = {}) {
397
400
  }
398
401
 
399
402
  // --- Apply font families to elements ---
400
- // Defining --font-* on :root is not enough — something must actually set
401
- // `font-family` on rendered elements, the same way colors get an application
402
- // rule ([id^="section-"] { background-color: var(--section) }). Without this
403
- // the font vars are orphaned and text never picks them up, even though the
404
- // vars are set and the @import/@font-face load. Only apply slots the SITE
405
- // explicitly set (fonts._userSlots) so a foundation's own default typography
406
- // is left untouched when theme.yml doesn't override that slot.
407
- const userSlots = new Set(fonts._userSlots || [])
403
+ // Defining --font-* on :root is not enough — something must set `font-family`
404
+ // on rendered elements, the same way colors get an application rule
405
+ // ([id^="section-"] { background-color: var(--section) }). The processor has
406
+ // already resolved which roles apply (`role.apply`): a built-in role applies
407
+ // only when the site set it (don't stomp a foundation's own typography with
408
+ // system-ui); a foundation-owned/redefined role applies always via its
409
+ // `applyTo` selectors.
408
410
  const applyRules = []
409
- if (userSlots.has('body')) {
410
- applyRules.push('body { font-family: var(--font-body); }')
411
- }
412
- if (userSlots.has('heading')) {
413
- // Only the prominent headings — display/heading faces are drawn for large
414
- // sizes; h4–h6 usually render at or near body size where a display face
415
- // reads awkwardly, so they stay in the body font.
416
- applyRules.push('h1, h2, h3 { font-family: var(--font-heading); }')
417
- }
418
- if (userSlots.has('mono')) {
419
- applyRules.push('code, pre, kbd, samp { font-family: var(--font-mono); }')
411
+ for (const [name, role] of Object.entries(fontRoles)) {
412
+ if (!role.apply || !role.applyTo?.length) continue
413
+ const selector = role.applyTo.join(', ')
414
+ applyRules.push(`${selector} { font-family: var(--font-${name}); }`)
420
415
  }
421
416
  if (applyRules.length > 0) {
422
417
  cssLines.push('')
@@ -494,6 +489,7 @@ export function generateThemeCSS(config = {}) {
494
489
  colors = DEFAULT_COLORS,
495
490
  contexts = {},
496
491
  fonts = {},
492
+ fontRoles = {},
497
493
  appearance = {},
498
494
  foundationVars = {},
499
495
  colorVars = {},
@@ -501,8 +497,11 @@ export function generateThemeCSS(config = {}) {
501
497
 
502
498
  const sections = []
503
499
 
504
- // 1. Font face rules and variables (links returned separately)
505
- const fontResult = generateFontCSS(fonts)
500
+ // 1. Font face rules and variables (links returned separately). Font roles are
501
+ // resolved by the processor; `fonts` carries only import[] / faces[] here.
502
+ // Font-typed vars are already split out of `foundationVars`, so the generic
503
+ // foundation-var emission below never double-writes a --font-<name>.
504
+ const fontResult = generateFontCSS(fontRoles, fonts)
506
505
  if (fontResult.css) {
507
506
  sections.push('/* Typography */\n' + fontResult.css)
508
507
  }
package/src/processor.js CHANGED
@@ -56,7 +56,53 @@ const DEFAULT_APPEARANCE = {
56
56
  const DEFAULT_FONTS = {
57
57
  body: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
58
58
  heading: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
59
- mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace',
59
+ code: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace',
60
+ }
61
+
62
+ /**
63
+ * Default application selectors for the three built-in font roles. These are
64
+ * *defaults*: a foundation can redefine a role's `applyTo` (or add new roles)
65
+ * via a `type: 'font'` var, so the roles and their selectors are one editable
66
+ * font-var namespace, not a fixed contract. `heading` is scoped to h1–h3 (h4–h6
67
+ * render near body size, where a display face reads awkwardly); `code` (formerly
68
+ * `mono`) owns `--font-code`.
69
+ */
70
+ const DEFAULT_FONT_ROLES = {
71
+ body: ['body'],
72
+ heading: ['h1', 'h2', 'h3'],
73
+ code: ['code', 'pre', 'kbd', 'samp'],
74
+ }
75
+
76
+ // Foundation `font-*` vars that are CSS longhands, not typefaces to load.
77
+ const NON_FAMILY_FONT_VARS = new Set([
78
+ 'font-weight', 'font-style', 'font-size', 'font-stretch',
79
+ 'font-feature-settings', 'font-variation-settings', 'font-variant',
80
+ 'font-kerning', 'font-optical-sizing', 'font-synthesis',
81
+ 'font-size-adjust', 'font-smoothing', 'font-palette', 'font-language-override',
82
+ ])
83
+
84
+ /**
85
+ * A foundation var is a themeable typeface (font role) when it declares
86
+ * `type: 'font'`. Deprecated fallback: a `font-*`-prefixed name that isn't a
87
+ * known CSS longhand — kept so foundations predating the convention still work.
88
+ */
89
+ function isFontVar(name, config) {
90
+ const type = typeof config === 'object' ? config?.type : undefined
91
+ if (type === 'font') return true
92
+ return name.startsWith('font-') && !NON_FAMILY_FONT_VARS.has(name)
93
+ }
94
+
95
+ // A font role's canonical name is bare; `font-X` and `X` are the same role.
96
+ function normalizeFontRoleName(name) {
97
+ return name.startsWith('font-') ? name.slice(5) : name
98
+ }
99
+
100
+ // Normalize applyTo to an array of trimmed selectors (or null).
101
+ function normalizeApplyTo(applyTo) {
102
+ if (applyTo == null) return null
103
+ const list = Array.isArray(applyTo) ? applyTo : String(applyTo).split(',')
104
+ const cleaned = list.map((s) => String(s).trim()).filter(Boolean)
105
+ return cleaned.length ? cleaned : null
60
106
  }
61
107
 
62
108
  /**
@@ -261,7 +307,10 @@ const CONTEXT_AWARE_TYPES = new Set(['color', 'gradient'])
261
307
  /**
262
308
  * Validate foundation variables configuration
263
309
  *
264
- * Accepts enriched var schema: { default, description, label, type, options, group, globalOnly }
310
+ * Accepts enriched var schema: { default, description, label, type, options, group, globalOnly, applyTo }
311
+ * A `type: 'font'` var is a themeable typeface (family loads, schema tags it as
312
+ * a font); its optional `applyTo` is a list of selectors the framework paints
313
+ * `font-family: var(--<name>)` onto, like a built-in role.
265
314
  *
266
315
  * @param {Object} vars - Foundation variables
267
316
  * @returns {{ valid: boolean, errors: string[] }}
@@ -478,15 +527,12 @@ export function processTheme(rawConfig = {}, options = {}) {
478
527
  contexts[name] = { ...defaultContexts[name], ...normalized }
479
528
  }
480
529
 
481
- // Process fonts
482
- const fonts = {
483
- ...DEFAULT_FONTS,
484
- ...(rawConfig.fonts || {}),
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)
530
+ // Fonts resolve into the font-role namespace after foundation vars are merged
531
+ // (below), so a foundation's font vars can redefine/extend the roles. `mono`
532
+ // is not a built-in role — the code/monospace role is `code`. It flows through
533
+ // as an ordinary name (a foundation may define a `mono` typeface); a
534
+ // post-resolution check nudges a site that set `fonts.mono` expecting code.
535
+ const rawFonts = { ...(rawConfig.fonts || {}) }
490
536
 
491
537
  // Normalize and process appearance
492
538
  const appearance = normalizeAppearance(rawConfig.appearance)
@@ -504,6 +550,80 @@ export function processTheme(rawConfig = {}, options = {}) {
504
550
  warnings.push(...foundationValidation.errors)
505
551
  }
506
552
 
553
+ // Resolve the font-role namespace. The three roles are framework *defaults*;
554
+ // a foundation `type: 'font'` var can redefine a role (retarget applyTo /
555
+ // change the default) or add a new role; the site sets any role's value in
556
+ // `fonts:` (or `vars:`). Everything lands in one map so each --font-<name> is
557
+ // emitted once, site-value-wins. Font vars are split OUT of foundationVars so
558
+ // they aren't also emitted by the generic foundation-var path (which would
559
+ // double-write --font-<name>, the later foundation default clobbering the site).
560
+ const fontRoles = {}
561
+ for (const [name, applyTo] of Object.entries(DEFAULT_FONT_ROLES)) {
562
+ fontRoles[name] = {
563
+ value: DEFAULT_FONTS[name],
564
+ applyTo: [...applyTo],
565
+ foundationOwned: false,
566
+ userSet: false,
567
+ }
568
+ }
569
+
570
+ const nonFontVars = {}
571
+ for (const [rawName, cfg] of Object.entries(mergedFoundationVars)) {
572
+ if (!isFontVar(rawName, cfg)) {
573
+ nonFontVars[rawName] = cfg
574
+ continue
575
+ }
576
+ const name = normalizeFontRoleName(rawName)
577
+ const cfgObj = typeof cfg === 'object' ? cfg : { default: cfg }
578
+ const existing = fontRoles[name]
579
+ fontRoles[name] = {
580
+ value: cfgObj.default ?? existing?.value,
581
+ applyTo:
582
+ cfgObj.applyTo !== undefined
583
+ ? normalizeApplyTo(cfgObj.applyTo)
584
+ : existing?.applyTo ?? null,
585
+ foundationOwned: true,
586
+ userSet: false,
587
+ }
588
+ }
589
+
590
+ const FONT_LOADING_KEYS = new Set(['import', 'faces'])
591
+ for (const [rawName, value] of Object.entries(rawFonts)) {
592
+ if (FONT_LOADING_KEYS.has(rawName)) continue
593
+ const name = normalizeFontRoleName(rawName)
594
+ const existing = fontRoles[name]
595
+ fontRoles[name] = {
596
+ value,
597
+ applyTo: existing?.applyTo ?? null,
598
+ foundationOwned: existing?.foundationOwned ?? false,
599
+ userSet: true,
600
+ }
601
+ }
602
+
603
+ // Per-role application + loading flags. apply: emit the font-family rule when
604
+ // the role has selectors AND is "intended" (site-set or foundation-owned) —
605
+ // a bare framework default is never forced onto a foundation's own typography.
606
+ // load: pull the family through the @font-face / import filter.
607
+ for (const role of Object.values(fontRoles)) {
608
+ const intended = role.foundationOwned || role.userSet
609
+ role.apply = intended && Array.isArray(role.applyTo) && role.applyTo.length > 0
610
+ role.load = intended
611
+ }
612
+
613
+ // `mono` is no longer aliased to `code`. Nudge a site that set `fonts.mono`
614
+ // expecting the code font, unless the foundation actually defines a `mono` role.
615
+ if (rawFonts.mono !== undefined && !fontRoles.mono?.foundationOwned) {
616
+ warnings.push('theme.yml `fonts.mono` is not a built-in font role — the code/monospace role is `code`. Rename `fonts.mono` → `fonts.code`.')
617
+ }
618
+
619
+ // config.fonts: name→value (for the Theme API) plus import/faces (loading).
620
+ const fonts = {}
621
+ if (rawFonts.import !== undefined) fonts.import = rawFonts.import
622
+ if (rawFonts.faces !== undefined) fonts.faces = rawFonts.faces
623
+ for (const [name, role] of Object.entries(fontRoles)) {
624
+ fonts[name] = role.value
625
+ }
626
+
507
627
  // Process code block theme
508
628
  // These values are stored for runtime injection by kit's Code component
509
629
  // (not converted to CSS here - enables tree-shaking when code blocks aren't used)
@@ -523,9 +643,10 @@ export function processTheme(rawConfig = {}, options = {}) {
523
643
  colors, // Raw colors for CSS generator
524
644
  palettes, // Generated palettes for Theme class
525
645
  contexts,
526
- fonts,
646
+ fonts, // name→value (+ import/faces) for the Theme API and loading
647
+ fontRoles, // resolved font-role map (value/applyTo/apply/load) for CSS gen
527
648
  appearance,
528
- foundationVars: mergedFoundationVars, // Context-independent vars only
649
+ foundationVars: nonFontVars, // NON-font vars only (font vars live in fontRoles)
529
650
  colorVars, // Context-aware vars: { light: {...}, dark: {...} }
530
651
  code, // Code block theme for runtime injection
531
652
  background, // Site-level background CSS value