@vectoriox/iox-ui 5.38.0 → 5.40.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 (33) hide show
  1. package/composite-core/index.d.ts +392 -16
  2. package/design-core/index.d.ts +322 -3
  3. package/fesm2022/vectoriox-iox-ui-actions-core.mjs +4 -2
  4. package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
  5. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +1129 -146
  6. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  7. package/fesm2022/vectoriox-iox-ui-design-core.mjs +383 -3
  8. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  9. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +219 -8
  10. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  11. package/fesm2022/vectoriox-iox-ui.mjs +16 -9
  12. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  13. package/generation-core/index.d.ts +107 -3
  14. package/index.d.ts +11 -6
  15. package/package.json +1 -1
  16. package/src/actions-core/element-actions.ts +4 -2
  17. package/src/composite-core/accordion-runtime.ts +3 -15
  18. package/src/composite-core/accordion.ts +4 -2
  19. package/src/composite-core/composite-kinds.ts +121 -7
  20. package/src/composite-core/composite-runtime.ts +68 -0
  21. package/src/composite-core/exclusive-runtime.ts +358 -0
  22. package/src/composite-core/public-api.ts +5 -2
  23. package/src/composite-core/structure.ts +22 -2
  24. package/src/composite-core/tabs.ts +628 -0
  25. package/src/design-core/contrast-audit.ts +5 -0
  26. package/src/design-core/element-styles.ts +7 -3
  27. package/src/design-core/flavor-spec.generated.ts +371 -0
  28. package/src/generation-core/compose-site.ts +147 -1
  29. package/src/generation-core/nav-shape.ts +53 -3
  30. package/src/generation-core/node-baselines.generated.ts +8 -2
  31. package/src/generation-core/patterns.ts +3 -0
  32. package/src/generation-core/site-spec.generated.ts +46 -0
  33. package/styles/iox-design.css +16 -0
@@ -19,10 +19,13 @@ export enum ElementStyleState {
19
19
  Focus = 'focus',
20
20
  /** The page you're on (CC1 N2): compiles to `[aria-current="page"]`, never a pseudo-class. */
21
21
  Current = 'current',
22
+ /** A composite part that is ON — the active tab (CC4a "Active"): compiles to `[data-iox-on]`, the attribute every
23
+ * composite runtime writes. */
24
+ On = 'on',
22
25
  }
23
26
 
24
27
  export const ELEMENT_STYLE_STATES: ReadonlyArray<ElementStyleState> =
25
- [ElementStyleState.Hover, ElementStyleState.Active, ElementStyleState.Focus, ElementStyleState.Current];
28
+ [ElementStyleState.Hover, ElementStyleState.Active, ElementStyleState.Focus, ElementStyleState.Current, ElementStyleState.On];
26
29
 
27
30
  export interface ElementStyle {
28
31
  readonly id: string;
@@ -34,6 +37,7 @@ export interface ElementStyle {
34
37
  readonly active?: Readonly<Record<string, string>>;
35
38
  readonly focus?: Readonly<Record<string, string>>;
36
39
  readonly current?: Readonly<Record<string, string>>;
40
+ readonly on?: Readonly<Record<string, string>>;
37
41
  }
38
42
 
39
43
  export const ELEMENT_STYLES: ReadonlyArray<ElementStyle> = SPEC_ELEMENT_STYLES;
@@ -166,7 +170,7 @@ function ownedEntries(style: ElementStyle): Array<OwnedTrait & { value: string }
166
170
  return out;
167
171
  }
168
172
 
169
- /** Every trait the style owns with the value it writes — base first, then hover/active/focus/current. */
173
+ /** Every trait the style owns with the value it writes — base first, then hover/active/focus/current/on. */
170
174
  export function ownedTraitsOf(style: ElementStyle): Array<OwnedTrait & { value: string }> {
171
175
  return ownedEntries(style);
172
176
  }
