@port60/template-kit 0.9.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 (39) 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 +6 -6
  6. package/src/commands/dev.mjs +97 -9
  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 +47 -19
  13. package/src/vendor/contract/v1/behaviours.json +4 -4
  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 +1667 -275
  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 -235
  21. package/src/vendor/contract/v1/layout.json +4 -4
  22. package/src/vendor/contract/v1/manifest.schema.json +9 -9
  23. package/src/vendor/contract/v1/sections.json +248 -92
  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/fixture-art.mjs +109 -0
  28. package/src/vendor/validator/model-reference.mjs +92 -0
  29. package/src/vendor/validator/platform-base.css +21 -0
  30. package/src/vendor/validator/preview.mjs +149 -56
  31. package/src/vendor/validator/site-context.mjs +97 -0
  32. package/src/vendor/validator/validate.mjs +71 -14
  33. package/starter/assets/theme.css +10 -10
  34. package/starter/layout.liquid +15 -16
  35. package/starter/manifest.json +2 -2
  36. package/starter/sections/campaigns.liquid +5 -5
  37. package/starter/sections/homeHero.liquid +11 -11
  38. package/starter/sections/people.liquid +1 -1
  39. package/starter/sections/values.liquid +1 -1
@@ -22,6 +22,7 @@ import contextContract from '../contract/v1/context.json' with { type: 'json' };
22
22
  import fontCatalogue from '../contract/v1/fonts.json' with { type: 'json' };
23
23
  import layoutContract from '../contract/v1/layout.json' with { type: 'json' };
24
24
  import behaviourCatalogue from '../contract/v1/behaviours.json' with { type: 'json' };
25
+ import { buildSiteFixture, extractContentFootprint, contentModel } from './site-context.mjs';
25
26
 
26
27
  const Ajv = Ajv2020.default ?? Ajv2020;
27
28
 
