@vectoriox/iox-ui 5.56.1 → 5.58.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 (36) hide show
  1. package/composite-core/index.d.ts +3 -3
  2. package/design-core/index.d.ts +40 -2
  3. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +6 -10
  4. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  5. package/fesm2022/vectoriox-iox-ui-design-core.mjs +46 -1
  6. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  7. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +4231 -897
  8. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  9. package/fesm2022/vectoriox-iox-ui-harness-core.mjs +24 -10
  10. package/fesm2022/vectoriox-iox-ui-harness-core.mjs.map +1 -1
  11. package/fesm2022/vectoriox-iox-ui.mjs +27 -1
  12. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  13. package/generation-core/index.d.ts +3774 -1101
  14. package/harness-core/index.d.ts +24 -9
  15. package/index.d.ts +22 -1
  16. package/package.json +1 -1
  17. package/src/composite-core/accordion-runtime.ts +2 -1
  18. package/src/composite-core/lightbox.ts +5 -6
  19. package/src/design-core/entrance-timing.ts +56 -0
  20. package/src/design-core/public-api.ts +1 -0
  21. package/src/generation-core/ai-run.ts +21 -0
  22. package/src/generation-core/audit-site.ts +32 -0
  23. package/src/generation-core/compose-site.ts +213 -42
  24. package/src/generation-core/entrances.ts +82 -13
  25. package/src/generation-core/patterns.ts +127 -15
  26. package/src/generation-core/public-api.ts +3 -2
  27. package/src/generation-core/showcase.ts +81 -0
  28. package/src/generation-core/site-plan-schema.ts +21 -8
  29. package/src/generation-core/site-plan.model.ts +75 -4
  30. package/src/generation-core/site-spec.generated.ts +4019 -1174
  31. package/src/generation-core/validate-site-plan.ts +129 -21
  32. package/src/harness-core/harness-spec.generated.ts +3 -3
  33. package/src/harness-core/public-api.ts +1 -0
  34. package/src/harness-core/reference-colour.ts +15 -3
  35. package/src/harness-core/site-audit-view.ts +10 -0
  36. package/src/harness-core/starting-design.ts +8 -4
@@ -1,4 +1,5 @@
1
- import { COLOR_SCHEMES, DesignChoices, LinkType, resolveDesignChoices, setColorScheme } from '@vectoriox/iox-ui/design-core';
1
+ import { AnimateId, COLOR_SCHEMES, DesignChoices, LinkType, resolveDesignChoices, setColorScheme } from '@vectoriox/iox-ui/design-core';
2
+ import { SEO_DESCRIPTION_MAX, SEO_TITLE_MAX, SeoRawDoc } from '@vectoriox/iox-ui/seo-core';
2
3
  import { FormPresetDef, FormPresetField, formPresetById } from '@vectoriox/iox-ui/forms-core';
