@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
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "The content-section catalogue — the closed set of section types a page may hold and a template may render. Section type = content KIND (coarse); layout/variant is the template's job. Additive-only: types and fields are added, never removed or re-shaped, within a contract major. Every section is independently omittable — a template must degrade gracefully when any subset is absent.",
2
+ "description": "The content-section catalogue, the closed set of section types a page may hold and a template may render. Section type = content KIND (coarse); layout/variant is the template's job. Additive-only: types and fields are added, never removed or re-shaped, within a contract major. Every section is independently omittable, a template must degrade gracefully when any subset is absent.",
3
3
  "sections": [
4
4
  {
5
5
  "type": "hero",
@@ -25,7 +25,7 @@
25
25
  "name": "bodyHtml",
26
26
  "kind": "richtext",
27
27
  "required": false,
28
- "description": "Sanitised HTML intro — render with the raw filter."
28
+ "description": "Sanitised HTML intro, render with the raw filter."
29
29
  }
30
30
  ],
31
31
  "minimal": {
@@ -33,8 +33,8 @@
33
33
  },
34
34
  "sample": {
35
35
  "eyebrow": "About us",
36
- "title": "Generosity, put to work.",
37
- "bodyHtml": "<p>We began in 2019 around a simple belief: small, regular acts of kindness, pooled together, change lives.</p>"
36
+ "title": "Neighbours, looked after.",
37
+ "bodyHtml": "<p>The Lantern Trust began in 2011 with a soup pot, a borrowed hall and a belief that nobody in Alderford should face a hard winter alone. Today we run a food bank, a youth club, an advice service and a community garden, powered by more than a hundred volunteers.</p>"
38
38
  }
39
39
  },
40
40
  {
@@ -97,16 +97,16 @@
97
97
  "heading": "Our values",
98
98
  "items": [
99
99
  {
100
- "title": "Transparency",
101
- "description": "Every pound is tracked and reported."
100
+ "title": "Openness",
101
+ "description": "Every pound is tracked, reported and published in our annual accounts."
102
102
  },
103
103
  {
104
104
  "title": "Dignity",
105
- "description": "We work alongside communities, not for them."
105
+ "description": "No referrals, no means tests at the door. If you say you need help, that is enough."
106
106
  },
107
107
  {
108
- "title": "Impact",
109
- "description": "We fund what works and measure the difference."
108
+ "title": "Neighbourliness",
109
+ "description": "We work alongside people, not for them. Most of our volunteers first came to us for help."
110
110
  }
111
111
  ]
112
112
  }
@@ -142,7 +142,7 @@
142
142
  "name": "buttonHref",
143
143
  "kind": "url",
144
144
  "required": true,
145
- "description": "The button's destination (scheme-guarded on save). The editor's cause picker writes /donate/{slug} here and records causeId beside it (editor bookkeeping — renderers use buttonHref only and may ignore causeId)."
145
+ "description": "The button's destination (scheme-guarded on save). The editor's cause picker writes /donate/{slug} here and records causeId beside it (editor bookkeeping, renderers use buttonHref only and may ignore causeId)."
146
146
  }
147
147
  ],
148
148
  "minimal": {
@@ -151,8 +151,8 @@
151
151
  "buttonHref": "/donate"
152
152
  },
153
153
  "sample": {
154
- "heading": "Ready to help?",
155
- "text": "It takes less than a minute.",
154
+ "heading": "Be somebody's good news today",
155
+ "text": "A monthly gift keeps the shelves stocked and the hall warm.",
156
156
  "buttonLabel": "Donate now",
157
157
  "buttonHref": "/#donate"
158
158
  }
@@ -215,13 +215,13 @@
215
215
  "form",
216
216
  "button"
217
217
  ],
218
- "description": "How this hero offers giving: 'form' (default) places the donation widget beside the copy; 'button' renders copy only — the primary button becomes the giving path (defaulting to /donate). Templates MUST render both."
218
+ "description": "How this hero offers giving: 'form' (default) places the donation widget beside the copy; 'button' renders copy only, the primary button becomes the giving path (defaulting to /donate). Templates MUST render both."
219
219
  },
