@port60/template-kit 0.7.0 → 0.9.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.7.0",
3
+ "version": "0.9.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": {
@@ -3,6 +3,9 @@ import { resolve, join, basename } from 'node:path';
3
3
  import { agentsMd } from '../lib/agentsMd.mjs';
4
4
 
5
5
  const NAME_PATTERN = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/;
6
+ const KIT_PACKAGE = JSON.parse(
7
+ readFileSync(resolve(import.meta.dirname, '../../package.json'), 'utf8')
8
+ );
6
9
 
7
10
  /** `create <dir> [--name x] [--label "X"]` — a working, validating template from the starter,
8
11
  * briefed for AI agents (AGENTS.md + CLAUDE.md) and wired with the kit's npm scripts. */
@@ -50,7 +53,7 @@ export function create(args) {
50
53
  package: 'p60-template-kit package .'
51
54
  },
52
55
  devDependencies: {
53
- '@port60/template-kit': '^0.1.0'
56
+ '@port60/template-kit': `^${KIT_PACKAGE.version}`
54
57
  }
55
58
  }, null, 2) + '\n');
56
59
  writeFileSync(join(target, 'README.md'), `# ${label}
@@ -1,5 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
- import { watch } from 'node:fs';
2
+ import { watch, readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
  import { renderStudioPreview } from '../vendor/validator/preview.mjs';
5
5
  import { validateArtifact } from '../vendor/validator/validate.mjs';
@@ -7,18 +7,45 @@ import { loadArtifactDir } from '../lib/artifactFiles.mjs';
7
7
 
8
8
  /**
9
9
  * `dev <dir> [--port 4400]` — the local preview: your template over the contract's kind fixtures,
10
- * the SAME render the studio and reviewers see (islands as placeholders, network-dead CSP).
11
- * Files are re-read on every request, so a browser refresh is the hot reload; file changes also
12
- * re-run validation into the terminal — the human watches the page, the agent watches the JSON.
10
+ * the SAME render the studio and reviewers see (islands as fixture-hydrated skeletons,
11
+ * network-dead CSP). ONE addition over the studio render: the platform's own behaviour runtime is
12
+ * inlined, so data-p60-* carousels, reveals and tabs run for real locally — the only script the
13
+ * document can execute. Files are re-read on every request, so a browser refresh is the hot
14
+ * reload; file changes also re-run validation into the terminal — the human watches the page,
15
+ * the agent watches the JSON.
13
16
  */
14
17
  export async function dev(args) {
15
18
  const dir = resolve(args._[0] ?? '.');
16
19
  const port = Number(args.port ?? 4400);
20
+ let behaviorsRuntime = null;
21
+ try {
22
+ behaviorsRuntime = readFileSync(
23
+ resolve(import.meta.dirname, '../vendor/validator/behaviors-runtime.js'), 'utf8');
24
+ } catch {
25
+ // An older vendored copy without the runtime — the preview degrades to the CSS approximation.
26
+ }
27
+
28
+ // The preview is ROUTED: nav links land on real surfaces, so an author sees every platform
29
+ // page wearing their chrome — their own page template where they ship one, the platform's
30
+ // fixture skeleton where the page is platform-owned (ticket purchase, donate, campaigns).
31
+ const surfaceFor = (rawUrl) => {
32
+ const url = new URL(rawUrl, 'http://preview.local');
33
+ const path = url.pathname.replace(/\/+$/, '') || '/';
34
+ if (path === '/events') return url.searchParams.has('event') ? 'event' : 'events';
35
+ if (path === '/services') return url.searchParams.has('service') ? 'service' : 'services';
36
+ if (path === '/donate') return 'donate';
37
+ if (path === '/articles' || path === '/articles/all') return 'articles';
38
+ if (path.startsWith('/articles/')) return 'article';
39
+ if (path === '/campaigns') return 'campaigns';
40
+ if (path.startsWith('/campaigns/')) return 'campaign';
41
+ if (path === '/courses') return 'course';
42
+ return 'home';
43
+ };
17
44
 
18
45
  const server = createServer(async (req, res) => {
19
46
  try {
20
47
  const files = loadArtifactDir(dir);
21
- const html = await renderStudioPreview(files);
48
+ const html = await renderStudioPreview(files, { behaviorsRuntime, surface: surfaceFor(req.url ?? '/') });
22
49
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
23
50
  res.end(html);
24
51
  } catch (e) {
@@ -33,6 +60,17 @@ ${errors.length ? `<h2>Validation says</h2><ul>${errors.map((x) => `<li>${String
33
60
  }
34
61
  });
35
62
 
63
+ // A taken port is the most likely first-run failure (a forgotten dev server from another
64
+ // template), and a raw EADDRINUSE stack reads like the kit is broken. Name the fix instead.
65
+ server.on('error', (e) => {
66
+ if (e.code === 'EADDRINUSE') {
67
+ console.error(`✗ port ${port} is already in use — another preview is probably still running.`);
68
+ console.error(` Stop it, or start this one elsewhere: p60-template-kit dev ${args._[0] ?? '.'} --port ${port + 1}`);
69
+ process.exit(1);
70
+ }
71
+ throw e;
72
+ });
73
+
36
74
  server.listen(port, () => {
37
75
  console.log(`✓ preview at http://localhost:${port} — refresh after edits`);
38
76
  console.log(' watching for changes; validation runs on save:');
@@ -53,9 +53,29 @@ platform accepts it; if it fails here, the upload will fail identically.
53
53
  hide the rail when \`worship\` is null, and rendering it undeclared is equally an error. The
54
54
  same honesty applies across the contract.
55
55
  5. **Render budgets are real.** Runaway loops are killed (~1s per render). Keep renderers simple.
56
- 6. **Context is a whitelist.** Sections see \`{section, brand}\`; layouts see
57
- \`{brand, nav, socials, worship}\`; page templates see their fixture + \`brand\`. Nothing else
58
- exists — do not invent variables.
56
+ 6. **Context is a whitelist.** Sections see \`{section, brand}\` plus only the collection named by
57
+ that section in the contract; layouts see \`{brand, nav, socials, worship, locale}\`; page
58
+ templates see their documented fixture + \`brand\`. Nothing else exists — do not invent
59
+ variables.
60
+ 7. **Capabilities are matching metadata, never entitlements.** Every value in
61
+ \`requiresCapabilities\` must have a declared section, page template or island that presents it.
62
+ \`suitsProfiles\` describes design intent and changes catalogue ordering only.
63
+
64
+ ## What each declaration owns
65
+
66
+ - \`supports.layout\` owns the visible header, navigation and footer around platform pages. The
67
+ platform still owns the document head, consent and identity.
68
+ - \`supports.pages\` owns section based bodies for \`home\` and \`about\` through the declared
69
+ renderers in \`sections/\`.
70
+ - \`supports.pageTemplates: ["events"]\` owns the events listing only. Event details, RSVP and
71
+ ticket purchase remain platform owned.
72
+ - \`supports.pageTemplates: ["course"]\` owns a course detail presentation only. The course
73
+ listing stays platform owned and enrolment remains the \`course_enrol\` island.
74
+ - \`supports.pageTemplates: ["articles"]\` owns the article front page and archive listings.
75
+ - \`supports.pageTemplates: ["article"]\` owns article detail presentation; engagement and
76
+ comments stay the \`article_engagement\` and \`article_comments\` islands.
77
+ - Other platform routes keep their platform body and render inside your layout. Capability flags
78
+ expose documented optional context; they do not transfer transaction or route ownership.
59
79
 
60
80
  ## What to build with
61
81
 
@@ -70,6 +90,15 @@ platform accepts it; if it fails here, the upload will fail identically.
70
90
  behaviourally.
71
91
  - "Looks" = named one-click bundles of knob values in \`manifest.looks\`.
72
92
  - Fonts: only families from the platform font catalogue, declared with the weights you use.
93
+ - Navigation can contain two levels below a top item. Render every supplied child and branch on
94
+ optional \`group\`, \`description\`, \`imageUrl\` and \`megaMenu\` promo metadata. Never hardcode
95
+ menu groups that are not in \`nav\`.
96
+ - Dynamic sections include appeals (\`causes\`), programmes (\`services\`), resources, locations,
97
+ volunteering opportunities and structured media. Derive or omit when a collection is empty and
98
+ use the supplied URLs rather than constructing routes.
99
+ - Submission and behaviour surfaces such as \`newsletter_signup\`, \`volunteer_signup\`, \`form\`,
100
+ \`search\`, \`language_switch\` and \`next_prayer\` are platform islands. Place and style them;
101
+ never reproduce their API calls or consent behaviour.
73
102
  - \`manifest.imagery.hero\` (optional but recommended): declare the photo shape YOUR hero
74
103
  composes best with (\`idealAspect\`, \`minWidth\`, a one-line \`note\`) — the charity's editor
75
104
  measures their actual upload against it and advises. Advice, never enforcement.
@@ -0,0 +1,129 @@
1
+ {
2
+ "description": "The behaviour catalogue: engine-attached JavaScript a template opts into with data attributes on its own markup. Templates never ship code; they author complete static markup and declare behaviours in supports.behaviors. The engine adds the p60-js class to the root element when its script runs; any CSS that hides pre-behaviour content MUST be scoped under .p60-js so the no-JS render stays complete. All behaviours honour prefers-reduced-motion centrally. State classes are toggled by the engine; their appearance is entirely the template's CSS.",
3
+ "behaviours": [
4
+ {
5
+ "name": "reveal",
6
+ "status": "available",
7
+ "label": "Reveal on scroll",
8
+ "description": "Adds is-revealed when the element first enters the viewport; the template's CSS does the actual motion. data-p60-reveal-group on a container staggers its children by stamping --p60-reveal-index on each (use it in a transition-delay calc). Reduced motion: is-revealed applies immediately on load.",
9
+ "attributes": [
10
+ { "attr": "data-p60-reveal", "value": "optional variant name for the CSS to key on" },
11
+ { "attr": "data-p60-reveal-group", "value": "none; staggers direct children" }
12
+ ],
13
+ "stateClasses": ["is-revealed"],
14
+ "cssVars": ["--p60-reveal-index"]
15
+ },
16
+ {
17
+ "name": "counter",
18
+ "status": "available",
19
+ "label": "Animated counter",
20
+ "description": "Counts the element's number up from zero when first revealed. The static text IS the final formatted value (the no-JS render); data-p60-count carries the numeric target the engine animates to, grouped with the page locale. Reduced motion: the final number shows immediately.",
21
+ "attributes": [
22
+ { "attr": "data-p60-count", "value": "the numeric target, digits only" },
23
+ { "attr": "data-p60-count-duration", "value": "optional ms, capped at 4000 (default 1200)" }
24
+ ],
25
+ "stateClasses": ["is-revealed"],
26
+ "cssVars": []
27
+ },
28
+ {
29
+ "name": "progress",
30
+ "status": "available",
31
+ "label": "Progress bar sweep",
32
+ "description": "Animates the marked element's width from zero to its authored width when first revealed. The authored width (inline style or CSS) is the target and the no-JS render. Reduced motion: the authored width stands untouched.",
33
+ "attributes": [
34
+ { "attr": "data-p60-progress", "value": "none; the element's own width is the target" }
35
+ ],
36
+ "stateClasses": ["is-revealed"],
37
+ "cssVars": []
38
+ },
39
+ {
40
+ "name": "countdown",
41
+ "status": "available",
42
+ "label": "Countdown",
43
+ "description": "Replaces the element's content with a live compact remainder to data-p60-until (\"3d 12h 45m\"). The static content is the authored date text (the no-JS render). On expiry the engine adds is-elapsed and swaps to data-p60-elapsed-text when provided. Countdowns keep ticking under reduced motion: they are information, not decoration. v1 unit labels are compact and language-neutral (d/h/m/s).",
44
+ "attributes": [
45
+ { "attr": "data-p60-countdown", "value": "segments: dhms, dhm (default) or hm" },
46
+ { "attr": "data-p60-until", "value": "ISO-8601 instant, required" },
47
+ { "attr": "data-p60-elapsed-text", "value": "optional text shown once elapsed" }
48
+ ],
49
+ "stateClasses": ["is-elapsed"],
50
+ "cssVars": []
51
+ },
52
+ {
53
+ "name": "accordion",
54
+ "status": "available",
55
+ "label": "Accordion",
56
+ "description": "On a container of native details elements: exclusive-open (opening one closes the others) plus smooth height animation. data-p60-accordion=\"multi\" keeps independent opening. Built on details/summary so the no-JS render is already functional and semantics are free. Reduced motion: no height animation, instant toggle.",
57
+ "attributes": [
58
+ { "attr": "data-p60-accordion", "value": "none for exclusive-open; \"multi\" for independent" }
59
+ ],
60
+ "stateClasses": ["is-open"],
61
+ "cssVars": []
62
+ },
63
+ {
64
+ "name": "stickyHeader",
65
+ "status": "available",
66
+ "label": "Sticky header condense",
67
+ "description": "State classes for scroll-aware headers: past a small threshold the engine adds is-condensed; with the \"auto-hide\" value, scrolling down past 160px adds is-hidden and scrolling up removes it. The engine only ever toggles classes (rAF-throttled) — what condensing or hiding looks like, and whether it animates, is entirely the template's CSS (gate transitions behind prefers-reduced-motion yourself; the classes always apply because a condensed header is layout, not decoration).",
68
+ "attributes": [
69
+ { "attr": "data-p60-sticky-header", "value": "none for condense-only; \"auto-hide\" adds the hide-on-scroll-down pair" }
70
+ ],
71
+ "stateClasses": ["is-condensed", "is-hidden"],
72
+ "cssVars": []
73
+ },
74
+ {
75
+ "name": "stickyCta",
76
+ "status": "available",
77
+ "label": "Sticky call-to-action bar",
78
+ "description": "Adds is-stuck once the visitor scrolls past the threshold (the attribute value in px, default 480) and removes it above. The bar should DUPLICATE an action that already exists in the page (a donate button, a ticket link) — because of that, this is the one behaviour whose element may be hidden unconditionally in CSS rather than .p60-js-scoped: a no-JS visitor loses nothing, the original action is still on the page.",
79
+ "attributes": [
80
+ { "attr": "data-p60-sticky-cta", "value": "optional scroll threshold in px (default 480)" }
81
+ ],
82
+ "stateClasses": ["is-stuck"],
83
+ "cssVars": []
84
+ },
85
+ {
86
+ "name": "lightbox",
87
+ "status": "available",
88
+ "label": "Lightbox",
89
+ "description": "On a group container: each data-p60-lightbox-item is an anchor whose href is the full image (the no-JS render simply navigates to it — already functional). The engine intercepts the click and opens an injected overlay (.p60-lightbox) with the image, a caption from data-p60-caption or the thumbnail's alt, a close button, backdrop and Escape close, arrow-key prev/next within the group, and a focus trap. Reduced motion: no transition on the overlay (the engine sets none; style the steady state).",
90
+ "attributes": [
91
+ { "attr": "data-p60-lightbox", "value": "none; the group container" },
92
+ { "attr": "data-p60-lightbox-item", "value": "none; on each anchor to a full image" },
93
+ { "attr": "data-p60-caption", "value": "optional caption text on an item" }
94
+ ],
95
+ "stateClasses": ["is-open"],
96
+ "cssVars": [],
97
+ "injects": [".p60-lightbox", ".p60-lightbox-img", ".p60-lightbox-caption", ".p60-lightbox-close", ".p60-lightbox-prev", ".p60-lightbox-next"]
98
+ },
99
+ {
100
+ "name": "tabs",
101
+ "status": "available",
102
+ "label": "Tabs",
103
+ "description": "data-p60-tab=\"key\" on each tab control and data-p60-panel=\"key\" on each panel, inside a data-p60-tabs container. The engine wires the tablist/tab/tabpanel roles, aria-selected, roving tabindex, arrow/Home/End keys, toggles is-active on the active pair and hides inactive panels with the hidden attribute — so the no-JS render shows every panel stacked and complete, with no CSS scoping needed. The first tab (or the one authored with is-active) starts active.",
104
+ "attributes": [
105
+ { "attr": "data-p60-tabs", "value": "none; the container" },
106
+ { "attr": "data-p60-tab", "value": "the key of the panel this control activates" },
107
+ { "attr": "data-p60-panel", "value": "the key matching its control" }
108
+ ],
109
+ "stateClasses": ["is-active"],
110
+ "cssVars": []
111
+ },
112
+ {
113
+ "name": "carousel",
114
+ "status": "available",
115
+ "label": "Carousel",
116
+ "description": "On a container with one data-p60-slide per child: the engine manages is-active across slides, injects a dots nav (.p60-carousel-dots with one button per slide), wires optional template-provided data-p60-carousel-prev/data-p60-carousel-next controls, adds swipe, keyboard arrows, pause on hover and focus, and hides inactive slides from assistive tech. Value \"auto\" enables auto-advance (data-p60-interval ms, min 3000, default 6000); reduced motion disables auto-advance while controls keep working. The engine stamps --p60-carousel-index on the container for track-translate designs; class-based fade on is-active is the simplest pattern. CSS hiding non-active slides MUST be .p60-js-scoped so the no-JS render shows all slides.",
117
+ "attributes": [
118
+ { "attr": "data-p60-carousel", "value": "none for manual; \"auto\" for auto-advance" },
119
+ { "attr": "data-p60-slide", "value": "none; one per slide child" },
120
+ { "attr": "data-p60-interval", "value": "optional auto-advance ms, min 3000 (default 6000)" },
121
+ { "attr": "data-p60-carousel-prev", "value": "none; optional template-provided control" },
122
+ { "attr": "data-p60-carousel-next", "value": "none; optional template-provided control" }
123
+ ],
124
+ "stateClasses": ["is-active"],
125
+ "cssVars": ["--p60-carousel-index"],
126
+ "injects": [".p60-carousel-dots", ".p60-carousel-dot", ".p60-carousel-dot--active"]
127
+ }
128
+ ]
129
+ }