@port60/template-kit 0.17.0 → 0.18.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.17.0",
3
+ "version": "0.18.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": {
@@ -132,7 +132,9 @@ export async function dev(args) {
132
132
  fixtureImageBase,
133
133
  previewContent,
134
134
  contentImageOrigins: imageOriginsOf(previewContent),
135
- surface: surfaceFor(req.url ?? '/')
135
+ surface: surfaceFor(req.url ?? '/'),
136
+ // ?focus=donate|volunteer|none: what leads, so the hero shows each widget and its buttons.
137
+ focus: url.searchParams.get('focus') ?? undefined
136
138
  });
137
139
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
138
140
  res.end(html);
@@ -31,7 +31,7 @@
31
31
  "section",
32
32
  "layout"
33
33
  ],
34
- "description": "What leads on the site: 'donate' (default) or 'volunteer'. The charity's one switch (docs/volunteering.md 'Site focus'); everything below follows from it."
34
+ "description": "What the primary_action_widget island becomes: 'donate' (default, the donation widget), 'volunteer' (the volunteer sign-up) or 'none' (the charity chose no widget in the hero). The charity's one switch (docs/volunteering.md 'Site focus'); everything below follows from it."
35
35
  },
36
36
  {
37
37
  "path": "site.actions",
@@ -46,7 +46,7 @@
46
46
  "availableIn": [
47
47
  "section"
48
48
  ],
49
- "description": "homeHero only: the leading action { kind, label, href }, the same as site.actions.primary. Render it as a widget with {% island 'primary_action' %} when section.actionStyle is 'widget'."
49
+ "description": "homeHero only: the leading action { kind, label, href }, the same as site.actions.primary. Render it as a widget with {% island 'primary_action_widget' %} when section.actionStyle is 'widget'; that island becomes this action's widget."
50
50
  },
51
51
  {
52
52
  "path": "section.secondary",
@@ -283,10 +283,10 @@
283
283
  ]
284
284
  },
285
285
  {
286
- "name": "primary_action",
287
- "label": "Primary action",
286
+ "name": "primary_action_widget",
287
+ "label": "Primary action widget",
288
288
  "status": "available",
289
- "description": "The composite for the site focus: mounts the donation widget or the volunteer sign-up according to what the charity chose to lead with (site.focus), so a template places one static island and the choice moves without a variable name. Both widgets wear .action-card; .donate-card and .volunteer-card carry kind-specific treatment. A template that wants a different layout per kind branches on section.primary.kind and places donation_widget or volunteer_signup directly.",
289
+ "description": "The widget that leads the site. It IS the donation widget when the charity chose giving to lead, the volunteer sign-up when volunteering leads, and nothing when the charity chose no widget in the hero; site.focus tells you which ('donate', 'volunteer' or 'none'). Place {% island 'primary_action_widget' %} in the home hero's widget slot INSTEAD of donation_widget whenever your template declares supports.focus: the platform decides which widget renders, the template never does, and the charity's switch moves it without a template change. Both widgets wear .action-card for shared treatment; .donate-card and .volunteer-card carry kind-specific styling. A template that wants a different layout per kind may branch on section.primary.kind and place donation_widget or volunteer_signup itself, but then it owns the switch.",
290
290
  "params": [],
291
291
  "stylingApi": [
292
292
  ".action-card",
@@ -185,7 +185,7 @@
185
185
  "volunteer"
186
186
  ]
187
187
  },
188
- "description": "The site-focus kinds this template can LEAD with (docs/volunteering.md). Declaring 'volunteer' means the home hero places the composite primary_action island (or branches on section.primary.kind), so the volunteer sign-up can take the widget slot; the picker marks templates that cannot lead with volunteering. Templates that predate the setting keep rendering the donation widget, since donate is the default."
188
+ "description": "The site-focus kinds this template can LEAD with (docs/volunteering.md). Declaring 'volunteer' means the home hero places the primary_action_widget island (the widget that becomes the donation widget or the volunteer sign-up as the charity chooses) or branches on section.primary.kind, so the volunteer sign-up can take the widget slot; the picker marks templates that cannot lead with volunteering. Templates that predate the setting keep rendering the donation widget, since donate is the default."
189
189
  }
190
190
  }
191
191
  },