220
220
  {
221
221
  "name": "imageUrl",
222
222
  "kind": "image",
223
223
  "required": false,
224
- "description": "Background photograph for the hero (uploaded through the platform pipeline). Superseded by `images` when that carries any entry — render imageUrl only when images is absent/empty. The template must TREAT the photo (scrim/tint from its own palette variables), never place it raw, and omit for its designed no-photo state."
224
+ "description": "Background photograph for the hero (uploaded through the platform pipeline). Superseded by `images` when that carries any entry, render imageUrl only when images is absent/empty. The template must TREAT the photo (scrim/tint from its own palette variables), never place it raw, and omit for its designed no-photo state."
225
225
  },
226
226
  {
227
227
  "name": "photoFraming",
@@ -231,13 +231,13 @@
231
231
  "fill",
232
232
  "whole"
233
233
  ],
234
- "description": "How hero photographs meet the hero. 'fill' (default): the hero keeps its designed shape and photos crop to fill it (use each photo's focus to pick the surviving slice). 'whole': the hero adapts toward the photo's own shape (height-clamped) so the FULL scene stays visible — the vista treatment for wide landscape photographs; templates overlay or stack their copy accordingly."
234
+ "description": "How hero photographs meet the hero. 'fill' (default): the hero keeps its designed shape and photos crop to fill it (use each photo's focus to pick the surviving slice). 'whole': the hero adapts toward the photo's own shape (height-clamped) so the FULL scene stays visible, the vista treatment for wide landscape photographs; templates overlay or stack their copy accordingly."
235
235
  },
236
236
  {
237
237
  "name": "images",
238
238
  "kind": "items",
239
239
  "required": false,
240
- "description": "Hero photographs (0–6, uploaded through the platform pipeline). One = a main image; several = rotation material — a template with supports.heroImagery renders at least the first and MAY place the hero_carousel island (or a CSS scroll strip) for the rest. Like every field: absent means the template's designed no-photo state, never a placeholder.",
240
+ "description": "Hero photographs (0–6, uploaded through the platform pipeline). One = a main image; several = rotation material, a template with supports.heroImagery renders at least the first and MAY place the hero_carousel island (or a CSS scroll strip) for the rest. Like every field: absent means the template's designed no-photo state, never a placeholder.",
241
241
  "itemFields": [
242
242
  {
243
243
  "name": "imageUrl",
@@ -262,23 +262,27 @@
262
262
  "left",
263
263
  "right"
264
264
  ],
265
- "description": "Which part of the photograph survives the fill-crop (templates render photos cover-style, so an aspect mismatch crops — this picks the slice that stays). Maps to background-position/object-position; absent means centre."
265
+ "description": "Which part of the photograph survives the fill-crop (templates render photos cover-style, so an aspect mismatch crops, this picks the slice that stays). Maps to background-position/object-position; absent means centre."
266
266
  }
267
267
  ]
268
268
  }
269
269
  ],
270
270
  "minimal": {},
271
271
  "sample": {
272
- "lead": "We turn everyday generosity into practical help for the families who need it most.",
272
+ "lead": "We turn everyday generosity into food parcels, warm rooms and open doors across Alderford.",
273
273
  "givingStyle": "button",
274
274
  "images": [
275
275
  {
276
- "imageUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 90'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23355e4b'/%3E%3Cstop offset='1' stop-color='%23103528'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='160' height='90' fill='url(%23g)'/%3E%3Ccircle cx='118' cy='28' r='14' fill='%23baa769' opacity='.7'/%3E%3Ctext x='10' y='78' font-family='sans-serif' font-size='9' fill='%23ffffff' opacity='.75'%3EHero photo one%3C/text%3E%3C/svg%3E",
277
- "alt": "Volunteers serving meals at dusk"
276
+ "imageUrl": "p60fixture:hero/dusk",
277
+ "alt": "Volunteers serving supper as the light fades"
278
+ },
279
+ {
280
+ "imageUrl": "p60fixture:hero/river",
281
+ "alt": "The lantern walk along the River Aldern"
278
282
  },
279
283
  {
280
- "imageUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 90'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23445a78'/%3E%3Cstop offset='1' stop-color='%23141d2c'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='160' height='90' fill='url(%23g)'/%3E%3Ccircle cx='40' cy='30' r='12' fill='%23e8e2d4' opacity='.5'/%3E%3Ctext x='10' y='78' font-family='sans-serif' font-size='9' fill='%23ffffff' opacity='.75'%3EHero photo two%3C/text%3E%3C/svg%3E",
281
- "alt": "The community hall on open day"
284
+ "imageUrl": "p60fixture:hero/garden",
285
+ "alt": "Raised beds in the Foundry Lane community garden"
282
286
  }
283
287
  ]
284
288
  }
