@vectoriox/iox-ui 5.39.0 → 5.41.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 (32) hide show
  1. package/composite-core/index.d.ts +210 -12
  2. package/design-core/index.d.ts +694 -17
  3. package/fesm2022/vectoriox-iox-ui-actions-core.mjs +4 -3
  4. package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
  5. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +535 -121
  6. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  7. package/fesm2022/vectoriox-iox-ui-design-core.mjs +843 -45
  8. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  9. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +894 -21
  10. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  11. package/fesm2022/vectoriox-iox-ui.mjs +3945 -3914
  12. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  13. package/generation-core/index.d.ts +625 -5
  14. package/index.d.ts +22 -1
  15. package/package.json +1 -1
  16. package/src/actions-core/element-actions.ts +4 -3
  17. package/src/composite-core/accordion.ts +1 -0
  18. package/src/composite-core/composite-kinds.ts +42 -6
  19. package/src/composite-core/exclusive-runtime.ts +60 -10
  20. package/src/composite-core/exclusive.ts +64 -0
  21. package/src/composite-core/public-api.ts +5 -2
  22. package/src/composite-core/segmented.ts +349 -0
  23. package/src/composite-core/structure.ts +6 -1
  24. package/src/composite-core/tabs.ts +6 -3
  25. package/src/design-core/contrast-audit.ts +8 -3
  26. package/src/design-core/flavor-spec.generated.ts +832 -41
  27. package/src/generation-core/compose-site.ts +248 -12
  28. package/src/generation-core/nav-shape.ts +53 -3
  29. package/src/generation-core/node-baselines.generated.ts +49 -0
  30. package/src/generation-core/patterns.ts +20 -0
  31. package/src/generation-core/site-spec.generated.ts +595 -0
  32. package/styles/iox-design.css +19 -0
@@ -1,11 +1,11 @@
1
- import { DesignChoices, LinkType, resolveDesignChoices } from '@vectoriox/iox-ui/design-core';
1
+ import { COLOR_SCHEMES, DesignChoices, LinkType, resolveDesignChoices, setColorScheme } from '@vectoriox/iox-ui/design-core';
2
2
  import { FormPresetDef, FormPresetField, formPresetById } from '@vectoriox/iox-ui/forms-core';
3
3
  import {
4
4
  ACCORDION_BIND_PROP, ACCORDION_CONTENT_LIMIT, ACCORDION_WORDS, accordionAnswerOf, accordionItems, accordionQuestionOf, AccordionRole,
5
5
  buildAccordionRecipe, buildCardRecipe, buildCarouselRecipe, buildLightboxRecipe, CardLayout, CardPart, CardRole, CARD_LAYOUTS, CARD_PARTS, CompositeNode,
6
6
  CompositeNodeSpec, findByRole, LIGHTBOX_LABEL, pickAccordionAutoBinds, pickTabsAutoBinds, SLIDE_TO_ROW, SLIDE_TYPE, buildTabsRecipe, tabLabelNodeOf,
7
7
  tabPanelImageOf, tabPanelTextOf, tabsListOf, tabsPanels, tabsPanelsOf, tabsTriggers, TABS_CONTENT_LIMIT, TABS_IMAGE_BIND_PROP, TABS_LABEL_BIND_PROP,
8
- TABS_TEXT_BIND_PROP, TABS_WORDS,
8
+ TABS_TEXT_BIND_PROP, TABS_WORDS, BadgeTone, BADGE_TONES, buildSegmentedRecipe, CardWords, ITEM_OPEN_STATE, segmentedOptions,
9
9
  } from '@vectoriox/iox-ui/composite-core';
10
10
 
11
11
  import { NODE_BASELINES } from './node-baselines.generated';
@@ -41,6 +41,18 @@ export enum PlaceholderKind {
41
41
  Image = 'image',
42
42
  /** CC3: a video slot with no clip — the studio uploads its own (the poster blank meanwhile). */
43
43
  Video = 'video',
44
+ /** CC4b (Q37): a price — a FACT the customer could be held to. Free text the studio types ("₪ 1,200", "From $40",
45
+ * "On request"); its label shows meanwhile ("Monthly price"), never a number. No currency model. */
46
+ Price = 'price',
47
+ }
48
+
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]);
52
+
53
+ /** True when a placeholder of `kind` blocks publishing until the studio fills it. */
54
+ export function blocksPublish(kind: PlaceholderKind): boolean {
55
+ return PUBLISH_BLOCKING_PLACEHOLDERS.has(kind);
44
56
  }
