@vectoriox/iox-ui 5.14.0 → 5.16.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 +190 -25
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +393 -62
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +38 -12
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/index.d.ts +18 -3
- package/package.json +1 -1
- package/src/design-core/design-apply.ts +116 -0
- package/src/design-core/design-settings.ts +194 -0
- package/src/design-core/empty-image.ts +14 -5
- package/src/design-core/flavor-seed.ts +39 -13
- package/src/design-core/flavor-spec.generated.ts +14 -6
- package/src/design-core/flavor.ts +6 -5
- package/src/design-core/fonts.ts +45 -8
- package/src/design-core/public-api.ts +2 -0
- package/src/design-core/token-css.ts +32 -2
- package/src/design-core/token-names.ts +21 -0
package/index.d.ts
CHANGED
|
@@ -184,9 +184,11 @@ declare class BuilderImageComponent {
|
|
|
184
184
|
alt: string;
|
|
185
185
|
nodeId: string;
|
|
186
186
|
/**
|
|
187
|
-
* What a MARKED image shows
|
|
188
|
-
* the shared `
|
|
189
|
-
* the blank on the live site, `null` for an unmarked image, which therefore renders
|
|
187
|
+
* What a MARKED image shows WHENEVER it has no source (design-system DS3) — set by each renderer
|
|
188
|
+
* from the shared `emptyImagePlaceholder` (`@vectoriox/iox-ui/design-core`): the "Add image" label
|
|
189
|
+
* on the canvas, the blank on the live site, `null` for an unmarked image, which therefore renders
|
|
190
|
+
* as before. It does not depend on `src`, so clearing the source (a binding emptied, a context slot
|
|
191
|
+
* not yet filled) shows it with no re-render; a renderer re-sets it only when the MARKER changes.
|
|
190
192
|
*/
|
|
191
193
|
emptySrc: string | null;
|
|
192
194
|
/**
|
|
@@ -200,8 +202,16 @@ declare class BuilderImageComponent {
|
|
|
200
202
|
* filled when the visitor interacts (see element-context-state-plan.md).
|
|
201
203
|
*/
|
|
202
204
|
get srcAttr(): string | null;
|
|
205
|
+
private get showsPlaceholder();
|
|
203
206
|
/** `data-iox-empty` while the placeholder stands in — the hook the shared fill rule keys on. */
|
|
204
207
|
get emptyAttr(): '' | null;
|
|
208
|
+
/**
|
|
209
|
+
* The placeholder is not the author's picture, so it never carries the author's alt: it is
|
|
210
|
+
* decorative (`alt=""`) and hidden from assistive technology — on the live site, in Preview and on
|
|
211
|
+
* the canvas alike (the canvas label is painted inside the placeholder, not announced as content).
|
|
212
|
+
*/
|
|
213
|
+
get altAttr(): string;
|
|
214
|
+
get ariaHiddenAttr(): 'true' | null;
|
|
205
215
|
static ɵfac: i0.ɵɵFactoryDeclaration<BuilderImageComponent, never>;
|
|
206
216
|
static ɵcmp: i0.ɵɵComponentDeclaration<BuilderImageComponent, "img[iox-img]", never, { "src": { "alias": "src"; "required": false; }; "alt": { "alias": "alt"; "required": false; }; "nodeId": { "alias": "nodeId"; "required": false; }; "emptySrc": { "alias": "emptySrc"; "required": false; }; }, {}, never, never, false, never>;
|
|
207
217
|
}
|
|
@@ -247,6 +257,11 @@ declare class BuilderIconComponent {
|
|
|
247
257
|
/**
|
|
248
258
|
* Directive-host link: the `<a>` IS the host — no wrapper. Content is the label; styles come from
|
|
249
259
|
* `.iox-node` (the legacy `style` input is kept for builder compat but no longer inline-applied).
|
|
260
|
+
*
|
|
261
|
+
* Outside a preview (`IS_PREVIEW` unprovided — the editing canvas, the design sampler) the link keeps
|
|
262
|
+
* its real `href` so it LOOKS like the live site's (the UA link styles key on it) and stays clickable
|
|
263
|
+
* so the canvas can select it, but no click navigates: plain, modified (cmd/ctrl/shift) and middle
|
|
264
|
+
* clicks all have their default cancelled here.
|
|
250
265
|
*/
|
|
251
266
|
declare class BuilderLinkComponent {
|
|
252
267
|
isPreview: boolean;
|
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { deriveBrandTokens } from './brand';
|
|
2
|
+
import { SPEC_LEGACY_ALIASES } from './flavor-spec.generated';
|
|
3
|
+
import { SWISS_FLAVOR } from './flavor';
|
|
4
|
+
import { buildFlavorTokens } from './flavor-tokens';
|
|
5
|
+
import { FlavorSeedPlan, planFlavorSeed, SeedBaseline, SeedExpectation } from './flavor-seed';
|
|
6
|
+
import { DesignChoices, DesignTokenRow, flavorSourceStamp, StoredDesignTokenRow } from './design-token.model';
|
|
7
|
+
import { flavorPackById, isRetiredFlavorId, packDesignChoices, StoredDesign } from './design-settings';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Applying a look (design-system DS4): the COMPLETE `buildFlavorTokens` set for the chosen
|
|
11
|
+
* `DesignChoices`, merged into the org's rows by the §10 C2 rule — rewrite only what the previous
|
|
12
|
+
* seed wrote and nobody touched since, keep the author's rows, report what was kept. And the one
|
|
13
|
+
* migration the contract names: an org still on Swiss becomes Crisp.
|
|
14
|
+
* Pure plans; each caller (builder, the server-side site build) applies them with its own transport.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SEED_STAMP = /^flavor:([^@]+)@/;
|
|
18
|
+
|
|
19
|
+
const expectations = (rows: ReadonlyArray<Pick<DesignTokenRow, 'name' | 'value' | 'values'>>): Map<string, SeedExpectation> =>
|
|
20
|
+
new Map(rows.map(r => [r.name, { value: r.value, ...(r.values?.dark ? { dark: r.values.dark } : {}) }]));
|
|
21
|
+
|
|
22
|
+
/** Every row a design writes — `buildFlavorTokens` for its pack. Empty for an unknown pack. */
|
|
23
|
+
export function designTokenRows(choices: DesignChoices): DesignTokenRow[] {
|
|
24
|
+
const pack = flavorPackById(choices.flavorId);
|
|
25
|
+
return pack ? buildFlavorTokens(pack, { ...choices, flavorId: pack.id }) : [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the F1/F2 Swiss seeding wrote: the four Swiss rows + the brand set derived from its seed. */
|
|
29
|
+
function swissExpectations(seed: string | null): Map<string, SeedExpectation> {
|
|
30
|
+
const rows: Array<Pick<DesignTokenRow, 'name' | 'value' | 'values'>> =
|
|
31
|
+
SWISS_FLAVOR.tokens.map(t => ({ name: t.name, value: t.value, values: t.values }));
|
|
32
|
+
if (seed) {
|
|
33
|
+
try { rows.push(...deriveBrandTokens(seed, SWISS_FLAVOR).map(t => ({ name: t.name, value: t.value, values: t.values }))); }
|
|
34
|
+
catch { /* an unparseable stored seed proves nothing about the rows */ }
|
|
35
|
+
}
|
|
36
|
+
return expectations(rows);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What the PREVIOUS design wrote, as a baseline keyed by the row's own stamp: a row Swiss stamped is
|
|
41
|
+
* judged against what Swiss wrote, every other row against `buildFlavorTokens` of the stored choices
|
|
42
|
+
* (for an org with no design row, Crisp's defaults — exactly what DS2's interim seed wrote). In a
|
|
43
|
+
* Swiss org the only v2 rows are that interim seed's, so they are judged against Crisp's DEFAULTS,
|
|
44
|
+
* never against the brand-seeded Crisp the migration is about to write.
|
|
45
|
+
*/
|
|
46
|
+
export function designBaseline(previous: StoredDesign): SeedBaseline {
|
|
47
|
+
const retired = isRetiredFlavorId(previous.storedFlavorId);
|
|
48
|
+
const current = expectations(designTokenRows(retired ? packDesignChoices(previous.choices.flavorId) : previous.choices));
|
|
49
|
+
const swiss = swissExpectations(retired ? previous.storedSeed : null);
|
|
50
|
+
return (row: StoredDesignTokenRow) => {
|
|
51
|
+
const stampedBy = SEED_STAMP.exec(row.source ?? '')?.[1];
|
|
52
|
+
return stampedBy === SWISS_FLAVOR.id ? swiss.get(row.name) : current.get(row.name);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Apply `next` over the org's rows, judged against `previous` — the plan FlavorApplyService runs. */
|
|
57
|
+
export function planDesignApply(
|
|
58
|
+
existing: ReadonlyArray<StoredDesignTokenRow>,
|
|
59
|
+
previous: StoredDesign,
|
|
60
|
+
next: DesignChoices,
|
|
61
|
+
): FlavorSeedPlan {
|
|
62
|
+
const pack = flavorPackById(next.flavorId);
|
|
63
|
+
if (!pack) return { create: [], update: [], kept: [] };
|
|
64
|
+
return planFlavorSeed(designTokenRows(next), existing, flavorSourceStamp(pack.id), designBaseline(previous));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The rows a design is missing by name — the builder-load top-up (never touches an existing row). */
|
|
68
|
+
export function missingDesignRows(existing: ReadonlyArray<{ name: string }>, choices: DesignChoices): DesignTokenRow[] {
|
|
69
|
+
const have = new Set((existing ?? []).map(r => r.name));
|
|
70
|
+
return designTokenRows(choices).filter(r => !have.has(r.name));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SwissMigration {
|
|
74
|
+
/** The design the org now wears: Crisp, carrying the stored brand seed when it is a colour. */
|
|
75
|
+
choices: DesignChoices;
|
|
76
|
+
/** The token merge, the four legacy alias rewrites included. */
|
|
77
|
+
plan: FlavorSeedPlan;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const LEGACY_ALIASES: Readonly<Record<string, string>> = SPEC_LEGACY_ALIASES;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Swiss → Crisp (spec `legacyAliases`; D1). Needed when the design row says a retired flavor, or when
|
|
84
|
+
* there is no design row but the org holds Swiss-stamped alias rows (F1 seeded those before the row
|
|
85
|
+
* existed). Idempotent: once the row says Crisp nothing is needed, and re-running the plan writes
|
|
86
|
+
* nothing.
|
|
87
|
+
*
|
|
88
|
+
* - The look: `planDesignApply` from Swiss to Crisp — Swiss-stamped rows untouched since Swiss seeded
|
|
89
|
+
* them (incl. F2's `surface` / `line` / `accent`) become Crisp's; edited ones are kept.
|
|
90
|
+
* - The aliases: `radius-control` / `radius-surface` / `space-control` / `space-stack` are rewritten to
|
|
91
|
+
* their alias reference ONLY while still machine-owned and untouched, so a node saved since F1 that
|
|
92
|
+
* reads `var(--iox-radius-control)` keeps resolving — now to Crisp's button radius.
|
|
93
|
+
*/
|
|
94
|
+
export function planSwissMigration(existing: ReadonlyArray<StoredDesignTokenRow>, stored: StoredDesign): SwissMigration | null {
|
|
95
|
+
const swissAliasRows = (existing ?? []).filter(r => r.name in LEGACY_ALIASES && SEED_STAMP.exec(r.source ?? '')?.[1] === SWISS_FLAVOR.id);
|
|
96
|
+
const needed = isRetiredFlavorId(stored.storedFlavorId) || (!stored.hasRow && swissAliasRows.length > 0);
|
|
97
|
+
if (!needed) return null;
|
|
98
|
+
|
|
99
|
+
const choices = stored.choices;
|
|
100
|
+
const previous: StoredDesign = isRetiredFlavorId(stored.storedFlavorId) ? stored : { ...stored, storedFlavorId: SWISS_FLAVOR.id };
|
|
101
|
+
const plan = planDesignApply(existing, previous, choices);
|
|
102
|
+
const stamp = flavorSourceStamp(choices.flavorId);
|
|
103
|
+
const swiss = swissExpectations(null);
|
|
104
|
+
for (const row of (existing ?? []).filter(r => r.name in LEGACY_ALIASES)) {
|
|
105
|
+
const alias = LEGACY_ALIASES[row.name];
|
|
106
|
+
if (row.value === alias) continue;
|
|
107
|
+
const was = swiss.get(row.name);
|
|
108
|
+
const untouched = SEED_STAMP.test(row.source ?? '') && !!was && row.value === was.value && !row.values?.dark;
|
|
109
|
+
if (untouched && row._id) {
|
|
110
|
+
plan.update.push({ id: row._id, data: { value: alias, values: {}, source: stamp } });
|
|
111
|
+
} else {
|
|
112
|
+
plan.kept.push(row.name);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { choices, plan };
|
|
116
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { SPEC_AXES, SPEC_FONT_ROLES } from './flavor-spec.generated';
|
|
2
|
+
import { parseHexColor } from './brand';
|
|
3
|
+
import { DesignChoices, DesignMode, FlavorPackV2, FontRoleToken, SPEC_FLAVORS } from './design-token.model';
|
|
4
|
+
import type { FlavorRevertSnapshot } from './flavor-seed';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `settings.design` row (design-system plan §3f), read and written by ONE pair of functions so
|
|
8
|
+
* the builder, the server's public page read (the engine's mode) and the site build agree on what a
|
|
9
|
+
* stored row MEANS. The row is the settings collection's generic `config: [{ key, value }]`; values
|
|
10
|
+
* are strings there (Boolean "true"/"false", Object as JSON), but a reader also accepts the typed
|
|
11
|
+
* value a client control may have sent.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The look a studio wears until it picks one (DS2's interim seed, and DS6's first-run selection). */
|
|
15
|
+
export const DEFAULT_FLAVOR_ID = 'crisp';
|
|
16
|
+
|
|
17
|
+
/** One `config` entry of a settings row. */
|
|
18
|
+
export interface DesignConfigEntry {
|
|
19
|
+
key: string;
|
|
20
|
+
value: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export enum DesignConfigKey {
|
|
24
|
+
FlavorId = 'flavorId',
|
|
25
|
+
FlavorVersion = 'flavorVersion',
|
|
26
|
+
BrandSeed = 'brandSeed',
|
|
27
|
+
PairId = 'pairId',
|
|
28
|
+
Shape = 'shape',
|
|
29
|
+
Density = 'density',
|
|
30
|
+
Motion = 'motion',
|
|
31
|
+
PillButtons = 'pillButtons',
|
|
32
|
+
Mode = 'mode',
|
|
33
|
+
Fonts = 'fonts',
|
|
34
|
+
Revert = 'revert',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A stored design, resolved: `choices` is ALWAYS complete and valid (pack defaults fill the gaps). */
|
|
38
|
+
export interface StoredDesign {
|
|
39
|
+
choices: DesignChoices;
|
|
40
|
+
/** The flavor id exactly as stored — `swiss` until the DS4 migration rewrites it; null when no row. */
|
|
41
|
+
storedFlavorId: string | null;
|
|
42
|
+
/** The seed exactly as stored (F2 orgs carry their brand colour here); null when absent. */
|
|
43
|
+
storedSeed: string | null;
|
|
44
|
+
flavorVersion: string | null;
|
|
45
|
+
revert: FlavorRevertSnapshot | null;
|
|
46
|
+
/** False when the org has never stored a design — DS6's first run. */
|
|
47
|
+
hasRow: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const MODES: ReadonlyArray<string> = SPEC_AXES.modes;
|
|
51
|
+
const FONT_ROLE_TOKENS: ReadonlyArray<string> = SPEC_FONT_ROLES.map(r => r.token);
|
|
52
|
+
|
|
53
|
+
/** The pack for an id; a retired id (Swiss) resolves to the pack that `replaces` it. */
|
|
54
|
+
export function flavorPackById(id: string | null | undefined): FlavorPackV2 | undefined {
|
|
55
|
+
if (!id) return undefined;
|
|
56
|
+
return SPEC_FLAVORS.find(f => f.id === id) ?? SPEC_FLAVORS.find(f => f.replaces === id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True when `id` names a retired flavor that a v2 pack replaces (Swiss → Crisp). */
|
|
60
|
+
export function isRetiredFlavorId(id: string | null | undefined): boolean {
|
|
61
|
+
return !!id && !SPEC_FLAVORS.some(f => f.id === id) && SPEC_FLAVORS.some(f => f.replaces === id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const defaultPack = (): FlavorPackV2 => flavorPackById(DEFAULT_FLAVOR_ID) as FlavorPackV2;
|
|
65
|
+
|
|
66
|
+
const asString = (v: unknown): string | null => (typeof v === 'string' && v.trim() !== '' ? v.trim() : null);
|
|
67
|
+
|
|
68
|
+
function asBoolean(v: unknown): boolean | null {
|
|
69
|
+
if (v === true || v === 'true') return true;
|
|
70
|
+
if (v === false || v === 'false') return false;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function asObject<T>(v: unknown): T | null {
|
|
75
|
+
if (v && typeof v === 'object') return v as T;
|
|
76
|
+
if (typeof v !== 'string' || v.trim() === '') return null;
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(v);
|
|
79
|
+
return parsed && typeof parsed === 'object' ? parsed as T : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function asFonts(v: unknown): DesignChoices['fonts'] | undefined {
|
|
86
|
+
const raw = asObject<Record<string, unknown>>(v);
|
|
87
|
+
if (!raw) return undefined;
|
|
88
|
+
const fonts: Partial<Record<FontRoleToken, string>> = {};
|
|
89
|
+
for (const role of FONT_ROLE_TOKENS) {
|
|
90
|
+
const family = raw[role];
|
|
91
|
+
if (typeof family === 'string' && family.trim() !== '') fonts[role as FontRoleToken] = family.trim();
|
|
92
|
+
}
|
|
93
|
+
return Object.keys(fonts).length ? fonts : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const keyOf = <K extends string>(table: Readonly<Record<K, unknown>>, v: string | null): K | null =>
|
|
97
|
+
v !== null && Object.prototype.hasOwnProperty.call(table, v) ? v as K : null;
|
|
98
|
+
|
|
99
|
+
/** A config array → the key/value map (last entry wins, as the settings form writes it). */
|
|
100
|
+
function configMap(config: ReadonlyArray<DesignConfigEntry> | null | undefined): Map<string, unknown> {
|
|
101
|
+
const map = new Map<string, unknown>();
|
|
102
|
+
for (const kv of config ?? []) if (kv && typeof kv.key === 'string') map.set(kv.key, kv.value);
|
|
103
|
+
return map;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A partial set of choices completed against its pack: every missing or invalid value falls back to
|
|
108
|
+
* the pack's own default (an unknown pair → the pack's first, a bad seed → the pack's seed).
|
|
109
|
+
*/
|
|
110
|
+
export function resolveDesignChoices(partial: Partial<DesignChoices> | null | undefined): DesignChoices {
|
|
111
|
+
const pack = flavorPackById(partial?.flavorId) ?? defaultPack();
|
|
112
|
+
const seed = asString(partial?.seed);
|
|
113
|
+
const pairId = asString(partial?.pairId);
|
|
114
|
+
const fonts = asFonts(partial?.fonts);
|
|
115
|
+
return {
|
|
116
|
+
flavorId: pack.id,
|
|
117
|
+
seed: seed && parseHexColor(seed) ? seed : pack.seed,
|
|
118
|
+
pairId: pairId && pack.pairs.some(p => p.id === pairId) ? pairId : pack.pairs[0]?.id ?? '',
|
|
119
|
+
shape: keyOf(SPEC_AXES.shapes, asString(partial?.shape)) ?? pack.defaults.shape,
|
|
120
|
+
density: keyOf(SPEC_AXES.densities, asString(partial?.density)) ?? pack.defaults.density,
|
|
121
|
+
motion: keyOf(SPEC_AXES.motions, asString(partial?.motion)) ?? pack.defaults.motion,
|
|
122
|
+
pillButtons: asBoolean(partial?.pillButtons) ?? pack.defaults.pillButtons,
|
|
123
|
+
mode: (MODES.includes(asString(partial?.mode) ?? '') ? asString(partial?.mode) : pack.defaults.mode) as DesignMode,
|
|
124
|
+
...(fonts ? { fonts } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The pack's own defaults for `flavorId` — what picking a card means before anything is tuned. */
|
|
129
|
+
export function packDesignChoices(flavorId: string): DesignChoices {
|
|
130
|
+
return resolveDesignChoices({ flavorId });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A stored `settings.design` config → the resolved design. No row (or no config) → Crisp's defaults. */
|
|
134
|
+
export function readDesignConfig(config: ReadonlyArray<DesignConfigEntry> | null | undefined, hasRow = !!config): StoredDesign {
|
|
135
|
+
const map = configMap(config);
|
|
136
|
+
const storedFlavorId = asString(map.get(DesignConfigKey.FlavorId));
|
|
137
|
+
const storedSeed = asString(map.get(DesignConfigKey.BrandSeed));
|
|
138
|
+
const choices = resolveDesignChoices({
|
|
139
|
+
flavorId: storedFlavorId ?? undefined,
|
|
140
|
+
seed: storedSeed ?? undefined,
|
|
141
|
+
pairId: asString(map.get(DesignConfigKey.PairId)) ?? undefined,
|
|
142
|
+
shape: map.get(DesignConfigKey.Shape) as DesignChoices['shape'],
|
|
143
|
+
density: map.get(DesignConfigKey.Density) as DesignChoices['density'],
|
|
144
|
+
motion: map.get(DesignConfigKey.Motion) as DesignChoices['motion'],
|
|
145
|
+
pillButtons: asBoolean(map.get(DesignConfigKey.PillButtons)) ?? undefined,
|
|
146
|
+
mode: map.get(DesignConfigKey.Mode) as DesignMode,
|
|
147
|
+
fonts: map.get(DesignConfigKey.Fonts) as DesignChoices['fonts'],
|
|
148
|
+
});
|
|
149
|
+
return {
|
|
150
|
+
choices,
|
|
151
|
+
storedFlavorId,
|
|
152
|
+
storedSeed,
|
|
153
|
+
flavorVersion: asString(map.get(DesignConfigKey.FlavorVersion)),
|
|
154
|
+
revert: asObject<FlavorRevertSnapshot>(map.get(DesignConfigKey.Revert)),
|
|
155
|
+
hasRow,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The colour mode a stored config compiles in — what the public page read hands the engine. */
|
|
160
|
+
export function designModeOf(config: ReadonlyArray<DesignConfigEntry> | null | undefined): DesignMode {
|
|
161
|
+
return readDesignConfig(config).choices.mode;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Resolved choices (+ the revert snapshot) → the config array a settings row stores. */
|
|
165
|
+
export function designConfigEntries(
|
|
166
|
+
choices: DesignChoices,
|
|
167
|
+
flavorVersion: string | number,
|
|
168
|
+
revert?: FlavorRevertSnapshot | null,
|
|
169
|
+
): DesignConfigEntry[] {
|
|
170
|
+
const c = resolveDesignChoices(choices);
|
|
171
|
+
return [
|
|
172
|
+
{ key: DesignConfigKey.FlavorId, value: c.flavorId },
|
|
173
|
+
{ key: DesignConfigKey.FlavorVersion, value: String(flavorVersion) },
|
|
174
|
+
{ key: DesignConfigKey.BrandSeed, value: c.seed },
|
|
175
|
+
{ key: DesignConfigKey.PairId, value: c.pairId },
|
|
176
|
+
{ key: DesignConfigKey.Shape, value: c.shape },
|
|
177
|
+
{ key: DesignConfigKey.Density, value: c.density },
|
|
178
|
+
{ key: DesignConfigKey.Motion, value: c.motion },
|
|
179
|
+
{ key: DesignConfigKey.PillButtons, value: String(c.pillButtons) },
|
|
180
|
+
{ key: DesignConfigKey.Mode, value: c.mode },
|
|
181
|
+
...(c.fonts ? [{ key: DesignConfigKey.Fonts, value: JSON.stringify(c.fonts) }] : []),
|
|
182
|
+
...(revert ? [{ key: DesignConfigKey.Revert, value: JSON.stringify(revert) }] : []),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Two choice sets describe the same look (fonts compared by role). */
|
|
187
|
+
export function sameDesignChoices(a: DesignChoices, b: DesignChoices): boolean {
|
|
188
|
+
const x = resolveDesignChoices(a);
|
|
189
|
+
const y = resolveDesignChoices(b);
|
|
190
|
+
return x.flavorId === y.flavorId && x.seed.toLowerCase() === y.seed.toLowerCase() && x.pairId === y.pairId
|
|
191
|
+
&& x.shape === y.shape && x.density === y.density && x.motion === y.motion
|
|
192
|
+
&& x.pillButtons === y.pillButtons && x.mode === y.mode
|
|
193
|
+
&& JSON.stringify(x.fonts ?? {}) === JSON.stringify(y.fonts ?? {});
|
|
194
|
+
}
|
|
@@ -41,12 +41,21 @@ export function emptyImageLabelSrc(label: string): string {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* What a node's Image shows WHENEVER its source is empty: the canvas label (when `label` is given) or
|
|
45
|
+
* the live blank for a MARKED node; `null` — render exactly as before — for an unmarked node. It does
|
|
46
|
+
* not look at the source, so a renderer computes it once per marker/label and the component falls
|
|
47
|
+
* back to it the moment its source is cleared (a binding emptied, a context slot not yet filled).
|
|
47
48
|
*/
|
|
48
|
-
export function
|
|
49
|
-
if (src != null && src !== '') return null;
|
|
49
|
+
export function emptyImagePlaceholder(node: { elementStyle?: string | null } | null | undefined, label?: string | null): string | null {
|
|
50
50
|
if (!hasElementStyleMarker(node)) return null;
|
|
51
51
|
return label ? emptyImageLabelSrc(label) : EMPTY_IMAGE_SRC;
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `src` a node's Image renders for a given source: its placeholder while the source is empty,
|
|
56
|
+
* `null` once it has one (the source itself renders) or when the node is unmarked.
|
|
57
|
+
*/
|
|
58
|
+
export function emptyImageSrc(node: { elementStyle?: string | null } | null | undefined, src: unknown, label?: string | null): string | null {
|
|
59
|
+
if (src != null && src !== '') return null;
|
|
60
|
+
return emptyImagePlaceholder(node, label);
|
|
61
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FlavorTokenDef } from './flavor';
|
|
2
|
-
import { DesignTokenRow, StoredDesignTokenRow } from './design-token.model';
|
|
2
|
+
import { DesignTokenRow, StoredDesignTokenRow, USER_TOKEN_SOURCE } from './design-token.model';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Seeding and reverting a look, as pure plans (flavors F2, moved here from iox-cms-client in
|
|
@@ -82,42 +82,68 @@ export function planFlavorRevert(
|
|
|
82
82
|
return plan;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** What a previous seed wrote for one row — the "untouched" comparison base. */
|
|
86
|
+
export interface SeedExpectation {
|
|
87
|
+
value: string;
|
|
88
|
+
dark?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What the PREVIOUS seed wrote, per row: a map by name, or a function of the stored row (a baseline
|
|
93
|
+
* that depends on WHICH seed stamped the row — the Swiss → Crisp migration's case).
|
|
94
|
+
*/
|
|
95
|
+
export type SeedBaseline =
|
|
96
|
+
| ReadonlyMap<string, SeedExpectation>
|
|
97
|
+
| ((row: StoredDesignTokenRow) => SeedExpectation | undefined);
|
|
98
|
+
|
|
99
|
+
const expectationFor = (baseline: SeedBaseline | null | undefined, row: StoredDesignTokenRow): SeedExpectation | undefined =>
|
|
100
|
+
!baseline ? undefined : typeof baseline === 'function' ? baseline(row) : baseline.get(row.name);
|
|
101
|
+
|
|
102
|
+
const sameValue = (row: { value: string; values?: { dark?: string } }, e: SeedExpectation) =>
|
|
103
|
+
row.value === e.value && (row.values?.dark ?? '') === (e.dark ?? '');
|
|
104
|
+
|
|
85
105
|
/**
|
|
86
106
|
* The seeding MERGE: desired tokens vs existing rows, deciding create / update / keep. The §10 C2
|
|
87
107
|
* rule — "an upgrade only rewrites untouched rows" — is a comparison against what the PREVIOUS
|
|
88
|
-
* seed wrote (`
|
|
89
|
-
* rewritten; any drift means the author took it over, and their hand wins. A row
|
|
90
|
-
* `flavor:` stamp
|
|
108
|
+
* seed wrote (`baseline`): a seeded row whose value still equals it is the machine's and may be
|
|
109
|
+
* rewritten; any drift means the author took it over, and their hand wins. A row the design did not
|
|
110
|
+
* write is never touched: no `flavor:` stamp, unless it is a `user` row (a font the author CHOSE in
|
|
111
|
+
* the design) whose value is exactly what the previous design wrote — that row is the design's own
|
|
112
|
+
* choice, so a new choice may replace it. `group`/`tier` ride every write; a desired row's own
|
|
113
|
+
* `source` (a chosen font's `user`) wins over the pack stamp.
|
|
91
114
|
*/
|
|
92
115
|
export function planFlavorSeed(
|
|
93
|
-
desired: ReadonlyArray<FlavorTokenDef>,
|
|
116
|
+
desired: ReadonlyArray<FlavorTokenDef & { source?: string }>,
|
|
94
117
|
existing: ReadonlyArray<StoredDesignTokenRow>,
|
|
95
118
|
stamp: string,
|
|
96
|
-
|
|
119
|
+
baseline: SeedBaseline | null,
|
|
97
120
|
): FlavorSeedPlan {
|
|
98
121
|
const byName = new Map((existing ?? []).map(r => [r.name, r]));
|
|
99
122
|
const plan: FlavorSeedPlan = { create: [], update: [], kept: [] };
|
|
100
123
|
for (const def of desired ?? []) {
|
|
101
124
|
if (!def?.name) continue;
|
|
102
125
|
const row = byName.get(def.name);
|
|
126
|
+
const source = def.source ?? stamp;
|
|
103
127
|
if (!row) {
|
|
104
128
|
plan.create.push({
|
|
105
129
|
name: def.name,
|
|
106
130
|
type: def.type ?? DEFAULT_TOKEN_TYPE,
|
|
107
131
|
value: def.value,
|
|
108
132
|
...(def.values ? { values: def.values } : {}),
|
|
109
|
-
source
|
|
133
|
+
source,
|
|
110
134
|
...classification(def),
|
|
111
135
|
});
|
|
112
136
|
continue;
|
|
113
137
|
}
|
|
114
|
-
|
|
138
|
+
const old = expectationFor(baseline, row);
|
|
139
|
+
const designOwned = isSeeded(row) || (row.source === USER_TOKEN_SOURCE && !!old && sameValue(row, old));
|
|
140
|
+
if (!designOwned) { plan.kept.push(def.name); continue; }
|
|
115
141
|
const sameAsDesired = row.value === def.value && (row.values?.dark ?? '') === (def.values?.dark ?? '');
|
|
116
|
-
if (sameAsDesired) continue; // already exactly this
|
|
117
|
-
|
|
118
|
-
const untouched = !!old &&
|
|
119
|
-
if (untouched && row._id) {
|
|
120
|
-
plan.update.push({ id: row._id, data: { value: def.value, values: def.values ?? {}, source
|
|
142
|
+
if (sameAsDesired && (row.source ?? '') === source) continue; // already exactly this
|
|
143
|
+
if (sameAsDesired && isSeeded(row) && source !== USER_TOKEN_SOURCE) continue; // same value, another pack's stamp — nothing to write
|
|
144
|
+
const untouched = !!old && sameValue(row, old);
|
|
145
|
+
if ((untouched || sameAsDesired) && row._id) {
|
|
146
|
+
plan.update.push({ id: row._id, data: { value: def.value, values: def.values ?? {}, source, ...classification(def) } });
|
|
121
147
|
} else {
|
|
122
148
|
plan.kept.push(def.name); // edited since seeding (or unverifiable) — the author's hand wins
|
|
123
149
|
}
|
|
@@ -1077,7 +1077,7 @@ export const SPEC_SPRINGS = {
|
|
|
1077
1077
|
} as const;
|
|
1078
1078
|
|
|
1079
1079
|
export const SPEC_FONTS = {
|
|
1080
|
-
"Inter": "Inter:wght@400;500;600;700",
|
|
1080
|
+
"Inter": "Inter:wght@300;400;500;600;700",
|
|
1081
1081
|
"Inter Tight": "Inter+Tight:wght@500;600;700",
|
|
1082
1082
|
"Manrope": "Manrope:wght@400;500;600;700",
|
|
1083
1083
|
"IBM Plex Sans": "IBM+Plex+Sans:wght@400;500;600;700",
|
|
@@ -1092,12 +1092,21 @@ export const SPEC_FONTS = {
|
|
|
1092
1092
|
"Figtree": "Figtree:wght@400;500;600;700;800",
|
|
1093
1093
|
"Google Sans Flex": "Google+Sans+Flex:wght@400;500;600;700",
|
|
1094
1094
|
"Bricolage Grotesque": "Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700",
|
|
1095
|
-
"Nunito": "Nunito:wght@400;600;700;800",
|
|
1095
|
+
"Nunito": "Nunito:wght@300;400;600;700;800",
|
|
1096
1096
|
"DM Sans": "DM+Sans:wght@400;500;700",
|
|
1097
1097
|
"Archivo": "Archivo:wght@400;500;700",
|
|
1098
1098
|
"Archivo Black": "Archivo+Black",
|
|
1099
1099
|
"Syne": "Syne:wght@600;700;800",
|
|
1100
|
-
"Space Mono": "Space+Mono:wght@400;700"
|
|
1100
|
+
"Space Mono": "Space+Mono:wght@400;700",
|
|
1101
|
+
"Poppins": "Poppins:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400",
|
|
1102
|
+
"Roboto": "Roboto:ital,wght@0,300;0,400;0,500;0,700;1,400",
|
|
1103
|
+
"Lato": "Lato:ital,wght@0,300;0,400;0,700;1,400",
|
|
1104
|
+
"Open Sans": "Open+Sans:ital,wght@0,300;0,400;0,600;0,700;1,400",
|
|
1105
|
+
"Montserrat": "Montserrat:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400",
|
|
1106
|
+
"Raleway": "Raleway:wght@300;400;500;600;700",
|
|
1107
|
+
"Playfair Display": "Playfair+Display:ital,wght@0,400;0,500;0,700;1,400",
|
|
1108
|
+
"Oswald": "Oswald:wght@300;400;500;600;700",
|
|
1109
|
+
"Ubuntu": "Ubuntu:ital,wght@0,300;0,400;0,500;0,700;1,400"
|
|
1101
1110
|
} as const;
|
|
1102
1111
|
|
|
1103
1112
|
export const SPEC_SCHEMES = {
|
|
@@ -1410,7 +1419,7 @@ export const SPEC_ELEMENT_STYLES = [
|
|
|
1410
1419
|
"fontSize": "var(--iox-size-lead)",
|
|
1411
1420
|
"fontWeight": "var(--iox-weight-body)",
|
|
1412
1421
|
"lineHeight": "var(--iox-leading-body)",
|
|
1413
|
-
"color": "
|
|
1422
|
+
"color": "inherit"
|
|
1414
1423
|
}
|
|
1415
1424
|
},
|
|
1416
1425
|
{
|
|
@@ -1425,7 +1434,7 @@ export const SPEC_ELEMENT_STYLES = [
|
|
|
1425
1434
|
"fontSize": "var(--iox-size-body)",
|
|
1426
1435
|
"fontWeight": "var(--iox-weight-body)",
|
|
1427
1436
|
"lineHeight": "var(--iox-leading-body)",
|
|
1428
|
-
"color": "
|
|
1437
|
+
"color": "inherit"
|
|
1429
1438
|
}
|
|
1430
1439
|
},
|
|
1431
1440
|
{
|
|
@@ -1762,7 +1771,6 @@ export const SPEC_FIELD_CLASS_BASELINE = {
|
|
|
1762
1771
|
export const SPEC_LEGACY_ALIASES = {
|
|
1763
1772
|
"radius-control": "var(--iox-button-radius)",
|
|
1764
1773
|
"radius-surface": "var(--iox-card-radius)",
|
|
1765
|
-
"space-control": "var(--iox-button-pad)",
|
|
1766
1774
|
"space-stack": "var(--iox-space-gap)"
|
|
1767
1775
|
} as const;
|
|
1768
1776
|
|
|
@@ -4,8 +4,9 @@ import { DesignTokenGroup, DesignTokenTier, DesignTokenType } from './design-tok
|
|
|
4
4
|
* Flavors v1 (flavors-and-theming-plan.md, F1–F2): a pack is its TOKENS plus the accent hue
|
|
5
5
|
* opinion the brand derivation reads. The per-type `defaults` map and its resolvers are gone
|
|
6
6
|
* (design-system plan §3e, DS2): a new element is born wearing an ELEMENT STYLE
|
|
7
|
-
* (`element-styles.ts`), which references tokens and is flavor-independent. Swiss
|
|
8
|
-
*
|
|
7
|
+
* (`element-styles.ts`), which references tokens and is flavor-independent. Swiss is retired (DS4
|
|
8
|
+
* migrates `flavorId: swiss` orgs to Crisp); its pack stays as the migration's BASELINE — what F1/F2
|
|
9
|
+
* wrote, so "untouched since seeding" stays a comparison (`design-apply.ts`).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
export interface FlavorTokenDef {
|
|
@@ -40,9 +41,9 @@ export function validateFlavorPack(pack: FlavorPack): string[] {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
* Swiss — the F1 pack: four tokens (control/surface radius, control/stack spacing) and the
|
|
44
|
-
* opinion F2's brand step
|
|
45
|
-
*
|
|
44
|
+
* Swiss — the retired F1 pack: four tokens (control/surface radius, control/stack spacing) and the
|
|
45
|
+
* hue opinion F2's brand step read. Three of its rows become the spec's `legacyAliases` on migration;
|
|
46
|
+
* `space-control` is a v2 token in every pack and takes Crisp's value.
|
|
46
47
|
*/
|
|
47
48
|
export const SWISS_FLAVOR: FlavorPack = {
|
|
48
49
|
id: 'swiss',
|
package/src/design-core/fonts.ts
CHANGED
|
@@ -77,19 +77,56 @@ export function parseFontFamilies(stack: string): string[] {
|
|
|
77
77
|
.filter(Boolean);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** Where Google serves the stylesheets, and the origin the font FILES come from (preconnect). */
|
|
81
|
+
export const GOOGLE_FONTS_CSS_ORIGIN = 'https://fonts.googleapis.com';
|
|
82
|
+
export const GOOGLE_FONTS_FILE_ORIGIN = 'https://fonts.gstatic.com';
|
|
83
|
+
|
|
84
|
+
/** The ONE stylesheet URL for a catalog id — both renderers link exactly this (one per family, T7). */
|
|
85
|
+
export function googleFontHref(id: string): string {
|
|
86
|
+
return `${GOOGLE_FONTS_CSS_ORIGIN}/css2?family=${id}&display=swap`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const CATALOG_BY_LOWER = new Map(Object.entries(FONT_CATALOG).map(([family, id]) => [family.toLowerCase(), id]));
|
|
90
|
+
|
|
91
|
+
/** The catalog id of a family name (case-insensitive), or null — a system or uploaded face has none. */
|
|
92
|
+
export function catalogFontId(family: string | null | undefined): string | null {
|
|
93
|
+
return CATALOG_BY_LOWER.get(String(family ?? '').trim().toLowerCase()) ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const MONO_ROLE = 'font-mono';
|
|
97
|
+
const ALWAYS_LOADED_ROLES = FONT_FAMILY_TOKENS.filter(role => role !== MONO_ROLE);
|
|
98
|
+
const MONO_REFERENCE = /var\(\s*--iox-font-mono\b/;
|
|
99
|
+
|
|
100
|
+
export interface FontsForTokensOptions {
|
|
101
|
+
/**
|
|
102
|
+
* The CSS that will actually render (node rules, element styles). `font-mono` is loaded only when
|
|
103
|
+
* something READS it — here or in another token's value — because no element style does: an org
|
|
104
|
+
* would otherwise download a monospace face for a site with no code on it.
|
|
105
|
+
*/
|
|
106
|
+
usedCss?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
80
109
|
/**
|
|
81
|
-
* The Google css2 ids to load for a token set: every catalog family named by
|
|
82
|
-
*
|
|
110
|
+
* The Google css2 ids to load for a token set: every catalog family named by the display / body /
|
|
111
|
+
* label roles, plus mono when referenced (above). One id per family (T7 — one `<link>` per family, so
|
|
112
|
+
* one bad id cannot sink the others), in role order.
|
|
83
113
|
*/
|
|
84
|
-
export function fontsForTokens(
|
|
114
|
+
export function fontsForTokens(
|
|
115
|
+
tokens: ReadonlyArray<DesignTokenLike> | null | undefined,
|
|
116
|
+
opts: FontsForTokensOptions = {},
|
|
117
|
+
): string[] {
|
|
118
|
+
const rows = (tokens ?? []).filter(t => !!t && typeof t.name === 'string');
|
|
119
|
+
const monoRead = MONO_REFERENCE.test(opts.usedCss ?? '')
|
|
120
|
+
|| rows.some(t => t.name !== MONO_ROLE && MONO_REFERENCE.test(`${t.value ?? ''} ${t.values?.dark ?? ''}`));
|
|
121
|
+
const roles = monoRead ? FONT_FAMILY_TOKENS : ALWAYS_LOADED_ROLES;
|
|
85
122
|
const seen = new Set<string>();
|
|
86
123
|
const ids: string[] = [];
|
|
87
|
-
for (const role of
|
|
88
|
-
const row =
|
|
124
|
+
for (const role of roles) {
|
|
125
|
+
const row = rows.find(t => t.name === role);
|
|
89
126
|
for (const family of parseFontFamilies(row?.value ?? '')) {
|
|
90
|
-
const id =
|
|
91
|
-
if (id && !seen.has(
|
|
92
|
-
seen.add(
|
|
127
|
+
const id = catalogFontId(family);
|
|
128
|
+
if (id && !seen.has(id)) {
|
|
129
|
+
seen.add(id);
|
|
93
130
|
ids.push(id);
|
|
94
131
|
}
|
|
95
132
|
}
|