@@ -289,7 +293,7 @@
289
293
  "pages": [
290
294
  "home"
291
295
  ],
292
- "description": "What gifts make possible — optional intro plus icon/title/description cards. Claims must be truthful for the tenant.",
296
+ "description": "What gifts make possible, optional intro plus icon/title/description cards. Claims must be truthful for the tenant.",
293
297
  "fields": [
294
298
  {
295
299
  "name": "eyebrow",
@@ -325,7 +329,7 @@
325
329
  "name": "icon",
326
330
  "kind": "icon",
327
331
  "required": false,
328
- "description": "Legacy emoji/icon — superseded by imageUrl; render it only when imageUrl is absent."
332
+ "description": "Legacy emoji/icon, superseded by imageUrl; render it only when imageUrl is absent."
329
333
  },
330
334
  {
331
335
  "name": "title",
@@ -355,18 +359,18 @@
355
359
  "heading": "Real help, made of small acts",
356
360
  "items": [
357
361
  {
358
- "imageUrl": "https://assets.example/impact-water.jpg",
359
- "title": "£10 · Clean water",
360
- "description": "Safe drinking water for a family for a whole month."
362
+ "imageUrl": "p60fixture:impact/parcel",
363
+ "title": "£10 · A family food parcel",
364
+ "description": "Three days of meals, toiletries and a bag of fresh produce from the garden."
361
365
  },
362
366
  {
363
- "icon": "🍲",
364
- "title": "£25 · Warm meals",
365
- "description": "Fifteen hot meals served at a local community kitchen."
367
+ "icon": "🔥",
368
+ "title": "£25 · A warm week",
369
+ "description": "Heating top-ups for a household choosing between eating and heating."
366
370
  },
367
371
  {
368
- "title": "£50 · School kit",
369
- "description": "Books and supplies for two children for a full term."
372
+ "title": "£50 · A month of youth club",
373
+ "description": "Coaching, kit and hot food for a young person, four evenings a week."
370
374
  }
371
375
  ]
372
376
  }
@@ -379,7 +383,7 @@
379
383
  ],
380
384
  "kind": "widget",
381
385
  "island": "whats_on_strip",
382
- "description": "Upcoming info events (special days — no sign-up). Render the `infoEvents` context your way, or place {% island 'whats_on_strip' %} for the ready-made strip.",
386
+ "description": "Upcoming info events (special days, no sign-up). Render the `infoEvents` context your way, or place {% island 'whats_on_strip' %} for the ready-made strip.",
383
387
  "fields": [],
384
388
  "minimal": {},
385
389
  "sample": {},
@@ -395,7 +399,7 @@
395
399
  ],
396
400
  "kind": "widget",
397
401
  "island": "events_carousel",
398
- "description": "Upcoming ticketed events on the home page. Your template renders the `events` context (the curated listing model) however its voice demands — or places {% island 'events_carousel' %} for the ready-made rotating showcase. Listing data only: ticket sales happen on the platform-rendered detail view (detailHref).",
402
+ "description": "Upcoming ticketed events on the home page. Your template renders the `events` context (the curated listing model) however its voice demands, or places {% island 'events_carousel' %} for the ready-made rotating showcase. Listing data only: ticket sales happen on the platform-rendered detail view (detailHref).",
399
403
  "fields": [],
400
404
  "minimal": {},
401
405
  "sample": {},
