@port60/template-kit 0.19.0 → 0.20.1

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.19.0",
3
+ "version": "0.20.1",
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": {
@@ -16,7 +16,8 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "ajv": "^8.20.0",
19
- "liquidjs": "^10.27.2"
19
+ "liquidjs": "^10.27.2",
20
+ "lucide-static": "^1.41.0"
20
21
  },
21
22
  "license": "MIT",
22
23
  "repository": {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, writeFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
- import { buildSiteFixture, validatePreviewContent } from '../vendor/validator/site-context.mjs';
3
+ import { buildSiteFixture, composePage, validatePreviewContent } from '../vendor/validator/site-context.mjs';
4
+ import { loadArtifactDir } from '../lib/artifactFiles.mjs';
4
5
 
5
6
  /**
6
7
  * `content [dir] [--out file] [--force]`, EJECT the content model's data as your hard copy.
@@ -12,8 +13,10 @@ import { buildSiteFixture, validatePreviewContent } from '../vendor/validator/si
12
13
  * p60-template-kit dev . --content my.json → the preview renders YOUR data
13
14
  *
14
15
  * The shape stays the platform's (schema-checked on every read, a field that does not exist in
15
- * production cannot exist in a preview); the data becomes yours. `about` is not ejected: it is
16
- * your template's own section composition, derived from the manifest.
16
+ * production cannot exist in a preview); the data becomes yours. The menu ejects as `nav`, and
17
+ * the home and about compositions eject as `pages` (each declared section the catalogue assigns
18
+ * to that page, with its sample copy), so section copy is edited in place; `about` itself is not
19
+ * ejected, it is derived from the page being previewed.
17
20
  */
18
21
  export function content(args) {
19
22
  const dir = resolve(args._[0] ?? '.');
@@ -23,8 +26,16 @@ export function content(args) {
23
26
  process.exit(1);
24
27
  }
25
28
  const site = buildSiteFixture(null);
29
+ let manifest = null;
30
+ try {
31
+ manifest = JSON.parse(loadArtifactDir(dir)['manifest.json'] ?? 'null');
32
+ } catch {
33
+ // no manifest here: collections and brand still eject, the compositions need one
34
+ }
26
35
  const data = {
27
36
  brand: site.brand,
37
+ nav: { items: site.nav?.items ?? [] },
38
+ ...(manifest ? { pages: { home: composePage(manifest, 'home'), about: composePage(manifest, 'about') } } : {}),
28
39
  ...Object.fromEntries(Object.entries(site.content).filter(([name]) => name !== 'about'))
29
40
  };
30
41
  const problems = validatePreviewContent(data);
@@ -36,7 +47,7 @@ export function content(args) {
36
47
  }
37
48
  writeFileSync(out, JSON.stringify(data, null, 2) + '\n');
38
49
  console.log(`✓ content model data written to ${out}`);
39
- console.log(' Edit the copy, swap imageUrl values for your own hosted images, then:');
50
+ console.log(' Edit the copy (pages carry each section, nav the menu), point imageUrl values at your own hosted images or a preview/ folder beside the template (/preview/<file>), then:');
40
51
  console.log(` p60-template-kit dev ${args._[0] ?? '.'} --content ${typeof args.out === 'string' ? args.out : 'preview-content.json'}`);
41
52
  console.log(' Shape is fixed (schema-checked every read); the data is yours. Never packaged.');
42
53
  }
@@ -1,6 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
- import { watch, readFileSync } from 'node:fs';
2
+ import { watch, readFileSync, existsSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
+ import { surfaceFor, knobOverridesFromQuery, previewAssetPath, previewAssetType } from '../lib/previewOptions.mjs';
4
5
  import { renderStudioPreview } from '../vendor/validator/preview.mjs';
5
6
  import { validateArtifact } from '../vendor/validator/validate.mjs';
6
7
  import { applyPreviewContent, buildSiteFixture, validatePreviewContent } from '../vendor/validator/site-context.mjs';
@@ -69,9 +70,11 @@ function imageOriginsOf(json) {
69
70
  /**
70
71
  * `dev <dir> [--port 4400]`, the local preview: your template over the contract's kind fixtures,
71
72
  * the SAME render the studio and reviewers see (islands as fixture-hydrated skeletons,
72
- * network-dead CSP). ONE addition over the studio render: the platform's own behaviour runtime is
73
- * inlined, so data-p60-* carousels, reveals and tabs run for real locally, the only script the
74
- * document can execute. Files are re-read on every request, so a browser refresh is the hot
73
+ * network-dead CSP), composed per page from supports.pages. Dev-only widenings over the studio
74
+ * render, each one something production does: the platform's own behaviour runtime is inlined
75
+ * (the only script the document can execute), webfonts load from the provider mirror, a Look or
76
+ * a knob can be switched from the query, and a preview/ folder beside the template serves the
77
+ * author's own imagery. Files are re-read on every request, so a browser refresh is the hot
75
78
  * reload; file changes also re-run validation into the terminal, the human watches the page,
76
79
  * the agent watches the JSON.
77
80
  */
@@ -96,27 +99,30 @@ export async function dev(args) {
96
99
  // An older vendored copy without the runtime, the preview degrades to the CSS approximation.
97
100
  }
98
101
 
99
- // The preview is ROUTED: nav links land on real surfaces, so an author sees every platform
100
- // page wearing their chrome, their own page template where they ship one, the platform's
101
- // fixture skeleton where the page is platform-owned (ticket purchase, donate, campaigns).
102
- const surfaceFor = (rawUrl) => {
103
- const url = new URL(rawUrl, 'http://preview.local');
104
- const path = url.pathname.replace(/\/+$/, '') || '/';
105
- if (path === '/events') return url.searchParams.has('event') ? 'event' : 'events';
106
- if (path === '/services') return url.searchParams.has('service') ? 'service' : 'services';
107
- if (path === '/donate') return 'donate';
108
- if (path === '/articles' || path === '/articles/all') return 'articles';
109
- if (path.startsWith('/articles/')) return 'article';
110
- if (path === '/campaigns') return 'campaigns';
111
- if (path.startsWith('/campaigns/')) return 'campaign';
112
- if (path === '/courses') return 'course';
113
- return 'home';
114
- };
102
+ // The preview is ROUTED (lib/previewOptions.mjs): nav links land on real surfaces, so an author
103
+ // sees every platform page wearing their chrome, their own page template where they ship one,
104
+ // the platform's fixture skeleton where the page is platform-owned (ticket purchase, donate,
105
+ // campaigns), and /about as its own composition. A preview/ folder beside the template is
106
+ // served at /preview/<file> for the author's own imagery (never packaged).
107
+ const previewFolder = existsSync(join(dir, 'preview'));
115
108
 
116
109
  const server = createServer(async (req, res) => {
117
110
  try {
118
111
  const files = loadArtifactDir(dir);
119
112
  const url = new URL(req.url ?? '/', 'http://preview.local');
113
+ if (url.pathname.startsWith('/preview/')) {
114
+ // The author's own imagery, and nothing else: a path that does not resolve to an image
115
+ // inside preview/ is a plain 404, so a broken image URL reads as one in the browser.
116
+ const asset = previewFolder ? previewAssetPath(dir, url.pathname) : null;
117
+ if (!asset) {
118
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
119
+ res.end('not found: only image files inside the template\'s preview/ folder are served here');
120
+ return;
121
+ }
122
+ res.writeHead(200, { 'Content-Type': previewAssetType(asset), 'Cache-Control': 'no-store' });
123
+ res.end(readFileSync(asset));
124
+ return;
125
+ }
120
126
  if (url.pathname === '/model') {
121
127
  // The model, where the developer lives: the registry beside the LIVE data this preview
122
128
  // renders (preview-content.json overlay included).
@@ -127,6 +133,11 @@ export async function dev(args) {
127
133
  return;
128
134
  }
129
135
  const previewContent = loadPreviewContent(dir, contentPath);
136
+ const manifest = JSON.parse(files['manifest.json'] ?? '{}');
137
+ // ?look=<name> applies one of the manifest's Looks; ?p60s-<key>=<value> sets one knob, the
138
+ // way Appearance does for a charity. Values are checked against each knob's kind in the
139
+ // renderer, exactly as production checks them.
140
+ const { knobs, look } = knobOverridesFromQuery(manifest, url.searchParams);
130
141
  const html = await renderStudioPreview(files, {
131
142
  behaviorsRuntime,
132
143
  fixtureImageBase,
@@ -134,7 +145,12 @@ export async function dev(args) {
134
145
  contentImageOrigins: imageOriginsOf(previewContent),
135
146
  surface: surfaceFor(req.url ?? '/'),
136
147
  // ?focus=donate|volunteer|none: what leads, so the hero shows each widget and its buttons.
137
- focus: url.searchParams.get('focus') ?? undefined
148
+ focus: url.searchParams.get('focus') ?? undefined,
149
+ knobs,
150
+ look,
151
+ // Dev-only widenings of the sealed studio render: real webfonts, and the preview/ folder.
152
+ webfonts: true,
153
+ localImages: previewFolder
138
154
  });
139
155
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
140
156
  res.end(html);
@@ -0,0 +1,70 @@
1
+ // The dev preview's request-level options, kept pure so they are testable without a server: which
2
+ // surface a URL is, which knob values a query asks for (a Look, or single ?p60s-<key>= values),
3
+ // and which local file a /preview/ URL may serve.
4
+ import { existsSync, statSync } from 'node:fs';
5
+ import { resolve, sep } from 'node:path';
6
+
7
+ /** Path → preview surface. Unknown paths are the home page, as production's fallback is. */
8
+ export function surfaceFor(rawUrl) {
9
+ const url = new URL(rawUrl, 'http://preview.local');
10
+ const path = url.pathname.replace(/\/+$/, '') || '/';
11
+ if (path === '/about') return 'about';
12
+ if (path === '/events') return url.searchParams.has('event') ? 'event' : 'events';
13
+ if (path === '/services') return url.searchParams.has('service') ? 'service' : 'services';
14
+ if (path === '/donate') return 'donate';
15
+ if (path === '/articles' || path === '/articles/all') return 'articles';
16
+ if (path.startsWith('/articles/')) return 'article';
17
+ if (path === '/campaigns') return 'campaigns';
18
+ if (path.startsWith('/campaigns/')) return 'campaign';
19
+ if (path === '/courses') return 'course';
20
+ return 'home';
21
+ }
22
+
23
+ /**
24
+ * Knob overrides from a query: `?look=<name>` applies one of the manifest's Looks (its values
25
+ * bundle), then any `?p60s-<key>=<value>` wins for that knob. Unknown Looks and keys are ignored;
26
+ * the renderer validates values against each knob's kind exactly as production does.
27
+ */
28
+ export function knobOverridesFromQuery(manifest, searchParams) {
29
+ const knobs = {};
30
+ const lookName = searchParams.get('look');
31
+ const look = lookName ? (manifest?.looks ?? []).find((l) => l.name === lookName) ?? null : null;
32
+ if (look) Object.assign(knobs, look.values ?? {});
33
+ const keys = new Set((manifest?.settings?.schema ?? []).map((k) => k.key));
34
+ for (const [key, value] of searchParams) {
35
+ if (key.startsWith('p60s-') && keys.has(key.slice('p60s-'.length))) knobs[key.slice('p60s-'.length)] = value;
36
+ }
37
+ return { knobs, look: look ? look.name : null };
38
+ }
39
+
40
+ /** Image types the preview folder may serve; everything else is refused. */
41
+ export const PREVIEW_ASSET_TYPES = {
42
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
43
+ '.webp': 'image/webp', '.avif': 'image/avif', '.svg': 'image/svg+xml',
44
+ };
45
+
46
+ /**
47
+ * `/preview/<file>` → the absolute path of a regular file inside `<dir>/preview/`, or null. The
48
+ * folder is the author's own imagery beside the template (never packaged); only image types are
49
+ * served, and a path that escapes the folder is refused.
50
+ */
51
+ export function previewAssetPath(dir, pathname) {
52
+ if (!pathname.startsWith('/preview/')) return null;
53
+ const root = resolve(dir, 'preview');
54
+ let rel;
55
+ try {
56
+ rel = decodeURIComponent(pathname.slice('/preview/'.length));
57
+ } catch {
58
+ return null;
59
+ }
60
+ const abs = resolve(root, rel);
61
+ if (abs !== root && !abs.startsWith(root + sep)) return null;
62
+ const ext = abs.slice(abs.lastIndexOf('.')).toLowerCase();
63
+ if (!PREVIEW_ASSET_TYPES[ext]) return null;
64
+ if (!existsSync(abs) || !statSync(abs).isFile()) return null;
65
+ return abs;
66
+ }
67
+
68
+ export function previewAssetType(abs) {
69
+ return PREVIEW_ASSET_TYPES[abs.slice(abs.lastIndexOf('.')).toLowerCase()] ?? 'application/octet-stream';
70
+ }
@@ -8,11 +8,21 @@
8
8
  "primaryAttribute": "data-p60-reveal",
9
9
  "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.",
10
10
  "attributes": [
11
- { "attr": "data-p60-reveal", "value": "optional variant name for the CSS to key on" },
12
- { "attr": "data-p60-reveal-group", "value": "none; staggers direct children" }
11
+ {
12
+ "attr": "data-p60-reveal",
13
+ "value": "optional variant name for the CSS to key on"
14
+ },
15
+ {
16
+ "attr": "data-p60-reveal-group",
17
+ "value": "none; staggers direct children"
18
+ }
13
19
  ],
14
- "stateClasses": ["is-revealed"],
15
- "cssVars": ["--p60-reveal-index"]
20
+ "stateClasses": [
21
+ "is-revealed"
22
+ ],
23
+ "cssVars": [
24
+ "--p60-reveal-index"
25
+ ]
16
26
  },
17
27
  {
18
28
  "name": "counter",
@@ -21,10 +31,18 @@
21
31
  "primaryAttribute": "data-p60-count",
22
32
  "description": "Counts the element's figure up from zero when first revealed. The static text IS the final value, typed exactly as it should read (\"£20\", \"95%\", \"1,200\", \"2.5k\"): the engine animates only the first run of digits and keeps the prefix, suffix, grouping and decimal places as typed, restoring the exact text on the last frame. A numeric attribute value (legacy) overrides the target; the text still sets the format. Reduced motion: the final figure shows immediately.",
23
33
  "attributes": [
24
- { "attr": "data-p60-count", "value": "none: the element's own text is the target and the format; or a numeric target (legacy)" },
25
- { "attr": "data-p60-count-duration", "value": "optional ms, capped at 4000 (default 1200)" }
34
+ {
35
+ "attr": "data-p60-count",
36
+ "value": "none: the element's own text is the target and the format; or a numeric target (legacy)"
37
+ },
38
+ {
39
+ "attr": "data-p60-count-duration",
40
+ "value": "optional ms, capped at 4000 (default 1200)"
41
+ }
42
+ ],
43
+ "stateClasses": [
44
+ "is-revealed"
26
45
  ],
27
- "stateClasses": ["is-revealed"],
28
46
  "cssVars": []
29
47
  },
30
48
  {
@@ -34,9 +52,14 @@
34
52
  "primaryAttribute": "data-p60-progress",
35
53
  "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.",
36
54
  "attributes": [
37
- { "attr": "data-p60-progress", "value": "none; the element's own width is the target" }
55
+ {
56
+ "attr": "data-p60-progress",
57
+ "value": "none; the element's own width is the target"
58
+ }
59
+ ],
60
+ "stateClasses": [
61
+ "is-revealed"
38
62
  ],
39
- "stateClasses": ["is-revealed"],
40
63
  "cssVars": []
41
64
  },
42
65
  {
@@ -46,11 +69,22 @@
46
69
  "primaryAttribute": "data-p60-countdown",
47
70
  "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).",
48
71
  "attributes": [
49
- { "attr": "data-p60-countdown", "value": "segments: dhms, dhm (default) or hm" },
50
- { "attr": "data-p60-until", "value": "ISO-8601 instant, required" },
51
- { "attr": "data-p60-elapsed-text", "value": "optional text shown once elapsed" }
72
+ {
73
+ "attr": "data-p60-countdown",
74
+ "value": "segments: dhms, dhm (default) or hm"
75
+ },
76
+ {
77
+ "attr": "data-p60-until",
78
+ "value": "ISO-8601 instant, required"
79
+ },
80
+ {
81
+ "attr": "data-p60-elapsed-text",
82
+ "value": "optional text shown once elapsed"
83
+ }
84
+ ],
85
+ "stateClasses": [
86
+ "is-elapsed"
52
87
  ],
53
- "stateClasses": ["is-elapsed"],
54
88
  "cssVars": []
55
89
  },
56
90
  {
@@ -60,9 +94,14 @@
60
94
  "primaryAttribute": "data-p60-accordion",
61
95
  "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.",
62
96
  "attributes": [
63
- { "attr": "data-p60-accordion", "value": "none for exclusive-open; \"multi\" for independent" }
97
+ {
98
+ "attr": "data-p60-accordion",
99
+ "value": "none for exclusive-open; \"multi\" for independent"
100
+ }
101
+ ],
102
+ "stateClasses": [
103
+ "is-open"
64
104
  ],
65
- "stateClasses": ["is-open"],
66
105
  "cssVars": []
67
106
  },
68
107
  {
@@ -72,9 +111,15 @@
72
111
  "primaryAttribute": "data-p60-sticky-header",
73
112
  "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).",
74
113
  "attributes": [
75
- { "attr": "data-p60-sticky-header", "value": "none for condense-only; \"auto-hide\" adds the hide-on-scroll-down pair" }
114
+ {
115
+ "attr": "data-p60-sticky-header",
116
+ "value": "none for condense-only; \"auto-hide\" adds the hide-on-scroll-down pair"
117
+ }
118
+ ],
119
+ "stateClasses": [
120
+ "is-condensed",
121
+ "is-hidden"
76
122
  ],
77
- "stateClasses": ["is-condensed", "is-hidden"],
78
123
  "cssVars": []
79
124
  },
80
125
  {
@@ -84,9 +129,14 @@
84
129
  "primaryAttribute": "data-p60-sticky-cta",
85
130
  "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.",
86
131
  "attributes": [
87
- { "attr": "data-p60-sticky-cta", "value": "optional scroll threshold in px (default 480)" }
132
+ {
133
+ "attr": "data-p60-sticky-cta",
134
+ "value": "optional scroll threshold in px (default 480)"
135
+ }
136
+ ],
137
+ "stateClasses": [
138
+ "is-stuck"
88
139
  ],
89
- "stateClasses": ["is-stuck"],
90
140
  "cssVars": []
91
141
  },
92
142
  {
@@ -96,13 +146,31 @@
96
146
  "primaryAttribute": "data-p60-lightbox",
97
147
  "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).",
98
148
  "attributes": [
99
- { "attr": "data-p60-lightbox", "value": "none; the group container" },
100
- { "attr": "data-p60-lightbox-item", "value": "none; on each anchor to a full image" },
101
- { "attr": "data-p60-caption", "value": "optional caption text on an item" }
149
+ {
150
+ "attr": "data-p60-lightbox",
151
+ "value": "none; the group container"
152
+ },
153
+ {
154
+ "attr": "data-p60-lightbox-item",
155
+ "value": "none; on each anchor to a full image"
156
+ },
157
+ {
158
+ "attr": "data-p60-caption",
159
+ "value": "optional caption text on an item"
160
+ }
161
+ ],
162
+ "stateClasses": [
163
+ "is-open"
102
164
  ],
103
- "stateClasses": ["is-open"],
104
165
  "cssVars": [],
105
- "injects": [".p60-lightbox", ".p60-lightbox-img", ".p60-lightbox-caption", ".p60-lightbox-close", ".p60-lightbox-prev", ".p60-lightbox-next"]
166
+ "injects": [
167
+ ".p60-lightbox",
168
+ ".p60-lightbox-img",
169
+ ".p60-lightbox-caption",
170
+ ".p60-lightbox-close",
171
+ ".p60-lightbox-prev",
172
+ ".p60-lightbox-next"
173
+ ]
106
174
  },
107
175
  {
108
176
  "name": "tabs",
@@ -111,11 +179,22 @@
111
179
  "primaryAttribute": "data-p60-tabs",
112
180
  "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.",
113
181
  "attributes": [
114
- { "attr": "data-p60-tabs", "value": "none; the container" },
115
- { "attr": "data-p60-tab", "value": "the key of the panel this control activates" },
116
- { "attr": "data-p60-panel", "value": "the key matching its control" }
182
+ {
183
+ "attr": "data-p60-tabs",
184
+ "value": "none; the container"
185
+ },
186
+ {
187
+ "attr": "data-p60-tab",
188
+ "value": "the key of the panel this control activates"
189
+ },
190
+ {
191
+ "attr": "data-p60-panel",
192
+ "value": "the key matching its control"
193
+ }
194
+ ],
195
+ "stateClasses": [
196
+ "is-active"
117
197
  ],
118
- "stateClasses": ["is-active"],
119
198
  "cssVars": []
120
199
  },
121
200
  {
@@ -123,17 +202,109 @@
123
202
  "status": "available",
124
203
  "label": "Carousel",
125
204
  "primaryAttribute": "data-p60-carousel",
126
- "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.",
205
+ "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, and hides inactive slides from assistive tech. Value \"auto\" enables auto-advance (data-p60-interval ms, min 3000, default 6000) and injects a rotation control (.p60-carousel-pause, first in the carousel's tab order) following the WAI-ARIA carousel pattern: hovering pauses and leaving resumes, keyboard focus entering the carousel stops rotation and leaving does not restart it, and the control is the only way back; is-paused sits on the carousel while rotation is stopped. Reduced motion disables auto-advance and the control while every other control keeps 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.",
127
206
  "attributes": [
128
- { "attr": "data-p60-carousel", "value": "none for manual; \"auto\" for auto-advance" },
129
- { "attr": "data-p60-slide", "value": "none; one per slide child" },
130
- { "attr": "data-p60-interval", "value": "optional auto-advance ms, min 3000 (default 6000)" },
131
- { "attr": "data-p60-carousel-prev", "value": "none; optional template-provided control" },
132
- { "attr": "data-p60-carousel-next", "value": "none; optional template-provided control" }
133
- ],
134
- "stateClasses": ["is-active"],
135
- "cssVars": ["--p60-carousel-index"],
136
- "injects": [".p60-carousel-dots", ".p60-carousel-dot", ".p60-carousel-dot--active"]
207
+ {
208
+ "attr": "data-p60-carousel",
209
+ "value": "none for manual; \"auto\" for auto-advance"
210
+ },
211
+ {
212
+ "attr": "data-p60-slide",
213
+ "value": "none; one per slide child"
214
+ },
215
+ {
216
+ "attr": "data-p60-interval",
217
+ "value": "optional auto-advance ms, min 3000 (default 6000)"
218
+ },
219
+ {
220
+ "attr": "data-p60-carousel-prev",
221
+ "value": "none; optional template-provided control"
222
+ },
223
+ {
224
+ "attr": "data-p60-carousel-next",
225
+ "value": "none; optional template-provided control"
226
+ }
227
+ ],
228
+ "stateClasses": [
229
+ "is-active",
230
+ "is-paused"
231
+ ],
232
+ "cssVars": [
233
+ "--p60-carousel-index"
234
+ ],
235
+ "injects": [
236
+ ".p60-carousel-dots",
237
+ ".p60-carousel-dot",
238
+ ".p60-carousel-dot--active",
239
+ ".p60-carousel-pause",
240
+ ".p60-carousel-pause--stopped"
241
+ ]
242
+ },
243
+ {
244
+ "name": "readingProgress",
245
+ "status": "available",
246
+ "label": "Reading progress",
247
+ "primaryAttribute": "data-p60-reading-progress",
248
+ "description": "A reading-position indicator. The engine writes --p60-reading-progress (0 to 1) on the marked element and keeps a progressbar role honest (aria-valuenow 0 to 100, an accessible name of \"Reading progress\" unless the template gives one); the bar's shape and motion are the template's CSS. With data-p60-reading-target naming an element's id the reading is that element's extent, otherwise the whole document; a target the page does not have leaves the bar static. is-started once past zero, is-complete at the end. Information, not decoration: it keeps updating under reduced motion. The bar is pure enhancement, so it may be hidden unconditionally in CSS: a no-JS visitor loses nothing.",
249
+ "attributes": [
250
+ {
251
+ "attr": "data-p60-reading-progress",
252
+ "value": "none; the indicator element"
253
+ },
254
+ {
255
+ "attr": "data-p60-reading-target",
256
+ "value": "optional id of the element whose extent is the reading (default: the document)"
257
+ }
258
+ ],
259
+ "stateClasses": [
260
+ "is-started",
261
+ "is-complete"
262
+ ],
263
+ "cssVars": [
264
+ "--p60-reading-progress"
265
+ ]
266
+ },
267
+ {
268
+ "name": "showMore",
269
+ "status": "available",
270
+ "label": "Show more and less",
271
+ "primaryAttribute": "data-p60-show-more",
272
+ "description": "A list that starts short. On a container whose entries carry data-p60-show-more-item and which holds (or is named by aria-controls from) a data-p60-show-more-toggle button: the engine hides every item past the visible count with the hidden attribute, so the no-JS render shows the complete list and needs no CSS scoping; the toggle expands and collapses, aria-expanded stays honest, is-expanded sits on the container, and a toggle whose attribute carries a value swaps its text to that value while expanded. A list with nothing to hide hides its toggle: there is never a dead control. Author the toggle to show only under .p60-js.",
273
+ "attributes": [
274
+ {
275
+ "attr": "data-p60-show-more",
276
+ "value": "optional number of items shown while collapsed (default 6)"
277
+ },
278
+ {
279
+ "attr": "data-p60-show-more-item",
280
+ "value": "none; on each entry"
281
+ },
282
+ {
283
+ "attr": "data-p60-show-more-toggle",
284
+ "value": "none, or the label to show while expanded; on a button inside the list or pointing at it with aria-controls"
285
+ }
286
+ ],
287
+ "stateClasses": [
288
+ "is-expanded"
289
+ ],
290
+ "cssVars": []
291
+ },
292
+ {
293
+ "name": "scrollspy",
294
+ "status": "available",
295
+ "label": "Scrollspy",
296
+ "primaryAttribute": "data-p60-scrollspy",
297
+ "description": "On a menu of in-page links (href=\"#id\"): the link whose section is being read gets is-current and aria-current=\"location\", where the current section is the target furthest down the page whose top has passed the activation line (the attribute value in px from the top of the viewport, default 96). Links keep navigating and nothing ever moves focus; links whose target is not on the page are ignored, and a menu with no targets is left alone. Information, not decoration: it keeps updating under reduced motion.",
298
+ "attributes": [
299
+ {
300
+ "attr": "data-p60-scrollspy",
301
+ "value": "none, or the activation line in px (default 96); on the menu"
302
+ }
303
+ ],
304
+ "stateClasses": [
305
+ "is-current"
306
+ ],
307
+ "cssVars": []
137
308
  },
138
309
  {
139
310
  "name": "nav",
@@ -142,13 +313,33 @@
142
313
  "primaryAttribute": "data-p60-nav",
143
314
  "description": "Your menu, made robust. You design the menu (markup, every pixel, the breakpoint); the engine supplies the interaction CSS :hover cannot: data-p60-nav on each nav root (several allowed), data-p60-nav-item on each group holding a data-p60-nav-toggle control and a data-p60-nav-menu panel (nesting allowed). Hover-capable pointers get hover intent (a short delay to open, a grace period to close) so crossing the gap between a toggle and its panel never closes it; keyboard, touch and pen toggle on the control (Enter/click, ArrowDown opens and focuses the first link; a mouse click on an already-hovered parent link navigates); Escape closes and returns focus to the toggle; a click outside or focus moving away closes; one item stays open per level and aria-expanded is kept honest. The burger: data-p60-nav-burger on a button whose aria-controls (or the attribute value) names the stacked panel; the engine toggles is-open on both, closes on Escape (returning focus), on a real navigation inside the panel, on an outside click, and when the burger disappears at a wider viewport. State is is-open on the item, the panel and the burger; under .p60-js show a panel only on is-open and keep your :hover/:focus-within rules scoped to html:not(.p60-js) for the no-JS render. Fit steps: when the root's row wraps or overflows, the engine steps is-fit-1, is-fit-2, is-fit-3 onto the root until it fits, re-measured on resize and once fonts settle, never on a stacked (column) nav; the platform's defaults shrink the member_menu pill at each step (the Port60 ID wordmark, then the text, leaving the person icon with its accessible label), and a template maps the same classes to anything else it wants to give up before the burger.",
144
315
  "attributes": [
145
- { "attr": "data-p60-nav", "value": "none; a nav root, several allowed" },
146
- { "attr": "data-p60-nav-item", "value": "none; a group holding one toggle and one menu, nestable" },
147
- { "attr": "data-p60-nav-toggle", "value": "none; the item's control (a link or button)" },
148
- { "attr": "data-p60-nav-menu", "value": "none; the item's panel" },
149
- { "attr": "data-p60-nav-burger", "value": "none, or the id of the panel when aria-controls is absent" }
316
+ {
317
+ "attr": "data-p60-nav",
318
+ "value": "none; a nav root, several allowed"
319
+ },
320
+ {
321
+ "attr": "data-p60-nav-item",
322
+ "value": "none; a group holding one toggle and one menu, nestable"
323
+ },
324
+ {
325
+ "attr": "data-p60-nav-toggle",
326
+ "value": "none; the item's control (a link or button)"
327
+ },
328
+ {
329
+ "attr": "data-p60-nav-menu",
330
+ "value": "none; the item's panel"
331
+ },
332
+ {
333
+ "attr": "data-p60-nav-burger",
334
+ "value": "none, or the id of the panel when aria-controls is absent"
335
+ }
336
+ ],
337
+ "stateClasses": [
338
+ "is-open",
339
+ "is-fit-1",
340
+ "is-fit-2",
341
+ "is-fit-3"
150
342
  ],
151
- "stateClasses": ["is-open", "is-fit-1", "is-fit-2", "is-fit-3"],
152
343
  "cssVars": []
153
344
  }
154
345
  ]
@@ -169,6 +169,9 @@
169
169
  "stickyCta",
170
170
  "lightbox",
171
171
  "tabs",
172
+ "readingProgress",
173
+ "showMore",
174
+ "scrollspy",
172
175
  "nav"
173
176
  ]
174
177
  },