@port60/template-kit 0.8.0 → 0.10.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 (40) hide show
  1. package/README.md +10 -3
  2. package/bin/cli.mjs +26 -4
  3. package/package.json +1 -1
  4. package/src/commands/content.mjs +42 -0
  5. package/src/commands/create.mjs +10 -7
  6. package/src/commands/dev.mjs +134 -8
  7. package/src/commands/model.mjs +34 -0
  8. package/src/commands/packageCmd.mjs +3 -3
  9. package/src/commands/refresh.mjs +48 -0
  10. package/src/commands/upgrade.mjs +44 -0
  11. package/src/commands/validate.mjs +2 -3
  12. package/src/lib/agentsMd.mjs +78 -21
  13. package/src/vendor/contract/v1/behaviours.json +129 -0
  14. package/src/vendor/contract/v1/content-bounds.json +3 -3
  15. package/src/vendor/contract/v1/content-model.json +193 -0
  16. package/src/vendor/contract/v1/context.json +1985 -141
  17. package/src/vendor/contract/v1/dialect.json +3 -3
  18. package/src/vendor/contract/v1/fonts.json +1 -1
  19. package/src/vendor/contract/v1/imagery.json +4 -4
  20. package/src/vendor/contract/v1/islands.json +256 -110
  21. package/src/vendor/contract/v1/layout.json +25 -0
  22. package/src/vendor/contract/v1/manifest.schema.json +60 -14
  23. package/src/vendor/contract/v1/sections.json +330 -61
  24. package/src/vendor/contract/v1/tokens.json +1 -1
  25. package/src/vendor/contract/v1.lock.json +1 -1
  26. package/src/vendor/engine/content-footprint.mjs +79 -0
  27. package/src/vendor/validator/behaviors-runtime.js +2 -0
  28. package/src/vendor/validator/fixture-art.mjs +109 -0
  29. package/src/vendor/validator/model-reference.mjs +92 -0
  30. package/src/vendor/validator/platform-base.css +4250 -0
  31. package/src/vendor/validator/preview.mjs +497 -23
  32. package/src/vendor/validator/site-context.mjs +97 -0
  33. package/src/vendor/validator/validate.mjs +214 -14
  34. package/starter/assets/theme.css +54 -8
  35. package/starter/layout.liquid +15 -16
  36. package/starter/manifest.json +10 -5
  37. package/starter/sections/campaigns.liquid +23 -0
  38. package/starter/sections/homeHero.liquid +26 -11
  39. package/starter/sections/people.liquid +1 -1
  40. package/starter/sections/values.liquid +6 -1
@@ -0,0 +1,97 @@
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
+ import contextContract from '../contract/v1/context.json' with { type: 'json' };
6
+ import sectionCatalogue from '../contract/v1/sections.json' with { type: 'json' };
7
+
8
+ export { contentModel, extractContentFootprint } from '../engine/content-footprint.mjs';
9
+ import { contentModel } from '../engine/content-footprint.mjs';
10
+
11
+ /** The canonical site fixture with `about` composed for this manifest's declared sections. */
12
+ export function buildSiteFixture(manifest) {
13
+ const base = contextContract.fixtures.site;
14
+ const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
15
+ const about = (manifest?.supports?.sections ?? [])
16
+ .map((type) => {
17
+ const entry = catalogueByType.get(type);
18
+ return entry ? { type, content: entry.sample ?? {} } : null;
19
+ })
20
+ .filter(Boolean);
21
+ return { ...base, content: { ...base.content, about } };
22
+ }
23
+
24
+ // ── preview-content.json (author-editable data) ────────────────────────────────
25
+ // Data is free, SHAPE is fixed: a collection override must be an array of items whose fields all
26
+ // exist in the model, within the collection's cap. Proofs always run on the canonical fixtures,
27
+ // so custom data can never dodge a gate; this file only feeds the dev preview.
28
+
29
+ const BRAND_FIELDS = new Set(['name', 'tagline', 'logoUrl', 'logoType', 'footerLogoUrl']);
30
+
31
+ export function validatePreviewContent(json) {
32
+ const errors = [];
33
+ if (json === null || typeof json !== 'object' || Array.isArray(json)) {
34
+ return ['preview-content.json: must be an object of { collection: [items] } overrides'];
35
+ }
36
+ for (const [name, items] of Object.entries(json)) {
37
+ // `brand` is the one non-collection override: the organisation's own name and marks.
38
+ if (name === 'brand') {
39
+ if (items === null || typeof items !== 'object' || Array.isArray(items)) {
40
+ errors.push('preview-content.json: brand must be an object');
41
+ continue;
42
+ }
43
+ for (const field of Object.keys(items)) {
44
+ if (!BRAND_FIELDS.has(field)) {
45
+ errors.push(`preview-content.json: brand.${field} does not exist — brand carries ${[...BRAND_FIELDS].join(', ')}`);
46
+ }
47
+ }
48
+ continue;
49
+ }
50
+ const model = contentModel.collections[name];
51
+ if (!model) {
52
+ errors.push(`preview-content.json: '${name}' is not a content collection (see contract/v1/content-model.json)`);
53
+ continue;
54
+ }
55
+ if (!Array.isArray(items)) {
56
+ errors.push(`preview-content.json: '${name}' must be an array`);
57
+ continue;
58
+ }
59
+ if (items.length > model.cap) {
60
+ errors.push(`preview-content.json: '${name}' holds ${items.length} items — the collection is bounded at ${model.cap}`);
61
+ }
62
+ items.forEach((item, i) => {
63
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
64
+ errors.push(`preview-content.json: ${name}[${i}] must be an object`);
65
+ return;
66
+ }
67
+ for (const field of Object.keys(item)) {
68
+ if (!model.item[field]) {
69
+ errors.push(`preview-content.json: ${name}[${i}].${field} does not exist in the model — a field that does not exist in production cannot exist in a preview`);
70
+ }
71
+ }
72
+ for (const [field, spec] of Object.entries(model.item)) {
73
+ const value = item[field];
74
+ if (value == null) continue;
75
+ const kind = spec.type === 'string[]' ? 'array' : spec.type;
76
+ const actual = Array.isArray(value) ? 'array' : typeof value;
77
+ if (kind !== 'object' && actual !== kind) {
78
+ errors.push(`preview-content.json: ${name}[${i}].${field} should be ${spec.type}`);
79
+ }
80
+ }
81
+ });
82
+ }
83
+ return errors;
84
+ }
85
+
86
+ /** Overlay validated preview content onto a site fixture (collections replaced wholesale). */
87
+ export function applyPreviewContent(site, json) {
88
+ if (!json || typeof json !== 'object') return site;
89
+ const content = { ...site.content };
90
+ for (const [name, items] of Object.entries(json)) {
91
+ if (contentModel.collections[name] && Array.isArray(items)) content[name] = items;
92
+ }
93
+ const brand = json.brand && typeof json.brand === 'object' && !Array.isArray(json.brand)
94
+ ? { ...site.brand, ...json.brand }
95
+ : site.brand;
96
+ return { ...site, brand, content };
97
+ }
@@ -20,10 +20,25 @@ import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
20
20
  import manifestSchema from '../contract/v1/manifest.schema.json' with { type: 'json' };
