@port60/template-kit 1.2.0 → 1.3.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 (52) hide show
  1. package/README.md +46 -1
  2. package/package.json +1 -1
  3. package/src/commands/create.mjs +11 -0
  4. package/src/lib/agentsMd.mjs +176 -0
  5. package/src/vendor/contract/v2/dialect.json +2 -1
  6. package/src/vendor/contract/v2/islands.json +3 -2
  7. package/src/vendor/contract/v2/manifest.schema.json +73 -2
  8. package/src/vendor/contract/v2/presentation.json +107 -0
  9. package/src/vendor/contract/v2/sections.json +120 -4
  10. package/src/vendor/contract/v2.lock.json +562 -9
  11. package/src/vendor/engine/budgets.mjs +0 -6
  12. package/src/vendor/engine/colour-roles.mjs +68 -0
  13. package/src/vendor/engine/content-footprint.mjs +0 -11
  14. package/src/vendor/engine/design-settings.mjs +34 -0
  15. package/src/vendor/engine/dialect.mjs +3 -10
  16. package/src/vendor/engine/locale.mjs +0 -5
  17. package/src/vendor/engine/majors.mjs +0 -5
  18. package/src/vendor/engine/presentation-capabilities.mjs +0 -2
  19. package/src/vendor/engine/section-fields.mjs +64 -0
  20. package/src/vendor/engine/section-heading-alignment.mjs +0 -2
  21. package/src/vendor/engine/section-presentation.mjs +127 -0
  22. package/src/vendor/validator/behaviors-runtime.js +1 -1
  23. package/src/vendor/validator/collection-link-visibility.mjs +0 -1
  24. package/src/vendor/validator/colour-treatment.mjs +154 -0
  25. package/src/vendor/validator/fixture-art.mjs +0 -15
  26. package/src/vendor/validator/focus.mjs +0 -5
  27. package/src/vendor/validator/fonts.mjs +0 -6
  28. package/src/vendor/validator/heading-alignment.mjs +0 -3
  29. package/src/vendor/validator/icons.mjs +0 -4
  30. package/src/vendor/validator/image-overlay.mjs +141 -0
  31. package/src/vendor/validator/intro-photo-framing.mjs +101 -0
  32. package/src/vendor/validator/model-reference-v2.mjs +0 -4
  33. package/src/vendor/validator/model-reference.mjs +0 -4
  34. package/src/vendor/validator/navigation-highlights.mjs +0 -3
  35. package/src/vendor/validator/platform-base.css +23 -0
  36. package/src/vendor/validator/presentation-proof.mjs +0 -2
  37. package/src/vendor/validator/preview-v1.mjs +3 -103
  38. package/src/vendor/validator/preview-v2.mjs +12 -105
  39. package/src/vendor/validator/section-fields.mjs +41 -0
  40. package/src/vendor/validator/section-layout.mjs +156 -0
  41. package/src/vendor/validator/section-presentation.mjs +225 -0
  42. package/src/vendor/validator/site-context-v2.mjs +17 -2
  43. package/src/vendor/validator/site-context.mjs +0 -14
  44. package/src/vendor/validator/validate-v1.mjs +4 -98
  45. package/src/vendor/validator/validate-v2.mjs +44 -103
  46. package/src/vendor/validator/validate.mjs +0 -2
  47. package/starter/assets/theme.css +55 -0
  48. package/starter/manifest.json +20 -0
  49. package/starter/sections/cta.liquid +2 -2
  50. package/starter/sections/hero.liquid +5 -3
  51. package/starter/sections/homeHero.liquid +6 -6
  52. package/starter/sections/values.liquid +2 -2