@@ -0,0 +1,49 @@
1
+ // The site focus for the PREVIEW: a JavaScript twin of charity-site's lib/focus.ts, kept in step by
2
+ // hand (the validator is plain JS and is vendored into the kit). Same rules: the leading action is
3
+ // the widget in the hero; the other action is the one button in the header and beside the hero; the
4
+ // leading action never appears twice. The preview assumes volunteering is OPEN, so the pairing shows.
5
+ export const FOCUS_CHOICES = ['donate', 'volunteer', 'none'];
6
+
7
+ const ACTION = {
8
+ donate: { kind: 'donate', label: 'Donate', href: '/donate' },
9
+ volunteer: { kind: 'volunteer', label: 'Volunteer', href: '/volunteer' }
10
+ };
11
+
12
+ /** `?focus=` from the dev server, or nothing: 'donate' is the platform default. */
13
+ export function normaliseFocus(value) {
14
+ return FOCUS_CHOICES.includes(value) ? value : 'donate';
15
+ }
16
+
17
+ /** The resolved surfaces for one preview render, the shape of site.actions on a live site. */
18
+ export function previewActions(focus) {
19
+ if (focus === 'volunteer') {
20
+ return { primary: ACTION.volunteer, secondary: ACTION.donate, widget: 'volunteer', header: ACTION.donate, hero: ACTION.donate };
21
+ }
22
+ if (focus === 'none') {
23
+ // Buttons only (a Custom setting with no widget): the hero button leads with giving.
24
+ return { primary: ACTION.donate, secondary: ACTION.volunteer, widget: null, header: ACTION.donate, hero: ACTION.donate };
25
+ }
26
+ return { primary: ACTION.donate, secondary: ACTION.volunteer, widget: 'donate', header: ACTION.volunteer, hero: ACTION.volunteer };
27
+ }
28
+
29
+ /** The home hero's resolved fields, as the platform computes them (lib/focus.ts withResolvedActions). */
30
+ export function withResolvedActions(content, actions) {
31
+ const style = content.actionStyle ?? content.givingStyle;
32
+ const buttonMode = style === 'button' || actions.widget === null;
33
+ return {
34
+ ...content,
35
+ actionStyle: buttonMode ? 'button' : 'widget',
36
+ primary: actions.primary,
37
+ secondary: actions.secondary,
38
+ action: buttonMode && actions.hero === null && style === 'button' ? actions.primary : actions.hero
39
+ };
40
+ }
41
+
42
+ /** The site tree with the focus applied: site.focus, site.actions, and the nav's one button. */
43
+ export function applyFocus(site, actions) {
44
+ const swapCta = (items) => Array.isArray(items)
45
+ ? items.map((item) => (item && item.cta ? (actions.header ? { ...item, label: actions.header.label, href: actions.header.href } : null) : item)).filter(Boolean)
46
+ : items;
47
+ const nav = site.nav ? { ...site.nav, items: swapCta(site.nav.items), derived: swapCta(site.nav.derived) } : site.nav;
48
+ return { ...site, focus: actions.widget ?? 'none', actions, nav };
49
+ }
@@ -17,6 +17,7 @@ import islandRegistry from '../contract/v1/islands.json' with { type: 'json' };
17
17
  import contextContract from '../contract/v1/context.json' with { type: 'json' };
18
18
  import { resolveFixtureArt } from './fixture-art.mjs';
19
19
  import { buildSiteFixture, applyPreviewContent } from './site-context.mjs';
20
+ import { normaliseFocus, previewActions, withResolvedActions, applyFocus } from './focus.mjs';
20
21
 
21
22
  // Fixture imagery resolved for the SEALED studio render (p60fixture: refs become inline-SVG data
22
23
  // URIs the network-dead CSP can show). The dev preview may instead resolve them to the platform
