@vectoriox/iox-ui 5.9.0 → 5.11.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.
package/index.d.ts CHANGED
@@ -5,6 +5,7 @@ import { Observable } from 'rxjs';
5
5
  import * as i3 from '@angular/platform-browser/animations';
6
6
  import Lenis from 'lenis';
7
7
  import * as i2 from '@angular/common';
8
+ export * from '@vectoriox/iox-ui/design-core';
8
9
 
9
10
  declare class ViewPositionDirective implements AfterViewInit {
10
11
  private _elr;
@@ -1313,8 +1314,6 @@ declare class IoxComponentRegistryService {
1313
1314
  * `1vh` / `1vw` fallbacks restore native browser behaviour.
1314
1315
  */
1315
1316
  declare function rewriteViewportUnits(value: string): string;
1316
- /** camelCase style key → kebab-case CSS property. */
1317
- declare function styleKeyToKebab(key: string): string;
1318
1317
  /**
1319
1318
  * Style props a type's render-default fully REPLACES. Emitting them would produce a dead
1320
1319
  * declaration that is immediately overridden in the same rule (e.g. an Icon's serialised
@@ -1497,115 +1496,6 @@ declare const PART_CLASS_NAMES: Record<string, Record<string, string>>;
1497
1496
  * Undefined when the type declares no such part (or no parts at all). */
1498
1497
  declare function partClassName(type: string, partKey: string): string | undefined;
1499
1498
 
1500
- /**
1501
- * Flavors (flavors-and-theming-plan.md, F1): the design language a studio launches with,
1502
- * expressed as data. THE invariant — a flavor never writes a literal into a node; it writes
1503
- * tokens, and element defaults that REFERENCE tokens. To that end a pack's `defaults` map
1504
- * holds TOKEN NAMES, not CSS values: the resolver builds `var(--iox-<name>, <packValue>)`,
1505
- * so smuggling a literal in is impossible by construction, and the validator only has to
1506
- * check that every referenced token exists.
1507
- *
1508
- * The fallback is the bootstrap: until F2 seeds the org's `designtokens` rows, no `--iox-*`
1509
- * var is defined anywhere, and `var(--iox-radius-control, 4px)` must still paint identically
1510
- * in the builder canvas and the SSR page. Once tokens ARE seeded (or re-themed), the var wins
1511
- * over the fallback everywhere — which is exactly the §3 propagation story.
1512
- */
1513
- interface FlavorTokenDef {
1514
- name: string;
1515
- value: string;
1516
- /** Dark-scheme override, seeded into DesignToken.values.dark at F2. */
1517
- values?: {
1518
- dark?: string;
1519
- };
1520
- type?: 'color' | 'spacing' | 'size' | 'font' | 'other';
1521
- /** Tokens-panel grouping (flavors plan §10 C5). */
1522
- group?: 'colour' | 'type' | 'space' | 'radius' | 'shadow' | 'motion';
1523
- }
1524
- interface FlavorPack {
1525
- id: string;
1526
- /** Bumped when the pack's values change; seeding stamps `flavor:<id>@<version>` as DesignToken.source. */
1527
- version: number;
1528
- label: string;
1529
- blurb: string;
1530
- tokens: FlavorTokenDef[];
1531
- /** type → styleTrait → TOKEN NAME (never a CSS value — see the module header). */
1532
- defaults: Record<string, Record<string, string>>;
1533
- /** F2: the accent's hue rotation (degrees in OKLab) from the brand seed — a pack opinion. */
1534
- accentHueDeg?: number;
1535
- }
1536
- /** trait → `var(--iox-<token>, <packValue>)` for one node type. Unknown type → {}. */
1537
- declare function resolveFlavorDefaults(type: string, pack: FlavorPack | null | undefined): Record<string, string>;
1538
- /**
1539
- * The ENGINE's half of the cascade: kebab `prop: var(…)` declarations for every pack default
1540
- * the node's persisted styleProps does NOT set. Appended beside renderDefaultDeclarations —
1541
- * same guard-on-undefined discipline, so a node saved post-F1 (var already serialised via the
1542
- * builder's trait defaults) is untouched, and a legacy node picks the pack default up at
1543
- * render time. Node styleProps always wins.
1544
- */
1545
- declare function flavorDefaultDeclarations(type: string, styleProps: Record<string, any> | null | undefined, pack: FlavorPack | null | undefined): string[];
1546
- /**
1547
- * The CI gate (flavors plan §10 C4): a pack may only name types and traits that exist in the
1548
- * live component registry, and tokens it defines itself. Returns human-readable errors; a
1549
- * consumer spec feeds it the REAL registry and asserts [].
1550
- */
1551
- declare function validateFlavorPack(pack: FlavorPack, knownTraitsByType: Record<string, ReadonlySet<string> | string[]>): string[];
1552
- /**
1553
- * Swiss — the default pack (flavors plan §8: "the flavor that can never embarrass anyone").
1554
- * Deliberately SMALL at F1: radius + control padding on the clickables, surface radius on Card.
1555
- * Colour, type and motion arrive with the brand step (F2) — a colour default here would repaint
1556
- * legacy pages before anyone chose anything.
1557
- */
1558
- declare const SWISS_FLAVOR: FlavorPack;
1559
- /** What both renderers use until F2 lets an org choose. */
1560
- declare const DEFAULT_FLAVOR: FlavorPack;
1561
-
1562
- /**
1563
- * BRAND DERIVATION (flavors plan §6, F2): one colour in, an asserted accessible ramp out.
1564
- *
1565
- * Competitors ask for a palette of five and hope; we ask for ONE seed and compute the rest so
1566
- * that contrast is a TEST, not a promise. WCAG 2 ratios are the gate (every guarantee below is
1567
- * unit-asserted across seeds); APCA is exposed as the picker's quality read-out only.
1568
- *
1569
- * Colour math is OKLab (perceptually even lightness — the ramp steps LOOK even), hand-rolled
1570
- * from the published matrices because this module must stay dependency-free: it runs in the
1571
- * builder, the engine and (later) the generator's Node service.
1572
- */
1573
- interface Rgb {
1574
- r: number;
1575
- g: number;
1576
- b: number;
1577
- }
1578
- interface OkLab {
1579
- L: number;
1580
- a: number;
1581
- b: number;
1582
- }
1583
- declare function parseHexColor(hex: string): Rgb | null;
1584
- declare function toHexColor(c: Rgb): string;
1585
- declare function rgbToOklab(c: Rgb): OkLab;
1586
- declare function oklabToRgb(c: OkLab): Rgb;
1587
- declare function relativeLuminance(c: Rgb): number;
1588
- /** WCAG 2 contrast ratio between two colours (order-independent), 1..21. */
1589
- declare function wcagContrastRatio(a: string | Rgb, b: string | Rgb): number;
1590
- /**
1591
- * APCA-W3 lightness contrast Lc (0.0.98G-4g constants), text-on-background and signed:
1592
- * positive for dark-on-light, negative for light-on-dark; |Lc| ≈ 106–108 at the extremes.
1593
- * Shown in the picker as the perceptual read-out; never used as the pass/fail gate here.
1594
- */
1595
- declare function apcaLc(text: string | Rgb, bg: string | Rgb): number;
1596
- /**
1597
- * The derived brand tokens (plan §6's table, every row asserted in brand.spec.ts):
1598
- * `brand-50…900` (monotonic OKLab lightness, gamut-safe) · `brandInk` (black or white, ≥4.5 on
1599
- * brand-500 — brand-500's lightness is nudged when neither side clears it) · `surface`/`ink`/
1600
- * `inkMuted` (seed-tinted neutrals, ≥7:1 / ≥4.5:1 on surface) · `line` (the LIGHTEST neutral that
1601
- * still clears 3:1 on surface) · `accent` (pack-rotated hue, ≥4.5:1 on surface) — with dark-scheme
1602
- * values for the neutral family riding `values.dark`.
1603
- *
1604
- * Throws on an unparseable seed: callers (the picker) validate; a silent fallback here would
1605
- * launch a studio into a palette nobody chose.
1606
- */
1607
- declare function deriveBrandTokens(seed: string, pack: FlavorPack): FlavorTokenDef[];
1608
-
1609
1499
  /** Trait names whose value Swiper reads per breakpoint. Anything else is config-wide. */
1610
1500
  declare const SWIPER_RESPONSIVE_TRAITS: readonly string[];
1611
1501
  /** `{ [minWidth]: { slidesPerView, … } }` — Swiper's own `breakpoints` shape. */
@@ -1631,44 +1521,6 @@ declare function buildSwiperBreakpoints(inputs: InputMap | null | undefined, res
1631
1521
  */
1632
1522
  declare function mergeSwiperBreakpoints(derived: SwiperBreakpointMap, advancedJson: string | null | undefined): SwiperBreakpointMap | undefined;
1633
1523
 
1634
- /**
1635
- * Design-token → CSS compilation, shared by BOTH renderers (flavors plan F0).
1636
- *
1637
- * A token row is `{ name, value }` plus, since F0, an optional per-scheme override:
1638
- * `values.dark`. The shape exists NOW — before any dark-mode UI — because retrofitting it
1639
- * later means rewriting every org's `designtokens` collection (flavors plan §10 C3).
1640
- *
1641
- * ⚠️ The appended-last contract: the dark block REDEFINES custom properties under
1642
- * `@media (prefers-color-scheme: dark)`. Per the responsive-breakpoints A3 lesson, every
1643
- * consumer appends it AFTER all of its base rules — the engine pushes it after the page's
1644
- * css blocks, the builder registry keeps it as its last token rule. Custom properties are
1645
- * order-insensitive against node rules, but keeping one ordering rule for every @media
1646
- * block in the product is cheaper than remembering which ones are exempt.
1647
- */
1648
- /** The transport-agnostic token row. `source` marks seeded rows (`flavor:<id>@<ver>`). */
1649
- interface DesignTokenLike {
1650
- name: string;
1651
- value: string;
1652
- /** Per-scheme overrides. Only `dark` exists today; absent means the base value everywhere. */
1653
- values?: {
1654
- dark?: string;
1655
- };
1656
- type?: string;
1657
- source?: string;
1658
- }
1659
- /** `{ 'iox-brand': '#2f6f62', … }` — the builder registry's input shape for base values. */
1660
- declare function tokensToVarMap(tokens: ReadonlyArray<DesignTokenLike> | null | undefined): Record<string, string>;
1661
- /** Only the tokens that carry a dark override, as `{ 'iox-name': darkValue }`. */
1662
- declare function tokensToDarkVarMap(tokens: ReadonlyArray<DesignTokenLike> | null | undefined): Record<string, string>;
1663
- /**
1664
- * The two CSS blocks a renderer emits. `base` goes FIRST (before node rules), `dark` goes
1665
- * LAST (after every base rule); either may be '' when there is nothing to say.
1666
- */
1667
- declare function compileTokenCss(tokens: ReadonlyArray<DesignTokenLike> | null | undefined): {
1668
- base: string;
1669
- dark: string;
1670
- };
1671
-
1672
1524
  /**
1673
1525
  * page-style — the ONE compiler that turns a page's style settings into CSS.
1674
1526
  *
@@ -2838,5 +2690,5 @@ declare class GalleryLoop {
2838
2690
  private emit;
2839
2691
  }
2840
2692
 
2841
- export { AOSService, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ContainerComponent, ContextStore, DEFAULT_FLAVOR, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_PHOSPHOR_WEIGHT, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PART_CLASS_NAMES, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_RESPONSIVE_TRAITS, SWISS_FLAVOR, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VALIDATION_STATES, VALIDATION_STATE_CLASS, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, activeActions, apcaLc, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildPartSelector, buildRelatedFilter, buildSwiperBreakpoints, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, compilePageStyle, compileTokenCss, composeVirtualTraits, computeRelative, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, defaultMessageResolver, deriveBrandTokens, deriveSeoFromContent, describeSeoFields, effectiveTransitionId, excludeSelf, feelFor, flavorDefaultDeclarations, getByPath, getSeoValue, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, normalizeIconClass, oklabToRgb, parentBreakpoint, parseHexColor, parseUrlList, partClassName, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, relativeLuminance, renderDefaultDeclarations, resetSeoField, resolveBoundValue, resolveContextValue, resolveDerivedInto, resolveFlavorDefaults, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSeo, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, rgbToOklab, safeSetInput, setInputOverride, setOverride, setSeoAuto, setSeoBinding, setSeoOverride, setSeoValue, stateNamesIn, stripSuppressedProps, styleKeyToKebab, toHexColor, tokensToDarkVarMap, tokensToVarMap, validateFlavorPack, variableAliases, variableItemsFrom, wcagContrastRatio, withContextSlots, wrapInBreakpointQuery, wrapInMediaQuery };
2842
- export type { BoundValueSpec, BreakpointDef, BuildRelatedFilterOptions, ContextActionEffect, ContextActionLike, ContextItemRef, ContextSlotDefault, ContextValueSpec, ContextWriteScope, DataSourceDomain, DataSourceEndpoints, DataSourceMode, DataSourcePlan, DataSourceQueryFilter, DataSourceRequest, DataSourceRequestSpec, DataSourceRouteFilter, DataSourceSpec, DesignTokenLike, FlavorPack, FlavorTokenDef, FormMessageResolver, GalleryBoundSource, GalleryDurations, GalleryEffect, GalleryEffectDescriptor, GalleryImage, GalleryLoopOptions, GalleryLoopScheduler, GalleryLoopState, GallerySlideState, IBuilderEventEmitter, InputMap, IoxContextSlot, IoxPipe, OkLab, OverrideBreakpoint, PageStyleSettings, PageTransitionCategory, PageTransitionCategoryMeta, PageTransitionOptions, PageTransitionPreset, PipeArgSpec, PipeContext, PipeDescriptor, RelativeInputs, RelativeSourceKind, RelativeSourceSpec, ResolveSiblingsOptions, ResolvedPageTransition, ResponsiveLayer, ResponsiveOverrides, ResponsiveRuleSet, Rgb, RouteContext, SeoDerivedContent, SeoFieldSource, SeoFieldState, SeoResolveContext, SetContextParams, SiblingsResult, StateStyleMap, StyleMap, StyleTreeNode, SwiperBreakpointMap, ToggleableAction, TransitionFeel, TransitionPhase, TransitionTimelineInput, ValueSource, VirtualTraitGroup };
2693
+ export { AOSService, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ContainerComponent, ContextStore, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_PHOSPHOR_WEIGHT, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PART_CLASS_NAMES, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_RESPONSIVE_TRAITS, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VALIDATION_STATES, VALIDATION_STATE_CLASS, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, activeActions, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildPartSelector, buildRelatedFilter, buildSwiperBreakpoints, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, compilePageStyle, composeVirtualTraits, computeRelative, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, defaultMessageResolver, deriveSeoFromContent, describeSeoFields, effectiveTransitionId, excludeSelf, feelFor, getByPath, getSeoValue, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, normalizeIconClass, parentBreakpoint, parseUrlList, partClassName, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resetSeoField, resolveBoundValue, resolveContextValue, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSeo, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, safeSetInput, setInputOverride, setOverride, setSeoAuto, setSeoBinding, setSeoOverride, setSeoValue, stateNamesIn, stripSuppressedProps, variableAliases, variableItemsFrom, withContextSlots, wrapInBreakpointQuery, wrapInMediaQuery };
2694
+ export type { BoundValueSpec, BreakpointDef, BuildRelatedFilterOptions, ContextActionEffect, ContextActionLike, ContextItemRef, ContextSlotDefault, ContextValueSpec, ContextWriteScope, DataSourceDomain, DataSourceEndpoints, DataSourceMode, DataSourcePlan, DataSourceQueryFilter, DataSourceRequest, DataSourceRequestSpec, DataSourceRouteFilter, DataSourceSpec, FormMessageResolver, GalleryBoundSource, GalleryDurations, GalleryEffect, GalleryEffectDescriptor, GalleryImage, GalleryLoopOptions, GalleryLoopScheduler, GalleryLoopState, GallerySlideState, IBuilderEventEmitter, InputMap, IoxContextSlot, IoxPipe, OverrideBreakpoint, PageStyleSettings, PageTransitionCategory, PageTransitionCategoryMeta, PageTransitionOptions, PageTransitionPreset, PipeArgSpec, PipeContext, PipeDescriptor, RelativeInputs, RelativeSourceKind, RelativeSourceSpec, ResolveSiblingsOptions, ResolvedPageTransition, ResponsiveLayer, ResponsiveOverrides, ResponsiveRuleSet, RouteContext, SeoDerivedContent, SeoFieldSource, SeoFieldState, SeoResolveContext, SetContextParams, SiblingsResult, StateStyleMap, StyleMap, StyleTreeNode, SwiperBreakpointMap, ToggleableAction, TransitionFeel, TransitionPhase, TransitionTimelineInput, ValueSource, VirtualTraitGroup };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.9.0",
3
+ "version": "5.11.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -27,6 +27,10 @@
27
27
  "types": "./consent-core/index.d.ts",
28
28
  "default": "./fesm2022/vectoriox-iox-ui-consent-core.mjs"
29
29
  },
30
+ "./design-core": {
31
+ "types": "./design-core/index.d.ts",
32
+ "default": "./fesm2022/vectoriox-iox-ui-design-core.mjs"
33
+ },
30
34
  "./forms-core": {
31
35
  "types": "./forms-core/index.d.ts",
32
36
  "default": "./fesm2022/vectoriox-iox-ui-forms-core.mjs"
@@ -0,0 +1,335 @@
1
+ import { FlavorPack, FlavorTokenDef } from './flavor';
2
+
3
+ /**
4
+ * BRAND DERIVATION (flavors plan §6, F2): one colour in, an asserted accessible ramp out.
5
+ *
6
+ * Competitors ask for a palette of five and hope; we ask for ONE seed and compute the rest so
7
+ * that contrast is a TEST, not a promise. WCAG 2 ratios are the gate (every guarantee below is
8
+ * unit-asserted across seeds); APCA is exposed as the picker's quality read-out only.
9
+ *
10
+ * Colour math is OKLab (perceptually even lightness — the ramp steps LOOK even), hand-rolled
11
+ * from the published matrices because this module must stay dependency-free: it runs in the
12
+ * builder, the engine and (later) the generator's Node service.
13
+ */
14
+
15
+ // ── sRGB ⇄ OKLab ─────────────────────────────────────────────────────────────
16
+
17
+ export interface Rgb { r: number; g: number; b: number; } // 0..1
18
+ export interface OkLab { L: number; a: number; b: number; }
19
+
20
+ export function parseHexColor(hex: string): Rgb | null {
21
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((hex ?? '').trim());
22
+ if (!m) return null;
23
+ let h = m[1];
24
+ if (h.length === 3) h = h.split('').map(c => c + c).join('');
25
+ const n = parseInt(h, 16);
26
+ return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
27
+ }
28
+
29
+ export function toHexColor(c: Rgb): string {
30
+ const q = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, '0');
31
+ return `#${q(c.r)}${q(c.g)}${q(c.b)}`;
32
+ }
33
+
34
+ const srgbToLinear = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
35
+ const linearToSrgb = (v: number) => (v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055);
36
+
37
+ export function rgbToOklab(c: Rgb): OkLab {
38
+ const r = srgbToLinear(c.r), g = srgbToLinear(c.g), b = srgbToLinear(c.b);
39
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
40
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
41
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
42
+ return {
43
+ L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
44
+ a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
45
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
46
+ };
47
+ }
48
+
49
+ export function oklabToRgb(c: OkLab): Rgb {
50
+ const l = Math.pow(c.L + 0.3963377774 * c.a + 0.2158037573 * c.b, 3);
51
+ const m = Math.pow(c.L - 0.1055613458 * c.a - 0.0638541728 * c.b, 3);
52
+ const s = Math.pow(c.L - 0.0894841775 * c.a - 1.291485548 * c.b, 3);
53
+ return {
54
+ r: linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
55
+ g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
56
+ b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
57
+ };
58
+ }
59
+
60
+ const inGamut = (c: Rgb) => [c.r, c.g, c.b].every(v => v >= -1e-6 && v <= 1 + 1e-6);
61
+
62
+ /** The OKLab colour at (L, hue, chroma), chroma reduced until it fits sRGB. */
63
+ function atLightness(L: number, hueA: number, hueB: number, chroma: number): Rgb {
64
+ const len = Math.hypot(hueA, hueB) || 1;
65
+ const ua = hueA / len, ub = hueB / len;
66
+ let c = chroma;
67
+ for (let i = 0; i < 24; i++) {
68
+ const rgb = oklabToRgb({ L, a: ua * c, b: ub * c });
69
+ if (inGamut(rgb)) return rgb;
70
+ c *= 0.82;
71
+ }
72
+ return oklabToRgb({ L, a: 0, b: 0 });
73
+ }
74
+
75
+ // ── WCAG 2 (the gate) ────────────────────────────────────────────────────────
76
+
77
+ export function relativeLuminance(c: Rgb): number {
78
+ return 0.2126 * srgbToLinear(c.r) + 0.7152 * srgbToLinear(c.g) + 0.0722 * srgbToLinear(c.b);
79
+ }
80
+
81
+ /** WCAG 2 contrast ratio between two colours (order-independent), 1..21. */
82
+ export function wcagContrastRatio(a: string | Rgb, b: string | Rgb): number {
83
+ const ca = typeof a === 'string' ? parseHexColor(a) : a;
84
+ const cb = typeof b === 'string' ? parseHexColor(b) : b;
85
+ if (!ca || !cb) return 1;
86
+ const la = relativeLuminance(ca), lb = relativeLuminance(cb);
87
+ const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
88
+ return (hi + 0.05) / (lo + 0.05);
89
+ }
90
+
91
+ // ── APCA (the read-out — WCAG 2 stays the gate) ─────────────────────────────
92
+
93
+ /**
94
+ * APCA-W3 lightness contrast Lc (0.0.98G-4g constants), text-on-background and signed:
95
+ * positive for dark-on-light, negative for light-on-dark; |Lc| ≈ 106–108 at the extremes.
96
+ * Shown in the picker as the perceptual read-out; never used as the pass/fail gate here.
97
+ */
98
+ export function apcaLc(text: string | Rgb, bg: string | Rgb): number {
99
+ const toY = (c: Rgb) =>
100
+ 0.2126729 * Math.pow(c.r, 2.4) + 0.7151522 * Math.pow(c.g, 2.4) + 0.072175 * Math.pow(c.b, 2.4);
101
+ const clamp = (y: number) => (y < 0.022 ? y + Math.pow(0.022 - y, 1.414) : y);
102
+ const ct = typeof text === 'string' ? parseHexColor(text) : text;
103
+ const cb = typeof bg === 'string' ? parseHexColor(bg) : bg;
104
+ if (!ct || !cb) return 0;
105
+ const yTxt = clamp(toY(ct)), yBg = clamp(toY(cb));
106
+ let lc: number;
107
+ if (yBg > yTxt) lc = (Math.pow(yBg, 0.56) - Math.pow(yTxt, 0.57)) * 1.14;
108
+ else lc = (Math.pow(yBg, 0.65) - Math.pow(yTxt, 0.62)) * 1.14;
109
+ if (Math.abs(lc) < 0.1) return 0;
110
+ return Math.round((lc > 0 ? lc - 0.027 : lc + 0.027) * 1000) / 10;
111
+ }
112
+
113
+ // ── The ramp ─────────────────────────────────────────────────────────────────
114
+
115
+ /** Fixed, perceptually even lightness ladder for brand-50 … brand-900. */
116
+ const RAMP_STEPS: ReadonlyArray<{ step: number; L: number; chromaScale: number }> = [
117
+ { step: 50, L: 0.97, chromaScale: 0.22 },
118
+ { step: 100, L: 0.93, chromaScale: 0.42 },
119
+ { step: 200, L: 0.86, chromaScale: 0.66 },
120
+ { step: 300, L: 0.78, chromaScale: 0.85 },
121
+ { step: 400, L: 0.70, chromaScale: 0.96 },
122
+ { step: 500, L: 0.62, chromaScale: 1.0 },
123
+ { step: 600, L: 0.53, chromaScale: 0.96 },
124
+ { step: 700, L: 0.45, chromaScale: 0.88 },
125
+ { step: 800, L: 0.36, chromaScale: 0.76 },
126
+ { step: 900, L: 0.27, chromaScale: 0.62 },
127
+ ];
128
+
129
+ const BLACK: Rgb = { r: 0, g: 0, b: 0 };
130
+ const WHITE: Rgb = { r: 1, g: 1, b: 1 };
131
+
132
+ /** Snap to the 8-bit hex the token will actually carry — contrast is judged on WHAT SHIPS.
133
+ * (The first version searched in float space and hex rounding landed 6.9596 where 7 was owed.) */
134
+ const quant = (c: Rgb): Rgb => parseHexColor(toHexColor(c))!;
135
+
136
+ /** Binary-search an OKLab lightness so `contrast(colour, against) ≥ ratio`, darkening-or-lightening. */
137
+ function atContrast(against: Rgb, ratio: number, hueA: number, hueB: number, chroma: number, darker: boolean): Rgb {
138
+ let lo = darker ? 0 : rgbToOklab(against).L;
139
+ let hi = darker ? rgbToOklab(against).L : 1;
140
+ let best = quant(atLightness(darker ? 0.18 : 0.92, hueA, hueB, chroma));
141
+ for (let i = 0; i < 28; i++) {
142
+ const mid = (lo + hi) / 2;
143
+ const candidate = quant(atLightness(mid, hueA, hueB, chroma));
144
+ if (wcagContrastRatio(candidate, against) >= ratio) {
145
+ best = candidate;
146
+ if (darker) lo = mid; else hi = mid; // pull back toward `against`: the LOWEST passing step
147
+ } else {
148
+ if (darker) hi = mid; else lo = mid;
149
+ }
150
+ }
151
+ return best;
152
+ }
153
+
154
+ /**
155
+ * The derived brand tokens (plan §6's table, every row asserted in brand.spec.ts):
156
+ * `brand-50…900` (monotonic OKLab lightness, gamut-safe) · `brandInk` (black or white, ≥4.5 on
157
+ * brand-500 — brand-500's lightness is nudged when neither side clears it) · `surface`/`ink`/
158
+ * `inkMuted` (seed-tinted neutrals, ≥7:1 / ≥4.5:1 on surface) · `line` (the LIGHTEST neutral that
159
+ * still clears 3:1 on surface) · `accent` (pack-rotated hue, ≥4.5:1 on surface) — with dark-scheme
160
+ * values for the neutral family riding `values.dark`.
161
+ *
162
+ * Throws on an unparseable seed: callers (the picker) validate; a silent fallback here would
163
+ * launch a studio into a palette nobody chose.
164
+ */
165
+ export function deriveBrandTokens(seed: string, pack: Pick<FlavorPack, 'accentHueDeg'>): FlavorTokenDef[] {
166
+ const seedRgb = parseHexColor(seed);
167
+ if (!seedRgb) throw new TypeError(`deriveBrandTokens: not a colour: "${seed}"`);
168
+ const lab = rgbToOklab(seedRgb);
169
+ const chroma = Math.hypot(lab.a, lab.b);
170
+ const ua = chroma > 1e-6 ? lab.a : 0.0, ub = chroma > 1e-6 ? lab.b : 1e-6; // grey seeds: keep a stable (arbitrary) hue axis
171
+ const baseChroma = Math.max(chroma, 0.02); // near-grey seeds still get a usable, quiet ramp
172
+
173
+ const token = (name: string, value: string, dark?: string): FlavorTokenDef =>
174
+ ({ name, value, ...(dark ? { values: { dark } } : {}), type: 'color', group: 'colour' });
175
+
176
+ // 1 — the ramp
177
+ const ramp = RAMP_STEPS.map(({ step, L, chromaScale }) =>
178
+ ({ step, rgb: atLightness(L, ua, ub, baseChroma * chromaScale) }));
179
+
180
+ // 2 — brandInk on brand-500, nudging 500 (within its neighbours) when neither ink clears 4.5
181
+ let five = ramp.find(r => r.step === 500)!;
182
+ five.rgb = quant(five.rgb);
183
+ const inkFor = (bg: Rgb) => (wcagContrastRatio(WHITE, bg) >= wcagContrastRatio(BLACK, bg) ? WHITE : BLACK);
184
+ for (let i = 0; i < 40 && wcagContrastRatio(inkFor(five.rgb), five.rgb) < 4.5; i++) {
185
+ const L = rgbToOklab(five.rgb).L;
186
+ // Mid tones can fail both inks — walk toward whichever side is already winning, but
187
+ // never past the neighbouring steps: the ramp's monotonic lightness is a guarantee.
188
+ const towardDark = wcagContrastRatio(WHITE, five.rgb) >= wcagContrastRatio(BLACK, five.rgb);
189
+ const next = Math.min(0.695, Math.max(0.545, L + (towardDark ? -0.012 : 0.012)));
190
+ if (next === L) break;
191
+ five.rgb = quant(atLightness(next, ua, ub, baseChroma));
192
+ }
193
+ const brandInk = inkFor(five.rgb);
194
+
195
+ // 3 — the neutral family, faintly tinted by the seed hue. Surfaces are quantized FIRST:
196
+ // every later guarantee is measured against the hex that ships, not a float ideal.
197
+ const tint = 0.006;
198
+ const surface = quant(atLightness(0.985, ua, ub, tint));
199
+ const surfaceDark = quant(atLightness(0.145, ua, ub, tint * 1.5));
200
+ const ink = atContrast(surface, 7, ua, ub, tint * 2, true);
201
+ const inkDark = atContrast(surfaceDark, 7, ua, ub, tint * 2, false);
202
+ const inkMuted = atContrast(surface, 4.5, ua, ub, tint * 2, true);
203
+ const inkMutedDark = atContrast(surfaceDark, 4.5, ua, ub, tint * 2, false);
204
+ const line = atContrast(surface, 3, ua, ub, tint, true);
205
+ const lineDark = atContrast(surfaceDark, 3, ua, ub, tint, false);
206
+
207
+ // 4 — the accent: pack-defined hue rotation from the seed, readable on surface
208
+ const rot = ((pack.accentHueDeg ?? 150) * Math.PI) / 180;
209
+ const ra = ua * Math.cos(rot) - ub * Math.sin(rot);
210
+ const rb = ua * Math.sin(rot) + ub * Math.cos(rot);
211
+ const accent = atContrast(surface, 4.5, ra, rb, Math.max(baseChroma, 0.06), true);
212
+
213
+ return [
214
+ ...ramp.map(({ step, rgb }) => token(`brand-${step}`, toHexColor(rgb))),
215
+ token('brandInk', toHexColor(brandInk)),
216
+ token('surface', toHexColor(surface), toHexColor(surfaceDark)),
217
+ token('ink', toHexColor(ink), toHexColor(inkDark)),
218
+ token('inkMuted', toHexColor(inkMuted), toHexColor(inkMutedDark)),
219
+ token('line', toHexColor(line), toHexColor(lineDark)),
220
+ token('accent', toHexColor(accent)),
221
+ ];
222
+ }
223
+
224
+ // ── The design-system accent quartet (design-system plan §3e) ────────────────
225
+
226
+ /** WCAG 2 AA for body text — the gate every quartet pair is held to. */
227
+ export const WCAG_AA_TEXT = 4.5;
228
+
229
+ export const ACCENT_TOKEN_NAMES = [
230
+ 'accent', 'accent-ink', 'accent-hover', 'accent-text', 'inverse-accent', 'inverse-accent-ink',
231
+ ] as const;
232
+ export type AccentTokenName = (typeof ACCENT_TOKEN_NAMES)[number];
233
+
234
+ /** The backgrounds the accent must read on, light and (optionally) dark. Without an inverse
235
+ * background the inverse pair is not derived — the pack's own stays. */
236
+ export interface AccentBackgrounds {
237
+ bg: string;
238
+ bgDark?: string;
239
+ inverseBg?: string;
240
+ inverseBgDark?: string;
241
+ }
242
+
243
+ const RAMP_ORDER = RAMP_STEPS.map(s => s.step);
244
+ const WHITE_HEX = toHexColor(WHITE);
245
+ const BLACK_HEX = toHexColor(BLACK);
246
+ const passesText = (fg: string, bg: string) => wcagContrastRatio(fg, bg) >= WCAG_AA_TEXT;
247
+ const bestInkHex = (bg: string) =>
248
+ (wcagContrastRatio(WHITE_HEX, bg) >= wcagContrastRatio(BLACK_HEX, bg) ? WHITE_HEX : BLACK_HEX);
249
+
250
+ /**
251
+ * The quartet a seed different from the pack's own becomes, built ON `deriveBrandTokens` (its
252
+ * ramp and its WCAG-gated `brandInk`) — never a second colour model:
253
+ *
254
+ * - `accent` = brand-500 (already nudged until `brandInk` clears 4.5:1 on it), `accent-ink` = brandInk
255
+ * - `accent-hover` = the neighbouring step AWAY from the ink, so ink-on-hover only gains contrast
256
+ * - `accent-text` = the first step from 500 toward the far end that clears 4.5:1 on `bg`
257
+ * - dark: the first of brand-300/200/400 an ink clears 4.5:1 on, the same hover rule, and
258
+ * `accent-text` = the first light step that clears 4.5:1 on `bgDark`
259
+ *
260
+ * - `inverse-accent` = the first step from 500 toward the end that reads on `inverseBg` clearing
261
+ * 4.5:1 on it; `inverse-accent-ink` = the higher-contrast of `brandInk` and the OPPOSITE ramp end
262
+ * (the spec's `brandSeed` rule). A step whose best ink still misses 4.5:1 is passed over, so the
263
+ * pair is a guarantee rather than a hope. Dark: the same against `inverseBgDark`.
264
+ *
265
+ * Every pair (ink/accent, ink/hover, text/bg, inverse ink/inverse accent, inverse accent/inverse
266
+ * bg) is asserted ≥ 4.5:1 across packs and seeds in `flavor-tokens.spec.ts`.
267
+ */
268
+ export function deriveAccentTokens(
269
+ seed: string,
270
+ against: AccentBackgrounds,
271
+ pack: Pick<FlavorPack, 'accentHueDeg'> = {},
272
+ ): FlavorTokenDef[] {
273
+ const derived = deriveBrandTokens(seed, pack);
274
+ const hexOf = (name: string) => derived.find(t => t.name === name)!.value;
275
+ const step = (n: number) => hexOf(`brand-${n}`);
276
+
277
+ const hoverFor = (accentStep: number, ink: string): string => {
278
+ const i = RAMP_ORDER.indexOf(accentStep) + (ink === WHITE_HEX ? 1 : -1);
279
+ const candidate = RAMP_ORDER[i] !== undefined ? step(RAMP_ORDER[i]) : null;
280
+ return candidate && passesText(ink, candidate) ? candidate : step(accentStep);
281
+ };
282
+ const firstOn = (steps: number[], bg: string) => step(steps.find(n => passesText(step(n), bg)) ?? steps[steps.length - 1]);
283
+
284
+ const ink = hexOf('brandInk');
285
+ const light = {
286
+ accent: step(500),
287
+ ink,
288
+ hover: hoverFor(500, ink),
289
+ text: firstOn([500, 600, 700, 800, 900], against.bg),
290
+ };
291
+
292
+ const dark = against.bgDark ? (() => {
293
+ const darkStep = [300, 200, 400].find(n => passesText(bestInkHex(step(n)), step(n))) ?? 300;
294
+ const darkInk = bestInkHex(step(darkStep));
295
+ return {
296
+ accent: step(darkStep),
297
+ ink: darkInk,
298
+ hover: hoverFor(darkStep, darkInk),
299
+ text: firstOn([300, 200, 100, 50], against.bgDark),
300
+ };
301
+ })() : null;
302
+
303
+ const inverseOn = (inverseBg: string) => {
304
+ const toward = wcagContrastRatio(step(RAMP_ORDER[0]), inverseBg) >= wcagContrastRatio(step(RAMP_ORDER[RAMP_ORDER.length - 1]), inverseBg)
305
+ ? -1 : 1;
306
+ const walk = RAMP_ORDER.filter(n => (toward < 0 ? n <= 500 : n >= 500));
307
+ if (toward < 0) walk.reverse();
308
+ const rampEnd = step(toward < 0 ? RAMP_ORDER[RAMP_ORDER.length - 1] : RAMP_ORDER[0]);
309
+ const inkOn = (fill: string) =>
310
+ (wcagContrastRatio(ink, fill) >= wcagContrastRatio(rampEnd, fill) ? ink : rampEnd);
311
+ for (const n of walk) {
312
+ const fill = step(n);
313
+ if (passesText(fill, inverseBg) && passesText(inkOn(fill), fill)) return { accent: fill, ink: inkOn(fill) };
314
+ }
315
+ const fill = step(walk[walk.length - 1]);
316
+ return { accent: fill, ink: passesText(inkOn(fill), fill) ? inkOn(fill) : bestInkHex(fill) };
317
+ };
318
+ const inverse = against.inverseBg ? inverseOn(against.inverseBg) : null;
319
+ const inverseDark = against.inverseBg && against.inverseBgDark ? inverseOn(against.inverseBgDark) : null;
320
+
321
+ const row = (name: AccentTokenName, value: string, darkValue?: string): FlavorTokenDef => ({
322
+ name, value, type: 'color', group: 'colour', tier: 'semantic',
323
+ ...(darkValue ? { values: { dark: darkValue } } : {}),
324
+ });
325
+ return [
326
+ row('accent', light.accent, dark?.accent),
327
+ row('accent-ink', light.ink, dark?.ink),
328
+ row('accent-hover', light.hover, dark?.hover),
329
+ row('accent-text', light.text, dark?.text),
330
+ ...(inverse ? [
331
+ row('inverse-accent', inverse.accent, inverseDark?.accent),
332
+ row('inverse-accent-ink', inverse.ink, inverseDark?.ink),
333
+ ] : []),
334
+ ];
335
+ }
@@ -0,0 +1,4 @@
1
+ /** camelCase style key → kebab CSS property (`borderRadius` → `border-radius`). */
2
+ export function styleKeyToKebab(key: string): string {
3
+ return key.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
4
+ }