@vectoriox/iox-ui 5.27.0 → 5.29.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/design-core/index.d.ts +186 -7
  2. package/fesm2022/vectoriox-iox-ui-design-core.mjs +201 -11
  3. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  4. package/fesm2022/vectoriox-iox-ui-forms-core.mjs +119 -5
  5. package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +591 -181
  7. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui.mjs +189 -16
  9. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  10. package/forms-core/index.d.ts +59 -3
  11. package/generation-core/index.d.ts +206 -74
  12. package/index.d.ts +30 -3
  13. package/package.json +1 -1
  14. package/src/design-core/contrast-audit.ts +7 -6
  15. package/src/design-core/flavor-spec.generated.ts +170 -5
  16. package/src/design-core/node-links.ts +32 -0
  17. package/src/design-core/public-api.ts +1 -0
  18. package/src/forms-core/collect.ts +3 -1
  19. package/src/forms-core/form-presets.ts +105 -0
  20. package/src/forms-core/options.ts +29 -0
  21. package/src/forms-core/public-api.ts +2 -0
  22. package/src/forms-core/transport.ts +6 -2
  23. package/src/generation-core/baseline-roles.ts +75 -0
  24. package/src/generation-core/compose-site.ts +256 -95
  25. package/src/generation-core/node-baselines.generated.ts +90 -0
  26. package/src/generation-core/patterns.ts +31 -9
  27. package/src/generation-core/public-api.ts +1 -0
  28. package/src/generation-core/serialized-node.ts +13 -2
  29. package/src/generation-core/site-plan.model.ts +4 -3
  30. package/src/generation-core/site-spec.generated.ts +110 -57
  31. package/src/generation-core/validate-site-plan.ts +52 -20
  32. package/styles/iox-forms.css +136 -8
@@ -16,7 +16,8 @@ import { DesignTokenLike, ResolvedMode, resolveSchemeTokenValues } from './token
16
16
  * - `inherit` → the container's ink, a transparent fill → what is under it, translucent fills composited,
17
17
  * a gradient ground (Glass's mesh, Midnight's glow) checked at its worst stop;
18
18
  * - base and hover; a button's icon read through the contract's `inkScopes` (it takes the button's ink),
19
- * its focus ring; the form parts the field baseline paints (`contrastAudit.formParts`).
19
+ * its focus ring; the form parts the field baseline paints (`contrastAudit.formParts`); the form's
20
+ * Success / Error notices, in every scheme and container.
20
21
  *
21
22
  * WCAG 2: 4.5:1 for text, 3:1 for glyphs, UI boundaries and focus indicators (1.4.11), judged on 8-bit
22
23
  * colours. The same audit gates the contract (`colour.mjs auditContrast`, every flavor × tint × a seed
@@ -162,11 +163,11 @@ export function auditDesignContrast(rows: ReadonlyArray<DesignTokenLike>, mode:
162
163
  check(FIELD, `${part.part} thumb`, parseCssColour(resolveIn(values, part.thumb), boxInk), bgs.map(b => compositeOver(paint, b)), part.min);
163
164
  }
164
165
  }
165
- if (box === null && schemeId === DEFAULT_SCHEME) {
166
- for (const n of notices) {
167
- const fill = paintColours(resolveIn(values, ownStyle(n)['backgroundColor']), boxInk);
168
- check(n.id, 'text', parseCssColour(resolveIn(values, ownStyle(n)['color']), boxInk), overAll(fill, bgs), WCAG_AA_TEXT);
169
- }
166
+ // A form's Success / Error notice sits wherever its form does — on the section in every scheme
167
+ // AND inside every container (the Studio's contact form lives in a Card): W1.1.
168
+ for (const n of notices) {
169
+ const fill = paintColours(resolveIn(values, ownStyle(n)['backgroundColor']), boxInk);
170
+ check(n.id, 'text', parseCssColour(resolveIn(values, ownStyle(n)['color']), boxInk), overAll(fill, bgs), WCAG_AA_TEXT);
170
171
  }
171
172
  }
172
173
  }
@@ -1522,6 +1522,23 @@ export const SPEC_ELEMENT_STYLES = [
1522
1522
  "color": "var(--iox-link-hover-color)"
1523
1523
  }
1524
1524
  },
