@port60/template-kit 1.2.1 → 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 (50) hide show
  1. package/README.md +38 -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/manifest.schema.json +73 -2
  7. package/src/vendor/contract/v2/presentation.json +107 -0
  8. package/src/vendor/contract/v2/sections.json +120 -4
  9. package/src/vendor/contract/v2.lock.json +559 -7
  10. package/src/vendor/engine/budgets.mjs +0 -6
  11. package/src/vendor/engine/colour-roles.mjs +68 -0
  12. package/src/vendor/engine/content-footprint.mjs +0 -11
  13. package/src/vendor/engine/design-settings.mjs +34 -0
  14. package/src/vendor/engine/dialect.mjs +3 -10
  15. package/src/vendor/engine/locale.mjs +0 -5
  16. package/src/vendor/engine/majors.mjs +0 -5
  17. package/src/vendor/engine/presentation-capabilities.mjs +0 -2
  18. package/src/vendor/engine/section-fields.mjs +64 -0
  19. package/src/vendor/engine/section-heading-alignment.mjs +0 -2
  20. package/src/vendor/engine/section-presentation.mjs +127 -0
  21. package/src/vendor/validator/behaviors-runtime.js +1 -1
  22. package/src/vendor/validator/collection-link-visibility.mjs +0 -1
  23. package/src/vendor/validator/colour-treatment.mjs +154 -0
  24. package/src/vendor/validator/fixture-art.mjs +0 -15
  25. package/src/vendor/validator/focus.mjs +0 -5
  26. package/src/vendor/validator/fonts.mjs +0 -6
  27. package/src/vendor/validator/heading-alignment.mjs +0 -3
  28. package/src/vendor/validator/icons.mjs +0 -4
  29. package/src/vendor/validator/image-overlay.mjs +141 -0
  30. package/src/vendor/validator/intro-photo-framing.mjs +101 -0
  31. package/src/vendor/validator/model-reference-v2.mjs +0 -4
  32. package/src/vendor/validator/model-reference.mjs +0 -4
  33. package/src/vendor/validator/navigation-highlights.mjs +0 -3
  34. package/src/vendor/validator/presentation-proof.mjs +0 -2
  35. package/src/vendor/validator/preview-v1.mjs +3 -103
  36. package/src/vendor/validator/preview-v2.mjs +11 -105
  37. package/src/vendor/validator/section-fields.mjs +41 -0
  38. package/src/vendor/validator/section-layout.mjs +156 -0
  39. package/src/vendor/validator/section-presentation.mjs +225 -0
  40. package/src/vendor/validator/site-context-v2.mjs +17 -2
  41. package/src/vendor/validator/site-context.mjs +0 -14
  42. package/src/vendor/validator/validate-v1.mjs +4 -98
  43. package/src/vendor/validator/validate-v2.mjs +44 -103
  44. package/src/vendor/validator/validate.mjs +0 -2
  45. package/starter/assets/theme.css +55 -0
  46. package/starter/manifest.json +20 -0
  47. package/starter/sections/cta.liquid +2 -2
  48. package/starter/sections/hero.liquid +5 -3
  49. package/starter/sections/homeHero.liquid +6 -6
  50. package/starter/sections/values.liquid +2 -2
@@ -1,14 +1,3 @@
1
- // The Port60 template CONFORMANCE VALIDATOR as a PURE MODULE (developer program T1.3): the same
2
- // checks the publish-time CLI has always run, callable with an in-memory file map, no fs, no
3
- // argv, no process.exit, so the studio upload lane (an HTTP endpoint) and the CLI share ONE
4
- // implementation, and "validated ⇒ renders in production" keeps holding: the Liquid instance is
5
- // configured identically to the engine's, dialect enforcement is the same shared module, and the
6
- // render budgets here match production's.
7
- //
8
- // validateArtifact(files) → { errors: string[], warnings: string[], manifest: object|null }
9
- //
10
- // `files` is a plain object of artifact-relative path → string content (manifest.json,
11
- // layout.liquid, sections/*.liquid, pages/*.liquid, assets/theme.css).
12
1
  import { Liquid } from 'liquidjs';
13
2
  import Ajv2020 from 'ajv/dist/2020.js';
14
3
  import { CONTENT_SLOT, configureDialect, splitIslandParts } from '../engine/dialect.mjs';
