@vectoriox/iox-ui 5.44.0 → 5.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { SPEC_FONTS } from './flavor-spec.generated';
1
+ import { SPEC_FONTS, SPEC_SCRIPTS } from './flavor-spec.generated';
2
2
  import { DesignTokenLike, resolveTokenValues } from './token-css';
3
3
 
4
4
  /**
@@ -429,6 +429,40 @@ export interface FontsForTokensOptions {
429
429
  * upload, and the browser picked Google's — DS5.1).
430
430
  */
431
431
  uploaded?: ReadonlyArray<string>;
432
+ /**
433
+ * The page's locale (`page.locale`). A locale whose script the Latin families cannot draw (`he` →
434
+ * Hebrew) also links each role's script PARTNER — the family the stack names after the first that draws
435
+ * it (H1-2). An English page loads no Hebrew file.
436
+ */
437
+ locale?: string | null;
438
+ }
439
+
440
+ /** A script the catalog's Latin families cannot draw, and the families that can (contract `scripts`). */
441
+ export type ScriptId = keyof typeof SPEC_SCRIPTS;
442
+ const SCRIPT_FAMILIES = new Map<ScriptId, Set<string>>(
443
+ (Object.entries(SPEC_SCRIPTS) as Array<[ScriptId, { families: ReadonlyArray<string> }]>)
444
+ .map(([script, def]) => [script, new Set(def.families.map(fontFamilyKey))]));
445
+
446
+ /** The scripts a page in `locale` needs drawn (`he`, `he-IL` → hebrew); none for a Latin locale. */
447
+ export function scriptsForLocale(locale: string | null | undefined): ScriptId[] {
448
+ const lang = String(locale ?? '').trim().toLowerCase().split(/[-_]/)[0];
449
+ return (Object.entries(SPEC_SCRIPTS) as Array<[ScriptId, { locales: ReadonlyArray<string> }]>)
450
+ .filter(([, def]) => def.locales.includes(lang)).map(([script]) => script);
451
+ }
452
+
453
+ /** True when `family` is a catalog family that draws `script`. */
454
+ export function drawsScript(family: string | null | undefined, script: ScriptId): boolean {
455
+ return !!SCRIPT_FAMILIES.get(script)?.has(fontFamilyKey(family));
456
+ }
457
+
458
+ /**
459
+ * A stack with its script PARTNERS removed — every family after the first that only stands there to draw
460
+ * a script. What the contract wrote before H1-2 placed the partners: a row seeded then still holds it, and
461
+ * is the look's own, not the author's (`designBaseline`).
462
+ */
463
+ export function withoutScriptPartners(stack: string | null | undefined): string {
464
+ const partner = (family: string) => [...SCRIPT_FAMILIES.values()].some(set => set.has(fontFamilyKey(family)));
465
+ return splitFontStack(stack).filter((e, i) => i === 0 || !partner(e.family)).map(e => e.raw).join(', ');
432
466
  }
433
467
 