21
21
  import contextContract from '../contract/v1/context.json' with { type: 'json' };
22
22
  import fontCatalogue from '../contract/v1/fonts.json' with { type: 'json' };
23
+ import layoutContract from '../contract/v1/layout.json' with { type: 'json' };
24
+ import behaviourCatalogue from '../contract/v1/behaviours.json' with { type: 'json' };
25
+ import { buildSiteFixture, extractContentFootprint, contentModel } from './site-context.mjs';
23
26
 
24
27
  const Ajv = Ajv2020.default ?? Ajv2020;
25
28
 
26
- export { dialect as contractDialect, sectionCatalogue, islandRegistry, contextContract };
29
+ export { dialect as contractDialect, sectionCatalogue, islandRegistry, contextContract, behaviourCatalogue };
30
+
31
+ // Templates are markup and attributes, NEVER code (docs/template-behaviours.md). These are hard
32
+ // errors over the RAW liquid source — even inside comments, because there is no legitimate reason
33
+ // for the tokens to appear at all. The handler pattern names real DOM event families rather than
34
+ // matching any on* word, so attributes like `once` or `online` never false-positive.
35
+ const FORBIDDEN_MARKUP = [
36
+ [/<script\b/i, 'a <script> tag'],
37
+ [/<(iframe|object|embed)\b/i, 'an embedded frame or plugin element'],
38
+ [/\son(?:click|dbl|aux|load|error|abort|unload|mouse|pointer|touch|drag|drop|wheel|scroll|key|focus|blur|input|change|submit|reset|invalid|select|toggle|copy|paste|cut|context|play|pause|ended|seek|stall|suspend|time|volume|waiting|canplay|animation|transition|message|resize|hashchange|popstate|storage|got|lost)[a-z]*\s*=/i,
39
+ 'an inline event handler'],
40
+ [/javascript\s*:/i, 'a javascript: URL'],
41
+ ];
27
42
 