@@ -196,7 +200,7 @@ function writeEntries(target: ElementStyleTarget, style: ElementStyle, skip: Own
196
200
  return unknown;
197
201
  }
198
202
 
199
- /** Write the style's exact base map and its hover/active/focus/current maps into the target. */
203
+ /** Write the style's exact base map and its hover/active/focus/current/on maps into the target. */
200
204
  export function applyElementStyle(target: ElementStyleTarget, id: string): ApplyElementStyleResult {
201
205
  const style = elementStyleById(id);
202
206
  if (!style) return { applied: false, unknownTraits: [] };
@@ -1019,6 +1019,41 @@ export const SPEC_TIERS = {
1019
1019
  "type": "spacing",
1020
1020
  "default": "0px 0px 20px 0px"
1021
1021
  },
1022
+ {
1023
+ "name": "tab-list-border",
1024
+ "type": "color",
1025
+ "default": "var(--iox-line)"
1026
+ },
1027
+ {
1028
+ "name": "tab-ink",
1029
+ "type": "color",
1030
+ "default": "var(--iox-text-muted)"
1031
+ },
1032
+ {
1033
+ "name": "tab-on-ink",
1034
+ "type": "color",
1035
+ "default": "var(--iox-text)"
1036
+ },
1037
+ {
1038
+ "name": "tab-on-mark",
1039
+ "type": "color",
1040
+ "default": "var(--iox-accent-text)"
1041
+ },
1042
+ {
1043
+ "name": "tab-pad",
1044
+ "type": "spacing",
1045
+ "default": "10px 16px"
1046
+ },
1047
+ {
1048
+ "name": "tab-list-border-width",
1049
+ "type": "size",
1050
+ "default": "0px 0px var(--iox-border-width) 0px"
1051
+ },
1052
+ {
1053
+ "name": "tab-mark-width",
1054
+ "type": "size",
1055
+ "default": "0px 0px 2px 0px"
1056
+ },
1022
1057
  {
1023
1058
  "name": "heading-color",
1024
1059
  "type": "color",
@@ -1949,6 +1984,48 @@ export const SPEC_ELEMENT_STYLES = [
1949
1984
  "color": "var(--iox-text-muted)"
1950
1985
  }
1951
1986
  },
1987
+ {
1988
+ "id": "tab-list",
1989
+ "label": "Tab list",
1990
+ "group": "Layout",
1991
+ "appliesTo": [
1992
+ "Container",
1993
+ "Repeater"
1994
+ ],
1995
+ "base": {
1996
+ "color": "inherit",
1997
+ "borderStyle": "solid",
1998
+ "borderWidth": "var(--iox-tab-list-border-width)",
1999
+ "borderColor": "var(--iox-tab-list-border)"
2000
+ }
2001
+ },
2002
+ {
2003
+ "id": "tab",
2004
+ "label": "Tab",
2005
+ "group": "Buttons",
2006
+ "appliesTo": [
2007
+ "ButtonBlock"
2008
+ ],
2009
+ "base": {
2010
+ "color": "var(--iox-tab-ink)",
2011
+ "padding": "var(--iox-tab-pad)",
2012
+ "fontFamily": "var(--iox-button-font)",
2013
+ "fontWeight": "var(--iox-button-weight)",
2014
+ "backgroundColor": "transparent",
2015
+ "borderStyle": "solid",
2016
+ "borderWidth": "var(--iox-tab-mark-width)",
2017
+ "borderColor": "transparent",
2018
+ "transitionDuration": "var(--iox-dur-fast)",
2019
+ "transitionTimingFunction": "var(--iox-ease-standard)"
2020
+ },
2021
+ "on": {
2022
+ "color": "var(--iox-tab-on-ink)",
2023
+ "borderColor": "var(--iox-tab-on-mark)"
2024
+ },
2025
+ "focus": {
2026
+ "boxShadow": "var(--iox-focus-ring)"
2027
+ }
2028
+ },
1952
2029
  {
1953
2030
  "id": "media",
1954
2031
  "label": "Image",
@@ -4181,6 +4258,55 @@ export const SPEC_FLAVOR_PACKS = [
4181
4258
  "group": "components",
4182
4259
  "value": "0px 0px 20px 0px"
4183
4260
  },
4261
+ {
4262
+ "name": "tab-list-border",
4263
+ "tier": "component",
4264
+ "type": "color",
4265
+ "group": "components",
4266
+ "value": "var(--iox-line)"
4267
+ },
4268
+ {
4269
+ "name": "tab-ink",
4270
+ "tier": "component",
4271
+ "type": "color",
4272
+ "group": "components",
4273
+ "value": "var(--iox-text-muted)"
4274
+ },
4275
+ {
4276
+ "name": "tab-on-ink",
4277
+ "tier": "component",
4278
+ "type": "color",
4279
+ "group": "components",
4280
+ "value": "var(--iox-text)"
4281
+ },
4282
+ {
4283
+ "name": "tab-on-mark",
4284
+ "tier": "component",
4285
+ "type": "color",
4286
+ "group": "components",
4287
+ "value": "var(--iox-accent-text)"
4288
+ },
4289
+ {
4290
+ "name": "tab-pad",
4291
+ "tier": "component",
4292
+ "type": "spacing",
4293
+ "group": "components",
4294
+ "value": "10px 16px"
4295
+ },
4296
+ {
4297
+ "name": "tab-list-border-width",
4298
+ "tier": "component",
4299
+ "type": "size",
4300
+ "group": "components",
4301
+ "value": "0px 0px var(--iox-border-width) 0px"
4302
+ },
4303
+ {
4304
+ "name": "tab-mark-width",
4305
+ "tier": "component",
4306
+ "type": "size",
4307
+ "group": "components",
4308
+ "value": "0px 0px 2px 0px"
4309
+ },
4184
4310
  {
4185
4311
  "name": "heading-color",
4186
4312
  "tier": "component",
@@ -5823,6 +5949,55 @@ export const SPEC_FLAVOR_PACKS = [
5823
5949
  "group": "components",
5824
5950
  "value": "0px 0px 20px 0px"
5825
5951
  },
5952
+ {
5953
+ "name": "tab-list-border",
5954
+ "tier": "component",
5955
+ "type": "color",
5956
+ "group": "components",
5957
+ "value": "var(--iox-line)"
5958
+ },
5959
+ {
5960
+ "name": "tab-ink",
5961
+ "tier": "component",
5962
+ "type": "color",
5963
+ "group": "components",
5964
+ "value": "var(--iox-text-muted)"
5965
+ },
5966
+ {
5967
+ "name": "tab-on-ink",
5968
+ "tier": "component",
5969
+ "type": "color",
5970
+ "group": "components",
5971
+ "value": "var(--iox-text)"
5972
+ },
5973
+ {
5974
+ "name": "tab-on-mark",
5975
+ "tier": "component",
5976
+ "type": "color",
5977
+ "group": "components",
5978
+ "value": "var(--iox-accent-text)"
5979
+ },
5980
+ {
5981
+ "name": "tab-pad",
5982
+ "tier": "component",
5983
+ "type": "spacing",
5984
+ "group": "components",
5985
+ "value": "10px 16px"
5986
+ },
5987
+ {
5988
+ "name": "tab-list-border-width",
5989
+ "tier": "component",
5990
+ "type": "size",
5991
+ "group": "components",
5992
+ "value": "0px 0px var(--iox-border-width) 0px"
5993
+ },
5994
+ {
5995
+ "name": "tab-mark-width",
5996
+ "tier": "component",
5997
+ "type": "size",
5998
+ "group": "components",
5999
+ "value": "0px 0px 2px 0px"
6000
+ },
5826
6001
  {
5827
6002
  "name": "heading-color",
5828
6003
  "tier": "component",
@@ -7459,6 +7634,55 @@ export const SPEC_FLAVOR_PACKS = [
7459
7634
  "group": "components",
7460
7635
  "value": "0px 0px 20px 0px"
7461
7636
  },
7637
+ {
7638
+ "name": "tab-list-border",
7639
+ "tier": "component",
7640
+ "type": "color",
7641
+ "group": "components",
7642
+ "value": "var(--iox-line)"
7643
+ },
7644
+ {
7645
+ "name": "tab-ink",
7646
+ "tier": "component",
7647
+ "type": "color",
7648
+ "group": "components",
7649
+ "value": "var(--iox-text-muted)"
7650
+ },
7651
+ {
7652
+ "name": "tab-on-ink",
7653
+ "tier": "component",
7654
+ "type": "color",
7655
+ "group": "components",
7656
+ "value": "var(--iox-text)"
7657
+ },
7658
+ {
7659
+ "name": "tab-on-mark",
7660
+ "tier": "component",
7661
+ "type": "color",
7662
+ "group": "components",
7663
+ "value": "var(--iox-accent-text)"
7664
+ },
7665
+ {
7666
+ "name": "tab-pad",
7667
+ "tier": "component",
7668
+ "type": "spacing",
7669
+ "group": "components",
7670
+ "value": "10px 16px"
7671
+ },
7672
+ {
7673
+ "name": "tab-list-border-width",
7674
+ "tier": "component",
7675
+ "type": "size",
7676
+ "group": "components",
7677
+ "value": "0px 0px var(--iox-border-width) 0px"
7678
+ },
7679
+ {
7680
+ "name": "tab-mark-width",
7681
+ "tier": "component",
7682
+ "type": "size",
7683
+ "group": "components",
7684
+ "value": "0px 0px 2px 0px"
7685
+ },
7462
7686
  {
7463
7687
  "name": "heading-color",
7464
7688
  "tier": "component",
@@ -9136,6 +9360,55 @@ export const SPEC_FLAVOR_PACKS = [
9136
9360
  "group": "components",
9137
9361
  "value": "0px 0px 20px 0px"
9138
9362
  },
9363
+ {
9364
+ "name": "tab-list-border",
9365
+ "tier": "component",
9366
+ "type": "color",
9367
+ "group": "components",
9368
+ "value": "var(--iox-line)"
9369
+ },
9370
+ {
9371
+ "name": "tab-ink",
9372
+ "tier": "component",
9373
+ "type": "color",
9374
+ "group": "components",
9375
+ "value": "var(--iox-text-muted)"
9376
+ },
9377
+ {
9378
+ "name": "tab-on-ink",
9379
+ "tier": "component",
9380
+ "type": "color",
9381
+ "group": "components",
9382
+ "value": "var(--iox-text)"
9383
+ },
9384
+ {
9385
+ "name": "tab-on-mark",
9386
+ "tier": "component",
9387
+ "type": "color",
9388
+ "group": "components",
9389
+ "value": "var(--iox-accent-text)"
9390
+ },
9391
+ {
9392
+ "name": "tab-pad",
9393
+ "tier": "component",
9394
+ "type": "spacing",
9395
+ "group": "components",
9396
+ "value": "10px 16px"
9397
+ },
9398
+ {
9399
+ "name": "tab-list-border-width",
9400
+ "tier": "component",
9401
+ "type": "size",
9402
+ "group": "components",
9403
+ "value": "0px 0px var(--iox-border-width) 0px"
9404
+ },
9405
+ {
9406
+ "name": "tab-mark-width",
9407
+ "tier": "component",
9408
+ "type": "size",
9409
+ "group": "components",
9410
+ "value": "0px 0px 2px 0px"
9411
+ },
9139
9412
  {
9140
9413
  "name": "heading-color",
9141
9414
  "tier": "component",
@@ -10775,6 +11048,55 @@ export const SPEC_FLAVOR_PACKS = [
10775
11048
  "group": "components",
10776
11049
  "value": "0px 0px 20px 0px"
10777
11050
  },
11051
+ {
11052
+ "name": "tab-list-border",
11053
+ "tier": "component",
11054
+ "type": "color",
11055
+ "group": "components",
11056
+ "value": "var(--iox-line)"
11057
+ },
11058
+ {
11059
+ "name": "tab-ink",
11060
+ "tier": "component",
11061
+ "type": "color",
11062
+ "group": "components",
11063
+ "value": "var(--iox-text-muted)"
11064
+ },
11065
+ {
11066
+ "name": "tab-on-ink",
11067
+ "tier": "component",
11068
+ "type": "color",
11069
+ "group": "components",
11070
+ "value": "var(--iox-text)"
11071
+ },
11072
+ {
11073
+ "name": "tab-on-mark",
11074
+ "tier": "component",
11075
+ "type": "color",
11076
+ "group": "components",
11077
+ "value": "var(--iox-accent-text)"
11078
+ },
11079
+ {
11080
+ "name": "tab-pad",
11081
+ "tier": "component",
11082
+ "type": "spacing",
11083
+ "group": "components",
11084
+ "value": "10px 16px"
11085
+ },
11086
+ {
11087
+ "name": "tab-list-border-width",
11088
+ "tier": "component",
11089
+ "type": "size",
11090
+ "group": "components",
11091
+ "value": "0px 0px var(--iox-border-width) 0px"
11092
+ },
11093
+ {
11094
+ "name": "tab-mark-width",
11095
+ "tier": "component",
11096
+ "type": "size",
11097
+ "group": "components",
11098
+ "value": "0px 0px 2px 0px"
11099
+ },
10778
11100
  {
10779
11101
  "name": "heading-color",
10780
11102
  "tier": "component",
@@ -12420,6 +12742,55 @@ export const SPEC_FLAVOR_PACKS = [
12420
12742
  "group": "components",
12421
12743
  "value": "0px 0px 20px 0px"
12422
12744
  },
12745
+ {
12746
+ "name": "tab-list-border",
12747
+ "tier": "component",
12748
+ "type": "color",
12749
+ "group": "components",
12750
+ "value": "var(--iox-line)"
12751
+ },
12752
+ {
12753
+ "name": "tab-ink",
12754
+ "tier": "component",
12755
+ "type": "color",
12756
+ "group": "components",
12757
+ "value": "var(--iox-text-muted)"
12758
+ },
12759
+ {
12760
+ "name": "tab-on-ink",
12761
+ "tier": "component",
12762
+ "type": "color",
12763
+ "group": "components",
12764
+ "value": "var(--iox-text)"
12765
+ },
12766
+ {
12767
+ "name": "tab-on-mark",
12768
+ "tier": "component",
12769
+ "type": "color",
12770
+ "group": "components",
12771
+ "value": "var(--iox-accent-text)"
12772
+ },
12773
+ {
12774
+ "name": "tab-pad",
12775
+ "tier": "component",
12776
+ "type": "spacing",
12777
+ "group": "components",
12778
+ "value": "10px 16px"
12779
+ },
12780
+ {
12781
+ "name": "tab-list-border-width",
12782
+ "tier": "component",
12783
+ "type": "size",
12784
+ "group": "components",
12785
+ "value": "0px 0px var(--iox-border-width) 0px"
12786
+ },
12787
+ {
12788
+ "name": "tab-mark-width",
12789
+ "tier": "component",
12790
+ "type": "size",
12791
+ "group": "components",
12792
+ "value": "0px 0px 2px 0px"
12793
+ },
12423
12794
  {
12424
12795
  "name": "heading-color",
12425
12796
  "tier": "component",
@@ -3,7 +3,9 @@ import { FormPresetDef, FormPresetField, formPresetById } from '@vectoriox/iox-u
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
- CompositeNodeSpec, findByRole, LIGHTBOX_LABEL, pickAccordionAutoBinds, SLIDE_TO_ROW, SLIDE_TYPE,
6
+ CompositeNodeSpec, findByRole, LIGHTBOX_LABEL, pickAccordionAutoBinds, pickTabsAutoBinds, SLIDE_TO_ROW, SLIDE_TYPE, buildTabsRecipe, tabLabelNodeOf,
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,
7
9
  } from '@vectoriox/iox-ui/composite-core';
8
10
 
9
11
  import { NODE_BASELINES } from './node-baselines.generated';
@@ -586,6 +588,7 @@ const CARD_BINDS: Readonly<Record<CardBindTarget, { role: CardRole; prop: string
586
588
  */
587
589
  function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | null, ctx: Ctx): SerializedNode {
588
590
  if (pn.recipe === PatternRecipe.Accordion) return composeAccordion(pn, id, ctx);
591
+ if (pn.recipe === PatternRecipe.Tabs) return composeTabs(pn, id, ctx);
589
592
  if (pn.recipe === PatternRecipe.Carousel) return composeCarousel(pn, id, ctx);
590
593
  if (pn.recipe === PatternRecipe.Lightbox) return composeLightbox(pn, id, repeater, ctx);
591
594
  if (pn.recipe !== PatternRecipe.Card) throw new ComposeError(`${id}: no recipe "${pn.recipe}" in composite-core`);
@@ -667,6 +670,70 @@ function composeAccordion(pn: PatternNode, id: string, ctx: Ctx): SerializedNode
667
670
  return root;
668
671
  }
669
672
 
673
+ /** The content type a section binding lists, through its data source. */
674
+ function boundContentType(binding: string, ctx: Ctx): ContentTypePlan | undefined {
675
+ const source = ctx.plan.dataSources.find(d => d.alias === binding);
676
+ return source && 'contentType' in source ? ctx.plan.contentTypes.find(t => t.name === source.contentType) : undefined;
677
+ }
678
+
679
+ const TABS_ROOT_KEY = 'tabs';
680
+ const TABS_HOLDER_KEYS: ReadonlySet<string> = new Set(['list', 'panels']);
681
+
682
+ /**
683
+ * Tabs (CC4a) — the composite-core recipe stamped with this composer's factory. Manual (no section binding): three
684
+ * tabs whose words are placeholders the studio rewrites (`ph: 'copy'`) and whose pictures it supplies — never invented.
685
+ * From content (the section names a list `binding`, R3's AI path): the tab list and the panels holder are BOTH
686
+ * Repeaters over that list, each holding its one template (one row → its tab AND its panel, paired by the row's id at
687
+ * render), the tab's words, the panel's words and its picture bound to the type's first short-text, long-text and image
688
+ * fields (`pickTabsAutoBinds`, the builder switch's rule), limit 6. The tab list is named by the pattern's `name`.
689
+ */
690
+ function composeTabs(pn: PatternNode, id: string, ctx: Ctx): SerializedNode {
691
+ const binding = pn.src === PatternDirective.OptionalBinding || pn.src === PatternDirective.Binding ? ctx.section?.binding ?? null : null;
692
+ if (pn.src === PatternDirective.Binding && !binding) throw new ComposeError(`${id}: "$binding" with no section binding`);
693
+ const factory = (spec: CompositeNodeSpec): CompositeNode => {
694
+ const type = binding && TABS_HOLDER_KEYS.has(spec.key) ? REPEATER : spec.type;
695
+ return create(spec.key === TABS_ROOT_KEY ? id : `${id}-${spec.key}`, type, { ...(spec.props ?? {}) }, spec.elementStyle ?? null) as CompositeNode;
696
+ };
697
+ const root = buildTabsRecipe(factory, { tabs: binding ? TABS_WORDS.slice(0, 1) : undefined, label: pn.name }) as SerializedNode;
698
+ const tree = root as CompositeNode;
699
+ if (!binding) {
700
+ if (pn.ph === PatternPlaceholder.Copy) {
701
+ for (const tab of tabsTriggers(tree)) {
702
+ const words = tabLabelNodeOf(tab);
703
+ if (words) ctx.placeholders.push({ nodeId: words.id!, owner: ctx.owner, kind: PlaceholderKind.Copy, text: String(words.props?.[TABS_LABEL_BIND_PROP] ?? '') });
704
+ }
705
+ for (const panel of tabsPanels(tree)) {
706
+ const text = tabPanelTextOf(panel);
707
+ if (text) ctx.placeholders.push({ nodeId: text.id!, owner: ctx.owner, kind: PlaceholderKind.Copy, text: String(text.props?.[TABS_TEXT_BIND_PROP] ?? '') });
708
+ const image = tabPanelImageOf(panel);
709
+ if (image) ctx.placeholders.push({ nodeId: image.id!, owner: ctx.owner, kind: PlaceholderKind.Image, text: '' });
710
+ }
711
+ }
712
+ return root;
713
+ }
714
+ const type = boundContentType(binding, ctx);
715
+ const picks = pickTabsAutoBinds((type?.fields ?? []).map(f => ({ name: f.name, type: f.type, controller: f.controller })));
716
+ if (!picks.label || !picks.text) {
717
+ throw new ComposeError(`${id}: "${binding}" has no short-text field for the tab and long-text field for its panel`);
718
+ }
719
+ for (const holder of [tabsListOf(tree), tabsPanelsOf(tree)] as SerializedNode[]) {
720
+ addBinding(holder, { prop: 'source', source: binding, path: '' });
721
+ setProp(holder, 'limit', TABS_CONTENT_LIMIT);
722
+ }
723
+ const [tab] = tabsTriggers(tree);
724
+ const [panel] = tabsPanels(tree);
725
+ delete tab.itemKey;
726
+ delete panel.itemKey;
727
+ addBinding(tabLabelNodeOf(tab) as SerializedNode, { prop: TABS_LABEL_BIND_PROP, source: binding, path: picks.label });
728
+ addBinding(tabPanelTextOf(panel) as SerializedNode, { prop: TABS_TEXT_BIND_PROP, source: binding, path: picks.text });
729
+ const image = tabPanelImageOf(panel) as SerializedNode | undefined;
730
+ if (image && picks.image) {
731
+ addBinding(image, { prop: TABS_IMAGE_BIND_PROP, source: binding, path: picks.image });
732
+ addBinding(image, { prop: 'alt', source: binding, path: picks.label });
733
+ }
734
+ return root;
735
+ }
736
+
670
737
  /** A recipe's node factory for this composer: ids derive from the recipe's keys (`<id>-<key>`, the root `<id>`). */
671
738
  function recipeFactory(id: string, rootKey: string): (spec: CompositeNodeSpec) => CompositeNode {
672
739
  return spec => create(spec.key === rootKey ? id : `${id}-${spec.key}`, spec.type, { ...(spec.props ?? {}) }, spec.elementStyle ?? null) as CompositeNode;
@@ -819,3 +886,82 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
819
886
  issues,
820
887
  };
821
888
  }
889
+
890
+ // ── The site header on its own (CC1.1) ──────────────────────────────────────────────────────────
891
+
892
+ /** The header pattern the Studio template stamps — and the builder palette's "Navigation bar". */
893
+ export const SITE_HEADER_PATTERN = 'nav.inline-cta';
894
+
895
+ /** A page of the studio as the builder lists it. */
896
+ export interface SitePageRef {
897
+ readonly name: string;
898
+ readonly route: string;
899
+ readonly isHome?: boolean;
900
+ }
901
+
902
+ /** What the header is composed from when there is no Site Plan: the studio's name and its pages. */
903
+ export interface NavHeaderInput {
904
+ readonly business: SiteBusiness;
905
+ /** Every page of the studio, in the order the navigation should list them. */
906
+ readonly pages: ReadonlyArray<SitePageRef>;
907
+ }
908
+
909
+ export interface NavHeaderOptions {
910
+ /** The header's id; its parts' ids derive from it (`<id>-0-1-…`), so a fresh id gives fresh parts. */
911
+ readonly id: string;
912
+ /** A phone-shape part's id (the builder passes its id generator). */
913
+ readonly newId?: (suffix: string) => string;
914
+ }
915
+
916
+ export interface ComposedNavHeader {
917
+ /** The header — a global BEFORE element — carrying the pattern's phone shape (the drawer). */
918
+ header: SerializedNode;
919
+ issues: ComposeIssue[];
920
+ }
921
+
922
+ const ROUTE_PARAM = ':';
923
+ const HOME_PATH = '/';
924
+ const KEY_UNSAFE = /[^a-z0-9]+/gi;
925
+
926
+ /** The pages a navigation links to: every page but Home (the brand links there) and template pages
927
+ * (a `/work/:slug` route has no single address), in the studio's order. */
928
+ export function siteNavPages(pages: ReadonlyArray<SitePageRef>): SitePageRef[] {
929
+ return pages.filter(p => !!p.route && !p.isHome && p.route !== HOME_PATH && !p.route.includes(ROUTE_PARAM));
930
+ }
931
+
932
+ function navPagePlans(pages: ReadonlyArray<SitePageRef>): PagePlan[] {
933
+ const used = new Set<string>();
934
+ return pages.map((p, i) => {
935
+ let key = p.route.replace(KEY_UNSAFE, '-').replace(/^-+|-+$/g, '').toLowerCase() || `page-${i}`;
936
+ for (let n = 2; used.has(key); n++) key = `${key}-${n}`;
937
+ used.add(key);
938
+ return { key, name: p.name, route: p.route, inNav: true, isHome: p.isHome === true, sections: [] };
939
+ });
940
+ }
941
+
942
+ /**
943
+ * The Studio template's header, composed for a studio that has no Site Plan — the builder palette's
944
+ * "Navigation bar". THE SAME pattern and the same composer as `composeSite`: the brand (the business name),
945
+ * one plain Link per navigation page, the call to action (W1.1's one dangling-link rule when the studio has
946
+ * no /contact page: an email to the business, else removed), and the pattern's phone shape. Pure.
947
+ */
948
+ export function composeNavHeader(input: NavHeaderInput, options: NavHeaderOptions): ComposedNavHeader {
949
+ if (!nonEmpty(options?.id)) throw new ComposeError('composeNavHeader needs the header id');
950
+ const pattern = patternById(SITE_HEADER_PATTERN) as SitePattern;
951
+ const all = navPagePlans(input.pages);
952
+ const navRoutes = new Set(siteNavPages(input.pages).map(p => p.route));
953
+ const pages = all.map(p => ({ ...p, inNav: navRoutes.has(p.route) }));
954
+ const plan = { business: input.business, pages, dataSources: [], contentTypes: [] } as unknown as SitePlan;
955
+ const email = nonEmpty(input.business.facts?.email) ? input.business.facts!.email!.trim() : null;
956
+ const issues: ComposeIssue[] = [];
957
+ const ctx: Ctx = {
958
+ owner: GlobalSlot.Header, page: null, section: null, plan, year: 0, email, navPage: null, nextAlias: null,
959
+ placeholders: [], issues, routes: new Set(pages.map(p => p.route)),
960
+ };
961
+ const root = composePattern(pattern, options.id, ctx);
962
+ root.isGlobal = true;
963
+ root.globalPosition = pattern.global;
964
+ const shape = pattern.phoneMenu as NavShape | undefined;
965
+ const header = shape ? planNavShape([root], shape, { newId: options.newId }, root.id).globals[0] : root;
966
+ return { header, issues };
967
+ }