@vectoriox/iox-ui 5.30.0 → 5.31.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.
Files changed (30) hide show
  1. package/composite-core/index.d.ts +270 -0
  2. package/composite-core/package.json +3 -0
  3. package/design-core/index.d.ts +870 -41
  4. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +698 -0
  5. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -0
  6. package/fesm2022/vectoriox-iox-ui-design-core.mjs +1090 -82
  7. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +110 -30
  9. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  10. package/fesm2022/vectoriox-iox-ui.mjs +134 -31
  11. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  12. package/generation-core/index.d.ts +35 -23
  13. package/index.d.ts +41 -15
  14. package/package.json +5 -1
  15. package/src/composite-core/badge.ts +103 -0
  16. package/src/composite-core/card.ts +524 -0
  17. package/src/composite-core/composite-node.ts +143 -0
  18. package/src/composite-core/public-api.ts +11 -0
  19. package/src/composite-core/structure.ts +58 -0
  20. package/src/design-core/contrast-audit.ts +38 -5
  21. package/src/design-core/flavor-fixtures.generated.ts +144 -144
  22. package/src/design-core/flavor-spec.generated.ts +970 -26
  23. package/src/design-core/node-classes.ts +16 -4
  24. package/src/design-core/node-links.ts +29 -0
  25. package/src/generation-core/compose-site.ts +55 -1
  26. package/src/generation-core/node-baselines.generated.ts +14 -2
  27. package/src/generation-core/patterns.ts +29 -2
  28. package/src/generation-core/serialized-node.ts +2 -0
  29. package/src/generation-core/site-spec.generated.ts +13 -26
  30. package/styles/iox-design.css +62 -0