434
468
  /**
@@ -445,23 +479,35 @@ export function fontsForTokens(
445
479
  || rows.some(t => t.name !== MONO_ROLE && MONO_REFERENCE.test(`${t.value ?? ''} ${t.values?.dark ?? ''}`));
446
480
  const roles = monoRead ? FONT_FAMILY_TOKENS : ALWAYS_LOADED_ROLES;
447
481
  const uploaded = new Set((opts.uploaded ?? []).map(fontFamilyKey));
482
+ const scripts = scriptsForLocale(opts.locale);
448
483
  const seen = new Set<string>();
449
484
  const ids: string[] = [];
485
+ const add = (id: string | null) => {
486
+ if (id && !seen.has(id)) {
487
+ seen.add(id);
488
+ ids.push(id);
489
+ }
490
+ };
450
491
  for (const role of roles) {
451
- const row = rows.find(t => t.name === role);
492
+ const families = parseFontFamilies(rows.find(t => t.name === role)?.value ?? '');
452
493
  // The role's FIRST family that renders decides: an uploaded one needs nothing from Google; the
453
494
  // first catalog one is linked (it renders, or stands in for an uploaded family that is missing
454
495
  // — `userFontStack` puts the look's font right after the author's). Nothing further down is
455
- // ever reached, so nothing further down is loaded.
496
+ // reached for a Latin glyph, so nothing further down is loaded for one.
497
+ let lead: string | null = null;
456
498
  let id: string | null = null;
457
- for (const family of parseFontFamilies(row?.value ?? '')) {
458
- if (uploaded.has(fontFamilyKey(family))) break;
499
+ for (const family of families) {
500
+ if (uploaded.has(fontFamilyKey(family))) { lead = family; break; }
459
501
  id = catalogFontId(family);
460
- if (id) break;
502
+ if (id) { lead = family; break; }
461
503
  }
462
- if (id && !seen.has(id)) {
463
- seen.add(id);
464
- ids.push(id);
504
+ add(id);
505
+ // …but a Hebrew glyph IS reached further down: the browser falls through the stack, per glyph, to the
506
+ // first family that draws it — the role's script partner, linked for a page whose locale needs it.
507
+ for (const script of scripts) {
508
+ if (lead && drawsScript(lead, script)) continue;
509
+ const partner = families.find(f => drawsScript(f, script) && !uploaded.has(fontFamilyKey(f)));
510
+ add(partner ? catalogFontId(partner) : null);
465
511
  }
466
512
  }
467
513
  return ids;
@@ -526,8 +572,10 @@ const PRELOAD_FORMAT: FontFormat = 'woff2';
526
572
 
527
573
  /**
528
574
  * The uploaded files the SSR head preloads (§5b — no flash of the fallback face): for the display and
529
- * body roles, the family each token names (its first uploaded family), the woff2 normal-style cut CSS
530
- * matching would pick for that role's weights. One file per weight that renders, never the family.
575
+ * body roles, the family the role RENDERS in — the stack's first family that is uploaded, in the catalog
576
+ * or on every system — when it is an uploaded one, the woff2 normal-style cut CSS matching would pick for
577
+ * that role's weights. One file per weight that renders, never the family. A family further down (a
578
+ * Hebrew partner, H1-2) draws only the glyphs the lead lacks: never preloaded.
531
579
  */