@@ -422,16 +426,40 @@
422
426
  {
423
427
  "type": "campaigns",
424
428
  "label": "Campaigns",
425
- "pages": ["home"],
429
+ "pages": [
430
+ "home"
431
+ ],
426
432
  "description": "The tenant's LIVE campaigns (appeals, petitions, surveys) as cards linking to each campaign's page. Render the `campaigns` context your way; render nothing when no campaign is live.",
427
- "context": ["campaigns", "campaignsLabel"],
433
+ "context": [
434
+ "campaigns",
435
+ "campaignsLabel"
436
+ ],
428
437
  "fields": [
429
- { "name": "eyebrow", "kind": "text", "required": false, "description": "Short kicker." },
430
- { "name": "heading", "kind": "text", "required": false, "description": "The section heading; fall back to the campaignsLabel context when unset." },
431
- { "name": "text", "kind": "text", "required": false, "description": "Optional introduction." }
438
+ {
439
+ "name": "eyebrow",
440
+ "kind": "text",
441
+ "required": false,
442
+ "description": "Short kicker."
443
+ },
444
+ {
445
+ "name": "heading",
446
+ "kind": "text",
447
+ "required": false,
448
+ "description": "The section heading; fall back to the campaignsLabel context when unset."
449
+ },
450
+ {
451
+ "name": "text",
452
+ "kind": "text",
453
+ "required": false,
454
+ "description": "Optional introduction."
455
+ }
432
456
  ],
433
457
  "minimal": {},
434
- "sample": { "eyebrow": "Get involved", "heading": "Our campaigns", "text": "What we are asking for right now." }
458
+ "sample": {
459
+ "eyebrow": "Get involved",
460
+ "heading": "What we are campaigning for",
461
+ "text": "The changes Alderford is asking for right now, and how to add your voice."
462
+ }
435
463
  },
436
464
  {
437
465
  "type": "emergency",
@@ -439,7 +467,7 @@
439
467
  "pages": [
440
468
  "home"
441
469
  ],
442
- "description": "A high-urgency full-width strip — one headline, an optional line, one action. For live emergencies and time-critical appeals.",
470
+ "description": "A high-urgency full-width strip, one headline, an optional line, one action. For live emergencies and time-critical appeals.",
443
471
  "context": [
444
472
  "causes"
445
473
  ],
@@ -473,9 +501,9 @@
473
501
  "heading": "Emergency appeal"
474
502
  },
475
503
  "sample": {
476
- "heading": "Gaza emergency appeal",
477
- "text": "Families need food, water and shelter right now.",
478
- "buttonLabel": "Give emergency aid",
504
+ "heading": "Flood response appeal",
505
+ "text": "The Aldern broke its banks on Sunday. Thirty households need dry bedding and warmth now.",
506
+ "buttonLabel": "Give to the flood response",
479
507
  "buttonHref": "/donate"
480
508
  }
481
509
  },
@@ -485,7 +513,7 @@
485
513
  "pages": [
486
514
  "home"
487
515
  ],
488
- "description": "Big headline numbers — the at-a-glance impact band. Values are author-entered text (write them exactly as they should read).",
516
+ "description": "Big headline numbers, the at-a-glance impact band. Values are author-entered text (write them exactly as they should read).",
489
517
  "fields": [
490
518
  {
491
519
  "name": "eyebrow",
@@ -533,16 +561,16 @@
533
561
  "heading": "What your giving did",
534
562
  "items": [
535
563
  {
536
- "value": "12,400",
537
- "label": "hot meals served"
564
+ "value": "9,200",
565
+ "label": "food parcels packed"
538
566
  },
539
567
  {
540
- "value": "3,100",
541
- "label": "families supported"
568
+ "value": "1,400",
569
+ "label": "warm space visits"
542
570
  },
543
571
  {
544
- "value": "98p",
545
- "label": "of every pound to the cause"
572
+ "value": "96p",
573
+ "label": "of every pound spent in Alderford"
546
574
  }
547
575
  ]
548
576
  }
@@ -553,7 +581,7 @@
553
581
  "pages": [
554
582
  "home"
555
583
  ],
556
- "description": "A grid of appeal/campaign cards — image, title, a line, and a per-card action.",
584
+ "description": "A grid of appeal/campaign cards, image, title, a line, and a per-card action.",
557
585
  "context": [
558
586
  "causes"
559
587
  ],
@@ -622,15 +650,15 @@
622
650
  "heading": "Where help is needed most",
623
651
  "items": [
624
652
  {
625
- "title": "Winter appeal",
626
- "text": "Warm meals and heating grants through the coldest months.",
653
+ "title": "Winter Appeal",
654
+ "text": "Heating top-ups, warm bedding and a hot meal through the coldest months.",
627
655
  "buttonLabel": "Support winter",
628
656
  "buttonHref": "/donate"
629
657
  },
630
658
  {
631
- "title": "Clean water",
632
- "text": "Wells and filtration for three villages.",
633
- "buttonLabel": "Give water",
659
+ "title": "Food Bank Fund",
660
+ "text": "Ten pounds fills a family parcel and keeps the depot shelves stocked.",
661
+ "buttonLabel": "Fill a parcel",
634
662
  "buttonHref": "/donate"
635
663
  }
636
664
  ]
@@ -669,65 +697,157 @@
669
697
  "minimal": {},
670
698
  "sample": {
671
699
  "eyebrow": "What we do",
672
- "heading": "Programmes for our community",
673
- "text": "Explore practical support, learning and activities."
700
+ "heading": "Support that meets you where you are",
701
+ "text": "Food, advice, activity and company: five programmes, one front door."
674
702
  }
675
703
  },