@@ -55,6 +56,18 @@ export async function validateArtifact(files) {
55
56
  } catch (e) {
56
57
  return { errors: [`manifest.json unreadable: ${e.message}`], warnings, manifest: null };
57
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
+
58
71
  const ajv = new Ajv({ allErrors: true });
59
72
  const validateManifest = ajv.compile(manifestSchema);
60
73
  if (!validateManifest(manifest)) {
@@ -212,15 +225,20 @@ export async function validateArtifact(files) {
212
225
  // the data itself — in which case both directions are proven behaviourally (the worship
213
226
  // pattern): the populated fixture's sentinel must appear, and the EMPTY context must render it
214
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;
215
233
  const WIDGET_SECTIONS = {
216
- events: { island: 'events_carousel', dataKey: 'events', sentinel: 'Fixture Lantern Gala' },
217
- whatsOn: { island: 'whats_on_strip', dataKey: 'infoEvents', sentinel: 'Fixture Open Morning' },
218
- articles: { island: 'latest_articles', dataKey: 'latestArticles', sentinel: 'Fixture Winter Diary' },
219
- campaigns: { island: null, dataKey: 'campaigns', sentinel: 'Fixture Winter Campaign' },
220
- resources: { island: null, dataKey: 'resources', sentinel: 'Fixture annual report.pdf' },
221
- locations: { island: null, dataKey: 'locations', sentinel: 'Fixture Community Hall' },
222
- volunteering: { island: 'volunteer_signup', dataKey: 'opportunities', sentinel: 'Fixture Garden Volunteer' },
223
- media: { island: null, dataKey: 'media', sentinel: 'Fixture Community Talk' }
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') }
224
242
  };
225
243
 
226
244
  // 2–4. Sections: catalogue membership, parse, fixture renders.
@@ -251,12 +269,13 @@ export async function validateArtifact(files) {
251
269
  const populated = await liquid.render(parsed, {
252
270
  section: {},
253
271
  brand: contextContract.fixtures.brand,
272
+ site: siteFx,
254
273
  ...dataFixture
255
274
  });
256
275
  const placesIsland = widget.island !== null
257
276
  && splitIslandParts(populated).some((p) => p.island === widget.island);
258
277
  if (!placesIsland) {
259
- if (!populated.includes(widget.sentinel)) {
278
+ if (widget.sentinel && !populated.includes(widget.sentinel)) {
260
279
  errors.push(
261
280
  widget.island
262
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`
@@ -266,9 +285,10 @@ export async function validateArtifact(files) {
266
285
  const empty = await liquid.render(parsed, {
267
286
  section: {},
268
287
  brand: contextContract.fixtures.brand,
288
+ site: { ...siteFx, content: Object.fromEntries(Object.keys(siteFx.content).map((k) => [k, []])) },
269
289
  [widget.dataKey]: []
270
290
  });
271
- if (empty.includes(widget.sentinel)) {
291
+ if (widget.sentinel && empty.includes(widget.sentinel)) {
272
292
  errors.push(`section '${type}': still shows fixture content with an empty ${widget.dataKey} — content must come from the context`);
273
293
  }
274
294
  if (/\bundefined\b|\bnull\b/.test(empty.replace(/data-[a-z-]+="[^"]*"/g, ''))) {
@@ -285,19 +305,23 @@ export async function validateArtifact(files) {
285
305
  const html = await liquid.render(parsed, {
286
306
  section: fixture,
287
307
  brand: contextContract.fixtures.brand,
308
+ site: siteFx,
288
309
  // Widget data rides the ordinary fixture renders too, so a hand-rendering section
289
310
  // doesn't fail the generic pass for want of its context.
290
311
  ...(widget ? (contextContract.fixtures.sections?.[type] ?? {}) : {})
291
312
  });
292
313
  if (type === 'homeHero' && fixtureName === 'sample') {
293
- 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:';
294
317
  homeHeroMultiShows = html.includes(probe)
295
318
  || splitIslandParts(html).some((p) => p.island === 'hero_carousel');
296
319
  // The single-photo path proven separately: same fixture, first photo only.
297
320
  try {
298
321
  const single = await liquid.render(parsed, {
299
322
  section: { ...fixture, images: (fixture.images ?? []).slice(0, 1) },
300
- brand: contextContract.fixtures.brand
323
+ brand: contextContract.fixtures.brand,
324
+ site: siteFx
301
325
  });
302
326
  homeHeroSingleShows = single.includes(probe);
303
327
  } catch {
@@ -359,6 +383,7 @@ export async function validateArtifact(files) {
359
383
  if (parsedLayout) {
360
384
  try {
361
385
  const html = await liquid.render(parsedLayout, {
386
+ site: siteFx,
362
387
  brand: contextContract.fixtures.brand,
363
388
  nav: contextContract.fixtures.layout.nav,
364
389
  socials: contextContract.fixtures.layout.socials ?? [],
@@ -394,10 +419,12 @@ export async function validateArtifact(files) {
394
419
  }
395
420
  if (manifest?.supports?.worship) {
396
421
  const nullHtml = await liquid.render(parsedLayout, {
422
+ site: siteFx,
397
423
  brand: contextContract.fixtures.brand,
398
424
  nav: contextContract.fixtures.layout.nav,
399
425
  socials: contextContract.fixtures.layout.socials ?? [],
400
426
  worship: null,
427
+ site: { ...siteFx, content: { ...siteFx.content, schedules: [] } },
401
428
  locale: contextContract.fixtures.layout.locale
402
429
  });
403
430
  if (nullHtml.includes(worshipProbe)) {
@@ -434,7 +461,7 @@ export async function validateArtifact(files) {
434
461
  continue;
435
462
  }
436
463
  try {
437
- 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 });
438
465
  for (const part of splitIslandParts(html)) {
439
466
  if (part.island === CONTENT_SLOT) {
440
467
  errors.push(`page template '${pageName}': uses {% content %} — that tag is layout-only`);
@@ -495,5 +522,35 @@ export async function validateArtifact(files) {
495
522
  }
496
523
  }
497
524
 
498
- return { errors, warnings, manifest };
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 };
499
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));
@@ -188,13 +188,13 @@ body { font-family: ' IBM Plex Sans Arabic', var(--p60s-siteFont, 'Inter'), ui-s
188
188
  .lq-people { grid-template-columns: 1fr; }
189
189
  }
190
190
 
191
- /* Campaigns — the same quiet rail as values: title, summary, one action. */
191
+ /* Campaigns, the same quiet rail as values: title, summary, one action. */
192
192
  .lq-campaigns { padding: 3.5rem 0; }
193
193
  .lq-campaign-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1.6rem; max-width: 700px; }
194
194
  .lq-campaign h3 { margin: 0 0 0.25rem; }
195
195
  .lq-campaign p { margin: 0 0 0.6rem; }
196
196
 
197
- /* Behaviours (engine-wired via data-p60-* — docs: Motion and behaviour guide). Everything that
197
+ /* Behaviours (engine-wired via data-p60-*, docs: Motion and behaviour guide). Everything that
198
198
  hides pre-behaviour content is .p60-js-scoped: the no-JS render stays complete. */
199
199
 
200
200
  /* Reveal: the values rail rises in with a stagger. */
@@ -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.8.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 \u2014 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",
@@ -1,16 +1,16 @@
1
1
  {% comment %}
2
- Live campaigns — the starter's plain rendering of the `campaigns` context, kept simple on
2
+ Live site.content.campaigns, the starter's plain rendering of the `site.content.campaigns` context, kept simple on
3
3
  purpose: this template is the worked example template authors copy from. The context is the
4
- platform's published campaigns; hrefs are platform-derived — always link via campaign.href.
4
+ platform's published site.content.campaigns; hrefs are platform-derived, always link via campaign.href.
5
5
  Derive or omit: nothing renders when no campaign is live.
6
6
  {% endcomment %}
7
- {% if campaigns.size > 0 %}
8
- <section class="lq-campaigns">
7
+ {% if site.content.campaigns.size > 0 %}
8
+ <section class="lq-site.content.campaigns">
9
9
  <div class="container">
10
10
  <h2>{{ section.heading | default: campaignsLabel }}</h2>
11
11
  {% if section.text %}<p>{{ section.text }}</p>{% endif %}
12
12
  <ul class="lq-campaign-list">
13
- {% for campaign in campaigns %}
13
+ {% for campaign in site.content.campaigns %}
14
14
  <li class="lq-campaign">
15
15
  <h3>{{ campaign.title }}</h3>
16
16
  {% if campaign.summary %}<p>{{ campaign.summary }}</p>{% endif %}
@@ -1,16 +1,16 @@
1
1
  {% comment %}
2
- The home opener — and the REFERENCE IMPLEMENTATION of supports.heroImagery. Rules worth
2
+ The home opener, and the REFERENCE IMPLEMENTATION of supports.heroImagery. Rules worth
3
3
  copying:
4
4
 
5
5
  1. EVERY field is optional. Copy derives from brand (| default) or omits; no photos means the
6
- designed gradient below — never a placeholder image.
6
+ designed gradient below, never a placeholder image.
7
7
  2. Photos are NEVER placed raw. The treatments in theme.css lay scrims built from the palette
8
8
  variables (color-mix over var(--bg)/var(--bg-tint)), so any uploaded photograph is toned
9
9
  into the template. Tune the treatment, don't remove it.
10
10
  3. Rotation is the PLATFORM's hero_carousel island (templates ship no JavaScript): with two or
11
11
  more photos, place it and style its documented classes.
12
12
  4. photoFraming is the charity's call, honour BOTH: 'fill' (default) covers your designed hero
13
- shape (per-photo focus picks the surviving slice); 'whole' is the VISTA — the hero adapts
13
+ shape (per-photo focus picks the surviving slice); 'whole' is the VISTA, the hero adapts
14
14
  toward the photo's own shape (height-clamped) so a wide landscape stays entirely visible,
15
15
  with your copy stacked after the band.
16
16
  {% endcomment %}
@@ -38,8 +38,8 @@
38
38
  {% if heroCount > 1 %}{% island 'hero_carousel' %}{% else %}<div class="lq-vista-scrim" aria-hidden="true"></div>{% endif %}
39
39
  <div class="lq-vista-copy">
40
40
  <div class="container">
41
- <p class="lq-kicker">{{ section.eyebrow | default: brand.name }}</p>
42
- <h1>{{ section.title | default: brand.tagline | default: brand.name }}</h1>
41
+ <p class="lq-kicker">{{ section.eyebrow | default: site.brand.name }}</p>
42
+ <h1>{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
43
43
  {% if section.lead %}<p class="lq-homehero-lead">{{ section.lead }}</p>{% endif %}
44
44
  <div class="lq-homehero-actions">
45
45
  <a class="button button-primary" href="{{ section.primaryHref | default: '/donate' }}">{{ section.primaryLabel | default: 'Donate now' }}</a>
@@ -50,7 +50,7 @@
50
50
  </div>
51
51
  </section>
52
52
  {% else %}
53
- {% comment %} Form mode: the grid pulls up over the band — copy on the photo, the widget riding
53
+ {% comment %} Form mode: the grid pulls up over the band, copy on the photo, the widget riding
54
54
  the seam onto the gradient below (never a stacked strip). {% endcomment %}
55
55
  <section class="lq-homehero lq-homehero--vista lq-homehero--vista-form{% if heroCount > 1 %} lq-homehero--carousel{% endif %}">
56
56
  <div class="lq-vista">
@@ -59,8 +59,8 @@
59
59
  </div>
60
60
  <div class="container lq-homehero-inner lq-vista-overlap">
61
61
  <div>
62
- <p class="lq-kicker">{{ section.eyebrow | default: brand.name }}</p>
63
- <h1>{{ section.title | default: brand.tagline | default: brand.name }}</h1>
62
+ <p class="lq-kicker">{{ section.eyebrow | default: site.brand.name }}</p>
63
+ <h1>{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
64
64
  {% if section.lead %}<p class="lq-homehero-lead">{{ section.lead }}</p>{% endif %}
65
65
  <div class="lq-homehero-actions">
66
66
  <a class="button button-primary" href="#donate">{{ section.primaryLabel | default: 'Donate now' }}</a>
@@ -76,7 +76,7 @@
76
76
  {% else %}
77
77
  <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 %}>
78
78
  {% comment %}
79
- Several photos → the BEHAVIOUR carousel (data-p60-carousel, engine-wired — the template ships
79
+ Several photos → the BEHAVIOUR carousel (data-p60-carousel, engine-wired, the template ships
80
80
  no JavaScript): complete static markup, one data-p60-slide per photo, auto-advance opt-in.
81
81
  Hiding non-active slides is .p60-js-scoped in theme.css so a no-JS visitor sees the photos
82
82
  stacked, never a blank hero. The vista framings above show the other route: the ready-made
@@ -93,8 +93,8 @@
93
93
  {% endif %}
94
94
  <div class="container lq-homehero-inner">
95
95
  <div>
96
- <p class="lq-kicker">{{ section.eyebrow | default: brand.name }}</p>
97
- <h1>{{ section.title | default: brand.tagline | default: brand.name }}</h1>
96
+ <p class="lq-kicker">{{ section.eyebrow | default: site.brand.name }}</p>
97
+ <h1>{{ section.title | default: site.brand.tagline | default: site.brand.name }}</h1>
98
98
  {% if section.lead %}<p class="lq-homehero-lead">{{ section.lead }}</p>{% endif %}
99
99
  <div class="lq-homehero-actions">
100
100
  {% if section.givingStyle == 'button' %}
@@ -1,5 +1,5 @@
1
1
  {% comment %}
2
- People / team — the reference implementation: simple cards (name, role, optional photo and
2
+ People / team, the reference implementation: simple cards (name, role, optional photo and
3
3
  one-line statement), a grid that collapses to one column on phones. No placeholder faces:
4
4
  a person without a photo just renders text (derive or omit, never fabricate).
5
5
  {% endcomment %}
@@ -3,7 +3,7 @@
3
3
  <h2>{{ section.heading | default: "What guides us" }}</h2>
4
4
  {% comment %}
5
5
  data-p60-reveal-group: the engine stamps --p60-reveal-index on each child and adds
6
- is-revealed when the rail scrolls into view — the stagger and motion are OURS, in theme.css,
6
+ is-revealed when the rail scrolls into view, the stagger and motion are OURS, in theme.css,
7
7
  and .p60-js-scoped so a no-JS visitor sees the rail complete and still.
8
8
  {% endcomment %}
9
9
  <ol class="lq-value-rail" data-p60-reveal-group>