@port60/template-kit 0.1.1 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@port60/template-kit",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Build Port60 site templates locally: scaffold, live-preview, validate against the platform contract, and package for studio upload. AI-agent ready — every scaffold ships AGENTS.md and validate emits machine-readable JSON.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -62,6 +62,12 @@ platform accepts it; if it fails here, the upload will fail identically.
62
62
  - Theme via CSS custom properties and the settings knobs you declare in
63
63
  \`manifest.settings.schema\` — they surface in the charity's Appearance editor as
64
64
  \`--p60s-<key>\` variables and \`data-p60s-<key>\` body attributes.
65
+ - Hero photographs (\`homeHero.images\`, when you declare \`supports.heroImagery\`): render one
66
+ photo directly as a TREATED backdrop (a scrim/tint built from your own palette variables via
67
+ color-mix — never raw), place the \`hero_carousel\` island for two or more (style its
68
+ \`.hero-slide-scrim\`), and design the no-photo state as a gradient/colour — never a
69
+ placeholder. The starter's \`.lq-homehero\` is the reference; the validator checks all of this
70
+ behaviourally.
65
71
  - "Looks" = named one-click bundles of knob values in \`manifest.looks\`.
66
72
  - Fonts: only families from the platform font catalogue, declared with the weights you use.
67
73
 
@@ -90,6 +90,23 @@
90
90
  ".course-pay-panel",
91
91
  ".course-soldout-note"
92
92
  ]
93
+ },
94
+ {
95
+ "name": "hero_carousel",
96
+ "label": "Hero image carousel",
97
+ "status": "available",
98
+ "description": "Rotation for the home hero's photographs (homeHero.images, 2+): auto-advance every ~6s, pauses on hover/focus, honours prefers-reduced-motion (no auto-advance), swipe on touch, dot navigation. The platform owns the motion; the TEMPLATE owns the look — including the tonal treatment: every slide ships an empty .hero-slide-scrim layer over its image, and the template colours it from its OWN palette variables (a colour wash, a blend, a gradient) so any uploaded photo sits inside the template's tones. Renders nothing with fewer than 2 images — single-image and no-image states are the template's own renderer, not this island's.",
99
+ "params": [],
100
+ "stylingApi": [
101
+ ".hero-carousel",
102
+ ".hero-slides",
103
+ ".hero-slide",
104
+ ".hero-slide-img",
105
+ ".hero-slide-scrim",
106
+ ".hero-dots",
107
+ ".hero-dot",
108
+ ".hero-dot--active"
109
+ ]
93
110
  }
94
111
  ]
95
- }
112
+ }
@@ -70,6 +70,11 @@
70
70
  "type": "boolean",
71
71
  "default": false,
72
72
  "description": "True when the layout renders the `worship` context (the prayer/service-times rail). Declared honesty: the choosers badge worship-enabled tenants toward looks that show their times, and warn on looks that don't. Requires supports.layout; the layout must actually branch on `worship`."
73
+ },
74
+ "heroImagery": {
75
+ "type": "boolean",
76
+ "default": false,
77
+ "description": "True when the homeHero renderer displays the tenant's hero photographs (homeHero.images, legacy imageUrl) — always under a TREATMENT built from the template's own palette variables (scrim/tint/blend), never raw. Declared honesty, proven behaviourally at validation: the choosers badge photo-led tenants toward templates that show their images and warn on ones that won't. Requires the homeHero section among supports.sections."
73
78
  }
74
79
  }
75
80
  },
@@ -221,13 +221,43 @@
221
221
  "name": "imageUrl",
222
222
  "kind": "image",
223
223
  "required": false,
224
- "description": "Background photograph for the hero (uploaded through the platform pipeline). The template lays a dark overlay under the headline; omit for the flat colour hero."
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
+ },
226
+ {
227
+ "name": "images",
228
+ "kind": "items",
229
+ "required": false,
230
+ "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.",
231
+ "itemFields": [
232
+ {
233
+ "name": "imageUrl",
234
+ "kind": "image",
235
+ "required": true,
236
+ "description": "The photograph."
237
+ },
238
+ {
239
+ "name": "alt",
240
+ "kind": "text",
241
+ "required": false,
242
+ "description": "Accessible description; empty renders as decorative (alt=\"\")."
243
+ }
244
+ ]
225
245
  }
226
246
  ],
227
247
  "minimal": {},
228
248
  "sample": {
229
249
  "lead": "We turn everyday generosity into practical help for the families who need it most.",
230
- "givingStyle": "button"
250
+ "givingStyle": "button",
251
+ "images": [
252
+ {
253
+ "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",
254
+ "alt": "Volunteers serving meals at dusk"
255
+ },
256
+ {
257
+ "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",
258
+ "alt": "The community hall on open day"
259
+ }
260
+ ]
231
261
  }
232
262
  },
233
263
  {
@@ -99,6 +99,9 @@ export async function validateArtifact(files) {
99
99
  const catalogueByType = new Map(sectionCatalogue.sections.map((s) => [s.type, s]));
100
100
  const declaredIslands = new Set(manifest?.supports?.islands ?? []);
101
101
  const placedIslands = new Set();
102
+ // heroImagery proof state (filled by the homeHero renders below).
103
+ let homeHeroMultiShows = null; // sample (several photos): probe in html OR hero_carousel placed
104
+ let homeHeroSingleShows = null; // single-photo variant: probe rendered directly
102
105
 
103
106
  // 2–4. Sections: catalogue membership, parse, fixture renders.
104
107
  for (const type of manifest?.supports?.sections ?? []) {
@@ -126,6 +129,21 @@ export async function validateArtifact(files) {
126
129
  section: fixture,
127
130
  brand: contextContract.fixtures.brand
128
131
  });
132
+ if (type === 'homeHero' && fixtureName === 'sample') {
133
+ const probe = 'Hero photo one';
134
+ homeHeroMultiShows = html.includes(probe)
135
+ || splitIslandParts(html).some((p) => p.island === 'hero_carousel');
136
+ // The single-photo path proven separately: same fixture, first photo only.
137
+ try {
138
+ const single = await liquid.render(parsed, {
139
+ section: { ...fixture, images: (fixture.images ?? []).slice(0, 1) },
140
+ brand: contextContract.fixtures.brand
141
+ });
142
+ homeHeroSingleShows = single.includes(probe);
143
+ } catch {
144
+ homeHeroSingleShows = false;
145
+ }
146
+ }
129
147
  for (const part of splitIslandParts(html)) {
130
148
  if (part.island === CONTENT_SLOT) {
131
149
  errors.push(`section '${type}': uses {% content %} — that tag is layout-only`);
@@ -139,6 +157,31 @@ export async function validateArtifact(files) {
139
157
  }
140
158
  }
141
159
 
160
+ // Hero-imagery honesty, checked BEHAVIOURALLY (the worship pattern). The homeHero sample
161
+ // fixture carries photographs whose data URIs embed a quote-free marker that survives HTML
162
+ // escaping, so "does the rendered hero display the tenant's photos?" is a substring check —
163
+ // and with several photos, placing the hero_carousel island IS displaying them (the island
164
+ // renders the slides at runtime). The single-photo path is proven separately: images[0] must
165
+ // appear directly. Declaration and behaviour must agree; the choosers badge photo-led tenants
166
+ // by supports.heroImagery. Legacy imageUrl-only renderers never match (the fixture's photos
167
+ // ride `images`), so they pass undeclared — they just don't earn the badge.
168
+ {
169
+ const declaresHero = manifest?.supports?.heroImagery === true;
170
+ if (declaresHero && !(manifest?.supports?.sections ?? []).includes('homeHero')) {
171
+ errors.push('manifest: supports.heroImagery requires the homeHero section — the photographs live on it');
172
+ } else if (homeHeroMultiShows !== null) {
173
+ if (declaresHero && !homeHeroMultiShows) {
174
+ errors.push('homeHero: manifest declares supports.heroImagery but the rendered section neither displays the images fixture nor places the hero_carousel island');
175
+ }
176
+ if (declaresHero && !homeHeroSingleShows) {
177
+ errors.push('homeHero: supports.heroImagery must render a SINGLE photograph directly (images[0], treated, never raw) — the carousel island only covers 2+');
178
+ }
179
+ if (!declaresHero && (homeHeroMultiShows || homeHeroSingleShows)) {
180
+ errors.push('homeHero: renders the hero photographs but the manifest does not declare supports.heroImagery — declare it so the choosers can badge it');
181
+ }
182
+ }
183
+ }
184
+
142
185
  // 7. Layout (when declared): parse + render the layout fixture + exactly one content slot.
143
186
  if (manifest?.supports?.worship && !manifest?.supports?.layout) {
144
187
  errors.push('manifest: supports.worship requires supports.layout — the worship rail is layout chrome');
@@ -41,3 +41,75 @@ strong { color: var(--ink); }
41
41
 
42
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
+
45
+ /* ── Home hero (supports.heroImagery — the reference treatment) ─────────────────────────────
46
+ The no-photo state is a DESIGNED gradient (rule: derive or omit, never a placeholder image).
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. */
49
+ .lq-homehero {
50
+ position: relative;
51
+ padding: 4.5rem 0;
52
+ background:
53
+ radial-gradient(720px 380px at 85% -10%, var(--primary-soft), transparent 60%),
54
+ linear-gradient(165deg, var(--bg-tint) 0%, var(--bg) 100%);
55
+ }
56
+ .lq-homehero--photo { background-size: cover; background-position: center; }
57
+ .lq-homehero--photo::before {
58
+ content: '';
59
+ position: absolute;
60
+ inset: 0;
61
+ background: linear-gradient(
62
+ 180deg,
63
+ color-mix(in srgb, var(--bg) 68%, transparent) 0%,
64
+ color-mix(in srgb, var(--bg) 38%, transparent) 50%,
65
+ color-mix(in srgb, var(--bg-tint) 72%, transparent) 100%
66
+ );
67
+ }
68
+ .lq-homehero > .container { position: relative; z-index: 1; }
69
+ .lq-homehero-inner { display: grid; gap: 2.5rem; align-items: center; }
70
+ @media (min-width: 900px) {
71
+ .lq-homehero-inner { grid-template-columns: 1.2fr 1fr; }
72
+ }
73
+ .lq-homehero-lead { color: var(--muted); max-width: 46ch; margin: 0.9rem 0 1.4rem; }
74
+ .lq-homehero-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; }
75
+
76
+ /* The platform hero_carousel island, styled through its class API — the island owns motion
77
+ (auto-advance, swipe, reduced-motion), this owns the look. Each slide's .hero-slide-scrim is
78
+ the same palette treatment as a single photo. */
79
+ .lq-homehero--carousel { overflow: hidden; }
80
+ .lq-homehero--carousel .hero-carousel { position: absolute; inset: 0; z-index: 0; }
81
+ .lq-homehero--carousel .hero-slides,
82
+ .lq-homehero--carousel .hero-slide { position: absolute; inset: 0; }
83
+ .lq-homehero--carousel .hero-slide { opacity: 0; transition: opacity 1s ease; }
84
+ .lq-homehero--carousel .hero-slide--active { opacity: 1; }
85
+ .lq-homehero--carousel .hero-slide-img { width: 100%; height: 100%; object-fit: cover; display: block; }
86
+ .lq-homehero--carousel .hero-slide-scrim {
87
+ position: absolute;
88
+ inset: 0;
89
+ background: linear-gradient(
90
+ 180deg,
91
+ color-mix(in srgb, var(--bg) 68%, transparent) 0%,
92
+ color-mix(in srgb, var(--bg) 38%, transparent) 50%,
93
+ color-mix(in srgb, var(--bg-tint) 72%, transparent) 100%
94
+ );
95
+ }
96
+ .lq-homehero--carousel .hero-dots {
97
+ position: absolute;
98
+ left: 0;
99
+ right: 0;
100
+ bottom: 0.8rem;
101
+ display: flex;
102
+ justify-content: center;
103
+ gap: 0.4rem;
104
+ z-index: 2;
105
+ }
106
+ .lq-homehero--carousel .hero-dot {
107
+ width: 9px;
108
+ height: 9px;
109
+ border-radius: 50%;
110
+ border: 1px solid var(--accent);
111
+ background: transparent;
112
+ padding: 0;
113
+ cursor: pointer;
114
+ }
115
+ .lq-homehero--carousel .hero-dot--active { background: var(--accent); }
@@ -1,23 +1,27 @@
1
1
  {
2
2
  "name": "starter",
3
- "version": "1.2.0",
3
+ "version": "1.3.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
+ "home",
9
10
  "about"
10
11
  ],
11
12
  "sections": [
13
+ "homeHero",
12
14
  "hero",
13
15
  "values",
14
16
  "cta"
15
17
  ],
16
18
  "islands": [
17
19
  "donation_widget",
18
- "member_menu"
20
+ "member_menu",
21
+ "hero_carousel"
19
22
  ],
20
- "layout": true
23
+ "layout": true,
24
+ "heroImagery": true
21
25
  },
22
26
  "settings": {
23
27
  "schema": [
@@ -36,5 +40,5 @@
36
40
  }
37
41
  ]
38
42
  },
39
- "changelog": "The reference font slot \u2014 one 'Site font' knob showing the var(--p60s-*) pattern."
43
+ "changelog": "Home hero with hero photographs: a treated single image or a rotating set, toned into the palette — the reference implementation of supports.heroImagery."
40
44
  }
@@ -0,0 +1,42 @@
1
+ {% comment %}
2
+ The home opener — and the REFERENCE IMPLEMENTATION of supports.heroImagery. Three rules worth
3
+ copying:
4
+
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.
7
+ 2. Photos are NEVER placed raw. The .lq-homehero--photo treatment in theme.css lays a scrim
8
+ built from the palette variables (color-mix over var(--bg)/var(--primary)), so any uploaded
9
+ photograph is toned into the template. Tune the treatment, don't remove it.
10
+ 3. Templates ship no JavaScript, so rotation is the PLATFORM's hero_carousel island: with two
11
+ or more photos, place it and style its documented classes (.hero-slide, .hero-slide-scrim,
12
+ .hero-dots) — the platform owns the motion, you own the look.
13
+ {% endcomment %}
14
+ {% assign heroCount = section.images | size %}
15
+ {% if heroCount == 1 %}
16
+ {% assign heroPhoto = section.images.first.imageUrl %}
17
+ {% elsif heroCount == 0 %}
18
+ {% assign heroPhoto = section.imageUrl %}
19
+ {% endif %}
20
+ <section class="lq-homehero{% if heroPhoto %} lq-homehero--photo{% endif %}{% if heroCount > 1 %} lq-homehero--carousel{% endif %}"{% if heroPhoto %} style="background-image: url('{{ heroPhoto }}')"{% endif %}>
21
+ {% if heroCount > 1 %}{% island 'hero_carousel' %}{% endif %}
22
+ <div class="container lq-homehero-inner">
23
+ <div>
24
+ <p class="lq-kicker">{{ section.eyebrow | default: brand.name }}</p>
25
+ <h1>{{ section.title | default: brand.tagline | default: brand.name }}</h1>
26
+ {% if section.lead %}<p class="lq-homehero-lead">{{ section.lead }}</p>{% endif %}
27
+ <div class="lq-homehero-actions">
28
+ {% if section.givingStyle == 'button' %}
29
+ <a class="button button-primary" href="{{ section.primaryHref | default: '/donate' }}">{{ section.primaryLabel | default: 'Donate now' }}</a>
30
+ {% else %}
31
+ <a class="button button-primary" href="#donate">{{ section.primaryLabel | default: 'Donate now' }}</a>
32
+ {% endif %}
33
+ <a class="button" href="{{ section.secondaryHref | default: '/about' }}">{{ section.secondaryLabel | default: 'Our story' }}</a>
34
+ </div>
35
+ </div>
36
+ {% unless section.givingStyle == 'button' %}
37
+ <aside id="donate">
38
+ {% island 'donation_widget' %}
39
+ </aside>
40
+ {% endunless %}
41
+ </div>
42
+ </section>