28
43
  export async function validateArtifact(files) {
29
44
  const errors = [];
@@ -41,6 +56,18 @@ export async function validateArtifact(files) {
41
56
  } catch (e) {
42
57
  return { errors: [`manifest.json unreadable: ${e.message}`], warnings, manifest: null };
43
58
  }
59
+ // preview-content.json is a DEV-ONLY data override: package excludes it and the intake
60
+ // refuses it — an artifact must never carry data, only shape.
61
+ if (has('preview-content.json')) {
62
+ errors.push('preview-content.json: development preview data never ships in an artifact — remove it (package excludes it automatically)');
63
+ }
64
+
65
+ // Content model v1: the footprint is decidable from the sources (closed dialect); unknown
66
+ // paths, dynamic indexing and tree aliasing are errors from site-context.mjs.
67
+ const contentAnalysis = extractContentFootprint(files);
68
+ errors.push(...contentAnalysis.errors);
69
+ const siteFx = buildSiteFixture(manifest);
70
+
44
71
  const ajv = new Ajv({ allErrors: true });
45
72
  const validateManifest = ajv.compile(manifestSchema);
46
73
  if (!validateManifest(manifest)) {
@@ -49,6 +76,96 @@ export async function validateArtifact(files) {
49
76
  }
50
77
  }
51
78
 
79
+ // Capability declarations are catalogue MATCHING metadata, not an entitlement shortcut. Keep
80
+ // them honest by requiring one corresponding template-facing surface. The mapping is deliberately
81
+ // structural: it proves the design can present a capability without inspecting tenant data or
82
+ // crossing the platform-owned transaction, identity and consent boundaries.
83
+ {
84
+ const sections = new Set(manifest?.supports?.sections ?? []);
85
+ const islands = new Set(manifest?.supports?.islands ?? []);
86
+ const pages = new Set(manifest?.supports?.pageTemplates ?? []);
87
+ const hasAny = (values, expected) => expected.some((value) => values.has(value));
88
+ const capabilitySurface = {
89
+ giving: () => islands.has('donation_widget'),
90
+ appeals: () => hasAny(sections, ['appealGrid', 'emergency']) || islands.has('donation_widget'),
91
+ worship: () => manifest?.supports?.worship === true || islands.has('next_prayer'),
92
+ courses: () => pages.has('course') || islands.has('course_enrol'),
93
+ membership: () => islands.has('member_menu'),
94
+ events: () => sections.has('events') || pages.has('events') || islands.has('events_carousel'),
95
+ articles: () => sections.has('articles') || hasAny(pages, ['articles', 'article']) || islands.has('latest_articles'),
96
+ services: () => sections.has('programmes'),
97
+ volunteering: () => sections.has('volunteering') || islands.has('volunteer_signup'),
98
+ forms: () => islands.has('form'),
99
+ resources: () => sections.has('resources'),
100
+ locations: () => sections.has('locations'),
101
+ newsletter: () => islands.has('newsletter_signup'),
102
+ i18n: () => islands.has('language_switch'),
103
+ search: () => islands.has('search'),
104
+ media: () => sections.has('media')
105
+ };
106
+ for (const capability of manifest?.requiresCapabilities ?? []) {
107
+ if (!capabilitySurface[capability]?.()) {
108
+ errors.push(`manifest: requiresCapabilities '${capability}' has no corresponding declared section, page template or island`);
109
+ }
110
+ }
111
+ }
112
+
113
+ // Markup and attributes, NEVER code — the machine-enforced JavaScript ban over every liquid
114
+ // source (docs/template-behaviours.md). Behaviour is engine-owned; a template wanting motion
115
+ // declares supports.behaviors and uses the data-p60-* grammar.
116
+ for (const [path, source] of Object.entries(files)) {
117
+ if (!path.endsWith('.liquid')) continue;
118
+ for (const [pattern, what] of FORBIDDEN_MARKUP) {
119
+ if (pattern.test(source)) {
120
+ errors.push(`${path}: contains ${what} — templates are markup and attributes, never code (behaviour is engine-owned; see the behaviour catalogue)`);
121
+ }
122
+ }
123
+ }
124
+
125
+ // Behaviour declaration and usage must agree in BOTH directions. Source-level, deliberately:
126
+ // usage often sits inside content-dependent branches the fixtures never take, so the grammar's
127
+ // presence in the source is the honest minimal proof.
128
+ {
129
+ const declaredBehaviours = new Set(manifest?.supports?.behaviors ?? []);
130
+ const liquidSource = Object.entries(files)
131
+ .filter(([path]) => path.endsWith('.liquid'))
132
+ .map(([, source]) => source)
133
+ .join('\n');
134
+ const PRIMARY_ATTR = {
135
+ reveal: [/data-p60-reveal\b/, 'data-p60-reveal'],
136
+ counter: [/data-p60-count\b/, 'data-p60-count'],
137
+ progress: [/data-p60-progress\b/, 'data-p60-progress'],
138
+ countdown: [/data-p60-countdown\b/, 'data-p60-countdown'],
139
+ accordion: [/data-p60-accordion\b/, 'data-p60-accordion'],
140
+ carousel: [/data-p60-carousel\b/, 'data-p60-carousel'],
141
+ stickyHeader: [/data-p60-sticky-header\b/, 'data-p60-sticky-header'],
142
+ stickyCta: [/data-p60-sticky-cta\b/, 'data-p60-sticky-cta'],
143
+ lightbox: [/data-p60-lightbox\b/, 'data-p60-lightbox'],
144
+ tabs: [/data-p60-tabs\b/, 'data-p60-tabs'],
145
+ };
146
+ for (const name of declaredBehaviours) {
147
+ const primary = PRIMARY_ATTR[name];
148
+ if (primary && !primary[0].test(liquidSource)) {
149
+ errors.push(`manifest: supports.behaviors declares '${name}' but no ${primary[1]} attribute appears in any liquid source`);
150
+ }
151
+ }
152
+ for (const [name, [pattern, attr]] of Object.entries(PRIMARY_ATTR)) {
153
+ if (pattern.test(liquidSource) && !declaredBehaviours.has(name)) {
154
+ errors.push(`behaviour '${name}': ${attr} appears in the markup but supports.behaviors does not declare it`);
155
+ }
156
+ }
157
+ if (/data-p60-carousel(?!-)\b/.test(liquidSource) && !/data-p60-slide\b/.test(liquidSource)) {
158
+ errors.push("behaviour 'carousel': a data-p60-carousel container needs data-p60-slide children");
159
+ }
160
+ if (/data-p60-lightbox(?!-)\b/.test(liquidSource) && !/data-p60-lightbox-item\b/.test(liquidSource)) {
161
+ errors.push("behaviour 'lightbox': a data-p60-lightbox group needs data-p60-lightbox-item anchors");
162
+ }
163
+ if (/data-p60-tabs\b/.test(liquidSource)
164
+ && (!/data-p60-tab(?!s)\b/.test(liquidSource) || !/data-p60-panel\b/.test(liquidSource))) {
165
+ errors.push("behaviour 'tabs': a data-p60-tabs container needs data-p60-tab controls and data-p60-panel panels");
166
+ }
167
+ }
168
+
52
169
  // Font knobs: the default family must be a real catalogue entry (the tenant-unset render uses
53
170
  // it), and the slot must declare the weights the template's typographic system needs.
54
171
  {
@@ -108,10 +225,20 @@ export async function validateArtifact(files) {
108
225
  // the data itself — in which case both directions are proven behaviourally (the worship
109
226
  // pattern): the populated fixture's sentinel must appear, and the EMPTY context must render it
110
227
  // away (derive or omit — nothing invented, nothing dangling).
228
+ // Sentinels are DERIVED from the canonical fixtures (the first item's display field), never
229
+ // hardcoded — the fixture data is free to become richer without touching a proof, and a proof
230
+ // can never drift from the data it renders.
231
+ const sentinelOf = (type, dataKey, field) =>
232
+ (contextContract.fixtures.sections?.[type]?.[dataKey] ?? [])[0]?.[field] ?? null;
111
233
  const WIDGET_SECTIONS = {
112
- events: { island: 'events_carousel', dataKey: 'events', sentinel: 'Fixture Lantern Gala' },
113
- whatsOn: { island: 'whats_on_strip', dataKey: 'infoEvents', sentinel: 'Fixture Open Morning' },
114
- articles: { island: 'latest_articles', dataKey: 'latestArticles', sentinel: 'Fixture Winter Diary' }
234
+ events: { island: 'events_carousel', dataKey: 'events', sentinel: sentinelOf('events', 'events', 'name') },
235
+ whatsOn: { island: 'whats_on_strip', dataKey: 'infoEvents', sentinel: sentinelOf('whatsOn', 'infoEvents', 'name') },
236
+ articles: { island: 'latest_articles', dataKey: 'latestArticles', sentinel: sentinelOf('articles', 'latestArticles', 'title') },
237
+ campaigns: { island: null, dataKey: 'campaigns', sentinel: sentinelOf('campaigns', 'campaigns', 'title') },
238
+ resources: { island: null, dataKey: 'resources', sentinel: sentinelOf('resources', 'resources', 'title') },
239
+ locations: { island: null, dataKey: 'locations', sentinel: sentinelOf('locations', 'locations', 'name') },
240
+ volunteering: { island: 'volunteer_signup', dataKey: 'opportunities', sentinel: sentinelOf('volunteering', 'opportunities', 'title') },
241
+ media: { island: null, dataKey: 'media', sentinel: sentinelOf('media', 'media', 'title') }
115
242
  };
116
243
 
117
244
  // 2–4. Sections: catalogue membership, parse, fixture renders.
@@ -142,21 +269,26 @@ export async function validateArtifact(files) {
142
269
  const populated = await liquid.render(parsed, {
143
270
  section: {},
144
271
  brand: contextContract.fixtures.brand,
272
+ site: siteFx,
145
273
  ...dataFixture
146
274
  });
147
- const placesIsland = splitIslandParts(populated).some((p) => p.island === widget.island);
275
+ const placesIsland = widget.island !== null
276
+ && splitIslandParts(populated).some((p) => p.island === widget.island);
148
277
  if (!placesIsland) {
149
- if (!populated.includes(widget.sentinel)) {
278
+ if (widget.sentinel && !populated.includes(widget.sentinel)) {
150
279
  errors.push(
151
- `section '${type}': neither places the ${widget.island} island nor renders the ${widget.dataKey} context — render the data (the fixture's "${widget.sentinel}" must appear) or place the island`
280
+ widget.island
281
+ ? `section '${type}': neither places the ${widget.island} island nor renders the ${widget.dataKey} context — render the data (the fixture's "${widget.sentinel}" must appear) or place the island`
282
+ : `section '${type}': does not render the ${widget.dataKey} context — the fixture's "${widget.sentinel}" must appear`
152
283
  );
153
284
  }
154
285
  const empty = await liquid.render(parsed, {
155
286
  section: {},
156
287
  brand: contextContract.fixtures.brand,
288
+ site: { ...siteFx, content: Object.fromEntries(Object.keys(siteFx.content).map((k) => [k, []])) },
157
289
  [widget.dataKey]: []
158
290
  });
159
- if (empty.includes(widget.sentinel)) {
291
+ if (widget.sentinel && empty.includes(widget.sentinel)) {
160
292
  errors.push(`section '${type}': still shows fixture content with an empty ${widget.dataKey} — content must come from the context`);
161
293
  }
162
294
  if (/\bundefined\b|\bnull\b/.test(empty.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
@@ -173,19 +305,23 @@ export async function validateArtifact(files) {
173
305
  const html = await liquid.render(parsed, {
174
306
  section: fixture,
175
307
  brand: contextContract.fixtures.brand,
308
+ site: siteFx,
176
309
  // Widget data rides the ordinary fixture renders too, so a hand-rendering section
177
310
  // doesn't fail the generic pass for want of its context.
178
311
  ...(widget ? (contextContract.fixtures.sections?.[type] ?? {}) : {})
179
312
  });
180
313
  if (type === 'homeHero' && fixtureName === 'sample') {
181
- const probe = 'Hero photo one';
314
+ // The sample's own first image reference is the probe: `p60fixture:` refs are quote-free
315
+ // and survive HTML escaping, so "does the hero display the photos?" stays a substring check.
316
+ const probe = (fixture.images ?? [])[0]?.imageUrl ?? 'p60fixture:';
182
317
  homeHeroMultiShows = html.includes(probe)
183
318
  || splitIslandParts(html).some((p) => p.island === 'hero_carousel');
184
319
  // The single-photo path proven separately: same fixture, first photo only.
185
320
  try {
186
321
  const single = await liquid.render(parsed, {
187
322
  section: { ...fixture, images: (fixture.images ?? []).slice(0, 1) },
188
- brand: contextContract.fixtures.brand
323
+ brand: contextContract.fixtures.brand,
324
+ site: siteFx
189
325
  });
190
326
  homeHeroSingleShows = single.includes(probe);
191
327
  } catch {
@@ -247,10 +383,12 @@ export async function validateArtifact(files) {
247
383
  if (parsedLayout) {
248
384
  try {
249
385
  const html = await liquid.render(parsedLayout, {
386
+ site: siteFx,
250
387
  brand: contextContract.fixtures.brand,
251
388
  nav: contextContract.fixtures.layout.nav,
252
389
  socials: contextContract.fixtures.layout.socials ?? [],
253
- worship: contextContract.fixtures.layout.worship ?? null
390
+ worship: contextContract.fixtures.layout.worship ?? null,
391
+ locale: contextContract.fixtures.layout.locale
254
392
  });
255
393
  let contentSlots = 0;
256
394
  const layoutIslands = [];
@@ -281,10 +419,13 @@ export async function validateArtifact(files) {
281
419
  }
282
420
  if (manifest?.supports?.worship) {
283
421
  const nullHtml = await liquid.render(parsedLayout, {
422
+ site: siteFx,
284
423
  brand: contextContract.fixtures.brand,
285
424
  nav: contextContract.fixtures.layout.nav,
286
425
  socials: contextContract.fixtures.layout.socials ?? [],
287
- worship: null
426
+ worship: null,
427
+ site: { ...siteFx, content: { ...siteFx.content, schedules: [] } },
428
+ locale: contextContract.fixtures.layout.locale
288
429
  });
289
430
  if (nullHtml.includes(worshipProbe)) {
290
431
  errors.push('layout: worship rail content appears even when `worship` is null — always branch on it (tenants without a schedule must not see a rail)');
@@ -320,7 +461,7 @@ export async function validateArtifact(files) {
320
461
  continue;
321
462
  }
322
463
  try {
323
- const html = await liquid.render(parsedPage, { ...fixture, brand: contextContract.fixtures.brand });
464
+ const html = await liquid.render(parsedPage, { ...fixture, site: siteFx, brand: contextContract.fixtures.brand });
324
465
  for (const part of splitIslandParts(html)) {
325
466
  if (part.island === CONTENT_SLOT) {
326
467
  errors.push(`page template '${pageName}': uses {% content %} — that tag is layout-only`);
@@ -352,5 +493,64 @@ export async function validateArtifact(files) {
352
493
  errors.push('assets/theme.css missing or empty — a template must ship its look');
353
494
  }
354
495
 
355
- return { errors, warnings, manifest };
496
+ // 6b. Layout contract (contract/v1/layout.json). Platform pages render through the content SEAM
497
+ // (.container / .full); a template STYLES those to place content, never a parallel content container.
498
+ // The rule is DATA — the seam token, the allowed selectors and the message all come from the contract
499
+ // file; this only implements the check KIND (a non-seam selector sizing its width off the token).
500
+ {
501
+ const css = has('assets/theme.css') ? read('assets/theme.css') : '';
502
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ' '); // drop comments so an example can't trip it
503
+ const RULE = /([^{}]+)\{([^{}]*)\}/g;
504
+ for (const rule of layoutContract.rules ?? []) {
505
+ if (rule.kind !== 'css-width-off-token') continue;
506
+ const token = rule.token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
507
+ const sizesOffToken = new RegExp(`\\b(?:min-|max-)?(?:width|inline-size)\\s*:[^;]*var\\(\\s*${token}\\b`);
508
+ const isSeam = new RegExp(`\\.(?:${rule.allow.join('|')})(?![\\w-])`);
509
+ const offenders = new Set();
510
+ let m;
511
+ RULE.lastIndex = 0;
512
+ while ((m = RULE.exec(stripped)) !== null) {
513
+ if (!sizesOffToken.test(m[2])) continue; // only rules that size a box off the token
514
+ for (const sel of m[1].split(',')) {
515
+ const s = sel.trim();
516
+ if (s && !isSeam.test(s)) offenders.add(s);
517
+ }
518
+ }
519
+ for (const sel of [...offenders].sort()) {
520
+ errors.push(`theme: selector '${sel}' ${rule.message}`);
521
+ }
522
+ }
523
+ }
524
+
525
+ // Open enums, proven survivable (content model v1 discipline 2): a template whose footprint
526
+ // reads site.content.events must survive a registration mode it has never heard of — new modes
527
+ // WILL arrive within the major. No throw, and no undefined/null literal leaking into markup.
528
+ if (contentAnalysis.footprint.includes('content.events')) {
529
+ const futureEvent = {
530
+ ...(siteFx.content.events[0] ?? {}),
531
+ id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
532
+ name: 'Open Enum Probe Event',
533
+ registrationMode: 'X_FUTURE_MODE',
534
+ detailHref: '/events?event=ffffffff-ffff-4fff-8fff-ffffffffffff'
535
+ };
536
+ const doctored = { ...siteFx, content: { ...siteFx.content, events: [...siteFx.content.events, futureEvent] } };
537
+ for (const type of manifest?.supports?.sections ?? []) {
538
+ const file = `sections/${type}.liquid`;
539
+ if (!has(file) || !/\bsite\s*[.[]/.test(read(file))) continue;
540
+ try {
541
+ const html = await liquid.render(liquid.parse(read(file)), {
542
+ section: catalogueByType.get(type)?.sample ?? {},
543
+ brand: contextContract.fixtures.brand,
544
+ site: doctored
545
+ });
546
+ if (/\bundefined\b|\bnull\b/.test(html.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
547
+ errors.push(`section '${type}': renders 'undefined'/'null' literals for an unknown event registrationMode — the enum is OPEN, branch on the modes you style and fall back for the rest`);
548
+ }
549
+ } catch (e) {
550
+ errors.push(`section '${type}': failed rendering an unknown event registrationMode — the enum is OPEN and new modes will arrive (${e.message})`);
551
+ }
552
+ }
553
+ }
554
+
555
+ return { errors, warnings, manifest, contentFootprint: contentAnalysis.footprint, minContentVersion: contentAnalysis.minModelVersion };
356
556
  }
@@ -1,4 +1,4 @@
1
- /* starter theme — a deliberately distinct look (midnight/emerald) proving the artifact owns the skin */
1
+ /* starter theme, a deliberately distinct look (midnight/emerald) proving the artifact owns the skin */
2
2
  :root:root {
3
3
  --bg: #0e1614;
4
4
  --bg-tint: #14201d;
@@ -39,13 +39,13 @@ strong { color: var(--ink); }
39
39
  .lq-cta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2.5rem; align-items: start; }
40
40
  @media (max-width: 900px) { .lq-cta-grid { grid-template-columns: 1fr; } }
41
41
 
42
- /* The one font slot — headings above already follow it; body text too. */
42
+ /* The one font slot, headings above already follow it; body text too. */
43
43
  body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-sans-serif, system-ui, sans-serif; }
44
44
 
45
- /* ── Home hero (supports.heroImagery — the reference treatment) ─────────────────────────────
45
+ /* ── Home hero (supports.heroImagery, the reference treatment) ─────────────────────────────
46
46
  The no-photo state is a DESIGNED gradient (rule: derive or omit, never a placeholder image).
47
47
  Photos get a scrim built from the palette itself via color-mix, so uploads tone into the
48
- template — and any palette change re-tones them for free. Never place a hero photo raw. */
48
+ template, and any palette change re-tones them for free. Never place a hero photo raw. */
49
49
  .lq-homehero {
50
50
  position: relative;
51
51
  padding: 4.5rem 0;
@@ -73,7 +73,7 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
73
73
  .lq-homehero-lead { color: var(--muted); max-width: 46ch; margin: 0.9rem 0 1.4rem; }
74
74
  .lq-homehero-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; }
75
75
 
76
- /* The platform hero_carousel island, styled through its class API — the island owns motion
76
+ /* The platform hero_carousel island, styled through its class API, the island owns motion
77
77
  (auto-advance, swipe, reduced-motion), this owns the look. Each slide's .hero-slide-scrim is
78
78
  the same palette treatment as a single photo. */
79
79
  .lq-homehero--carousel { overflow: hidden; }
@@ -114,7 +114,7 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
114
114
  }
115
115
  .lq-homehero--carousel .hero-dot--active { background: var(--accent); }
116
116
 
117
- /* ── Vista framing (photoFraming 'whole') — the hero adapts to the PHOTO's shape ───────────── */
117
+ /* ── Vista framing (photoFraming 'whole'), the hero adapts to the PHOTO's shape ───────────── */
118
118
  .lq-homehero--vista { padding-top: 0; }
119
119
  .lq-vista { position: relative; overflow: hidden; }
120
120
  .lq-vista-sizer { display: block; width: 100%; height: auto; max-height: 74vh; object-fit: cover; }
@@ -153,7 +153,7 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
153
153
  .lq-vista-sizer { min-height: 160px; }
154
154
  }
155
155
 
156
- /* Vista + form (1.5.2): the widget rides the seam — grid pulled up over the band (under the
156
+ /* Vista + form (1.5.2): the widget rides the seam, grid pulled up over the band (under the
157
157
  280px floor), gradient filling where the image ends; narrow screens stack back underneath. */
158
158
  .lq-homehero--vista-form { padding-bottom: 2.6rem; }
159
159
  .lq-vista-overlap { position: relative; z-index: 1; margin-top: -240px; align-items: start; }
@@ -161,7 +161,7 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
161
161
  .lq-vista-overlap { margin-top: 0; padding-top: 1.6rem; }
162
162
  }
163
163
 
164
- /* People / team cards — grid to single column on phones. */
164
+ /* People / team cards, grid to single column on phones. */
165
165
  .lq-people {
166
166
  display: grid;
167
167
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
@@ -187,3 +187,49 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
187
187
  @media (max-width: 640px) {
188
188
  .lq-people { grid-template-columns: 1fr; }
189
189
  }
190
+
191
+ /* Campaigns, the same quiet rail as values: title, summary, one action. */
192
+ .lq-campaigns { padding: 3.5rem 0; }
193
+ .lq-campaign-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1.6rem; max-width: 700px; }
194
+ .lq-campaign h3 { margin: 0 0 0.25rem; }
195
+ .lq-campaign p { margin: 0 0 0.6rem; }
196
+
197
+ /* Behaviours (engine-wired via data-p60-*, docs: Motion and behaviour guide). Everything that
198
+ hides pre-behaviour content is .p60-js-scoped: the no-JS render stays complete. */
199
+
200
+ /* Reveal: the values rail rises in with a stagger. */
201
+ .p60-js .lq-value-rail li {
202
+ opacity: 0;
203
+ transform: translateY(0.6rem);
204
+ transition: opacity 480ms ease, transform 480ms ease;
205
+ transition-delay: calc(var(--p60-reveal-index, 0) * 90ms);
206
+ }
207
+ .p60-js .lq-value-rail.is-revealed li { opacity: 1; transform: none; }
208
+ @media (prefers-reduced-motion: reduce) {
209
+ .p60-js .lq-value-rail li { opacity: 1; transform: none; transition: none; }
210
+ }
211
+
212
+ /* Behaviour carousel: the standard hero's photo stack (the vista framings use the island). */
213
+ .lq-hero-slides { position: absolute; inset: 0; overflow: hidden; }
214
+ .lq-hero-slide { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
215
+ .p60-js .lq-hero-slide { opacity: 0; transition: opacity 700ms ease; }
216
+ .p60-js .lq-hero-slide.is-active { opacity: 1; }
217
+ .lq-hero-slides .p60-carousel-dots {
218
+ position: absolute;
219
+ inset-inline: 0;
220
+ bottom: 0.9rem;
221
+ display: flex;
222
+ justify-content: center;
223
+ gap: 0.45rem;
224
+ z-index: 2;
225
+ }
226
+ .lq-hero-slides .p60-carousel-dot {
227
+ width: 0.55rem;
228
+ height: 0.55rem;
229
+ border-radius: 50%;
230
+ border: 1px solid rgba(255, 255, 255, 0.9);
231
+ background: transparent;
232
+ padding: 0;
233
+ cursor: pointer;
234
+ }
235
+ .lq-hero-slides .p60-carousel-dot--active { background: rgba(255, 255, 255, 0.95); }
@@ -1,22 +1,21 @@
1
1
  {% comment %}
2
- The starter's chrome — header/nav/footer around the {% content %} slot. It reuses the platform
2
+ The starter's chrome, header/nav/footer around the {% content %} slot. It reuses the platform
3
3
  chrome classes (.site-header/.site-nav/…) so the base mechanics (sticky header, burger
4
4
  breakpoint, dropdown hover) apply, and emits the documented behaviour attributes
5
5
  (data-nav-burger, #site-nav) so the platform tail script wires the mobile burger. Identity is
6
- PLACED, never rebuilt: {% island 'member_menu' %}. The footer derives everything from brand/nav —
7
- no fabricated copy (derive or omit).
6
+ PLACED, never rebuilt: {% island 'member_menu' %}. The footer derives everything from brand/nav, no fabricated copy (derive or omit).
8
7
  {% endcomment %}
9
8
  <header class="site-header">
10
9
  <div class="container header-inner">
11
10
  <a class="brand-lockup" href="/">
12
- {% if brand.logoUrl and brand.logoType != 'icon' %}
13
- <img class="brand-logo" src="{{ brand.logoUrl }}" alt="{{ brand.name }}">
14
- {% elsif brand.logoUrl %}
15
- <img class="brand-logo-icon" src="{{ brand.logoUrl }}" alt="">
16
- <strong>{{ brand.name }}</strong>
11
+ {% if site.brand.logoUrl and site.brand.logoType != 'icon' %}
12
+ <img class="brand-logo" src="{{ site.brand.logoUrl }}" alt="{{ site.brand.name }}">
13
+ {% elsif site.brand.logoUrl %}
14
+ <img class="brand-logo-icon" src="{{ site.brand.logoUrl }}" alt="">
15
+ <strong>{{ site.brand.name }}</strong>
17
16
  {% else %}
18
17
  <span class="brand-mark" aria-hidden="true">♥</span>
19
- <strong>{{ brand.name }}</strong>
18
+ <strong>{{ site.brand.name }}</strong>
20
19
  {% endif %}
21
20
  </a>
22
21
 
@@ -27,7 +26,7 @@
27
26
  </button>
28
27
 
29
28
  <nav class="site-nav" id="site-nav" aria-label="Primary">
30
- {% for item in nav.items %}
29
+ {% for item in site.nav.items %}
31
30
  {% if item.children %}
32
31
  <span class="nav-dropdown">
33
32
  <a href="{{ item.href }}" class="nav-dropdown-toggle" aria-haspopup="true">{{ item.label }} ▾</a>
@@ -55,20 +54,20 @@
55
54
  <footer class="site-footer">
56
55
  <div class="container footer-grid">
57
56
  <div>
58
- <p class="lq-kicker">{{ brand.name }}</p>
59
- {% if brand.tagline %}<h2>{{ brand.tagline }}</h2>{% endif %}
57
+ <p class="lq-kicker">{{ site.brand.name }}</p>
58
+ {% if site.brand.tagline %}<h2>{{ site.brand.tagline }}</h2>{% endif %}
60
59
  </div>
61
60
  <div class="footer-links">
62
- {% comment %} A tenant-curated footer menu when they've built one, else the main nav. {% endcomment %}
63
- {% if nav.footer.size > 0 %}
64
- {% for item in nav.footer %}
61
+ {% comment %} A tenant-curated footer menu when they've built one, else the main site.nav. {% endcomment %}
62
+ {% if site.nav.footer.size > 0 %}
63
+ {% for item in site.nav.footer %}
65
64
  <a href="{{ item.href }}"{% if item.external %} target="_blank" rel="noopener"{% endif %}>{{ item.label }}</a>
66
65
  {% for child in item.children %}
67
66
  <a href="{{ child.href }}"{% if child.external %} target="_blank" rel="noopener"{% endif %}>{{ child.label }}</a>
68
67
  {% endfor %}
69
68
  {% endfor %}
70
69
  {% else %}
71
- {% for item in nav.items %}
70
+ {% for item in site.nav.items %}
72
71
  <a href="{{ item.href }}">{{ item.label }}</a>
73
72
  {% endfor %}
74
73
  {% endif %}
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "starter",
3
- "version": "1.6.0",
3
+ "version": "1.9.0",
4
4
  "format": "port60-liquid@1",
5
5
  "label": "Starter",
6
- "description": "The reference template for the Port60 dialect — the developer docs' worked example and the base to copy when building your own. 1.1.0 adds the layout slot: the template owns the header, navigation and footer chrome.",
6
+ "description": "The reference template for the Port60 dialect, the developer docs' worked example and the base to copy when building your own. 1.1.0 adds the layout slot: the template owns the header, navigation and footer chrome.",
7
7
  "supports": {
8
8
  "pages": [
9
9
  "home",
@@ -14,7 +14,8 @@
14
14
  "hero",
15
15
  "values",
16
16
  "cta",
17
- "people"
17
+ "people",
18
+ "campaigns"
18
19
  ],
19
20
  "islands": [
20
21
  "donation_widget",
@@ -22,7 +23,11 @@
22
23
  "hero_carousel"
23
24
  ],
24
25
  "layout": true,
25
- "heroImagery": true
26
+ "heroImagery": true,
27
+ "behaviors": [
28
+ "reveal",
29
+ "carousel"
30
+ ]
26
31
  },
27
32
  "settings": {
28
33
  "schema": [
@@ -41,7 +46,7 @@
41
46
  }
42
47
  ]
43
48
  },
44
- "changelog": "Versionless source workflow — no content changes.",
49
+ "changelog": "Demonstrates the behaviour catalogue: the values rail reveals with a stagger and a multi-photo standard hero becomes an engine-wired carousel.",
45
50
  "imagery": {
46
51
  "hero": {
47
52
  "idealAspect": "16:9",