3
4
  import {
4
5
  ACCORDION_BIND_PROP, ACCORDION_CONTENT_LIMIT, ACCORDION_WORDS, accordionAnswerOf, accordionItems, accordionQuestionOf, AccordionRole,
@@ -8,12 +9,15 @@ import {
8
9
  TABS_TEXT_BIND_PROP, TABS_WORDS, BadgeTone, BADGE_TONES, buildSegmentedRecipe, CardWords, ITEM_OPEN_STATE, segmentedOptions,
9
10
  } from '@vectoriox/iox-ui/composite-core';
10
11
 
12
+ import { EntranceMark, EntrancePart, planEntrances } from './entrances';
11
13
  import { NODE_BASELINES } from './node-baselines.generated';
12
- import { BindScope, CardBindTarget, PatternDirective, PatternNode, PatternOpens, PatternPlaceholder, PatternRecipe, SitePattern, patternById } from './patterns';
13
- import { assertStyleSlots, ComposeError, createSerializedNode, SerializedBinding, SerializedNode, setStyleProps } from './serialized-node';
14
14
  import {
15
- ContentTypePlan, DataSourcePlan, FactKey, FACT_KEYS, GlobalSlot, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType,
16
- RelativeOp, SitePlan, SitePlanSource, SITE_FACT_LABELS, SiteBusiness,
15
+ BindScope, CardBindTarget, parsePatternPath, PatternDirective, PatternNode, PatternOpens, PatternPlaceholder, PatternRecipe, SitePattern, patternById,
16
+ } from './patterns';
17
+ import { assertStyleSlots, ComposeError, createSerializedNode, SerializedBinding, SerializedNode, setStyleProps, walkSerialized } from './serialized-node';
18
+ import {
19
+ ContentRole, ContentTypePlan, DataSourcePlan, DEFAULT_SITE_LOCALE, FactKey, FACT_KEYS, GlobalSlot, isRtlLocale, PagePlan, PageSectionPlan, PlanDataSourceMode,
20
+ PlanDataSourceType, RelativeOp, roleField, SiteLocale, SitePlan, SitePlanSource, SITE_FACT_LABELS, SiteBusiness,
17
21
  } from './site-plan.model';
18
22
  import { assertValidSitePlan, nextSourceOf, routeParams } from './validate-site-plan';
19
23
  import { NavShape, planNavShape } from './nav-shape';
@@ -44,11 +48,14 @@ export enum PlaceholderKind {
44
48
  /** CC4b (Q37): a price — a FACT the customer could be held to. Free text the studio types ("₪ 1,200", "From $40",
45
49
  * "On request"); its label shows meanwhile ("Monthly price"), never a number. No currency model. */
46
50
  Price = 'price',
51
+ /** L2 (`proof.stat-row`): a number the studio stands behind ("12 years", "300 clients") — a fact, its label shown
52
+ * meanwhile ("Number"), never a number written for it. */
53
+ Stat = 'stat',
47
54
  }
48
55
 
49
- /** The placeholders a page cannot be published with unfilled (W4's gate): a fact, a price, the studio's own video.
50
- * Copy and an empty image are the author's to rewrite, never blocking. */
51
- export const PUBLISH_BLOCKING_PLACEHOLDERS: ReadonlySet<PlaceholderKind> = new Set([PlaceholderKind.Fact, PlaceholderKind.Price, PlaceholderKind.Video]);
56
+ /** The placeholders a page cannot be published with unfilled (W4's gate): a fact, a price, a number, the studio's own
57
+ * video. Copy and an empty image are the author's to rewrite, never blocking. */
58
+ export const PUBLISH_BLOCKING_PLACEHOLDERS: ReadonlySet<PlaceholderKind> = new Set([PlaceholderKind.Fact, PlaceholderKind.Price, PlaceholderKind.Stat, PlaceholderKind.Video]);
52
59
 
53
60
  /** True when a placeholder of `kind` blocks publishing until the studio fills it. */
54
61
  export function blocksPublish(kind: PlaceholderKind): boolean {
@@ -106,8 +113,13 @@ export interface ComposedPage {
106
113
  route: { path: string };
107
114
  isHome: boolean;
108
115
  status: 'draft';
116
+ /** The site's language (L0-2): the page's locale — the fonts it loads, the side its entrances slide from. */
117
+ locale: `${SiteLocale}`;
109
118
  /** DS3: every page the wizard creates follows the look (page-bg / text / font-body). */
110
119
  pageSettings: { designSystem: true };
120
+ /** M1-6c: the page's search-result words — written (a static page) or bound to the route item (a template page),
121
+ * in the seo-core document shape the shared `resolveSeo` reads. */
122
+ seo: SeoRawDoc;
111
123
  layout: SerializedNode[];
112
124
  }
113
125
 
@@ -234,6 +246,9 @@ const create = (id: string, type: string, props: Record<string, unknown> = {}, e
234
246
  interface RepeaterScope {
235
247
  /** The alias a row's children bind (`repeaterItemAlias`: the iterate-path's last segment, else the source). */
236
248
  itemAlias: string;
249
+ /** The data source whose items the rows are (its content type's roles), or null when the rows are bare values (a
250
+ * gallery's URLs). */
251
+ source: string | null;
237
252
  }
238
253
 
239
254
  interface Ctx {
@@ -258,6 +273,8 @@ interface Ctx {
258
273
  /** A section inserted from the palette (`composeSection`): a button to a page the studio lacks keeps its words,
259
274
  * unlinked — the author links it — instead of being removed. */
260
275
  keepButtons?: boolean;
276
+ /** M1-6b: the pattern's entrance parts as stamped, for `planEntrances` once the pattern is whole. */
277
+ entrances?: Array<Omit<EntranceMark, 'index'>>;
261
278
  }
262
279
 
263
280
  interface ShownWhen {
@@ -298,28 +315,66 @@ function chooseText(pn: PatternNode, ctx: Ctx): TextChoice {
298
315
  const own = pn.text === undefined ? undefined : fillVariables(pn.text, ctx);
299
316
  if (pn.ph === PatternPlaceholder.Copy) return { value: own, placeholder: { kind: PlaceholderKind.Copy } };
300
317
  if (pn.ph === PatternPlaceholder.Price) return { value: own, placeholder: { kind: PlaceholderKind.Price } };
318
+ if (pn.ph === PatternPlaceholder.Stat) return { value: own, placeholder: { kind: PlaceholderKind.Stat } };
301
319
  return { value: own };
302
320
  }
303
321
 
304
- /** A `bind` / `alt` / `param` / `src` path → the binding source + path, in this node's scope. */
305
- function resolveScoped(path: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): { source: string; path: string } {
306
- const [scope, ...rest] = path.split('.');
307
- const sub = rest.join('.');
322
+ interface ScopedPath {
323
+ source: string;
324
+ path: string;
325
+ }
326
+
327
+ /** The binding source of a scope, and the data source whose content type its item is (for its roles). */
328
+ function scopeOf(scope: string, path: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): { source: string; typeSource: string | null } {
308
329
  switch (scope) {
309
330
  case BindScope.Item:
310
331
  if (!repeater) throw new ComposeError(`${nodeId}: "${path}" outside a Repeater`);
311
- return { source: repeater.itemAlias, path: sub || ITEM_VALUE_PATH };
332
+ return { source: repeater.itemAlias, typeSource: repeater.source };
312
333
  case BindScope.Route:
313
334
  if (!ctx.page?.routeSource) throw new ComposeError(`${nodeId}: "${path}" on a page with no routeSource`);
314
- return { source: ctx.page.routeSource, path: sub || ITEM_VALUE_PATH };
335
+ return { source: ctx.page.routeSource, typeSource: ctx.page.routeSource };
315
336
  case BindScope.Next:
316
337
  if (!ctx.nextAlias) throw new ComposeError(`${nodeId}: "${path}" with no next-sibling source`);
317
- return { source: ctx.nextAlias, path: sub || ITEM_VALUE_PATH };
338
+ return { source: ctx.nextAlias, typeSource: ctx.nextAlias };
318
339
  default:
319
340
  throw new ComposeError(`${nodeId}: unknown binding scope "${scope}"`);
320
341
  }
321
342
  }
322
343
 
344
+ /**
345
+ * A `bind` / `alt` / `param` / `src` path → the binding source + path, in this node's scope. A ROLE (`item.@title`,
346
+ * L0-3) is the field of the item's content type that plays it; an optional role the type does not name (`@image?`)
347
+ * gives null — the part is left out. A required one it lacks is a plan the validator should have refused.
348
+ */
349
+ function resolveScopedPath(path: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): ScopedPath | null {
350
+ const parsed = parsePatternPath(path);
351
+ const { source, typeSource } = scopeOf(parsed.scope, path, repeater, ctx, nodeId);
352
+ if (!parsed.role) return { source, path: parsed.field || ITEM_VALUE_PATH };
353
+ const field = roleField(typeSource ? contentTypeOfSource(typeSource, ctx.plan) : undefined, parsed.role);
354
+ if (field) return { source, path: field };
355
+ if (parsed.optional) return null;
356
+ throw new ComposeError(`${nodeId}: "${path}" — the item's content type names no ${parsed.role} field`);
357
+ }
358
+
359
+ function resolveScoped(path: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): ScopedPath {
360
+ const scoped = resolveScopedPath(path, repeater, ctx, nodeId);
361
+ if (!scoped) throw new ComposeError(`${nodeId}: "${path}" names an optional role where one is required`);
362
+ return scoped;
363
+ }
364
+
365
+ /** The page that shows ONE item of `scope`'s content type (the plan's template page for it), and the binding of its
366
+ * route param — `detail: item | next` (L0-3). Null when the plan has no such page: the link is simply not made. */
367
+ function detailLinkOf(scope: string, repeater: RepeaterScope | null, ctx: Ctx, nodeId: string): { route: string; param: string; binding: ScopedPath } | null {
368
+ const { typeSource } = scopeOf(scope, scope, repeater, ctx, nodeId);
369
+ const type = typeSource ? contentTypeOfSource(typeSource, ctx.plan) : undefined;
370
+ const page = type ? ctx.plan.pages.find(p => p.template === type.name && !!p.routeSource) : undefined;
371
+ const byRoute = page ? ctx.plan.dataSources.find(d => d.alias === page.routeSource) : undefined;
372
+ const filter = byRoute && byRoute.type === PlanDataSourceType.Cms ? byRoute.filterByRouteParam : undefined;
373
+ const [param] = page ? routeParams(page.route) : [];
374
+ if (!page || !filter || !param) return null;
375
+ return { route: page.route, param, binding: resolveScoped(`${scope}.${filter.field}`, repeater, ctx, nodeId) };
376
+ }
377
+
323
378
  /** `page.<field>` on a node stamped for a navigation page: that page's own words, written in. */
324
379
  function stampedPageValue(path: string, ctx: Ctx, nodeId: string): string {
325
380
  const field = path.split('.').slice(1).join('.');
@@ -413,13 +468,12 @@ function reportDangling(fate: DanglingFate, pn: PatternNode, id: string, words:
413
468
  });
414
469
  }
415
470
 
416
- /** The alt binding of a bound Image: the pattern's `alt`, else the bound item's preview text field. */
417
- function altBinding(pn: PatternNode, repeater: RepeaterScope | null, ctx: Ctx, id: string): { source: string; path: string } {
471
+ /** The alt binding of a bound Image: the pattern's `alt`, else the bound item's title role (W1.1, Q7 — every content
472
+ * type names its title). */
473
+ function altBinding(pn: PatternNode, repeater: RepeaterScope | null, ctx: Ctx, id: string): ScopedPath {
418
474
  if (pn.alt) return resolveScoped(pn.alt, repeater, ctx, id);
419
- const bound = resolveScoped(pn.bind as string, repeater, ctx, id);
420
- const field = contentTypeOfSource(bound.source, ctx.plan)?.previewTextField;
421
- if (!field) throw new ComposeError(`${id}: a bound image needs an alt — the pattern names none and "${bound.source}" has no previewTextField`);
422
- return { source: bound.source, path: field };
475
+ const scope = parsePatternPath(pn.bind as string).scope;
476
+ return resolveScoped(`${scope}.@${ContentRole.Title}`, repeater, ctx, id);
423
477
  }
424
478
 
425
479
  /** A preset Form's children — the forms-core preset's fields, submit and the WORDS of both state blocks. */
@@ -446,8 +500,10 @@ function composeForm(node: SerializedNode, pn: PatternNode, id: string, ctx: Ctx
446
500
  node.children = [...(node.children ?? []), ...FORM_STATE_BLOCKS.map((type, i) => create(`${id}-${(pn.c?.length ?? 0) + i}`, type))];
447
501
  return;
448
502
  }
449
- const preset = formPresetById(pn.preset);
450
- if (!preset) throw new ComposeError(`${id}: no form preset "${pn.preset}" in forms-core`);
503
+ // A section may give the pattern's form another preset (`form`: Practice's contact page asks to book).
504
+ const presetId = ctx.section?.form ?? pn.preset;
505
+ const preset = formPresetById(presetId);
506
+ if (!preset) throw new ComposeError(`${id}: no form preset "${presetId}" in forms-core`);
451
507
  if (pn.c?.length) throw new ComposeError(`${id}: a preset Form takes its fields from the preset, not the pattern`);
452
508
  setProp(node, 'name', preset.formName);
453
509
  setProp(node, 'formId', `${preset.id}-${stableHash(`${ctx.owner}/${id}`)}`);
@@ -476,9 +532,26 @@ function composeChildren(pn: PatternNode, id: string, repeater: RepeaterScope |
476
532
 
477
533
  /** One pattern node → one serialized node (and its subtree). `null` when the section leaves it out or a
478
534
  * dangling link is removed. */
479
- function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null, ctx: Ctx): SerializedNode | null {
535
+ /** A section re-points a linked slot to another page of the plan (`links`, validated): the node, with that route. */
536
+ function withSectionLink(pn: PatternNode, ctx: Ctx): PatternNode {
537
+ const route = pn.slot ? ctx.section?.links?.[pn.slot] : undefined;
538
+ return route ? { ...pn, href: route } : pn;
539
+ }
540
+
541
+ /** M1-6b: a marked node joins the pattern's entrance parts (counted among its siblings once the pattern is whole). */
542
+ function markEntrance(pn: PatternNode, nodeId: string, repeater: RepeaterScope | null, ctx: Ctx): void {
543
+ if (!pn.enter) return;
544
+ (ctx.entrances ??= []).push({ nodeId, part: pn.enter as EntrancePart, repeated: !!repeater, ...(pn.side ? { side: pn.side } : {}) });
545
+ }
546
+
547
+ function composeNode(given: PatternNode, id: string, repeater: RepeaterScope | null, ctx: Ctx): SerializedNode | null {
548
+ const pn = withSectionLink(given, ctx);
480
549
  if (pn.optional && ctx.section?.without?.includes(pn.optional)) return null;
481
550
  if (pn.recipe) return composeRecipe(pn, id, repeater, ctx);
551
+ // A part bound to a role its item's type does not have (`@meta?`) is left out (L0-3).
552
+ const bound = pn.bind && !isPageScope(pn.bind) ? resolveScopedPath(pn.bind, repeater, ctx, id) : undefined;
553
+ if (bound === null) return null;
554
+ const detail = pn.detail ? detailLinkOf(pn.detail, repeater, ctx, id) : null;
482
555
 
483
556
  const words = chooseText(pn, ctx);
484
557
  const fate = danglingFate(pn, ctx);
@@ -486,7 +559,9 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
486
559
  reportDangling(fate, pn, id, words.value, ctx);
487
560
  return null;
488
561
  }
489
- const type = fate === DanglingFate.Unlink ? UNLINKED_TYPE[pn.t] ?? pn.t : pn.t;
562
+ // A box that would link to its item's own page, where the plan has none, is the same box unlinked.
563
+ const unlinked = fate === DanglingFate.Unlink || (!!pn.detail && !detail);
564
+ const type = unlinked ? UNLINKED_TYPE[pn.t] ?? pn.t : pn.t;
490
565
 
491
566
  const props: Record<string, unknown> = {};
492
567
  if (pn.level) props['level'] = pn.level;
@@ -499,6 +574,7 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
499
574
  // Props first: a Heading with no `es` wears its LEVEL's default style.
500
575
  const node = create(id, type, props, pn.es ?? null);
501
576
  if (pn.role) node.role = pn.role;
577
+ markEntrance(pn, id, repeater, ctx);
502
578
 
503
579
  // Words.
504
580
  if (words.placeholder) {
@@ -559,11 +635,15 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
559
635
  if (names.length !== 1) throw new ComposeError(`${id}: "param" needs an href with one :param`);
560
636
  addBinding(node, { prop: `routeParam:${names[0]}`, ...resolveScoped(pn.param, repeater, ctx, id) });
561
637
  }
638
+ if (detail) {
639
+ writePageLink(node, detail.route);
640
+ addBinding(node, { prop: `routeParam:${detail.param}`, ...detail.binding });
641
+ }
562
642
  if (pn.bind) {
563
643
  const prop = BIND_PROP[type];
564
644
  if (!prop) throw new ComposeError(`${id}: ${type} has no bindable value`);
565
645
  if (isPageScope(pn.bind)) setProp(node, prop, stampedPageValue(pn.bind, ctx, id));
566
- else addBinding(node, { prop, ...resolveScoped(pn.bind, repeater, ctx, id) });
646
+ else addBinding(node, { prop, ...(bound as ScopedPath) });
567
647
  }
568
648
  if (type === IMAGE && pn.bind && !isPageScope(pn.bind)) {
569
649
  setProp(node, 'src', '');
@@ -590,7 +670,8 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
590
670
  }
591
671
  addBinding(node, binding);
592
672
  const lastSegment = binding.path.split('.').filter(Boolean).pop();
593
- rowScope = { itemAlias: lastSegment || binding.source };
673
+ // Rows of a list source are its items (their type's roles); rows of an item's array are bare values.
674
+ rowScope = { itemAlias: lastSegment || binding.source, source: binding.path ? null : binding.source };
594
675
  }
595
676
 
596
677
  // Children.
@@ -640,12 +721,12 @@ function wireShownWhen(root: SerializedNode, entries: ReadonlyArray<ShownWhen>):
640
721
  }
641
722
  }
642
723
 
643
- /** A Card v2 `binds` key → the part it binds and that part's bound prop. */
644
- const CARD_BINDS: Readonly<Record<CardBindTarget, { role: CardRole; prop: string }>> = {
645
- [CardBindTarget.Image]: { role: CardRole.Media, prop: 'src' },
646
- [CardBindTarget.Eyebrow]: { role: CardRole.Eyebrow, prop: 'content' },
724
+ /** A Card v2 `binds` key → the part it binds, that part's bound prop, and the optional part it is (the title is not). */
725
+ const CARD_BINDS: Readonly<Record<CardBindTarget, { role: CardRole; prop: string; part?: CardPart }>> = {
726
+ [CardBindTarget.Image]: { role: CardRole.Media, prop: 'src', part: CardPart.Media },
727
+ [CardBindTarget.Eyebrow]: { role: CardRole.Eyebrow, prop: 'content', part: CardPart.Eyebrow },
647
728
  [CardBindTarget.Title]: { role: CardRole.Title, prop: 'content' },
648
- [CardBindTarget.Text]: { role: CardRole.Text, prop: 'content' },
729
+ [CardBindTarget.Text]: { role: CardRole.Text, prop: 'content', part: CardPart.Text },
649
730
  };
650
731
 
651
732
  /**
@@ -670,10 +751,22 @@ function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | nu
670
751
  const tone = pn.badge as BadgeTone | undefined;
671
752
  if (tone && !BADGE_TONES.includes(tone)) throw new ComposeError(`${id}: a badge has no tone "${pn.badge}"`);
672
753
  for (const key of Object.keys(pn.words ?? {})) if (!CARD_WORD_KEYS.includes(key)) throw new ComposeError(`${id}: a card has no words "${key}"`);
754
+ // L0-3: each part binds a ROLE of the row's type; a part whose optional role the type lacks starts switched off.
755
+ const binds: Array<[CardBindTarget, ScopedPath, string]> = [];
756
+ const parts: Partial<Record<CardPart, boolean>> = { ...(pn.parts as Partial<Record<CardPart, boolean>> | undefined) };
757
+ for (const [key, path] of Object.entries(pn.binds ?? {})) {
758
+ const target = CARD_BINDS[key as CardBindTarget];
759
+ if (!target) throw new ComposeError(`${id}: a card cannot bind "${key}"`);
760
+ const scoped = resolveScopedPath(path, repeater, ctx, id);
761
+ if (scoped) binds.push([key as CardBindTarget, scoped, path]);
762
+ else if (target.part) parts[target.part] = false;
763
+ else throw new ComposeError(`${id}: a card's ${key} cannot be left out`);
764
+ }
673
765
  const card = buildCardRecipe(factory, {
674
- layout, parts: pn.parts as Partial<Record<CardPart, boolean>>, words: pn.words as Partial<CardWords> | undefined, badgeTone: tone,
766
+ layout, parts, words: pn.words as Partial<CardWords> | undefined, badgeTone: tone,
675
767
  ...(pn.icon ? { badgeIcon: pn.icon } : {}),
676
768
  }) as SerializedNode;
769
+ markEntrance(pn, id, repeater, ctx);
677
770
  if (pn.scheme) {
678
771
  if (!COLOR_SCHEMES.some(c => c.id === pn.scheme)) throw new ComposeError(`${id}: no colour scheme "${pn.scheme}"`);
679
772
  setColorScheme(card, pn.scheme);
@@ -684,19 +777,27 @@ function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | nu
684
777
  content.children = [...(content.children ?? []), ...composeChildren(pn, id, repeater, ctx)];
685
778
  }
686
779
 
687
- for (const [key, path] of Object.entries(pn.binds ?? {})) {
688
- const target = CARD_BINDS[key as CardBindTarget];
689
- if (!target) throw new ComposeError(`${id}: a card cannot bind "${key}"`);
780
+ for (const [key, scoped, path] of binds) {
781
+ const target = CARD_BINDS[key];
690
782
  const part = findByRole(card as CompositeNode, target.role) as SerializedNode | undefined;
691
783
  if (!part) throw new ComposeError(`${id}: "${key}" binds a part the card leaves out`);
692
784
  const node = target.role === CardRole.Media ? part.children?.find(c => c.type === IMAGE) : part;
693
785
  if (!node) throw new ComposeError(`${id}: the card's media holds no image`);
694
- addBinding(node, { prop: target.prop, ...resolveScoped(path, repeater, ctx, id) });
786
+ addBinding(node, { prop: target.prop, ...scoped });
695
787
  if (node.type === IMAGE) addBinding(node, { prop: 'alt', ...altBinding({ ...pn, t: IMAGE, bind: path, alt: undefined }, repeater, ctx, id) });
696
788
  }
697
789
 
698
- const fate = danglingFate({ ...pn, t: 'Heading' }, ctx);
790
+ // The whole card links to its item's own page through its title (`detail`) — or, by route, to a page (`href`).
699
791
  const title = findByRole(card as CompositeNode, CardRole.Title) as SerializedNode;
792
+ if (pn.detail) {
793
+ const detail = detailLinkOf(pn.detail, repeater, ctx, id);
794
+ if (detail) {
795
+ writePageLink(title, detail.route);
796
+ addBinding(title, { prop: `routeParam:${detail.param}`, ...detail.binding });
797
+ }
798
+ return card;
799
+ }
800
+ const fate = danglingFate({ ...pn, t: 'Heading' }, ctx);
700
801
  if (pn.href && !fate) writePageLink(title, pn.href);
701
802
  else if (fate) {
702
803
  if (fate === DanglingFate.Email) writeExternalLink(title, `${MAILTO}${ctx.email}`);
@@ -890,17 +991,81 @@ function wireOpeners(root: SerializedNode, openers: ReadonlyArray<string>): void
890
991
  visit(root);
891
992
  }
892
993
 
893
- function composePattern(pattern: SitePattern, id: string, ctx: Ctx): SerializedNode {
994
+ /** How a composed section moves in (M1-6b): the look's How animated (or the section's own) on the page's direction. */
995
+ interface SectionMotion {
996
+ level: AnimateId | string | null;
997
+ rtl: boolean;
998
+ }
999
+
1000
+ function composePattern(pattern: SitePattern, id: string, ctx: Ctx, motion: SectionMotion | null = null): SerializedNode {
894
1001
  if (pattern.node.each) throw new ComposeError(`${id}: a pattern's root cannot be stamped per page`);
895
- const own: Ctx = { ...ctx, openers: [], shownWhen: [] };
1002
+ const own: Ctx = { ...ctx, openers: [], shownWhen: [], entrances: [] };
896
1003
  const node = composeNode(pattern.node, id, null, own);
897
1004
  if (!node) throw new ComposeError(`${id}: a pattern's root cannot be optional or a link`);
898
1005
  wireOpeners(node, own.openers ?? []);
899
1006
  wireShownWhen(node, own.shownWhen ?? []);
1007
+ if (motion && !pattern.global) stampEntrances(node, pattern.family, own.entrances ?? [], motion);
900
1008
  node.label = pattern.label;
901
1009
  return node;
902
1010
  }
903
1011
 
1012
+ /**
1013
+ * M1-6b: the look's entrances on a composed section — `planEntrances` over the pattern's marked parts, each counted
1014
+ * among the marked siblings of its part (static cards a literal delay apart), appended to the node's interactions
1015
+ * (`origin: 'look'`, `viewportEnter`, once, on itself — a duplicated node needs no remap).
1016
+ */
1017
+ function stampEntrances(root: SerializedNode, family: string, entrances: ReadonlyArray<Omit<EntranceMark, 'index'>>, motion: SectionMotion): void {
1018
+ if (!entrances.length && !root.children?.length) return;
1019
+ const parentOf = new Map<string, string>();
1020
+ const nodes = new Map<string, SerializedNode>();
1021
+ walkSerialized([root], (n, ancestors) => { nodes.set(n.id, n); if (ancestors.length) parentOf.set(n.id, ancestors[ancestors.length - 1].id); });
1022
+ const seen = new Map<string, number>();
1023
+ const marks: EntranceMark[] = entrances.map(e => {
1024
+ const key = `${parentOf.get(e.nodeId) ?? ''}|${e.part}`;
1025
+ const index = seen.get(key) ?? 0;
1026
+ seen.set(key, index + 1);
1027
+ return { ...e, index };
1028
+ });
1029
+ const stamps = planEntrances({ family, level: motion.level, marks, contentIds: (root.children ?? []).map(c => c.id), rtl: motion.rtl });
1030
+ for (const { nodeId, interaction } of stamps) {
1031
+ const node = nodes.get(nodeId);
1032
+ if (node) node.interactions = [...node.interactions, interaction];
1033
+ }
1034
+ }
1035
+
1036
+ const SEO_JOIN = ' · ';
1037
+ const SEO_LINE = ' — ';
1038
+
1039
+ /** Cut to `max` characters on a word boundary, with an ellipsis only when something was cut (seo-core's limits). */
1040
+ function fitSeo(text: string, max: number): string {
1041
+ const clean = text.replace(/\s+/g, ' ').trim();
1042
+ if (clean.length <= max) return clean;
1043
+ const cut = clean.slice(0, max - 1);
1044
+ const at = cut.lastIndexOf(' ');
1045
+ return `${(at > max / 2 ? cut.slice(0, at) : cut).trimEnd()}…`;
1046
+ }
1047
+
1048
+ /**
1049
+ * A composed page's search-result words (M1-6c), in the seo-core shape `resolveSeo` reads — every page resolves a
1050
+ * title and a description, and nothing is invented: the plan's own words when it writes them; else Home is the
1051
+ * business (and its line), an inner page "<page> · <business>", described by the business's line or, without one,
1052
+ * by the page, the business and the city it gave. A TEMPLATE page writes neither unless the plan does: the shared
1053
+ * ladder's content rung reads its own bound heading and lead, so each item's page has its own title and line (the old
1054
+ * one-title-for-every-template-page defect, seo-plan.md). Plain words only — a stored `{ binding }` value is refused by
1055
+ * the CMS's page schema today (vectoriox/iox-cms#42).
1056
+ */
1057
+ function composePageSeo(page: PagePlan, plan: SitePlan): SeoRawDoc {
1058
+ const name = plan.business.name.trim();
1059
+ const tagline = nonEmpty(plan.business.tagline) ? plan.business.tagline.trim() : '';
1060
+ const city = nonEmpty(plan.business.facts?.city) ? plan.business.facts!.city!.trim() : '';
1061
+ const perItem = !!page.routeSource;
1062
+ const title = nonEmpty(page.seo?.title) ? page.seo!.title
1063
+ : perItem ? undefined : fitSeo(page.isHome ? (tagline ? `${name}${SEO_LINE}${tagline}` : name) : `${page.name}${SEO_JOIN}${name}`, SEO_TITLE_MAX);
1064
+ const description = nonEmpty(page.seo?.description) ? page.seo!.description
1065
+ : perItem ? undefined : fitSeo(tagline || `${page.name}${SEO_LINE}${name}${city ? `, ${city}` : ''}.`, SEO_DESCRIPTION_MAX);
1066
+ return { ...(title ? { title } : {}), ...(description ? { description } : {}) };
1067
+ }
1068
+
904
1069
  function composeDataSource(ds: DataSourcePlan): ComposedDataSource {
905
1070
  const id = `ds-${ds.alias}`;
906
1071
  if (ds.type === PlanDataSourceType.Cms) {
@@ -950,11 +1115,15 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
950
1115
  const email = nonEmpty(plan.business.facts?.email) ? plan.business.facts!.email!.trim() : null;
951
1116
  const base = { plan, placeholders, issues, routes, year: context.year, email, navPage: null };
952
1117
 
1118
+ const design = resolveDesignChoices(clone(plan.design));
1119
+ const locale = plan.locale ?? DEFAULT_SITE_LOCALE;
1120
+ const rtl = isRtlLocale(locale);
953
1121
  const pages: ComposedPage[] = plan.pages.map(page => {
954
1122
  const nextAlias = page.routeSource ? nextSourceOf(relatives, page.routeSource) : null;
955
1123
  const layout = page.sections.map((section, i) => {
956
1124
  const pattern = patternById(section.pattern) as SitePattern;
957
- return composePattern(pattern, `${page.key}-${i}`, { ...base, owner: page.key, page, section, nextAlias });
1125
+ const motion: SectionMotion = { level: section.enter ?? design.animate ?? null, rtl };
1126
+ return composePattern(pattern, `${page.key}-${i}`, { ...base, owner: page.key, page, section, nextAlias }, motion);
958
1127
  });
959
1128
  return {
960
1129
  key: page.key,
@@ -962,7 +1131,9 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
962
1131
  route: { path: page.route },
963
1132
  isHome: page.isHome === true,
964
1133
  status: STATUS_DRAFT,
1134
+ locale,
965
1135
  pageSettings: { designSystem: true },
1136
+ seo: composePageSeo(page, plan),
966
1137
  layout,
967
1138
  };
968
1139
  });
@@ -984,7 +1155,7 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
984
1155
  return {
985
1156
  source: clone(plan.source),
986
1157
  business: clone(plan.business),
987
- design: resolveDesignChoices(clone(plan.design)),
1158
+ design,
988
1159
  schemas: clone(plan.contentTypes) as ContentTypePlan[],
989
1160
  items: Object.fromEntries(plan.contentTypes.map(t => [t.name, composeItems(t)])),
990
1161
  dataSources: plan.dataSources.map(composeDataSource),
@@ -1,9 +1,11 @@
1
- import { AnimateId, SPEC_AXES } from '@vectoriox/iox-ui/design-core';
1
+ import { AnimateId, EntranceParam, SPEC_AXES, staggerDelay } from '@vectoriox/iox-ui/design-core';
2
2
 
3
3
  /**
4
4
  * How animated (AI plan rev 3.1 §3p) — the ONE reading of the contract's `axes.animations` (what each level
5
5
  * moves, how) and `axes.animationFamilies` (which parts a section family lets move). The pieces every stamper
6
- * shares: the Design step's business specimen (H4-4) today, `planEntrances` in the composer (M1-6b) next. Pure.
6
+ * shares: the Design step's business specimen (H4-4) and `planEntrances`, which the composer runs per section (M1-6b).
7
+ * When an entrance plays — a static item's literal delay, a Repeater row's share — is design-core's `staggerDelay` /
8
+ * `entranceDelay`, which both interaction runtimes read too. Pure.
7
9
  *
8
10
  * A level with `content` moves a section's content together, once; otherwise it names parts (heading, items,
9
11
  * media…) and each part enters on its own, items one after another. Navigation, footers and globals are a family
@@ -52,7 +54,7 @@ export interface EntranceAction {
52
54
  delay: number;
53
55
  easing: typeof ENTRANCE_EASING;
54
56
  once: true;
55
- params?: { distance: number };
57
+ params?: { distance?: number; stagger?: number; cap?: number };
56
58
  }
57
59
 
58
60
  export interface EntranceInteraction {
@@ -84,23 +86,42 @@ export function sectionEntrances(level: AnimateId | string | null | undefined, f
84
86
  return { content: null, parts };
85
87
  }
86
88
 
87
- /** The literal delay of the `index`-th item: `stagger` ms each, the last `cap` waiting no longer than the one before. */
88
- export function staggerDelay(spec: EntranceSpec, index = 0): number {
89
- if (!spec.stagger) return 0;
90
- const cap = Math.max(1, spec.cap ?? 1);
91
- return Math.min(Math.max(0, index), cap - 1) * spec.stagger;
89
+ /** Which side of its row a picture sits on (`rowMedia`): an end-side picture enters from the end. */
90
+ export enum EntranceSide {
91
+ Start = 'start',
92
+ End = 'end',
92
93
  }
93
94
 
94
- /** The action a node plays: the spec's type (mirrored on a right-to-left page), its distance and its delay. */
95
- export function entranceAction(spec: EntranceSpec, opts: { index?: number; rtl?: boolean } = {}): EntranceAction {
95
+ export interface EntranceActionOptions {
96
+ /** The item's place among the items that enter together (a literal delay, `staggerDelay`). */
97
+ index?: number;
98
+ /** A right-to-left page: what slides comes from the mirrored side. */
99
+ rtl?: boolean;
100
+ /** A row picture's side (default end — the side the spec's own type enters from). */
101
+ side?: EntranceSide | `${EntranceSide}`;
102
+ /** The node is a Repeater's template: its rows take their share at runtime (`entranceDelay`), so the stagger is
103
+ * carried as params and nothing is written into the delay. */
104
+ repeated?: boolean;
105
+ }
106
+
107
+ /** The action a node plays: the spec's type (mirrored for a start-side picture and on a right-to-left page — both
108
+ * together cancel out), its distance, and its delay (or, for a Repeater's rows, the stagger the runtime adds). */
109
+ export function entranceAction(spec: EntranceSpec, opts: EntranceActionOptions = {}): EntranceAction {
110
+ const mirrored = (opts.side === EntranceSide.Start) !== !!opts.rtl;
111
+ const params: Record<string, number> = {};
112
+ if (spec.distance) params[EntranceParam.Distance] = spec.distance;
113
+ if (opts.repeated && spec.stagger) {
114
+ params[EntranceParam.Stagger] = spec.stagger;
115
+ params[EntranceParam.Cap] = spec.cap ?? 1;
116
+ }
96
117
  return {
97
- type: opts.rtl && spec.mirror ? spec.mirror : spec.type,
118
+ type: mirrored && spec.mirror ? spec.mirror : spec.type,
98
119
  target: 'self',
99
120
  duration: spec.duration,
100
- delay: staggerDelay(spec, opts.index),
121
+ delay: opts.repeated ? 0 : staggerDelay(spec, opts.index),
101
122
  easing: ENTRANCE_EASING,
102
123
  once: true,
103
- ...(spec.distance ? { params: { distance: spec.distance } } : {}),
124
+ ...(Object.keys(params).length ? { params } : {}),
104
125
  };
105
126
  }
106
127
 
@@ -108,3 +129,51 @@ export function entranceAction(spec: EntranceSpec, opts: { index?: number; rtl?:
108
129
  export function entranceInteraction(nodeId: string, action: EntranceAction): EntranceInteraction {
109
130
  return { id: `${nodeId}-enter`, trigger: ENTRANCE_TRIGGER, actions: [action], origin: ENTRANCE_ORIGIN_LOOK };
110
131
  }
132
+
133
+ /** A node a pattern marks as an entrance part (`enter`), as the composer stamped it. */
134
+ export interface EntranceMark {
135
+ nodeId: string;
136
+ part: EntrancePart | `${EntrancePart}`;
137
+ /** Its place among the marks of the same part under the same parent — what a static item's delay is counted by. */
138
+ index: number;
139
+ /** A Repeater's template (its rows share the node). */
140
+ repeated: boolean;
141
+ side?: EntranceSide | `${EntranceSide}`;
142
+ }
143
+
144
+ export interface EntranceStamp {
145
+ nodeId: string;
146
+ interaction: EntranceInteraction;
147
+ }
148
+
149
+ export interface PlanEntrancesInput {
150
+ /** The section's family (contract `animationFamilies`). */
151
+ family: string;
152
+ /** How animated — the look's, or the section's own (`PageSectionPlan.enter`). */
153
+ level: AnimateId | string | null | undefined;
154
+ /** The section's marked parts, in document order. */
155
+ marks: ReadonlyArray<EntranceMark>;
156
+ /** The section's own children: what a level that moves the CONTENT fades when the pattern marks no content node. */
157
+ contentIds: ReadonlyArray<string>;
158
+ rtl?: boolean;
159
+ }
160
+
161
+ /**
162
+ * The entrances a composed section gets (AI plan §3p, M1-6b): a level that moves the CONTENT fades the pattern's
163
+ * `content` marks — else every child of the section — together, once; otherwise each marked part its family allows
164
+ * enters on its own (static items a literal delay apart, a Repeater's rows by the runtime), a row picture from its side.
165
+ * Nothing for a header or footer family, Still, or an unknown level. Pure.
166
+ */
167
+ export function planEntrances(input: PlanEntrancesInput): EntranceStamp[] {
168
+ const plan = sectionEntrances(input.level, input.family);
169
+ const stamp = (nodeId: string, spec: EntranceSpec, opts: EntranceActionOptions): EntranceStamp =>
170
+ ({ nodeId, interaction: entranceInteraction(nodeId, entranceAction(spec, { ...opts, rtl: input.rtl })) });
171
+ if (plan.content) {
172
+ const marked = input.marks.filter(m => m.part === EntrancePart.Content).map(m => m.nodeId);
173
+ return (marked.length ? marked : input.contentIds).map(id => stamp(id, plan.content!, {}));
174
+ }
175
+ return input.marks.flatMap(m => {
176
+ const spec = m.part === EntrancePart.Content ? undefined : plan.parts[m.part as EntrancePart];
177
+ return spec ? [stamp(m.nodeId, spec, { index: m.index, repeated: m.repeated, side: m.side })] : [];
178
+ });
179
+ }