1525
+ {
1526
+ "id": "button-label",
1527
+ "label": "Button label",
1528
+ "group": "Text",
1529
+ "appliesTo": [
1530
+ "Text"
1531
+ ],
1532
+ "base": {
1533
+ "fontFamily": "inherit",
1534
+ "fontSize": "inherit",
1535
+ "fontWeight": "inherit",
1536
+ "lineHeight": "inherit",
1537
+ "letterSpacing": "inherit",
1538
+ "textTransform": "inherit",
1539
+ "color": "inherit"
1540
+ }
1541
+ },
1525
1542
  {
1526
1543
  "id": "section",
1527
1544
  "label": "Section",
@@ -1811,7 +1828,8 @@ export const SPEC_FIELD_CLASS_BASELINE = {
1811
1828
  ".iox-es-field .iox-field__control:focus-visible": {
1812
1829
  "border-color": "var(--iox-accent-text)",
1813
1830
  "box-shadow": "var(--iox-field-focus)",
1814
- "outline": "none"
1831
+ "outline": "2px solid transparent",
1832
+ "outline-offset": "2px"
1815
1833
  },
1816
1834
  ".iox-es-field .iox-field__label": {
1817
1835
  "font-family": "var(--iox-font-body)",
@@ -1819,6 +1837,12 @@ export const SPEC_FIELD_CLASS_BASELINE = {
1819
1837
  "font-weight": "var(--iox-field-label-weight)",
1820
1838
  "color": "var(--iox-text)"
1821
1839
  },
1840
+ ".iox-es-field .iox-field__option": {
1841
+ "font-family": "var(--iox-font-body)",
1842
+ "font-size": "var(--iox-size-body)",
1843
+ "color": "var(--iox-text)",
1844
+ "cursor": "pointer"
1845
+ },
1822
1846
  ".iox-es-field .iox-field__help": {
1823
1847
  "font-size": "var(--iox-size-small)",
1824
1848
  "color": "var(--iox-text-muted)"
@@ -1834,11 +1858,121 @@ export const SPEC_FIELD_CLASS_BASELINE = {
1834
1858
  "color": "var(--iox-text-muted)",
1835
1859
  "opacity": "1"
1836
1860
  },
1837
- ".iox-es-field .iox-field__control:where([type='checkbox'], [type='radio'], [type='range'])": {
1861
+ ".iox-es-field .iox-field__control:disabled": {
1862
+ "opacity": "0.55",
1863
+ "cursor": "not-allowed"
1864
+ },
1865
+ ".iox-es-field .iox-field__control:where(.iox-field__control--switch, [type='range'])": {
1838
1866
  "border": "none",
1839
1867
  "padding": "0",
1840
- "background": "transparent",
1841
- "accent-color": "var(--iox-accent-text)"
1868
+ "background": "transparent"
1869
+ },
1870
+ ".iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio'])": {
1871
+ "appearance": "none",
1872
+ "-webkit-appearance": "none",
1873
+ "display": "inline-grid",
1874
+ "place-content": "center",
1875
+ "flex-shrink": "0",
1876
+ "margin": "0",
1877
+ "padding": "0",
1878
+ "inline-size": "1.25em",
1879
+ "block-size": "1.25em",
1880
+ "vertical-align": "middle",
1881
+ "cursor": "pointer",
1882
+ "background": "var(--iox-surface)",
1883
+ "border-color": "var(--iox-line-strong)",
1884
+ "border-radius": "var(--iox-radius-xs)",
1885
+ "transition": "background var(--iox-dur-fast) var(--iox-ease-standard), border-color var(--iox-dur-fast) var(--iox-ease-standard), box-shadow var(--iox-dur-fast) var(--iox-ease-standard)"
1886
+ },
1887
+ ".iox-es-field .iox-field__control[type='radio']": {
1888
+ "border-radius": "50%"
1889
+ },
1890
+ ".iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio'])::before": {
1891
+ "content": "''",
1892
+ "inline-size": "0.28em",
1893
+ "block-size": "0.56em",
1894
+ "border-style": "solid",
1895
+ "border-color": "var(--iox-accent-ink)",
1896
+ "border-width": "0 0.14em 0.14em 0",
1897
+ "transform": "translateY(-0.08em) rotate(45deg) scale(0)",
1898
+ "transition": "transform var(--iox-dur-fast) var(--iox-ease-standard)"
1899
+ },
1900
+ ".iox-es-field .iox-field__control[type='radio']::before": {
1901
+ "inline-size": "0",
1902
+ "block-size": "0",
1903
+ "border-width": "0.25em",
1904
+ "border-radius": "50%",
1905
+ "transform": "scale(0)"
1906
+ },
1907
+ ".iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio']):hover": {
1908
+ "border-color": "var(--iox-accent-text)"
1909
+ },
1910
+ ".iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio']):checked": {
1911
+ "background": "var(--iox-accent)",
1912
+ "border-color": "var(--iox-accent-text)"
1913
+ },
1914
+ ".iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio']):checked:hover": {
1915
+ "background": "var(--iox-accent-hover)"
1916
+ },
1917
+ ".iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio']):checked::before": {
1918
+ "transform": "translateY(-0.08em) rotate(45deg) scale(1)"
1919
+ },
1920
+ ".iox-es-field .iox-field__control[type='radio']:checked::before": {
1921
+ "transform": "scale(1)"
1922
+ },
1923
+ ".iox-es-field .iox-field__control:where([type='checkbox']:not(.iox-field__control--switch), [type='radio']):focus-visible": {
1924
+ "box-shadow": "var(--iox-focus-ring)"
1925
+ },
1926
+ ".iox-es-field .iox-field__control:where([type='range'])": {
1927
+ "appearance": "none",
1928
+ "-webkit-appearance": "none",
1929
+ "inline-size": "100%",
1930
+ "block-size": "1.25em",
1931
+ "cursor": "pointer"
1932
+ },
1933
+ ".iox-es-field .iox-field__control:where([type='range'])::-webkit-slider-runnable-track": {
1934
+ "block-size": "0.25em",
1935
+ "border-radius": "var(--iox-radius-pill)",
1936
+ "background": "var(--iox-line-strong)",
1937
+ "outline": "1px solid transparent"
1938
+ },
1939
+ ".iox-es-field .iox-field__control:where([type='range'])::-moz-range-track": {
1940
+ "block-size": "0.25em",
1941
+ "border-radius": "var(--iox-radius-pill)",
1942
+ "background": "var(--iox-line-strong)",
1943
+ "outline": "1px solid transparent"
1944
+ },
1945
+ ".iox-es-field .iox-field__control:where([type='range'])::-webkit-slider-thumb": {
1946
+ "appearance": "none",
1947
+ "-webkit-appearance": "none",
1948
+ "inline-size": "1.25em",
1949
+ "block-size": "1.25em",
1950
+ "border-radius": "50%",
1951
+ "background": "var(--iox-accent-text)",
1952
+ "border": "2px solid var(--iox-bg)",
1953
+ "box-sizing": "border-box",
1954
+ "cursor": "pointer",
1955
+ "margin-block-start": "-0.5em"
1956
+ },
1957
+ ".iox-es-field .iox-field__control:where([type='range'])::-moz-range-thumb": {
1958
+ "appearance": "none",
1959
+ "-webkit-appearance": "none",
1960
+ "inline-size": "1.25em",
1961
+ "block-size": "1.25em",
1962
+ "border-radius": "50%",
1963
+ "background": "var(--iox-accent-text)",
1964
+ "border": "2px solid var(--iox-bg)",
1965
+ "box-sizing": "border-box",
1966
+ "cursor": "pointer"
1967
+ },
1968
+ ".iox-es-field .iox-field__control:where([type='range']):focus-visible": {
1969
+ "box-shadow": "none"
1970
+ },
1971
+ ".iox-es-field .iox-field__control:where([type='range']):focus-visible::-webkit-slider-thumb": {
1972
+ "box-shadow": "var(--iox-focus-ring)"
1973
+ },
1974
+ ".iox-es-field .iox-field__control:where([type='range']):focus-visible::-moz-range-thumb": {
1975
+ "box-shadow": "var(--iox-focus-ring)"
1842
1976
  },
1843
1977
  ".iox-es-field .iox-field__control--switch": {
1844
1978
  "background": "var(--iox-text-muted)",
@@ -2044,9 +2178,40 @@ export const SPEC_CONTRAST_AUDIT = {
2044
2178
  "min": 3
2045
2179
  },
2046
2180
  {
2047
- "part": "checkbox · radio · range",
2181
+ "part": "checkbox · radio edge",
2182
+ "fg": "var(--iox-line-strong)",
2183
+ "min": 3
2184
+ },
2185
+ {
2186
+ "part": "checkbox · radio edge on its fill",
2187
+ "fg": "var(--iox-line-strong)",
2188
+ "on": "var(--iox-surface)",
2189
+ "min": 3
2190
+ },
2191
+ {
2192
+ "part": "checkbox · radio checked",
2193
+ "fg": [
2194
+ "var(--iox-accent-text)",
2195
+ "var(--iox-accent)"
2196
+ ],
2197
+ "any": true,
2198
+ "min": 3
2199
+ },
2200
+ {
2201
+ "part": "check mark · radio dot",
2202
+ "fg": "var(--iox-accent-ink)",
2203
+ "on": "var(--iox-accent)",
2204
+ "min": 3
2205
+ },
2206
+ {
2207
+ "part": "hover edge · range thumb",
2048
2208
  "fg": "var(--iox-accent-text)",
2049
2209
  "min": 3
2210
+ },
2211
+ {
2212
+ "part": "range track",
2213
+ "fg": "var(--iox-line-strong)",
2214
+ "min": 3
2050
2215
  }
2051
2216
  ]
2052
2217
  } as const;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Where a node links (W1.1). Link and LinkedContainer always had two link types — an external URL or a
3
+ * website PAGE by its route — and ButtonBlock now has the same pair, so a composed "Get in touch" button
4
+ * is a page link the builder shows as `/contact`, not a hand-built URL. Framework-free: the composer
5
+ * (generation-core) writes it, the builder canvas and both engine render paths read it.
6
+ */
7
+
8
+ export enum LinkType {
9
+ External = 'external',
10
+ Page = 'page',
11
+ }
12
+
13
+ export interface ButtonBlockLinkProps {
14
+ tag?: unknown;
15
+ linkType?: unknown;
16
+ href?: unknown;
17
+ pageRoute?: unknown;
18
+ }
19
+
20
+ const ANCHOR_TAG = 'a';
21
+
22
+ /**
23
+ * The href a ButtonBlock renders, or `null` for none (it is a `<button>`, or a link with no target).
24
+ * Only an `<a>` links. A page link's route goes through `pageHref` — the engine's `resolvePageHref`
25
+ * (`:params` substituted, base-relative); the builder canvas passes none and shows the route as authored,
26
+ * as its Link does.
27
+ */
28
+ export function buttonBlockHref(props: ButtonBlockLinkProps, pageHref: (route: string) => string = route => route): string | null {
29
+ if (props.tag !== ANCHOR_TAG) return null;
30
+ const href = props.linkType === LinkType.Page ? pageHref(String(props.pageRoute ?? '')) : String(props.href ?? '');
31
+ return href || null;
32
+ }
@@ -17,6 +17,7 @@ export * from './element-styles';
17
17
  export * from './adopt';
18
18
  export * from './node-classes';
19
19
  export * from './empty-image';
20
+ export * from './node-links';
20
21
  export * from './brand';
21
22
  export * from './colour-eval';
22
23
  export * from './brand-neutrals';
@@ -15,6 +15,7 @@ import {
15
15
  SubmitAction,
16
16
  ValidationRule,
17
17
  } from './form.model';
18
+ import { fieldOptionsOf } from './options';
18
19
 
19
20
  /** The minimum a node must look like. Structural on purpose: this file knows no renderer. */
20
21
  export interface FormNodeLike {
@@ -117,7 +118,8 @@ function toFieldSpec(node: FormNodeLike, taken: ReadonlySet<string>): FieldSpec
117
118
  if (props['required']) spec.required = true;
118
119
  const rules = normaliseRules(props['rules']);
119
120
  if (rules.length) spec.rules = rules;
120
- if (Array.isArray(props['options']) && props['options'].length) spec.options = props['options'];
121
+ const options = fieldOptionsOf(props);
122
+ if (options.length) spec.options = options;
121
123
  if (props['optionsSource']) spec.optionsSource = props['optionsSource'];
122
124
  if (props['defaultValue'] !== undefined && props['defaultValue'] !== '') {
123
125
  spec.defaultValue = props['defaultValue'];
@@ -0,0 +1,105 @@
1
+ /**
2
+ * FORM PRESETS — a complete, working form as one insert: its fields, its submit label and the words of
3
+ * its Success and Error blocks. ONE definition (W1.1): the builder's Forms tray builds a preset from
4
+ * here (`buildFormPreset`, iox-builder), and the site composer (`generation-core`) stamps the SAME preset
5
+ * into a composed page, so a generated contact form is the real one — never a second, drifting copy.
6
+ * Data only: framework-free, like the rest of forms-core.
7
+ */
8
+
9
+ export enum FormPresetId {
10
+ Contact = 'contact',
11
+ Newsletter = 'newsletter',
12
+ Application = 'application',
13
+ Booking = 'booking',
14
+ }
15
+
16
+ export interface FormPresetField {
17
+ label: string;
18
+ control: string;
19
+ required?: boolean;
20
+ help?: string;
21
+ placeholder?: string;
22
+ /** Choices for select/radio, `value|Label` per line — the Field's `optionsText` trait. */
23
+ optionsText?: string;
24
+ rows?: number;
25
+ }
26
+
27
+ export interface FormPresetDef {
28
+ id: string;
29
+ name: string;
30
+ icon: string;
31
+ /** One line in the panel: what it collects and where it goes. */
32
+ description: string;
33
+ /** The form's author-facing name, which the submissions inbox groups by. */
34
+ formName: string;
35
+ fields: FormPresetField[];
36
+ submitLabel: string;
37
+ successText: string;
38
+ errorText: string;
39
+ }
40
+
41
+ export const FORM_PRESETS: readonly FormPresetDef[] = Object.freeze([
42
+ {
43
+ id: FormPresetId.Contact, name: 'Contact', icon: 'ph-thin ph-envelope-simple',
44
+ description: 'Name, email and a message. Stored, and emailed to you.',
45
+ formName: 'Contact form',
46
+ fields: [
47
+ { label: 'Full name', control: 'text', required: true },
48
+ { label: 'Email', control: 'email', required: true },
49
+ { label: 'Message', control: 'textarea', required: true, rows: 5 },
50
+ ],
51
+ submitLabel: 'Send message',
52
+ successText: 'Thanks — we’ll be in touch shortly.',
53
+ errorText: 'We couldn’t send your message. Please try again — your answers are still here.',
54
+ },
55
+ {
56
+ id: FormPresetId.Newsletter, name: 'Newsletter', icon: 'ph-thin ph-paper-plane-tilt',
57
+ description: 'One email field plus consent. Stored.',
58
+ formName: 'Newsletter signup',
59
+ fields: [
60
+ { label: 'Email', control: 'email', required: true, placeholder: 'you@company.com' },
61
+ // Consent is a checkbox that must be TICKED — `required` on a checkbox becomes the
62
+ // `accepted` rule, with its own message, rather than a generic "this is required".
63
+ { label: 'Email me occasional updates', control: 'checkbox', required: true },
64
+ ],
65
+ submitLabel: 'Subscribe',
66
+ successText: 'You’re on the list.',
67
+ errorText: 'We couldn’t sign you up. Please try again.',
68
+ },
69
+ {
70
+ id: FormPresetId.Application, name: 'Application', icon: 'ph-thin ph-read-cv-logo',
71
+ description: 'Details plus a CV upload. Stored, with an auto-reply.',
72
+ formName: 'Application',
73
+ fields: [
74
+ { label: 'Full name', control: 'text', required: true },
75
+ { label: 'Email', control: 'email', required: true },
76
+ { label: 'Phone', control: 'tel' },
77
+ { label: 'Role', control: 'select', required: true, optionsText: 'Design\nEngineering\nOther' },
78
+ { label: 'CV', control: 'file', required: true, help: 'PDF or Word, up to 5MB' },
79
+ { label: 'Anything else', control: 'textarea', rows: 4 },
80
+ ],
81
+ submitLabel: 'Apply',
82
+ successText: 'Application received — we’ll read it properly and reply.',
83
+ errorText: 'We couldn’t submit your application. Please try again.',
84
+ },
85
+ {
86
+ id: FormPresetId.Booking, name: 'Booking request', icon: 'ph-thin ph-calendar-check',
87
+ description: 'Date and party size. Stored, and it notifies you.',
88
+ formName: 'Booking request',
89
+ fields: [
90
+ { label: 'Name', control: 'text', required: true },
91
+ { label: 'Email', control: 'email', required: true },
92
+ { label: 'Date', control: 'date', required: true },
93
+ { label: 'People', control: 'number', required: true },
94
+ { label: 'Notes', control: 'textarea', rows: 3, help: 'Allergies, access needs, anything useful' },
95
+ ],
96
+ submitLabel: 'Request a booking',
97
+ successText: 'Request received — we’ll confirm by email.',
98
+ errorText: 'We couldn’t send your request. Please try again.',
99
+ },
100
+ ]);
101
+
102
+ /** The preset with this id, or undefined. */
103
+ export function formPresetById(id: string | null | undefined): FormPresetDef | undefined {
104
+ return FORM_PRESETS.find(p => p.id === id);
105
+ }
@@ -0,0 +1,29 @@
1
+ import { FieldOption } from './form.model';
2
+
3
+ /** `value|Label` separates a submitted value from what the visitor reads; a bare line is both. */
4
+ const OPTION_SEPARATOR = '|';
5
+
6
+ /**
7
+ * A Field's `optionsText` trait — the author's choices, one per line — as options. ONE parser for the
8
+ * builder canvas, both engine paths and iox-cms: until 2026-10-19 nothing parsed the trait, so a Radio
9
+ * group or a Dropdown from the tray rendered no choices at all on the canvas and on the live site.
10
+ */
11
+ export function parseFieldOptions(text: unknown): FieldOption[] {
12
+ if (typeof text !== 'string') return [];
13
+ const out: FieldOption[] = [];
14
+ for (const line of text.split(/\r?\n/)) {
15
+ const raw = line.trim();
16
+ if (!raw) continue;
17
+ const at = raw.indexOf(OPTION_SEPARATOR);
18
+ const value = (at === -1 ? raw : raw.slice(0, at)).trim();
19
+ const label = (at === -1 ? raw : raw.slice(at + 1)).trim() || value;
20
+ if (value) out.push({ value, label });
21
+ }
22
+ return out;
23
+ }
24
+
25
+ /** The choices a saved Field offers: an explicit `options` list wins, else its `optionsText` parsed. */
26
+ export function fieldOptionsOf(props: { options?: unknown; optionsText?: unknown } | null | undefined): FieldOption[] {
27
+ if (Array.isArray(props?.options) && props!.options.length) return props!.options as FieldOption[];
28
+ return parseFieldOptions(props?.optionsText);
29
+ }
@@ -14,6 +14,8 @@ export * from './form.model';
14
14
  export * from './messages';
15
15
  export * from './validate';
16
16
  export * from './collect';
17
+ export * from './options';
17
18
  export * from './runtime';
18
19
  export * from './rule-catalog';
19
20
  export * from './transport';
21
+ export * from './form-presets';
@@ -49,10 +49,14 @@ export type FormTransport = (request: SubmitRequest) => Promise<SubmitResponse>;
49
49
  * The public submit path. Built here rather than interpolated at each call site, so the engine and
50
50
  * any future host cannot disagree about it — and so changing it is one edit.
51
51
  *
52
- * Deliberately relative and WITHOUT a leading slash: the engine mounts under a per-org base href.
52
+ * ⚠️ ROOT-relative (leading slash), never base-relative. A live site is served under a per-org
53
+ * `<base href="/<label>/">`, so a relative `api/…` resolved to `/<label>/api/…` — a path the reverse
54
+ * proxy (`/api` → iox-cms, everything else → the engine) hands to the ENGINE, which answers a POST with
55
+ * 404: no visitor submission ever reached iox-cms from an org path (found 2026-10-19 by
56
+ * e2e:forminsert:real). The org is already in the path segment after `site/`.
53
57
  */
54
58
  export function submitEndpointPath(orgId: string, formId: string): string {
55
- return `api/site/${encodeURIComponent(orgId)}/forms/${encodeURIComponent(formId)}/submit`;
59
+ return `/api/site/${encodeURIComponent(orgId)}/forms/${encodeURIComponent(formId)}/submit`;
56
60
  }
57
61
 
58
62
  /**
@@ -0,0 +1,75 @@
1
+ import { NEUTRAL_STYLE_PROPS, NODE_BASELINES } from './node-baselines.generated';
2
+
3
+ /**
4
+ * Why each type's constructor sets the style values it sets (W1.1, Q1). Every value a type's config
5
+ * gives that differs from what the trait saves when nobody sets it (`NEUTRAL_STYLE_PROPS`) is exactly
6
+ * one of:
7
+ *
8
+ * - **Layout** — a size or spacing that exists only so an EMPTY node dropped from the palette is visible
9
+ * and grabbable (a Container's 200px, a Text's 10px padding). A composed node is never empty and its
10
+ * pattern owns its box, so the composer RELEASES these to the neutral value unless the pattern
11
+ * declares one: the nav bar was 200px tall and every lead line indented 10px (W1's screenshot).
12
+ * - **Behaviour** — what makes the type work: a button's inline-flex centring, a form's column, the 44px
13
+ * touch target, a slide's width, a spacer's height, a list's marker room. Kept.
14
+ * - **Look** — the type's pre-design-system paint, which its element style replaces. Kept (the style
15
+ * writes over it; a type with no style keeps its pre-DS2 look, D5).
16
+ *
17
+ * Saved pages are untouched: this is the composer's rule, never the builder's (a palette drop still
18
+ * gets its 200px). `baseline-roles.spec.ts` fails the moment a config gains a default this table does
19
+ * not classify — so a new default is a decision, not an accident.
20
+ */
21
+ export enum BaselineRole {
22
+ Layout = 'layout',
23
+ Behaviour = 'behaviour',
24
+ Look = 'look',
25
+ }
26
+
27
+ const { Layout, Behaviour, Look } = BaselineRole;
28
+
29
+ export const BASELINE_ROLES: Readonly<Record<string, Readonly<Record<string, BaselineRole>>>> = {
30
+ Button: {},
31
+ ButtonBlock: {
32
+ display: Behaviour, justifyContent: Behaviour, alignItems: Behaviour, gap: Behaviour, cursor: Behaviour,
33
+ padding: Look, borderRadius: Look,
34
+ },
35
+ Card: { width: Behaviour, maxWidth: Layout, padding: Look, borderWidth: Look, borderColor: Look, borderRadius: Look, backgroundColor: Look },
36
+ // `width: 100%` (DEFAULT_WIDTH_BY_TYPE) is what lets a block fill its row — a flex child collapses without it.
37
+ Container: { width: Behaviour, minHeight: Layout },
38
+ Heading: { fontSize: Look, fontWeight: Look },
39
+ Text: { width: Behaviour, minHeight: Layout, padding: Layout },
40
+ Image: { width: Behaviour, maxWidth: Layout, aspectRatio: Layout },
41
+ Link: {},
42
+ LinkedContainer: { minHeight: Layout },
43
+ Section: { minHeight: Layout },
44
+ Icon: { lineHeight: Behaviour, fontSize: Behaviour, color: Look },
45
+ Divider: { width: Behaviour, height: Look, margin: Layout, backgroundColor: Look },
46
+ Spacer: { width: Behaviour, height: Behaviour },
47
+ List: { width: Behaviour, padding: Behaviour },
48
+ ListItem: { width: Behaviour },
49
+ Repeater: { width: Behaviour },
50
+ SliderContainer: { width: Behaviour },
51
+ SliderSlide: { width: Behaviour, aspectRatio: Behaviour },
52
+ // The crossfade stacks its frames absolutely: without a box it has no height at all.
53
+ Gallery: { minHeight: Behaviour, backgroundColor: Look },
54
+ Form: { display: Behaviour, flexDirection: Behaviour, gap: Behaviour, width: Behaviour },
55
+ Field: { width: Behaviour },
56
+ FieldGroup: { display: Behaviour, gap: Behaviour, width: Behaviour },
57
+ SubmitButton: {
58
+ display: Behaviour, justifyContent: Behaviour, alignItems: Behaviour, minHeight: Behaviour, cursor: Behaviour,
59
+ color: Look, padding: Look, borderRadius: Look, backgroundColor: Look,
60
+ },
61
+ FormSuccess: { width: Behaviour, color: Look, padding: Look, borderRadius: Look, backgroundColor: Look },
62
+ FormError: { width: Behaviour, color: Look, padding: Look, borderRadius: Look, backgroundColor: Look },
63
+ Dialog: { width: Behaviour, padding: Look, borderRadius: Look, backgroundColor: Look, boxShadow: Look },
64
+ };
65
+
66
+ /** The style values a type's constructor sets for an empty palette drop, each mapped to the value the
67
+ * trait saves when nobody sets it — what a composed node of that type starts from. */
68
+ export function releasedLayoutDefaults(type: string): Record<string, unknown> {
69
+ const roles = BASELINE_ROLES[type] ?? {};
70
+ const out: Record<string, unknown> = {};
71
+ for (const [trait, role] of Object.entries(roles)) {
72
+ if (role === BaselineRole.Layout && trait in (NODE_BASELINES[type]?.styleProps ?? {})) out[trait] = NEUTRAL_STYLE_PROPS[trait];
73
+ }
74
+ return out;
75
+ }