@@ -0,0 +1,524 @@
1
+ import { elementStyleById, LinkType, switchElementStyle } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import { BadgeTone, buildBadge } from './badge';
4
+ import {
5
+ clearLayerStyles, CompositeNode, CompositeNodeFactory, findByRole, parentInComposite, readLayerStyle, readProp, readStyle,
6
+ removeFromComposite, writeLayerStyles, writeProp, writeStyles,
7
+ } from './composite-node';
8
+
9
+ /**
10
+ * CARD v2 (composite T23; complex-components board 8, APPROVED 2026-10-20). Not a component: a recipe of
11
+ * ordinary nodes, each part carrying a role, so every part is selectable on the canvas, in the layer tree
12
+ * and the breadcrumb, wears its own element style and is edited on its own (R2). No runtime and no design
13
+ * state — nothing is hidden at rest.
14
+ *
15
+ * Container `card` role card
16
+ * ├─ Container `box` role card.media (optional: Image)
17
+ * │ ├─ Image `media`
18
+ * │ └─ Container `badge-accent` role card.badge (optional) › Icon + Text
19
+ * └─ Container `box` role card.content (locked)
20
+ * ├─ Text `eyebrow` role card.eyebrow (optional)
21
+ * ├─ Heading h3 `h3` role card.title (locked — the card's link when linked)
22
+ * ├─ Text `body` role card.text (optional)
23
+ * └─ Container `box` role card.actions (optional) › ButtonBlock primary + ghost
24
+ *
25
+ * One recipe for every consumer: the builder's palette (its `createConfig`), generation-core's composer
26
+ * (its `createSerializedNode`) and the site build — each passes its own node factory.
27
+ */
28
+
29
+ export enum CardRole {
30
+ Card = 'card',
31
+ Media = 'card.media',
32
+ Badge = 'card.badge',
33
+ Content = 'card.content',
34
+ Eyebrow = 'card.eyebrow',
35
+ Title = 'card.title',
36
+ Text = 'card.text',
37
+ Actions = 'card.actions',
38
+ }
39
+
40
+ /** The optional parts — Card ▸ Parts, one switch each, in the board's order. Title and Content are locked. */
41
+ export enum CardPart {
42
+ Media = 'media',
43
+ Badge = 'badge',
44
+ Eyebrow = 'eyebrow',
45
+ Text = 'text',
46
+ Actions = 'actions',
47
+ }
48
+
49
+ export const CARD_PARTS: ReadonlyArray<CardPart> = [CardPart.Media, CardPart.Badge, CardPart.Eyebrow, CardPart.Text, CardPart.Actions];
50
+
51
+ export const CARD_PART_ROLE: Readonly<Record<CardPart, CardRole>> = {
52
+ [CardPart.Media]: CardRole.Media,
53
+ [CardPart.Badge]: CardRole.Badge,
54
+ [CardPart.Eyebrow]: CardRole.Eyebrow,
55
+ [CardPart.Text]: CardRole.Text,
56
+ [CardPart.Actions]: CardRole.Actions,
57
+ };
58
+
59
+ /** Card ▸ Layout — a preset, never a stored trait: `applyCardLayout` writes the card's layout styles. */
60
+ export enum CardLayout {
61
+ Top = 'top',
62
+ Side = 'side',
63
+ Overlay = 'overlay',
64
+ Compact = 'compact',
65
+ }
66
+
67
+ export const CARD_LAYOUTS: ReadonlyArray<CardLayout> = [CardLayout.Top, CardLayout.Side, CardLayout.Overlay, CardLayout.Compact];
68
+ /** What Card ▸ Layout shows once the author has changed the layout styles by hand. */
69
+ export const CARD_LAYOUT_CUSTOM = 'custom';
70
+ export type CardLayoutState = CardLayout | typeof CARD_LAYOUT_CUSTOM;
71
+
72
+ /** The words a fresh card starts with — the author's to replace (or a bound field's). */
73
+ export interface CardWords {
74
+ eyebrow: string;
75
+ title: string;
76
+ text: string;
77
+ primary: string;
78
+ secondary: string;
79
+ badge: string;
80
+ }
81
+
82
+ export const CARD_WORDS: Readonly<CardWords> = {
83
+ eyebrow: 'Category',
84
+ title: 'Card title',
85
+ text: 'One or two sentences about this item.',
86
+ primary: 'Learn more',
87
+ secondary: 'Save',
88
+ badge: 'New',
89
+ };
90
+
91
+ /** The breakpoint whose layer stacks an "Image side" card (Breakpoint.Mobile, ≤767px). */
92
+ export const CARD_STACK_BREAKPOINT = 'mobile';
93
+
94
+ enum Type {
95
+ Container = 'Container',
96
+ Image = 'Image',
97
+ Heading = 'Heading',
98
+ Text = 'Text',
99
+ Icon = 'Icon',
100
+ ButtonBlock = 'ButtonBlock',
101
+ }
102
+
103
+ enum Style {
104
+ Card = 'card',
105
+ Box = 'box',
106
+ Media = 'media',
107
+ Eyebrow = 'eyebrow',
108
+ Title = 'h3',
109
+ Body = 'body',
110
+ Label = 'button-label',
111
+ Primary = 'button-primary',
112
+ Ghost = 'button-ghost',
113
+ Icon = 'icon',
114
+ Scrim = 'media-scrim',
115
+ }
116
+
117
+ enum Scheme {
118
+ Default = 'default',
119
+ Inverse = 'inverse',
120
+ }
121
+
122
+ const COLOR_SCHEME = 'colorScheme';
123
+ const LINK_TYPE = 'linkType';
124
+ const SAVE_ICON = 'ph ph-bookmark-simple';
125
+
126
+ /** A value an element style writes — read from the contract, never retyped. */
127
+ const styleValue = (style: string, trait: string): string => String(elementStyleById(style)?.base[trait] ?? '');
128
+ const CARD_PAD = styleValue(Style.Card, 'padding');
129
+ const IMAGE_RADIUS = styleValue(Style.Media, 'borderRadius');
130
+ const TITLE_SIZE = styleValue(Style.Title, 'fontSize');
131
+ const LEAD_SIZE = 'var(--iox-size-lead)';
132
+ const ZERO_BOX = '0px 0px 0px 0px';
133
+
134
+ /** What every card part holds in every layout — the recipe's box. */
135
+ const CARD_BASE: Readonly<Record<string, Readonly<Record<string, string>>>> = {
136
+ [CardRole.Card]: { display: 'flex', gap: '14px', width: '100%', height: '100%', minWidth: '0px', overflow: 'hidden', position: 'relative' },
137
+ [CardRole.Media]: { display: 'block', width: '100%', minHeight: 'auto', flexGrow: '0', flexShrink: '0' },
138
+ [CardRole.Content]: { display: 'flex', flexDirection: 'column', gap: '8px', width: '100%', minWidth: '0px', minHeight: 'auto' },
139
+ [CardRole.Eyebrow]: { margin: ZERO_BOX, padding: ZERO_BOX, minHeight: 'auto' },
140
+ [CardRole.Title]: { margin: ZERO_BOX },
141
+ [CardRole.Text]: { margin: ZERO_BOX, padding: ZERO_BOX, minHeight: 'auto' },
142
+ // Buttons pinned to the bottom (board 8 Q: equal heights + buttons pinned): the content column grows,
143
+ // and the row's auto top margin takes the slack.
144
+ [CardRole.Actions]: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '8px', margin: 'auto 0px 0px 0px', padding: '6px 0px 0px 0px', width: '100%', minHeight: 'auto' },
145
+ };
146
+ const IMAGE_BASE: Readonly<Record<string, string>> = { display: 'block', width: '100%', maxWidth: '100%' };
147
+ /** A button's label and a pill's words: inherit the type and ink of what holds them. */
148
+ const LABEL_BASE: Readonly<Record<string, string>> = { width: 'auto', minHeight: 'auto', padding: ZERO_BOX, margin: ZERO_BOX };
149
+ const INLINE_ICON: Readonly<Record<string, string>> = { fontSize: '1em' };
150
+
151
+ /** One layout preset: the style values it writes per role (every layout writes the same keys, so
152
+ * switching is clean and detection exact), where the badge sits, and what it switches off. */
153
+ interface CardLayoutSpec {
154
+ card: Readonly<Record<string, string>>;
155
+ media: Readonly<Record<string, string>>;
156
+ image: Readonly<Record<string, string>>;
157
+ content: Readonly<Record<string, string>>;
158
+ contentStyle: Style;
159
+ contentScheme: Scheme;
160
+ title: Readonly<Record<string, string>>;
161
+ badgeOnMedia: boolean;
162
+ /** The stacking layer (Image side only); every other layout clears these keys from it. */
163
+ stack?: { card: Readonly<Record<string, string>>; media: Readonly<Record<string, string>>; image: Readonly<Record<string, string>> };
164
+ partsOff?: ReadonlyArray<CardPart>;
165
+ }
166
+
167
+ const IN_FLOW = { position: 'relative', top: 'auto', right: 'auto', bottom: 'auto', left: 'auto' };
168
+ const GROWING_CONTENT = { position: 'static', flexGrow: '1', flexShrink: '1', flexBasis: '0%', padding: ZERO_BOX };
169
+
170
+ const LAYOUTS: Readonly<Record<CardLayout, CardLayoutSpec>> = {
171
+ [CardLayout.Top]: {
172
+ card: { flexDirection: 'column', alignItems: 'stretch', justifyContent: 'flex-start', minHeight: 'auto', padding: CARD_PAD },
173
+ media: { ...IN_FLOW, flexBasis: 'auto' },
174
+ image: { aspectRatio: '4 / 3', height: 'auto', minHeight: 'auto', borderRadius: IMAGE_RADIUS },
175
+ content: GROWING_CONTENT, contentStyle: Style.Box, contentScheme: Scheme.Default,
176
+ title: { fontSize: TITLE_SIZE },
177
+ badgeOnMedia: true,
178
+ },
179
+ [CardLayout.Side]: {
180
+ card: { flexDirection: 'row', alignItems: 'stretch', justifyContent: 'flex-start', minHeight: 'auto', padding: CARD_PAD },
181
+ media: { ...IN_FLOW, flexBasis: '42%' },
182
+ image: { aspectRatio: 'auto', height: '100%', minHeight: '180px', borderRadius: IMAGE_RADIUS },
183
+ content: GROWING_CONTENT, contentStyle: Style.Box, contentScheme: Scheme.Default,
184
+ title: { fontSize: TITLE_SIZE },
185
+ badgeOnMedia: true,
186
+ stack: {
187
+ card: { flexDirection: 'column' },
188
+ media: { flexBasis: 'auto' },
189
+ image: { aspectRatio: '4 / 3', height: 'auto', minHeight: 'auto' },
190
+ },
191
+ },
192
+ [CardLayout.Overlay]: {
193
+ card: { flexDirection: 'column', alignItems: 'stretch', justifyContent: 'flex-end', minHeight: '340px', padding: ZERO_BOX },
194
+ media: { position: 'absolute', top: '0px', right: '0px', bottom: '0px', left: '0px', flexBasis: 'auto' },
195
+ image: { aspectRatio: 'auto', height: '100%', minHeight: 'auto', borderRadius: '0px' },
196
+ content: { position: 'relative', flexGrow: '0', flexShrink: '0', flexBasis: 'auto', padding: `72px ${CARD_PAD} ${CARD_PAD} ${CARD_PAD}` },
197
+ contentStyle: Style.Scrim, contentScheme: Scheme.Inverse,
198
+ title: { fontSize: TITLE_SIZE },
199
+ badgeOnMedia: false,
200
+ },
201
+ [CardLayout.Compact]: {
202
+ card: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', minHeight: 'auto', padding: CARD_PAD },
203
+ media: { ...IN_FLOW, flexBasis: '72px' },
204
+ image: { aspectRatio: '1 / 1', height: 'auto', minHeight: 'auto', borderRadius: IMAGE_RADIUS },
205
+ content: GROWING_CONTENT, contentStyle: Style.Box, contentScheme: Scheme.Default,
206
+ title: { fontSize: LEAD_SIZE },
207
+ badgeOnMedia: false,
208
+ partsOff: [CardPart.Text, CardPart.Actions],
209
+ },
210
+ };
211
+
212
+ const STACK_KEYS = {
213
+ card: Object.keys(LAYOUTS[CardLayout.Side].stack!.card),
214
+ media: Object.keys(LAYOUTS[CardLayout.Side].stack!.media),
215
+ image: Object.keys(LAYOUTS[CardLayout.Side].stack!.image),
216
+ };
217
+
218
+ /** What a type's UNSTYLED node holds — passed so a style switch (Content ⇄ the scrim) hands the old
219
+ * style's values back to the type, never to `undefined`. */
220
+ export type BaselineStyles = (type: string) => Readonly<Record<string, unknown>>;
221
+
222
+ export interface CardEditOptions {
223
+ baselineStyles?: BaselineStyles;
224
+ }
225
+
226
+ export interface CardOptions extends CardEditOptions {
227
+ layout?: CardLayout;
228
+ /** Which optional parts the card starts with (all, by default). */
229
+ parts?: Partial<Record<CardPart, boolean>>;
230
+ words?: Partial<CardWords>;
231
+ badgeTone?: BadgeTone;
232
+ }
233
+
234
+
235
+ function make(create: CompositeNodeFactory, key: string, type: string, role: CardRole | null, elementStyle: string | null,
236
+ props: Record<string, unknown> = {}, label?: string): CompositeNode {
237
+ const node = create({ key, type, props, elementStyle });
238
+ if (role) node.role = role;
239
+ if (label) node.label = label;
240
+ writeStyles(node, role ? CARD_BASE[role] ?? {} : {});
241
+ return node;
242
+ }
243
+
244
+ function buttonLabel(create: CompositeNodeFactory, key: string, words: string): CompositeNode {
245
+ const label = make(create, key, Type.Text, null, Style.Label, { content: words, tag: 'span' });
246
+ writeStyles(label, LABEL_BASE);
247
+ return label;
248
+ }
249
+
250
+ /** Each optional part (and the locked title) as the recipe builds it. */
251
+ const PART_BUILDERS: Readonly<Record<CardPart, (create: CompositeNodeFactory, words: CardWords, tone: BadgeTone) => CompositeNode>> = {
252
+ [CardPart.Media]: (create) => {
253
+ const media = make(create, 'media', Type.Container, CardRole.Media, Style.Box, {}, 'Media');
254
+ const image = make(create, 'image', Type.Image, null, Style.Media, { src: '', alt: '' });
255
+ writeStyles(image, IMAGE_BASE);
256
+ media.children = [image];
257
+ return media;
258
+ },
259
+ [CardPart.Badge]: (create, words, tone) => {
260
+ const badge = buildBadge(create, { tone, text: words.badge, key: 'badge' });
261
+ badge.role = CardRole.Badge;
262
+ badge.label = 'Badge';
263
+ return badge;
264
+ },
265
+ [CardPart.Eyebrow]: (create, words) => make(create, 'eyebrow', Type.Text, CardRole.Eyebrow, Style.Eyebrow, { content: words.eyebrow, tag: 'p' }, 'Eyebrow'),
266
+ [CardPart.Text]: (create, words) => make(create, 'text', Type.Text, CardRole.Text, Style.Body, { content: words.text, tag: 'p' }, 'Text'),
267
+ [CardPart.Actions]: (create, words) => {
268
+ const actions = make(create, 'actions', Type.Container, CardRole.Actions, Style.Box, {}, 'Buttons');
269
+ const primary = make(create, 'primary', Type.ButtonBlock, null, Style.Primary);
270
+ primary.children = [buttonLabel(create, 'primary-label', words.primary)];
271
+ const ghost = make(create, 'secondary', Type.ButtonBlock, null, Style.Ghost);
272
+ const icon = make(create, 'secondary-icon', Type.Icon, null, Style.Icon, { iconClass: SAVE_ICON });
273
+ writeStyles(icon, INLINE_ICON);
274
+ ghost.children = [icon, buttonLabel(create, 'secondary-label', words.secondary)];
275
+ actions.children = [primary, ghost];
276
+ return actions;
277
+ },
278
+ };
279
+
280
+ function buildTitle(create: CompositeNodeFactory, words: CardWords): CompositeNode {
281
+ return make(create, 'title', Type.Heading, CardRole.Title, Style.Title, { content: words.title, level: 'h3', [LINK_TYPE]: LinkType.None }, 'Title');
282
+ }
283
+
284
+ /** A fresh Card v2: every part a node with its role and element style, laid out by `options.layout`. */
285
+ export function buildCardRecipe(create: CompositeNodeFactory, options: CardOptions = {}): CompositeNode {
286
+ const words: CardWords = { ...CARD_WORDS, ...(options.words ?? {}) };
287
+ const tone = options.badgeTone ?? BadgeTone.Accent;
288
+ const on = (part: CardPart) => options.parts?.[part] !== false;
289
+ const card = make(create, 'card', Type.Container, CardRole.Card, Style.Card, {}, 'Card');
290
+ const content = make(create, 'content', Type.Container, CardRole.Content, Style.Box, {}, 'Content');
291
+ content.children = [
292
+ ...(on(CardPart.Eyebrow) ? [PART_BUILDERS[CardPart.Eyebrow](create, words, tone)] : []),
293
+ buildTitle(create, words),
294
+ ...(on(CardPart.Text) ? [PART_BUILDERS[CardPart.Text](create, words, tone)] : []),
295
+ ...(on(CardPart.Actions) ? [PART_BUILDERS[CardPart.Actions](create, words, tone)] : []),
296
+ ];
297
+ card.children = [...(on(CardPart.Media) ? [PART_BUILDERS[CardPart.Media](create, words, tone)] : []), content];
298
+ if (on(CardPart.Badge)) placeBadge(card, PART_BUILDERS[CardPart.Badge](create, words, tone), true);
299
+ applyCardLayout(card, options.layout ?? CardLayout.Top, options);
300
+ return card;
301
+ }
302
+
303
+ /** True when `node` is a Card v2 root. */
304
+ export function isCardRoot(node: CompositeNode | null | undefined): boolean {
305
+ return node?.role === CardRole.Card;
306
+ }
307
+
308
+ /** The Card a part belongs to: the nearest ancestor (or the node itself) playing `card`. `path` = the
309
+ * node's ancestors, root first. */
310
+ export function cardRootOf(node: CompositeNode, path: ReadonlyArray<CompositeNode>): CompositeNode | null {
311
+ if (isCardRoot(node)) return node;
312
+ for (let i = path.length - 1; i >= 0; i--) if (isCardRoot(path[i])) return path[i];
313
+ return null;
314
+ }
315
+
316
+ /** Card ▸ Parts: which optional parts the card holds now. */
317
+ export function cardPartsOf(card: CompositeNode): Record<CardPart, boolean> {
318
+ return Object.fromEntries(CARD_PARTS.map(part => [part, !!findByRole(card, CARD_PART_ROLE[part])])) as Record<CardPart, boolean>;
319
+ }
320
+
321
+ const imageOf = (media: CompositeNode | undefined): CompositeNode | undefined => media?.children?.find(c => c.type === Type.Image);
322
+
323
+ /** Put the badge where the layout says: the Media's top corner, or the head of the content column
324
+ * (Image behind text, Compact, or no Media at all). */
325
+ function placeBadge(card: CompositeNode, badge: CompositeNode, onMedia: boolean): void {
326
+ const media = findByRole(card, CardRole.Media);
327
+ const content = findByRole(card, CardRole.Content);
328
+ removeFromComposite(card, badge);
329
+ if (onMedia && media) media.children = [...(media.children ?? []), badge];
330
+ else if (content) content.children = [badge, ...(content.children ?? [])];
331
+ }
332
+
333
+ function writeScheme(node: CompositeNode, scheme: Scheme): void {
334
+ writeProp(node, COLOR_SCHEME, scheme);
335
+ }
336
+
337
+ /** Write one role's layout values from a spec (base + layers). */
338
+ function applyPartLayout(card: CompositeNode, spec: CardLayoutSpec, part: CompositeNode, baseline: BaselineStyles): void {
339
+ switch (part.role) {
340
+ case CardRole.Card:
341
+ writeStyles(part, { ...CARD_BASE[CardRole.Card], ...spec.card });
342
+ setStack(part, spec.stack?.card, STACK_KEYS.card);
343
+ return;
344
+ case CardRole.Media: {
345
+ writeStyles(part, { ...CARD_BASE[CardRole.Media], ...spec.media });
346
+ setStack(part, spec.stack?.media, STACK_KEYS.media);
347
+ const image = imageOf(part);
348
+ if (image) {
349
+ writeStyles(image, { ...IMAGE_BASE, ...spec.image });
350
+ setStack(image, spec.stack?.image, STACK_KEYS.image);
351
+ }
352
+ return;
353
+ }
354
+ case CardRole.Content: {
355
+ const current = part.elementStyle;
356
+ if (current !== spec.contentStyle) {
357
+ switchElementStyle(part, current, spec.contentStyle, baseline(part.type));
358
+ part.elementStyle = spec.contentStyle;
359
+ }
360
+ writeStyles(part, { ...CARD_BASE[CardRole.Content], ...spec.content });
361
+ writeScheme(part, spec.contentScheme);
362
+ return;
363
+ }
364
+ case CardRole.Title:
365
+ writeStyles(part, spec.title);
366
+ return;
367
+ default:
368
+ return;
369
+ }
370
+ }
371
+
372
+ function setStack(node: CompositeNode, values: Readonly<Record<string, string>> | undefined, keys: ReadonlyArray<string>): void {
373
+ if (values) writeLayerStyles(node, CARD_STACK_BREAKPOINT, values);
374
+ else clearLayerStyles(node, CARD_STACK_BREAKPOINT, keys);
375
+ }
376
+
377
+ export interface CardLayoutResult {
378
+ layout: CardLayout;
379
+ /** Parts the preset switched off (Compact: Text and Buttons). */
380
+ removed: CardPart[];
381
+ }
382
+
383
+ /**
384
+ * Card ▸ Layout. Rewrites the card's LAYOUT styles for `layout` — the card's direction and box, the
385
+ * Media's placement, the Image's aspect, the content block (Image behind text: the `media-scrim` style +
386
+ * the Inverse scheme), the title size (Compact) and, for Image side, a Mobile layer that stacks it — and
387
+ * moves the badge to where the layout puts it. Nothing else on any part. One undo step for the caller.
388
+ */
389
+ export function applyCardLayout(card: CompositeNode, layout: CardLayout, options: CardEditOptions = {}): CardLayoutResult {
390
+ const spec = LAYOUTS[layout];
391
+ const baseline = options.baselineStyles ?? (() => ({}));
392
+ const removed: CardPart[] = [];
393
+ for (const part of spec.partsOff ?? []) if (setCardPart(card, part, false)) removed.push(part);
394
+ applyPartLayout(card, spec, card, baseline);
395
+ for (const role of [CardRole.Media, CardRole.Content, CardRole.Title]) {
396
+ const part = findByRole(card, role);
397
+ if (part) applyPartLayout(card, spec, part, baseline);
398
+ }
399
+ const badge = findByRole(card, CardRole.Badge);
400
+ if (badge) placeBadge(card, badge, spec.badgeOnMedia);
401
+ return { layout, removed };
402
+ }
403
+
404
+ const same = (a: unknown, b: unknown) => String(a ?? '').trim() === String(b ?? '').trim();
405
+ const holds = (node: CompositeNode, values: Readonly<Record<string, string>>) => Object.entries(values).every(([k, v]) => same(readStyle(node, k), v));
406
+ const holdsLayer = (node: CompositeNode, values: Readonly<Record<string, string>> | undefined, keys: ReadonlyArray<string>) =>
407
+ values ? Object.entries(values).every(([k, v]) => same(readLayerStyle(node, CARD_STACK_BREAKPOINT, k), v))
408
+ : keys.every(k => readLayerStyle(node, CARD_STACK_BREAKPOINT, k) === undefined);
409
+
410
+ function matches(card: CompositeNode, spec: CardLayoutSpec): boolean {
411
+ if (!holds(card, spec.card) || !holdsLayer(card, spec.stack?.card, STACK_KEYS.card)) return false;
412
+ const media = findByRole(card, CardRole.Media);
413
+ if (media) {
414
+ if (!holds(media, spec.media) || !holdsLayer(media, spec.stack?.media, STACK_KEYS.media)) return false;
415
+ const image = imageOf(media);
416
+ if (image && (!holds(image, spec.image) || !holdsLayer(image, spec.stack?.image, STACK_KEYS.image))) return false;
417
+ }
418
+ const content = findByRole(card, CardRole.Content);
419
+ if (content) {
420
+ if (content.elementStyle !== spec.contentStyle || !holds(content, spec.content)) return false;
421
+ if (!same(readProp(content, COLOR_SCHEME) ?? Scheme.Default, spec.contentScheme)) return false;
422
+ }
423
+ const title = findByRole(card, CardRole.Title);
424
+ if (title && !holds(title, spec.title)) return false;
425
+ const badge = findByRole(card, CardRole.Badge);
426
+ if (badge && media) {
427
+ const onMedia = parentInComposite(card, badge) === media;
428
+ if (onMedia !== spec.badgeOnMedia) return false;
429
+ }
430
+ return true;
431
+ }
432
+
433
+ /** The layout the card's styles hold now, or `custom` once they were changed by hand (Card ▸ Layout). */
434
+ export function detectCardLayout(card: CompositeNode): CardLayoutState {
435
+ return CARD_LAYOUTS.find(layout => matches(card, LAYOUTS[layout])) ?? CARD_LAYOUT_CUSTOM;
436
+ }
437
+
438
+ export interface CardPartChange {
439
+ part: CardPart;
440
+ /** The node removed (off) or stamped (on); null when there was nothing to do. */
441
+ node: CompositeNode | null;
442
+ }
443
+
444
+ /**
445
+ * Card ▸ Parts. Off removes the part (the badge first moves out of a removed Media); on stamps it back
446
+ * from the recipe into its role's slot, wearing its element style and laid out like the rest of the card
447
+ * (the card's current layout; Image top when it is Custom). Returns the node touched, or null when the
448
+ * card already was that way. Title and Content are not parts: they are locked (structure guard).
449
+ */
450
+ export function setCardPart(card: CompositeNode, part: CardPart, on: boolean, create?: CompositeNodeFactory, options: CardOptions = {}): CompositeNode | null {
451
+ const role = CARD_PART_ROLE[part];
452
+ const existing = findByRole(card, role);
453
+ if (!on) {
454
+ if (!existing) return null;
455
+ if (part === CardPart.Media) {
456
+ const badge = findByRole(existing, CardRole.Badge);
457
+ if (badge) placeBadge(card, badge, false);
458
+ }
459
+ removeFromComposite(card, existing);
460
+ return existing;
461
+ }
462
+ if (existing || !create) return null;
463
+ const layout = detectCardLayout(card);
464
+ const spec = LAYOUTS[layout === CARD_LAYOUT_CUSTOM ? CardLayout.Top : layout];
465
+ const words: CardWords = { ...CARD_WORDS, ...(options.words ?? {}) };
466
+ const node = PART_BUILDERS[part](create, words, options.badgeTone ?? BadgeTone.Accent);
467
+ const content = findByRole(card, CardRole.Content);
468
+ switch (part) {
469
+ case CardPart.Media: {
470
+ card.children = [node, ...(card.children ?? [])];
471
+ applyPartLayout(card, spec, node, () => ({}));
472
+ const badge = findByRole(card, CardRole.Badge);
473
+ if (badge) placeBadge(card, badge, spec.badgeOnMedia);
474
+ break;
475
+ }
476
+ case CardPart.Badge:
477
+ placeBadge(card, node, spec.badgeOnMedia);
478
+ break;
479
+ default:
480
+ if (content) insertInContent(content, part, node);
481
+ break;
482
+ }
483
+ return node;
484
+ }
485
+
486
+ /** The content column's order: [badge], eyebrow, title, text, buttons. */
487
+ const CONTENT_ORDER: ReadonlyArray<string> = [CardRole.Badge, CardRole.Eyebrow, CardRole.Title, CardRole.Text, CardRole.Actions];
488
+
489
+ function insertInContent(content: CompositeNode, part: CardPart, node: CompositeNode): void {
490
+ const rank = (n: CompositeNode) => { const i = CONTENT_ORDER.indexOf(n.role ?? ''); return i < 0 ? CONTENT_ORDER.length : i; };
491
+ const children = [...(content.children ?? [])];
492
+ const own = rank(node);
493
+ const at = children.findIndex(c => rank(c) > own);
494
+ children.splice(at < 0 ? children.length : at, 0, node);
495
+ content.children = children;
496
+ }
497
+
498
+ // ── Link the whole card ──────────────────────────────────────────────────────────────────────
499
+
500
+ /** The card's title — what "Link the whole card" makes the link (its words are the link's name). */
501
+ export function cardTitleOf(card: CompositeNode): CompositeNode | undefined {
502
+ return findByRole(card, CardRole.Title);
503
+ }
504
+
505
+ /** True when the card links as a whole: its title carries a link (the stretched link renders from it). */
506
+ export function isCardLinked(card: CompositeNode): boolean {
507
+ const title = cardTitleOf(card);
508
+ if (!title) return false;
509
+ const type = readProp(title, LINK_TYPE);
510
+ return type === LinkType.Page || type === LinkType.External;
511
+ }
512
+
513
+ /**
514
+ * Card ▸ Link ▸ "Link the whole card". On: the title links (to a website page, unless it already pointed
515
+ * at a URL — the target it held is kept); off: the title links nowhere (its target is kept for the next
516
+ * time). The stretched link itself is the shared CSS (iox-design.css), keyed on the title's anchor.
517
+ */
518
+ export function setCardLinked(card: CompositeNode, on: boolean): boolean {
519
+ const title = cardTitleOf(card);
520
+ if (!title) return false;
521
+ const current = readProp(title, LINK_TYPE);
522
+ const next = on ? (current === LinkType.External ? LinkType.External : LinkType.Page) : LinkType.None;
523
+ return writeProp(title, LINK_TYPE, next);
524
+ }
@@ -0,0 +1,143 @@
1
+ import { ElementStyleTarget, readOwnedTrait, writeOwnedTrait } from '@vectoriox/iox-ui/design-core';
2
+
3
+ /**
4
+ * The node shape composite-core reads and writes — both a builder `ComponentNode` (values in
5
+ * `traits[].default` / `styleTraits`) and a saved layout node (flat `props` / `styleProps`), the same
6
+ * dual shape design-core's element styles already speak. A composite is a tree of ORDINARY nodes
7
+ * (composite-components plan §1): this module only adds the `role` each part carries.
8
+ */
9
+ export interface CompositeTrait {
10
+ name: string;
11
+ default?: unknown;
12
+ }
13
+
14
+ export interface CompositeLayer {
15
+ styleProps?: Record<string, unknown>;
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ export interface CompositeNode extends ElementStyleTarget {
20
+ id?: string;
21
+ /** The part this node plays in its composite (`card.title`). Additive; saved; read by the shared CSS
22
+ * (`iox-role-<role>`), the structure guard and the composite's own editors. */
23
+ role?: string;
24
+ label?: string;
25
+ props?: Record<string, unknown>;
26
+ traits?: CompositeTrait[];
27
+ responsive?: Partial<Record<string, CompositeLayer>>;
28
+ children?: CompositeNode[];
29
+ }
30
+
31
+ /** What a recipe asks its consumer to create: a NEW node of `type`, the way that consumer creates one
32
+ * (the builder's `createConfig`, generation-core's `createSerializedNode`), with these props and this
33
+ * element style. `key` is stable per recipe node — a consumer that needs deterministic ids builds them
34
+ * from it (generation-core: `<root id>-<key>`). */
35
+ export interface CompositeNodeSpec {
36
+ key: string;
37
+ type: string;
38
+ props?: Readonly<Record<string, unknown>>;
39
+ elementStyle?: string | null;
40
+ }
41
+
42
+ export type CompositeNodeFactory = (spec: CompositeNodeSpec) => CompositeNode;
43
+
44
+ const usesTraits = (node: CompositeNode): boolean => Array.isArray(node.traits);
45
+
46
+ /** A content trait's value — `traits[].default` on a builder node, `props[name]` on a saved one. */
47
+ export function readProp(node: CompositeNode, name: string): unknown {
48
+ return usesTraits(node) ? node.traits!.find(t => t.name === name)?.default : node.props?.[name];
49
+ }
50
+
51
+ /** Write a content trait. False when a builder node has no such trait (the value would be dropped). */
52
+ export function writeProp(node: CompositeNode, name: string, value: unknown): boolean {
53
+ if (usesTraits(node)) {
54
+ const trait = node.traits!.find(t => t.name === name);
55
+ if (!trait) return false;
56
+ trait.default = value;
57
+ return true;
58
+ }
59
+ node.props = { ...(node.props ?? {}), [name]: value };
60
+ return true;
61
+ }
62
+
63
+ /** A base style value (the node's own, either shape). */
64
+ export function readStyle(node: CompositeNode, name: string): unknown {
65
+ return readOwnedTrait(node, { trait: name });
66
+ }
67
+
68
+ /** Write base style values; returns the names the node has no slot for (a builder node drops them). */
69
+ export function writeStyles(node: CompositeNode, values: Readonly<Record<string, string>>): string[] {
70
+ const missing: string[] = [];
71
+ for (const [name, value] of Object.entries(values)) {
72
+ if (!writeOwnedTrait(node, { trait: name }, value)) missing.push(name);
73
+ }
74
+ return missing;
75
+ }
76
+
77
+ /** Set style values in one breakpoint layer (`responsive[bp].styleProps`, the shape both renderers'
78
+ * shared `planResponsiveRules` reads). */
79
+ export function writeLayerStyles(node: CompositeNode, breakpoint: string, values: Readonly<Record<string, string>>): void {
80
+ const responsive = { ...(node.responsive ?? {}) };
81
+ const layer: CompositeLayer = { ...(responsive[breakpoint] ?? {}) };
82
+ layer.styleProps = { ...(layer.styleProps ?? {}), ...values };
83
+ responsive[breakpoint] = layer;
84
+ node.responsive = responsive;
85
+ }
86
+
87
+ /** Remove style keys from one breakpoint layer; an emptied layer (and an emptied map) is dropped. */
88
+ export function clearLayerStyles(node: CompositeNode, breakpoint: string, names: ReadonlyArray<string>): void {
89
+ const layer = node.responsive?.[breakpoint];
90
+ if (!layer?.styleProps) return;
91
+ const styleProps = { ...layer.styleProps };
92
+ for (const name of names) delete styleProps[name];
93
+ const next: CompositeLayer = { ...layer };
94
+ if (Object.keys(styleProps).length) next.styleProps = styleProps;
95
+ else delete next.styleProps;
96
+ const responsive = { ...(node.responsive ?? {}) };
97
+ if (Object.keys(next).length) responsive[breakpoint] = next;
98
+ else delete responsive[breakpoint];
99
+ node.responsive = Object.keys(responsive).length ? responsive : undefined;
100
+ }
101
+
102
+ /** A breakpoint layer's own value for one style key. */
103
+ export function readLayerStyle(node: CompositeNode, breakpoint: string, name: string): unknown {
104
+ return node.responsive?.[breakpoint]?.styleProps?.[name];
105
+ }
106
+
107
+ /** Depth-first: `node`, then every descendant. */
108
+ export function walkComposite(node: CompositeNode, visit: (node: CompositeNode, parent: CompositeNode | null) => void, parent: CompositeNode | null = null): void {
109
+ visit(node, parent);
110
+ for (const child of node.children ?? []) walkComposite(child, visit, node);
111
+ }
112
+
113
+ /**
114
+ * The first descendant of `root` playing `role`. Never descends into a NESTED composite of the same
115
+ * root role (a card inside a card's content keeps its own parts).
116
+ */
117
+ export function findByRole(root: CompositeNode, role: string): CompositeNode | undefined {
118
+ for (const child of root.children ?? []) {
119
+ if (child.role === role) return child;
120
+ if (child.role === root.role && root.role) continue;
121
+ const hit = findByRole(child, role);
122
+ if (hit) return hit;
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ /** The node's parent inside `root`, or null. */
128
+ export function parentInComposite(root: CompositeNode, node: CompositeNode): CompositeNode | null {
129
+ for (const child of root.children ?? []) {
130
+ if (child === node) return root;
131
+ const hit = parentInComposite(child, node);
132
+ if (hit) return hit;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /** Detach `node` from its parent inside `root`. True when it was found. */
138
+ export function removeFromComposite(root: CompositeNode, node: CompositeNode): boolean {
139
+ const parent = parentInComposite(root, node);
140
+ if (!parent?.children) return false;
141
+ parent.children.splice(parent.children.indexOf(node), 1);
142
+ return true;
143
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @vectoriox/iox-ui/composite-core — composites built from the builder's own parts (composite-components
3
+ * plan §3a). FRAMEWORK-FREE: the builder, the SSR engine and iox-cms (generation-core's composer, the site
4
+ * build) read the same recipes, roles and structure rules. Card v2 + Badge / Chip (T23 / T25) are the
5
+ * first; Accordion and Tabs join with their own phases.
6
+ * ⚠️ Consumers wire the usual trio — ng-package raw-ts assets, jest moduleNameMapper, tsconfig paths.
7
+ */
8
+ export * from './composite-node';
9
+ export * from './badge';
10
+ export * from './card';
11
+ export * from './structure';