45
57
 
46
58
  export interface ComposedPlaceholder {
@@ -241,6 +253,18 @@ interface Ctx {
241
253
  routes: ReadonlySet<string>;
242
254
  /** CC3: nodes whose click opens the pattern's lightbox (`opens`), wired once the pattern is stamped. */
243
255
  openers?: string[];
256
+ /** CC4b: nodes shown while an option is on (`shownWhen`), wired once the pattern is stamped. */
257
+ shownWhen?: ShownWhen[];
258
+ /** A section inserted from the palette (`composeSection`): a button to a page the studio lacks keeps its words,
259
+ * unlinked — the author links it — instead of being removed. */
260
+ keepButtons?: boolean;
261
+ }
262
+
263
+ interface ShownWhen {
264
+ id: string;
265
+ key: string;
266
+ /** The display the node shows with (its own layout's, else flex). */
267
+ display: string;
244
268
  }
245
269
 
246
270
  function nonEmpty(v: unknown): v is string {
@@ -273,6 +297,7 @@ function chooseText(pn: PatternNode, ctx: Ctx): TextChoice {
273
297
  }
274
298
  const own = pn.text === undefined ? undefined : fillVariables(pn.text, ctx);
275
299
  if (pn.ph === PatternPlaceholder.Copy) return { value: own, placeholder: { kind: PlaceholderKind.Copy } };
300
+ if (pn.ph === PatternPlaceholder.Price) return { value: own, placeholder: { kind: PlaceholderKind.Price } };
276
301
  return { value: own };
277
302
  }
278
303
 
@@ -364,7 +389,7 @@ enum DanglingFate {
364
389
  function danglingFate(pn: PatternNode, ctx: Ctx): DanglingFate | null {
365
390
  if (!pn.href || pn.href === PatternDirective.PageRoute || ctx.routes.has(pn.href)) return null;
366
391
  if (ctx.email) return DanglingFate.Email;
367
- return UNLINKED_TYPE[pn.t] ? DanglingFate.Unlink : DanglingFate.Remove;
392
+ return UNLINKED_TYPE[pn.t] || (ctx.keepButtons && pn.t === BUTTON_BLOCK) ? DanglingFate.Unlink : DanglingFate.Remove;
368
393
  }
369
394
 
370
395
  const DANGLING_ISSUE: Readonly<Record<DanglingFate, ComposeIssueCode>> = {
@@ -378,7 +403,9 @@ function reportDangling(fate: DanglingFate, pn: PatternNode, id: string, words:
378
403
  const outcome = {
379
404
  [DanglingFate.Email]: `it now opens an email to ${ctx.email}`,
380
405
  [DanglingFate.Remove]: 'with no email address to send to instead, it was removed',
381
- [DanglingFate.Unlink]: 'with no email address to send to instead, the box keeps its contents without the link',
406
+ [DanglingFate.Unlink]: pn.t === BUTTON_BLOCK
407
+ ? 'with no email address to send to instead, the button keeps its words without the link'
408
+ : 'with no email address to send to instead, the box keeps its contents without the link',
382
409
  }[fate];
383
410
  ctx.issues.push({
384
411
  code: DANGLING_ISSUE[fate], nodeId: id, owner: ctx.owner, route: pn.href,
@@ -459,7 +486,7 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
459
486
  reportDangling(fate, pn, id, words.value, ctx);
460
487
  return null;
461
488
  }
462
- const type = fate === DanglingFate.Unlink ? UNLINKED_TYPE[pn.t] : pn.t;
489
+ const type = fate === DanglingFate.Unlink ? UNLINKED_TYPE[pn.t] ?? pn.t : pn.t;
463
490
 
464
491
  const props: Record<string, unknown> = {};
465
492
  if (pn.level) props['level'] = pn.level;
@@ -501,6 +528,11 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
501
528
  ctx.placeholders.push({ nodeId: id, owner: ctx.owner, kind: PlaceholderKind.Video, text: '' });
502
529
  }
503
530
  if (pn.name) setProp(node, 'ariaLabel', pn.name);
531
+ if (pn.icon) {
532
+ if (type !== ICON) throw new ComposeError(`${id}: "icon" belongs on an Icon`);
533
+ setProp(node, 'iconClass', pn.icon);
534
+ }
535
+ if (pn.layer) node.label = pn.layer;
504
536
  if (pn.opens) {
505
537
  if (pn.opens !== PatternOpens.Lightbox) throw new ComposeError(`${id}: "opens" names the pattern's lightbox`);
506
538
  (ctx.openers ??= []).push(id);
@@ -542,11 +574,8 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
542
574
  }
543
575
 
544
576
  // Layout.
545
- if (pn.lay) setStyleProps(node, pn.lay);
546
- if (pn.resp) {
547
- for (const layer of Object.values(pn.resp)) assertStyleSlots(node.type, Object.keys(layer));
548
- node.responsive = Object.fromEntries(Object.entries(pn.resp).map(([bp, styleProps]) => [bp, { styleProps: { ...styleProps } }]));
549
- }
577
+ applyLayout(node, pn);
578
+ if (pn.shownWhen) hideUntilOn(node, pn, ctx);
550
579
 
551
580
  // A Repeater's rows.
552
581
  let rowScope = repeater;
@@ -573,6 +602,44 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
573
602
  return node;
574
603
  }
575
604
 
605
+ /** A pattern node's layout: its `lay` styleProps and its per-breakpoint `resp` layers. */
606
+ function applyLayout(node: SerializedNode, pn: PatternNode): void {
607
+ if (pn.lay) setStyleProps(node, pn.lay);
608
+ if (pn.resp) {
609
+ for (const layer of Object.values(pn.resp)) assertStyleSlots(node.type, Object.keys(layer));
610
+ node.responsive = Object.fromEntries(Object.entries(pn.resp).map(([bp, styleProps]) => [bp, { styleProps: { ...styleProps } }]));
611
+ }
612
+ }
613
+
614
+ /** CC4b (`shownWhen`, Q38): hidden at base and shown — with its own layout's display — while the pattern's Segmented
615
+ * option of that key is on. Wired once the pattern is stamped (the option's id is known then). */
616
+ function hideUntilOn(node: SerializedNode, pn: PatternNode, ctx: Ctx): void {
617
+ const display = String(node.styleProps['display'] ?? '') || SHOWN_DISPLAY;
618
+ setStyleProps(node, { display: HIDDEN_DISPLAY });
619
+ (ctx.shownWhen ??= []).push({ id: node.id, key: pn.shownWhen!, display });
620
+ }
621
+
622
+ /** CC4b: every `shownWhen` node of a stamped pattern follows its option — the targeted `trigger-on` ("when Yearly is
623
+ * on"), compiled by both renderers to ONE `:has()` rule on the nearest common ancestor. */
624
+ function wireShownWhen(root: SerializedNode, entries: ReadonlyArray<ShownWhen>): void {
625
+ if (!entries.length) return;
626
+ const nodes = new Map<string, SerializedNode>();
627
+ const options = new Map<string, string>();
628
+ const visit = (n: SerializedNode) => {
629
+ nodes.set(n.id, n);
630
+ for (const option of segmentedOptions(n as unknown as CompositeNode)) if (option.itemKey && !options.has(option.itemKey)) options.set(option.itemKey, option.id!);
631
+ (n.children ?? []).forEach(visit);
632
+ };
633
+ visit(root);
634
+ for (const entry of entries) {
635
+ const option = options.get(entry.key);
636
+ if (!option) throw new ComposeError(`${entry.id}: "shownWhen: ${entry.key}" names no option of a segmented in the pattern`);
637
+ const node = nodes.get(entry.id)!;
638
+ node.stateStyles = { ...(node.stateStyles ?? {}), [ITEM_OPEN_STATE]: { display: entry.display } };
639
+ node.stateTriggers = { ...(node.stateTriggers ?? {}), [ITEM_OPEN_STATE]: option };
640
+ }
641
+ }
642
+
576
643
  /** A Card v2 `binds` key → the part it binds and that part's bound prop. */
577
644
  const CARD_BINDS: Readonly<Record<CardBindTarget, { role: CardRole; prop: string }>> = {
578
645
  [CardBindTarget.Image]: { role: CardRole.Media, prop: 'src' },
@@ -591,6 +658,7 @@ function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | nu
591
658
  if (pn.recipe === PatternRecipe.Tabs) return composeTabs(pn, id, ctx);
592
659
  if (pn.recipe === PatternRecipe.Carousel) return composeCarousel(pn, id, ctx);
593
660
  if (pn.recipe === PatternRecipe.Lightbox) return composeLightbox(pn, id, repeater, ctx);
661
+ if (pn.recipe === PatternRecipe.Segmented) return composeSegmented(pn, id);
594
662
  if (pn.recipe !== PatternRecipe.Card) throw new ComposeError(`${id}: no recipe "${pn.recipe}" in composite-core`);
595
663
  const layout = (pn.layout ?? CardLayout.Top) as CardLayout;
596
664
  if (!CARD_LAYOUTS.includes(layout)) throw new ComposeError(`${id}: a card has no layout "${pn.layout}"`);
@@ -599,7 +667,21 @@ function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | nu
599
667
  }
600
668
  const factory = (spec: CompositeNodeSpec): CompositeNode =>
601
669
  create(spec.key === CardRole.Card ? id : `${id}-${spec.key}`, spec.type, { ...(spec.props ?? {}) }, spec.elementStyle ?? null) as CompositeNode;
602
- const card = buildCardRecipe(factory, { layout, parts: pn.parts as Partial<Record<CardPart, boolean>> }) as SerializedNode;
670
+ const tone = pn.badge as BadgeTone | undefined;
671
+ if (tone && !BADGE_TONES.includes(tone)) throw new ComposeError(`${id}: a badge has no tone "${pn.badge}"`);
672
+ for (const key of Object.keys(pn.words ?? {})) if (!CARD_WORD_KEYS.includes(key)) throw new ComposeError(`${id}: a card has no words "${key}"`);
673
+ const card = buildCardRecipe(factory, {
674
+ layout, parts: pn.parts as Partial<Record<CardPart, boolean>>, words: pn.words as Partial<CardWords> | undefined, badgeTone: tone,
675
+ }) as SerializedNode;
676
+ if (pn.scheme) {
677
+ if (!COLOR_SCHEMES.some(c => c.id === pn.scheme)) throw new ComposeError(`${id}: no colour scheme "${pn.scheme}"`);
678
+ setColorScheme(card, pn.scheme);
679
+ }
680
+ if (pn.ph === PatternPlaceholder.Copy) copyPlaceholders(card, ctx);
681
+ if (pn.c?.length) {
682
+ const content = findByRole(card as CompositeNode, CardRole.Content) as SerializedNode;
683
+ content.children = [...(content.children ?? []), ...composeChildren(pn, id, repeater, ctx)];
684
+ }
603
685
 
604
686
  for (const [key, path] of Object.entries(pn.binds ?? {})) {
605
687
  const target = CARD_BINDS[key as CardBindTarget];
@@ -628,6 +710,31 @@ function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | nu
628
710
  }
629
711
 
630
712
  const REPEATER = 'Repeater';
713
+ const ICON = 'Icon';
714
+ const SHOWN_DISPLAY = 'flex';
715
+ const HIDDEN_DISPLAY = 'none';
716
+ /** The words a card recipe's `words` may set (composite-core `CardWords`). */
717
+ const CARD_WORD_KEYS: ReadonlyArray<string> = ['eyebrow', 'title', 'text', 'primary', 'secondary', 'badge'];
718
+ /** A card's words the studio rewrites (`ph: 'copy'`): its eyebrow, title, text and badge. */
719
+ const CARD_COPY_ROLES: ReadonlyArray<string> = [CardRole.Eyebrow, CardRole.Title, CardRole.Text];
720
+
721
+ /** A card's own words, as COPY placeholders (its badge's words too). */
722
+ function copyPlaceholders(card: SerializedNode, ctx: Ctx): void {
723
+ for (const role of CARD_COPY_ROLES) {
724
+ const part = findByRole(card as unknown as CompositeNode, role) as SerializedNode | undefined;
725
+ if (part) ctx.placeholders.push({ nodeId: part.id, owner: ctx.owner, kind: PlaceholderKind.Copy, text: String(part.props?.['content'] ?? '') });
726
+ }
727
+ const badge = findByRole(card as unknown as CompositeNode, CardRole.Badge) as SerializedNode | undefined;
728
+ const words = badge?.children?.find(c => c.type === TEXT);
729
+ if (words) ctx.placeholders.push({ nodeId: words.id, owner: ctx.owner, kind: PlaceholderKind.Copy, text: String(words.props?.['content'] ?? '') });
730
+ }
731
+
732
+ /** CC4b: the Segmented control — composite-core's recipe (Monthly · Yearly), named by the pattern, laid out by it. */
733
+ function composeSegmented(pn: PatternNode, id: string): SerializedNode {
734
+ const root = buildSegmentedRecipe(recipeFactory(id, 'segmented'), pn.name !== undefined ? { label: pn.name } : {}) as SerializedNode;
735
+ applyLayout(root, pn);
736
+ return root;
737
+ }
631
738
 
632
739
  /**
633
740
  * An Accordion (CC2) — the composite-core recipe stamped with this composer's factory. Manual (no section
@@ -784,10 +891,11 @@ function wireOpeners(root: SerializedNode, openers: ReadonlyArray<string>): void
784
891
 
785
892
  function composePattern(pattern: SitePattern, id: string, ctx: Ctx): SerializedNode {
786
893
  if (pattern.node.each) throw new ComposeError(`${id}: a pattern's root cannot be stamped per page`);
787
- const own: Ctx = { ...ctx, openers: [] };
894
+ const own: Ctx = { ...ctx, openers: [], shownWhen: [] };
788
895
  const node = composeNode(pattern.node, id, null, own);
789
896
  if (!node) throw new ComposeError(`${id}: a pattern's root cannot be optional or a link`);
790
897
  wireOpeners(node, own.openers ?? []);
898
+ wireShownWhen(node, own.shownWhen ?? []);
791
899
  node.label = pattern.label;
792
900
  return node;
793
901
  }
@@ -886,3 +994,131 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
886
994
  issues,
887
995
  };
888
996
  }
997
+
998
+ // ── The site header on its own (CC1.1) ──────────────────────────────────────────────────────────
999
+
1000
+ /** The header pattern the Studio template stamps — and the builder palette's "Navigation bar". */
1001
+ export const SITE_HEADER_PATTERN = 'nav.inline-cta';
1002
+
1003
+ /** A page of the studio as the builder lists it. */
1004
+ export interface SitePageRef {
1005
+ readonly name: string;
1006
+ readonly route: string;
1007
+ readonly isHome?: boolean;
1008
+ }
1009
+
1010
+ /** What the header is composed from when there is no Site Plan: the studio's name and its pages. */
1011
+ export interface NavHeaderInput {
1012
+ readonly business: SiteBusiness;
1013
+ /** Every page of the studio, in the order the navigation should list them. */
1014
+ readonly pages: ReadonlyArray<SitePageRef>;
1015
+ }
1016
+
1017
+ export interface NavHeaderOptions {
1018
+ /** The header's id; its parts' ids derive from it (`<id>-0-1-…`), so a fresh id gives fresh parts. */
1019
+ readonly id: string;
1020
+ /** A phone-shape part's id (the builder passes its id generator). */
1021
+ readonly newId?: (suffix: string) => string;
1022
+ }
1023
+
1024
+ export interface ComposedNavHeader {
1025
+ /** The header — a global BEFORE element — carrying the pattern's phone shape (the drawer). */
1026
+ header: SerializedNode;
1027
+ issues: ComposeIssue[];
1028
+ }
1029
+
1030
+ const ROUTE_PARAM = ':';
1031
+ const HOME_PATH = '/';
1032
+ const KEY_UNSAFE = /[^a-z0-9]+/gi;
1033
+
1034
+ /** The pages a navigation links to: every page but Home (the brand links there) and template pages
1035
+ * (a `/work/:slug` route has no single address), in the studio's order. */
1036
+ export function siteNavPages(pages: ReadonlyArray<SitePageRef>): SitePageRef[] {
1037
+ return pages.filter(p => !!p.route && !p.isHome && p.route !== HOME_PATH && !p.route.includes(ROUTE_PARAM));
1038
+ }
1039
+
1040
+ function navPagePlans(pages: ReadonlyArray<SitePageRef>): PagePlan[] {
1041
+ const used = new Set<string>();
1042
+ return pages.map((p, i) => {
1043
+ let key = p.route.replace(KEY_UNSAFE, '-').replace(/^-+|-+$/g, '').toLowerCase() || `page-${i}`;
1044
+ for (let n = 2; used.has(key); n++) key = `${key}-${n}`;
1045
+ used.add(key);
1046
+ return { key, name: p.name, route: p.route, inNav: true, isHome: p.isHome === true, sections: [] };
1047
+ });
1048
+ }
1049
+
1050
+ /**
1051
+ * The Studio template's header, composed for a studio that has no Site Plan — the builder palette's
1052
+ * "Navigation bar". THE SAME pattern and the same composer as `composeSite`: the brand (the business name),
1053
+ * one plain Link per navigation page, the call to action (W1.1's one dangling-link rule when the studio has
1054
+ * no /contact page: an email to the business, else removed), and the pattern's phone shape. Pure.
1055
+ */
1056
+ export function composeNavHeader(input: NavHeaderInput, options: NavHeaderOptions): ComposedNavHeader {
1057
+ if (!nonEmpty(options?.id)) throw new ComposeError('composeNavHeader needs the header id');
1058
+ const pattern = patternById(SITE_HEADER_PATTERN) as SitePattern;
1059
+ const all = navPagePlans(input.pages);
1060
+ const navRoutes = new Set(siteNavPages(input.pages).map(p => p.route));
1061
+ const pages = all.map(p => ({ ...p, inNav: navRoutes.has(p.route) }));
1062
+ const plan = { business: input.business, pages, dataSources: [], contentTypes: [] } as unknown as SitePlan;
1063
+ const email = nonEmpty(input.business.facts?.email) ? input.business.facts!.email!.trim() : null;
1064
+ const issues: ComposeIssue[] = [];
1065
+ const ctx: Ctx = {
1066
+ owner: GlobalSlot.Header, page: null, section: null, plan, year: 0, email, navPage: null, nextAlias: null,
1067
+ placeholders: [], issues, routes: new Set(pages.map(p => p.route)),
1068
+ };
1069
+ const root = composePattern(pattern, options.id, ctx);
1070
+ root.isGlobal = true;
1071
+ root.globalPosition = pattern.global;
1072
+ const shape = pattern.phoneMenu as NavShape | undefined;
1073
+ const header = shape ? planNavShape([root], shape, { newId: options.newId }, root.id).globals[0] : root;
1074
+ return { header, issues };
1075
+ }
1076
+
1077
+ // ── One pattern on its own (CC4b) ───────────────────────────────────────────────────────────────
1078
+
1079
+ /** The pricing section (CC4b, board 10): plans with a Monthly / Yearly switch — the library and the palette, never a
1080
+ * Studio page (Q35). */
1081
+ export const PRICING_PATTERN = 'pricing.tiers';
1082
+
1083
+ /** What a section is composed from when there is no Site Plan: the studio's name, its pages (the routes a link may
1084
+ * reach) and its facts. */
1085
+ export interface SectionInput {
1086
+ readonly business?: SiteBusiness;
1087
+ readonly pages?: ReadonlyArray<SitePageRef>;
1088
+ }
1089
+
1090
+ export interface SectionOptions {
1091
+ /** The section's id; its parts' ids derive from it (`<id>-0-1-…`), so a fresh id gives fresh parts. */
1092
+ readonly id: string;
1093
+ }
1094
+
1095
+ export interface ComposedSection {
1096
+ section: SerializedNode;
1097
+ placeholders: ComposedPlaceholder[];
1098
+ issues: ComposeIssue[];
1099
+ }
1100
+
1101
+ /**
1102
+ * ONE library pattern composed on its own — the builder palette's ready-made sections (CC4b: "Pricing", the
1103
+ * `pricing.tiers` pattern). THE SAME pattern and composer as `composeSite`: its words, its placeholders (a price shows
1104
+ * its label, never a number) and the one dangling-link rule — except a button to a page the studio lacks keeps its
1105
+ * words, unlinked, for the author to link. Pure.
1106
+ */
1107
+ export function composeSection(patternId: string, input: SectionInput, options: SectionOptions): ComposedSection {
1108
+ if (!nonEmpty(options?.id)) throw new ComposeError('composeSection needs the section id');
1109
+ const pattern = patternById(patternId);
1110
+ if (!pattern) throw new ComposeError(`no pattern "${patternId}" in the library`);
1111
+ if (pattern.global) throw new ComposeError(`"${patternId}" is a header or footer — composeNavHeader / composeSite stamp those`);
1112
+ const business: SiteBusiness = input.business ?? { name: '' } as SiteBusiness;
1113
+ const pages = navPagePlans(input.pages ?? []);
1114
+ const plan = { business, pages, dataSources: [], contentTypes: [] } as unknown as SitePlan;
1115
+ const email = nonEmpty(business.facts?.email) ? business.facts!.email!.trim() : null;
1116
+ const placeholders: ComposedPlaceholder[] = [];
1117
+ const issues: ComposeIssue[] = [];
1118
+ const ctx: Ctx = {
1119
+ owner: options.id, page: null, section: null, plan, year: 0, email, navPage: null, nextAlias: null,
1120
+ placeholders, issues, routes: new Set(pages.map(p => p.route)), keepButtons: true,
1121
+ };
1122
+ const section = composePattern(pattern, options.id, ctx);
1123
+ return { section, placeholders, issues };
1124
+ }
@@ -67,6 +67,8 @@ export interface NavShapeOptions {
67
67
  barItems?: number;
68
68
  /** A new part's id (the builder passes its id generator). Default: `<nav id>-<suffix>`. */
69
69
  newId?: (suffix: string) => string;
70
+ /** The studio's pages, for a header that links to none (a hand-built one): its menu lists these. */
71
+ pages?: ReadonlyArray<NavPage>;
70
72
  }
71
73
 
72
74
  export interface NavShapeResult {
@@ -199,6 +201,21 @@ export function navPages(root: SerializedNode): NavPage[] {
199
201
  return source.filter(isPageLink).map(n => ({ label: textOf(n), route: String(prop(n, 'pageRoute')) }));
200
202
  }
201
203
 
204
+ /** Where a phone menu's pages come from: the header's own page Links, else the studio's pages. */
205
+ export enum NavPagesSource {
206
+ Header = 'header',
207
+ Site = 'site',
208
+ None = 'none',
209
+ }
210
+
211
+ /** The pages a phone menu lists: the header's (`navPages`), else `sitePages` — a hand-built header with no
212
+ * page links still gets a working menu. */
213
+ export function phoneMenuPages(root: SerializedNode, sitePages: ReadonlyArray<NavPage> = []): { pages: NavPage[]; source: NavPagesSource } {
214
+ const own = navPages(root);
215
+ if (own.length) return { pages: own, source: NavPagesSource.Header };
216
+ return sitePages.length ? { pages: [...sitePages], source: NavPagesSource.Site } : { pages: [], source: NavPagesSource.None };
217
+ }
218
+
202
219
  /** A hand-built header: every page Link outside a drawer. */
203
220
  function collectHeaderLinks(root: SerializedNode): SerializedNode[] {
204
221
  const out: SerializedNode[] = [];
@@ -439,7 +456,7 @@ export function planNavShape(globals: ReadonlyArray<SerializedNode>, shape: NavS
439
456
  const kit = makeKit(out, root.id, options.newId);
440
457
 
441
458
  adoptRoles(root);
442
- const pages = navPages(root);
459
+ const { pages } = phoneMenuPages(root, options.pages);
443
460
  const wantDrawer = shape !== NavShape.Bar;
444
461
  const wantBar = shape !== NavShape.Drawer;
445
462
  const wantMenuButton = shape === NavShape.Drawer;
@@ -538,7 +555,7 @@ export interface AddBarItemResult {
538
555
  * The bar's Items list "Add item" (board 2): one more item, before More — the next navigation page the
539
556
  * bar does not link to yet (Home when every page is there). Refused at five (Q2). Input not mutated.
540
557
  */
541
- export function addBarItem(globals: ReadonlyArray<SerializedNode>, options: Pick<NavShapeOptions, 'newId'> = {}): AddBarItemResult {
558
+ export function addBarItem(globals: ReadonlyArray<SerializedNode>, options: Pick<NavShapeOptions, 'newId' | 'pages'> = {}): AddBarItemResult {
542
559
  const out = clone(globals) as SerializedNode[];
543
560
  const root = findNavRoot(out);
544
561
  const b = find(out, NavRole.Bar);
@@ -546,10 +563,43 @@ export function addBarItem(globals: ReadonlyArray<SerializedNode>, options: Pick
546
563
  const kids = b.children ?? (b.children = []);
547
564
  if (kids.length >= BAR_ITEMS_MAX) return { globals: out, added: false };
548
565
  const linked = new Set(kids.filter(c => c.role === NavRole.BarItem).map(c => prop(c, 'pageRoute')));
549
- const page = barPages(root, navPages(root), BAR_ITEMS_MAX + 1).find(p => !linked.has(p.route))
566
+ const page = barPages(root, phoneMenuPages(root, options.pages).pages, BAR_ITEMS_MAX + 1).find(p => !linked.has(p.route))
550
567
  ?? { label: NAV_SHAPE_COPY.home, route: HOME_ROUTE };
551
568
  const item = barItem(makeKit(out, root.id, options.newId), kids.length, page);
552
569
  const more = kids.findIndex(c => c.role === NavRole.More);
553
570
  if (more >= 0) kids.splice(more, 0, item); else kids.push(item);
554
571
  return { globals: out, added: true };
555
572
  }
573
+
574
+ export interface BarItemsEditResult {
575
+ globals: SerializedNode[];
576
+ /** False when nothing changed: no bar, an unknown item, or the bar would drop below three items (Q2). */
577
+ changed: boolean;
578
+ }
579
+
580
+ function barOf(globals: ReadonlyArray<SerializedNode>): { out: SerializedNode[]; kids: SerializedNode[] | null } {
581
+ const out = clone(globals) as SerializedNode[];
582
+ const b = find(out, NavRole.Bar);
583
+ return { out, kids: b ? (b.children ?? (b.children = [])) : null };
584
+ }
585
+
586
+ /**
587
+ * The bar's Items list "Remove" (board 2): the item goes, More included — refused below three items (Q2:
588
+ * 3–5), so the bar never shrinks into something a thumb cannot use. Input not mutated.
589
+ */
590
+ export function removeBarItem(globals: ReadonlyArray<SerializedNode>, itemId: string): BarItemsEditResult {
591
+ const { out, kids } = barOf(globals);
592
+ const i = kids ? kids.findIndex(c => c.id === itemId) : -1;
593
+ if (!kids || i < 0 || kids.length <= BAR_ITEMS_MIN) return { globals: out, changed: false };
594
+ kids.splice(i, 1);
595
+ return { globals: out, changed: true };
596
+ }
597
+
598
+ /** The bar's Items list drag-to-reorder (board 2): item `from` moves to `to`. Input not mutated. */
599
+ export function moveBarItem(globals: ReadonlyArray<SerializedNode>, from: number, to: number): BarItemsEditResult {
600
+ const { out, kids } = barOf(globals);
601
+ if (!kids || from === to || from < 0 || to < 0 || from >= kids.length || to >= kids.length) return { globals: out, changed: false };
602
+ const [item] = kids.splice(from, 1);
603
+ kids.splice(to, 0, item);
604
+ return { globals: out, changed: true };
605
+ }