@vectoriox/iox-ui 5.24.0 → 5.26.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/design-core/index.d.ts +1153 -191
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +1680 -241
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +81 -14
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/index.d.ts +35 -3
- package/package.json +1 -1
- package/src/design-core/brand-neutrals.ts +236 -0
- package/src/design-core/brand.ts +24 -14
- package/src/design-core/colour-eval.ts +117 -0
- package/src/design-core/contrast-audit.ts +214 -0
- package/src/design-core/design-apply.ts +34 -14
- package/src/design-core/design-settings.ts +12 -3
- package/src/design-core/design-token.model.ts +26 -0
- package/src/design-core/flavor-fixtures.generated.ts +150 -0
- package/src/design-core/flavor-spec.generated.ts +1061 -189
- package/src/design-core/flavor-tokens.ts +36 -8
- package/src/design-core/public-api.ts +3 -0
- package/src/design-core/token-css.ts +48 -15
- package/styles/iox-design.css +16 -0
- package/styles/iox-forms.css +15 -9
package/index.d.ts
CHANGED
|
@@ -1668,8 +1668,40 @@ interface PageStyleOptions {
|
|
|
1668
1668
|
*/
|
|
1669
1669
|
designSystem?: boolean;
|
|
1670
1670
|
}
|
|
1671
|
-
/** The
|
|
1671
|
+
/** The three page-style fields a design-system page can inherit from the look. */
|
|
1672
|
+
declare enum PageStyleField {
|
|
1673
|
+
Background = "background",
|
|
1674
|
+
TextColor = "textColor",
|
|
1675
|
+
FontFamily = "fontFamily"
|
|
1676
|
+
}
|
|
1677
|
+
/** Where a page-style field's value comes from. */
|
|
1678
|
+
declare enum PageStyleSource {
|
|
1679
|
+
/** The page sets it itself. */
|
|
1680
|
+
Own = "own",
|
|
1681
|
+
/** A design-system page that sets nothing wears the look's token. */
|
|
1682
|
+
Look = "look",
|
|
1683
|
+
/** A page without the flag that sets nothing: the consumer's own default shows through. */
|
|
1684
|
+
Unset = "unset"
|
|
1685
|
+
}
|
|
1686
|
+
/** The token a design-system page falls back to, per field (spec tokens only). */
|
|
1687
|
+
declare const PAGE_STYLE_TOKENS: Readonly<Record<PageStyleField, string>>;
|
|
1688
|
+
/** The token fallbacks of a design-system page, as CSS declarations. */
|
|
1672
1689
|
declare const PAGE_TOKEN_FALLBACKS: Readonly<Record<string, string>>;
|
|
1690
|
+
/** One field as a panel shows it: where its value comes from and, when it is the look's, which token. */
|
|
1691
|
+
interface PageStyleFieldState {
|
|
1692
|
+
field: PageStyleField;
|
|
1693
|
+
source: PageStyleSource;
|
|
1694
|
+
/** The token the field falls back to — set only when `source` is `Look`. */
|
|
1695
|
+
token: string | null;
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Where each page-style field comes from — decided by the SAME compile the renderers run, so the
|
|
1699
|
+
* Page panel can never say "from the look" for a field the canvas paints from the page, or the other
|
|
1700
|
+
* way round. A flagged page's empty field is the look's token; an unflagged page's is `Unset`.
|
|
1701
|
+
*/
|
|
1702
|
+
declare function describePageStyle(style: Partial<PageStyleSettings> | null | undefined, options?: PageStyleOptions): Record<PageStyleField, PageStyleFieldState>;
|
|
1703
|
+
/** The style with one field handed back to the look: cleared to '', every other key kept. */
|
|
1704
|
+
declare function resetPageStyleField<T extends Partial<PageStyleSettings>>(style: T | null | undefined, field: PageStyleField): T;
|
|
1673
1705
|
/**
|
|
1674
1706
|
* Compile page style settings into CSS declarations.
|
|
1675
1707
|
*
|
|
@@ -2808,5 +2840,5 @@ declare class GalleryLoop {
|
|
|
2808
2840
|
private emit;
|
|
2809
2841
|
}
|
|
2810
2842
|
|
|
2811
|
-
export { AOSService, BACKDROP_TWIN_KEY, BACKDROP_WHOLE_KEY, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CONTAINER_HTML_TAGS, 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, HOVER_DEFAULT_TRANSITION, HOVER_STATES, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxDialogComponent, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, LEGACY_HOVER_TRANSITION, LinkedContainerComponent, PAGE_TOKEN_FALLBACKS, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PART_CLASS_NAMES, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, RESERVED_ALIAS_PREFIX, STATE_PSEUDO_CLASS, 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, containerHtmlTag, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, decomposeBackdropFilter, defaultHoverTransition, defaultMessageResolver, deriveSeoFromContent, describeSeoFields, effectiveTransitionId, excludeSelf, feelFor, getByPath, getSeoValue, hasHoverState, hasOwnTransition, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, isWholeBackdropSet, 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, statePseudoClass, stripSuppressedProps, variableAliases, variableItemsFrom, withContextSlots, withDefaultHoverTransition, wrapInBreakpointQuery, wrapInMediaQuery };
|
|
2812
|
-
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, PageStyleOptions, 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 };
|
|
2843
|
+
export { AOSService, BACKDROP_TWIN_KEY, BACKDROP_WHOLE_KEY, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CONTAINER_HTML_TAGS, 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, HOVER_DEFAULT_TRANSITION, HOVER_STATES, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxDialogComponent, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, LEGACY_HOVER_TRANSITION, LinkedContainerComponent, PAGE_STYLE_TOKENS, PAGE_TOKEN_FALLBACKS, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PART_CLASS_NAMES, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PageStyleField, PageStyleSource, RESERVED_ALIAS_PREFIX, STATE_PSEUDO_CLASS, 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, containerHtmlTag, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, decomposeBackdropFilter, defaultHoverTransition, defaultMessageResolver, deriveSeoFromContent, describePageStyle, describeSeoFields, effectiveTransitionId, excludeSelf, feelFor, getByPath, getSeoValue, hasHoverState, hasOwnTransition, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, isWholeBackdropSet, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, normalizeIconClass, parentBreakpoint, parseUrlList, partClassName, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resetPageStyleField, 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, statePseudoClass, stripSuppressedProps, variableAliases, variableItemsFrom, withContextSlots, withDefaultHoverTransition, wrapInBreakpointQuery, wrapInMediaQuery };
|
|
2844
|
+
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, PageStyleFieldState, PageStyleOptions, 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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AccentGround, atLightness, deriveAccentTokens, inSrgbGamut, oklabToRgb, parseHexColor, rgbToOklab, Rgb, toHexColor, wcagContrastRatio, WCAG_AA_TEXT,
|
|
3
|
+
} from './brand';
|
|
4
|
+
import { compositeOver, paintColours, quantizeRgb } from './colour-eval';
|
|
5
|
+
import { SPEC_AXES } from './flavor-spec.generated';
|
|
6
|
+
import { FlavorPalette, FlavorTintConfig, PRE_TINT_DEFAULT, TintId } from './design-token.model';
|
|
7
|
+
import { resolveTokenValues } from './token-css';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The whole colour system follows the brand (design-system DS9 — Doron, 2026-10-19: "I would imagine
|
|
11
|
+
* that once I change the colour of the brand, the whole colour system changes, but it doesn't").
|
|
12
|
+
*
|
|
13
|
+
* One brand colour in → every colour token of the look out, light and dark:
|
|
14
|
+
* - **Background tint** (`DesignChoices.tint`): Neutral is the look's own palette, untouched; Subtle and
|
|
15
|
+
* Rich give every neutral role the flavor lists the BRAND's hue — the role keeps its own OKLab
|
|
16
|
+
* lightness (so the look's light/dark structure and contrast survive) at the level's chroma (or its
|
|
17
|
+
* own, when the flavor coloured it more), scaled by how chromatic the brand is: a grey brand tints
|
|
18
|
+
* nothing. Material 3's neutral tonal palettes and Radix's tinted greys, as one table in the contract.
|
|
19
|
+
* A pure-white page is capped just under white (the level's `groundMaxL`) so it can carry the tint; the flavor's paints rotate
|
|
20
|
+
* with the brand (Glass's mesh); Midnight's glow already reads the accent.
|
|
21
|
+
* - **Accents**: the flavor's own quartet at its own seed, else derived from the brand ramp — and
|
|
22
|
+
* `accent-text` is the first step that reads on EVERY ground (bg, surface, surface-2, the page's paint).
|
|
23
|
+
* - **Held pairs**: every text and line role is pushed until it clears its pair again on its grounds.
|
|
24
|
+
* A no-op for the hand-tuned palette at its own seed — the generator's gate proves it.
|
|
25
|
+
*
|
|
26
|
+
* Port of iox-ai-guidance `design-system/colour.mjs` `flavorColours`; held equal to it by
|
|
27
|
+
* `flavor-fixtures.v1.json` (brand-neutrals.spec.ts). Change the maths THERE first.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** A seed below this OKLCH chroma tints at a proportionally lower strength. */
|
|
31
|
+
export const TINT_FULL_CHROMA = 0.08;
|
|
32
|
+
/** A neutral at least this chromatic was coloured on purpose: it keeps its hue offset from the flavor's seed. */
|
|
33
|
+
export const TINT_OWN_HUE_CHROMA = 0.01;
|
|
34
|
+
/** A brand below this chroma has no reliable hue: paints and coloured neutrals are not turned toward it. */
|
|
35
|
+
export const TINT_TURN_CHROMA = 0.02;
|
|
36
|
+
/** WCAG 1.4.11 — non-text contrast: UI boundaries, glyphs, focus indicators. */
|
|
37
|
+
export const WCAG_AA_UI = 3;
|
|
38
|
+
|
|
39
|
+
const PAGE = 'page';
|
|
40
|
+
const GROUND_ROLES: ReadonlySet<string> = new Set(['bg']);
|
|
41
|
+
const TEXT_GROUNDS: ReadonlyArray<string> = ['bg', 'surface', 'surface-2', PAGE];
|
|
42
|
+
const INVERSE_GROUNDS: ReadonlyArray<string> = ['inverse-bg', 'inverse-surface'];
|
|
43
|
+
const MODES = ['light', 'dark'] as const;
|
|
44
|
+
|
|
45
|
+
/** The roles a derivation holds to their pair, on the grounds named (`page` = the page paint's colours). */
|
|
46
|
+
export const HELD_CONTRAST: ReadonlyArray<readonly [string, ReadonlyArray<string>, number]> = [
|
|
47
|
+
['text', TEXT_GROUNDS, WCAG_AA_TEXT],
|
|
48
|
+
['text-muted', TEXT_GROUNDS, WCAG_AA_TEXT],
|
|
49
|
+
['line-strong', TEXT_GROUNDS, WCAG_AA_UI],
|
|
50
|
+
['danger', TEXT_GROUNDS, WCAG_AA_TEXT],
|
|
51
|
+
['success', TEXT_GROUNDS, WCAG_AA_TEXT],
|
|
52
|
+
['inverse-text', INVERSE_GROUNDS, WCAG_AA_TEXT],
|
|
53
|
+
['inverse-muted', INVERSE_GROUNDS, WCAG_AA_TEXT],
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/** One tint level of the contract (`axes.tints`). */
|
|
57
|
+
export interface TintLevel {
|
|
58
|
+
readonly label: string;
|
|
59
|
+
readonly chroma?: Readonly<Record<string, number>>;
|
|
60
|
+
readonly ownScale?: number;
|
|
61
|
+
readonly paintChroma?: number;
|
|
62
|
+
/** A pure-white page is capped just under white so it can carry any chroma (at L 1 only white exists). */
|
|
63
|
+
readonly groundMaxL?: number;
|
|
64
|
+
}
|
|
65
|
+
export const TINT_LEVELS: Readonly<Record<TintId, TintLevel>> = SPEC_AXES.tints;
|
|
66
|
+
|
|
67
|
+
/** What the colour derivation reads of a flavor. */
|
|
68
|
+
export interface ColourFlavor {
|
|
69
|
+
readonly seed: string;
|
|
70
|
+
readonly palette: FlavorPalette;
|
|
71
|
+
readonly tint: FlavorTintConfig;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every colour token of a look: `[light, dark]` per name. */
|
|
75
|
+
export type FlavorColours = Record<string, [string, string]>;
|
|
76
|
+
|
|
77
|
+
const upper = (hex: string) => hex.toUpperCase();
|
|
78
|
+
const passesAll = (fg: string, bgs: ReadonlyArray<string>, min: number) => bgs.every(bg => wcagContrastRatio(fg, bg) >= min);
|
|
79
|
+
|
|
80
|
+
/** The lightest/darkest version of `fg` (its own hue and chroma) that clears `min` on every ground. */
|
|
81
|
+
export function ensureContrast(fg: string, bgs: ReadonlyArray<string>, min: number): string {
|
|
82
|
+
if (!parseHexColor(fg) || bgs.some(b => !parseHexColor(b)) || passesAll(fg, bgs, min)) return fg;
|
|
83
|
+
const lab = rgbToOklab(parseHexColor(fg) as Rgb);
|
|
84
|
+
const meanBgL = bgs.reduce((sum, b) => sum + rgbToOklab(parseHexColor(b) as Rgb).L, 0) / bgs.length;
|
|
85
|
+
const darker = lab.L <= meanBgL;
|
|
86
|
+
const chroma = Math.hypot(lab.a, lab.b);
|
|
87
|
+
const ua = chroma > 1e-6 ? lab.a : 0;
|
|
88
|
+
const ub = chroma > 1e-6 ? lab.b : 1e-6;
|
|
89
|
+
let lo = darker ? 0 : lab.L;
|
|
90
|
+
let hi = darker ? lab.L : 1;
|
|
91
|
+
let best = darker ? '#000000' : '#ffffff';
|
|
92
|
+
for (let i = 0; i < 30; i++) {
|
|
93
|
+
const mid = (lo + hi) / 2;
|
|
94
|
+
const candidate = toHexColor(atLightness(mid, ua, ub, chroma));
|
|
95
|
+
if (passesAll(candidate, bgs, min)) {
|
|
96
|
+
best = candidate;
|
|
97
|
+
if (darker) lo = mid; else hi = mid;
|
|
98
|
+
} else if (darker) hi = mid; else lo = mid;
|
|
99
|
+
}
|
|
100
|
+
return best;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const RGBA = /^rgba\(\s*(\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\s*\)$/i;
|
|
104
|
+
const ALPHA_STEP = 0.01;
|
|
105
|
+
|
|
106
|
+
/** A translucent `fg` (rgba) made just opaque enough to clear `min` on every ground, in 0.01 steps. */
|
|
107
|
+
export function ensureAlpha(fg: string, bgs: ReadonlyArray<string>, min: number): string {
|
|
108
|
+
const m = RGBA.exec(String(fg ?? '').trim());
|
|
109
|
+
if (!m) return fg;
|
|
110
|
+
const [r, g, b] = [m[1], m[2], m[3]].map(Number);
|
|
111
|
+
const passes = (a: number) => bgs.every(bg => {
|
|
112
|
+
const ground = parseHexColor(bg) as Rgb;
|
|
113
|
+
return wcagContrastRatio(quantizeRgb(compositeOver({ r: r / 255, g: g / 255, b: b / 255, a }, ground)), ground) >= min;
|
|
114
|
+
});
|
|
115
|
+
let alpha = Number(m[4]);
|
|
116
|
+
if (passes(alpha)) return fg;
|
|
117
|
+
while (alpha < 1 && !passes(alpha)) alpha = Math.min(1, Math.round((alpha + ALPHA_STEP) * 100) / 100);
|
|
118
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The colour at (L, hue angle) with the most chroma ≤ `chroma` that fits sRGB (a tint never overshoots its
|
|
122
|
+
* level, and a stronger level never lands under a weaker one — `atLightness`'s ×0.82 steps can). */
|
|
123
|
+
export function atLightnessMaxChroma(L: number, hue: number, chroma: number): Rgb {
|
|
124
|
+
const ua = Math.cos(hue);
|
|
125
|
+
const ub = Math.sin(hue);
|
|
126
|
+
const at = (c: number) => oklabToRgb({ L, a: ua * c, b: ub * c });
|
|
127
|
+
if (inSrgbGamut(at(chroma))) return at(chroma);
|
|
128
|
+
let lo = 0;
|
|
129
|
+
let hi = chroma;
|
|
130
|
+
for (let i = 0; i < 24; i++) {
|
|
131
|
+
const mid = (lo + hi) / 2;
|
|
132
|
+
if (inSrgbGamut(at(mid))) lo = mid; else hi = mid;
|
|
133
|
+
}
|
|
134
|
+
return at(lo);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The seed's OKLab hue angle and chroma — a grey seed keeps a stable (arbitrary) axis. */
|
|
138
|
+
function seedHue(seed: string): { hue: number; chroma: number } {
|
|
139
|
+
const lab = rgbToOklab(parseHexColor(seed) as Rgb);
|
|
140
|
+
const chroma = Math.hypot(lab.a, lab.b);
|
|
141
|
+
const ua = chroma > 1e-6 ? lab.a : 0.0;
|
|
142
|
+
const ub = chroma > 1e-6 ? lab.b : 1e-6;
|
|
143
|
+
return { hue: Math.atan2(ub, ua), chroma };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const HEX_IN = /#[0-9a-f]{6}\b/gi;
|
|
147
|
+
|
|
148
|
+
/** Every hex colour inside a paint (a gradient's stops) turned by `angle` radians of OKLab hue. */
|
|
149
|
+
function rotatePaint(paint: string, angle: number, chromaScale: number): string {
|
|
150
|
+
return String(paint).replace(HEX_IN, hex => {
|
|
151
|
+
const lab = rgbToOklab(parseHexColor(hex) as Rgb);
|
|
152
|
+
const a = lab.a * Math.cos(angle) - lab.b * Math.sin(angle);
|
|
153
|
+
const b = lab.a * Math.sin(angle) + lab.b * Math.cos(angle);
|
|
154
|
+
return upper(toHexColor(atLightness(lab.L, a, b, Math.hypot(a, b) * chromaScale)));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The opaque colours a palette's named grounds show; `page` = every colour of the page's own paint. */
|
|
159
|
+
function groundsOf(p: Readonly<Record<string, string>>, names: ReadonlyArray<string>): string[] {
|
|
160
|
+
const out: string[] = [];
|
|
161
|
+
for (const name of names) {
|
|
162
|
+
if (name === PAGE) {
|
|
163
|
+
const resolved = resolveTokenValues(Object.entries(p).map(([n, value]) => ({ name: n, value })))['page-bg'];
|
|
164
|
+
for (const c of paintColours(resolved)) if (c.a >= 1) out.push(toHexColor(c));
|
|
165
|
+
} else if (parseHexColor(p[name])) out.push(p[name]);
|
|
166
|
+
}
|
|
167
|
+
return [...new Set(out.map(upper))];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function holdContrast(p: Record<string, string>): void {
|
|
171
|
+
for (const [fg, names, min] of HELD_CONTRAST) {
|
|
172
|
+
if (parseHexColor(p[fg])) p[fg] = upper(ensureContrast(p[fg], groundsOf(p, names), min));
|
|
173
|
+
else if (RGBA.test(String(p[fg] ?? '').trim())) p[fg] = ensureAlpha(p[fg], groundsOf(p, names), min);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function tintPalette(p: Record<string, string>, flavor: ColourFlavor, level: TintLevel, seed: string): void {
|
|
178
|
+
const { hue: brandHue, chroma } = seedHue(seed);
|
|
179
|
+
const strength = Math.min(1, chroma / TINT_FULL_CHROMA);
|
|
180
|
+
const turn = chroma >= TINT_TURN_CHROMA ? brandHue - seedHue(flavor.seed).hue : 0;
|
|
181
|
+
for (const role of flavor.tint.roles) {
|
|
182
|
+
const rgb = parseHexColor(p[role]);
|
|
183
|
+
if (!rgb) continue;
|
|
184
|
+
const lab = rgbToOklab(rgb);
|
|
185
|
+
const L = GROUND_ROLES.has(role) ? Math.min(lab.L, level.groundMaxL ?? 1) : lab.L;
|
|
186
|
+
const own = Math.hypot(lab.a, lab.b);
|
|
187
|
+
const hue = own >= TINT_OWN_HUE_CHROMA ? Math.atan2(lab.b, lab.a) + turn : brandHue;
|
|
188
|
+
const c = Math.max(own * (level.ownScale ?? 1), (level.chroma?.[role] ?? 0) * flavor.tint.scale) * strength;
|
|
189
|
+
p[role] = upper(toHexColor(atLightnessMaxChroma(L, hue, c)));
|
|
190
|
+
}
|
|
191
|
+
for (const name of flavor.tint.paints ?? []) p[name] = rotatePaint(p[name], turn, level.paintChroma ?? 1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The colour tokens of a flavor for a brand `seed` and a background `tint`, light and dark.
|
|
196
|
+
* Neutral at the flavor's own seed returns its palette exactly.
|
|
197
|
+
*/
|
|
198
|
+
export function flavorColours(flavor: ColourFlavor, choices: { seed?: string | null; tint?: TintId | null }): FlavorColours {
|
|
199
|
+
const seed = choices.seed || flavor.seed;
|
|
200
|
+
const level = TINT_LEVELS[choices.tint ?? PRE_TINT_DEFAULT];
|
|
201
|
+
const palette = {
|
|
202
|
+
light: { ...flavor.palette.light } as Record<string, string>,
|
|
203
|
+
dark: { ...flavor.palette.light, ...flavor.palette.dark } as Record<string, string>,
|
|
204
|
+
};
|
|
205
|
+
if (level?.chroma) for (const mode of MODES) tintPalette(palette[mode], flavor, level, seed);
|
|
206
|
+
|
|
207
|
+
if (seed.toLowerCase() !== flavor.seed.toLowerCase()) {
|
|
208
|
+
// Twice: the page paint may read the accent (Midnight's glow), so its grounds are known only once
|
|
209
|
+
// the fill is — and the fill never depends on the grounds.
|
|
210
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
211
|
+
const derived = deriveAccentTokens(seed, {
|
|
212
|
+
bg: groundsOf(palette.light, TEXT_GROUNDS), bgDark: groundsOf(palette.dark, TEXT_GROUNDS),
|
|
213
|
+
inverseBg: groundsOf(palette.light, INVERSE_GROUNDS), inverseBgDark: groundsOf(palette.dark, INVERSE_GROUNDS),
|
|
214
|
+
});
|
|
215
|
+
for (const t of derived) {
|
|
216
|
+
palette.light[t.name] = upper(t.value);
|
|
217
|
+
if (t.values?.dark) palette.dark[t.name] = upper(t.values.dark);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const mode of MODES) {
|
|
222
|
+
const p = palette[mode];
|
|
223
|
+
p['accent-text'] = upper(ensureContrast(p['accent-text'], groundsOf(p, TEXT_GROUNDS), WCAG_AA_TEXT));
|
|
224
|
+
p['inverse-accent'] = upper(ensureContrast(p['inverse-accent'], groundsOf(p, INVERSE_GROUNDS), WCAG_AA_TEXT));
|
|
225
|
+
holdContrast(p);
|
|
226
|
+
}
|
|
227
|
+
const out: FlavorColours = {};
|
|
228
|
+
for (const name of Object.keys(flavor.palette.light)) out[name] = [palette.light[name], palette.dark[name]];
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The grounds the PRE-DS9 accent derivation read: `bg` alone (light/dark), `inverse-bg` alone. */
|
|
233
|
+
export function preTintAccentGrounds(palette: FlavorPalette): { bg: AccentGround; bgDark?: AccentGround; inverseBg?: AccentGround; inverseBgDark?: AccentGround } {
|
|
234
|
+
const dark = { ...palette.light, ...palette.dark };
|
|
235
|
+
return { bg: palette.light['bg'] ?? '', bgDark: dark['bg'], inverseBg: palette.light['inverse-bg'], inverseBgDark: dark['inverse-bg'] };
|
|
236
|
+
}
|
package/src/design-core/brand.ts
CHANGED
|
@@ -57,10 +57,11 @@ export function oklabToRgb(c: OkLab): Rgb {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
const
|
|
60
|
+
export const inSrgbGamut = (c: Rgb) => [c.r, c.g, c.b].every(v => v >= -1e-6 && v <= 1 + 1e-6);
|
|
61
|
+
const inGamut = inSrgbGamut;
|
|
61
62
|
|
|
62
63
|
/** 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
|
+
export function atLightness(L: number, hueA: number, hueB: number, chroma: number): Rgb {
|
|
64
65
|
const len = Math.hypot(hueA, hueB) || 1;
|
|
65
66
|
const ua = hueA / len, ub = hueB / len;
|
|
66
67
|
let c = chroma;
|
|
@@ -231,19 +232,27 @@ export const ACCENT_TOKEN_NAMES = [
|
|
|
231
232
|
] as const;
|
|
232
233
|
export type AccentTokenName = (typeof ACCENT_TOKEN_NAMES)[number];
|
|
233
234
|
|
|
235
|
+
/** One ground, or every ground the accent must read on (DS9: the bg, the surfaces a scheme or a
|
|
236
|
+
* container puts under the same text, the page's own paint) — the FIRST decides direction. */
|
|
237
|
+
export type AccentGround = string | ReadonlyArray<string>;
|
|
238
|
+
|
|
234
239
|
/** The backgrounds the accent must read on, light and (optionally) dark. Without an inverse
|
|
235
240
|
* background the inverse pair is not derived — the pack's own stays. */
|
|
236
241
|
export interface AccentBackgrounds {
|
|
237
|
-
bg:
|
|
238
|
-
bgDark?:
|
|
239
|
-
inverseBg?:
|
|
240
|
-
inverseBgDark?:
|
|
242
|
+
bg: AccentGround;
|
|
243
|
+
bgDark?: AccentGround;
|
|
244
|
+
inverseBg?: AccentGround;
|
|
245
|
+
inverseBgDark?: AccentGround;
|
|
241
246
|
}
|
|
242
247
|
|
|
248
|
+
const groundList = (g: AccentGround | null | undefined): string[] =>
|
|
249
|
+
(g === undefined || g === null ? [] : typeof g === 'string' ? [g] : [...g]);
|
|
250
|
+
const hasGround = (g: AccentGround | null | undefined) => groundList(g).length > 0;
|
|
251
|
+
|
|
243
252
|
const RAMP_ORDER = RAMP_STEPS.map(s => s.step);
|
|
244
253
|
const WHITE_HEX = toHexColor(WHITE);
|
|
245
254
|
const BLACK_HEX = toHexColor(BLACK);
|
|
246
|
-
const passesText = (fg: string, bg:
|
|
255
|
+
const passesText = (fg: string, bg: AccentGround) => groundList(bg).every(g => wcagContrastRatio(fg, g) >= WCAG_AA_TEXT);
|
|
247
256
|
const bestInkHex = (bg: string) =>
|
|
248
257
|
(wcagContrastRatio(WHITE_HEX, bg) >= wcagContrastRatio(BLACK_HEX, bg) ? WHITE_HEX : BLACK_HEX);
|
|
249
258
|
|
|
@@ -279,7 +288,7 @@ export function deriveAccentTokens(
|
|
|
279
288
|
const candidate = RAMP_ORDER[i] !== undefined ? step(RAMP_ORDER[i]) : null;
|
|
280
289
|
return candidate && passesText(ink, candidate) ? candidate : step(accentStep);
|
|
281
290
|
};
|
|
282
|
-
const firstOn = (steps: number[], bg:
|
|
291
|
+
const firstOn = (steps: number[], bg: AccentGround) => step(steps.find(n => passesText(step(n), bg)) ?? steps[steps.length - 1]);
|
|
283
292
|
|
|
284
293
|
const ink = hexOf('brandInk');
|
|
285
294
|
const light = {
|
|
@@ -289,19 +298,20 @@ export function deriveAccentTokens(
|
|
|
289
298
|
text: firstOn([500, 600, 700, 800, 900], against.bg),
|
|
290
299
|
};
|
|
291
300
|
|
|
292
|
-
const dark = against.bgDark ? (() => {
|
|
301
|
+
const dark = hasGround(against.bgDark) ? (() => {
|
|
293
302
|
const darkStep = [300, 200, 400].find(n => passesText(bestInkHex(step(n)), step(n))) ?? 300;
|
|
294
303
|
const darkInk = bestInkHex(step(darkStep));
|
|
295
304
|
return {
|
|
296
305
|
accent: step(darkStep),
|
|
297
306
|
ink: darkInk,
|
|
298
307
|
hover: hoverFor(darkStep, darkInk),
|
|
299
|
-
text: firstOn([300, 200, 100, 50], against.bgDark),
|
|
308
|
+
text: firstOn([300, 200, 100, 50], against.bgDark as AccentGround),
|
|
300
309
|
};
|
|
301
310
|
})() : null;
|
|
302
311
|
|
|
303
|
-
const inverseOn = (inverseBg:
|
|
304
|
-
const
|
|
312
|
+
const inverseOn = (inverseBg: AccentGround) => {
|
|
313
|
+
const ground = groundList(inverseBg)[0];
|
|
314
|
+
const toward = wcagContrastRatio(step(RAMP_ORDER[0]), ground) >= wcagContrastRatio(step(RAMP_ORDER[RAMP_ORDER.length - 1]), ground)
|
|
305
315
|
? -1 : 1;
|
|
306
316
|
const walk = RAMP_ORDER.filter(n => (toward < 0 ? n <= 500 : n >= 500));
|
|
307
317
|
if (toward < 0) walk.reverse();
|
|
@@ -315,8 +325,8 @@ export function deriveAccentTokens(
|
|
|
315
325
|
const fill = step(walk[walk.length - 1]);
|
|
316
326
|
return { accent: fill, ink: passesText(inkOn(fill), fill) ? inkOn(fill) : bestInkHex(fill) };
|
|
317
327
|
};
|
|
318
|
-
const inverse = against.inverseBg ? inverseOn(against.inverseBg) : null;
|
|
319
|
-
const inverseDark = against.inverseBg && against.inverseBgDark ? inverseOn(against.inverseBgDark) : null;
|
|
328
|
+
const inverse = hasGround(against.inverseBg) ? inverseOn(against.inverseBg as AccentGround) : null;
|
|
329
|
+
const inverseDark = hasGround(against.inverseBg) && hasGround(against.inverseBgDark) ? inverseOn(against.inverseBgDark as AccentGround) : null;
|
|
320
330
|
|
|
321
331
|
const row = (name: AccentTokenName, value: string, darkValue?: string): FlavorTokenDef => ({
|
|
322
332
|
name, value, type: 'color', group: 'colour', tier: 'semantic',
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { parseHexColor, Rgb, toHexColor } from './brand';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a token VALUE paints, as colours (design-system DS9): the evaluator the contrast audit and the
|
|
5
|
+
* brand-tinted neutrals read a resolved CSS value with. It understands exactly the colour syntax the
|
|
6
|
+
* contract writes — `#rgb`/`#rrggbb`, `rgb()`/`rgba()`, `transparent`, `currentColor`/`inherit`,
|
|
7
|
+
* `color-mix(in srgb, A p%, B)` (premultiplied, as CSS mixes), and a gradient layer list (each stop
|
|
8
|
+
* over the final plain layer: the page's paint shows every one of those to a reader somewhere).
|
|
9
|
+
* Port of iox-ai-guidance `design-system/colour.mjs` — held equal by the derivation fixtures.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface Rgba extends Rgb { a: number; }
|
|
13
|
+
|
|
14
|
+
const TRANSPARENT: Rgba = { r: 0, g: 0, b: 0, a: 0 };
|
|
15
|
+
const NAMED: Readonly<Record<string, Rgba>> = {
|
|
16
|
+
transparent: TRANSPARENT,
|
|
17
|
+
white: { r: 1, g: 1, b: 1, a: 1 },
|
|
18
|
+
black: { r: 0, g: 0, b: 0, a: 1 },
|
|
19
|
+
};
|
|
20
|
+
const CURRENT_COLOR = new Set(['currentcolor', 'inherit']);
|
|
21
|
+
const RGB_FN = /^rgba?\(([^)]*)\)$/i;
|
|
22
|
+
const COLOR_MIX = /^color-mix\(\s*in srgb\s*,(.*)\)$/i;
|
|
23
|
+
const MIX_STOP = /^(.*?)(?:\s+(\d+(?:\.\d+)?)%)?$/;
|
|
24
|
+
const GRADIENT = /^(?:radial|linear|conic)-gradient\((.*)\)$/i;
|
|
25
|
+
const STOP_POSITION = /\s+-?\d+(?:\.\d+)?%(\s+-?\d+(?:\.\d+)?%)?$/;
|
|
26
|
+
const SHADOW_LAYER = /^(-?[\d.]+px|0)\s+(-?[\d.]+px|0)\s+(-?[\d.]+px|0)\s+(-?[\d.]+px|0)\s+(.+)$/;
|
|
27
|
+
|
|
28
|
+
/** Split on top-level commas (never inside parentheses). */
|
|
29
|
+
export function splitTopLevel(value: string, separator = ','): string[] {
|
|
30
|
+
const out: string[] = [];
|
|
31
|
+
let depth = 0;
|
|
32
|
+
let current = '';
|
|
33
|
+
for (const ch of String(value ?? '')) {
|
|
34
|
+
if (ch === '(') depth++;
|
|
35
|
+
if (ch === ')') depth--;
|
|
36
|
+
if (ch === separator && depth === 0) { out.push(current.trim()); current = ''; continue; }
|
|
37
|
+
current += ch;
|
|
38
|
+
}
|
|
39
|
+
if (current.trim()) out.push(current.trim());
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `top` composited over an opaque `under`. */
|
|
44
|
+
export function compositeOver(top: Rgba, under: Rgb): Rgba {
|
|
45
|
+
const a = top.a;
|
|
46
|
+
return { r: top.r * a + under.r * (1 - a), g: top.g * a + under.g * (1 - a), b: top.b * a + under.b * (1 - a), a: 1 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The 8-bit colour a screen shows — contrast is judged on WHAT SHIPS, as the derivation's grounds are. */
|
|
50
|
+
export const quantizeRgb = (c: Rgb): Rgb => parseHexColor(toHexColor(c)) as Rgb;
|
|
51
|
+
|
|
52
|
+
/** One CSS colour → RGBA, or null when it is not a colour this evaluator reads. `ink` = currentColor. */
|
|
53
|
+
export function parseCssColour(value: string | null | undefined, ink?: Rgba | null): Rgba | null {
|
|
54
|
+
const v = String(value ?? '').trim();
|
|
55
|
+
const low = v.toLowerCase();
|
|
56
|
+
if (low in NAMED) return NAMED[low];
|
|
57
|
+
if (CURRENT_COLOR.has(low)) return ink ?? null;
|
|
58
|
+
const hex = parseHexColor(v);
|
|
59
|
+
if (hex) return { ...hex, a: 1 };
|
|
60
|
+
const rgb = RGB_FN.exec(v);
|
|
61
|
+
if (rgb) {
|
|
62
|
+
const p = rgb[1].split(/[\s,/]+/).filter(Boolean).map(Number);
|
|
63
|
+
return { r: p[0] / 255, g: p[1] / 255, b: p[2] / 255, a: p[3] ?? 1 };
|
|
64
|
+
}
|
|
65
|
+
const mix = COLOR_MIX.exec(v);
|
|
66
|
+
if (mix) return mixColours(splitTopLevel(mix[1]), ink);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mixColours(parts: string[], ink: Rgba | null | undefined): Rgba | null {
|
|
71
|
+
const stop = (s: string) => {
|
|
72
|
+
const m = MIX_STOP.exec(s.trim()) as RegExpExecArray;
|
|
73
|
+
return { c: parseCssColour(m[1], ink), p: m[2] !== undefined ? Number(m[2]) / 100 : null };
|
|
74
|
+
};
|
|
75
|
+
const [x, y] = parts.map(stop);
|
|
76
|
+
if (!x?.c || !y?.c) return null;
|
|
77
|
+
const px = x.p ?? (y.p !== null ? 1 - y.p : 0.5);
|
|
78
|
+
const py = y.p ?? 1 - px;
|
|
79
|
+
const a = x.c.a * px + y.c.a * py;
|
|
80
|
+
if (a <= 0) return TRANSPARENT;
|
|
81
|
+
const channel = (k: keyof Rgb) => ((x.c as Rgba)[k] * (x.c as Rgba).a * px + (y.c as Rgba)[k] * (y.c as Rgba).a * py) / a;
|
|
82
|
+
return { r: channel('r'), g: channel('g'), b: channel('b'), a };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A paint → every colour a reader can meet ON it: a plain colour is one; a gradient layer list is each
|
|
87
|
+
* stop over the final plain layer, and that layer itself. The worst case is the one a check reads.
|
|
88
|
+
*/
|
|
89
|
+
export function paintColours(value: string | null | undefined, ink?: Rgba | null): Rgba[] {
|
|
90
|
+
const layers = splitTopLevel(String(value ?? ''));
|
|
91
|
+
if (!layers.length) return [];
|
|
92
|
+
const last = parseCssColour(layers[layers.length - 1], ink);
|
|
93
|
+
if (layers.length === 1) return last ? [last] : [];
|
|
94
|
+
if (!last) return [];
|
|
95
|
+
const ground: Rgba = { ...last, a: 1 };
|
|
96
|
+
const out: Rgba[] = [ground];
|
|
97
|
+
for (const layer of layers.slice(0, -1)) {
|
|
98
|
+
const g = GRADIENT.exec(layer);
|
|
99
|
+
if (!g) continue;
|
|
100
|
+
for (const part of splitTopLevel(g[1])) {
|
|
101
|
+
const colour = parseCssColour(part.replace(STOP_POSITION, ''), ink);
|
|
102
|
+
if (colour) out.push(compositeOver(colour, ground));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The outermost ring of a box-shadow list (`0 0 0 4px <colour>`) — a focus indicator's colour. */
|
|
109
|
+
export function ringColour(shadow: string | null | undefined, ink?: Rgba | null): Rgba | null {
|
|
110
|
+
const layers = splitTopLevel(String(shadow ?? ''))
|
|
111
|
+
.map(l => SHADOW_LAYER.exec(l.trim()))
|
|
112
|
+
.filter((m): m is RegExpExecArray => !!m)
|
|
113
|
+
.map(m => ({ spread: parseFloat(m[4]) || 0, colour: m[5] }));
|
|
114
|
+
if (!layers.length) return null;
|
|
115
|
+
const outer = layers.reduce((a, b) => (b.spread >= a.spread ? b : a));
|
|
116
|
+
return parseCssColour(outer.colour, ink);
|
|
117
|
+
}
|