676
704
  {
677
705
  "type": "resources",
678
706
  "label": "Resource library",
679
- "pages": ["home"],
707
+ "pages": [
708
+ "home"
709
+ ],
680
710
  "description": "Public documents from the tenant's media library. The resources context contains PUBLIC documents only; private files and storage metadata never reach Liquid.",
681
- "context": ["resources"],
711
+ "context": [
712
+ "resources"
713
+ ],
682
714
  "fields": [
683
- { "name": "eyebrow", "kind": "text", "required": false, "description": "Short kicker." },
684
- { "name": "heading", "kind": "text", "required": false, "description": "The section heading." },
685
- { "name": "text", "kind": "text", "required": false, "description": "Optional introduction." }
715
+ {
716
+ "name": "eyebrow",
717
+ "kind": "text",
718
+ "required": false,
719
+ "description": "Short kicker."
720
+ },
721
+ {
722
+ "name": "heading",
723
+ "kind": "text",
724
+ "required": false,
725
+ "description": "The section heading."
726
+ },
727
+ {
728
+ "name": "text",
729
+ "kind": "text",
730
+ "required": false,
731
+ "description": "Optional introduction."
732
+ }
686
733
  ],
687
734
  "minimal": {},
688
- "sample": { "eyebrow": "Resources", "heading": "Useful documents", "text": "Policies, guides and downloads." }
735
+ "sample": {
736
+ "eyebrow": "Resources",
737
+ "heading": "Reports and documents",
738
+ "text": "Our accounts, policies and handbooks, all in one place."
739
+ }
689
740
  },
690
741
  {
691
742
  "type": "locations",
692
743
  "label": "Locations",
693
- "pages": ["home"],
744
+ "pages": [
745
+ "home"
746
+ ],
694
747
  "description": "The tenant's public venue directory with safe directions links. Provider references and private venue notes are never exposed.",
695
- "context": ["locations"],
748
+ "context": [
749
+ "locations"
750
+ ],
696
751
  "fields": [
697
- { "name": "eyebrow", "kind": "text", "required": false, "description": "Short kicker." },
698
- { "name": "heading", "kind": "text", "required": false, "description": "The section heading." },
699
- { "name": "text", "kind": "text", "required": false, "description": "Optional introduction." }
752
+ {
753
+ "name": "eyebrow",
754
+ "kind": "text",
755
+ "required": false,
756
+ "description": "Short kicker."
757
+ },
758
+ {
759
+ "name": "heading",
760
+ "kind": "text",
761
+ "required": false,
762
+ "description": "The section heading."
763
+ },
764
+ {
765
+ "name": "text",
766
+ "kind": "text",
767
+ "required": false,
768
+ "description": "Optional introduction."
769
+ }
700
770
  ],
701
771
  "minimal": {},
702
- "sample": { "eyebrow": "Find us", "heading": "Our locations", "text": "Visit the place nearest to you." }
772
+ "sample": {
773
+ "eyebrow": "Find us",
774
+ "heading": "Where to find us",
775
+ "text": "Two front doors in the middle of Alderford, both step-free."
776
+ }
703
777
  },
