@uniweb/theming 0.1.6 → 0.1.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": "@uniweb/theming",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Theming utilities for the Uniweb platform — shade generation, CSS generation, theme processing",
5
5
  "type": "module",
6
6
  "exports": {
@@ -210,54 +210,36 @@ const GENERIC_FAMILIES = new Set([
210
210
  'inherit', 'initial', 'revert', 'unset',
211
211
  ])
212
212
 
213
- // Foundation `font-*` vars that are CSS longhands, not typefaces — never
214
- // treat their value as a family to load.
215
- const NON_FAMILY_FONT_VARS = new Set([
216
- 'font-weight', 'font-style', 'font-size', 'font-stretch',
217
- 'font-feature-settings', 'font-variation-settings', 'font-variant',
218
- 'font-kerning', 'font-optical-sizing', 'font-synthesis',
219
- 'font-size-adjust', 'font-smoothing', 'font-palette', 'font-language-override',
220
- ])
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.
221
216
 
222
217
  /**
223
- * Extract the font family names the site actually references.
218
+ * Extract the font family names to load, from a resolved font-role map.
224
219
  *
225
- * Two sources count as "used":
226
- * 1. The `body`/`heading`/`mono` slots the user explicitly configured
227
- * (tracked via _userSlots from the processor). Default system stacks are
228
- * ignored so they don't trigger unnecessary font loading.
229
- * 2. Foundation `font-*` vars (e.g. an editorial `font-serif`). A foundation
230
- * can declare a themeable typeface beyond the three built-in roles; the
231
- * site sets its value and loads it with `fonts.import` / `fonts.faces`.
232
- * Without counting these, such a family is filtered out as "unused" and
233
- * never loads — capping a foundation at the three role slots.
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.
234
224
  *
235
- * @param {Object} fonts - Font configuration (may carry _userSlots)
236
- * @param {Object} [foundationVars] - Resolved foundation vars (name → { default } | value)
237
- * @returns {Set<string>} Lowercase family names referenced by the site
225
+ * @param {Object} fontRoles - Resolved roles: name → { value, applyTo, apply, load }
226
+ * @returns {Set<string>} Lowercase family names to load (@font-face / import filter)
238
227
  */
239
- function extractUsedFamilies(fonts, foundationVars = {}) {
228
+ function extractUsedFamilies(fontRoles = {}) {
240
229
  const used = new Set()
241
230
 
242
231
  const addFamiliesFrom = (value) => {
243
232
  if (typeof value !== 'string') return
244
233
  for (const segment of value.split(',')) {
245
234
  const name = segment.trim().replace(/^["']|["']$/g, '').toLowerCase()
246
- // Skip generics, empties, and numeric/length tokens (a weight "600" or
247
- // size "1rem" a non-family font-* var might carry).
235
+ // Skip generics, empties, and numeric/length tokens.
248
236
  if (!name || GENERIC_FAMILIES.has(name) || /^\d/.test(name)) continue
249
237
  used.add(name)
250
238
  }
251
239
  }
252
240
 
253
- const slots = fonts._userSlots || ['body', 'heading', 'mono']
254
- for (const slot of slots) {
255
- addFamiliesFrom(fonts[slot])
256
- }
257
-
258
- for (const [name, config] of Object.entries(foundationVars)) {
259
- if (!name.startsWith('font-') || NON_FAMILY_FONT_VARS.has(name)) continue
260
- addFamiliesFrom(typeof config === 'object' ? config?.default : config)
241
+ for (const role of Object.values(fontRoles)) {
242
+ if (role && role.load) addFamiliesFrom(role.value)
261
243
  }
262
244
 
263
245
  return used
@@ -309,14 +291,15 @@ function isGoogleFontsUrl(url) {
309
291
  /**
310
292
  * Generate font CSS and external link tags.
311
293
  *
312
- * @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[])
313
296
  * @returns {{ css: string, links: string }} css for <style>, links for <head>
314
297
  */
315
- function generateFontCSS(fonts = {}, foundationVars = {}) {
298
+ function generateFontCSS(fontRoles = {}, fonts = {}) {
316
299
  const cssLines = []
317
300
  const linkTags = []
318
301
 
319
- const usedFamilies = extractUsedFamilies(fonts, foundationVars)
302
+ const usedFamilies = extractUsedFamilies(fontRoles)
320
303
 
321
304
  // --- @font-face rules from faces[] (filtered to used families) ---
322
305
  // Also generate <link rel="preload"> hints so browsers fetch fonts early
@@ -402,16 +385,12 @@ function generateFontCSS(fonts = {}, foundationVars = {}) {
402
385
  }
403
386
  }
404
387
 
405
- // --- :root font variables ---
388
+ // --- :root font variables (one --font-<name> per role, emitted once) ---
406
389
  const fontVars = []
407
- if (fonts.body) {
408
- fontVars.push(` --font-body: ${fonts.body};`)
409
- }
410
- if (fonts.heading) {
411
- fontVars.push(` --font-heading: ${fonts.heading};`)
412
- }
413
- if (fonts.mono) {
414
- 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
+ }
415
394
  }
416
395
 
417
396
  if (fontVars.length > 0) {
@@ -421,26 +400,18 @@ function generateFontCSS(fonts = {}, foundationVars = {}) {
421
400
  }
422
401
 
423
402
  // --- Apply font families to elements ---
424
- // Defining --font-* on :root is not enough — something must actually set
425
- // `font-family` on rendered elements, the same way colors get an application
426
- // rule ([id^="section-"] { background-color: var(--section) }). Without this
427
- // the font vars are orphaned and text never picks them up, even though the
428
- // vars are set and the @import/@font-face load. Only apply slots the SITE
429
- // explicitly set (fonts._userSlots) so a foundation's own default typography
430
- // is left untouched when theme.yml doesn't override that slot.
431
- 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.
432
410
  const applyRules = []
433
- if (userSlots.has('body')) {
434
- applyRules.push('body { font-family: var(--font-body); }')
435
- }
436
- if (userSlots.has('heading')) {
437
- // Only the prominent headings — display/heading faces are drawn for large
438
- // sizes; h4–h6 usually render at or near body size where a display face
439
- // reads awkwardly, so they stay in the body font.
440
- applyRules.push('h1, h2, h3 { font-family: var(--font-heading); }')
441
- }
442
- if (userSlots.has('mono')) {
443
- 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}); }`)
444
415
  }
445
416
  if (applyRules.length > 0) {
446
417
  cssLines.push('')
@@ -518,6 +489,7 @@ export function generateThemeCSS(config = {}) {
518
489
  colors = DEFAULT_COLORS,
519
490
  contexts = {},
520
491
  fonts = {},
492
+ fontRoles = {},
521
493
  appearance = {},
522
494
  foundationVars = {},
523
495
  colorVars = {},
@@ -525,10 +497,11 @@ export function generateThemeCSS(config = {}) {
525
497
 
526
498
  const sections = []
527
499
 
528
- // 1. Font face rules and variables (links returned separately)
529
- // foundationVars is passed so families referenced by a foundation `font-*`
530
- // var (e.g. an editorial `font-serif`) survive the used-family filter.
531
- const fontResult = generateFontCSS(fonts, foundationVars)
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)
532
505
  if (fontResult.css) {
533
506
  sections.push('/* Typography */\n' + fontResult.css)
534
507
  }
package/src/index.js CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  processTheme,
35
35
  extractFoundationVars,
36
36
  foundationHasVars,
37
+ isFontVar,
37
38
  } from './processor.js'
38
39
 
39
40
  // Value normalization
package/src/processor.js CHANGED
@@ -56,7 +56,54 @@ 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'`, or — the inferred case — its name is `font-*` and not a known
87
+ * CSS longhand. Exported so the build can persist the inferred `type: 'font'`
88
+ * into schema.json (the runtime relies on the same predicate).
89
+ */
90
+ export function isFontVar(name, config) {
91
+ const type = typeof config === 'object' ? config?.type : undefined
92
+ if (type === 'font') return true
93
+ return name.startsWith('font-') && !NON_FAMILY_FONT_VARS.has(name)
94
+ }
95
+
96
+ // A font role's canonical name is bare; `font-X` and `X` are the same role.
97
+ function normalizeFontRoleName(name) {
98
+ return name.startsWith('font-') ? name.slice(5) : name
99
+ }
100
+
101
+ // Normalize applyTo to an array of trimmed selectors (or null).
102
+ function normalizeApplyTo(applyTo) {
103
+ if (applyTo == null) return null
104
+ const list = Array.isArray(applyTo) ? applyTo : String(applyTo).split(',')
105
+ const cleaned = list.map((s) => String(s).trim()).filter(Boolean)
106
+ return cleaned.length ? cleaned : null
60
107
  }
61
108
 
62
109
  /**
@@ -261,7 +308,10 @@ const CONTEXT_AWARE_TYPES = new Set(['color', 'gradient'])
261
308
  /**
262
309
  * Validate foundation variables configuration
263
310
  *
264
- * Accepts enriched var schema: { default, description, label, type, options, group, globalOnly }
311
+ * Accepts enriched var schema: { default, description, label, type, options, group, globalOnly, applyTo }
312
+ * A `type: 'font'` var is a themeable typeface (family loads, schema tags it as
313
+ * a font); its optional `applyTo` is a list of selectors the framework paints
314
+ * `font-family: var(--<name>)` onto, like a built-in role.
265
315
  *
266
316
  * @param {Object} vars - Foundation variables
267
317
  * @returns {{ valid: boolean, errors: string[] }}
@@ -478,15 +528,12 @@ export function processTheme(rawConfig = {}, options = {}) {
478
528
  contexts[name] = { ...defaultContexts[name], ...normalized }
479
529
  }
480
530
 
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)
531
+ // Fonts resolve into the font-role namespace after foundation vars are merged
532
+ // (below), so a foundation's font vars can redefine/extend the roles. `mono`
533
+ // is not a built-in role — the code/monospace role is `code`. It flows through
534
+ // as an ordinary name (a foundation may define a `mono` typeface); a
535
+ // post-resolution check nudges a site that set `fonts.mono` expecting code.
536
+ const rawFonts = { ...(rawConfig.fonts || {}) }
490
537
 
491
538
  // Normalize and process appearance
492
539
  const appearance = normalizeAppearance(rawConfig.appearance)
@@ -504,6 +551,80 @@ export function processTheme(rawConfig = {}, options = {}) {
504
551
  warnings.push(...foundationValidation.errors)
505
552
  }
506
553
 
554
+ // Resolve the font-role namespace. The three roles are framework *defaults*;
555
+ // a foundation `type: 'font'` var can redefine a role (retarget applyTo /
556
+ // change the default) or add a new role; the site sets any role's value in
557
+ // `fonts:` (or `vars:`). Everything lands in one map so each --font-<name> is
558
+ // emitted once, site-value-wins. Font vars are split OUT of foundationVars so
559
+ // they aren't also emitted by the generic foundation-var path (which would
560
+ // double-write --font-<name>, the later foundation default clobbering the site).
561
+ const fontRoles = {}
562
+ for (const [name, applyTo] of Object.entries(DEFAULT_FONT_ROLES)) {
563
+ fontRoles[name] = {
564
+ value: DEFAULT_FONTS[name],
565
+ applyTo: [...applyTo],
566
+ foundationOwned: false,
567
+ userSet: false,
568
+ }
569
+ }
570
+
571
+ const nonFontVars = {}
572
+ for (const [rawName, cfg] of Object.entries(mergedFoundationVars)) {
573
+ if (!isFontVar(rawName, cfg)) {
574
+ nonFontVars[rawName] = cfg
575
+ continue
576
+ }
577
+ const name = normalizeFontRoleName(rawName)
578
+ const cfgObj = typeof cfg === 'object' ? cfg : { default: cfg }
579
+ const existing = fontRoles[name]
580
+ fontRoles[name] = {
581
+ value: cfgObj.default ?? existing?.value,
582
+ applyTo:
583
+ cfgObj.applyTo !== undefined
584
+ ? normalizeApplyTo(cfgObj.applyTo)
585
+ : existing?.applyTo ?? null,
586
+ foundationOwned: true,
587
+ userSet: false,
588
+ }
589
+ }
590
+
591
+ const FONT_LOADING_KEYS = new Set(['import', 'faces'])
592
+ for (const [rawName, value] of Object.entries(rawFonts)) {
593
+ if (FONT_LOADING_KEYS.has(rawName)) continue
594
+ const name = normalizeFontRoleName(rawName)
595
+ const existing = fontRoles[name]
596
+ fontRoles[name] = {
597
+ value,
598
+ applyTo: existing?.applyTo ?? null,
599
+ foundationOwned: existing?.foundationOwned ?? false,
600
+ userSet: true,
601
+ }
602
+ }
603
+
604
+ // Per-role application + loading flags. apply: emit the font-family rule when
605
+ // the role has selectors AND is "intended" (site-set or foundation-owned) —
606
+ // a bare framework default is never forced onto a foundation's own typography.
607
+ // load: pull the family through the @font-face / import filter.
608
+ for (const role of Object.values(fontRoles)) {
609
+ const intended = role.foundationOwned || role.userSet
610
+ role.apply = intended && Array.isArray(role.applyTo) && role.applyTo.length > 0
611
+ role.load = intended
612
+ }
613
+
614
+ // `mono` is no longer aliased to `code`. Nudge a site that set `fonts.mono`
615
+ // expecting the code font, unless the foundation actually defines a `mono` role.
616
+ if (rawFonts.mono !== undefined && !fontRoles.mono?.foundationOwned) {
617
+ warnings.push('theme.yml `fonts.mono` is not a built-in font role — the code/monospace role is `code`. Rename `fonts.mono` → `fonts.code`.')
618
+ }
619
+
620
+ // config.fonts: name→value (for the Theme API) plus import/faces (loading).
621
+ const fonts = {}
622
+ if (rawFonts.import !== undefined) fonts.import = rawFonts.import
623
+ if (rawFonts.faces !== undefined) fonts.faces = rawFonts.faces
624
+ for (const [name, role] of Object.entries(fontRoles)) {
625
+ fonts[name] = role.value
626
+ }
627
+
507
628
  // Process code block theme
508
629
  // These values are stored for runtime injection by kit's Code component
509
630
  // (not converted to CSS here - enables tree-shaking when code blocks aren't used)
@@ -523,9 +644,10 @@ export function processTheme(rawConfig = {}, options = {}) {
523
644
  colors, // Raw colors for CSS generator
524
645
  palettes, // Generated palettes for Theme class
525
646
  contexts,
526
- fonts,
647
+ fonts, // name→value (+ import/faces) for the Theme API and loading
648
+ fontRoles, // resolved font-role map (value/applyTo/apply/load) for CSS gen
527
649
  appearance,
528
- foundationVars: mergedFoundationVars, // Context-independent vars only
650
+ foundationVars: nonFontVars, // NON-font vars only (font vars live in fontRoles)
529
651
  colorVars, // Context-aware vars: { light: {...}, dark: {...} }
530
652
  code, // Code block theme for runtime injection
531
653
  background, // Site-level background CSS value