532
580
  export function fontPreloads(
533
581
  descriptors: ReadonlyArray<FontDescriptor> | null | undefined,
@@ -539,7 +587,9 @@ export function fontPreloads(
539
587
  const out = new Map<string, FontDescriptor>();
540
588
  for (const { role, weights, fallback } of PRELOAD_ROLES) {
541
589
  const named = parseFontFamilies(resolved[role] ?? '').map(fontFamilyKey);
542
- const family = named.map(key => families.find(f => fontFamilyKey(f.family) === key)).find(Boolean);
590
+ const uploadedFamily = (key: string) => families.find(f => fontFamilyKey(f.family) === key);
591
+ const lead = named.find(key => !!uploadedFamily(key) || !!catalogFontId(key) || ALWAYS_AVAILABLE_FAMILIES.has(key));
592
+ const family = lead ? uploadedFamily(lead) : undefined;
543
593
  const faces = family?.faces.filter(f => f.format === PRELOAD_FORMAT && normalizeFontStyle(f.style) === 'normal') ?? [];
544
594
  for (const token of weights) {
545
595
  const weight = Number.parseInt(resolved[token] ?? '', 10);
@@ -29,3 +29,4 @@ export * from './fonts';
29
29
  export * from './token-names';
30
30
  export * from './design-settings';
31
31
  export * from './design-apply';
32
+ export * from './style-presets';
@@ -0,0 +1,71 @@
1
+ import { SPEC_MOODS, SPEC_STYLE_PRESETS } from './flavor-spec.generated';
2
+ import { resolveDesignChoices } from './design-settings';
3
+ import {
4
+ DensityId, DesignChoices, DesignMode, DesignMotion, MotionId, ShapeId, TintId,
5
+ } from './design-token.model';
6
+
7
+ /**
8
+ * Style presets (AI plan rev 3 §3f, H1-1) — a style is a preset over what exists, never a stylesheet: a
9
+ * named `DesignChoices` + the site's motion character + the colour modes it passed the contrast audit in.
10
+ * Generated 1:1 from the contract (`stylePresets`, AA-gated per preset × mode in build-spec.mjs); Design ▸
11
+ * Look lists them, ranked by the harness (`@vectoriox/iox-ui/harness-core`).
12
+ */
13
+
14
+ /** The closed mood vocabulary a preset carries (≤ 3) and an analysis returns. */
15
+ export type MoodId = keyof typeof SPEC_MOODS;
16
+ export const MOOD_IDS = Object.keys(SPEC_MOODS) as ReadonlyArray<MoodId>;
17
+ export const MOOD_LABELS: Readonly<Record<MoodId, string>> = SPEC_MOODS;
18
+
19
+ /** A colour mode a preset was audited in (`auto` is offered on top when it passed both). */
20
+ export type PresetMode = Exclude<DesignMode, 'auto'>;
21
+
22
+ export interface StylePreset {
23
+ readonly id: string;
24
+ readonly name: string;
25
+ readonly blurb: string;
26
+ readonly flavorId: string;
27
+ readonly seed: string;
28
+ readonly tint: TintId;
29
+ readonly pairId: string;
30
+ readonly shape: ShapeId;
31
+ readonly density: DensityId;
32
+ readonly motion: MotionId;
33
+ readonly pillButtons: boolean;
34
+ readonly moves: Readonly<DesignMotion>;
35
+ /** The modes it PASSED the audit in; the first is its default. */
36
+ readonly modes: ReadonlyArray<PresetMode>;
37
+ readonly moods: ReadonlyArray<MoodId>;
38
+ /** The UI UX Pro Max styles it stands for (attribution and the mapping table). */
39
+ readonly uupm: ReadonlyArray<string>;
40
+ /** In the M1-alpha slice. */
41
+ readonly slice: boolean;
42
+ }
43
+
44
+ /** The 24, catalogue order (four per flavor). Assignment here proves the generated data fits the type. */
45
+ export const STYLE_PRESETS: ReadonlyArray<StylePreset> = SPEC_STYLE_PRESETS;
46
+
47
+ export function stylePresetById(id: string | null | undefined): StylePreset | undefined {
48
+ return id ? STYLE_PRESETS.find(p => p.id === id) : undefined;
49
+ }
50
+
51
+ const FOLLOW_VISITOR: DesignMode = 'auto';
52
+
53
+ /** The modes Design offers for a preset: the audited ones, then "Follow visitor" when it passed both (H-D3). */
54
+ export function presetModes(preset: StylePreset): DesignMode[] {
55
+ return preset.modes.length > 1 ? [...preset.modes, FOLLOW_VISITOR] : [...preset.modes];
56
+ }
57
+
58
+ /**
59
+ * What picking a preset means — the complete `DesignChoices`, its motion included — in `mode` (default:
60
+ * its first). A mode it was not audited in is REFUSED (null): a light-only preset is never worn dark.
61
+ */
62
+ export function presetChoices(id: string, mode?: DesignMode): DesignChoices | null {
63
+ const preset = stylePresetById(id);
64
+ if (!preset) return null;
65
+ const wear = mode ?? preset.modes[0];
66
+ if (!presetModes(preset).includes(wear)) return null;
67
+ return resolveDesignChoices({
68
+ flavorId: preset.flavorId, seed: preset.seed, pairId: preset.pairId, shape: preset.shape, density: preset.density,
69
+ motion: preset.motion, pillButtons: preset.pillButtons, mode: wear, tint: preset.tint, ...preset.moves,
70
+ });
71
+ }