@vectoriox/iox-ui 5.8.0 → 5.10.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/actions-core/index.d.ts +4 -1
- package/design-core/index.d.ts +9343 -0
- package/design-core/package.json +3 -0
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +23 -1
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +11325 -0
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -0
- package/fesm2022/vectoriox-iox-ui.mjs +11286 -311
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/index.d.ts +9251 -59
- package/package.json +5 -1
- package/src/actions-core/element-actions.ts +25 -0
- package/src/design-core/brand.ts +302 -0
- package/src/design-core/css-names.ts +4 -0
- package/src/design-core/design-token.model.ts +121 -0
- package/src/design-core/flavor-seed.ts +126 -0
- package/src/design-core/flavor-spec.generated.ts +10540 -0
- package/src/design-core/flavor-tokens.ts +99 -0
- package/src/design-core/flavor.ts +137 -0
- package/src/design-core/fonts.ts +88 -0
- package/src/design-core/public-api.ts +20 -0
- package/src/design-core/spring.ts +37 -0
- package/src/design-core/token-css.ts +183 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { deriveAccentTokens } from './brand';
|
|
2
|
+
import { cssString } from './fonts';
|
|
3
|
+
import { FLAVOR_SPEC } from './flavor-spec.generated';
|
|
4
|
+
import {
|
|
5
|
+
DesignChoices, DesignTokenRow, FlavorPackV2, FlavorSpecToken, flavorSourceStamp, USER_TOKEN_SOURCE,
|
|
6
|
+
} from './design-token.model';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A flavor's COMPLETE token set for a studio's choices (design-system plan §3a/§3e). A flavor IS
|
|
10
|
+
* its token values, so switching flavor — or any choice — rewrites token rows and nothing else.
|
|
11
|
+
*
|
|
12
|
+
* Layering, each step over the last (the boards' `tokenMap`, with the product's colour math):
|
|
13
|
+
* pack tokens → chosen font pair → shape · density · motion axes → pill-button rule →
|
|
14
|
+
* the author's own font roles → a seed ≠ the pack's own re-derives the accent quartet.
|
|
15
|
+
*
|
|
16
|
+
* `mode` does not change rows — every row keeps its light value and its dark override; mode is
|
|
17
|
+
* how the stylesheet is compiled.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const LABEL = 'label';
|
|
21
|
+
const BUTTON_RADIUS = 'button-radius';
|
|
22
|
+
const PILL_RADIUS = 'var(--iox-radius-pill)';
|
|
23
|
+
const CONTROL_RADIUS = 'var(--iox-radius-sm)';
|
|
24
|
+
const USER_FONT_FALLBACK = 'system-ui, sans-serif';
|
|
25
|
+
|
|
26
|
+
type AxisTable = Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
27
|
+
|
|
28
|
+
const axisValues = (table: AxisTable, id: string): Array<[string, string]> =>
|
|
29
|
+
Object.entries(table[id] ?? {}).filter(([k]) => k !== LABEL);
|
|
30
|
+
|
|
31
|
+
/** The pack's own defaults as a full set of choices — what Apply means with nothing changed. */
|
|
32
|
+
export function defaultDesignChoices(pack: FlavorPackV2): DesignChoices {
|
|
33
|
+
return {
|
|
34
|
+
flavorId: pack.id,
|
|
35
|
+
seed: pack.seed,
|
|
36
|
+
pairId: pack.pairs[0]?.id ?? '',
|
|
37
|
+
...pack.defaults,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `'<Family>', system-ui, sans-serif` — the value a font role holds once the author picks a face. */
|
|
42
|
+
export function userFontStack(family: string): string {
|
|
43
|
+
return `${cssString(family.trim())}, ${USER_FONT_FALLBACK}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const toRow = (t: FlavorSpecToken, source: string): DesignTokenRow => ({
|
|
47
|
+
name: t.name,
|
|
48
|
+
type: t.type,
|
|
49
|
+
value: t.value,
|
|
50
|
+
...(t.dark !== undefined ? { values: { dark: t.dark } } : {}),
|
|
51
|
+
source,
|
|
52
|
+
group: t.group,
|
|
53
|
+
tier: t.tier,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export function buildFlavorTokens(pack: FlavorPackV2, choices: DesignChoices): DesignTokenRow[] {
|
|
57
|
+
const stamp = flavorSourceStamp(pack.id);
|
|
58
|
+
const rows = pack.tokens.map(t => toRow(t, stamp));
|
|
59
|
+
const byName = new Map(rows.map(r => [r.name, r]));
|
|
60
|
+
|
|
61
|
+
/** An override is ONE value in every mode — a pair or axis value has no dark twin. */
|
|
62
|
+
const set = (name: string, value: string, source = stamp) => {
|
|
63
|
+
const row = byName.get(name);
|
|
64
|
+
if (!row) return;
|
|
65
|
+
row.value = value;
|
|
66
|
+
delete row.values;
|
|
67
|
+
row.source = source;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const pair = pack.pairs.find(p => p.id === choices.pairId) ?? pack.pairs[0];
|
|
71
|
+
for (const [name, value] of Object.entries(pair?.tokens ?? {})) set(name, value);
|
|
72
|
+
|
|
73
|
+
const axes = FLAVOR_SPEC.axes;
|
|
74
|
+
for (const [name, value] of [
|
|
75
|
+
...axisValues(axes.shapes, choices.shape),
|
|
76
|
+
...axisValues(axes.densities, choices.density),
|
|
77
|
+
...axisValues(axes.motions, choices.motion),
|
|
78
|
+
]) set(name, value);
|
|
79
|
+
|
|
80
|
+
const buttonRadius = pack.tokens.find(t => t.name === BUTTON_RADIUS);
|
|
81
|
+
set(BUTTON_RADIUS, choices.pillButtons ? PILL_RADIUS : (buttonRadius?.overridden ? buttonRadius.value : CONTROL_RADIUS));
|
|
82
|
+
|
|
83
|
+
for (const [role, family] of Object.entries(choices.fonts ?? {})) {
|
|
84
|
+
if (typeof family === 'string' && family.trim() !== '') set(role, userFontStack(family), USER_TOKEN_SOURCE);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (choices.seed && choices.seed.toLowerCase() !== pack.seed.toLowerCase()) {
|
|
88
|
+
const bg = byName.get('bg');
|
|
89
|
+
for (const accent of deriveAccentTokens(choices.seed, { bg: bg?.value ?? '', bgDark: bg?.values?.dark })) {
|
|
90
|
+
const row = byName.get(accent.name);
|
|
91
|
+
if (!row) continue;
|
|
92
|
+
row.value = accent.value;
|
|
93
|
+
if (accent.values?.dark) row.values = { dark: accent.values.dark };
|
|
94
|
+
else delete row.values;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { styleKeyToKebab } from './css-names';
|
|
2
|
+
import { DesignTokenGroup, DesignTokenTier, DesignTokenType } from './design-token.model';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Flavors (flavors-and-theming-plan.md, F1): the design language a studio launches with,
|
|
6
|
+
* expressed as data. THE invariant — a flavor never writes a literal into a node; it writes
|
|
7
|
+
* tokens, and element defaults that REFERENCE tokens. To that end a pack's `defaults` map
|
|
8
|
+
* holds TOKEN NAMES, not CSS values: the resolver builds `var(--iox-<name>, <packValue>)`,
|
|
9
|
+
* so smuggling a literal in is impossible by construction, and the validator only has to
|
|
10
|
+
* check that every referenced token exists.
|
|
11
|
+
*
|
|
12
|
+
* The fallback is the bootstrap: until F2 seeds the org's `designtokens` rows, no `--iox-*`
|
|
13
|
+
* var is defined anywhere, and `var(--iox-radius-control, 4px)` must still paint identically
|
|
14
|
+
* in the builder canvas and the SSR page. Once tokens ARE seeded (or re-themed), the var wins
|
|
15
|
+
* over the fallback everywhere — which is exactly the §3 propagation story.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface FlavorTokenDef {
|
|
19
|
+
name: string;
|
|
20
|
+
value: string;
|
|
21
|
+
/** Dark-scheme override, seeded into DesignToken.values.dark at F2. */
|
|
22
|
+
values?: { dark?: string };
|
|
23
|
+
type?: DesignTokenType;
|
|
24
|
+
group?: DesignTokenGroup;
|
|
25
|
+
tier?: DesignTokenTier;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FlavorPack {
|
|
29
|
+
id: string;
|
|
30
|
+
/** Bumped when the pack's values change; seeding stamps `flavor:<id>@<version>` as DesignToken.source. */
|
|
31
|
+
version: number;
|
|
32
|
+
label: string;
|
|
33
|
+
blurb: string;
|
|
34
|
+
tokens: FlavorTokenDef[];
|
|
35
|
+
/** type → styleTrait → TOKEN NAME (never a CSS value — see the module header). */
|
|
36
|
+
defaults: Record<string, Record<string, string>>;
|
|
37
|
+
/** F2: the accent's hue rotation (degrees in OKLab) from the brand seed — a pack opinion. */
|
|
38
|
+
accentHueDeg?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** trait → `var(--iox-<token>, <packValue>)` for one node type. Unknown type → {}. */
|
|
42
|
+
export function resolveFlavorDefaults(
|
|
43
|
+
type: string,
|
|
44
|
+
pack: FlavorPack | null | undefined,
|
|
45
|
+
): Record<string, string> {
|
|
46
|
+
const map = pack?.defaults?.[type];
|
|
47
|
+
if (!map) return {};
|
|
48
|
+
const byName = new Map((pack!.tokens ?? []).map(t => [t.name, t.value]));
|
|
49
|
+
const out: Record<string, string> = {};
|
|
50
|
+
for (const [trait, tokenName] of Object.entries(map)) {
|
|
51
|
+
const fallback = byName.get(tokenName);
|
|
52
|
+
if (fallback === undefined) continue; // validator fails the build; runtime stays safe
|
|
53
|
+
out[trait] = `var(--iox-${tokenName}, ${fallback})`;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The ENGINE's half of the cascade: kebab `prop: var(…)` declarations for every pack default
|
|
60
|
+
* the node's persisted styleProps does NOT set. Appended beside renderDefaultDeclarations —
|
|
61
|
+
* same guard-on-undefined discipline, so a node saved post-F1 (var already serialised via the
|
|
62
|
+
* builder's trait defaults) is untouched, and a legacy node picks the pack default up at
|
|
63
|
+
* render time. Node styleProps always wins.
|
|
64
|
+
*/
|
|
65
|
+
export function flavorDefaultDeclarations(
|
|
66
|
+
type: string,
|
|
67
|
+
styleProps: Record<string, any> | null | undefined,
|
|
68
|
+
pack: FlavorPack | null | undefined,
|
|
69
|
+
): string[] {
|
|
70
|
+
const sp = styleProps ?? {};
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
for (const [trait, value] of Object.entries(resolveFlavorDefaults(type, pack))) {
|
|
73
|
+
if (sp[trait] === undefined) out.push(`${styleKeyToKebab(trait)}: ${value}`);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The CI gate (flavors plan §10 C4): a pack may only name types and traits that exist in the
|
|
80
|
+
* live component registry, and tokens it defines itself. Returns human-readable errors; a
|
|
81
|
+
* consumer spec feeds it the REAL registry and asserts [].
|
|
82
|
+
*/
|
|
83
|
+
export function validateFlavorPack(
|
|
84
|
+
pack: FlavorPack,
|
|
85
|
+
knownTraitsByType: Record<string, ReadonlySet<string> | string[]>,
|
|
86
|
+
): string[] {
|
|
87
|
+
const errors: string[] = [];
|
|
88
|
+
const tokenNames = new Set((pack.tokens ?? []).map(t => t.name));
|
|
89
|
+
for (const t of pack.tokens ?? []) {
|
|
90
|
+
if (!t.name) errors.push(`${pack.id}: token with empty name`);
|
|
91
|
+
if (!t.value) errors.push(`${pack.id}: token "${t.name}" has no value`);
|
|
92
|
+
}
|
|
93
|
+
for (const [type, traits] of Object.entries(pack.defaults ?? {})) {
|
|
94
|
+
const known = knownTraitsByType[type];
|
|
95
|
+
if (!known) { errors.push(`${pack.id}: unknown component type "${type}"`); continue; }
|
|
96
|
+
const knownSet = known instanceof Set ? known : new Set(known);
|
|
97
|
+
for (const [trait, tokenName] of Object.entries(traits)) {
|
|
98
|
+
if (!knownSet.has(trait)) errors.push(`${pack.id}: "${type}" has no style trait "${trait}"`);
|
|
99
|
+
if (!tokenNames.has(tokenName)) errors.push(`${pack.id}: "${type}.${trait}" references undefined token "${tokenName}"`);
|
|
100
|
+
if (/[:(]|^#|\d/.test(tokenName)) errors.push(`${pack.id}: "${type}.${trait}" must name a token, not a value ("${tokenName}")`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return errors;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Swiss — the default pack (flavors plan §8: "the flavor that can never embarrass anyone").
|
|
108
|
+
* Deliberately SMALL at F1: radius + control padding on the clickables, surface radius on Card.
|
|
109
|
+
* Colour, type and motion arrive with the brand step (F2) — a colour default here would repaint
|
|
110
|
+
* legacy pages before anyone chose anything.
|
|
111
|
+
*/
|
|
112
|
+
export const SWISS_FLAVOR: FlavorPack = {
|
|
113
|
+
id: 'swiss',
|
|
114
|
+
version: 1,
|
|
115
|
+
label: 'Swiss',
|
|
116
|
+
blurb: 'Grid discipline, hairline rules, near-zero motion.',
|
|
117
|
+
tokens: [
|
|
118
|
+
{ name: 'radius-control', value: '4px', type: 'size', group: 'shape' },
|
|
119
|
+
{ name: 'radius-surface', value: '4px', type: 'size', group: 'shape' },
|
|
120
|
+
{ name: 'space-control', value: '10px 18px', type: 'spacing', group: 'space' },
|
|
121
|
+
{ name: 'space-stack', value: '12px', type: 'spacing', group: 'space' },
|
|
122
|
+
],
|
|
123
|
+
defaults: {
|
|
124
|
+
Button: { borderRadius: 'radius-control', padding: 'space-control' },
|
|
125
|
+
ButtonBlock: { borderRadius: 'radius-control', padding: 'space-control' },
|
|
126
|
+
Card: { borderRadius: 'radius-surface' },
|
|
127
|
+
},
|
|
128
|
+
// F2: Swiss turns the seed toward a cool counterpoint. NOTE deliberately: the brand tokens
|
|
129
|
+
// deriveBrandTokens() emits are SEEDED per org — they do not join `defaults` here, because a
|
|
130
|
+
// colour default repaints legacy nodes whose saved styleProps lack the key (the engine fills
|
|
131
|
+
// missing keys from config defaults). Coloured element defaults are a Swiss v2 upgrade
|
|
132
|
+
// decision (§10 C2), taken with the picker in front of the author — never a side effect.
|
|
133
|
+
accentHueDeg: 150,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** What both renderers use until F2 lets an org choose. */
|
|
137
|
+
export const DEFAULT_FLAVOR: FlavorPack = SWISS_FLAVOR;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { FLAVOR_SPEC } from './flavor-spec.generated';
|
|
2
|
+
import { DesignTokenLike } from './token-css';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fonts (design-system plan §5b, D7): ONE Google catalog for both renderers, and ONE face builder
|
|
6
|
+
* for the families a studio uploads.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Family name → Google Fonts css2 `family=` id. Replaces the engine's private GOOGLE_FONTS map. */
|
|
10
|
+
export const FONT_CATALOG: Readonly<Record<string, string>> = FLAVOR_SPEC.fonts;
|
|
11
|
+
|
|
12
|
+
export type FontFormat = 'woff2' | 'woff' | 'truetype' | 'opentype';
|
|
13
|
+
export type FontStyle = 'normal' | 'italic';
|
|
14
|
+
|
|
15
|
+
/** One uploaded font FILE (spec `fontDescriptor`) — a family is all descriptors sharing `family`. */
|
|
16
|
+
export interface FontDescriptor {
|
|
17
|
+
family: string;
|
|
18
|
+
/** 100–900, from the file's OS/2 `usWeightClass`. */
|
|
19
|
+
weight: number;
|
|
20
|
+
style: FontStyle;
|
|
21
|
+
url: string;
|
|
22
|
+
format: FontFormat;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The token roles whose values name the families a page renders in. */
|
|
26
|
+
export const FONT_FAMILY_TOKENS = ['font-display', 'font-body', 'font-label', 'font-mono'] as const;
|
|
27
|
+
|
|
28
|
+
const DEFAULT_WEIGHT = 400;
|
|
29
|
+
const MIN_WEIGHT = 100;
|
|
30
|
+
const MAX_WEIGHT = 900;
|
|
31
|
+
const FONT_FORMATS: ReadonlyArray<FontFormat> = ['woff2', 'woff', 'truetype', 'opentype'];
|
|
32
|
+
|
|
33
|
+
/** A CSS string literal, single-quoted, with `\` and `'` escaped and line breaks dropped. */
|
|
34
|
+
export function cssString(value: string): string {
|
|
35
|
+
return `'${String(value).replace(/[\r\n\f]/g, '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const clampWeight = (w: unknown): number => {
|
|
39
|
+
const n = Math.round(Number(w));
|
|
40
|
+
return Number.isFinite(n) && n > 0 ? Math.min(MAX_WEIGHT, Math.max(MIN_WEIGHT, n)) : DEFAULT_WEIGHT;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** One `@font-face` per file, carrying its real weight and style — so bold is never synthesised. */
|
|
44
|
+
export function buildFontFaceCss(descriptors: ReadonlyArray<FontDescriptor> | null | undefined): string {
|
|
45
|
+
return (descriptors ?? [])
|
|
46
|
+
.filter(d => !!d && typeof d.family === 'string' && d.family.trim() !== '' && typeof d.url === 'string' && d.url !== '')
|
|
47
|
+
.map(d => {
|
|
48
|
+
const format = FONT_FORMATS.includes(d.format) ? ` format(${cssString(d.format)})` : '';
|
|
49
|
+
return [
|
|
50
|
+
'@font-face {',
|
|
51
|
+
` font-family: ${cssString(d.family.trim())};`,
|
|
52
|
+
` src: url(${cssString(d.url)})${format};`,
|
|
53
|
+
` font-weight: ${clampWeight(d.weight)};`,
|
|
54
|
+
` font-style: ${d.style === 'italic' ? 'italic' : 'normal'};`,
|
|
55
|
+
' font-display: swap;',
|
|
56
|
+
'}',
|
|
57
|
+
].join('\n');
|
|
58
|
+
})
|
|
59
|
+
.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The family names in a CSS `font-family` stack, unquoted, in order. */
|
|
63
|
+
export function parseFontFamilies(stack: string): string[] {
|
|
64
|
+
return String(stack ?? '')
|
|
65
|
+
.split(',')
|
|
66
|
+
.map(f => f.trim().replace(/^(['"])(.*)\1$/, '$2').trim())
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The Google css2 ids to load for a token set: every catalog family named by a font role token,
|
|
72
|
+
* one id per family (T7 — one `<link>` per family, so one bad id cannot sink the others).
|
|
73
|
+
*/
|
|
74
|
+
export function fontsForTokens(tokens: ReadonlyArray<DesignTokenLike> | null | undefined): string[] {
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
const ids: string[] = [];
|
|
77
|
+
for (const role of FONT_FAMILY_TOKENS) {
|
|
78
|
+
const row = (tokens ?? []).find(t => t?.name === role);
|
|
79
|
+
for (const family of parseFontFamilies(row?.value ?? '')) {
|
|
80
|
+
const id = FONT_CATALOG[family];
|
|
81
|
+
if (id && !seen.has(family)) {
|
|
82
|
+
seen.add(family);
|
|
83
|
+
ids.push(id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return ids;
|
|
88
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vectoriox/iox-ui/design-core — the shared, FRAMEWORK-FREE core of the design system
|
|
3
|
+
* (design-system plan §3e): the token vocabulary, the flavor contract (generated 1:1 from
|
|
4
|
+
* iox-ai-guidance `flavor-spec.v1.json`), `buildFlavorTokens`, the token stylesheet compiler, the
|
|
5
|
+
* spring easing, the font catalog and face builder, and the seed/revert planners.
|
|
6
|
+
* Consumed by the builder, the SSR engine AND iox-cms (the server-side site build) — so nothing
|
|
7
|
+
* here may import Angular. The main entry re-exports all of it, so `@vectoriox/iox-ui` keeps working.
|
|
8
|
+
* ⚠️ Consumers wire the usual trio — ng-package raw-ts assets, jest moduleNameMapper, tsconfig
|
|
9
|
+
* paths — or `nest start` dies with ERR_REQUIRE_ESM while jest and build both pass.
|
|
10
|
+
*/
|
|
11
|
+
export * from './flavor-spec.generated';
|
|
12
|
+
export * from './design-token.model';
|
|
13
|
+
export * from './css-names';
|
|
14
|
+
export * from './token-css';
|
|
15
|
+
export * from './flavor';
|
|
16
|
+
export * from './brand';
|
|
17
|
+
export * from './flavor-tokens';
|
|
18
|
+
export * from './flavor-seed';
|
|
19
|
+
export * from './spring';
|
|
20
|
+
export * from './fonts';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A damped spring as a CSS `linear()` easing (design-system plan §3e) — the algorithm the spec's
|
|
3
|
+
* generator (`build-spec.mjs` `spring()`) uses to write `springs.lively`, so a spring computed at
|
|
4
|
+
* runtime is byte-identical to the one in the contract.
|
|
5
|
+
*
|
|
6
|
+
* `stiffness` is k (ω₀ = √k), `damping` the ratio ζ (< 1 under-damped, ≥ 1 critically damped).
|
|
7
|
+
* Duration = the last instant the curve is more than 0.1% away from rest, rounded up to 10ms.
|
|
8
|
+
*/
|
|
9
|
+
const SETTLE_TOLERANCE = 0.001;
|
|
10
|
+
const SETTLE_HORIZON_S = 3;
|
|
11
|
+
const SETTLE_STEP_S = 0.001;
|
|
12
|
+
const SAMPLES = 40;
|
|
13
|
+
const DECIMALS = 4;
|
|
14
|
+
const ROUND_TO_MS = 10;
|
|
15
|
+
|
|
16
|
+
export interface LinearSpring {
|
|
17
|
+
easing: string;
|
|
18
|
+
duration: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function springToLinear(stiffness: number, damping: number): LinearSpring {
|
|
22
|
+
const w0 = Math.sqrt(stiffness);
|
|
23
|
+
const x = (t: number): number => {
|
|
24
|
+
if (damping < 1) {
|
|
25
|
+
const wd = w0 * Math.sqrt(1 - damping * damping);
|
|
26
|
+
return 1 - Math.exp(-damping * w0 * t) * (Math.cos(wd * t) + ((damping * w0) / wd) * Math.sin(wd * t));
|
|
27
|
+
}
|
|
28
|
+
return 1 - Math.exp(-w0 * t) * (1 + w0 * t);
|
|
29
|
+
};
|
|
30
|
+
let settle = 0;
|
|
31
|
+
for (let t = 0; t < SETTLE_HORIZON_S; t += SETTLE_STEP_S) if (Math.abs(1 - x(t)) > SETTLE_TOLERANCE) settle = t;
|
|
32
|
+
const durationMs = Math.ceil((settle * 1000) / ROUND_TO_MS) * ROUND_TO_MS;
|
|
33
|
+
const points: number[] = [];
|
|
34
|
+
for (let i = 0; i <= SAMPLES; i++) points.push(+x((settle * i) / SAMPLES).toFixed(DECIMALS));
|
|
35
|
+
points[SAMPLES] = 1;
|
|
36
|
+
return { easing: `linear(${points.join(', ')})`, duration: `${durationMs}ms` };
|
|
37
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { FLAVOR_SPEC } from './flavor-spec.generated';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Design-token → CSS compilation, shared by BOTH renderers (flavors F0, design-system DS1).
|
|
5
|
+
*
|
|
6
|
+
* A token row is `{ name, value }` plus an optional per-scheme override `values.dark`.
|
|
7
|
+
* A value that references another token (`var(--iox-surface)`) passes through verbatim.
|
|
8
|
+
*
|
|
9
|
+
* Block order (design-system plan §3e, T8):
|
|
10
|
+
* 1. base `:root { --iox-*: … }`
|
|
11
|
+
* 2. schemes `.iox-scheme-<id> { overrides; every var()-referencing token re-declared }`
|
|
12
|
+
* 3. reduced motion `@media (prefers-reduced-motion: reduce)`
|
|
13
|
+
* 4. reduced transparency `@media (prefers-reduced-transparency: reduce)`
|
|
14
|
+
* 5. dark — LAST `@media (prefers-color-scheme: dark)`
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ The appended-last contract: the dark block REDEFINES custom properties, so every consumer
|
|
17
|
+
* appends it AFTER all of its base rules — the engine pushes it after the page's css blocks, the
|
|
18
|
+
* builder registry keeps it as its last token rule (responsive A3 lesson).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The transport-agnostic token row. `source` marks seeded rows (`flavor:<id>@<ver>`). */
|
|
22
|
+
export interface DesignTokenLike {
|
|
23
|
+
name: string;
|
|
24
|
+
value: string;
|
|
25
|
+
/** Per-scheme overrides. Only `dark` exists today; absent means the base value everywhere. */
|
|
26
|
+
values?: { dark?: string };
|
|
27
|
+
type?: string;
|
|
28
|
+
source?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TokenCssOptions {
|
|
32
|
+
/** Selector that carries the variables instead of `:root` — a preview wrapper, a specimen. */
|
|
33
|
+
scope?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TokenCssBlocks {
|
|
37
|
+
/** Base, schemes and the two accessibility blocks — emitted BEFORE node rules. */
|
|
38
|
+
base: string;
|
|
39
|
+
/** The prefers-color-scheme block(s) — emitted AFTER every base rule. */
|
|
40
|
+
dark: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What `prefers-reduced-motion: reduce` collapses — movement and duration, never colour. */
|
|
44
|
+
export const REDUCED_MOTION_OVERRIDES: Readonly<Record<string, string>> = {
|
|
45
|
+
'dur-fast': '0ms',
|
|
46
|
+
'dur-base': '0ms',
|
|
47
|
+
'dur-slow': '0ms',
|
|
48
|
+
'hover-lift': '0px',
|
|
49
|
+
'button-hover-x': '0px',
|
|
50
|
+
'button-hover-y': '0px',
|
|
51
|
+
'card-hover-x': '0px',
|
|
52
|
+
'card-lift': '0px',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** What `prefers-reduced-transparency: reduce` makes solid — every glass surface, in one block. */
|
|
56
|
+
export const REDUCED_TRANSPARENCY_OVERRIDES: Readonly<Record<string, string>> = {
|
|
57
|
+
'surface-glass': 'var(--iox-surface)',
|
|
58
|
+
'surface-glass-strong': 'var(--iox-surface)',
|
|
59
|
+
'blur-surface': 'none',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const ROOT = ':root';
|
|
63
|
+
const DEFAULT_SCHEME = 'default';
|
|
64
|
+
const REFERENCE = 'var(';
|
|
65
|
+
const MEDIA_DARK = '(prefers-color-scheme: dark)';
|
|
66
|
+
const ACCESSIBILITY_MEDIA: ReadonlyArray<{ query: string; overrides: Readonly<Record<string, string>> }> = [
|
|
67
|
+
{ query: '(prefers-reduced-motion: reduce)', overrides: REDUCED_MOTION_OVERRIDES },
|
|
68
|
+
{ query: '(prefers-reduced-transparency: reduce)', overrides: REDUCED_TRANSPARENCY_OVERRIDES },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const usable = (t: DesignTokenLike | null | undefined): t is DesignTokenLike =>
|
|
72
|
+
!!t && typeof t.name === 'string' && t.name !== '' &&
|
|
73
|
+
typeof t.value === 'string' && t.value !== '';
|
|
74
|
+
|
|
75
|
+
const darkOf = (t: DesignTokenLike): string | null => {
|
|
76
|
+
const dark = t.values?.dark;
|
|
77
|
+
return typeof dark === 'string' && dark !== '' ? dark : null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const usableTokens = (tokens: ReadonlyArray<DesignTokenLike> | null | undefined): DesignTokenLike[] =>
|
|
81
|
+
(tokens ?? []).filter(usable);
|
|
82
|
+
|
|
83
|
+
const declarations = (entries: ReadonlyArray<[string, string]>, indent: string): string =>
|
|
84
|
+
entries.map(([name, value]) => `${indent}--iox-${name}: ${value};`).join('\n');
|
|
85
|
+
|
|
86
|
+
const rule = (selector: string, entries: ReadonlyArray<[string, string]>, indent = ''): string =>
|
|
87
|
+
entries.length ? `${indent}${selector} {\n${declarations(entries, indent + ' ')}\n${indent}}` : '';
|
|
88
|
+
|
|
89
|
+
const media = (query: string, rules: ReadonlyArray<string>): string => {
|
|
90
|
+
const body = rules.filter(Boolean);
|
|
91
|
+
return body.length ? `@media ${query} {\n${body.join('\n')}\n}` : '';
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const schemeSelector = (id: string, scope?: string) =>
|
|
95
|
+
scope ? `${scope} .iox-scheme-${id}` : `.iox-scheme-${id}`;
|
|
96
|
+
|
|
97
|
+
const nonDefaultSchemes = (): ReadonlyArray<[string, Readonly<Record<string, string>>]> =>
|
|
98
|
+
Object.entries(FLAVOR_SPEC.schemes)
|
|
99
|
+
.filter(([id]) => id !== DEFAULT_SCHEME)
|
|
100
|
+
.map(([id, scheme]) => [id, scheme.set as Readonly<Record<string, string>>]);
|
|
101
|
+
|
|
102
|
+
/** `{ 'iox-brand': '#2f6f62', … }` — the builder registry's input shape for base values. */
|
|
103
|
+
export function tokensToVarMap(tokens: ReadonlyArray<DesignTokenLike> | null | undefined): Record<string, string> {
|
|
104
|
+
const map: Record<string, string> = {};
|
|
105
|
+
for (const t of usableTokens(tokens)) map[`iox-${t.name}`] = t.value;
|
|
106
|
+
return map;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Only the tokens that carry a dark override, as `{ 'iox-name': darkValue }`. */
|
|
110
|
+
export function tokensToDarkVarMap(tokens: ReadonlyArray<DesignTokenLike> | null | undefined): Record<string, string> {
|
|
111
|
+
const map: Record<string, string> = {};
|
|
112
|
+
for (const t of usableTokens(tokens)) {
|
|
113
|
+
const dark = darkOf(t);
|
|
114
|
+
if (dark !== null) map[`iox-${t.name}`] = dark;
|
|
115
|
+
}
|
|
116
|
+
return map;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Section colour schemes (design-system plan §3c). ⚠️ Custom properties resolve where they are
|
|
121
|
+
* DECLARED: `--iox-card-bg: var(--iox-surface)` declared on `:root` keeps `:root`'s surface
|
|
122
|
+
* inside `.iox-scheme-inverse`. So each scheme block carries the spec's overrides PLUS every other
|
|
123
|
+
* token whose value references a token, re-declared verbatim — re-resolving it in the section.
|
|
124
|
+
*/
|
|
125
|
+
export function compileSchemeCss(tokens: ReadonlyArray<DesignTokenLike> | null | undefined, scope?: string): string {
|
|
126
|
+
const rows = usableTokens(tokens);
|
|
127
|
+
if (!rows.length) return '';
|
|
128
|
+
return nonDefaultSchemes().map(([id, set]) => rule(schemeSelector(id, scope), [
|
|
129
|
+
...Object.entries(set),
|
|
130
|
+
...rows.filter(t => !(t.name in set) && t.value.includes(REFERENCE))
|
|
131
|
+
.map((t): [string, string] => [t.name, t.value]),
|
|
132
|
+
])).filter(Boolean).join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The dark twin of the re-declaration rule: a re-declared token with a dark value would
|
|
136
|
+
* otherwise pin the LIGHT value inside the section once the visitor is in dark mode. */
|
|
137
|
+
function darkSchemeRules(rows: ReadonlyArray<DesignTokenLike>, scope: string | undefined): string[] {
|
|
138
|
+
const redeclared = rows.filter(t => {
|
|
139
|
+
const dark = darkOf(t);
|
|
140
|
+
return dark !== null && (t.value.includes(REFERENCE) || dark.includes(REFERENCE));
|
|
141
|
+
});
|
|
142
|
+
return nonDefaultSchemes().map(([id, set]) => rule(schemeSelector(id, scope), redeclared
|
|
143
|
+
.filter(t => !(t.name in set))
|
|
144
|
+
.map((t): [string, string] => [t.name, darkOf(t) as string]), ' '));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function presentOverrides(rows: ReadonlyArray<DesignTokenLike>, overrides: Readonly<Record<string, string>>,
|
|
148
|
+
onlyWithDark: boolean): Array<[string, string]> {
|
|
149
|
+
return Object.entries(overrides).filter(([name]) => rows.some(t => t.name === name && (!onlyWithDark || darkOf(t) !== null)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The token CSS as the two halves a renderer places around its node rules. */
|
|
153
|
+
export function compileTokenCssBlocks(
|
|
154
|
+
tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
|
|
155
|
+
opts: TokenCssOptions = {},
|
|
156
|
+
): TokenCssBlocks {
|
|
157
|
+
const rows = usableTokens(tokens);
|
|
158
|
+
const root = opts.scope ?? ROOT;
|
|
159
|
+
const base = [
|
|
160
|
+
rule(root, rows.map((t): [string, string] => [t.name, t.value])),
|
|
161
|
+
compileSchemeCss(rows, opts.scope),
|
|
162
|
+
...ACCESSIBILITY_MEDIA.map(({ query, overrides }) =>
|
|
163
|
+
media(query, [rule(root, presentOverrides(rows, overrides, false), ' ')])),
|
|
164
|
+
].filter(Boolean).join('\n');
|
|
165
|
+
|
|
166
|
+
const darkRows = rows.map((t): [string, string | null] => [t.name, darkOf(t)])
|
|
167
|
+
.filter((e): e is [string, string] => e[1] !== null);
|
|
168
|
+
const dark = darkRows.length ? [
|
|
169
|
+
media(MEDIA_DARK, [rule(root, darkRows, ' '), ...darkSchemeRules(rows, opts.scope)]),
|
|
170
|
+
// Glass carries dark glass values; without this the dark block (LAST) would out-rank the
|
|
171
|
+
// reduced-transparency swap and keep dark surfaces translucent.
|
|
172
|
+
...ACCESSIBILITY_MEDIA.map(({ query, overrides }) =>
|
|
173
|
+
media(`${MEDIA_DARK} and ${query}`, [rule(root, presentOverrides(rows, overrides, true), ' ')])),
|
|
174
|
+
].filter(Boolean).join('\n') : '';
|
|
175
|
+
|
|
176
|
+
return { base, dark };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One stylesheet string: base → schemes → reduced motion → reduced transparency → dark LAST. */
|
|
180
|
+
export function compileTokenCss(tokens: ReadonlyArray<DesignTokenLike> | null | undefined, opts: TokenCssOptions = {}): string {
|
|
181
|
+
const { base, dark } = compileTokenCssBlocks(tokens, opts);
|
|
182
|
+
return [base, dark].filter(Boolean).join('\n');
|
|
183
|
+
}
|