@@ -143,8 +144,16 @@ function islandSkeleton(name, ctx = {}, fx = STUDIO_FX) {
143
144
  <label class="newsletter-label">Email address</label><div class="newsletter-fields"><input class="newsletter-email" type="email" disabled><button class="newsletter-submit" type="button" disabled>Join our newsletter</button></div>
144
145
  <label class="newsletter-consent"><input type="checkbox" disabled><span>Email me about our work and appeals.</span></label>
145
146
  </form>`;
146
- case 'primary_action':
147
- return islandSkeleton('donation_widget', ctx, fx).replace('data-p60-preview-island="donation_widget"', 'data-p60-preview-island="primary_action"');
147
+ case 'primary_action_widget':
148
+ // What this island becomes follows the preview's focus (fixtures.focus, from ?focus= on the
149
+ // dev server): the volunteer sign-up, nothing, or the default, the donation widget.
150
+ if (fixtures.focus === 'volunteer') {
151
+ return islandSkeleton('volunteer_signup', ctx, fx).replace('data-p60-preview-island="volunteer_signup"', 'data-p60-preview-island="primary_action_widget"');
152
+ }
153
+ if (fixtures.focus === 'none') {
154
+ return `<div class="p60-preview-empty" data-p60-preview-island="primary_action_widget">${previewNote(name)}<p>No widget here: this charity leads with buttons only (site.focus is 'none').</p></div>`;
155
+ }
156
+ return islandSkeleton('donation_widget', ctx, fx).replace('data-p60-preview-island="donation_widget"', 'data-p60-preview-island="primary_action_widget"');
148
157
  case 'volunteer_signup':
149
158
  return `<section class="donate-card vol-card" data-p60-preview-island="volunteer_signup">
150
159
  ${previewNote(name)}
@@ -359,18 +368,27 @@ const SURFACES = {
359
368
  /** The routed dev preview's surface names (plus 'home'). */
360
369
  export const PREVIEW_SURFACES = ['home', ...Object.keys(SURFACES)];
361
370
 
362
- function surfaceBar(active) {
371
+ function surfaceBar(active, focus = 'donate') {
372
+ const withFocus = (href) => (focus === 'donate' ? href : `${href}${href.includes('?') ? '&' : '?'}focus=${focus}`);
363
373
  const link = (name, href) =>
364
- `<a href="${href}"${name === active ? ' style="font-weight:700;text-decoration:underline"' : ''}>${name}</a>`;
374
+ `<a href="${withFocus(href)}"${name === active ? ' style="font-weight:700;text-decoration:underline"' : ''}>${name}</a>`;
365
375
  const links = [
366
376
  link('home', '/'), link('events', '/events'), link('event', '/events?event=fixture'),
367
377
  link('services', '/services'), link('service', '/services?service=fixture'),
368
378
  link('donate', '/donate'), link('articles', '/articles'), link('article', '/articles/fixture'),
369
379
  link('campaigns', '/campaigns'), link('campaign', '/campaigns/fixture'), link('course', '/courses'),
370
- `<a href="/model" style="margin-left:auto;font-weight:700">site.content model →</a>`,
380
+ `<a href="${withFocus('/model')}" style="margin-left:auto;font-weight:700">site.content model →</a>`,
371
381
  ];
382
+ // What leads: the charity's switch, here as three links, so a developer sees the hero widget as
383
+ // the donation widget, as the volunteer sign-up, or absent, with the buttons following each time.
384
+ const focusLink = (value, label) =>
385
+ `<a href="${value === 'donate' ? '/' : `/?focus=${value}`}"${value === focus ? ' style="font-weight:700;text-decoration:underline"' : ''}>${label}</a>`;
386
+ const focusLinks = [focusLink('donate', 'giving'), focusLink('volunteer', 'volunteering'), focusLink('none', 'buttons only')];
372
387
  return `<nav class="p60-preview-surfaces" aria-label="Preview surfaces" style="position:sticky;top:0;z-index:99;display:flex;gap:12px;flex-wrap:wrap;padding:8px 14px;font:12px/1.4 system-ui,sans-serif;background:#0b1220;color:#e6e9f2;opacity:.94">
373
388
  <strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Surfaces</strong>${links.join('')}
389
+ <span style="flex-basis:100%;height:0"></span>
390
+ <strong style="letter-spacing:.06em;text-transform:uppercase;font-size:10px">Leads with</strong>${focusLinks.join('')}
391
+ <span style="opacity:.7">the hero widget becomes the donation widget, the volunteer sign-up, or nothing; the buttons follow</span>
374
392
  </nav>`;
375
393
  }
376
394
 
@@ -420,12 +438,17 @@ export async function renderStudioPreview(files, options = {}) {
420
438
  // imagery to the platform CDN instead of inline-SVG art (the dev-richer half of the split).
421
439
  const artOptions = options.fixtureImageBase ? { imageBase: options.fixtureImageBase } : null;
422
440
  let fx = artOptions ? resolveFixtureArt(contextContract.fixtures, artOptions) : STUDIO_FX;
441
+ // What leads (docs/volunteering.md 'Site focus'): the dev server passes ?focus=; the studio
442
+ // renders the platform default. The island skeletons read it from the fixtures they are handed.
443
+ const focus = normaliseFocus(options.focus);
444
+ const actions = previewActions(focus);
445
+ fx = { ...fx, focus };
423
446
  // The one content tree (content model v1): about composed from this manifest's declared
424
447
  // sections, dev preview-content overlaid when the kit passes it (validated there), imagery
425
448
  // resolved exactly like the rest of the fixtures.
426
- const site = resolveFixtureArt(
449
+ const site = applyFocus(resolveFixtureArt(
427
450
  applyPreviewContent(buildSiteFixture(manifest), options.previewContent ?? null),
428
- artOptions ?? {});
451
+ artOptions ?? {}), actions);
429
452
  const brand = site.brand ?? fx.brand;
430
453
  // With a content override, the TREE is the source of truth for every fixture view: the routed
431
454
  // platform skeletons and island skeletons re-derive their slices from the overridden site, so
@@ -481,8 +504,12 @@ export async function renderStudioPreview(files, options = {}) {
481
504
  const entry = catalogueByType.get(type);
482
505
  const source = files[`sections/${type}.liquid`];
483
506
  if (!entry || source == null) continue;
507
+ const sample = artOptions ? resolveFixtureArt(entry.sample ?? {}, artOptions) : resolveFixtureArt(entry.sample ?? {});
484
508
  const context = {
485
- section: artOptions ? resolveFixtureArt(entry.sample ?? {}, artOptions) : resolveFixtureArt(entry.sample ?? {}),
509
+ // The home hero carries the resolved actions exactly as the platform hands them over, so a
510
+ // developer sees the widget AND the button that pairs with it. The catalogue sample's
511
+ // givingStyle is dropped here: in the preview the focus decides the style.
512
+ section: type === 'homeHero' ? withResolvedActions({ ...sample, givingStyle: undefined, actionStyle: undefined }, actions) : sample,
486
513
  brand,
487
514
  site,
488
515
  ...(fx.sections?.[type] ?? {})
@@ -580,7 +607,7 @@ export async function renderStudioPreview(files, options = {}) {
580
607
  <style>${themeCss}</style>
581
608
  </head>
582
609
  <body ${attrs}>
583
- ${options.surface ? surfaceBar(surface) : ''}
610
+ ${options.surface ? surfaceBar(surface, focus) : ''}
584
611
  ${bodyHtml}
585
612
  ${runtime ? `<script>${runtime}\np60Behaviors.initBehaviors();</script>` : ''}
586
613
  </body>
@@ -100,7 +100,7 @@ export async function validateArtifact(files) {
100
100
  newsletter: () => islands.has('newsletter_signup'),
101
101
  i18n: () => islands.has('language_switch'),
102
102
  search: () => islands.has('search'),
103
- volunteering: () => islands.has('volunteer_signup') || islands.has('primary_action')
103
+ volunteering: () => islands.has('volunteer_signup') || islands.has('primary_action_widget')
104
104
  };
105
105
  for (const capability of manifest?.requiresCapabilities ?? []) {
106
106
  if (!capabilitySurface[capability]?.()) {
@@ -110,14 +110,15 @@ export async function validateArtifact(files) {
110
110
  }
111
111
 
112
112
  // Site focus honesty (docs/volunteering.md): a template that claims it can lead with
113
- // volunteering must give the volunteer sign-up a way into the hero, either the composite
114
- // primary_action island or the volunteer_signup island placed directly.
113
+ // volunteering must give the volunteer sign-up a way into the hero, either the
114
+ // primary_action_widget island (which becomes the sign-up when volunteering leads) or the
115
+ // volunteer_signup island placed directly.
115
116
  {
116
117
  const focusKinds = new Set(manifest?.supports?.focus ?? []);
117
118
  if (focusKinds.has('volunteer')) {
118
119
  const liquidSource = Object.entries(files).filter(([path]) => path.endsWith('.liquid')).map(([, s]) => s).join('\n');
119
- if (!/island\s+['"]primary_action['"]/.test(liquidSource) && !/island\s+['"]volunteer_signup['"]/.test(liquidSource)) {
120
- errors.push("manifest: supports.focus lists 'volunteer' but no section places {% island 'primary_action' %} (or 'volunteer_signup'), so the site could never lead with volunteering");
120
+ if (!/island\s+['"]primary_action_widget['"]/.test(liquidSource) && !/island\s+['"]volunteer_signup['"]/.test(liquidSource)) {
121
+ errors.push("manifest: supports.focus lists 'volunteer' but no section places {% island 'primary_action_widget' %} (or 'volunteer_signup'), so the site could never lead with volunteering");
121
122
  }
122
123
  }
123
124
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starter",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "format": "port60-liquid@1",
5
5
  "label": "Starter",
6
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.",
@@ -23,7 +23,7 @@
23
23
  "member_menu",
24
24
  "hero_carousel",
25
25
  "map",
26
- "primary_action"
26
+ "primary_action_widget"
27
27
  ],
28
28
  "layout": true,
29
29
  "heroImagery": true,
@@ -68,8 +68,8 @@
68
68
  </div>
69
69
  </div>
70
70
  <aside id="donate">
71
- {%- comment -%} The site focus decides which widget leads (section.primary); the composite island mounts it. {%- endcomment -%}
72
- {% island 'primary_action' %}
71
+ {%- comment -%} The site focus decides which widget leads (section.primary); this island becomes it: the donation widget or the volunteer sign-up. {%- endcomment -%}
72
+ {% island 'primary_action_widget' %}
73
73
  </aside>
74
74
  </div>
75
75
  </section>
@@ -108,7 +108,7 @@
108
108
  </div>
109
109
  {% unless section.actionStyle == 'button' or section.givingStyle == 'button' %}
110
110
  <aside id="donate">
111
- {% island 'primary_action' %}
111
+ {% island 'primary_action_widget' %}
112
112
  </aside>
113
113
  {% endunless %}
114
114
  </div>