@@ -25,6 +14,10 @@ import behaviourCatalogue from '../contract/v2/behaviours.json' with { type: 'js
25
14
  import { buildSiteFixture, extractContentFootprint, contentModel, emptyCollections, resolveSectionFixture } from './site-context-v2.mjs';
26
15
  import { proveNavigationHighlights } from './navigation-highlights.mjs';
27
16
  import { proveHeadingAlignment } from './heading-alignment.mjs';
17
+ import { proveSectionPresentation } from './section-presentation.mjs';
18
+ import { readSectionPresentation, SECTION_PRESENTATION_CONTROLS } from '../engine/section-presentation.mjs';
19
+ import { readSectionFields, sectionFieldCapabilities } from '../engine/section-fields.mjs';
20
+ import { proveSectionFields } from './section-fields.mjs';
28
21
  import { proveCollectionLinkVisibility } from './collection-link-visibility.mjs';
29
22
  import { proveNavigationModes } from './navigation-modes.mjs';
30
23
  import { resolvedNavigationMode } from '../engine/presentation-capabilities.mjs';
@@ -34,11 +27,6 @@ const Ajv = Ajv2020.default ?? Ajv2020;
34
27
 
35
28
  export { dialect as contractDialect, sectionCatalogue, islandRegistry, contextContract, behaviourCatalogue };
36
29
 
37
- // The behaviour to opt-in-attribute map is DERIVED from the catalogue (FR-5): `primaryAttribute` on
38
- // each entry feeds the checks below, the generated reference and the kit, so adding a behaviour is
39
- // one catalogue entry plus its runtime initialiser. The trailing word boundary keeps the original
40
- // semantics: a companion attribute that starts with the primary one (data-p60-reveal-group,
41
- // data-p60-nav-item) counts as the behaviour in use.
42
30
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
43
31
  export const BEHAVIOUR_PRIMARY_ATTR = Object.fromEntries(
44
32
  behaviourCatalogue.behaviours.map((b) => [b.name, b.primaryAttribute])
@@ -47,8 +35,6 @@ const PRIMARY_ATTR = Object.fromEntries(
47
35
  behaviourCatalogue.behaviours.map((b) => [b.name, [new RegExp(`${escapeRegExp(b.primaryAttribute)}\\b`), b.primaryAttribute]])
48
36
  );
49
37
 
50
- // Inline declarations only: preserve quoted/function values while discarding CSS comments.
51
- // A URL in a custom property, quoted string or comment is not a rendered background image.
52
38
  function heroStyleDeclarations(style = '') {
53
39
  const declarations = [];
54
40
  let value = '', quote = '', depth = 0;
@@ -98,7 +84,6 @@ function heroNodeCanRender(node) {
98
84
  if (/^none$/i.test(heroInlineValue(declarations, ['display']))
99
85
  || /^(?:hidden|collapse)$/i.test(heroInlineValue(declarations, ['visibility']))
100
86
  || /^0(?:\.0*)?%?$/.test(heroInlineValue(declarations, ['opacity']))) return false;
101
- // aria-hidden and inert do not visually hide decorative photographs or carousel slides.
102
87
  }
103
88
  return true;
104
89
  }
@@ -111,10 +96,6 @@ function heroBackgroundShows(node, imageUrl) {
111
96
  return false;
112
97
  }
113
98
 
114
- // Templates are markup and attributes, NEVER code (docs/template-behaviours.md). These are hard
115
- // errors over the RAW liquid source, even inside comments, because there is no legitimate reason
116
- // for the tokens to appear at all. The handler pattern names real DOM event families rather than
117
- // matching any on* word, so attributes like `once` or `online` never false-positive.
118
99
  const FORBIDDEN_MARKUP = [
119
100
  [/<script\b/i, 'a <script> tag'],
120
101
  [/<(iframe|object|embed)\b/i, 'an embedded frame or plugin element'],
@@ -129,7 +110,6 @@ export async function validateArtifact(files) {
129
110
  const has = (path) => Object.hasOwn(files, path);
130
111
  const read = (path) => files[path];
131
112
 
132
- // 1. Manifest against the schema.
133
113
  let manifest = null;
134
114
  if (!has('manifest.json')) {
135
115
  return { errors: ['manifest.json is missing, every artifact starts with its manifest'], warnings, manifest };
@@ -139,14 +119,10 @@ export async function validateArtifact(files) {
139
119
  } catch (e) {
140
120
  return { errors: [`manifest.json unreadable: ${e.message}`], warnings, manifest: null };
141
121
  }
142
- // preview-content.json is a DEV-ONLY data override: package excludes it and the intake
143
- // refuses it, an artifact must never carry data, only shape.
144
122
  if (has('preview-content.json')) {
145
123
  errors.push('preview-content.json: development preview data never ships in an artifact, remove it (package excludes it automatically)');
146
124
  }
147
125
 
148
- // Content model v1: the footprint is decidable from the sources (closed dialect); unknown
149
- // paths, dynamic indexing and tree aliasing are errors from site-context.mjs.
150
126
  const contentAnalysis = extractContentFootprint(files);
151
127
  errors.push(...contentAnalysis.errors);
152
128
  const siteFx = buildSiteFixture(manifest);
@@ -159,10 +135,6 @@ export async function validateArtifact(files) {
159
135
  }
160
136
  }
161
137
 
162
- // Capability declarations are catalogue MATCHING metadata, not an entitlement shortcut. Keep
163
- // them honest by requiring one corresponding template-facing surface. The mapping is deliberately
164
- // structural: it proves the design can present a capability without inspecting tenant data or
165
- // crossing the platform-owned transaction, identity and consent boundaries.
166
138
  {
167
139
  const sections = new Set(manifest?.supports?.sections ?? []);
168
140
  const islands = new Set(manifest?.supports?.islands ?? []);
@@ -191,10 +163,6 @@ export async function validateArtifact(files) {
191
163
  }
192
164
  }
193
165
 
194
- // Site focus honesty (docs/volunteering.md): a template that claims it can lead with
195
- // volunteering must give the volunteer sign-up a way into the hero, either the
196
- // primary_action_widget island (which becomes the sign-up when volunteering leads) or the
197
- // volunteer_signup island placed directly.
198
166
  {
199
167
  const focusKinds = new Set(manifest?.supports?.focus ?? []);
200
168
  if (focusKinds.has('volunteer')) {
@@ -205,9 +173,6 @@ export async function validateArtifact(files) {
205
173
  }
206
174
  }
207
175
 
208
- // Markup and attributes, NEVER code, the machine-enforced JavaScript ban over every liquid
209
- // source (docs/template-behaviours.md). Behaviour is engine-owned; a template wanting motion
210
- // declares supports.behaviors and uses the data-p60-* grammar.
211
176
  for (const [path, source] of Object.entries(files)) {
212
177
  if (!path.endsWith('.liquid')) continue;
213
178
  for (const [pattern, what] of FORBIDDEN_MARKUP) {
@@ -217,9 +182,6 @@ export async function validateArtifact(files) {
217
182
  }
218
183
  }
219
184
 
220
- // Behaviour declaration and usage must agree in BOTH directions. Source-level, deliberately:
221
- // usage often sits inside content-dependent branches the fixtures never take, so the grammar's
222
- // presence in the source is the honest minimal proof.
223
185
  {
224
186
  const declaredBehaviours = new Set(manifest?.supports?.behaviors ?? []);
225
187
  const liquidSource = Object.entries(files)
@@ -266,11 +228,12 @@ export async function validateArtifact(files) {
266
228
  }
267
229
  }
268
230
 
269
- // Font knobs: the default family must be a real catalogue entry (the tenant-unset render uses
270
- // it), and the slot must declare the weights the template's typographic system needs.
271
231
  {
272
232
  const familyNames = new Set(fontCatalogue.families.map((f) => f.name));
273
233
  for (const knob of manifest?.settings?.schema ?? []) {
234
+ if (['header', 'footer'].includes(knob.group) && manifest?.supports?.layout !== true) {
235
+ errors.push(`settings knob '${knob.key}': group '${knob.group}' requires supports.layout`);
236
+ }
274
237
  if (knob.kind !== 'font') continue;
275
238
  if (typeof knob.default !== 'string' || !familyNames.has(knob.default)) {
276
239
  errors.push(`settings knob '${knob.key}': font default '${knob.default}' is not in the font catalogue`);
@@ -280,8 +243,6 @@ export async function validateArtifact(files) {
280
243
  }
281
244
  }
282
245
 
283
- // Looks: every value must target a declared knob and be valid for it, a look that half-applies
284
- // would leave the tenant in a state no author designed.
285
246
  {
286
247
  const knobByKey = new Map((manifest?.settings?.schema ?? []).map((k) => [k.key, k]));
287
248
  const familyNames = new Set(fontCatalogue.families.map((f) => f.name));
@@ -304,8 +265,6 @@ export async function validateArtifact(files) {
304
265
  }
305
266
  }
306
267
 
307
- // Engine identical to production: escape-by-default, strict filters, restricted dialect,
308
- // and the SAME DoS budgets the live renderer runs with.
309
268
  const availableIslands = new Set(
310
269
  islandRegistry.islands.filter((i) => i.status === 'available').map((i) => i.name)
311
270
  );
@@ -314,6 +273,20 @@ export async function validateArtifact(files) {
314
273
  configureDialect(liquid, dialect, allIslands);
315
274
 
316
275
  const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
276
+ if (manifest?.supports?.sectionFields !== undefined && readSectionFields(manifest.supports.sectionFields, manifest.supports.sections) === null) {
277
+ errors.push('manifest: sectionFields requires supported section types and paired imageUrl/imageAlt fields, with optional photoFraming');
278
+ }
279
+ if (manifest?.supports?.sectionPresentation !== undefined
280
+ && readSectionPresentation(manifest.supports.sectionPresentation, manifest.supports.sections) === null) {
281
+ errors.push('manifest: sectionPresentation requires supported section types, known controls, unique allowed options and a default among those options');
282
+ }
283
+ for (const [type, controls] of Object.entries(manifest?.supports?.sectionPresentation ?? {})) {
284
+ for (const key of Object.keys(controls ?? {})) {
285
+ const required = SECTION_PRESENTATION_CONTROLS[key]?.requiresSupport;
286
+ if (required && manifest?.supports?.[required] !== true) errors.push(`manifest: ${type} ${key} requires explicit supports.${required}`);
287
+ if (key === 'sectionLayout' && !sectionFieldCapabilities(manifest)[type]) errors.push(`manifest: ${type} sectionLayout requires explicit supports.sectionFields.${type} photograph fields`);
288
+ }
289
+ }
317
290
  for (const type of Object.keys(manifest?.supports?.sectionHeadingAlignment ?? {})) {
318
291
  if (!(manifest?.supports?.sections ?? []).includes(type)) {
319
292
  errors.push(`manifest: supports.sectionHeadingAlignment '${type}' must also be declared in supports.sections`);
@@ -324,17 +297,10 @@ export async function validateArtifact(files) {
324
297
  }
325
298
  const declaredIslands = new Set(manifest?.supports?.islands ?? []);
326
299
  const placedIslands = new Set();
327
- // heroImagery proof state (filled by the homeHero renders below).
328
- let homeHeroMultiShows = null; // sample (several photos): probe in html OR hero_carousel placed
329
- let homeHeroSingleShows = null; // single-photo variant: probe rendered directly
330
- let homeHeroShowsMultiple = false; // explicit multi-photo capability, distinct rendered images
331
-
332
- // V2 collection sections render the supplied bounded envelopes, without independently
333
- // fetching legacy display islands. The populated fixture's sentinel must appear, and the
334
- // empty context must render it away (derive or omit, nothing invented, nothing dangling).
335
- // Sentinels are DERIVED from the canonical fixtures (the first item's display field), never
336
- // hardcoded, the fixture data is free to become richer without touching a proof, and a proof
337
- // can never drift from the data it renders.
300
+ let homeHeroMultiShows = null;
301
+ let homeHeroSingleShows = null;
302
+ let homeHeroShowsMultiple = false;
303
+
338
304
  const sentinelOf = (type, field) => siteFx.content[type]?.items?.[0]?.[field] ?? null;
339
305
  const WIDGET_SECTIONS = {
340
306
  events: { island: null, dataKey: 'events', sentinel: sentinelOf('events', 'name') },
@@ -345,8 +311,6 @@ export async function validateArtifact(files) {
345
311
  documents: { island: null, dataKey: 'documents', sentinel: sentinelOf('documents', 'title') }
346
312
  };
347
313
 
348
- // 2b. Compositions (site editor stage 4): each page must be a supported page, each type a
349
- // supported section the catalogue assigns to that page, listed once, with a role.
350
314
  const compositions = manifest?.compositions ?? {};
351
315
  for (const [page, entries] of Object.entries(compositions)) {
352
316
  if (!(manifest?.supports?.pages ?? []).includes(page)) {
@@ -369,7 +333,6 @@ export async function validateArtifact(files) {
369
333
  }
370
334
  }
371
335
 
372
- // 2-4. Sections: catalogue membership, parse, fixture renders.
373
336
  for (const type of manifest?.supports?.sections ?? []) {
374
337
  const entry = catalogueByType.get(type);
375
338
  if (!entry) {
@@ -398,6 +361,23 @@ export async function validateArtifact(files) {
398
361
  ...(contextContract.fixtures.sections?.[type] ?? {})
399
362
  }), entry, manifest?.supports?.sectionHeadingAlignment?.[type], { inheritedHeading: siteFx.content[type]?.label }));
400
363
  }
364
+ errors.push(...await proveSectionFields(content => liquid.render(parsed, {
365
+ section: resolveSectionFixture({ type, content }, siteFx, manifest), site: siteFx,
366
+ ...(contextContract.fixtures.sections?.[type] ?? {})
367
+ }), entry, sectionFieldCapabilities(manifest)[type], { fieldMarkers: manifest?.supports?.fieldMarkers === true, css: read('assets/theme.css') }));
368
+ if (Object.hasOwn(manifest?.supports?.sectionPresentation ?? {}, type)
369
+ || Object.values(SECTION_PRESENTATION_CONTROLS).some(control => read(file).includes(control.attribute))) {
370
+ for (const [control, descriptor] of Object.entries(SECTION_PRESENTATION_CONTROLS)) {
371
+ if (read(file).includes(descriptor.attribute) && !Object.hasOwn(manifest?.supports?.sectionPresentation?.[type] ?? {}, control)) {
372
+ warnings.push(`section '${type}': ${descriptor.attribute} is inert without supports.sectionPresentation.${type}.${control}`);
373
+ }
374
+ }
375
+ errors.push(...await proveSectionPresentation(content => liquid.render(parsed, {
376
+ section: resolveSectionFixture({ type, content }, siteFx, manifest), site: siteFx,
377
+ ...(contextContract.fixtures.sections?.[type] ?? {})
378
+ }), entry, manifest?.supports?.sectionPresentation?.[type], read('assets/theme.css') ?? '',
379
+ { inheritedHeading: siteFx.content[type]?.label, heroImageLimit: manifest?.supports?.heroImageLimit ?? 1 }));
380
+ }
401
381
  if ((Array.isArray(manifest?.supports?.sectionCollectionLinkVisibility) && manifest.supports.sectionCollectionLinkVisibility.includes(type)) || /data-p60-collection-link\b/.test(read(file))) {
402
382
  const declared = Array.isArray(manifest?.supports?.sectionCollectionLinkVisibility) && manifest.supports.sectionCollectionLinkVisibility.includes(type);
403
383
  const collection = type === 'appealGrid' ? 'causes' : type;
@@ -407,8 +387,6 @@ export async function validateArtifact(files) {
407
387
  }, entry, declared));
408
388
  if (!declared) warnings.push(`section '${type}': data-p60-collection-link is inert without supports.sectionCollectionLinkVisibility; the editor will not offer visibility`);
409
389
  }
410
- // Widget-section proof (independent of the minimal/sample loop): island placed → the island
411
- // owns everything; hand-rendered → sentinel appears with data, vanishes without.
412
390
  const widget = WIDGET_SECTIONS[type];
413
391
  if (widget) {
414
392
  const dataFixture = contextContract.fixtures.sections?.[type] ?? {};
@@ -449,18 +427,12 @@ export async function validateArtifact(files) {
449
427
  const html = await liquid.render(parsed, {
450
428
  section: resolveSectionFixture({ type, content: fixture }, siteFx, manifest),
451
429
  site: siteFx,
452
- // Widget data rides the ordinary fixture renders too, so a hand-rendering section
453
- // doesn't fail the generic pass for want of its context.
454
430
  ...(widget ? (contextContract.fixtures.sections?.[type] ?? {}) : {})
455
431
  });
456
432
  if (type === 'homeHero' && fixtureName === 'sample') {
457
- // The sample's own first image reference is the probe: `p60fixture:` refs are quote-free
458
- // and survive HTML escaping, so "does the hero display the photos?" stays a substring check.
459
433
  const probe = (fixture.images ?? [])[0]?.imageUrl ?? 'p60fixture:';
460
434
  homeHeroMultiShows = html.includes(probe)
461
435
  || splitIslandParts(html).some((p) => p.island === 'hero_carousel');
462
- // Prove the advertised capacity, not just the two-photo catalogue sample. Real image
463
- // attributes prevent comments, copied text or repeated first images from opting in.
464
436
  const limit = manifest?.supports?.heroImageLimit;
465
437
  if (Number.isInteger(limit) && limit > 1 && limit <= 6) {
466
438
  const images = Array.from({ length: limit }, (_, index) => ({
@@ -477,7 +449,6 @@ export async function validateArtifact(files) {
477
449
  (node.tag === 'img' && node.attrs.src === image.imageUrl)
478
450
  || heroBackgroundShows(node, image.imageUrl)));
479
451
  }
480
- // The single-photo path proven separately: same fixture, first photo only.
481
452
  try {
482
453
  const single = await liquid.render(parsed, {
483
454
  section: { ...fixture, images: (fixture.images ?? []).slice(0, 1) },
@@ -501,11 +472,6 @@ export async function validateArtifact(files) {
501
472
  }
502
473
  }
503
474
 
504
- // 5. FIELD MARKERS: which node shows which field, so the editor can put the caret on the page
505
- // instead of in a side panel. Source-level, like behaviours: a marker often sits inside a
506
- // content-dependent branch the fixtures never take, and its presence in the source is the honest
507
- // proof. A marker is an address inside that section's own content: `title`, or `items.0.label`
508
- // for a list, where the index is usually a Liquid expression and stands for any position.
509
475
  {
510
476
  const MARKER = /data-p60-field="([^"]*)"/g;
511
477
  const fieldsOf = (type) => new Map((catalogueByType.get(type)?.fields ?? []).map((f) => [f.name, f]));
@@ -515,7 +481,6 @@ export async function validateArtifact(files) {
515
481
  const fields = fieldsOf(type);
516
482
  for (const [, raw] of source.matchAll(MARKER)) {
517
483
  marked.push(type);
518
- // An index written as Liquid stands for whichever item this is.
519
484
  const steps = raw.replace(/\{\{[^}]*\}\}/g, '#').split('.');
520
485
  const field = fields.get(steps[0]);
521
486
  const named = steps.length === 1
@@ -543,14 +508,6 @@ export async function validateArtifact(files) {
543
508
  }
544
509
  }
545
510
 
546
- // Hero-imagery honesty, checked BEHAVIOURALLY (the worship pattern). The homeHero sample
547
- // fixture carries photographs whose data URIs embed a quote-free marker that survives HTML
548
- // escaping, so "does the rendered hero display the tenant's photos?" is a substring check,
549
- // and with several photos, placing the hero_carousel island IS displaying them (the island
550
- // renders the slides at runtime). The single-photo path is proven separately: images[0] must
551
- // appear directly. Declaration and behaviour must agree; the choosers badge photo-led tenants
552
- // by supports.heroImagery. Legacy imageUrl-only renderers never match (the fixture's photos
553
- // ride `images`), so they pass undeclared, they just don't earn the badge.
554
511
  {
555
512
  const declaresHero = manifest?.supports?.heroImagery === true;
556
513
  if (declaresHero && manifest?.supports?.heroImageLimit > 1 && !homeHeroShowsMultiple) {
@@ -571,7 +528,6 @@ export async function validateArtifact(files) {
571
528
  }
572
529
  }
573
530
 
574
- // 7. Layout (when declared): parse + render the layout fixture + exactly one content slot.
575
531
  if (manifest?.supports?.worship && !manifest?.supports?.layout) {
576
532
  errors.push('manifest: supports.worship requires supports.layout, the worship rail is layout chrome');
577
533
  }
@@ -618,11 +574,6 @@ export async function validateArtifact(files) {
618
574
  errors.push(...highlights.errors);
619
575
  warnings.push(...highlights.warnings);
620
576
 
621
- // Two-sided worship honesty, checked BEHAVIOURALLY: does the rendered layout actually
622
- // display the worship fixture's times? Declaration and behaviour must agree, the
623
- // choosers steer worship-enabled tenants by supports.worship, so a false declaration
624
- // either hides their times (undeclared but rendered is fine to fix by declaring) or
625
- // promises a rail that never appears.
626
577
  const worshipProbe = contextContract.fixtures.layout.worship?.times?.[0]?.name;
627
578
  if (worshipProbe) {
628
579
  const rendersWorship = html.includes(worshipProbe);
@@ -650,7 +601,6 @@ export async function validateArtifact(files) {
650
601
  warnings.push('layout.liquid present but manifest.supports.layout is not true, it will be ignored');
651
602
  }
652
603
 
653
- // 8. Page templates (when declared): file exists, parses, renders the page's data fixture.
654
604
  for (const pageName of manifest?.supports?.pageTemplates ?? []) {
655
605
  const fixture = contextContract.fixtures.pages?.[pageName];
656
606
  if (!fixture) {
@@ -685,7 +635,6 @@ export async function validateArtifact(files) {
685
635
  }
686
636
  }
687
637
 
688
- // 5. Island discipline: placed ⊆ declared ⊆ registry (and available).
689
638
  for (const name of placedIslands) {
690
639
  if (!declaredIslands.has(name)) {
691
640
  errors.push(`island '${name}': placed in a section but not declared in manifest.supports.islands`);
@@ -699,18 +648,13 @@ export async function validateArtifact(files) {
699
648
  }
700
649
  }
701
650
 
702
- // 6. Theme.
703
651
  if (!has('assets/theme.css') || read('assets/theme.css').trim() === '') {
704
652
  errors.push('assets/theme.css missing or empty, a template must ship its look');
705
653
  }
706
654
 
707
- // 6b. Layout contract (contract/v1/layout.json). Platform pages render through the content SEAM
708
- // (.container / .full); a template STYLES those to place content, never a parallel content container.
709
- // The rule is DATA, the seam token, the allowed selectors and the message all come from the contract
710
- // file; this only implements the check KIND (a non-seam selector sizing its width off the token).
711
655
  {
712
656
  const css = has('assets/theme.css') ? read('assets/theme.css') : '';
713
- const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ' '); // drop comments so an example can't trip it
657
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ' ');
714
658
  const RULE = /([^{}]+)\{([^{}]*)\}/g;
715
659
  for (const rule of layoutContract.rules ?? []) {
716
660
  if (rule.kind !== 'css-width-off-token') continue;
@@ -721,7 +665,7 @@ export async function validateArtifact(files) {
721
665
  let m;
722
666
  RULE.lastIndex = 0;
723
667
  while ((m = RULE.exec(stripped)) !== null) {
724
- if (!sizesOffToken.test(m[2])) continue; // only rules that size a box off the token
668
+ if (!sizesOffToken.test(m[2])) continue;
725
669
  for (const sel of m[1].split(',')) {
726
670
  const s = sel.trim();
727
671
  if (s && !isSeam.test(s)) offenders.add(s);
@@ -733,9 +677,6 @@ export async function validateArtifact(files) {
733
677
  }
734
678
  }
735
679
 
736
- // Open enums, proven survivable (content model v1 discipline 2): a template whose footprint
737
- // reads site.content.events must survive a registration mode it has never heard of, new modes
738
- // WILL arrive within the major. No throw, and no undefined/null literal leaking into markup.
739
680
  if (contentAnalysis.footprint.includes('content.events')) {
740
681
  const futureEvent = {
741
682
  ...(siteFx.content.events.items[0] ?? {}),
@@ -1,8 +1,6 @@
1
- // Existing artifacts are validated against their own contract. New uploads have an explicit v2 gate.
2
1
  import { validateArtifact as validateV1Artifact } from './validate-v1.mjs';
3
2
  import { validateArtifact as validateV2Artifact } from './validate-v2.mjs';
4
3
  export { validateV1Artifact, validateV2Artifact };
5
- // Historical consumers use these exact v1 exports; v2 consumers select the versioned module.
6
4
  export { contractDialect, sectionCatalogue, islandRegistry, contextContract, behaviourCatalogue, BEHAVIOUR_PRIMARY_ATTR } from './validate-v1.mjs';
7
5
 
8
6
  function manifestOf(files) {
@@ -236,3 +236,58 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
236
236
  cursor: pointer;
237
237
  }
238
238
  .lq-hero-slides .p60-carousel-dot--active { background: rgba(255, 255, 255, 0.95); }
239
+
240
+ /* Bounded presentation keeps the existing responsive heading families and outer padding. */
241
+ h1[data-p60-heading-scale="compact"] { font-size: clamp(2.16rem, 4.5vw, 3.24rem); }
242
+ h1[data-p60-heading-scale="standard"] { font-size: clamp(2.4rem, 5vw, 3.6rem); }
243
+ h1[data-p60-heading-scale="large"] { font-size: clamp(2.64rem, 5.5vw, 3.96rem); }
244
+ h2[data-p60-heading-scale="compact"] { font-size: clamp(1.53rem, 2.7vw, 2.16rem); }
245
+ h2[data-p60-heading-scale="standard"] { font-size: clamp(1.7rem, 3vw, 2.4rem); }
246
+ h2[data-p60-heading-scale="large"] { font-size: clamp(1.87rem, 3.3vw, 2.64rem); }
247
+
248
+ .lq-hero[data-p60-section-spacing="compact"] { padding-block: 3.6rem 2rem; }
249
+ .lq-hero[data-p60-section-spacing="standard"] { padding-block: 4.5rem 2.5rem; }
250
+ .lq-hero[data-p60-section-spacing="spacious"] { padding-block: 5.4rem 3rem; }
251
+ .lq-values[data-p60-section-spacing="compact"] { padding-block: 2.8rem; }
252
+ .lq-values[data-p60-section-spacing="standard"] { padding-block: 3.5rem; }
253
+ .lq-values[data-p60-section-spacing="spacious"] { padding-block: 4.2rem; }
254
+ .lq-cta[data-p60-section-spacing="compact"] { padding-block: 2.8rem 3.6rem; }
255
+ .lq-cta[data-p60-section-spacing="standard"] { padding-block: 3.5rem 4.5rem; }
256
+ .lq-cta[data-p60-section-spacing="spacious"] { padding-block: 4.2rem 5.4rem; }
257
+ .lq-homehero[data-p60-section-spacing="compact"] { padding-block: 3.6rem; }
258
+ .lq-homehero[data-p60-section-spacing="standard"] { padding-block: 4.5rem; }
259
+ .lq-homehero[data-p60-section-spacing="spacious"] { padding-block: 5.4rem; }
260
+ /* Whole-photo framing has no top padding; the form branch has its own authored bottom. */
261
+ .lq-homehero--vista[data-p60-section-spacing="compact"] { padding-block-start: 0; }
262
+ .lq-homehero--vista[data-p60-section-spacing="standard"] { padding-block-start: 0; }
263
+ .lq-homehero--vista[data-p60-section-spacing="spacious"] { padding-block-start: 0; }
264
+ .lq-homehero--vista-form[data-p60-section-spacing="compact"] { padding-block-end: 2.08rem; }
265
+ .lq-homehero--vista-form[data-p60-section-spacing="standard"] { padding-block-end: 2.6rem; }
266
+ .lq-homehero--vista-form[data-p60-section-spacing="spacious"] { padding-block-end: 3.12rem; }
267
+
268
+ /* Author-bounded Intro photo layouts. No-photo defaults remain unchanged. */
269
+ .lq-hero[data-p60-layout-has-media] [data-p60-layout-role="frame"] { display:grid; grid-template-columns:minmax(0,1fr); align-items:center; gap:2rem; }
270
+ .lq-hero[data-p60-layout-has-media] [data-p60-layout-role="media"],.lq-hero[data-p60-layout-has-media] [data-p60-layout-role="content"] { min-inline-size:0; }
271
+ .lq-hero[data-p60-layout-has-media] [data-p60-layout-role="content"] { overflow-wrap:anywhere; }
272
+ .lq-hero[data-p60-layout-has-media] [data-p60-layout-role="media"] { container-type:inline-size; container-name:p60-intro-photo; }
273
+ .lq-hero[data-p60-layout-has-media] [data-p60-layout-role="media"] img { display:block; inline-size:100%; block-size:auto; }
274
+ .lq-hero img[data-p60-field="imageUrl"] { object-fit:contain; }
275
+ .lq-hero img[data-p60-photo-framing="whole"] { aspect-ratio:auto; object-fit:contain; }
276
+ .lq-hero img[data-p60-photo-framing="fill"] { aspect-ratio:16 / 9; object-fit:cover; }
277
+ @container p60-intro-photo (max-width:48rem) {
278
+ .lq-hero img[data-p60-photo-framing="fill"] { aspect-ratio:1 / 1; }
279
+ }
280
+ .lq-hero[data-p60-layout-has-media] [data-p60-layout-role="content"] { inline-size:100%; container-type:inline-size; }
281
+ .lq-hero[data-p60-layout-has-media] [data-p60-layout-role="content"] h1 { font-size:clamp(2.2rem,8.5cqi,3.6rem); }
282
+ .lq-hero[data-p60-layout-has-media] [data-p60-layout-role="content"] h1[data-p60-heading-scale="compact"] { font-size:clamp(1.98rem,7.65cqi,3.24rem); }
283
+ .lq-hero[data-p60-layout-has-media] [data-p60-layout-role="content"] h1[data-p60-heading-scale="standard"] { font-size:clamp(2.2rem,8.5cqi,3.6rem); }
284
+ .lq-hero[data-p60-layout-has-media] [data-p60-layout-role="content"] h1[data-p60-heading-scale="large"] { font-size:clamp(2.464rem,9.52cqi,4.032rem); }
285
+ @media (min-width:1024px) {
286
+ .lq-hero[data-p60-section-layout="image-start"][data-p60-layout-has-media] [data-p60-layout-role="frame"] { grid-template-columns:minmax(0,1fr) minmax(0,1fr); }
287
+ .lq-hero[data-p60-section-layout="image-start"][data-p60-layout-has-media] [data-p60-layout-role="media"] { grid-column:1; grid-row:1; }
288
+ .lq-hero[data-p60-section-layout="image-start"][data-p60-layout-has-media] [data-p60-layout-role="content"] { grid-column:2; grid-row:1; }
289
+ .lq-hero[data-p60-section-layout="image-end"][data-p60-layout-has-media] [data-p60-layout-role="frame"] { grid-template-columns:minmax(0,1fr) minmax(0,1fr); }
290
+ .lq-hero[data-p60-section-layout="image-end"][data-p60-layout-has-media] [data-p60-layout-role="media"] { grid-column:2; grid-row:1; }
291
+ .lq-hero[data-p60-section-layout="image-end"][data-p60-layout-has-media] [data-p60-layout-role="content"] { grid-column:1; grid-row:1; }
292
+ }
293
+ /* End Intro photo layouts. */
@@ -7,6 +7,26 @@
7
7
  "supports": {
8
8
  "navigationHighlights": false,
9
9
  "fieldMarkers": true,
10
+ "sectionFields": { "hero": ["imageUrl", "imageAlt", "photoFraming"] },
11
+ "sectionPresentation": {
12
+ "homeHero": {
13
+ "headingScale": { "options": ["compact", "standard", "large"], "default": "standard" },
14
+ "sectionSpacing": { "options": ["compact", "standard", "spacious"], "default": "standard" }
15
+ },
16
+ "hero": {
17
+ "sectionLayout": { "options": ["image-start", "image-end", "stacked"], "default": "stacked" },
18
+ "headingScale": { "options": ["compact", "standard", "large"], "default": "standard" },
19
+ "sectionSpacing": { "options": ["compact", "standard", "spacious"], "default": "standard" }
20
+ },
21
+ "values": {
22
+ "headingScale": { "options": ["compact", "standard", "large"], "default": "standard" },
23
+ "sectionSpacing": { "options": ["compact", "standard", "spacious"], "default": "standard" }
24
+ },
25
+ "cta": {
26
+ "headingScale": { "options": ["compact", "standard", "large"], "default": "standard" },
27
+ "sectionSpacing": { "options": ["compact", "standard", "spacious"], "default": "standard" }
28
+ }
29
+ },
10
30
  "pages": [
11
31
  "home",
12
32
  "about"
@@ -1,7 +1,7 @@
1
- <section class="lq-cta">
1
+ <section class="lq-cta" data-p60-section-spacing="{{ section.presentation.sectionSpacing }}">
2
2
  <div class="container lq-cta-grid">
3
3
  <div>
4
- <h2 data-p60-field="heading">{{ section.heading }}</h2>
4
+ <h2 data-p60-field="heading" data-p60-heading-scale="{{ section.presentation.headingScale }}">{{ section.heading }}</h2>
5
5
  {% if section.text %}<p data-p60-field="text">{{ section.text }}</p>{% endif %}
6
6
  <a class="button button-primary" href="{{ section.buttonHref }}">{{ section.buttonLabel }}</a>
7
7
  </div>
@@ -5,10 +5,12 @@
5
5
  wrap the value in a span when there is punctuation or other copy beside it (people.liquid shows
6
6
  one). Marking is optional and additive: an unmarked template is edited in the panel as before.
7
7
  {% endcomment %}
8
- <section class="lq-hero">
9
- <div class="container">
8
+ <section class="lq-hero" data-p60-section-spacing="{{ section.presentation.sectionSpacing }}" data-p60-section-layout="{{ section.presentation.sectionLayout }}"{% if section.imageUrl != blank %} data-p60-layout-has-media{% endif %}>
9
+ <div class="container" data-p60-layout-role="frame">
10
+ {% if section.imageUrl != blank %}<div data-p60-layout-role="media"><img data-p60-field="imageUrl" data-p60-photo-framing="{{ section.photoFraming }}" src="{{ section.imageUrl }}" alt="{{ section.imageAlt }}" loading="lazy" decoding="async"></div><div data-p60-layout-role="content">{% endif %}
10
11
  {% if section.eyebrow %}<p class="lq-kicker" data-p60-field="eyebrow">{{ section.eyebrow }}</p>{% endif %}
11
- <h1 data-p60-field="title">{{ section.title }}</h1>
12
+ <h1 data-p60-field="title" data-p60-heading-scale="{{ section.presentation.headingScale }}">{{ section.title }}</h1>
12
13
  <div class="lq-hero-body">{{ section.bodyHtml | raw }}</div>
14
+ {% if section.imageUrl != blank %}</div>{% endif %}
13
15
  </div>
14
16
  </section>
@@ -36,14 +36,14 @@
36
36
  {% assign heroFraming = section.photoFraming | default: 'fill' %}
37
37
  {% if heroCount > 0 and heroFraming == 'whole' %}
38
38
  {% if section.actionStyle == 'button' %}
39
- <section class="lq-homehero lq-homehero--vista{% if heroCount > 1 %} lq-homehero--carousel{% endif %}">
39
+ <section class="lq-homehero lq-homehero--vista{% if heroCount > 1 %} lq-homehero--carousel{% endif %}" data-p60-section-spacing="{{ section.presentation.sectionSpacing }}">
40
40
  <div class="lq-vista">
41
41
  <img class="lq-vista-sizer" src="{{ section.images.first.imageUrl }}" alt="{{ section.images.first.alt }}" style="object-position: {{ heroFocus }}" fetchpriority="high" decoding="async">
42
42
  {% if heroCount > 1 %}{% island 'hero_carousel' %}{% else %}<div class="lq-vista-scrim" aria-hidden="true"></div>{% endif %}
43
43
  <div class="lq-vista-copy">
44
44
  <div class="container">
45
45
  {% if section.eyebrow != nil and section.eyebrow != '' %}<p class="lq-kicker" data-p60-field="eyebrow">{{ section.eyebrow }}</p>{% endif %}
46
- {% if section.title != nil and section.title != '' %}<h1 data-p60-field="title">{{ section.title }}</h1>{% endif %}
46
+ {% if section.title != nil and section.title != '' %}<h1 data-p60-field="title" data-p60-heading-scale="{{ section.presentation.headingScale }}">{{ section.title }}</h1>{% endif %}
47
47
  {% if section.lead %}<p class="lq-homehero-lead" data-p60-field="lead">{{ section.lead }}</p>{% endif %}
48
48
  <div class="lq-homehero-actions">
49
49
  {% if heroButtonLabel != nil and heroButtonLabel != '' and heroButtonHref != nil and heroButtonHref != '' %}<a class="button button-primary" href="{{ heroButtonHref }}">{% if section.primaryLabel != nil %}<span data-p60-field="primaryLabel">{{ section.primaryLabel }}</span>{% else %}{{ heroButtonLabel }}{% endif %}</a>{% endif %}
@@ -56,7 +56,7 @@
56
56
  {% else %}
57
57
  {% comment %} Form mode: the grid pulls up over the band, copy on the photo, the widget riding
58
58
  the seam onto the gradient below (never a stacked strip). {% endcomment %}
59
- <section class="lq-homehero lq-homehero--vista lq-homehero--vista-form{% if heroCount > 1 %} lq-homehero--carousel{% endif %}">
59
+ <section class="lq-homehero lq-homehero--vista lq-homehero--vista-form{% if heroCount > 1 %} lq-homehero--carousel{% endif %}" data-p60-section-spacing="{{ section.presentation.sectionSpacing }}">
60
60
  <div class="lq-vista">
61
61
  <img class="lq-vista-sizer" src="{{ section.images.first.imageUrl }}" alt="{{ section.images.first.alt }}" style="object-position: {{ heroFocus }}" fetchpriority="high" decoding="async">
62
62
  {% if heroCount > 1 %}{% island 'hero_carousel' %}{% else %}<div class="lq-vista-scrim" aria-hidden="true"></div>{% endif %}
@@ -64,7 +64,7 @@
64
64
  <div class="container lq-homehero-inner lq-vista-overlap">
65
65
  <div>
66
66
  {% if section.eyebrow != nil and section.eyebrow != '' %}<p class="lq-kicker" data-p60-field="eyebrow">{{ section.eyebrow }}</p>{% endif %}
67
- {% if section.title != nil and section.title != '' %}<h1 data-p60-field="title">{{ section.title }}</h1>{% endif %}
67
+ {% if section.title != nil and section.title != '' %}<h1 data-p60-field="title" data-p60-heading-scale="{{ section.presentation.headingScale }}">{{ section.title }}</h1>{% endif %}
68
68
  {% if section.lead %}<p class="lq-homehero-lead" data-p60-field="lead">{{ section.lead }}</p>{% endif %}
69
69
  <div class="lq-homehero-actions">
70
70
  {% if heroButtonLabel != nil and heroButtonLabel != '' and heroButtonHref != nil and heroButtonHref != '' %}<a class="button button-primary" href="{{ heroButtonHref }}">{% if section.primaryLabel != nil %}<span data-p60-field="primaryLabel">{{ section.primaryLabel }}</span>{% else %}{{ heroButtonLabel }}{% endif %}</a>{% endif %}
@@ -79,7 +79,7 @@
79
79
  </section>
80
80
  {% endif %}
81
81
  {% else %}
82
- <section class="lq-homehero{% if heroPhoto %} lq-homehero--photo{% endif %}{% if heroCount > 1 %} lq-homehero--carousel{% endif %}"{% if heroPhoto %} style="background-image: url('{{ heroPhoto }}'); background-position: {{ heroFocus }}"{% endif %}>
82
+ <section class="lq-homehero{% if heroPhoto %} lq-homehero--photo{% endif %}{% if heroCount > 1 %} lq-homehero--carousel{% endif %}" data-p60-section-spacing="{{ section.presentation.sectionSpacing }}"{% if heroPhoto %} style="background-image: url('{{ heroPhoto }}'); background-position: {{ heroFocus }}"{% endif %}>
83
83
  {% comment %}
84
84
  Several photos → the BEHAVIOUR carousel (data-p60-carousel, engine-wired, the template ships
85
85
  no JavaScript): complete static markup, one data-p60-slide per photo, auto-advance opt-in.
@@ -99,7 +99,7 @@
99
99
  <div class="container lq-homehero-inner">
100
100
  <div>
101
101
  {% if section.eyebrow != nil and section.eyebrow != '' %}<p class="lq-kicker" data-p60-field="eyebrow">{{ section.eyebrow }}</p>{% endif %}
102
- {% if section.title != nil and section.title != '' %}<h1 data-p60-field="title">{{ section.title }}</h1>{% endif %}
102
+ {% if section.title != nil and section.title != '' %}<h1 data-p60-field="title" data-p60-heading-scale="{{ section.presentation.headingScale }}">{{ section.title }}</h1>{% endif %}
103
103
  {% if section.lead %}<p class="lq-homehero-lead" data-p60-field="lead">{{ section.lead }}</p>{% endif %}
104
104
  <div class="lq-homehero-actions">
105
105
  {% if section.actionStyle == 'button' %}
@@ -1,6 +1,6 @@
1
- <section class="lq-values">
1
+ <section class="lq-values" data-p60-section-spacing="{{ section.presentation.sectionSpacing }}">
2
2
  <div class="container">
3
- {% if section.heading != nil and section.heading != '' %}<h2 data-p60-field="heading">{{ section.heading }}</h2>{% endif %}
3
+ {% if section.heading != nil and section.heading != '' %}<h2 data-p60-field="heading" data-p60-heading-scale="{{ section.presentation.headingScale }}">{{ section.heading }}</h2>{% endif %}
4
4
  {% comment %}
5
5
  data-p60-reveal-group: the engine stamps --p60-reveal-index on each child and adds
6
6
  is-revealed when the rail scrolls into view, the stagger and motion are OURS, in theme.css,