704
778
  {
705
779
  "type": "volunteering",
706
780
  "label": "Volunteering opportunities",
707
- "pages": ["home"],
781
+ "pages": [
782
+ "home"
783
+ ],
708
784
  "description": "Published opportunities from the volunteering module. Templates render the cards and place volunteer_signup for the platform-owned, privacy-recorded expression-of-interest flow.",
709
- "context": ["opportunities"],
785
+ "context": [
786
+ "opportunities"
787
+ ],
710
788
  "fields": [
711
- { "name": "eyebrow", "kind": "text", "required": false, "description": "Short kicker." },
712
- { "name": "heading", "kind": "text", "required": false, "description": "The section heading." },
713
- { "name": "text", "kind": "text", "required": false, "description": "Optional introduction." }
789
+ {
790
+ "name": "eyebrow",
791
+ "kind": "text",
792
+ "required": false,
793
+ "description": "Short kicker."
794
+ },
795
+ {
796
+ "name": "heading",
797
+ "kind": "text",
798
+ "required": false,
799
+ "description": "The section heading."
800
+ },
801
+ {
802
+ "name": "text",
803
+ "kind": "text",
804
+ "required": false,
805
+ "description": "Optional introduction."
806
+ }
714
807
  ],
715
808
  "minimal": {},
716
- "sample": { "eyebrow": "Get involved", "heading": "Volunteer with us", "text": "Find a role that suits your time and skills." }
809
+ "sample": {
810
+ "eyebrow": "Get involved",
811
+ "heading": "Volunteer with us",
812
+ "text": "Two hours or twenty, there is a role that fits the time you have."
813
+ }
717
814
  },
718
815
  {
719
816
  "type": "media",
720
817
  "label": "Audio, video and livestreams",
721
- "pages": ["home"],
818
+ "pages": [
819
+ "home"
820
+ ],
722
821
  "description": "Structured public audio, video and livestream entries with titles, speakers, dates and safe media links.",
723
- "context": ["media"],
822
+ "context": [
823
+ "media"
824
+ ],
724
825
  "fields": [
725
- { "name": "eyebrow", "kind": "text", "required": false, "description": "Short kicker." },
726
- { "name": "heading", "kind": "text", "required": false, "description": "The section heading." },
727
- { "name": "text", "kind": "text", "required": false, "description": "Optional introduction." }
826
+ {
827
+ "name": "eyebrow",
828
+ "kind": "text",
829
+ "required": false,
830
+ "description": "Short kicker."
831
+ },
832
+ {
833
+ "name": "heading",
834
+ "kind": "text",
835
+ "required": false,
836
+ "description": "The section heading."
837
+ },
838
+ {
839
+ "name": "text",
840
+ "kind": "text",
841
+ "required": false,
842
+ "description": "Optional introduction."
843
+ }
728
844
  ],
729
845
  "minimal": {},
730
- "sample": { "eyebrow": "Media", "heading": "Watch and listen", "text": "Talks, updates and live broadcasts." }
846
+ "sample": {
847
+ "eyebrow": "Media",
848
+ "heading": "Watch and listen",
849
+ "text": "Films, talks and the occasional kitchen soundscape from around the Trust."
850
+ }
731
851
  },