@@ -0,0 +1,156 @@
1
+ import { SECTION_ROOT_TAGS, sectionLayoutInteractiveMedia } from '../engine/section-presentation.mjs';
2
+ import { parsePresentationMarkup, presentationSignature, presentationVisible, presentationText } from './presentation-proof.mjs';
3
+
4
+ const ATTRIBUTE = 'data-p60-section-layout';
5
+ const MEDIA = 'data-p60-layout-has-media';
6
+ const ROLE = 'data-p60-layout-role';
7
+ const ROLES = ['frame', 'media', 'content'];
8
+ const ROOT = '(?:[a-z][\\w-]*)?(?:\\.[\\w-]+)*';
9
+ const TOKEN = '\\[data-p60-section-layout\\s*=\\s*(?:"(image-start|image-end|stacked)"|\'(image-start|image-end|stacked)\'|(image-start|image-end|stacked))\\s*\\]';
10
+ const OWNED = '\\[data-p60-layout-role\\s*=\\s*(?:"(frame|media|content)"|\'(frame|media|content)\'|(frame|media|content))\\s*\\]';
11
+ const SELECTOR = new RegExp(`^${ROOT}${TOKEN}\\[${MEDIA}\\](?:\\s+${OWNED})?$`);
12
+ const cssIdentifier = value => value.replace(/\\([\da-f]{1,6})\s?|\\([^\r\n])/gi, (_, hex, character) =>
13
+ hex ? String.fromCodePoint(Math.min(Number.parseInt(hex, 16), 0x10ffff)) : character);
14
+ const mentionsLayout = value => cssIdentifier(value).toLowerCase().includes(ATTRIBUTE);
15
+
16
+ function hasNestedLayoutRule(css) {
17
+ const scopes = [];
18
+ let start = 0, quote = null;
19
+ for (let index = 0; index < css.length; index++) {
20
+ const character = css[index];
21
+ if (character === '\\') { index++; continue; }
22
+ if (quote) { if (character === quote) quote = null; continue; }
23
+ if (character === '"' || character === "'") { quote = character; continue; }
24
+ if (character === '{') {
25
+ if (scopes.some(Boolean)) return true;
26
+ scopes.push(mentionsLayout(css.slice(start, index)));
27
+ start = index + 1;
28
+ } else if (character === '}') {
29
+ scopes.pop();
30
+ start = index + 1;
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+
36
+ function safeDeclaration(property, value, role) {
37
+ if (['frame', null].includes(role)) {
38
+ if (property === 'display') return value === 'grid';
39
+ if (property === 'grid-template-rows') return /^(?:auto|auto\s+auto)$/.test(value);
40
+ if (property === 'grid-template-columns') {
41
+ const fraction = '(?:[1-9]\\d*(?:\\.\\d+)?|0?\\.\\d*[1-9]\\d*)fr';
42
+ const track = `(?:minmax\\(0,\\s*${fraction}\\)|${fraction})`;
43
+ return new RegExp(`^${track}(?:\\s+${track})?$`).test(value);
44
+ }
45
+ return false;
46
+ }
47
+ return ['grid-column', 'grid-row'].includes(property) && /^(?:auto|1|2|1\s*\/\s*-1)$/.test(value);
48
+ }
49
+
50
+ /** Only the existing grid and its two owned slots may move. No arbitrary CSS targets. */
51
+ export function sectionLayoutRules(css) {
52
+ const rules = [], errors = [];
53
+ const clean = css.replace(/\/\*[\s\S]*?\*\//g, '');
54
+ if (hasNestedLayoutRule(clean)) errors.push('sectionLayout rules must use explicit flat scoped selectors; nested rules and nested at-rules are not supported');
55
+ for (const [, selectors, body] of clean.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
56
+ if (!mentionsLayout(selectors)) continue;
57
+ for (const selector of selectors.split(',')) {
58
+ const match = SELECTOR.exec(selector.trim());
59
+ if (!match) {
60
+ errors.push('sectionLayout CSS must target the exact root token with data-p60-layout-has-media and an optional documented frame, media or content role');
61
+ continue;
62
+ }
63
+ const role = match[4] ?? match[5] ?? match[6] ?? null;
64
+ const declarations = new Map();
65
+ for (const part of body.split(';').map(value => value.trim()).filter(Boolean)) {
66
+ const declaration = /^([a-z-]+)\s*:\s*(.+)$/.exec(part);
67
+ if (!declaration || !safeDeclaration(declaration[1], declaration[2].trim(), role)) {
68
+ errors.push('sectionLayout CSS may change only bounded grid tracks on the frame and grid-column/grid-row on media/content; no sizing, gaps, paint, order, positioning, typography or priority overrides');
69
+ continue;
70
+ }
71
+ declarations.set(declaration[1], declaration[2].trim());
72
+ }
73
+ if (!declarations.size) errors.push('sectionLayout needs effective grid declarations, not only a changing marker');
74
+ rules.push({ token: match[1] ?? match[2] ?? match[3], role, root: selector.trim().split('[')[0], declarations });
75
+ }
76
+ }
77
+ return { rules, errors: [...new Set(errors)] };
78
+ }
79
+
80
+ export function sectionLayoutCssErrors(css, declaration) {
81
+ const { rules, errors } = sectionLayoutRules(css);
82
+ if (declaration?.options?.length && !rules.length) errors.push('sectionLayout needs authored grid rules');
83
+ return errors;
84
+ }
85
+
86
+ function matchesRoot(node, selector) {
87
+ const tag = /^[a-z][\w-]*/i.exec(selector)?.[0];
88
+ const classes = [...selector.matchAll(/\.([\w-]+)/g)].map(match => match[1]);
89
+ return (!tag || node.tag === tag.toLowerCase()) && classes.every(name => (node.attrs.class ?? '').split(/\s+/).includes(name));
90
+ }
91
+
92
+ function layoutErrors(rules, declaration, frameIsRoot) {
93
+ const errors = [], signatures = new Set();
94
+ if (rules.some(rule => rule.token === declaration?.default)) errors.push('sectionLayout default must keep inherited CSS exactly; do not author a token override for the default');
95
+ for (const token of declaration?.options?.filter(value => value !== declaration.default) ?? []) {
96
+ const current = rules.filter(rule => rule.token === token);
97
+ const coverage = role => new Map(current.filter(rule => rule.role === role).flatMap(rule => [...rule.declarations]));
98
+ if (!coverage(frameIsRoot ? null : 'frame').has('grid-template-columns')) errors.push(`sectionLayout '${token}' needs authored grid-template-columns on its actual frame`);
99
+ for (const role of ['media', 'content']) if (!coverage(role).has('grid-column') || !coverage(role).has('grid-row')) {
100
+ errors.push(`sectionLayout '${token}' needs independent grid-column and grid-row placement for its ${role} slot`);
101
+ }
102
+ const signature = JSON.stringify(current.map(rule => [rule.role, [...rule.declarations].sort()]).sort());
103
+ if (signatures.has(signature)) errors.push('sectionLayout options must not repeat identical grid rules');
104
+ signatures.add(signature);
105
+ }
106
+ return errors;
107
+ }
108
+
109
+ /** No layout token may invent, remove, reorder or replace content or platform islands. */
110
+ export function inspectSectionLayout(html, expected, required, css, declaration, { imageUrl } = {}) {
111
+ const parsed = parsePresentationMarkup(html);
112
+ const roots = parsed.root.children.filter(node => typeof node !== 'string');
113
+ const hooks = parsed.nodes.filter(node => Object.hasOwn(node.attrs, ATTRIBUTE));
114
+ const roles = parsed.nodes.filter(node => Object.hasOwn(node.attrs, ROLE));
115
+ const errors = [];
116
+ const empty = !roots.length && !presentationText(parsed.root);
117
+ if (required && empty && imageUrl) errors.push('sectionLayout must render its authored photograph fixture');
118
+ if (required && !empty && hooks.length !== 1) errors.push('one permanent data-p60-section-layout hook is required on the natural outer section root');
119
+ for (const hook of hooks) {
120
+ if (roots.length !== 1 || hook !== roots[0] || !SECTION_ROOT_TAGS.includes(hook.tag) || !presentationVisible(hook)
121
+ || parsed.root.children.some(node => typeof node === 'string' && node.trim())) errors.push('sectionLayout must target the single visible natural outer section root');
122
+ if (hook.attrs[ATTRIBUTE] !== (expected ?? '')) errors.push('sectionLayout must render its exact accepted token or an empty inherited hook');
123
+ }
124
+ if (required && (parsed.nodes.some(node => node.tag === 'astro-island' || Object.hasOwn(node.attrs, 'data-p60-island'))
125
+ || /\u0000P60_ISLAND:/.test(html))) errors.push('sectionLayout does not support sections containing platform islands in this slice');
126
+ if (required && !empty && hooks.length === 1) {
127
+ const hook = hooks[0];
128
+ const frame = roles.filter(node => node.attrs[ROLE] === 'frame');
129
+ const media = roles.filter(node => node.attrs[ROLE] === 'media');
130
+ const content = roles.filter(node => node.attrs[ROLE] === 'content');
131
+ if (roles.some(node => !ROLES.includes(node.attrs[ROLE]) || !presentationVisible(node))) errors.push('sectionLayout roles must be visible authored frame, media or content slots');
132
+ if (Object.hasOwn(hook.attrs, MEDIA) !== (media.length > 0)) errors.push('data-p60-layout-has-media must be present exactly when the authored media slot exists');
133
+ if (media.length || imageUrl) {
134
+ if (frame.length !== 1 || media.length !== 1 || content.length !== 1 || media[0].parent !== frame[0] || content[0].parent !== frame[0]
135
+ || frame[0].children.some(node => typeof node === 'string' ? node.trim() : ![media[0], content[0]].includes(node))) {
136
+ errors.push('sectionLayout requires one existing frame with only direct sibling media and content slots');
137
+ }
138
+ const images = parsed.nodes.filter(node => node.tag === 'img' && node.attrs.src && presentationVisible(node));
139
+ const inMedia = node => {
140
+ for (let parent = node; parent; parent = parent.parent) if (parent === media[0]) return true;
141
+ return false;
142
+ };
143
+ if (media[0] && (presentationText(media[0]) || parsed.nodes.some(node => inMedia(node)
144
+ && sectionLayoutInteractiveMedia(node.tag, node.attrs)))) {
145
+ errors.push('sectionLayout media must contain only the photograph and decorative treatment, never copy, actions or interactive content');
146
+ }
147
+ if (!images.some(node => {
148
+ return inMedia(node) && (!imageUrl || node.attrs.src === imageUrl);
149
+ })) errors.push('sectionLayout media must contain the actual authored photograph, never a placeholder');
150
+ const rules = sectionLayoutRules(css).rules.filter(rule => matchesRoot(hook, rule.root));
151
+ errors.push(...layoutErrors(rules, declaration, frame[0] === hook));
152
+ } else if (frame.length > 1 || content.length > 1) errors.push('sectionLayout may not duplicate frame or content slots without media');
153
+ if (imageUrl === '' && media.length) errors.push('sectionLayout must omit the media slot when no photograph is supplied');
154
+ }
155
+ return { errors: [...new Set(errors)], signature: presentationSignature(parsed.root, () => [ATTRIBUTE]) };
156
+ }
@@ -0,0 +1,225 @@
1
+ import { SECTION_PRESENTATION_CONTROLS, SECTION_ROOT_TAGS } from '../engine/section-presentation.mjs';
2
+ import { headingAlignmentFixture } from './heading-alignment.mjs';
3
+ import { parsePresentationMarkup, presentationText, presentationVisible, presentationSignature } from './presentation-proof.mjs';
4
+ import { proveImageOverlay } from './image-overlay.mjs';
5
+ import { colourTreatmentCssErrors, inspectColourTreatment } from './colour-treatment.mjs';
6
+ import { sectionLayoutCssErrors, inspectSectionLayout } from './section-layout.mjs';
7
+
8
+ const ATTRIBUTE = 'data-p60-heading-scale';
9
+ const normalise = value => String(value ?? '').replace(/\s+/g, ' ').trim();
10
+
11
+ /** Structural proof: scale belongs on the real heading and may not change any other markup. */
12
+ export function inspectHeadingScale(html, expected, heading, required = true) {
13
+ const parsed = parsePresentationMarkup(html);
14
+ const hooks = parsed.nodes.filter(node => Object.hasOwn(node.attrs, ATTRIBUTE));
15
+ const errors = [];
16
+ if (required && !hooks.length) errors.push('a permanent data-p60-heading-scale hook is required on the actual section heading');
17
+ for (const node of hooks) {
18
+ if (!/^h[1-6]$/.test(node.tag) || !presentationVisible(node) || !normalise(heading)
19
+ || presentationText(node) !== normalise(heading)) errors.push('headingScale must target the visible section heading, not an eyebrow, group, card, body or island');
20
+ if (node.attrs[ATTRIBUTE] !== (expected ?? '')) errors.push(`headingScale '${expected ?? 'default'}' must render its exact token, or an empty inherited hook`);
21
+ for (let parent = node.parent; parent; parent = parent.parent) {
22
+ if (parent.tag === 'astro-island') errors.push('headingScale cannot target a platform island');
23
+ }
24
+ }
25
+ return { errors: [...new Set(errors)], signature: presentationSignature(parsed.root, () => [ATTRIBUTE]) };
26
+ }
27
+
28
+ /** This is a CSS declaration gate, not a substitute for computed browser-size conformance. */
29
+ export function headingScaleCssErrors(css, options) {
30
+ const clean = css.replace(/\/\*[\s\S]*?\*\//g, '');
31
+ const rules = [...clean.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
32
+ return options.flatMap(option => {
33
+ const selector = new RegExp(`\\[data-p60-heading-scale\\s*=\\s*(?:"${option}"|'${option}'|${option})\\s*\\]`);
34
+ return rules.some(([, selectors, body]) => selector.test(selectors)
35
+ && /(?:^|;)\s*font-size\s*:\s*[^;\s][^;]*(?:;|$)/i.test(body)) ? []
36
+ : [`headingScale '${option}' needs an authored font-size rule for its data-p60-heading-scale token, not only a changing marker`];
37
+ });
38
+ }
39
+
40
+ /** Spacing belongs to the existing single outer root, regardless of heading visibility. */
41
+ export function inspectSectionSpacing(html, expected, _heading, required = true) {
42
+ const parsed = parsePresentationMarkup(html);
43
+ const attribute = SECTION_PRESENTATION_CONTROLS.sectionSpacing.attribute;
44
+ const hooks = parsed.nodes.filter(node => Object.hasOwn(node.attrs, attribute));
45
+ const roots = parsed.root.children.filter(child => typeof child !== 'string');
46
+ const errors = [];
47
+ if (required && hooks.length !== 1) errors.push('one permanent data-p60-section-spacing hook is required on the natural outer section root');
48
+ for (const node of hooks) {
49
+ if (roots.length !== 1 || node !== roots[0] || !SECTION_ROOT_TAGS.includes(node.tag)
50
+ || !presentationVisible(node) || parsed.root.children.some(child => typeof child === 'string' && child.trim())) {
51
+ errors.push('sectionSpacing must target the single visible natural outer section root, not an inner wrapper, heading, card or island');
52
+ }
53
+ if (node.attrs[attribute] !== (expected ?? '')) errors.push(`sectionSpacing '${expected ?? 'default'}' must render its exact token, or an empty inherited hook`);
54
+ }
55
+ return { errors: [...new Set(errors)], signature: presentationSignature(parsed.root, () => [attribute]) };
56
+ }
57
+
58
+ /** Every token rule targets the root itself and changes only block-axis padding. */
59
+ export function sectionSpacingCssErrors(css, options) {
60
+ const clean = css.replace(/\/\*[\s\S]*?\*\//g, '');
61
+ const rules = [...clean.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
62
+ const attribute = SECTION_PRESENTATION_CONTROLS.sectionSpacing.attribute;
63
+ const errors = [];
64
+ for (const [, selectors, body] of rules.filter(([, selectors]) => selectors.includes(attribute))) {
65
+ const declarations = body.split(';').map(part => part.trim()).filter(Boolean);
66
+ if (declarations.some(part => !/^padding-(?:block(?:-start|-end)?|top|bottom)\s*:\s*\S/i.test(part))) {
67
+ errors.push('sectionSpacing CSS may change only block-axis padding, never horizontal gutters, gaps, height, typography or island internals');
68
+ }
69
+ for (const selector of selectors.split(',')) {
70
+ if (!new RegExp(`\\[${attribute}\\s*=\\s*(?:"[^"]*"|'[^']*'|[^\\]\\s]+)\\s*\\]\\s*$`).test(selector)) {
71
+ errors.push('sectionSpacing CSS must target the root hook itself, not descendants, siblings, pseudo-elements or unrelated targets');
72
+ }
73
+ }
74
+ }
75
+ for (const option of options) {
76
+ const selector = new RegExp(`\\[${attribute}\\s*=\\s*(?:"${option}"|'${option}'|${option})\\s*\\]`);
77
+ if (!rules.some(([, selectors, body]) => selector.test(selectors)
78
+ && /(?:^|;)\s*padding-(?:block(?:-start|-end)?|top|bottom)\s*:\s*[^;\s][^;]*(?:;|$)/i.test(body))) {
79
+ errors.push(`sectionSpacing '${option}' needs an authored block-axis padding rule for its data-p60-section-spacing token, not only a changing marker`);
80
+ }
81
+ }
82
+ return [...new Set(errors)];
83
+ }
84
+
85
+ const proofs = {
86
+ sectionLayout: {
87
+ inspect: (html, expected, _heading, required, css, declaration, content) => inspectSectionLayout(html, expected, required, css, declaration, { imageUrl: content.imageUrl }),
88
+ css: (css, _values, declaration) => sectionLayoutCssErrors(css, declaration), boundary: 'layout root'
89
+ },
90
+ headingScale: { inspect: inspectHeadingScale, css: headingScaleCssErrors, boundary: 'heading' },
91
+ sectionSpacing: { inspect: inspectSectionSpacing, css: sectionSpacingCssErrors, boundary: 'root' },
92
+ colourTreatment: {
93
+ inspect: (html, expected, _heading, required, css, declaration) => inspectColourTreatment(html, expected, required, css, declaration),
94
+ css: (css, _values, declaration) => colourTreatmentCssErrors(css, declaration), boundary: 'colour root'
95
+ }
96
+ };
97
+
98
+ /** Pairwise inheritance plus every all-active interaction for the current bounded registry.
99
+ * At most four controls may be combined. New controls must deliberately revisit this budget. */
100
+ export function presentationCombinations(controls, declaration) {
101
+ if (controls.length > 4) throw new Error('Presentation proof supports at most four independent controls');
102
+ const cases = new Map();
103
+ const add = value => cases.set(JSON.stringify(value), value);
104
+ add({});
105
+ for (let first = 0; first < controls.length; first++) {
106
+ const key = controls[first][0];
107
+ for (const value of declaration[key].options) add({ [key]: value });
108
+ for (let second = first + 1; second < controls.length; second++) {
109
+ const other = controls[second][0];
110
+ for (const value of declaration[key].options) for (const next of declaration[other].options) add({ [key]: value, [other]: next });
111
+ }
112
+ }
113
+ // Preserve all three-way cases currently possible and all-active four-way combinations.
114
+ // Partial fourth-control interactions stay pairwise, rather than growing a full power set.
115
+ for (const value of controls.reduce((rows, [key]) => rows.flatMap(row => declaration[key].options.map(option => ({ ...row, [key]: option }))), [{}])) add(value);
116
+ return [...cases.values()];
117
+ }
118
+
119
+ /** Registered choices remain bounded: include partial inheritance and reset across controls. */
120
+ async function proveCombinations(render, fixtures, declaration) {
121
+ const controls = Object.entries(SECTION_PRESENTATION_CONTROLS).filter(([control]) => Array.isArray(declaration?.[control]?.options));
122
+ if (controls.length < 2) return [];
123
+ const combinations = presentationCombinations(controls, declaration);
124
+ const attributes = controls.map(([, descriptor]) => descriptor.attribute);
125
+ const errors = [];
126
+ for (const fixture of fixtures) {
127
+ let baseline;
128
+ for (const presentation of [...combinations, {}]) {
129
+ const label = Object.entries(presentation).map(([key, value]) => `${key}=${value}`).join(', ') || 'inherited/reset';
130
+ try {
131
+ const html = await render({ ...fixture.content, ...(Object.keys(presentation).length ? { presentation } : {}) });
132
+ const parsed = parsePresentationMarkup(html);
133
+ for (const [control, descriptor] of controls) {
134
+ // Individual probes prove placement. Here every existing hook must keep its own
135
+ // exact token, including empty-heading and wholly omitted section fixtures.
136
+ for (const node of parsed.nodes.filter(node => Object.hasOwn(node.attrs, descriptor.attribute))) {
137
+ if (node.attrs[descriptor.attribute] !== (presentation[control] ?? '')) {
138
+ errors.push(`combined ${fixture.name} ${label}: ${control} must retain its independent exact token`);
139
+ }
140
+ }
141
+ }
142
+ // Ignore values only, not hook existence: adding/removing either hook is a mutation.
143
+ for (const node of parsed.nodes) for (const attribute of attributes) {
144
+ if (Object.hasOwn(node.attrs, attribute)) node.attrs[attribute] = '';
145
+ }
146
+ const signature = presentationSignature(parsed.root);
147
+ baseline ??= signature;
148
+ if (signature !== baseline) errors.push(`combined ${fixture.name} ${label}: presentation controls must not change content, heading levels, hook placement or markup together`);
149
+ } catch (error) {
150
+ errors.push(`combined ${fixture.name} ${label}: failed rendering presentation fixture, ${error.message}`);
151
+ return errors;
152
+ }
153
+ }
154
+ }
155
+ return errors;
156
+ }
157
+
158
+ export async function proveSectionPresentation(render, entry, declaration, css, { inheritedHeading, heroImageLimit = 6 } = {}) {
159
+ // A control's required authored inputs must exist in the canonical section contract.
160
+ const layoutFields = SECTION_PRESENTATION_CONTROLS.sectionLayout.contentFields;
161
+ if (declaration?.sectionLayout && Object.entries(layoutFields).some(([name, kind]) => !entry.fields.some(field => field.name === name && field.kind === kind))) {
162
+ return [`section '${entry.type}' sectionLayout requires canonical ${Object.entries(layoutFields).map(([name, kind]) => `${name} (${kind})`).join(' and ')} fields in this section's catalogue entry`];
163
+ }
164
+ const fixture = headingAlignmentFixture(entry);
165
+ delete fixture.content.presentation;
166
+ const fixtures = [{ ...fixture, name: 'authored' }];
167
+ if (typeof inheritedHeading === 'string' && inheritedHeading) {
168
+ const content = { ...fixture.content };
169
+ delete content.title;
170
+ fixtures.push({ content, heading: inheritedHeading, name: 'inherited' });
171
+ }
172
+ const controls = Object.entries(SECTION_PRESENTATION_CONTROLS).filter(([control]) => control !== 'imageOverlay').map(([control, descriptor]) => {
173
+ const choice = declaration?.[control];
174
+ const values = Array.isArray(choice?.options) ? choice.options.filter(value => descriptor.options.includes(value)) : [];
175
+ return { control, descriptor, values, proof: proofs[control] };
176
+ });
177
+ // Collect inexpensive declaration diagnostics before rendering. A failed render may be
178
+ // budget-exhausting input; stop all presentation probes immediately without losing these.
179
+ const errors = controls.flatMap(({ control, values, proof }) => proof.css(css, values, declaration?.[control]).map(error => `${control}: ${error}`));
180
+ const report = () => [...new Set(errors)].map(error => `section '${entry.type}' ${error}`);
181
+ for (const { control, descriptor, values, proof } of controls) {
182
+ const cases = control === 'sectionLayout' && values.length
183
+ ? fixtures.flatMap(fixture => [fixture,
184
+ { ...fixture, content: { ...fixture.content, imageUrl: 'https://example.invalid/p60-layout-photo.jpg', imageAlt: 'Layout fixture' }, name: `${fixture.name} with photograph` },
185
+ { ...fixture, content: { ...fixture.content, title: '', heading: '', imageUrl: 'https://example.invalid/p60-layout-photo.jpg', imageAlt: 'Layout fixture' }, name: `${fixture.name} hidden heading with photograph` },
186
+ { ...fixture, content: { ...fixture.content, imageUrl: '', imageAlt: '' }, name: `${fixture.name} without photograph` }])
187
+ : ['sectionSpacing', 'colourTreatment'].includes(control)
188
+ ? [...fixtures, { ...fixture, content: { ...fixture.content, title: '', heading: '' }, name: 'hidden heading' }]
189
+ : fixtures;
190
+ for (const fixture of cases) {
191
+ let baseline;
192
+ let required = values.length > 0;
193
+ // Repeating inheritance proves resetting restores exactly the authored markup.
194
+ for (const requested of [undefined, ...descriptor.options, 'unknown', undefined]) {
195
+ const content = { ...fixture.content, ...(requested === undefined ? {} : { presentation: { [control]: requested } }) };
196
+ try {
197
+ const html = await render(content);
198
+ // Some authored sections omit their entire root when copy is explicitly cleared.
199
+ // Only that empty inherited baseline may omit the hook; the signature comparison
200
+ // still requires every choice and reset to preserve the same complete omission.
201
+ if (control === 'sectionSpacing' && fixture.name === 'hidden heading' && baseline === undefined
202
+ && parsePresentationMarkup(html).root.children.every(node => typeof node === 'string' && !node.trim())) required = false;
203
+ const result = proof.inspect(html, values.includes(requested) ? requested : undefined,
204
+ fixture.heading, required, css, declaration?.[control], fixture.content);
205
+ errors.push(...result.errors.map(error => `${control} ${fixture.name} ${requested ?? 'default'}: ${error}`));
206
+ baseline ??= result.signature;
207
+ if (baseline !== result.signature) errors.push(`${control} ${fixture.name} ${requested ?? 'default'}: ${control} must not change content, heading levels or markup outside its ${proof.boundary} hook`);
208
+ } catch (error) {
209
+ errors.push(`${control} ${fixture.name} ${requested ?? 'default'}: failed rendering ${control} fixture, ${error.message}`);
210
+ return report();
211
+ }
212
+ }
213
+ }
214
+ }
215
+ if (entry.type === 'homeHero') {
216
+ const overlay = await proveImageOverlay(render, fixture, declaration?.imageOverlay, css, heroImageLimit);
217
+ errors.push(...overlay.errors.map(error => `imageOverlay: ${error}`));
218
+ if (overlay.failed) return report();
219
+ }
220
+ const combinedFixtures = [...fixtures, { ...fixture, content: { ...fixture.content, title: '', heading: '' }, name: 'hidden heading' }];
221
+ if (declaration?.sectionLayout) combinedFixtures.push(...combinedFixtures.map(value => ({ ...value,
222
+ content: { ...value.content, imageUrl: 'https://example.invalid/p60-layout-photo.jpg', imageAlt: 'Layout fixture' }, name: `${value.name} with photograph` })));
223
+ errors.push(...await proveCombinations(render, combinedFixtures, declaration));
224
+ return report();
225
+ }
@@ -5,6 +5,8 @@ import schema from '../contract/v2/site.schema.json' with { type: 'json' };
5
5
  import contentModel from '../contract/v2/content-model.json' with { type: 'json' };
6
6
  import { withHeadingAlignment } from '../engine/section-heading-alignment.mjs';
7
7
  import { resolvedNavigationMode, withCollectionLinkVisibility } from '../engine/presentation-capabilities.mjs';
8
+ import { presentationValueErrors, withSectionPresentation } from '../engine/section-presentation.mjs';
9
+ import { INTRO_PHOTO_FRAMING, safeSectionImageUrl, withSectionFields } from '../engine/section-fields.mjs';
8
10
  export { extractContentFootprint } from '../engine/content-footprint.mjs';
9
11
  export { contentModel };
10
12
 
@@ -27,7 +29,7 @@ export function composePage(manifest, page) {
27
29
 
28
30
  export function buildSiteFixture(manifest, { page = 'home', path } = {}) {
29
31
  const site = structuredClone(context.fixtures.site);
30
- site.locale.languages = []; // One site's language, not a visitor language catalogue.
32
+ site.locale.languages = [];
31
33
  const headerMode = resolvedNavigationMode(manifest, site.nav.headerMode);
32
34
  if (headerMode === undefined) delete site.nav.headerMode;
33
35
  else site.nav.headerMode = headerMode;
@@ -62,6 +64,19 @@ function validatePageSections(value, path, errors) {
62
64
  for (const [name, fieldValue] of Object.entries(section.content)) {
63
65
  const field = fields.get(name);
64
66
  if (!field) { errors.push(`${at}.content.${name}: not an authored ${section.type} field`); continue; }
67
+ if (section.type === 'hero' && name === 'imageUrl' && fieldValue !== '' && fieldValue !== null && !safeSectionImageUrl(fieldValue, { fixture: true })) {
68
+ errors.push(`${at}.content.imageUrl: must be a safe public image URL or a preview fixture reference`);
69
+ continue;
70
+ }
71
+ if (section.type === 'hero' && ['imageUrl', 'imageAlt'].includes(name) && fieldValue === null) continue;
72
+ if (section.type === 'hero' && name === 'photoFraming') {
73
+ if (fieldValue !== null && !INTRO_PHOTO_FRAMING.includes(fieldValue)) errors.push(`${at}.content.photoFraming: must be fill or whole; omit it to inherit`);
74
+ continue;
75
+ }
76
+ if (name === 'presentation') {
77
+ errors.push(...presentationValueErrors(fieldValue, section.type).map(error => `${at}.content.presentation: ${error}`));
78
+ continue;
79
+ }
65
80
  if (name === 'headingAlignment' && !field.options?.includes(fieldValue)) {
66
81
  errors.push(`${at}.content.headingAlignment: must be start, center or end; omit it for the template default`);
67
82
  continue;
@@ -120,7 +135,7 @@ export function pageComposition(manifest, page, json) {
120
135
  /** Keep missing fields missing so a generated label never becomes an authored field marker. */
121
136
  export function resolveSectionFixture(section, _site, manifest) {
122
137
  const content = structuredClone(section.content ?? {});
123
- return manifest === undefined ? content : withCollectionLinkVisibility(withHeadingAlignment(content, section.type, manifest), section.type, manifest);
138
+ return manifest === undefined ? content : withSectionFields(withSectionPresentation(withCollectionLinkVisibility(withHeadingAlignment(content, section.type, manifest), section.type, manifest), section.type, manifest), section.type, manifest, { fixture: true });
124
139
  }
125
140
 
126
141
  export function emptyCollections(site) {
@@ -1,7 +1,3 @@
1
- // The `site` tree for validator and preview renders (content model v1, contract/v1/
2
- // content-model.json): ONE realistic organisation assembled from the canonical fixtures, with
3
- // `site.content.about` composed per-template from the section catalogue's samples for the
4
- // manifest's declared sections, the admin-authored page composition, previewed honestly.
5
1
  import contextContract from '../contract/v1/context.json' with { type: 'json' };
6
2
  import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
7
3
 
@@ -20,8 +16,6 @@ export const PAGE_KEYS = ['home', 'about'];
20
16
  * site's composition is admin-authored, and the preview-content `pages` block plays that role.
21
17
  */
22
18
  export function composePage(manifest, page) {
23
- // The template's own composition when it declares one (stage 4): its order, its choice of
24
- // sections, the `optional` ones left out so the preview shows the designed default.
25
19
  const declared = manifest?.compositions?.[page];
26
20
  if (Array.isArray(declared) && declared.length > 0) {
27
21
  return declared
@@ -55,11 +49,6 @@ export function buildSiteFixture(manifest, { page = null } = {}) {
55
49
  return { ...base, content: { ...base.content, about } };
56
50
  }
57
51
 
58
- // ── preview-content.json (author-editable data) ────────────────────────────────
59
- // Data is free, SHAPE is fixed: a collection override must be an array of items whose fields all
60
- // exist in the model, within the collection's cap. Proofs always run on the canonical fixtures,
61
- // so custom data can never dodge a gate; this file only feeds the dev preview.
62
-
63
52
  const BRAND_FIELDS = new Set(['name', 'tagline', 'logoUrl', 'logoType', 'footerLogoUrl']);
64
53
  const NAV_ITEM_FIELDS = new Set(['label', 'href', 'cta', 'children', 'group', 'description', 'imageUrl', 'megaMenu', 'type']);
65
54
 
@@ -136,8 +125,6 @@ export function validatePreviewContent(json) {
136
125
  return ['preview-content.json: must be an object of { collection: [items] } overrides'];
137
126
  }
138
127
  for (const [name, items] of Object.entries(json)) {
139
- // `nav` is the menu the layout renders (the shape of site.nav), `pages` the section
140
- // compositions of the home and about pages (the admin-authored composition, previewed).
141
128
  if (name === 'nav') {
142
129
  if (items === null || typeof items !== 'object' || Array.isArray(items)) {
143
130
  errors.push('preview-content.json: nav must be an object with an items array');
@@ -163,7 +150,6 @@ export function validatePreviewContent(json) {
163
150
  }
164
151
  continue;
165
152
  }
166
- // `brand` is the one other non-collection override: the organisation's own name and marks.
167
153
  if (name === 'brand') {
168
154
  if (items === null || typeof items !== 'object' || Array.isArray(items)) {
169
155
  errors.push('preview-content.json: brand must be an object');