732
852
  {
733
853
  "type": "people",
@@ -735,7 +855,7 @@
735
855
  "pages": [
736
856
  "about"
737
857
  ],
738
- "description": "The key people behind the charity — trustees, staff, volunteers — as simple cards: name, position, an optional photograph (uploaded through the platform pipeline) and an optional one-line statement. Templates render a card grid that collapses to a single column on small screens; a card without a photo renders cleanly (initials or plain), never a placeholder face.",
858
+ "description": "The key people behind the charity, trustees, staff, volunteers, as simple cards: name, position, an optional photograph (uploaded through the platform pipeline) and an optional one-line statement. Templates render a card grid that collapses to a single column on small screens; a card without a photo renders cleanly (initials or plain), never a placeholder face.",
739
859
  "fields": [
740
860
  {
741
861
  "name": "eyebrow",
@@ -777,7 +897,7 @@
777
897
  "name": "imageUrl",
778
898
  "kind": "image",
779
899
  "required": false,
780
- "description": "A photograph (platform pipeline). Absent renders cleanly — never a placeholder face."
900
+ "description": "A photograph (platform pipeline). Absent renders cleanly, never a placeholder face."
781
901
  },
782
902
  {
783
903
  "name": "statement",
@@ -799,25 +919,61 @@
799
919
  "sample": {
800
920
  "eyebrow": "Who we are",
801
921
  "heading": "The people behind the work",
802
- "text": "A small team and a lot of volunteers, accountable to you.",
922
+ "text": "A small staff team, a board of local trustees, and more than a hundred volunteers, accountable to you.",
803
923
  "items": [
804
924
  {
805
- "name": "Amina Khan",
925
+ "name": "Priya Sharma",
806
926
  "role": "Chair of Trustees",
807
- "imageUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' fill='%23355e4b'/%3E%3Ccircle cx='48' cy='38' r='16' fill='%23e8e2d4'/%3E%3Cpath d='M16 88c4-20 18-28 32-28s28 8 32 28z' fill='%23e8e2d4'/%3E%3C/svg%3E",
808
- "statement": "Every pound is a trust — we treat it that way."
927
+ "imageUrl": "p60fixture:people/priya/portrait",
928
+ "statement": "Every pound is a trust. We spend it like a neighbour is watching, because one is."
809
929
  },
810
930
  {
811
- "name": "Daniel Osei",
812
- "role": "Treasurer"
931
+ "name": "Derek Osei",
932
+ "role": "Food Bank Lead",
933
+ "imageUrl": "p60fixture:people/derek/portrait"
813
934
  },
814
935
  {
815
936
  "name": "Sara Malik",
816
937
  "role": "Volunteer Coordinator",
817
- "statement": "Fifty pairs of hands, one purpose."
938
+ "statement": "A hundred pairs of hands, one purpose."
818
939
  }
819
940
  ]
820
941
  }
942
+ },
943
+ {
944
+ "type": "impactMap",
945
+ "label": "Impact map",
946
+ "pages": [
947
+ "home"
948
+ ],
949
+ "description": "The organisation's impact map: a themed map whose points link existing content (campaigns, causes, articles, events, places), each opening a card derived from the linked thing. Place the `map` island, it brings the map, the points and the cards; your template brings placement and tokens (the island theming contract).",
950
+ "context": [],
951
+ "fields": [
952
+ {
953
+ "name": "eyebrow",
954
+ "kind": "text",
955
+ "required": false,
956
+ "description": "Short kicker."
957
+ },
958
+ {
959
+ "name": "heading",
960
+ "kind": "text",
961
+ "required": false,
962
+ "description": "The section heading."
963
+ },
964
+ {
965
+ "name": "text",
966
+ "kind": "text",
967
+ "required": false,
968
+ "description": "Optional introduction."
969
+ }
970
+ ],
971
+ "minimal": {},
972
+ "sample": {
973
+ "eyebrow": "Where we work",
974
+ "heading": "Our impact, on the map",
975
+ "text": "Every marker is a real place: a programme, an appeal, a story. Click one to see what happens there."
976
+ }
821
977
  }
822
978
  ]
823
979
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "The CSS custom-property contract — the theming tokens the platform base declares and every island/page consumes. A template overrides them via :root:root in its theme.css. Additive-only: tokens are never removed or repurposed within a contract major.",
2
+ "description": "The CSS custom-property contract, the theming tokens the platform base declares and every island/page consumes. A template overrides them via :root:root in its theme.css. Additive-only: tokens are never removed or repurposed within a contract major.",
3
3
  "tokens": [
4
4
  { "name": "--bg", "description": "Page background." },
5
5
  { "name": "--bg-tint", "description": "Subtle tinted background (footer, wells)." },
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "FROZEN compatibility baseline for port60-liquid@1 (8.6). tests/contract-freeze.test.mjs enforces that everything here still exists — removals/narrowings fail. Regenerate ONLY deliberately via scripts/contract-lock.mjs; the diff is the review.",
2
+ "_comment": "FROZEN compatibility baseline for port60-liquid@1 (8.6). tests/contract-freeze.test.mjs enforces that everything here still exists, removals/narrowings fail. Regenerate ONLY deliberately via scripts/contract-lock.mjs; the diff is the review.",
3
3
  "format": "port60-liquid@1",
4
4
  "dialect": {
5
5
  "tags": [