@classic-homes/slides 0.2.1 → 0.3.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.
@@ -1,56 +1,154 @@
1
- // src/inline.ts
2
- var ESCAPES = {
3
- "&": "&",
4
- "<": "&lt;",
5
- ">": "&gt;",
6
- '"': "&quot;",
7
- "'": "&#39;"
1
+ // src/brand.ts
2
+ import { colors } from "@classic-homes/theme-tokens";
3
+ import { getContrastRatio } from "@classic-homes/theme-tokens/contrast";
4
+ import { z as z2 } from "zod";
5
+
6
+ // src/schema/common.ts
7
+ import { z } from "zod";
8
+ var Text = z.string().describe(
9
+ "Inline text. Supports **bold**, *highlight* (brand accent), `code`, [label](url) and newlines."
10
+ );
11
+ var Track = z.enum(["value", "build", "neutral"]).describe("Accent colour for the slide: value = red, build = teal, neutral = navy.");
12
+ var Background = z.enum(["paper", "navy", "slate", "red", "teal", "plum", "grey", "stone"]).describe(
13
+ 'Slide ground. "paper" is the default light grid; the others are solid brand colours with white text.'
14
+ );
15
+ var Image = z.preprocess(
16
+ (v) => typeof v === "string" ? { src: v } : v,
17
+ z.object({
18
+ src: z.string().min(1).describe(
19
+ "Relative file path (resolved from the deck file), https URL, data: URI, or asset:<id> in hosted decks."
20
+ ),
21
+ alt: z.string().default("").describe("Describe the image for screen readers. Empty only for pure decoration."),
22
+ fit: z.enum(["cover", "contain"]).default("cover").describe("cover crops to fill; contain shows the whole image."),
23
+ size: z.enum(["fill", "natural"]).default("fill").describe(
24
+ "fill stretches to the layout slot; natural keeps small images (logos, badges) at their own size."
25
+ ),
26
+ focus: z.string().optional().describe('CSS object-position used when cropping, e.g. "top", "center 30%".')
27
+ })
28
+ );
29
+ var Item = z.preprocess(
30
+ (v) => typeof v === "string" ? { text: v } : v,
31
+ z.object({
32
+ text: Text,
33
+ sub: Text.optional().describe("Smaller secondary line under the item.")
34
+ })
35
+ );
36
+ var Marker = z.enum(["square", "check"]).default("square").describe("Bullet style.");
37
+ var baseSlide = {
38
+ id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/).optional().describe("Stable slug for this slide (used by editors and agents to address it)."),
39
+ track: Track.optional(),
40
+ background: Background.optional(),
41
+ notes: z.string().optional().describe(
42
+ 'Speaker notes. Blank lines split paragraphs; "- " lines become bullets. Put detail here, not on the slide.'
43
+ ),
44
+ time: z.string().optional().describe('Timing cue shown in the notes panel, e.g. "4:00 \xB7 2 min".'),
45
+ step: z.number().int().positive().optional().describe(
46
+ "Journey step this slide belongs to. Draws the vertical STEP rail. Requires deck.journey."
47
+ ),
48
+ stepDone: z.boolean().optional().describe("Show the step rail with a completed check mark."),
49
+ showJourney: z.boolean().optional().describe(
50
+ 'Show the compact "you are here" journey strip at the top (uses deck.journey and step).'
51
+ ),
52
+ hidden: z.boolean().optional().describe("Skip this slide when presenting and exporting (kept in the editor)."),
53
+ backgroundImage: Image.optional().describe(
54
+ "Full-bleed photo behind the slide. Best on title, section and statement slides."
55
+ ),
56
+ overlay: z.enum(["dark", "light", "brand"]).optional().describe("Tint over the background image so text stays readable (default dark).")
8
57
  };
9
- function escapeHtml(value) {
10
- return value.replace(/[&<>"']/g, (c) => ESCAPES[c]);
11
- }
12
- function escapeAttr(value) {
13
- return escapeHtml(value);
14
- }
15
- var SAFE_URL = /^(https?:|mailto:|tel:|#|\/)/i;
16
- function inline(text) {
17
- if (!text) return "";
18
- const codes = [];
19
- let s = text.replace(/`([^`]+)`/g, (_, code) => {
20
- codes.push(code);
21
- return `\uE000${codes.length - 1}\uE000`;
22
- });
23
- const links = [];
24
- s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label, url) => {
25
- if (!SAFE_URL.test(url)) return label;
26
- links.push(`<a href="${escapeAttr(url)}">${formatEmphasis(escapeHtml(label))}</a>`);
27
- return `\uE001${links.length - 1}\uE001`;
28
- });
29
- s = formatEmphasis(escapeHtml(s));
30
- s = s.replace(/\r?\n/g, "<br>");
31
- s = s.replace(/\uE001(\d+)\uE001/g, (_, i) => links[Number(i)]);
32
- s = s.replace(
33
- /\uE000(\d+)\uE000/g,
34
- (_, i) => `<code>${escapeHtml(codes[Number(i)])}</code>`
35
- );
36
- return s;
37
- }
38
- function formatEmphasis(escaped) {
39
- return escaped.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/(^|[^*])\*(?!\s)(.+?)(?<!\s)\*(?!\*)/g, '$1<em class="hl">$2</em>');
58
+
59
+ // src/brand.ts
60
+ var Hex = z2.string().regex(/^#[0-9a-fA-F]{6}$/, "Use a 6-digit hex colour like #a91c22.");
61
+ var BrandPaletteSchema = z2.object({
62
+ primary: Hex.describe('Main accent: rails, bullets, highlights, "value" slides.'),
63
+ accent: Hex.describe('Second accent: "build" slides and secondary highlights.'),
64
+ dark: Hex.describe('Headings, navy grounds and "neutral" slides.')
65
+ }).strict();
66
+ var BrandProfileSchema = z2.object({
67
+ key: z2.string().regex(/^[a-z0-9][a-z0-9-]{1,40}$/, "Lowercase letters, numbers and dashes.").describe("Stable id used by decks (brand.profile)."),
68
+ name: z2.string().min(1).max(60).describe("Company or division name shown in the logo and tab title."),
69
+ tagline: z2.string().max(80).optional(),
70
+ logo: Image.optional().describe(
71
+ "Logo for light grounds. Omit to use the Classic Homes mark + name."
72
+ ),
73
+ logoOnDark: Image.optional().describe("Logo for dark/solid grounds (white version)."),
74
+ favicon: Image.optional().describe("Square icon for the browser tab."),
75
+ palette: BrandPaletteSchema.optional().describe(
76
+ "Replaces the Classic Homes colours for this brand."
77
+ ),
78
+ footer: z2.string().max(120).optional().describe("Default footer text on every slide.")
79
+ }).strict();
80
+ var DeckBrandSchema = z2.object({
81
+ profile: z2.string().optional().describe("Brand profile key; defaults to classic-homes."),
82
+ name: z2.string().min(1).max(60).optional().describe("Override the company name."),
83
+ tagline: z2.string().max(80).optional(),
84
+ logo: Image.optional().describe("Override the logo (light grounds)."),
85
+ logoOnDark: Image.optional().describe("Override the logo for dark grounds."),
86
+ favicon: Image.optional().describe("Override the browser-tab icon."),
87
+ footer: z2.string().max(120).optional().describe("Footer text on every slide.")
88
+ }).strict();
89
+ var CLASSIC_HOMES_PALETTE = {
90
+ primary: colors.red[700],
91
+ accent: colors.teal[700],
92
+ dark: colors.navy[700]
93
+ };
94
+ var CLASSIC_HOMES_BRAND = {
95
+ key: "classic-homes",
96
+ name: "Classic Homes",
97
+ palette: CLASSIC_HOMES_PALETTE
98
+ };
99
+ function resolveBrand(deckBrand, brands = []) {
100
+ const all = [CLASSIC_HOMES_BRAND, ...brands];
101
+ const key = deckBrand?.profile ?? CLASSIC_HOMES_BRAND.key;
102
+ const profile = [...all].reverse().find((b) => b.key === key) ?? CLASSIC_HOMES_BRAND;
103
+ const palette = profile.palette ?? CLASSIC_HOMES_PALETTE;
104
+ return {
105
+ key: profile.key,
106
+ name: deckBrand?.name ?? profile.name,
107
+ tagline: deckBrand?.tagline ?? profile.tagline,
108
+ footer: deckBrand?.footer ?? profile.footer,
109
+ logo: deckBrand?.logo ?? profile.logo,
110
+ logoOnDark: deckBrand?.logoOnDark ?? profile.logoOnDark,
111
+ favicon: deckBrand?.favicon ?? profile.favicon,
112
+ palette,
113
+ stock: palette.primary === CLASSIC_HOMES_PALETTE.primary && palette.accent === CLASSIC_HOMES_PALETTE.accent && palette.dark === CLASSIC_HOMES_PALETTE.dark
114
+ };
40
115
  }
41
- function prose(text) {
42
- if (!text) return "";
43
- return text.trim().split(/\n\s*\n/).map((para) => {
44
- const lines = para.split(/\n/);
45
- if (lines.every((l) => /^\s*[-*]\s+/.test(l))) {
46
- return `<ul>${lines.map((l) => `<li>${inline(l.replace(/^\s*[-*]\s+/, ""))}</li>`).join("")}</ul>`;
116
+ var PAPER = "#faf9f6";
117
+ function checkPalette(palette) {
118
+ const issues = [];
119
+ for (const field of ["primary", "accent", "dark"]) {
120
+ const c = palette[field];
121
+ const onPaper = getContrastRatio(c, PAPER);
122
+ const withWhite = getContrastRatio("#ffffff", c);
123
+ if (onPaper < 4.5 || withWhite < 4.5) {
124
+ issues.push({
125
+ field,
126
+ message: `${field} ${c} is too light for text (${onPaper.toFixed(1)}:1 on paper, ${withWhite.toFixed(1)}:1 under white text; both need 4.5:1). Pick a darker shade.`
127
+ });
47
128
  }
48
- return `<p>${inline(para)}</p>`;
49
- }).join("");
129
+ }
130
+ return issues;
131
+ }
132
+ function validateBrandProfile(input) {
133
+ const parsed = BrandProfileSchema.safeParse(input);
134
+ if (!parsed.success) {
135
+ return {
136
+ ok: false,
137
+ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message }))
138
+ };
139
+ }
140
+ const contrast = parsed.data.palette ? checkPalette(parsed.data.palette) : [];
141
+ if (contrast.length) {
142
+ return {
143
+ ok: false,
144
+ issues: contrast.map((c) => ({ path: ["palette", c.field], message: c.message }))
145
+ };
146
+ }
147
+ return { ok: true, profile: parsed.data };
50
148
  }
51
149
 
52
150
  // src/theme.ts
53
- import { colors } from "@classic-homes/theme-tokens";
151
+ import { colors as colors2 } from "@classic-homes/theme-tokens";
54
152
 
55
153
  // src/generated/fonts.ts
56
154
  var FONTS = {
@@ -65,29 +163,33 @@ var FONTS = {
65
163
 
66
164
  // src/theme.ts
67
165
  var PALETTE = {
68
- red: colors.red[700],
69
- redLogo: colors.red[500],
70
- teal: colors.teal[700],
166
+ red: colors2.red[700],
167
+ redLogo: colors2.red[500],
168
+ teal: colors2.teal[700],
71
169
  // brand teal 500 fails AA as small text on paper; 700 passes
72
- tealFill: colors.teal[700],
73
- navy: colors.navy[700],
74
- plum: colors.plum[700],
75
- sage: colors.sage[600],
76
- stone: colors.stone[700],
77
- grey: colors.gray[600],
78
- ink: colors.navy[700],
79
- inkSoft: colors.gray[600],
80
- inkFaint: colors.gray[500]
170
+ tealFill: colors2.teal[700],
171
+ navy: colors2.navy[700],
172
+ plum: colors2.plum[700],
173
+ sage: colors2.sage[600],
174
+ stone: colors2.stone[700],
175
+ grey: colors2.gray[600],
176
+ ink: colors2.navy[700],
177
+ inkSoft: colors2.gray[600],
178
+ inkFaint: colors2.gray[500]
81
179
  };
82
- function tokensCss() {
180
+ function tokensCss(palette) {
83
181
  const p = PALETTE;
182
+ const primary = palette?.primary ?? p.red;
183
+ const accent = palette?.accent ?? p.teal;
184
+ const dark = palette?.dark ?? p.navy;
185
+ const custom = !!palette && palette.primary !== p.red;
84
186
  return `:root{color-scheme:light;
85
187
  --paper:hsl(40 20% 97.5%);--paper-2:#fff;--paper-3:hsl(60 11% 96%);
86
- --ink:${p.ink};--ink-soft:${p.inkSoft};--ink-faint:${p.inkFaint};
188
+ --ink:${dark};--ink-soft:${p.inkSoft};--ink-faint:${p.inkFaint};
87
189
  --line:hsl(210 12% 80%);--line-strong:hsl(200 14% 55%);--line-faint:hsl(210 14% 90%);
88
- --red:${p.red};--red-logo:${p.redLogo};--red-soft:${colors.red[50]};
89
- --teal:${p.teal};--teal-fill:${p.tealFill};--teal-soft:${colors.teal[50]};
90
- --navy:${p.navy};--slate:${colors.navy[600]};--plum:${p.plum};--sage:${p.sage};--stone:${p.stone};--grey:${p.grey};
190
+ --red:${primary};--red-logo:${custom ? primary : p.redLogo};--red-soft:${custom ? `color-mix(in srgb,${primary} 8%,#fff)` : colors2.red[50]};
191
+ --teal:${accent};--teal-fill:${accent};--teal-soft:${custom ? `color-mix(in srgb,${accent} 8%,#fff)` : colors2.teal[50]};
192
+ --navy:${dark};--slate:${palette ? `color-mix(in srgb,${dark} 75%,#fff)` : colors2.navy[600]};--plum:${p.plum};--sage:${p.sage};--stone:${p.stone};--grey:${p.grey};
91
193
  --grid:hsl(196 30% 20% / .03);
92
194
  --shadow:0 1px 2px hsl(196 25% 20% / .04),0 6px 18px -10px hsl(196 25% 20% / .12);
93
195
  --font-display:"Spectral",Georgia,"Times New Roman",serif;
@@ -114,60 +216,453 @@ var LOGO_PATHS = [
114
216
  "M14.202 22.5c-.1-.3-.5-1.4-.6-2.3 0-.2.6-.5.7.1.1.6.4 1.6.6 1.9.3.5-.4.9-.7.3z",
115
217
  "M10.302 22.5c.1-.3.5-1.4.6-2.2 0-.3-.5-.6-.7 0-.1.6-.4 1.6-.6 1.9-.3.5.4.9.7.3z"
116
218
  ];
117
- var LOGO_SVG = `<span class="logo" role="img" aria-label="Classic Homes"><svg viewBox="0 0 25 25" aria-hidden="true"><g class="logo-mark">${LOGO_PATHS.map(
219
+ var LOGO_MARK_SVG = `<svg viewBox="0 0 25 25" aria-hidden="true"><g class="logo-mark">${LOGO_PATHS.map(
118
220
  (d) => `<path d="${d}"/>`
119
- ).join("")}</g></svg><span class="logo-word" aria-hidden="true">Classic Homes</span></span>`;
120
-
121
- // src/layouts/text.ts
122
- import { z as z2 } from "zod";
221
+ ).join("")}</g></svg>`;
222
+ var LOGO_SVG = logoLockup("Classic Homes");
223
+ function logoLockup(name, withMark = true) {
224
+ const safe = name.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
225
+ return `<span class="logo${withMark ? "" : " logo--word"}" role="img" aria-label="${safe}">${withMark ? LOGO_MARK_SVG : ""}<span class="logo-word" aria-hidden="true">${safe}</span></span>`;
226
+ }
123
227
 
124
- // src/schema/common.ts
125
- import { z } from "zod";
126
- var Text = z.string().describe(
127
- "Inline text. Supports **bold**, *highlight* (brand accent), `code`, [label](url) and newlines."
128
- );
129
- var Track = z.enum(["value", "build", "neutral"]).describe("Accent colour for the slide: value = red, build = teal, neutral = navy.");
130
- var Background = z.enum(["paper", "navy", "slate", "red", "teal", "plum", "grey", "stone"]).describe(
131
- 'Slide ground. "paper" is the default light grid; the others are solid brand colours with white text.'
132
- );
133
- var Image = z.preprocess(
134
- (v) => typeof v === "string" ? { src: v } : v,
135
- z.object({
136
- src: z.string().min(1).describe(
137
- "Relative file path (resolved from the deck file), https URL, data: URI, or asset:<id> in hosted decks."
138
- ),
139
- alt: z.string().default("").describe("Describe the image for screen readers. Empty only for pure decoration."),
140
- fit: z.enum(["cover", "contain"]).default("cover").describe("cover crops to fill; contain shows the whole image."),
141
- size: z.enum(["fill", "natural"]).default("fill").describe(
142
- "fill stretches to the layout slot; natural keeps small images (logos, badges) at their own size."
143
- ),
144
- focus: z.string().optional().describe('CSS object-position used when cropping, e.g. "top", "center 30%".')
145
- })
146
- );
147
- var Item = z.preprocess(
148
- (v) => typeof v === "string" ? { text: v } : v,
149
- z.object({
150
- text: Text,
151
- sub: Text.optional().describe("Smaller secondary line under the item.")
152
- })
153
- );
154
- var Marker = z.enum(["square", "check"]).default("square").describe("Bullet style.");
155
- var baseSlide = {
156
- id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/).optional().describe("Stable slug for this slide (used by editors and agents to address it)."),
157
- track: Track.optional(),
158
- background: Background.optional(),
159
- notes: z.string().optional().describe(
160
- 'Speaker notes. Blank lines split paragraphs; "- " lines become bullets. Put detail here, not on the slide.'
228
+ // src/charts.ts
229
+ import { z as z3 } from "zod";
230
+ var CHART_TYPES = [
231
+ "column",
232
+ "bar",
233
+ "stacked-column",
234
+ "stacked-bar",
235
+ "line",
236
+ "area",
237
+ "pie",
238
+ "donut",
239
+ "funnel",
240
+ "gauge",
241
+ "scatter",
242
+ "radar"
243
+ ];
244
+ var ChartSeries = z3.object({
245
+ name: z3.string().min(1).max(40),
246
+ values: z3.array(z3.number()).min(1).max(40)
247
+ });
248
+ var ChartSpec = z3.object({
249
+ type: z3.enum(CHART_TYPES).describe(
250
+ "column/bar compare categories; stacked-* show parts of a whole per category; line/area show change over time; pie/donut show shares (one series); funnel shows drop-off; gauge shows one value against a max; scatter pairs two series; radar compares profiles."
161
251
  ),
162
- time: z.string().optional().describe('Timing cue shown in the notes panel, e.g. "4:00 \xB7 2 min".'),
163
- step: z.number().int().positive().optional().describe(
164
- "Journey step this slide belongs to. Draws the vertical STEP rail. Requires deck.journey."
252
+ labels: z3.array(z3.string().max(40)).min(1).max(40).describe("Category labels (x-axis, slices, stages). One per value."),
253
+ series: z3.array(ChartSeries).min(1).max(6).describe(
254
+ "Data series; each has one value per label. Pie, donut, funnel and gauge use the first series."
165
255
  ),
166
- stepDone: z.boolean().optional().describe("Show the step rail with a completed check mark."),
167
- showJourney: z.boolean().optional().describe(
168
- 'Show the compact "you are here" journey strip at the top (uses deck.journey and step).'
169
- )
256
+ format: z3.enum(["number", "percent", "currency", "compact"]).default("number").describe("How values are shown. percent expects 0\u2013100."),
257
+ decimals: z3.number().int().min(0).max(3).default(0),
258
+ unit: z3.string().max(16).optional().describe('Suffix after values, e.g. "homes", "days".'),
259
+ legend: z3.enum(["auto", "show", "hide"]).default("auto"),
260
+ labelsOnData: z3.boolean().default(true).describe("Print values on bars, slices and points."),
261
+ highlight: z3.string().optional().describe("A label to emphasise; other categories are muted."),
262
+ max: z3.number().optional().describe("Axis or gauge maximum.")
263
+ }).strict();
264
+ function chartIssues(spec) {
265
+ const out = [];
266
+ spec.series.forEach((s) => {
267
+ if (s.values.length !== spec.labels.length) {
268
+ out.push(
269
+ `Series "${s.name}" has ${s.values.length} values but there are ${spec.labels.length} labels.`
270
+ );
271
+ }
272
+ });
273
+ if (spec.type === "scatter" && spec.series.length < 2)
274
+ out.push("Scatter needs two series (x and y).");
275
+ if (spec.type === "gauge" && spec.max === void 0) out.push("Gauge needs a max.");
276
+ return out;
277
+ }
278
+ function chartWarnings(spec) {
279
+ const out = [];
280
+ if (["pie", "donut"].includes(spec.type) && spec.labels.length > 7) {
281
+ out.push(
282
+ `${spec.labels.length} slices is hard to read; group small ones into "Other" or use a bar chart.`
283
+ );
284
+ }
285
+ if (["pie", "donut", "funnel", "gauge"].includes(spec.type) && spec.series.length > 1) {
286
+ out.push(`${spec.type} charts only show the first series.`);
287
+ }
288
+ if (spec.labels.length > 16 && ["column", "bar", "stacked-column", "stacked-bar"].includes(spec.type)) {
289
+ out.push("More than 16 categories crowds the axis; consider a line chart or fewer categories.");
290
+ }
291
+ return out;
292
+ }
293
+ function formatValue(v, spec) {
294
+ const opts = { minimumFractionDigits: spec.decimals, maximumFractionDigits: spec.decimals };
295
+ let s;
296
+ switch (spec.format) {
297
+ case "percent":
298
+ s = `${v.toLocaleString("en-US", opts)}%`;
299
+ break;
300
+ case "currency":
301
+ s = v.toLocaleString("en-US", { style: "currency", currency: "USD", ...opts });
302
+ break;
303
+ case "compact":
304
+ s = v.toLocaleString("en-US", {
305
+ notation: "compact",
306
+ maximumFractionDigits: Math.max(1, spec.decimals)
307
+ });
308
+ break;
309
+ default:
310
+ s = v.toLocaleString("en-US", opts);
311
+ }
312
+ return spec.unit ? `${s} ${spec.unit}` : s;
313
+ }
314
+ function chartColors(palette) {
315
+ const p = palette ?? { primary: PALETTE.red, accent: PALETTE.teal, dark: PALETTE.navy };
316
+ return [p.primary, p.accent, p.dark, PALETTE.sage, PALETTE.plum, PALETTE.stone];
317
+ }
318
+ var MUTED = "#c9ccc6";
319
+ var TEXT = "#4f4f4f";
320
+ var AXIS = "#d1d5db";
321
+ function chartOption(spec, palette) {
322
+ const colors3 = chartColors(palette);
323
+ const fmt = (v) => formatValue(v, spec);
324
+ const muted = (label, color) => spec.highlight && spec.highlight !== label ? MUTED : color;
325
+ const showLegend = spec.legend === "show" || spec.legend === "auto" && spec.series.length > 1;
326
+ const textStyle = { fontFamily: "Figtree, system-ui, sans-serif", color: TEXT, fontSize: 15 };
327
+ const base = {
328
+ animation: false,
329
+ color: colors3,
330
+ textStyle,
331
+ legend: showLegend ? { top: 0, textStyle: { ...textStyle, fontSize: 14 }, itemGap: 18 } : { show: false }
332
+ };
333
+ const first = spec.series[0];
334
+ const labelStyle = {
335
+ show: spec.labelsOnData,
336
+ color: TEXT,
337
+ fontSize: 13,
338
+ formatter: (p) => fmt(p.value)
339
+ };
340
+ if (spec.type === "pie" || spec.type === "donut") {
341
+ return {
342
+ ...base,
343
+ legend: spec.legend === "show" ? base.legend : { show: false },
344
+ series: [
345
+ {
346
+ type: "pie",
347
+ radius: spec.type === "donut" ? ["46%", "72%"] : [0, "72%"],
348
+ center: ["50%", "54%"],
349
+ data: spec.labels.map((name, i) => ({
350
+ name,
351
+ value: first.values[i],
352
+ itemStyle: { color: muted(name, colors3[i % colors3.length]) }
353
+ })),
354
+ label: {
355
+ show: true,
356
+ color: TEXT,
357
+ fontSize: 14,
358
+ formatter: (p) => spec.labelsOnData ? `${p.name}
359
+ ${fmt(p.value)}` : p.name
360
+ },
361
+ itemStyle: { borderColor: "#fff", borderWidth: 2 }
362
+ }
363
+ ]
364
+ };
365
+ }
366
+ if (spec.type === "funnel") {
367
+ return {
368
+ ...base,
369
+ legend: { show: false },
370
+ series: [
371
+ {
372
+ type: "funnel",
373
+ left: "12%",
374
+ width: "76%",
375
+ top: 16,
376
+ bottom: 16,
377
+ sort: "descending",
378
+ gap: 3,
379
+ data: spec.labels.map((name, i) => ({
380
+ name,
381
+ value: first.values[i],
382
+ itemStyle: { color: muted(name, colors3[i % colors3.length]) }
383
+ })),
384
+ label: {
385
+ show: true,
386
+ position: "inside",
387
+ color: "#fff",
388
+ fontSize: 14,
389
+ formatter: (p) => spec.labelsOnData ? `${p.name} ${fmt(p.value)}` : p.name
390
+ }
391
+ }
392
+ ]
393
+ };
394
+ }
395
+ if (spec.type === "gauge") {
396
+ return {
397
+ ...base,
398
+ series: [
399
+ {
400
+ type: "gauge",
401
+ min: 0,
402
+ max: spec.max ?? 100,
403
+ progress: { show: true, width: 22, itemStyle: { color: colors3[0] } },
404
+ axisLine: { lineStyle: { width: 22, color: [[1, "#e5e7eb"]] } },
405
+ axisTick: { show: false },
406
+ splitLine: { show: false },
407
+ axisLabel: { show: false },
408
+ pointer: { show: false },
409
+ anchor: { show: false },
410
+ title: { offsetCenter: [0, "30%"], fontSize: 16, color: TEXT },
411
+ detail: {
412
+ valueAnimation: false,
413
+ offsetCenter: [0, "-5%"],
414
+ fontSize: 44,
415
+ fontWeight: 600,
416
+ color: colors3[2],
417
+ formatter: (v) => fmt(v)
418
+ },
419
+ data: [{ value: first.values[0], name: spec.labels[0] }]
420
+ }
421
+ ]
422
+ };
423
+ }
424
+ if (spec.type === "radar") {
425
+ const max = spec.max ?? Math.max(...spec.series.flatMap((s) => s.values)) * 1.1;
426
+ return {
427
+ ...base,
428
+ radar: {
429
+ indicator: spec.labels.map((name) => ({ name, max })),
430
+ axisName: { color: TEXT, fontSize: 14 },
431
+ splitLine: { lineStyle: { color: AXIS } },
432
+ splitArea: { show: false },
433
+ axisLine: { lineStyle: { color: AXIS } }
434
+ },
435
+ series: [
436
+ {
437
+ type: "radar",
438
+ data: spec.series.map((s, i) => ({
439
+ name: s.name,
440
+ value: s.values,
441
+ areaStyle: { opacity: 0.12 },
442
+ lineStyle: { width: 2.5, color: colors3[i % colors3.length] },
443
+ itemStyle: { color: colors3[i % colors3.length] }
444
+ }))
445
+ }
446
+ ]
447
+ };
448
+ }
449
+ if (spec.type === "scatter") {
450
+ const [xs, ys] = spec.series;
451
+ return {
452
+ ...base,
453
+ legend: { show: false },
454
+ grid: { left: 70, right: 30, top: 30, bottom: 60, containLabel: true },
455
+ xAxis: {
456
+ type: "value",
457
+ name: xs.name,
458
+ nameLocation: "middle",
459
+ nameGap: 34,
460
+ axisLine: { lineStyle: { color: AXIS } },
461
+ splitLine: { lineStyle: { color: "#eef0ec" } }
462
+ },
463
+ yAxis: {
464
+ type: "value",
465
+ name: ys.name,
466
+ nameLocation: "middle",
467
+ nameGap: 50,
468
+ axisLine: { lineStyle: { color: AXIS } },
469
+ splitLine: { lineStyle: { color: "#eef0ec" } }
470
+ },
471
+ series: [
472
+ {
473
+ type: "scatter",
474
+ symbolSize: 16,
475
+ data: spec.labels.map((name, i) => ({
476
+ name,
477
+ value: [xs.values[i], ys.values[i]],
478
+ itemStyle: { color: muted(name, colors3[0]) }
479
+ })),
480
+ label: {
481
+ show: spec.labelsOnData,
482
+ formatter: (p) => p.name,
483
+ position: "top",
484
+ color: TEXT,
485
+ fontSize: 12
486
+ }
487
+ }
488
+ ]
489
+ };
490
+ }
491
+ const horizontal = spec.type === "bar" || spec.type === "stacked-bar";
492
+ const stacked = spec.type.startsWith("stacked");
493
+ const isLine = spec.type === "line" || spec.type === "area";
494
+ const category = {
495
+ type: "category",
496
+ data: spec.labels,
497
+ axisLine: { lineStyle: { color: AXIS } },
498
+ axisTick: { show: false },
499
+ axisLabel: { color: TEXT, fontSize: 14, interval: 0, hideOverlap: true },
500
+ boundaryGap: !isLine
501
+ };
502
+ const value = {
503
+ type: "value",
504
+ max: spec.max,
505
+ axisLine: { show: false },
506
+ splitLine: { lineStyle: { color: "#eef0ec" } },
507
+ axisLabel: {
508
+ color: "#7a7a7a",
509
+ fontSize: 13,
510
+ formatter: (v) => formatValue(v, { ...spec, unit: void 0 })
511
+ }
512
+ };
513
+ return {
514
+ ...base,
515
+ grid: { left: 16, right: 24, top: showLegend ? 48 : 20, bottom: 12, containLabel: true },
516
+ xAxis: horizontal ? value : category,
517
+ yAxis: horizontal ? { ...category, inverse: true } : value,
518
+ series: spec.series.map((s, si) => {
519
+ const color = colors3[si % colors3.length];
520
+ return {
521
+ type: isLine ? "line" : "bar",
522
+ name: s.name,
523
+ stack: stacked ? "total" : void 0,
524
+ smooth: false,
525
+ symbolSize: 8,
526
+ lineStyle: isLine ? { width: 3 } : void 0,
527
+ areaStyle: spec.type === "area" ? { opacity: 0.18 } : void 0,
528
+ barMaxWidth: 56,
529
+ itemStyle: stacked || isLine ? { color } : void 0,
530
+ data: s.values.map((v, i) => ({
531
+ value: v,
532
+ itemStyle: stacked || isLine ? void 0 : {
533
+ color: muted(spec.labels[i], color),
534
+ borderRadius: horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]
535
+ }
536
+ })),
537
+ label: {
538
+ ...labelStyle,
539
+ show: spec.labelsOnData && (!stacked || spec.series.length <= 3),
540
+ position: stacked ? "inside" : horizontal ? "right" : "top",
541
+ color: stacked ? "#fff" : TEXT
542
+ }
543
+ };
544
+ })
545
+ };
546
+ }
547
+ var renderer;
548
+ function loadChartRenderer() {
549
+ renderer ??= import("./echarts-render-2QVSBZ6V.js").then((m) => m.createRenderer());
550
+ return renderer;
551
+ }
552
+ async function renderChartSvg(spec, opts = {}) {
553
+ const width = opts.width ?? 960;
554
+ const height = opts.height ?? 520;
555
+ const r = await loadChartRenderer();
556
+ const svg = r.render(chartOption(spec, opts.palette), width, height);
557
+ const label = `${opts.title ? `${opts.title}: ` : ""}${describeChart(spec)}`.replace(
558
+ /[&<>"]/g,
559
+ (c) => `&#${c.charCodeAt(0)};`
560
+ );
561
+ return svg.replace(/^<svg([^>]*?) width="\d+" height="\d+"/, "<svg$1").replace(/^<svg/, `<svg role="img" aria-label="${label}" preserveAspectRatio="xMidYMid meet"`);
562
+ }
563
+ function describeChart(spec) {
564
+ const parts = spec.series.slice(0, ["pie", "donut", "funnel", "gauge"].includes(spec.type) ? 1 : 6).map(
565
+ (s) => `${s.name}: ${spec.labels.map((l, i) => `${l} ${formatValue(s.values[i] ?? 0, spec)}`).join(", ")}`
566
+ );
567
+ return `${spec.type.replace("-", " ")} chart. ${parts.join(". ")}.`;
568
+ }
569
+
570
+ // src/inline.ts
571
+ var ESCAPES = {
572
+ "&": "&amp;",
573
+ "<": "&lt;",
574
+ ">": "&gt;",
575
+ '"': "&quot;",
576
+ "'": "&#39;"
170
577
  };
578
+ function escapeHtml(value) {
579
+ return value.replace(/[&<>"']/g, (c) => ESCAPES[c]);
580
+ }
581
+ function escapeAttr(value) {
582
+ return escapeHtml(value);
583
+ }
584
+ var SAFE_URL = /^(https?:|mailto:|tel:|#|\/)/i;
585
+ function isSafeLinkUrl(url) {
586
+ return SAFE_URL.test(url);
587
+ }
588
+ function inline(text) {
589
+ if (!text) return "";
590
+ const codes = [];
591
+ let s = text.replace(/`([^`]+)`/g, (_, code) => {
592
+ codes.push(code);
593
+ return `\uE000${codes.length - 1}\uE000`;
594
+ });
595
+ const links = [];
596
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label, url) => {
597
+ if (!isSafeLinkUrl(url)) return label;
598
+ links.push(`<a href="${escapeAttr(url)}">${formatEmphasis(escapeHtml(label))}</a>`);
599
+ return `\uE001${links.length - 1}\uE001`;
600
+ });
601
+ s = formatEmphasis(escapeHtml(s));
602
+ s = s.replace(/\r?\n/g, "<br>");
603
+ s = s.replace(/\uE001(\d+)\uE001/g, (_, i) => links[Number(i)]);
604
+ s = s.replace(
605
+ /\uE000(\d+)\uE000/g,
606
+ (_, i) => `<code>${escapeHtml(codes[Number(i)])}</code>`
607
+ );
608
+ return s;
609
+ }
610
+ function formatEmphasis(escaped) {
611
+ return escaped.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/(^|[^*])\*(?!\s)(.+?)(?<!\s)\*(?!\*)/g, '$1<em class="hl">$2</em>');
612
+ }
613
+ function prose(text) {
614
+ if (!text) return "";
615
+ return text.trim().split(/\n\s*\n/).map((para) => {
616
+ const lines = para.split(/\n/);
617
+ if (lines.every((l) => /^\s*[-*]\s+/.test(l))) {
618
+ return `<ul>${lines.map((l) => `<li>${inline(l.replace(/^\s*[-*]\s+/, ""))}</li>`).join("")}</ul>`;
619
+ }
620
+ return `<p>${inline(para)}</p>`;
621
+ }).join("");
622
+ }
623
+
624
+ // src/video.ts
625
+ var YT = /^(?:https:\/\/)?(?:www\.|m\.)?(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([\w-]{11})/;
626
+ var VIMEO = /^(?:https:\/\/)?(?:www\.|player\.)?vimeo\.com\/(?:video\/)?(\d{6,12})/;
627
+ function parseVideoUrl(url) {
628
+ const yt = YT.exec(url);
629
+ if (yt) {
630
+ return {
631
+ provider: "youtube",
632
+ id: yt[1],
633
+ watchUrl: `https://www.youtube.com/watch?v=${yt[1]}`,
634
+ poster: `https://i.ytimg.com/vi/${yt[1]}/hqdefault.jpg`
635
+ };
636
+ }
637
+ const vm = VIMEO.exec(url);
638
+ if (vm) return { provider: "vimeo", id: vm[1], watchUrl: `https://vimeo.com/${vm[1]}` };
639
+ return void 0;
640
+ }
641
+ function videoEmbedUrl(ref, o) {
642
+ if (ref.provider === "youtube") {
643
+ const q2 = new URLSearchParams({
644
+ rel: "0",
645
+ modestbranding: "1",
646
+ playsinline: "1",
647
+ enablejsapi: "1"
648
+ });
649
+ if (o.start) q2.set("start", String(o.start));
650
+ if (o.end) q2.set("end", String(o.end));
651
+ if (o.muted) q2.set("mute", "1");
652
+ if (o.captions) q2.set("cc_load_policy", "1");
653
+ if (o.autoplay) q2.set("autoplay", "1");
654
+ return `https://www.youtube-nocookie.com/embed/${ref.id}?${q2}`;
655
+ }
656
+ const q = new URLSearchParams({ dnt: "1", title: "0", byline: "0", portrait: "0" });
657
+ if (o.muted) q.set("muted", "1");
658
+ if (o.autoplay) q.set("autoplay", "1");
659
+ if (o.captions) q.set("texttrack", "en");
660
+ const hash = o.start ? `#t=${o.start}s` : "";
661
+ return `https://player.vimeo.com/video/${ref.id}?${q}${hash}`;
662
+ }
663
+
664
+ // src/layouts/text.ts
665
+ import { z as z4 } from "zod";
171
666
 
172
667
  // src/layouts/parts.ts
173
668
  function eyebrow(text, sub) {
@@ -188,7 +683,7 @@ function items(list, marker = "square") {
188
683
  if (!list?.length) return "";
189
684
  const cls = marker === "check" ? "clean check" : "clean";
190
685
  return `<ul class="${cls}">${list.map(
191
- (i) => `<li>${inline(i.text)}${i.sub ? `<span class="sub">${inline(i.sub)}</span>` : ""}</li>`
686
+ (i) => `<li class="b-item">${inline(i.text)}${i.sub ? `<span class="sub">${inline(i.sub)}</span>` : ""}</li>`
192
687
  ).join("")}</ul>`;
193
688
  }
194
689
  function img(image2, ctx, cls = "") {
@@ -249,9 +744,9 @@ var title = defineLayout({
249
744
  title: Text,
250
745
  lead: Text.optional(),
251
746
  image: Image.optional().describe("Hero photo under the title."),
252
- meta: z2.array(Text).max(4).optional().describe("Short facts on one line, e.g. URL, date, presenter."),
253
- logo: z2.boolean().default(true).describe("Show the Classic Homes logo."),
254
- align: z2.enum(["left", "center"]).default("left")
747
+ meta: z4.array(Text).max(4).optional().describe("Short facts on one line, e.g. URL, date, presenter."),
748
+ logo: z4.boolean().default(true).describe("Show the Classic Homes logo."),
749
+ align: z4.enum(["left", "center"]).default("left")
255
750
  },
256
751
  defaults: { track: "neutral" },
257
752
  example: {
@@ -267,7 +762,7 @@ var title = defineLayout({
267
762
  ],
268
763
  render: (s, ctx) => ({
269
764
  classes: ["title-wrap", `align-${s.align}`],
270
- html: `${s.logo ? `<div class="brand-logo">${LOGO_SVG}</div>` : ""}${s.kicker ? `<p class="kicker">${inline(s.kicker)}</p>` : ""}${heading(s.title, 1)}${lead(s.lead)}${s.image ? `<div class="hero">${img(s.image, ctx)}</div>` : ""}${s.meta?.length ? `<div class="title-meta">${s.meta.map((m) => `<span>${inline(m)}</span>`).join("")}</div>` : ""}`
765
+ html: `${s.logo ? `<div class="brand-logo">${ctx.logo()}</div>` : ""}${s.kicker ? `<p class="kicker">${inline(s.kicker)}</p>` : ""}${heading(s.title, 1)}${lead(s.lead)}${s.image ? `<div class="hero">${img(s.image, ctx)}</div>` : ""}${s.meta?.length ? `<div class="title-meta">${s.meta.map((m) => `<span>${inline(m)}</span>`).join("")}</div>` : ""}`
271
766
  })
272
767
  });
273
768
  var section = defineLayout({
@@ -278,10 +773,10 @@ var section = defineLayout({
278
773
  kicker: Text.optional(),
279
774
  title: Text,
280
775
  lead: Text.optional(),
281
- images: z2.array(Image).max(12).optional().describe(
776
+ images: z4.array(Image).max(12).optional().describe(
282
777
  "Optional photos under the title (e.g. the team for this step). Rows wrap after 6."
283
778
  ),
284
- imageAspect: z2.enum(["portrait", "landscape", "native"]).default("portrait").describe(
779
+ imageAspect: z4.enum(["portrait", "landscape", "native"]).default("portrait").describe(
285
780
  "portrait suits headshots; landscape/native suit a single scene photo (shown wide)."
286
781
  )
287
782
  },
@@ -313,8 +808,8 @@ var statement = defineLayout({
313
808
  eyebrowSub: Text.optional(),
314
809
  title: Text,
315
810
  lead: Text.optional(),
316
- style: z2.enum(["headline", "display"]).default("headline").describe("display = large italic serif."),
317
- logo: z2.boolean().default(false)
811
+ style: z4.enum(["headline", "display"]).default("headline").describe("display = large italic serif."),
812
+ logo: z4.boolean().default(false)
318
813
  },
319
814
  example: {
320
815
  layout: "statement",
@@ -326,8 +821,8 @@ var statement = defineLayout({
326
821
  ...maxLen(s.title, 110, ["title"], "Headline"),
327
822
  ...maxLen(s.lead, 240, ["lead"], "Lead")
328
823
  ],
329
- render: (s) => ({
330
- html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title, s.style === "display" ? 1 : 2)}${lead(s.lead)}${s.logo ? `<div class="brand-logo end">${LOGO_SVG}</div>` : ""}`
824
+ render: (s, ctx) => ({
825
+ html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title, s.style === "display" ? 1 : 2)}${lead(s.lead)}${s.logo ? `<div class="brand-logo end">${ctx.logo()}</div>` : ""}`
331
826
  })
332
827
  });
333
828
  var bullets = defineLayout({
@@ -339,10 +834,11 @@ var bullets = defineLayout({
339
834
  eyebrowSub: Text.optional(),
340
835
  title: Text,
341
836
  lead: Text.optional(),
342
- items: z2.array(Item).min(1),
837
+ items: z4.array(Item).min(1),
343
838
  marker: Marker,
344
- columns: z2.union([z2.literal(1), z2.literal(2)]).default(1),
345
- footnote: Text.optional().describe("Closing line under the list.")
839
+ columns: z4.union([z4.literal(1), z4.literal(2)]).default(1),
840
+ footnote: Text.optional().describe("Closing line under the list."),
841
+ build: z4.boolean().default(false).describe("Reveal one bullet per click while presenting.")
346
842
  },
347
843
  example: {
348
844
  layout: "bullets",
@@ -368,7 +864,7 @@ var bullets = defineLayout({
368
864
  const half = Math.ceil(s.items.length / 2);
369
865
  const list = s.columns === 2 ? `<div class="cols even">${items(s.items.slice(0, half), s.marker)}${items(s.items.slice(half), s.marker)}</div>` : items(s.items, s.marker);
370
866
  return {
371
- html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}${list}${footnote(s.footnote)}`
867
+ html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}${s.build ? `<div data-build>${list}</div>` : list}${footnote(s.footnote)}`
372
868
  };
373
869
  }
374
870
  });
@@ -381,17 +877,18 @@ var cards = defineLayout({
381
877
  eyebrowSub: Text.optional(),
382
878
  title: Text,
383
879
  lead: Text.optional(),
384
- columns: z2.number().int().min(2).max(4).default(3),
385
- style: z2.enum(["card", "banner"]).default("card").describe("banner = coloured header band per card."),
386
- cards: z2.array(
387
- z2.object({
880
+ columns: z4.number().int().min(2).max(4).default(3),
881
+ style: z4.enum(["card", "banner"]).default("card").describe("banner = coloured header band per card."),
882
+ cards: z4.array(
883
+ z4.object({
388
884
  tag: Text.optional(),
389
885
  title: Text,
390
886
  body: Text.optional(),
391
887
  image: Image.optional()
392
888
  })
393
889
  ).min(1),
394
- footnote: Text.optional().describe("Closing line under the cards.")
890
+ footnote: Text.optional().describe("Closing line under the cards."),
891
+ build: z4.boolean().default(false).describe("Reveal one card per click while presenting.")
395
892
  },
396
893
  example: {
397
894
  layout: "cards",
@@ -416,8 +913,8 @@ var cards = defineLayout({
416
913
  ])
417
914
  ],
418
915
  render: (s, ctx) => ({
419
- html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}<div class="cardgrid cards--${s.style}" style="--cols:${s.columns}">${s.cards.map(
420
- (c) => `<div class="card">${c.image ? img(c.image, ctx, "card-img") : ""}${s.style === "banner" ? `<div class="card-band">${inline(c.tag ?? c.title)}</div><div class="card-b">${c.tag ? `<h3>${inline(c.title)}</h3>` : ""}${c.body ? `<p>${inline(c.body)}</p>` : ""}</div>` : `<div class="card-b">${c.tag ? `<span class="tag">${inline(c.tag)}</span>` : ""}<h3>${inline(
916
+ html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}<div class="cardgrid cards--${s.style}"${s.build ? " data-build" : ""} style="--cols:${s.columns}">${s.cards.map(
917
+ (c) => `<div class="card b-item">${c.image ? img(c.image, ctx, "card-img") : ""}${s.style === "banner" ? `<div class="card-band">${inline(c.tag ?? c.title)}</div><div class="card-b">${c.tag ? `<h3>${inline(c.title)}</h3>` : ""}${c.body ? `<p>${inline(c.body)}</p>` : ""}</div>` : `<div class="card-b">${c.tag ? `<span class="tag">${inline(c.tag)}</span>` : ""}<h3>${inline(
421
918
  c.title
422
919
  )}</h3>${c.body ? `<p>${inline(c.body)}</p>` : ""}</div>`}</div>`
423
920
  ).join("")}</div>${footnote(s.footnote)}`
@@ -432,8 +929,15 @@ var stats = defineLayout({
432
929
  eyebrowSub: Text.optional(),
433
930
  title: Text,
434
931
  lead: Text.optional(),
435
- stats: z2.array(
436
- z2.object({ value: z2.string().max(12).describe('Short: "60", "2 wks", "3\xD7".'), label: Text })
932
+ stats: z4.array(
933
+ z4.object({
934
+ value: z4.string().max(12).describe('Short: "60", "2 wks", "3\xD7".'),
935
+ label: Text,
936
+ trend: z4.enum(["up", "down", "flat"]).optional().describe("Arrow next to the value."),
937
+ delta: z4.string().max(16).optional().describe('Change text beside the arrow, e.g. "+12%".'),
938
+ good: z4.enum(["up", "down"]).default("up").describe("Which direction is good news (colours the arrow)."),
939
+ spark: z4.array(z4.number()).min(2).max(40).optional().describe("Recent values drawn as a tiny line under the number.")
940
+ })
437
941
  ).min(1)
438
942
  },
439
943
  example: {
@@ -443,7 +947,14 @@ var stats = defineLayout({
443
947
  stats: [
444
948
  { value: "60", label: "days from contract to final selections" },
445
949
  { value: "14", label: "construction stages" },
446
- { value: "2", label: "site walkthroughs with your superintendent" }
950
+ { value: "2", label: "site walkthroughs with your superintendent" },
951
+ {
952
+ value: "4.8",
953
+ label: "average homeowner rating",
954
+ trend: "up",
955
+ delta: "+0.3",
956
+ spark: [4.2, 4.4, 4.5, 4.8]
957
+ }
447
958
  ]
448
959
  },
449
960
  lint: (s) => [
@@ -452,7 +963,7 @@ var stats = defineLayout({
452
963
  ],
453
964
  render: (s) => ({
454
965
  html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}<div class="stat-row">${s.stats.map(
455
- (st) => `<div class="stat"><b>${inline(st.value)}</b><span>${inline(st.label)}</span></div>`
966
+ (st) => `<div class="stat"><b>${inline(st.value)}</b>${st.trend ? `<em class="trend trend--${st.trend === "flat" ? "flat" : st.trend === st.good ? "good" : "bad"}">${st.trend === "up" ? "\u25B2" : st.trend === "down" ? "\u25BC" : "\u25B6"}${st.delta ? ` ${inline(st.delta)}` : ""}</em>` : ""}${st.spark ? sparkline(st.spark) : ""}<span>${inline(st.label)}</span></div>`
456
967
  ).join("")}</div>`
457
968
  })
458
969
  });
@@ -465,12 +976,12 @@ var comparison = defineLayout({
465
976
  eyebrowSub: Text.optional(),
466
977
  title: Text,
467
978
  lead: Text.optional(),
468
- columns: z2.array(
469
- z2.object({
979
+ columns: z4.array(
980
+ z4.object({
470
981
  heading: Text,
471
982
  tone: Track.default("value"),
472
983
  text: Text.optional(),
473
- items: z2.array(Item).optional(),
984
+ items: z4.array(Item).optional(),
474
985
  marker: Marker
475
986
  })
476
987
  ).min(2).max(3)
@@ -492,53 +1003,549 @@ var comparison = defineLayout({
492
1003
  }
493
1004
  ]
494
1005
  },
495
- lint: (s) => [
496
- ...maxLen(s.title, 80, ["title"], "Title"),
497
- ...s.columns.flatMap(
498
- (c, n) => maxCount(c.items, 6, ["columns", n, "items"], "items in one column")
499
- )
500
- ],
1006
+ lint: (s) => [
1007
+ ...maxLen(s.title, 80, ["title"], "Title"),
1008
+ ...s.columns.flatMap(
1009
+ (c, n) => maxCount(c.items, 6, ["columns", n, "items"], "items in one column")
1010
+ )
1011
+ ],
1012
+ render: (s) => ({
1013
+ html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}<div class="split2" style="--cols:${s.columns.length}">${s.columns.map(
1014
+ (c) => `<div class="col tone-${c.tone}"><h3>${inline(c.heading)}</h3>${c.text ? `<p>${inline(c.text)}</p>` : ""}${items(
1015
+ c.items,
1016
+ c.marker
1017
+ )}</div>`
1018
+ ).join("")}</div>`
1019
+ })
1020
+ });
1021
+ var quote = defineLayout({
1022
+ name: "quote",
1023
+ description: "A pull quote with attribution, optional portrait.",
1024
+ whenToUse: "A homeowner testimonial, a value statement, a line from leadership.",
1025
+ shape: {
1026
+ eyebrow: Text.optional(),
1027
+ quote: Text,
1028
+ cite: Text.optional(),
1029
+ image: Image.optional().describe("Portrait of the person quoted.")
1030
+ },
1031
+ example: {
1032
+ layout: "quote",
1033
+ quote: "They walked us through every step. We always knew what came next.",
1034
+ cite: "The Martinez family \xB7 Wolf Ranch"
1035
+ },
1036
+ lint: (s) => [
1037
+ ...maxLen(s.quote, 240, ["quote"], "Quote"),
1038
+ ...missingAlt(s.image ? [s.image] : [], ["image"])
1039
+ ],
1040
+ render: (s, ctx) => ({
1041
+ html: `${eyebrow(s.eyebrow)}<div class="${s.image ? "cols quote-cols" : ""}"><blockquote class="quote">${inline(
1042
+ s.quote
1043
+ )}${s.cite ? `<cite>${inline(s.cite)}</cite>` : ""}</blockquote>${s.image ? `<div class="portrait">${img(s.image, ctx)}</div>` : ""}</div>`
1044
+ })
1045
+ });
1046
+ function sparkline(values) {
1047
+ const w = 120;
1048
+ const h = 32;
1049
+ const min = Math.min(...values);
1050
+ const span = Math.max(...values) - min || 1;
1051
+ const pts = values.map(
1052
+ (v, i) => `${(i / (values.length - 1) * w).toFixed(1)},${(h - 3 - (v - min) / span * (h - 6)).toFixed(1)}`
1053
+ ).join(" ");
1054
+ const last = pts.split(" ").pop().split(",");
1055
+ return `<svg class="spark" viewBox="0 0 ${w} ${h}" aria-hidden="true"><polyline points="${pts}"/><circle cx="${last[0]}" cy="${last[1]}" r="3"/></svg>`;
1056
+ }
1057
+
1058
+ // src/layouts/data.ts
1059
+ import { z as z5 } from "zod";
1060
+ var CHART_SIZE = { wide: { width: 1040, height: 500 }, side: { width: 720, height: 500 } };
1061
+ var chart = defineLayout({
1062
+ name: "chart",
1063
+ description: "A branded chart (column, bar, stacked, line, area, pie, donut, funnel, gauge, scatter, radar) with an optional takeaway column.",
1064
+ whenToUse: "Numbers that tell a story: sales by quarter, timelines, shares, survey scores. State the takeaway in the title.",
1065
+ shape: {
1066
+ eyebrow: Text.optional(),
1067
+ eyebrowSub: Text.optional(),
1068
+ title: Text.describe('Say what the chart shows, e.g. "Closings doubled since spring".'),
1069
+ lead: Text.optional(),
1070
+ chart: ChartSpec,
1071
+ insights: z5.array(Item).max(4).optional().describe("Up to 4 takeaways shown beside the chart."),
1072
+ source: Text.optional().describe("Where the numbers come from.")
1073
+ },
1074
+ example: {
1075
+ layout: "chart",
1076
+ eyebrow: "Sales",
1077
+ title: "Closings *doubled* since spring",
1078
+ chart: {
1079
+ type: "column",
1080
+ labels: ["Q1", "Q2", "Q3", "Q4"],
1081
+ series: [{ name: "Closings", values: [42, 61, 88, 94] }],
1082
+ unit: "homes",
1083
+ highlight: "Q4"
1084
+ },
1085
+ insights: ["Q4 best quarter on record", "**+124%** vs Q1"],
1086
+ source: "Sales ops, closed contracts"
1087
+ },
1088
+ prepare: (s) => ({
1089
+ charts: [
1090
+ {
1091
+ key: JSON.stringify(s.chart),
1092
+ spec: s.chart,
1093
+ ...s.insights?.length ? CHART_SIZE.side : CHART_SIZE.wide
1094
+ }
1095
+ ]
1096
+ }),
1097
+ lint: (s) => [
1098
+ ...maxLen(s.title, 80, ["title"], "Title"),
1099
+ ...chartIssues(s.chart).map(
1100
+ (message) => ({ level: "error", path: ["chart"], message })
1101
+ ),
1102
+ ...chartWarnings(s.chart).map(
1103
+ (message) => ({ level: "warning", path: ["chart"], message })
1104
+ )
1105
+ ],
1106
+ render: (s, ctx) => {
1107
+ const svg = `<figure class="chart-fig">${ctx.chart(JSON.stringify(s.chart))}${s.source ? `<figcaption class="source">Source: ${inline(s.source)}</figcaption>` : ""}</figure>`;
1108
+ return {
1109
+ html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}${s.insights?.length ? `<div class="chart-split">${svg}<div class="insights">${items(s.insights)}</div></div>` : svg}`
1110
+ };
1111
+ }
1112
+ });
1113
+ var Cell = z5.union([z5.string().max(80), z5.number()]);
1114
+ var table = defineLayout({
1115
+ name: "table",
1116
+ description: "A clean data table: header row, aligned numbers, optional highlighted row/column and totals row.",
1117
+ whenToUse: "Exact figures people will compare: pricing, schedules, specs, option matrices.",
1118
+ shape: {
1119
+ eyebrow: Text.optional(),
1120
+ eyebrowSub: Text.optional(),
1121
+ title: Text,
1122
+ lead: Text.optional(),
1123
+ columns: z5.array(
1124
+ z5.object({
1125
+ label: Text,
1126
+ align: z5.enum(["left", "center", "right"]).optional().describe("Defaults to right for numbers."),
1127
+ format: z5.enum(["text", "number", "percent", "currency"]).default("text")
1128
+ })
1129
+ ).min(1).max(8),
1130
+ rows: z5.array(z5.array(Cell)).min(1).max(14).describe("One array per row, one cell per column. Text cells support inline formatting."),
1131
+ totals: z5.array(Cell).optional().describe("Optional bold totals row."),
1132
+ highlightRow: z5.number().int().positive().optional().describe("1-based row to emphasise."),
1133
+ highlightColumn: z5.number().int().positive().optional().describe("1-based column to emphasise."),
1134
+ footnote: Text.optional()
1135
+ },
1136
+ example: {
1137
+ layout: "table",
1138
+ eyebrow: "Options",
1139
+ title: "Smart home packages",
1140
+ columns: [
1141
+ { label: "Package" },
1142
+ { label: "Devices", format: "number" },
1143
+ { label: "Price", format: "currency" }
1144
+ ],
1145
+ rows: [
1146
+ ["Essentials", 6, 1450],
1147
+ ["Connected", 11, 2900],
1148
+ ["Premier", 18, 4800]
1149
+ ],
1150
+ highlightRow: 2,
1151
+ footnote: "Prices per home; installed before closing."
1152
+ },
1153
+ lint: (s) => {
1154
+ const out = [...maxLen(s.title, 80, ["title"], "Title")];
1155
+ s.rows.forEach((r, i) => {
1156
+ if (r.length !== s.columns.length) {
1157
+ out.push({
1158
+ level: "error",
1159
+ path: ["rows", i],
1160
+ message: `Row has ${r.length} cells but there are ${s.columns.length} columns.`
1161
+ });
1162
+ }
1163
+ });
1164
+ if (s.totals && s.totals.length !== s.columns.length) {
1165
+ out.push({
1166
+ level: "error",
1167
+ path: ["totals"],
1168
+ message: `Totals has ${s.totals.length} cells but there are ${s.columns.length} columns.`
1169
+ });
1170
+ }
1171
+ if (s.rows.length > 10) {
1172
+ out.push({
1173
+ level: "warning",
1174
+ path: ["rows"],
1175
+ message: `${s.rows.length} rows is dense for a slide; split or summarise.`
1176
+ });
1177
+ }
1178
+ return out;
1179
+ },
1180
+ render: (s) => {
1181
+ const cell = (v, ci) => {
1182
+ const col = s.columns[ci];
1183
+ if (typeof v === "number" && col.format !== "text") {
1184
+ return formatValue(v, {
1185
+ format: col.format === "currency" ? "currency" : col.format === "percent" ? "percent" : "number",
1186
+ decimals: 0
1187
+ });
1188
+ }
1189
+ return typeof v === "number" ? escapeHtml(String(v)) : inline(v);
1190
+ };
1191
+ const align = (ci) => s.columns[ci].align ?? (s.columns[ci].format === "text" ? "left" : "right");
1192
+ const td = (tag, v, ci) => `<${tag} class="al-${align(ci)}${s.highlightColumn === ci + 1 ? " hl-col" : ""}">${cell(v, ci)}</${tag}>`;
1193
+ return {
1194
+ html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}<div class="table-wrap"><table class="data-table"><thead><tr>${s.columns.map(
1195
+ (c, ci) => `<th scope="col" class="al-${align(ci)}${s.highlightColumn === ci + 1 ? " hl-col" : ""}">${inline(c.label)}</th>`
1196
+ ).join("")}</tr></thead><tbody>${s.rows.map(
1197
+ (r, ri) => `<tr${s.highlightRow === ri + 1 ? ' class="hl-row"' : ""}>${r.map((v, ci) => td(ci === 0 ? "th" : "td", v, ci)).join("")}</tr>`
1198
+ ).join("")}</tbody>${s.totals ? `<tfoot><tr>${s.totals.map((v, ci) => td(ci === 0 ? "th" : "td", v, ci)).join("")}</tr></tfoot>` : ""}</table></div>${footnote(s.footnote)}`
1199
+ };
1200
+ }
1201
+ });
1202
+
1203
+ // src/layouts/diagrams.ts
1204
+ import { z as z6 } from "zod";
1205
+ var Build = z6.boolean().default(false).describe("Reveal one item per click while presenting.");
1206
+ var Step = z6.object({
1207
+ title: Text,
1208
+ text: Text.optional()
1209
+ });
1210
+ var buildAttr = (on) => on ? " data-build" : "";
1211
+ var flow = defineLayout({
1212
+ name: "flow",
1213
+ description: "A process: 2\u20137 steps joined by arrows, left-to-right or top-to-bottom, with an optional end pill.",
1214
+ whenToUse: "How something happens in order: approval paths, what comes next, hand-offs.",
1215
+ shape: {
1216
+ eyebrow: Text.optional(),
1217
+ eyebrowSub: Text.optional(),
1218
+ title: Text,
1219
+ lead: Text.optional(),
1220
+ steps: z6.array(Step).min(2).max(7),
1221
+ direction: z6.enum(["horizontal", "vertical"]).default("horizontal"),
1222
+ numbered: z6.boolean().default(true),
1223
+ end: Text.optional().describe('Pill after the last step, e.g. "Step 3" or "Keys!".'),
1224
+ build: Build,
1225
+ footnote: Text.optional()
1226
+ },
1227
+ example: {
1228
+ layout: "flow",
1229
+ eyebrow: "Step 2",
1230
+ title: "What comes next",
1231
+ steps: [
1232
+ { title: "Personalized Color Chart", text: "Design timeline \u2014 60 days from contract" },
1233
+ { title: "New Home Specialists", text: "Home Start Review" }
1234
+ ],
1235
+ end: "Step 3",
1236
+ direction: "vertical"
1237
+ },
1238
+ lint: (s) => [
1239
+ ...maxLen(s.title, 80, ["title"], "Title"),
1240
+ ...s.steps.flatMap((st, i) => maxLen(st.text, 110, ["steps", i, "text"], "Step text")),
1241
+ ...s.direction === "horizontal" && s.steps.length > 5 ? [
1242
+ {
1243
+ level: "warning",
1244
+ path: ["steps"],
1245
+ message: "More than 5 steps side by side gets cramped; try direction: vertical."
1246
+ }
1247
+ ] : []
1248
+ ],
1249
+ render: (s) => ({
1250
+ html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}<ol class="flow flow--${s.direction}"${buildAttr(
1251
+ s.build
1252
+ )} style="--n:${s.steps.length + (s.end ? 1 : 0)}">${s.steps.map(
1253
+ (st, i) => `<li class="flow-step b-item">${s.numbered ? `<span class="flow-n">${i + 1}</span>` : ""}<div><h3>${inline(
1254
+ st.title
1255
+ )}</h3>${st.text ? `<p>${inline(st.text)}</p>` : ""}</div></li>`
1256
+ ).join(
1257
+ ""
1258
+ )}${s.end ? `<li class="flow-end b-item"><span>${inline(s.end)}</span></li>` : ""}</ol>${footnote(s.footnote)}`
1259
+ })
1260
+ });
1261
+ var timeline = defineLayout({
1262
+ name: "timeline",
1263
+ description: "Dated milestones along a line, optionally grouped into tinted phases, with the current point marked.",
1264
+ whenToUse: "Schedules and histories: build timeline, closing countdown, company milestones.",
1265
+ shape: {
1266
+ eyebrow: Text.optional(),
1267
+ eyebrowSub: Text.optional(),
1268
+ title: Text,
1269
+ lead: Text.optional(),
1270
+ items: z6.array(
1271
+ z6.object({
1272
+ date: z6.string().max(24).describe('Short: "Week 1", "Mar 2026", "Day 60".'),
1273
+ title: Text,
1274
+ text: Text.optional()
1275
+ })
1276
+ ).min(2).max(10),
1277
+ phases: z6.array(
1278
+ z6.object({
1279
+ label: Text,
1280
+ from: z6.number().int().positive().describe("First item in the phase (1-based)."),
1281
+ to: z6.number().int().positive().describe("Last item in the phase (1-based).")
1282
+ })
1283
+ ).max(4).optional(),
1284
+ current: z6.number().int().positive().optional().describe('Item to mark as "you are here" (1-based).'),
1285
+ build: Build
1286
+ },
1287
+ example: {
1288
+ layout: "timeline",
1289
+ eyebrow: "Your guide",
1290
+ title: "The whole path \u2014 from contract to year eight",
1291
+ items: [
1292
+ { date: "01", title: "Introduction" },
1293
+ { date: "02", title: "Purchasing" },
1294
+ { date: "03", title: "Design" },
1295
+ { date: "04", title: "Construction" },
1296
+ { date: "05", title: "Closing" },
1297
+ { date: "06", title: "Warranty" },
1298
+ { date: "07", title: "Use & Care" },
1299
+ { date: "08", title: "HOA & Metro" }
1300
+ ],
1301
+ phases: [
1302
+ { label: "Getting to your home", from: 1, to: 5 },
1303
+ { label: "Living in your home", from: 6, to: 8 }
1304
+ ],
1305
+ current: 5
1306
+ },
1307
+ lint: (s) => {
1308
+ const out = [...maxLen(s.title, 80, ["title"], "Title")];
1309
+ (s.phases ?? []).forEach((p, i) => {
1310
+ if (p.from > p.to || p.to > s.items.length) {
1311
+ out.push({
1312
+ level: "error",
1313
+ path: ["phases", i],
1314
+ message: `Phase covers items ${p.from}\u2013${p.to} but there are ${s.items.length} items.`
1315
+ });
1316
+ }
1317
+ });
1318
+ if (s.current && s.current > s.items.length) {
1319
+ out.push({
1320
+ level: "error",
1321
+ path: ["current"],
1322
+ message: `current is ${s.current} but there are ${s.items.length} items.`
1323
+ });
1324
+ }
1325
+ return out;
1326
+ },
1327
+ render: (s) => {
1328
+ const phaseOf = (i) => (s.phases ?? []).findIndex((p) => i + 1 >= p.from && i + 1 <= p.to);
1329
+ const bands = (s.phases ?? []).map(
1330
+ (p, pi) => `<div class="tl-phase tl-phase--${pi % 2}" style="grid-column:${p.from} / ${p.to + 1}"><span>${inline(p.label)}</span></div>`
1331
+ ).join("");
1332
+ return {
1333
+ html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}<div class="timeline"${buildAttr(
1334
+ s.build
1335
+ )} style="--n:${s.items.length}">${bands ? `<div class="tl-phases">${bands}</div>` : ""}<ol class="tl-items">${s.items.map((it, i) => {
1336
+ const cls = [
1337
+ "tl-item",
1338
+ "b-item",
1339
+ phaseOf(i) >= 0 ? `tl-in-${phaseOf(i) % 2}` : "",
1340
+ s.current === i + 1 ? "tl-current" : "",
1341
+ s.current && i + 1 < s.current ? "tl-done" : ""
1342
+ ].filter(Boolean).join(" ");
1343
+ return `<li class="${cls}"><span class="tl-dot"></span><span class="tl-date">${escapeHtml(it.date)}</span><h3>${inline(
1344
+ it.title
1345
+ )}</h3>${it.text ? `<p>${inline(it.text)}</p>` : ""}</li>`;
1346
+ }).join("")}</ol></div>`
1347
+ };
1348
+ }
1349
+ });
1350
+ var cycle = defineLayout({
1351
+ name: "cycle",
1352
+ description: "A repeating process: 3\u20136 steps arranged around a circle with arrows, optional centre label.",
1353
+ whenToUse: "Loops that repeat: seasonal maintenance, feedback cycles, plan-do-review.",
1354
+ shape: {
1355
+ eyebrow: Text.optional(),
1356
+ title: Text,
1357
+ lead: Text.optional(),
1358
+ steps: z6.array(Step).min(3).max(6),
1359
+ center: Text.optional().describe("Word in the middle of the circle.")
1360
+ },
1361
+ example: {
1362
+ layout: "cycle",
1363
+ eyebrow: "Use & care",
1364
+ title: "Seasonal maintenance, every year",
1365
+ center: "Your home",
1366
+ steps: [
1367
+ { title: "Spring", text: "Check grading and gutters" },
1368
+ { title: "Summer", text: "Service AC, reseal decks" },
1369
+ { title: "Fall", text: "Blow out sprinklers" },
1370
+ { title: "Winter", text: "Replace furnace filter" }
1371
+ ]
1372
+ },
1373
+ lint: (s) => s.steps.flatMap((st, i) => maxLen(st.text, 70, ["steps", i, "text"], "Step text")),
1374
+ render: (s) => {
1375
+ const n = s.steps.length;
1376
+ const pos = s.steps.map((_, i) => {
1377
+ const a = i / n * Math.PI * 2 - Math.PI / 2;
1378
+ return { x: 50 + 38 * Math.cos(a), y: 50 + 38 * Math.sin(a) };
1379
+ });
1380
+ const arcs = pos.map((_, i) => {
1381
+ const a0 = i / n * Math.PI * 2 - Math.PI / 2 + 0.34;
1382
+ const a1 = (i + 1) / n * Math.PI * 2 - Math.PI / 2 - 0.34;
1383
+ const p = (a) => `${(50 + 38 * Math.cos(a)).toFixed(2)} ${(50 + 38 * Math.sin(a)).toFixed(2)}`;
1384
+ return `<path d="M ${p(a0)} A 38 38 0 0 1 ${p(a1)}" marker-end="url(#cy-arrow)"/>`;
1385
+ }).join("");
1386
+ return {
1387
+ html: `${eyebrow(s.eyebrow)}${heading(s.title)}${lead(s.lead)}<div class="cycle"><svg class="cycle-ring" viewBox="0 0 100 100" aria-hidden="true"><defs><marker id="cy-arrow" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="4" markerHeight="4" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker></defs>${arcs}</svg>${s.center ? `<div class="cycle-center">${inline(s.center)}</div>` : ""}${s.steps.map(
1388
+ (st, i) => `<div class="cycle-node" style="left:${pos[i].x.toFixed(2)}%;top:${pos[i].y.toFixed(2)}%"><h3>${inline(st.title)}</h3>${st.text ? `<p>${inline(st.text)}</p>` : ""}</div>`
1389
+ ).join("")}</div>`
1390
+ };
1391
+ }
1392
+ });
1393
+ var hierarchy = defineLayout({
1394
+ name: "hierarchy",
1395
+ description: "An org chart or tree, up to 3 levels, with optional photos.",
1396
+ whenToUse: "Who reports to whom, how a team is organised, how a topic breaks down.",
1397
+ shape: {
1398
+ eyebrow: Text.optional(),
1399
+ title: Text,
1400
+ lead: Text.optional(),
1401
+ nodes: z6.array(
1402
+ z6.object({
1403
+ id: z6.string().regex(/^[a-z0-9-]+$/).describe("Short slug, referenced by children."),
1404
+ parent: z6.string().optional().describe("id of the parent; omit for the top node."),
1405
+ name: Text,
1406
+ role: Text.optional(),
1407
+ photo: Image.optional()
1408
+ })
1409
+ ).min(1).max(16)
1410
+ },
1411
+ example: {
1412
+ layout: "hierarchy",
1413
+ eyebrow: "Your team",
1414
+ title: "Who builds your home",
1415
+ nodes: [
1416
+ { id: "pm", name: "Project Manager", role: "Oversees your community" },
1417
+ { id: "super", parent: "pm", name: "Superintendent", role: "Runs your site day to day" },
1418
+ { id: "cs", parent: "pm", name: "Customer Service", role: "After you move in" },
1419
+ {
1420
+ id: "trades",
1421
+ parent: "super",
1422
+ name: "Trade partners",
1423
+ role: "Framing, plumbing, electrical\u2026"
1424
+ }
1425
+ ]
1426
+ },
1427
+ lint: (s) => {
1428
+ const out = [
1429
+ ...missingAlt(
1430
+ s.nodes.flatMap((n) => n.photo ? [n.photo] : []),
1431
+ ["nodes"]
1432
+ )
1433
+ ];
1434
+ const ids = new Set(s.nodes.map((n) => n.id));
1435
+ const roots = s.nodes.filter((n) => !n.parent);
1436
+ if (roots.length !== 1)
1437
+ out.push({
1438
+ level: "error",
1439
+ path: ["nodes"],
1440
+ message: `Needs exactly one top node (no parent); found ${roots.length}.`
1441
+ });
1442
+ s.nodes.forEach((n, i) => {
1443
+ if (n.parent && !ids.has(n.parent))
1444
+ out.push({
1445
+ level: "error",
1446
+ path: ["nodes", i, "parent"],
1447
+ message: `No node with id "${n.parent}".`
1448
+ });
1449
+ });
1450
+ const depth = (id, seen = /* @__PURE__ */ new Set()) => {
1451
+ const node = s.nodes.find((n) => n.id === id);
1452
+ if (!node?.parent || seen.has(id)) return 1;
1453
+ seen.add(id);
1454
+ return 1 + depth(node.parent, seen);
1455
+ };
1456
+ if (s.nodes.some((n) => depth(n.id) > 3))
1457
+ out.push({ level: "error", path: ["nodes"], message: "Keep the tree to 3 levels." });
1458
+ return out;
1459
+ },
1460
+ render: (s, ctx) => {
1461
+ const kids = (id) => s.nodes.filter((n) => n.parent === id);
1462
+ const node = (n) => {
1463
+ const children = kids(n.id);
1464
+ return `<li><div class="h-node">${n.photo ? img(n.photo, ctx, "avatar") : ""}<div><h3>${inline(n.name)}</h3>${n.role ? `<p>${inline(n.role)}</p>` : ""}</div></div>${children.length ? `<ul>${children.map(node).join("")}</ul>` : ""}</li>`;
1465
+ };
1466
+ const root = s.nodes.find((n) => !n.parent);
1467
+ return {
1468
+ html: `${eyebrow(s.eyebrow)}${heading(s.title)}${lead(s.lead)}<div class="hierarchy"><ul>${root ? node(root) : ""}</ul></div>`
1469
+ };
1470
+ }
1471
+ });
1472
+ var matrix = defineLayout({
1473
+ name: "matrix",
1474
+ description: "A 2\xD72 grid with labelled axes and a few items in each quadrant.",
1475
+ whenToUse: "Sorting options by two factors: effort vs impact, cost vs value, who handles what when.",
1476
+ shape: {
1477
+ eyebrow: Text.optional(),
1478
+ title: Text,
1479
+ lead: Text.optional(),
1480
+ xAxis: z6.object({ low: Text, high: Text, label: Text.optional() }),
1481
+ yAxis: z6.object({ low: Text, high: Text, label: Text.optional() }),
1482
+ quadrants: z6.array(z6.object({ title: Text, items: z6.array(Text).max(4).optional() })).length(4).describe("In reading order: top-left, top-right, bottom-left, bottom-right."),
1483
+ highlight: z6.number().int().min(1).max(4).optional().describe("Quadrant to emphasise (1\u20134).")
1484
+ },
1485
+ example: {
1486
+ layout: "matrix",
1487
+ title: "Where to spend your upgrade budget",
1488
+ xAxis: { low: "Easy to add later", high: "Hard to add later", label: "After move-in" },
1489
+ yAxis: { low: "Nice to have", high: "High value", label: "Value" },
1490
+ quadrants: [
1491
+ { title: "Add later", items: ["Paint colours", "Light fixtures"] },
1492
+ { title: "Choose now", items: ["Structural options", "Electrical & low volt"] },
1493
+ { title: "Skip", items: ["Trend finishes"] },
1494
+ { title: "Consider", items: ["Flooring upgrades"] }
1495
+ ],
1496
+ highlight: 2
1497
+ },
501
1498
  render: (s) => ({
502
- html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}<div class="split2" style="--cols:${s.columns.length}">${s.columns.map(
503
- (c) => `<div class="col tone-${c.tone}"><h3>${inline(c.heading)}</h3>${c.text ? `<p>${inline(c.text)}</p>` : ""}${items(
504
- c.items,
505
- c.marker
506
- )}</div>`
507
- ).join("")}</div>`
1499
+ html: `${eyebrow(s.eyebrow)}${heading(s.title)}${lead(s.lead)}<div class="matrix"><div class="mx-y"><span>${inline(s.yAxis.high)}</span>${s.yAxis.label ? `<b>${inline(s.yAxis.label)}</b>` : ""}<span>${inline(s.yAxis.low)}</span></div><div class="mx-grid">${s.quadrants.map(
1500
+ (q, i) => `<div class="mx-q${s.highlight === i + 1 ? " mx-hl" : ""}"><h3>${inline(q.title)}</h3>${q.items?.length ? `<ul>${q.items.map((t) => `<li>${inline(t)}</li>`).join("")}</ul>` : ""}</div>`
1501
+ ).join(
1502
+ ""
1503
+ )}</div><div class="mx-x"><span>${inline(s.xAxis.low)}</span>${s.xAxis.label ? `<b>${inline(s.xAxis.label)}</b>` : ""}<span>${inline(
1504
+ s.xAxis.high
1505
+ )}</span></div></div>`
508
1506
  })
509
1507
  });
510
- var quote = defineLayout({
511
- name: "quote",
512
- description: "A pull quote with attribution, optional portrait.",
513
- whenToUse: "A homeowner testimonial, a value statement, a line from leadership.",
1508
+ var tiers = defineLayout({
1509
+ name: "tiers",
1510
+ description: "A funnel (wide to narrow) or pyramid (narrow to wide) of 3\u20136 labelled tiers with optional values.",
1511
+ whenToUse: "Stages that narrow (leads \u2192 contracts \u2192 closings) or build on each other (foundation \u2192 finish).",
514
1512
  shape: {
515
1513
  eyebrow: Text.optional(),
516
- quote: Text,
517
- cite: Text.optional(),
518
- image: Image.optional().describe("Portrait of the person quoted.")
1514
+ title: Text,
1515
+ lead: Text.optional(),
1516
+ style: z6.enum(["funnel", "pyramid"]).default("funnel"),
1517
+ tiers: z6.array(z6.object({ label: Text, value: z6.string().max(16).optional(), note: Text.optional() })).min(3).max(6)
519
1518
  },
520
1519
  example: {
521
- layout: "quote",
522
- quote: "They walked us through every step. We always knew what came next.",
523
- cite: "The Martinez family \xB7 Wolf Ranch"
1520
+ layout: "tiers",
1521
+ eyebrow: "Warranty",
1522
+ title: "Coverage that lasts",
1523
+ style: "pyramid",
1524
+ tiers: [
1525
+ { label: "Structural", value: "10 yrs", note: "Load-bearing elements" },
1526
+ { label: "Systems", value: "2 yrs", note: "Plumbing, electrical, HVAC" },
1527
+ { label: "Workmanship", value: "1 yr", note: "Fit and finish" }
1528
+ ]
524
1529
  },
525
- lint: (s) => [
526
- ...maxLen(s.quote, 240, ["quote"], "Quote"),
527
- ...missingAlt(s.image ? [s.image] : [], ["image"])
528
- ],
529
- render: (s, ctx) => ({
530
- html: `${eyebrow(s.eyebrow)}<div class="${s.image ? "cols quote-cols" : ""}"><blockquote class="quote">${inline(
531
- s.quote
532
- )}${s.cite ? `<cite>${inline(s.cite)}</cite>` : ""}</blockquote>${s.image ? `<div class="portrait">${img(s.image, ctx)}</div>` : ""}</div>`
533
- })
1530
+ lint: (s) => maxCount(s.tiers, 6, ["tiers"], "tiers"),
1531
+ render: (s) => {
1532
+ const n = s.tiers.length;
1533
+ return {
1534
+ html: `${eyebrow(s.eyebrow)}${heading(s.title)}${lead(s.lead)}<ol class="tiers tiers--${s.style}">${s.tiers.map((t, i) => {
1535
+ const rank = s.style === "funnel" ? i : n - 1 - i;
1536
+ const width = 100 - rank * 60 / Math.max(1, n - 1);
1537
+ return `<li style="--w:${width.toFixed(1)}%;--i:${i}"><div class="tier-bar"><span class="tier-label">${inline(t.label)}</span>${t.value ? `<b>${escapeHtml(t.value)}</b>` : ""}</div>${t.note ? `<p class="tier-note">${inline(t.note)}</p>` : ""}</li>`;
1538
+ }).join("")}</ol>`
1539
+ };
1540
+ }
534
1541
  });
535
1542
 
536
1543
  // src/layouts/media.ts
537
- import { z as z3 } from "zod";
538
- var Group = z3.object({
1544
+ import { z as z7 } from "zod";
1545
+ var Group = z7.object({
539
1546
  heading: Text.optional().describe("Small bold heading above this group."),
540
1547
  text: Text.optional(),
541
- items: z3.array(Item).optional(),
1548
+ items: z7.array(Item).optional(),
542
1549
  marker: Marker
543
1550
  });
544
1551
  var split = defineLayout({
@@ -550,11 +1557,11 @@ var split = defineLayout({
550
1557
  eyebrowSub: Text.optional(),
551
1558
  title: Text,
552
1559
  lead: Text.optional(),
553
- groups: z3.array(Group).optional().describe("Text blocks under the title, each with an optional heading."),
554
- images: z3.array(Image).max(4).default([]).describe("Photos. May be empty when a badge carries the media side."),
555
- arrange: z3.enum(["single", "stack", "grid", "overlap"]).default("single").describe("single: one photo \xB7 stack: vertical \xB7 grid: 2\xD72 \xB7 overlap: two photos layered."),
556
- mediaSide: z3.enum(["right", "left"]).default("right"),
557
- badge: z3.object({ title: Text, text: Text.optional() }).optional().describe(
1560
+ groups: z7.array(Group).optional().describe("Text blocks under the title, each with an optional heading."),
1561
+ images: z7.array(Image).max(4).default([]).describe("Photos. May be empty when a badge carries the media side."),
1562
+ arrange: z7.enum(["single", "stack", "grid", "overlap"]).default("single").describe("single: one photo \xB7 stack: vertical \xB7 grid: 2\xD72 \xB7 overlap: two photos layered."),
1563
+ mediaSide: z7.enum(["right", "left"]).default("right"),
1564
+ badge: z7.object({ title: Text, text: Text.optional() }).optional().describe(
558
1565
  'Red circle callout over the photos, e.g. "Pre-drywall meeting / about 60 days in".'
559
1566
  ),
560
1567
  footnote: Text.optional().describe("Closing line under the text column.")
@@ -610,10 +1617,10 @@ var photos = defineLayout({
610
1617
  eyebrow: Text.optional(),
611
1618
  title: Text.optional(),
612
1619
  lead: Text.optional(),
613
- images: z3.array(Image).min(1).max(24),
614
- columns: z3.number().int().min(1).max(8).optional().describe("Defaults to a balanced count for the number of photos."),
615
- aspect: z3.enum(["portrait", "landscape", "square", "native"]).default("landscape").describe("portrait suits headshots; native keeps each photo uncropped."),
616
- captions: z3.boolean().default(false).describe("Show each image alt text as a caption.")
1620
+ images: z7.array(Image).min(1).max(24),
1621
+ columns: z7.number().int().min(1).max(8).optional().describe("Defaults to a balanced count for the number of photos."),
1622
+ aspect: z7.enum(["portrait", "landscape", "square", "native"]).default("landscape").describe("portrait suits headshots; native keeps each photo uncropped."),
1623
+ captions: z7.boolean().default(false).describe("Show each image alt text as a caption.")
617
1624
  },
618
1625
  example: {
619
1626
  layout: "photos",
@@ -648,7 +1655,15 @@ var image = defineLayout({
648
1655
  title: Text.optional(),
649
1656
  image: Image,
650
1657
  caption: Text.optional(),
651
- frame: z3.boolean().default(true).describe("White card frame; turn off for full-bleed photos.")
1658
+ frame: z7.boolean().default(true).describe("White card frame; turn off for full-bleed photos."),
1659
+ hotspots: z7.array(
1660
+ z7.object({
1661
+ x: z7.number().min(0).max(100).describe("Horizontal position, % from the left."),
1662
+ y: z7.number().min(0).max(100).describe("Vertical position, % from the top."),
1663
+ label: Text,
1664
+ text: Text.optional()
1665
+ })
1666
+ ).max(12).optional().describe("Numbered markers on the image with a legend, e.g. rooms on a floor plan.")
652
1667
  },
653
1668
  example: {
654
1669
  layout: "image",
@@ -657,12 +1672,143 @@ var image = defineLayout({
657
1672
  caption: "Model homes are open daily 10\u20135."
658
1673
  },
659
1674
  lint: (s) => missingAlt([s.image], ["image"]).map((i) => ({ ...i, path: ["image", "alt"] })),
1675
+ render: (s, ctx) => {
1676
+ if (s.hotspots?.length) {
1677
+ const marks = s.hotspots.map(
1678
+ (h, i) => `<span class="hs-mark" style="left:${h.x}%;top:${h.y}%" aria-hidden="true">${i + 1}</span>`
1679
+ ).join("");
1680
+ const legend = s.hotspots.map(
1681
+ (h) => `<li><b>${inline(h.label)}</b>${h.text ? `<span>${inline(h.text)}</span>` : ""}</li>`
1682
+ ).join("");
1683
+ return {
1684
+ classes: ["has-hotspots"],
1685
+ html: `${eyebrow(s.eyebrow)}${s.title ? heading(s.title) : ""}<div class="hs-layout"><figure class="diagram-shot"><div class="hs-wrap">${img(
1686
+ { ...s.image, fit: "contain" },
1687
+ ctx
1688
+ )}${marks}</div>${s.caption ? `<figcaption class="shot-cap">${inline(s.caption)}</figcaption>` : ""}</figure><ol class="hs-legend">${legend}</ol></div>`
1689
+ };
1690
+ }
1691
+ return {
1692
+ classes: s.frame ? [] : ["bleed"],
1693
+ html: `${eyebrow(s.eyebrow)}${s.title ? heading(s.title) : ""}<figure class="${s.frame ? "diagram-shot" : "bleed-img"}">${img(
1694
+ { ...s.image, fit: s.frame ? "contain" : s.image.fit },
1695
+ ctx
1696
+ )}${s.caption ? `<figcaption class="shot-cap">${inline(s.caption)}</figcaption>` : ""}</figure>`
1697
+ };
1698
+ }
1699
+ });
1700
+ var video = defineLayout({
1701
+ name: "video",
1702
+ description: "A YouTube or Vimeo video that loads when the slide is shown, with a poster + QR fallback offline and in print.",
1703
+ whenToUse: "Walkthrough tours, testimonials, how-to clips. Keep them short; needs internet to play.",
1704
+ shape: {
1705
+ eyebrow: Text.optional(),
1706
+ title: Text.optional(),
1707
+ url: z7.string().url().refine((u) => !!parseVideoUrl(u), "Use a YouTube or Vimeo link.").describe("YouTube or Vimeo link (watch, share or embed URL)."),
1708
+ poster: Image.optional().describe(
1709
+ "Still shown before play and offline. YouTube posters are fetched automatically."
1710
+ ),
1711
+ start: z7.number().int().min(0).optional().describe("Start at this many seconds."),
1712
+ end: z7.number().int().min(1).optional().describe("Stop at this many seconds (YouTube)."),
1713
+ autoplay: z7.boolean().default(false).describe("Start playing when the slide is shown (muted by browsers)."),
1714
+ muted: z7.boolean().default(false),
1715
+ captions: z7.boolean().default(true).describe("Show captions when the video has them."),
1716
+ caption: Text.optional()
1717
+ },
1718
+ example: {
1719
+ layout: "video",
1720
+ eyebrow: "Take the tour",
1721
+ title: "Inside the Aspen floor plan",
1722
+ url: "https://www.youtube.com/watch?v=aqz-KE-bpKQ",
1723
+ caption: "Virtual tour \xB7 2 min"
1724
+ },
1725
+ prepare: (s) => {
1726
+ const ref = parseVideoUrl(s.url);
1727
+ return { qr: [ref.watchUrl], images: !s.poster && ref.poster ? [ref.poster] : [] };
1728
+ },
1729
+ render: (s, ctx) => {
1730
+ const ref = parseVideoUrl(s.url);
1731
+ const src = videoEmbedUrl(ref, s);
1732
+ const posterSrc = s.poster?.src ?? ref.poster;
1733
+ const poster = posterSrc ? `<img class="media-poster" src="${escapeAttr(ctx.image(posterSrc))}" alt="${escapeAttr(
1734
+ s.poster?.alt || (s.title ? `Video: ${s.title}` : "Video poster")
1735
+ )}" decoding="async">` : "";
1736
+ return {
1737
+ classes: ["has-media"],
1738
+ html: `${eyebrow(s.eyebrow)}${s.title ? heading(s.title) : ""}<figure class="media-frame" data-media-src="${escapeAttr(
1739
+ src
1740
+ )}" data-media-title="${escapeAttr(s.title ?? "Video")}" data-media-kind="video">${poster}<div class="media-fallback"><span class="play-badge" aria-hidden="true">\u25B6</span><div class="media-qr">${ctx.qr(
1741
+ ref.watchUrl
1742
+ )}<a href="${escapeAttr(ref.watchUrl)}" target="_blank" rel="noopener">Watch on ${ref.provider === "youtube" ? "YouTube" : "Vimeo"} \u2197</a></div></div></figure>${s.caption ? `<p class="shot-cap">${inline(s.caption)}</p>` : ""}`
1743
+ };
1744
+ }
1745
+ });
1746
+ var embed = defineLayout({
1747
+ name: "embed",
1748
+ description: "A live web page (dashboard, map, form, 3D tour) in a sandboxed frame that loads when the slide is shown, with an open link and QR fallback.",
1749
+ whenToUse: "Showing something interactive: a Matterport tour, a Google Map, a Looker dashboard, a sign-up form. Needs internet; some sites refuse to be framed.",
1750
+ shape: {
1751
+ eyebrow: Text.optional(),
1752
+ title: Text.optional(),
1753
+ url: z7.string().url().refine((u) => u.startsWith("https://"), "Embeds must use https.").describe("https address of the page to show."),
1754
+ aspect: z7.enum(["16:9", "4:3", "1:1", "tall"]).default("16:9").describe("Frame shape; tall suits forms and phones."),
1755
+ fallback: Image.optional().describe(
1756
+ "Screenshot shown offline, in print, or if the site refuses framing."
1757
+ ),
1758
+ caption: Text.optional()
1759
+ },
1760
+ example: {
1761
+ layout: "embed",
1762
+ eyebrow: "Explore",
1763
+ title: "Find your community",
1764
+ url: "https://www.openstreetmap.org/export/embed.html?bbox=-104.95,38.78,-104.65,39.03&layer=mapnik",
1765
+ caption: "Pan and zoom to see every Classic Homes community."
1766
+ },
1767
+ prepare: (s) => ({ qr: [s.url] }),
1768
+ render: (s, ctx) => ({
1769
+ classes: ["has-media"],
1770
+ html: `${eyebrow(s.eyebrow)}${s.title ? heading(s.title) : ""}<figure class="media-frame aspect-${s.aspect.replace(
1771
+ ":",
1772
+ "x"
1773
+ )}" data-media-src="${escapeAttr(s.url)}" data-media-title="${escapeAttr(s.title ?? "Embedded page")}" data-media-kind="embed">${s.fallback ? img(s.fallback, ctx, "media-poster") : ""}<div class="media-fallback"><div class="media-qr">${ctx.qr(s.url)}<a href="${escapeAttr(
1774
+ s.url
1775
+ )}" target="_blank" rel="noopener">${escapeHtml(s.url.replace(/^https:\/\//, "").split("/")[0])} \u2197</a></div></div><a class="media-open" href="${escapeAttr(
1776
+ s.url
1777
+ )}" target="_blank" rel="noopener">Open \u2197</a></figure>${s.caption ? `<p class="shot-cap">${inline(s.caption)}</p>` : ""}`
1778
+ })
1779
+ });
1780
+ var compare = defineLayout({
1781
+ name: "compare",
1782
+ description: "Before/after: two photos in one frame with a draggable divider (side by side in print).",
1783
+ whenToUse: "Showing change: framing vs finished, model vs your home, standard vs upgraded finish.",
1784
+ shape: {
1785
+ eyebrow: Text.optional(),
1786
+ title: Text.optional(),
1787
+ before: Image,
1788
+ after: Image,
1789
+ beforeLabel: z7.string().max(24).default("Before"),
1790
+ afterLabel: z7.string().max(24).default("After"),
1791
+ caption: Text.optional()
1792
+ },
1793
+ example: {
1794
+ layout: "compare",
1795
+ title: "Framing to finished",
1796
+ before: { src: "images/framing.jpg", alt: "Kitchen during framing" },
1797
+ after: { src: "images/finished.jpg", alt: "Finished kitchen" },
1798
+ beforeLabel: "Week 6",
1799
+ afterLabel: "Move-in"
1800
+ },
1801
+ lint: (s) => missingAlt([s.before, s.after], ["images"]),
660
1802
  render: (s, ctx) => ({
661
- classes: s.frame ? [] : ["bleed"],
662
- html: `${eyebrow(s.eyebrow)}${s.title ? heading(s.title) : ""}<figure class="${s.frame ? "diagram-shot" : "bleed-img"}">${img(
663
- { ...s.image, fit: s.frame ? "contain" : s.image.fit },
664
- ctx
665
- )}${s.caption ? `<figcaption class="shot-cap">${inline(s.caption)}</figcaption>` : ""}</figure>`
1803
+ html: `${eyebrow(s.eyebrow)}${s.title ? heading(s.title) : ""}<figure class="compare" style="--pos:50%">${img(
1804
+ s.after,
1805
+ ctx,
1806
+ "cmp-after"
1807
+ )}<div class="cmp-before-clip">${img(s.before, ctx, "cmp-before")}</div><span class="cmp-label cmp-label--before">${escapeHtml(
1808
+ s.beforeLabel
1809
+ )}</span><span class="cmp-label cmp-label--after">${escapeHtml(
1810
+ s.afterLabel
1811
+ )}</span><input class="cmp-range" type="range" min="0" max="100" value="50" aria-label="Reveal before or after"><span class="cmp-handle" aria-hidden="true"></span></figure>${s.caption ? `<p class="shot-cap">${inline(s.caption)}</p>` : ""}`
666
1812
  })
667
1813
  });
668
1814
  var walkthrough = defineLayout({
@@ -672,23 +1818,23 @@ var walkthrough = defineLayout({
672
1818
  shape: {
673
1819
  eyebrow: Text.optional(),
674
1820
  title: Text,
675
- label: z3.string().default("STAGE").describe("Word on the badge, e.g. STAGE, PHASE, DAY."),
676
- start: z3.number().int().positive().default(1).describe(
1821
+ label: z7.string().default("STAGE").describe("Word on the badge, e.g. STAGE, PHASE, DAY."),
1822
+ start: z7.number().int().positive().default(1).describe(
677
1823
  "Position of the first stage in the whole sequence (1-based). Use to continue a sequence split by other slides."
678
1824
  ),
679
- numberFrom: z3.number().int().default(1).describe("Number shown on the badge of the first stage in the sequence, e.g. 0."),
680
- total: z3.number().int().positive().optional().describe(
1825
+ numberFrom: z7.number().int().default(1).describe("Number shown on the badge of the first stage in the sequence, e.g. 0."),
1826
+ total: z7.number().int().positive().optional().describe(
681
1827
  'Stages in the whole sequence when it spans several walkthroughs (for "n of N" and the rail).'
682
1828
  ),
683
- stages: z3.array(
684
- z3.object({
1829
+ stages: z7.array(
1830
+ z7.object({
685
1831
  image: Image,
686
- badge: z3.string().max(10).optional().describe('Replaces the badge for this stage, e.g. "PERMIT" (label is hidden).'),
687
- number: z3.union([z3.string().max(4), z3.number().int()]).optional().describe("Number shown under the label for this stage; defaults to its position."),
1832
+ badge: z7.string().max(10).optional().describe('Replaces the badge for this stage, e.g. "PERMIT" (label is hidden).'),
1833
+ number: z7.union([z7.string().max(4), z7.number().int()]).optional().describe("Number shown under the label for this stage; defaults to its position."),
688
1834
  eyebrow: Text.optional().describe("Overrides the walkthrough eyebrow for this stage."),
689
1835
  heading: Text.optional(),
690
1836
  text: Text,
691
- notes: z3.string().optional().describe("Speaker notes for this stage (falls back to the slide notes).")
1837
+ notes: z7.string().optional().describe("Speaker notes for this stage (falls back to the slide notes).")
692
1838
  })
693
1839
  ).min(2).max(20)
694
1840
  },
@@ -752,11 +1898,11 @@ var qr = defineLayout({
752
1898
  eyebrowSub: Text.optional(),
753
1899
  title: Text,
754
1900
  lead: Text.optional(),
755
- items: z3.array(Item).optional(),
756
- url: z3.string().url().optional().describe("Where the code points. Required unless image is set."),
1901
+ items: z7.array(Item).optional(),
1902
+ url: z7.string().url().refine(isSafeLinkUrl, "Use an http, https, mailto or tel URL.").optional().describe("Where the code points. Required unless image is set."),
757
1903
  image: Image.optional().describe("Use a pre-made QR/image instead of generating one from url."),
758
- logo: z3.boolean().default(false).describe("Show the Classic Homes logo under the text."),
759
- label: z3.string().optional().describe("URL text shown under the code; defaults to the URL without https://.")
1904
+ logo: z7.boolean().default(false).describe("Show the Classic Homes logo under the text."),
1905
+ label: z7.string().optional().describe("URL text shown under the code; defaults to the URL without https://.")
760
1906
  },
761
1907
  example: {
762
1908
  layout: "qr",
@@ -765,6 +1911,7 @@ var qr = defineLayout({
765
1911
  lead: "Scan to open the Homeowner Guide on your phone.",
766
1912
  url: "https://guide.classichomes.com"
767
1913
  },
1914
+ prepare: (s) => ({ qr: !s.image && s.url ? [s.url] : [] }),
768
1915
  lint: (s) => [
769
1916
  ...maxLen(s.title, 70, ["title"], "Title"),
770
1917
  ...s.url || s.image ? [] : [
@@ -780,7 +1927,7 @@ var qr = defineLayout({
780
1927
  const label = s.label ?? s.url?.replace(/^https?:\/\//, "").replace(/\/$/, "");
781
1928
  const inner = `${code}${label ? `<div class="u">${escapeHtml(label)}</div>` : ""}`;
782
1929
  return {
783
- html: `<div class="cols"><div>${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}${items(s.items)}${s.logo ? `<div class="brand-logo end">${LOGO_SVG}</div>` : ""}</div><div class="qr-wrap">${s.url ? `<a class="qr-hero" href="${escapeAttr(s.url)}">${inner}</a>` : `<div class="qr-hero">${inner}</div>`}</div></div>`
1930
+ html: `<div class="cols"><div>${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}${items(s.items)}${s.logo ? `<div class="brand-logo end">${ctx.logo()}</div>` : ""}</div><div class="qr-wrap">${s.url ? `<a class="qr-hero" href="${escapeAttr(s.url)}">${inner}</a>` : `<div class="qr-hero">${inner}</div>`}</div></div>`
784
1931
  };
785
1932
  }
786
1933
  });
@@ -792,13 +1939,13 @@ var contacts = defineLayout({
792
1939
  eyebrow: Text.optional(),
793
1940
  title: Text,
794
1941
  lead: Text.optional(),
795
- contacts: z3.array(
796
- z3.object({
1942
+ contacts: z7.array(
1943
+ z7.object({
797
1944
  name: Text,
798
1945
  role: Text.optional(),
799
- phone: z3.string().optional(),
800
- email: z3.string().email().optional(),
801
- url: z3.string().url().optional(),
1946
+ phone: z7.string().optional(),
1947
+ email: z7.string().email().optional(),
1948
+ url: z7.string().url().refine(isSafeLinkUrl, "Use an http, https, mailto or tel URL.").optional(),
802
1949
  photo: Image.optional()
803
1950
  })
804
1951
  ).min(1).max(9)
@@ -836,7 +1983,7 @@ var journey = defineLayout({
836
1983
  eyebrowSub: Text.optional(),
837
1984
  title: Text,
838
1985
  lead: Text.optional(),
839
- active: z3.number().int().positive().optional().describe("Step to highlight; defaults to the slide step.")
1986
+ active: z7.number().int().positive().optional().describe("Step to highlight; defaults to the slide step.")
840
1987
  },
841
1988
  example: {
842
1989
  layout: "journey",
@@ -874,15 +2021,169 @@ var LAYOUTS = [
874
2021
  walkthrough,
875
2022
  quote,
876
2023
  qr,
877
- contacts
2024
+ contacts,
2025
+ chart,
2026
+ table,
2027
+ flow,
2028
+ timeline,
2029
+ cycle,
2030
+ hierarchy,
2031
+ matrix,
2032
+ tiers,
2033
+ video,
2034
+ embed,
2035
+ compare
878
2036
  ];
879
2037
  var byName = new Map(LAYOUTS.map((l) => [l.name, l]));
880
2038
  function getLayout(name) {
881
2039
  return byName.get(name);
882
2040
  }
2041
+ var LAYOUT_CATEGORY_ORDER = [
2042
+ "Openers & dividers",
2043
+ "Text",
2044
+ "Data",
2045
+ "Diagrams",
2046
+ "Media",
2047
+ "People & places"
2048
+ ];
2049
+ var LAYOUT_CATEGORIES = {
2050
+ title: "Openers & dividers",
2051
+ section: "Openers & dividers",
2052
+ statement: "Openers & dividers",
2053
+ journey: "Openers & dividers",
2054
+ bullets: "Text",
2055
+ cards: "Text",
2056
+ comparison: "Text",
2057
+ quote: "Text",
2058
+ stats: "Data",
2059
+ chart: "Data",
2060
+ table: "Data",
2061
+ flow: "Diagrams",
2062
+ timeline: "Diagrams",
2063
+ cycle: "Diagrams",
2064
+ hierarchy: "Diagrams",
2065
+ matrix: "Diagrams",
2066
+ tiers: "Diagrams",
2067
+ split: "Media",
2068
+ photos: "Media",
2069
+ image: "Media",
2070
+ compare: "Media",
2071
+ walkthrough: "Media",
2072
+ video: "Media",
2073
+ embed: "Media",
2074
+ qr: "People & places",
2075
+ contacts: "People & places"
2076
+ };
2077
+
2078
+ // src/edit.ts
2079
+ function findSlide(deck, ref) {
2080
+ const index = typeof ref === "number" || /^\d+$/.test(ref) ? Number(ref) - 1 : deck.slides.findIndex((s) => s.id === ref);
2081
+ if (index < 0 || index >= deck.slides.length) {
2082
+ throw new Error(
2083
+ `No slide "${ref}". The deck has ${deck.slides.length} slides; use a 1-based number or a slide id.`
2084
+ );
2085
+ }
2086
+ return index;
2087
+ }
2088
+ function newSlideId(taken = /* @__PURE__ */ new Set()) {
2089
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
2090
+ for (; ; ) {
2091
+ const bytes = new Uint8Array(6);
2092
+ globalThis.crypto.getRandomValues(bytes);
2093
+ const id = `s-${Array.from(bytes, (b) => alphabet[b % alphabet.length]).join("")}`;
2094
+ if (!taken.has(id)) return id;
2095
+ }
2096
+ }
2097
+ function ensureSlideIds(deck) {
2098
+ const taken = new Set(
2099
+ deck.slides.map((s) => s.id).filter((id) => typeof id === "string")
2100
+ );
2101
+ let added = 0;
2102
+ for (const slide of deck.slides) {
2103
+ if (typeof slide.id === "string" && slide.id) continue;
2104
+ slide.id = newSlideId(taken);
2105
+ taken.add(slide.id);
2106
+ added++;
2107
+ }
2108
+ return added;
2109
+ }
2110
+ function slideKey(slide, index) {
2111
+ return typeof slide.id === "string" && slide.id ? slide.id : `#${index + 1}`;
2112
+ }
2113
+ function addSlide(deck, slide, position) {
2114
+ if (typeof slide.id !== "string" || !slide.id) {
2115
+ slide = { ...slide, id: newSlideId(new Set(deck.slides.map((s) => s.id))) };
2116
+ }
2117
+ const at = position === void 0 ? deck.slides.length : Math.max(0, Math.min(deck.slides.length, position - 1));
2118
+ deck.slides.splice(at, 0, slide);
2119
+ return at + 1;
2120
+ }
2121
+ function updateSlide(deck, ref, patch) {
2122
+ const i = findSlide(deck, ref);
2123
+ const next = patch.layout && patch.layout !== deck.slides[i].layout ? { id: deck.slides[i].id } : { ...deck.slides[i] };
2124
+ for (const [k, v] of Object.entries(patch)) {
2125
+ if (v === null) delete next[k];
2126
+ else next[k] = v;
2127
+ }
2128
+ deck.slides[i] = next;
2129
+ return i + 1;
2130
+ }
2131
+ function moveSlide(deck, ref, to) {
2132
+ const from = findSlide(deck, ref);
2133
+ const [slide] = deck.slides.splice(from, 1);
2134
+ const at = Math.max(0, Math.min(deck.slides.length, to - 1));
2135
+ deck.slides.splice(at, 0, slide);
2136
+ return at + 1;
2137
+ }
2138
+ function removeSlide(deck, ref) {
2139
+ const i = findSlide(deck, ref);
2140
+ return deck.slides.splice(i, 1)[0];
2141
+ }
2142
+ function outline(deck) {
2143
+ return deck.slides.map((s, i) => {
2144
+ const label = s.title ?? s.quote ?? s.kicker ?? "";
2145
+ return `${i + 1}. [${s.layout}]${s.id ? ` #${s.id}` : ""} ${label}`.trim();
2146
+ });
2147
+ }
2148
+
2149
+ // src/schema/deck.ts
2150
+ import { z as z8 } from "zod";
2151
+ var slideSchemas = LAYOUTS.map(
2152
+ (layout) => z8.object({ layout: z8.literal(layout.name), ...baseSlide, ...layout.shape }).strict().describe(`${layout.description} Use for: ${layout.whenToUse}`)
2153
+ );
2154
+ var SlideSchema = z8.discriminatedUnion("layout", slideSchemas);
2155
+ var DeckSettingsSchema = z8.object({
2156
+ slideNumbers: z8.boolean().default(false).describe("Show the slide number in the footer."),
2157
+ logo: z8.enum(["title", "all", "none"]).default("title").describe(
2158
+ "Where the logo appears: title/closing slides only, a corner mark on every slide, or nowhere."
2159
+ ),
2160
+ confidential: z8.string().max(40).optional().describe('Footer label such as "Internal \u2014 do not distribute".'),
2161
+ autoAdvance: z8.number().int().min(3).max(600).optional().describe(
2162
+ "Kiosk mode: seconds per slide (loops unattended). For a file or hosted URL, ?kiosk=15 also works."
2163
+ ),
2164
+ loop: z8.boolean().default(false).describe("Return to the first slide after the last (kiosk).")
2165
+ }).strict();
2166
+ var DeckSchema = z8.object({
2167
+ title: z8.string().min(1).describe("Deck title (browser tab, file name, gallery)."),
2168
+ description: z8.string().optional().describe("One sentence: who the deck is for and what it covers."),
2169
+ author: z8.string().optional(),
2170
+ date: z8.string().optional(),
2171
+ lang: z8.string().default("en"),
2172
+ journey: z8.object({
2173
+ steps: z8.array(Text).min(2).max(10).describe("Short names of each step, in order."),
2174
+ label: z8.string().default("STEP").describe("Word on the vertical step rail.")
2175
+ }).optional().describe(
2176
+ 'Optional numbered journey (agenda). Enables the journey layout, step rails and "you are here" strips.'
2177
+ ),
2178
+ brand: DeckBrandSchema.optional().describe(
2179
+ "Which brand the deck speaks for (profile key) plus optional name/logo/favicon/footer overrides."
2180
+ ),
2181
+ settings: DeckSettingsSchema.default({}),
2182
+ slides: z8.array(SlideSchema).min(1)
2183
+ }).strict();
883
2184
 
884
2185
  // src/editor.ts
885
- import { z as z4 } from "zod";
2186
+ import { z as z9 } from "zod";
886
2187
  var MULTILINE = /* @__PURE__ */ new Set(["lead", "body", "text", "quote", "footnote", "sub", "notes", "caption"]);
887
2188
  var LABELS = {
888
2189
  eyebrowSub: "Eyebrow subtitle",
@@ -906,19 +2207,21 @@ function unwrap(schema) {
906
2207
  let description = schema.description;
907
2208
  for (; ; ) {
908
2209
  description ??= s.description;
909
- if (s instanceof z4.ZodOptional || s instanceof z4.ZodNullable) {
2210
+ if (s instanceof z9.ZodOptional || s instanceof z9.ZodNullable) {
910
2211
  required = false;
911
2212
  s = s.unwrap();
912
- } else if (s instanceof z4.ZodDefault) {
2213
+ } else if (s instanceof z9.ZodDefault) {
913
2214
  required = false;
914
2215
  dflt = s._def.defaultValue();
915
2216
  s = s._def.innerType;
2217
+ } else if (s instanceof z9.ZodEffects && s !== Image && s !== Item) {
2218
+ s = s.innerType();
916
2219
  } else break;
917
2220
  }
918
2221
  return { inner: s, required, default: dflt, description };
919
2222
  }
920
2223
  function isText(s) {
921
- return s === Text || s instanceof z4.ZodString && s.description === Text.description;
2224
+ return s === Text || s instanceof z9.ZodString && s.description === Text.description;
922
2225
  }
923
2226
  function describeField(key, schema) {
924
2227
  const { inner, required, default: dflt, description } = unwrap(schema);
@@ -931,13 +2234,32 @@ function describeField(key, schema) {
931
2234
  ...MULTILINE.has(key) ? { multiline: true } : {}
932
2235
  };
933
2236
  if (inner === Image) return { ...base, kind: "image" };
2237
+ if (inner === ChartSpec) {
2238
+ return {
2239
+ ...base,
2240
+ kind: "group",
2241
+ hint: "chart",
2242
+ fields: Object.entries(ChartSpec.shape).map(
2243
+ ([k, v]) => describeField(k, v)
2244
+ )
2245
+ };
2246
+ }
934
2247
  if (inner === Item) return { ...base, kind: "item" };
935
2248
  if (isText(inner)) return { ...base, kind: "inline" };
936
- if (inner instanceof z4.ZodString) return { ...base, kind: "string" };
937
- if (inner instanceof z4.ZodBoolean) return { ...base, kind: "boolean" };
938
- if (inner instanceof z4.ZodEnum) return { ...base, kind: "enum", options: [...inner.options] };
939
- if (inner instanceof z4.ZodLiteral) return { ...base, kind: "enum", options: [inner.value] };
940
- if (inner instanceof z4.ZodNumber) {
2249
+ if (inner instanceof z9.ZodString) {
2250
+ if (key === "url" || inner.isURL) {
2251
+ return {
2252
+ ...base,
2253
+ kind: "string",
2254
+ hint: /YouTube|Vimeo/.test(description ?? "") ? "video-url" : "url"
2255
+ };
2256
+ }
2257
+ return { ...base, kind: "string" };
2258
+ }
2259
+ if (inner instanceof z9.ZodBoolean) return { ...base, kind: "boolean" };
2260
+ if (inner instanceof z9.ZodEnum) return { ...base, kind: "enum", options: [...inner.options] };
2261
+ if (inner instanceof z9.ZodLiteral) return { ...base, kind: "enum", options: [inner.value] };
2262
+ if (inner instanceof z9.ZodNumber) {
941
2263
  return {
942
2264
  ...base,
943
2265
  kind: "number",
@@ -946,9 +2268,9 @@ function describeField(key, schema) {
946
2268
  ...inner.maxValue !== null ? { max: inner.maxValue } : {}
947
2269
  };
948
2270
  }
949
- if (inner instanceof z4.ZodUnion) {
2271
+ if (inner instanceof z9.ZodUnion) {
950
2272
  const opts = inner.options;
951
- if (opts.every((o) => o instanceof z4.ZodLiteral)) {
2273
+ if (opts.every((o) => o instanceof z9.ZodLiteral)) {
952
2274
  return {
953
2275
  ...base,
954
2276
  kind: "enum",
@@ -957,20 +2279,24 @@ function describeField(key, schema) {
957
2279
  }
958
2280
  return { ...base, kind: "string" };
959
2281
  }
960
- if (inner instanceof z4.ZodArray) {
2282
+ if (inner instanceof z9.ZodArray) {
961
2283
  const el = inner.element;
962
2284
  return {
963
2285
  ...base,
964
2286
  kind: "list",
2287
+ ...el instanceof z9.ZodArray ? { hint: "grid" } : {},
965
2288
  of: { ...describeField("", el), label: labelFor(key.replace(/s$/, "")) },
966
2289
  ...inner._def.minLength ? { min: inner._def.minLength.value } : {},
967
2290
  ...inner._def.maxLength ? { max: inner._def.maxLength.value } : {}
968
2291
  };
969
2292
  }
970
- if (inner instanceof z4.ZodObject) {
2293
+ if (inner instanceof z9.ZodObject) {
2294
+ const shape = inner.shape;
2295
+ const isPoint = shape.x instanceof z9.ZodNumber && shape.y instanceof z9.ZodNumber;
971
2296
  return {
972
2297
  ...base,
973
2298
  kind: "group",
2299
+ ...isPoint ? { hint: "point" } : {},
974
2300
  fields: Object.entries(inner.shape).map(
975
2301
  ([k, v]) => describeField(k, v)
976
2302
  )
@@ -978,6 +2304,16 @@ function describeField(key, schema) {
978
2304
  }
979
2305
  return { ...base, kind: "string" };
980
2306
  }
2307
+ function deckEditorFields() {
2308
+ return {
2309
+ brand: Object.entries(DeckBrandSchema.shape).map(
2310
+ ([k, v]) => describeField(k, v)
2311
+ ),
2312
+ settings: Object.entries(DeckSettingsSchema.shape).map(
2313
+ ([k, v]) => describeField(k, v)
2314
+ )
2315
+ };
2316
+ }
981
2317
  function commonSlideFields() {
982
2318
  return Object.entries(baseSlide).map(([k, v]) => describeField(k, v));
983
2319
  }
@@ -986,6 +2322,7 @@ function layoutEditorInfo(name) {
986
2322
  if (!layout) return void 0;
987
2323
  return {
988
2324
  name: layout.name,
2325
+ category: LAYOUT_CATEGORIES[layout.name],
989
2326
  description: layout.description,
990
2327
  whenToUse: layout.whenToUse,
991
2328
  defaults: layout.defaults ?? {},
@@ -1007,32 +2344,15 @@ function isPlaceholderImage(src) {
1007
2344
  function blankSlide(name) {
1008
2345
  const layout = getLayout(name);
1009
2346
  if (!layout) return void 0;
1010
- return JSON.parse(
2347
+ const slide = JSON.parse(
1011
2348
  JSON.stringify(layout.example),
1012
2349
  (key, value) => key === "src" && typeof value === "string" && !/^(https?:|data:)/.test(value) ? PLACEHOLDER_IMAGE : value
1013
2350
  );
2351
+ return { ...slide, id: newSlideId() };
1014
2352
  }
1015
2353
 
1016
- // src/schema/deck.ts
1017
- import { z as z5 } from "zod";
1018
- var slideSchemas = LAYOUTS.map(
1019
- (layout) => z5.object({ layout: z5.literal(layout.name), ...baseSlide, ...layout.shape }).strict().describe(`${layout.description} Use for: ${layout.whenToUse}`)
1020
- );
1021
- var SlideSchema = z5.discriminatedUnion("layout", slideSchemas);
1022
- var DeckSchema = z5.object({
1023
- title: z5.string().min(1).describe("Deck title (browser tab, file name, gallery)."),
1024
- description: z5.string().optional().describe("One sentence: who the deck is for and what it covers."),
1025
- author: z5.string().optional(),
1026
- date: z5.string().optional(),
1027
- lang: z5.string().default("en"),
1028
- journey: z5.object({
1029
- steps: z5.array(Text).min(2).max(10).describe("Short names of each step, in order."),
1030
- label: z5.string().default("STEP").describe("Word on the vertical step rail.")
1031
- }).optional().describe(
1032
- 'Optional numbered journey (agenda). Enables the journey layout, step rails and "you are here" strips.'
1033
- ),
1034
- slides: z5.array(SlideSchema).min(1)
1035
- }).strict();
2354
+ // src/validate.ts
2355
+ import QRCode2 from "qrcode";
1036
2356
 
1037
2357
  // src/render.ts
1038
2358
  import QRCode from "qrcode";
@@ -1040,6 +2360,55 @@ import QRCode from "qrcode";
1040
2360
  // src/runtime.ts
1041
2361
  var RUNTIME_JS = String.raw`
1042
2362
  (() => {
2363
+ // The presenter runs this exact script too, retaining the host's script hash.
2364
+ // A MessagePort stays attached to its document if the popup later navigates;
2365
+ // unlike window.postMessage('*'), it cannot leak notes to a replacement page.
2366
+ if (document.body.classList.contains('presenter')) {
2367
+ if (!window.opener) return;
2368
+ const channel = document.querySelector('meta[name="presenter-channel"]').content;
2369
+ const connection = new MessageChannel();
2370
+ const port = connection.port1;
2371
+ let started = Date.now();
2372
+ function scale(container, html) {
2373
+ container.innerHTML = html;
2374
+ const wrap = container.firstElementChild;
2375
+ if (!wrap?.classList.contains('ov-thumb')) return;
2376
+ const width = Math.max(200, container.clientWidth || 760);
2377
+ wrap.style.width = width + 'px';
2378
+ wrap.style.height = width * 9 / 16 + 'px';
2379
+ wrap.firstElementChild.style.transform = 'scale(' + width / 1280 + ')';
2380
+ }
2381
+ function tick() {
2382
+ const seconds = Math.floor((Date.now() - started) / 1000);
2383
+ document.querySelector('.pv-elapsed').textContent = Math.floor(seconds / 60) + ':' + String(seconds % 60).padStart(2, '0');
2384
+ document.querySelector('.pv-time').textContent = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
2385
+ }
2386
+ port.onmessage = (event) => {
2387
+ const state = event.data;
2388
+ if (!state || state.type !== 'classic-slides:presenter-state') return;
2389
+ document.title = 'Presenter · ' + state.title;
2390
+ started = state.started;
2391
+ scale(document.querySelector('.pv-cur'), state.current);
2392
+ scale(document.querySelector('.pv-next'), state.next);
2393
+ document.querySelector('.pv-notes-body').innerHTML = state.notes;
2394
+ document.querySelector('.pv-cue').textContent = state.cue;
2395
+ document.querySelector('.pv-count').textContent = state.count;
2396
+ tick();
2397
+ };
2398
+ const keys = ['ArrowDown', 'ArrowRight', 'PageDown', ' ', 'Enter', 'ArrowUp', 'ArrowLeft', 'PageUp', 'Backspace', 'Home', 'End'];
2399
+ document.addEventListener('keydown', (event) => {
2400
+ if (event.metaKey || event.ctrlKey || event.altKey || !keys.includes(event.key)) return;
2401
+ if (event.target?.closest?.('a,button,input,textarea,select,[contenteditable]')) return;
2402
+ event.preventDefault();
2403
+ port.postMessage({ type: 'classic-slides:presenter-key', key: event.key });
2404
+ });
2405
+ window.addEventListener('resize', () => port.postMessage({ type: 'classic-slides:presenter-refresh' }));
2406
+ window.opener.postMessage({ type: 'classic-slides:presenter-ready', channel }, '*', [connection.port2]);
2407
+ const timer = setInterval(tick, 1000);
2408
+ window.addEventListener('beforeunload', () => { clearInterval(timer); port.close(); });
2409
+ return;
2410
+ }
2411
+ const runtimeSource = document.currentScript.textContent;
1043
2412
  const NOTES = JSON.parse(document.getElementById('deck-notes').textContent || '[]');
1044
2413
  const deck = document.getElementById('deck');
1045
2414
  const slides = [...deck.querySelectorAll('.slide')];
@@ -1049,6 +2418,8 @@ var RUNTIME_JS = String.raw`
1049
2418
  const notesTime = document.getElementById('notesTime');
1050
2419
  const rail = document.getElementById('rail');
1051
2420
  const counter = document.getElementById('counter');
2421
+ const body = document.body;
2422
+ const embedded = window.parent !== window;
1052
2423
 
1053
2424
  slides.forEach((s, i) => {
1054
2425
  const b = document.createElement('button');
@@ -1058,27 +2429,72 @@ var RUNTIME_JS = String.raw`
1058
2429
  });
1059
2430
  const dots = [...dotsWrap.children];
1060
2431
 
1061
- let idx = 0;
1062
- function setActive(i) {
2432
+ // ---------- progressive reveal ----------
2433
+ const buildItems = slides.map((s) => [...s.querySelectorAll('[data-build] .b-item')]);
2434
+ const shown = slides.map((_, i) => buildItems[i].length); // start fully shown until entered forwards
2435
+ function paintBuild(i) {
2436
+ buildItems[i].forEach((el, j) => el.classList.toggle('b-hidden', j >= shown[i]));
2437
+ }
2438
+
2439
+ // ---------- media (video / embed) ----------
2440
+ function mountMedia(slide) {
2441
+ slide.querySelectorAll('.media-frame[data-media-src]').forEach((f) => {
2442
+ if (f.querySelector('iframe') || !navigator.onLine) return;
2443
+ const ifr = document.createElement('iframe');
2444
+ ifr.src = f.dataset.mediaSrc;
2445
+ ifr.title = f.dataset.mediaTitle || 'Embedded content';
2446
+ ifr.loading = 'eager';
2447
+ ifr.referrerPolicy = 'strict-origin-when-cross-origin';
2448
+ ifr.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups allow-presentation');
2449
+ if (f.dataset.mediaKind === 'video') {
2450
+ ifr.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
2451
+ ifr.allowFullscreen = true;
2452
+ }
2453
+ f.appendChild(ifr);
2454
+ f.classList.add('live');
2455
+ });
2456
+ }
2457
+ function unmountMedia(slide) {
2458
+ slide.querySelectorAll('.media-frame.live').forEach((f) => {
2459
+ f.querySelector('iframe')?.remove();
2460
+ f.classList.remove('live');
2461
+ });
2462
+ }
2463
+
2464
+ // ---------- before / after ----------
2465
+ deck.querySelectorAll('.compare').forEach((c) => {
2466
+ const r = c.querySelector('.cmp-range');
2467
+ r.addEventListener('input', () => c.style.setProperty('--pos', r.value + '%'));
2468
+ r.addEventListener('keydown', (e) => e.stopPropagation());
2469
+ });
2470
+
2471
+ // ---------- active slide ----------
2472
+ let idx = -1;
2473
+ function setActive(i, fromKeys) {
2474
+ if (i === idx) return;
2475
+ const prev = idx;
1063
2476
  idx = i;
2477
+ if (prev >= 0) unmountMedia(slides[prev]);
2478
+ if (!fromKeys) shown[i] = buildItems[i].length; // arriving by scroll or jump shows everything
2479
+ paintBuild(i);
2480
+ mountMedia(slides[i]);
1064
2481
  dots.forEach((d, j) => d.setAttribute('aria-current', j === i ? 'true' : 'false'));
1065
- const s = slides[i];
1066
- const track = s.getAttribute('data-track');
2482
+ const track = slides[i].getAttribute('data-track');
1067
2483
  rail.style.background = track === 'build' ? 'var(--teal)' : track === 'neutral' ? 'var(--navy)' : 'var(--red)';
1068
2484
  counter.textContent = (i + 1) + ' / ' + slides.length;
1069
- if (notes.classList.contains('open')) {
1070
- const n = NOTES[i] || {};
1071
- notesBody.innerHTML = n.html || '<p><i>No notes for this slide.</i></p>';
1072
- notesTime.textContent = n.time || '';
1073
- }
1074
- // Sandboxed or srcdoc frames may refuse history access; the deep link is optional.
2485
+ if (notes?.classList.contains('open')) fillNotes();
1075
2486
  try {
1076
2487
  if (location.hash !== '#slide-' + (i + 1)) history.replaceState(null, '', '#slide-' + (i + 1));
1077
2488
  } catch (_) {}
1078
- if (window.parent !== window) window.parent.postMessage({ type: 'classic-slides:slide', index: i, count: slides.length }, '*');
2489
+ if (embedded) window.parent.postMessage({ type: 'classic-slides:slide', index: i, count: slides.length }, '*');
2490
+ presenterSync();
2491
+ }
2492
+ function fillNotes() {
2493
+ const n = NOTES[idx] || {};
2494
+ notesBody.innerHTML = n.html || '<p><i>No notes for this slide.</i></p>';
2495
+ notesTime.textContent = n.time || '';
1079
2496
  }
1080
2497
 
1081
- // Current slide comes from the scroll position, the single source of truth.
1082
2498
  const clamp = (n) => Math.max(0, Math.min(slides.length - 1, n));
1083
2499
  const currentIndex = () => clamp(Math.round(deck.scrollTop / deck.clientHeight));
1084
2500
  function updateRail() {
@@ -1087,53 +2503,232 @@ var RUNTIME_JS = String.raw`
1087
2503
  }
1088
2504
 
1089
2505
  let ticking = false;
2506
+ let jumping = false;
1090
2507
  deck.addEventListener('scroll', () => {
1091
2508
  if (ticking) return;
1092
2509
  ticking = true;
1093
2510
  requestAnimationFrame(() => {
1094
2511
  ticking = false;
1095
2512
  updateRail();
1096
- const i = currentIndex();
1097
- if (i !== idx) setActive(i);
2513
+ if (!jumping) setActive(currentIndex());
1098
2514
  });
1099
2515
  }, { passive: true });
1100
2516
 
1101
2517
  // Scroll the deck element itself; scrolling the slide into view would also move an embedding page.
1102
- function go(n, instant) {
2518
+ function go(n, instant, fromKeys) {
1103
2519
  const t = clamp(n);
1104
- deck.scrollTo({ top: t * deck.clientHeight, behavior: instant ? 'auto' : 'smooth' });
1105
- setActive(t);
2520
+ jumping = true;
2521
+ // 'auto' would inherit the CSS smooth scrolling; jumps must really be instant.
2522
+ deck.scrollTo({ top: t * deck.clientHeight, behavior: instant || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' });
2523
+ setActive(t, fromKeys);
2524
+ clearTimeout(go.t);
2525
+ // Ignore scroll events until the programmatic scroll settles.
2526
+ go.t = setTimeout(() => { jumping = false; }, instant ? 80 : 1200);
2527
+ }
2528
+ deck.addEventListener('scrollend', () => {
2529
+ if (!jumping) return;
2530
+ clearTimeout(go.t);
2531
+ jumping = false;
2532
+ });
2533
+
2534
+ // next/prev step through build items first, then move between slides.
2535
+ // Entering a slide forwards starts its build from zero; going back shows it complete.
2536
+ function next() {
2537
+ if (idx < 0) return;
2538
+ if (shown[idx] < buildItems[idx].length) { shown[idx]++; paintBuild(idx); presenterSync(); return; }
2539
+ if (idx === slides.length - 1) { if (loop) { shown[0] = 0; go(0, true, true); } return; }
2540
+ shown[idx + 1] = 0;
2541
+ go(idx + 1, false, true);
2542
+ }
2543
+ function prev() {
2544
+ if (idx < 0) return;
2545
+ if (buildItems[idx].length && shown[idx] > 0) { shown[idx]--; paintBuild(idx); presenterSync(); return; }
2546
+ const t = clamp(idx - 1);
2547
+ shown[t] = buildItems[t].length;
2548
+ go(t, false, true);
1106
2549
  }
1107
2550
 
1108
- // Embedders (editors, previews) can drive the deck: postMessage({ type: 'classic-slides:go', index }).
1109
2551
  window.addEventListener('message', (e) => {
1110
2552
  const d = e.data;
1111
2553
  if (!d || typeof d !== 'object') return;
2554
+ if (pw && e.source === pw && d.type === 'classic-slides:presenter-ready' && d.channel === presenterChannel && e.ports[0]) {
2555
+ presenterPort?.close();
2556
+ presenterPort = e.ports[0];
2557
+ presenterPort.onmessage = (event) => {
2558
+ const command = event.data;
2559
+ if (command?.type === 'classic-slides:presenter-refresh') presenterSync();
2560
+ else if (command?.type === 'classic-slides:presenter-key' && typeof command.key === 'string') {
2561
+ onKey({ key: command.key, preventDefault() {} });
2562
+ }
2563
+ };
2564
+ presenterSync();
2565
+ return;
2566
+ }
2567
+ if (!embedded || e.source !== window.parent) return;
1112
2568
  if (d.type === 'classic-slides:go' && Number.isInteger(d.index)) go(d.index, true);
1113
- else if (d.type === 'classic-slides:notes') { if (notes.classList.contains('open') !== !!d.open) toggleNotes(); }
2569
+ else if (d.type === 'classic-slides:notes') { if (notes && notes.classList.contains('open') !== !!d.open) toggleNotes(); }
1114
2570
  });
1115
2571
 
1116
- document.addEventListener('keydown', (e) => {
2572
+ function onKey(e) {
1117
2573
  if (e.metaKey || e.ctrlKey || e.altKey) return;
1118
- if (['ArrowDown', 'ArrowRight', 'PageDown', ' '].includes(e.key)) { e.preventDefault(); go(idx + 1); }
1119
- else if (['ArrowUp', 'ArrowLeft', 'PageUp'].includes(e.key)) { e.preventDefault(); go(idx - 1); }
1120
- else if (e.key === 'Home') { e.preventDefault(); go(0); }
1121
- else if (e.key === 'End') { e.preventDefault(); go(slides.length - 1); }
1122
- else if (e.key.toLowerCase() === 'n') toggleNotes();
1123
- else if (e.key.toLowerCase() === 'f') toggleFs();
1124
- });
2574
+ const k = e.key;
2575
+ if (overview && k === 'Escape') { e.preventDefault(); closeOverview(); return; }
2576
+ if (overview && k === 'Tab') {
2577
+ const buttons = [...overview.querySelectorAll('button')];
2578
+ const first = buttons[0], last = buttons[buttons.length - 1];
2579
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); }
2580
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); }
2581
+ return;
2582
+ }
2583
+ if (e.target?.closest?.('a,button,input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="button"],[role="slider"]')) return;
2584
+ if (overview) return;
2585
+ if (['ArrowDown', 'ArrowRight', 'PageDown', ' ', 'Enter'].includes(k)) { e.preventDefault(); next(); }
2586
+ else if (['ArrowUp', 'ArrowLeft', 'PageUp', 'Backspace'].includes(k)) { e.preventDefault(); prev(); }
2587
+ else if (k === 'Home') { e.preventDefault(); go(0); }
2588
+ else if (k === 'End') { e.preventDefault(); go(slides.length - 1); }
2589
+ else if (k.toLowerCase() === 'n') toggleNotes();
2590
+ else if (k.toLowerCase() === 'f') toggleFs();
2591
+ else if (k.toLowerCase() === 'p') openPresenter();
2592
+ else if (k.toLowerCase() === 'o') toggleOverview();
2593
+ else return;
2594
+ wake();
2595
+ }
2596
+ document.addEventListener('keydown', onKey);
1125
2597
 
1126
- function toggleNotes() { notes.classList.toggle('open'); if (notes.classList.contains('open')) setActive(idx); }
1127
- document.getElementById('notesBtn').addEventListener('click', toggleNotes);
2598
+ // Shared links may render without speaker notes: no panel, no presenter window.
2599
+ function toggleNotes() { if (!notes) return; notes.classList.toggle('open'); if (notes.classList.contains('open')) fillNotes(); }
2600
+ document.getElementById('notesBtn')?.addEventListener('click', toggleNotes);
1128
2601
  function toggleFs() {
1129
2602
  if (!document.fullscreenElement) document.documentElement.requestFullscreen?.();
1130
2603
  else document.exitFullscreen?.();
1131
2604
  }
1132
2605
 
2606
+ // ---------- idle chrome ----------
2607
+ let idleT;
2608
+ function wake() {
2609
+ body.classList.remove('idle');
2610
+ clearTimeout(idleT);
2611
+ idleT = setTimeout(() => body.classList.add('idle'), 2800);
2612
+ }
2613
+ document.addEventListener('mousemove', wake, { passive: true });
2614
+
2615
+ // ---------- overview (O) ----------
2616
+ let overview = null;
2617
+ let overviewFocus = null;
2618
+ function scaledClone(i, width) {
2619
+ const wrap = document.createElement('div');
2620
+ wrap.className = 'ov-thumb';
2621
+ const inner = slides[i].cloneNode(true);
2622
+ inner.removeAttribute('id');
2623
+ inner.querySelectorAll('iframe').forEach((f) => f.remove());
2624
+ inner.querySelectorAll('.media-frame.live').forEach((f) => f.classList.remove('live'));
2625
+ inner.querySelectorAll('.b-hidden').forEach((el) => el.classList.remove('b-hidden'));
2626
+ inner.style.width = '1280px';
2627
+ inner.style.height = '720px';
2628
+ inner.style.transform = 'scale(' + width / 1280 + ')';
2629
+ inner.style.transformOrigin = '0 0';
2630
+ inner.style.position = 'absolute';
2631
+ inner.style.pointerEvents = 'none';
2632
+ wrap.style.width = width + 'px';
2633
+ wrap.style.height = (width * 9) / 16 + 'px';
2634
+ wrap.appendChild(inner);
2635
+ return wrap;
2636
+ }
2637
+ function toggleOverview() { overview ? closeOverview() : openOverview(); }
2638
+ function openOverview() {
2639
+ overviewFocus = document.activeElement;
2640
+ overview = document.createElement('div');
2641
+ overview.className = 'overview';
2642
+ overview.setAttribute('role', 'dialog');
2643
+ overview.setAttribute('aria-modal', 'true');
2644
+ overview.setAttribute('aria-label', 'All slides');
2645
+ const w = Math.min(300, Math.floor((window.innerWidth - 96) / 4) - 16);
2646
+ slides.forEach((_, i) => {
2647
+ const b = document.createElement('button');
2648
+ b.className = 'ov-item' + (i === idx ? ' ov-current' : '');
2649
+ b.setAttribute('aria-label', 'Go to slide ' + (i + 1));
2650
+ b.appendChild(scaledClone(i, w));
2651
+ const n = document.createElement('span');
2652
+ n.textContent = String(i + 1);
2653
+ b.appendChild(n);
2654
+ b.addEventListener('click', () => { closeOverview(); go(i, true); });
2655
+ overview.appendChild(b);
2656
+ });
2657
+ body.appendChild(overview);
2658
+ const cur = overview.querySelector('.ov-current');
2659
+ if (cur) { overview.scrollTop = cur.offsetTop - overview.clientHeight / 2 + cur.clientHeight / 2; cur.focus(); }
2660
+ }
2661
+ function closeOverview() { overview?.remove(); overview = null; overviewFocus?.focus(); overviewFocus = null; }
2662
+
2663
+ // ---------- presenter window (P) ----------
2664
+ let pw = null;
2665
+ let presenterPort = null;
2666
+ let presenterChannel = '';
2667
+ let presenterUrl = '';
2668
+ const started = Date.now();
2669
+ function openPresenter() {
2670
+ if (!notes) return;
2671
+ if (pw && !pw.closed) { pw.focus(); return; }
2672
+ presenterPort?.close();
2673
+ presenterPort = null;
2674
+ if (presenterUrl) URL.revokeObjectURL(presenterUrl);
2675
+ presenterChannel = Array.from(crypto.getRandomValues(new Uint8Array(24)), (n) => n.toString(16).padStart(2, '0')).join('');
2676
+ const styles = [...document.querySelectorAll('style')].map((s) => s.outerHTML).join('');
2677
+ const html = '<!doctype html><html><head><meta charset="utf-8"><meta name="presenter-channel" content="' + presenterChannel + '"><title>Presenter</title>' + styles +
2678
+ '</head><body class="presenter"><div class="pv-main"><div class="pv-cur"></div><div class="pv-side"><div class="pv-label">Next</div><div class="pv-next"></div>' +
2679
+ '<div class="pv-clock"><b class="pv-elapsed">0:00</b><span class="pv-time"></span><span class="pv-count"></span></div></div></div>' +
2680
+ '<div class="pv-notes"><div class="pv-label">Notes <span class="pv-cue"></span></div><div class="pv-notes-body notes__body"></div></div><script>' + runtimeSource + '<' + '/script></body></html>';
2681
+ presenterUrl = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
2682
+ pw = window.open(presenterUrl, '_blank', 'width=1200,height=760');
2683
+ if (!pw) { URL.revokeObjectURL(presenterUrl); presenterUrl = ''; }
2684
+ }
2685
+ function presenterSync() {
2686
+ if (!pw || pw.closed || !presenterPort || idx < 0) return;
2687
+ const current = scaledClone(idx, 760);
2688
+ buildItems[idx].forEach((_, j) => {
2689
+ if (j >= shown[idx]) current.querySelectorAll('[data-build] .b-item')[j]?.classList.add('b-dim');
2690
+ });
2691
+ const n = NOTES[idx] || {};
2692
+ const steps = buildItems[idx].length ? ' · step ' + shown[idx] + '/' + buildItems[idx].length : '';
2693
+ presenterPort.postMessage({
2694
+ type: 'classic-slides:presenter-state', title: document.title, started,
2695
+ current: current.outerHTML,
2696
+ next: idx + 1 < slides.length ? scaledClone(idx + 1, 360).outerHTML : 'End of deck',
2697
+ notes: n.html || '<p><i>No notes for this slide.</i></p>', cue: n.time || '',
2698
+ count: 'Slide ' + (idx + 1) + ' of ' + slides.length + steps,
2699
+ });
2700
+ }
2701
+ window.addEventListener('beforeunload', () => {
2702
+ presenterPort?.close();
2703
+ if (pw && !pw.closed) pw.close();
2704
+ if (presenterUrl) URL.revokeObjectURL(presenterUrl);
2705
+ });
2706
+
2707
+ // ---------- kiosk ----------
2708
+ const km = /[?&]kiosk=(\d+)/.exec(location.search);
2709
+ const autoAdvance = km ? Number(km[1]) : Number(body.dataset.autoAdvance || 0);
2710
+ const loop = !!km || body.hasAttribute('data-loop');
2711
+ if (autoAdvance >= 3 && (!embedded || body.hasAttribute('data-embedded-kiosk'))) {
2712
+ body.classList.add('kiosk');
2713
+ setInterval(() => {
2714
+ shown[idx] = buildItems[idx].length;
2715
+ if (idx === slides.length - 1) { if (loop) go(0, true); }
2716
+ else go(idx + 1, true);
2717
+ }, autoAdvance * 1000);
2718
+ }
2719
+
2720
+ // ---------- start ----------
1133
2721
  const m = /^#slide-(\d+)$/.exec(location.hash);
1134
- const initial = m ? Number(m[1]) - 1 : Number(document.body.dataset.initialSlide || 0);
1135
- if (initial > 0) requestAnimationFrame(() => go(initial, true));
2722
+ const initial = m ? Number(m[1]) - 1 : Number(body.dataset.initialSlide || 0);
2723
+ // The script runs at the end of <body>, so layout exists; jump synchronously.
2724
+ if (initial > 0) go(initial, true);
1136
2725
  else setActive(0);
2726
+ window.addEventListener('online', () => mountMedia(slides[idx]));
2727
+ window.addEventListener('hashchange', () => {
2728
+ const h = /^#slide-(\d+)$/.exec(location.hash);
2729
+ if (h && Number(h[1]) - 1 !== idx) go(Number(h[1]) - 1, true);
2730
+ });
2731
+ wake();
1137
2732
  })();
1138
2733
  `;
1139
2734
 
@@ -1141,13 +2736,14 @@ var RUNTIME_JS = String.raw`
1141
2736
  var DECK_CSS = String.raw`
1142
2737
  *{box-sizing:border-box}
1143
2738
  html{scroll-behavior:smooth}
1144
- @media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
2739
+ @media (prefers-reduced-motion:reduce){html,.deck{scroll-behavior:auto}}
1145
2740
  body{margin:0;font-family:var(--font-body);color:var(--ink);background:var(--paper);-webkit-font-smoothing:antialiased}
1146
2741
  a{color:var(--teal)}
1147
2742
  img{display:block;max-width:100%}
1148
2743
 
1149
2744
  /* ---------- deck + slide frame ---------- */
1150
2745
  .deck{height:100vh;overflow-y:scroll;scroll-snap-type:y mandatory;scroll-behavior:smooth}
2746
+ @media (prefers-reduced-motion:reduce){.deck{scroll-behavior:auto}}
1151
2747
  .slide{height:100vh;scroll-snap-align:start;scroll-snap-stop:always;position:relative;display:flex;flex-direction:column;justify-content:center;
1152
2748
  padding:clamp(24px,5.5vh,76px) clamp(28px,8vw,116px);overflow:hidden;
1153
2749
  background:linear-gradient(var(--grid) 1px,transparent 1px) 0 0/100% 46px,linear-gradient(90deg,var(--grid) 1px,transparent 1px) 0 0/46px 100%,var(--paper)}
@@ -1446,6 +3042,247 @@ img.natural,.media img.natural,.photo-grid img.natural{width:auto;max-width:min(
1446
3042
  .rail,.hud,.dots,.notes,.hint{display:none!important}
1447
3043
  }
1448
3044
  `;
3045
+ var DECK_CSS_EXTRA = String.raw`
3046
+ /* ---------- brand logo images ---------- */
3047
+ .logo--word .logo-word{font-weight:700;letter-spacing:.01em}
3048
+ .logo--img img{height:2.1em;width:auto;max-width:14em;object-fit:contain}
3049
+ .logo--img .logo-dark{display:none}
3050
+ .solid .logo--img .logo-light{display:none}
3051
+ .solid .logo--img .logo-dark{display:inline-block}
3052
+
3053
+ /* ---------- background photos ---------- */
3054
+ .slide.has-bg{background:var(--overlay),var(--bg-img) center/cover no-repeat}
3055
+ .slide.overlay-dark{--overlay:linear-gradient(100deg,hsl(196 100% 6% / .78),hsl(196 100% 8% / .45))}
3056
+ .slide.overlay-brand{--overlay:linear-gradient(100deg,color-mix(in srgb,var(--navy) 86%,transparent),color-mix(in srgb,var(--navy) 55%,transparent))}
3057
+ .slide.overlay-light{--overlay:linear-gradient(100deg,hsl(40 20% 98% / .92),hsl(40 20% 98% / .7))}
3058
+ .slide.has-bg.solid .lead{color:#fff}
3059
+
3060
+ /* ---------- footer, hidden ---------- */
3061
+ .slide-foot{position:absolute;left:clamp(28px,8vw,116px);right:clamp(28px,8vw,116px);bottom:14px;display:flex;align-items:center;justify-content:space-between;gap:16px;font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;color:var(--ink-faint);pointer-events:none}
3062
+ .slide.has-step .slide-foot{left:clamp(92px,10vw,168px)}
3063
+ .slide-foot .foot-l{display:flex;align-items:center;gap:16px}
3064
+ .slide-foot .foot-l>span+span::before{content:"·";margin-right:16px}
3065
+ .slide-foot .foot-logo .logo{font-size:12px}
3066
+ .slide-foot .foot-n{font-weight:700;color:var(--ink-soft)}
3067
+ .slide.solid .slide-foot,.slide.solid .slide-foot .foot-n{color:hsl(0 0% 100% / .72)}
3068
+ .slide.is-hidden>.slide__inner{opacity:.45}
3069
+ .hidden-badge{position:absolute;top:14px;right:18px;font-family:var(--font-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;background:var(--ink);color:#fff;padding:4px 8px;border-radius:4px}
3070
+
3071
+ /* ---------- progressive reveal ---------- */
3072
+ [data-build] .b-item{transition:opacity .35s ease,transform .35s ease}
3073
+ [data-build] .b-item.b-hidden{opacity:0;transform:translateY(10px)}
3074
+ .b-dim{opacity:.28!important;transform:none!important}
3075
+ @media (prefers-reduced-motion:reduce){[data-build] .b-item{transition:none}}
3076
+
3077
+ /* ---------- stats trends ---------- */
3078
+ .stat .trend{display:inline-block;margin:6px 0 0;font-family:var(--font-mono);font-style:normal;font-size:13px;font-weight:700}
3079
+ .trend--good{color:var(--good)}
3080
+ .trend--bad{color:var(--red)}
3081
+ .trend--flat{color:var(--ink-faint)}
3082
+ .stat .spark{display:block;width:100%;max-width:140px;height:32px;margin-top:8px}
3083
+ .spark polyline{fill:none;stroke:var(--red);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}
3084
+ .spark circle{fill:var(--red)}
3085
+ .slide[data-track="build"] .spark polyline{stroke:var(--teal)}
3086
+ .slide[data-track="build"] .spark circle{fill:var(--teal)}
3087
+ .slide[data-track="neutral"] .spark polyline{stroke:var(--navy)}
3088
+ .slide[data-track="neutral"] .spark circle{fill:var(--navy)}
3089
+
3090
+ /* ---------- chart ---------- */
3091
+ .chart-fig{margin:8px 0 0}
3092
+ .chart-fig svg{display:block;width:100%;height:auto;max-height:56vh}
3093
+ .chart-split{display:grid;grid-template-columns:1.55fr 1fr;gap:clamp(20px,3vw,44px);align-items:center}
3094
+ @media (max-width:840px){.chart-split{grid-template-columns:1fr}}
3095
+ .insights ul.clean li{font-size:clamp(16px,1.7vw,21px)}
3096
+ .source{margin-top:6px;font-family:var(--font-mono);font-size:11.5px;color:var(--ink-faint)}
3097
+ .slide.solid .chart-fig{background:#fff;border-radius:10px;padding:14px}
3098
+
3099
+ /* ---------- table ---------- */
3100
+ .table-wrap{margin-top:14px;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--paper-2);box-shadow:var(--shadow)}
3101
+ .data-table{width:100%;border-collapse:collapse;font-size:clamp(14px,1.45vw,18px);font-variant-numeric:tabular-nums}
3102
+ .data-table th,.data-table td{padding:12px 18px;border-bottom:1px solid var(--line-faint);color:var(--ink-soft)}
3103
+ .data-table thead th{background:var(--paper-3);font-family:var(--font-mono);font-size:12px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-faint)}
3104
+ .data-table tbody th{font-weight:600;color:var(--ink)}
3105
+ .data-table tbody tr:nth-child(even){background:hsl(60 11% 98%)}
3106
+ .data-table .al-left{text-align:left}.data-table .al-center{text-align:center}.data-table .al-right{text-align:right}
3107
+ .data-table .hl-row th,.data-table .hl-row td{background:var(--red-soft);color:var(--ink);font-weight:600}
3108
+ .data-table .hl-col{background:color-mix(in srgb,var(--red-soft) 70%,transparent)}
3109
+ .data-table thead .hl-col{color:var(--red)}
3110
+ .data-table tfoot th,.data-table tfoot td{font-weight:700;color:var(--ink);border-top:2px solid var(--ink);border-bottom:0}
3111
+ .slide.solid .data-table th,.slide.solid .data-table td{color:var(--ink-soft)}
3112
+ .slide.solid .data-table tbody th,.slide.solid .data-table tfoot *{color:var(--ink)}
3113
+
3114
+ /* ---------- flow ---------- */
3115
+ .flow{list-style:none;margin:18px 0 0;padding:0;display:grid;gap:clamp(22px,2.6vw,40px)}
3116
+ .flow--horizontal{grid-template-columns:repeat(var(--n),minmax(0,1fr));align-items:stretch}
3117
+ .flow--vertical{max-width:720px;gap:34px}
3118
+ .align-center .flow--vertical{margin-left:auto;margin-right:auto}
3119
+ .flow-step{position:relative;display:flex;gap:14px;align-items:flex-start;background:var(--paper-2);border:1px solid var(--line);border-radius:12px;padding:18px 20px;box-shadow:var(--shadow);text-align:left}
3120
+ .flow-step h3{margin:0 0 4px;font-size:clamp(16px,1.6vw,20px);color:var(--ink)}
3121
+ .flow-step p{margin:0;font-size:clamp(13.5px,1.35vw,16px);line-height:1.45;color:var(--ink-soft)}
3122
+ .flow-n{flex:none;width:32px;height:32px;border-radius:50%;background:var(--red);color:#fff;font-family:var(--font-mono);font-weight:700;display:flex;align-items:center;justify-content:center;font-size:14px}
3123
+ .slide[data-track="build"] .flow-n{background:var(--teal-fill)}
3124
+ .slide[data-track="neutral"] .flow-n{background:var(--navy)}
3125
+ .flow>li:not(:last-child)::after{content:"";position:absolute;background:var(--red);-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4 11h12.2l-4.6-4.6L13 5l7 7-7 7-1.4-1.4 4.6-4.6H4z'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4 11h12.2l-4.6-4.6L13 5l7 7-7 7-1.4-1.4 4.6-4.6H4z'/%3E%3C/svg%3E") center/contain no-repeat}
3126
+ .flow--horizontal>li:not(:last-child)::after{width:24px;height:24px;right:calc(-1 * clamp(22px,2.6vw,40px) / 2 - 12px);top:calc(50% - 12px)}
3127
+ .flow--vertical>li:not(:last-child)::after{width:26px;height:26px;left:calc(50% - 13px);bottom:-31px;transform:rotate(90deg)}
3128
+ .slide[data-track="build"] .flow>li::after{background:var(--teal)}
3129
+ .flow-end{position:relative;display:flex;align-items:center;justify-content:center}
3130
+ .flow-end span{background:var(--red);color:#fff;font-weight:700;letter-spacing:.12em;text-transform:uppercase;padding:12px 30px;border-radius:999px;font-size:clamp(14px,1.4vw,17px)}
3131
+ .slide.solid .flow-step h3{color:var(--ink)}
3132
+ .slide.solid .flow-step p{color:var(--ink-soft)}
3133
+
3134
+ /* ---------- timeline ---------- */
3135
+ .timeline{margin-top:24px;display:grid;grid-template-columns:1fr;gap:14px}
3136
+ .tl-phases{display:grid;grid-template-columns:repeat(var(--n),minmax(0,1fr));gap:0 10px}
3137
+ .tl-phase{border-radius:8px 8px 0 0;padding:8px 12px;font-family:var(--font-mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;font-weight:700;border-bottom:3px solid}
3138
+ .tl-phase--0{background:var(--red-soft);color:var(--red);border-color:var(--red)}
3139
+ .tl-phase--1{background:var(--teal-soft);color:var(--teal);border-color:var(--teal)}
3140
+ .tl-items{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(var(--n),minmax(0,1fr));gap:10px;position:relative}
3141
+ .tl-items::before{content:"";position:absolute;left:0;right:0;top:9px;height:2px;background:var(--line)}
3142
+ .tl-item{position:relative;padding-top:30px;text-align:left}
3143
+ .tl-dot{position:absolute;top:2px;left:0;width:16px;height:16px;border-radius:50%;background:#fff;border:2.5px solid var(--line-strong)}
3144
+ .tl-in-0 .tl-dot,.tl-done .tl-dot{border-color:var(--red)}
3145
+ .tl-in-1 .tl-dot{border-color:var(--teal)}
3146
+ .tl-done .tl-dot{background:var(--red)}
3147
+ .tl-current .tl-dot{background:var(--red);border-color:var(--red);box-shadow:0 0 0 5px var(--red-soft);transform:scale(1.2)}
3148
+ .tl-date{display:block;font-family:var(--font-mono);font-size:12px;letter-spacing:.06em;color:var(--ink-faint);margin-bottom:4px}
3149
+ .tl-item h3{margin:0 0 4px;font-size:clamp(14px,1.45vw,18px);line-height:1.2;color:var(--ink)}
3150
+ .tl-item p{margin:0;font-size:clamp(12.5px,1.2vw,14.5px);line-height:1.4;color:var(--ink-soft)}
3151
+ .tl-current h3{color:var(--red)}
3152
+ .slide.solid .tl-item h3{color:#fff}
3153
+ .slide.solid .tl-items::before{background:hsl(0 0% 100% / .35)}
3154
+
3155
+ /* ---------- cycle ---------- */
3156
+ .cycle{position:relative;width:min(62vh,540px);aspect-ratio:1;margin:10px auto 0}
3157
+ .cycle-ring{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
3158
+ .cycle-ring path{fill:none;stroke:var(--line-strong);stroke-width:.9}
3159
+ .cycle-ring marker path{fill:var(--line-strong);stroke:none}
3160
+ .cycle-center{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-family:var(--font-display);font-style:italic;font-size:clamp(20px,2.4vw,30px);color:var(--red);text-align:center;max-width:30%}
3161
+ .cycle-node{position:absolute;transform:translate(-50%,-50%);width:clamp(140px,15vw,200px);background:var(--paper-2);border:1px solid var(--line);border-top:3px solid var(--red);border-radius:10px;padding:12px 14px;box-shadow:var(--shadow);text-align:center}
3162
+ .slide[data-track="build"] .cycle-node{border-top-color:var(--teal)}
3163
+ .cycle-node h3{margin:0 0 3px;font-size:clamp(15px,1.5vw,18px);color:var(--ink)}
3164
+ .cycle-node p{margin:0;font-size:clamp(12.5px,1.2vw,14px);line-height:1.35;color:var(--ink-soft)}
3165
+
3166
+ /* ---------- hierarchy ---------- */
3167
+ .hierarchy{margin-top:16px;overflow:visible}
3168
+ .hierarchy ul{position:relative;display:flex;justify-content:center;gap:0;margin:0;padding:26px 0 0;list-style:none}
3169
+ .hierarchy>ul{padding-top:0}
3170
+ .hierarchy li{position:relative;display:flex;flex-direction:column;align-items:center;padding:26px 10px 0}
3171
+ .hierarchy>ul>li{padding-top:0}
3172
+ .hierarchy li::before,.hierarchy li::after{content:"";position:absolute;top:0;width:50%;height:26px;border-top:2px solid var(--line-strong)}
3173
+ .hierarchy li::before{right:50%}
3174
+ .hierarchy li::after{left:50%;border-left:2px solid var(--line-strong)}
3175
+ .hierarchy li:only-child::before,.hierarchy li:only-child::after{border-top:0}
3176
+ .hierarchy li:first-child::before,.hierarchy li:last-child::after{border-top:0}
3177
+ .hierarchy li:last-child::before{border-right:2px solid var(--line-strong)}
3178
+ .hierarchy li:last-child::after{border-left:0}
3179
+ .hierarchy>ul>li::before,.hierarchy>ul>li::after{display:none}
3180
+ .hierarchy ul ul::before{content:"";position:absolute;top:0;left:50%;height:26px;border-left:2px solid var(--line-strong)}
3181
+ .h-node{display:flex;align-items:center;gap:12px;background:var(--paper-2);border:1px solid var(--line);border-top:3px solid var(--red);border-radius:10px;padding:12px 16px;box-shadow:var(--shadow);text-align:left;min-width:150px;max-width:240px}
3182
+ .hierarchy>ul>li>.h-node{border-top-color:var(--navy)}
3183
+ .h-node .avatar{width:44px;height:44px;border-radius:50%;object-fit:cover;flex:none}
3184
+ .h-node h3{margin:0;font-size:clamp(14px,1.4vw,17px);color:var(--ink)}
3185
+ .h-node p{margin:2px 0 0;font-size:clamp(12px,1.15vw,13.5px);line-height:1.3;color:var(--ink-soft)}
3186
+ .slide.solid .hierarchy li::before,.slide.solid .hierarchy li::after,.slide.solid .hierarchy ul ul::before{border-color:hsl(0 0% 100% / .5)}
3187
+
3188
+ /* ---------- matrix ---------- */
3189
+ .matrix{display:grid;grid-template-columns:auto 1fr;grid-template-rows:1fr auto;gap:10px;margin-top:12px;max-width:980px}
3190
+ .mx-y{grid-row:1;display:flex;flex-direction:column-reverse;justify-content:space-between;align-items:center;writing-mode:vertical-rl;transform:rotate(180deg);font-size:13px;color:var(--ink-faint);font-family:var(--font-mono);letter-spacing:.04em}
3191
+ .mx-y b,.mx-x b{color:var(--ink);font-family:var(--font-body);letter-spacing:.08em;text-transform:uppercase;font-size:12px}
3192
+ .mx-grid{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:10px}
3193
+ .mx-x{grid-column:2;display:flex;justify-content:space-between;font-size:13px;color:var(--ink-faint);font-family:var(--font-mono);letter-spacing:.04em}
3194
+ .mx-q{background:var(--paper-2);border:1px solid var(--line);border-radius:12px;padding:16px 18px;box-shadow:var(--shadow);min-height:clamp(110px,17vh,170px);text-align:left}
3195
+ .mx-q h3{margin:0 0 8px;font-size:clamp(15px,1.5vw,19px);color:var(--ink)}
3196
+ .mx-q ul{margin:0;padding-left:18px;color:var(--ink-soft);font-size:clamp(13px,1.3vw,15.5px);line-height:1.5}
3197
+ .mx-hl{background:var(--red);border-color:var(--red)}
3198
+ .mx-hl h3,.mx-hl ul{color:#fff}
3199
+
3200
+ /* ---------- tiers ---------- */
3201
+ .tiers{list-style:none;margin:18px auto 0;padding:0;display:flex;flex-direction:column;gap:8px;max-width:880px}
3202
+ .tiers li{display:grid;grid-template-columns:1fr minmax(160px,.55fr);align-items:center;gap:22px}
3203
+ .tier-bar{width:var(--w);justify-self:center;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 22px;border-radius:6px;color:#fff;background:color-mix(in srgb,var(--red) calc(100% - var(--i) * 14%),var(--navy))}
3204
+ .slide[data-track="build"] .tier-bar{background:color-mix(in srgb,var(--teal-fill) calc(100% - var(--i) * 14%),var(--navy))}
3205
+ .tier-label{font-weight:700;font-size:clamp(15px,1.55vw,19px)}
3206
+ .tier-bar b{color:#fff;font-family:var(--font-display);font-size:clamp(18px,2vw,26px)}
3207
+ .tier-note{margin:0;font-size:clamp(13px,1.3vw,15.5px);color:var(--ink-soft);text-align:left}
3208
+ .slide.solid .tier-note{color:hsl(0 0% 100% / .85)}
3209
+
3210
+ /* ---------- image hotspots ---------- */
3211
+ .hs-layout{display:grid;grid-template-columns:1.5fr 1fr;gap:clamp(20px,3vw,40px);align-items:center}
3212
+ @media (max-width:840px){.hs-layout{grid-template-columns:1fr}}
3213
+ .hs-layout .diagram-shot{margin:0}
3214
+ .hs-wrap{position:relative;display:inline-block;max-width:100%}
3215
+ .hs-wrap img{display:block;max-height:62vh;width:auto;max-width:100%}
3216
+ .hs-mark{position:absolute;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:var(--red);color:#fff;font-family:var(--font-mono);font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px #fff,0 4px 10px rgba(0,0,0,.3)}
3217
+ .hs-legend{margin:0;padding:0;list-style:none;counter-reset:hs;display:flex;flex-direction:column;gap:12px;text-align:left}
3218
+ .hs-legend li{counter-increment:hs;position:relative;padding-left:42px;font-size:clamp(14px,1.45vw,17px);line-height:1.4;color:var(--ink-soft)}
3219
+ .hs-legend li::before{content:counter(hs);position:absolute;left:0;top:-2px;width:28px;height:28px;border-radius:50%;background:var(--red);color:#fff;font-family:var(--font-mono);font-weight:700;font-size:12.5px;display:flex;align-items:center;justify-content:center}
3220
+ .hs-legend b{display:block;color:var(--ink)}
3221
+ .slide.solid .hs-legend li{color:hsl(0 0% 100% / .85)}
3222
+ .slide.solid .hs-legend b{color:#fff}
3223
+
3224
+ /* ---------- video / embed ---------- */
3225
+ .media-frame{position:relative;margin:8px auto 0;width:min(100%,calc(62vh * 16 / 9));aspect-ratio:16/9;border-radius:12px;overflow:hidden;background:var(--navy);box-shadow:0 18px 40px -18px rgba(0,0,0,.45)}
3226
+ .media-frame.aspect-4x3{aspect-ratio:4/3;width:min(100%,calc(62vh * 4 / 3))}
3227
+ .media-frame.aspect-1x1{aspect-ratio:1;width:min(100%,62vh)}
3228
+ .media-frame.aspect-tall{aspect-ratio:9/14;width:min(100%,calc(64vh * 9 / 14))}
3229
+ .media-poster{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
3230
+ .media-frame iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#fff;z-index:3}
3231
+ .media-fallback{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:28px;background:linear-gradient(180deg,hsl(196 100% 8% / .25),hsl(196 100% 8% / .6));z-index:2}
3232
+ .media-frame.live .media-fallback{display:none}
3233
+ .play-badge{width:84px;height:84px;border-radius:50%;background:var(--red);color:#fff;font-size:34px;display:flex;align-items:center;justify-content:center;padding-left:6px;box-shadow:0 8px 24px rgba(0,0,0,.35)}
3234
+ .media-qr{background:#fff;border-radius:12px;padding:12px;text-align:center;width:150px;box-shadow:var(--shadow)}
3235
+ .media-qr svg{width:100%;height:auto;display:block}
3236
+ .media-qr a{display:block;margin-top:8px;font-family:var(--font-mono);font-size:10.5px;color:var(--ink);text-decoration:none;overflow-wrap:anywhere}
3237
+ .media-open{position:absolute;right:10px;top:10px;z-index:4;font-family:var(--font-mono);font-size:11.5px;background:hsl(0 0% 100% / .92);color:var(--ink);padding:5px 9px;border-radius:6px;text-decoration:none;opacity:.85}
3238
+ .media-open:hover{opacity:1}
3239
+ .has-media .shot-cap{text-align:center}
3240
+
3241
+ /* ---------- compare ---------- */
3242
+ .compare{position:relative;margin:8px auto 0;width:min(100%,calc(62vh * 16 / 10));aspect-ratio:16/10;border-radius:12px;overflow:hidden;box-shadow:var(--shadow);user-select:none}
3243
+ .compare img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
3244
+ .cmp-before-clip{position:absolute;inset:0;clip-path:inset(0 calc(100% - var(--pos)) 0 0)}
3245
+ .cmp-handle{position:absolute;top:0;bottom:0;left:var(--pos);width:3px;margin-left:-1.5px;background:#fff;box-shadow:0 0 12px rgba(0,0,0,.4);pointer-events:none}
3246
+ .cmp-handle::after{content:"◀ ▶";position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;color:var(--ink);font-size:11px;padding:8px 10px;border-radius:999px;white-space:nowrap;box-shadow:var(--shadow)}
3247
+ .cmp-range{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:ew-resize;margin:0}
3248
+ .cmp-label{position:absolute;bottom:12px;z-index:2;font-family:var(--font-mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;background:hsl(196 100% 8% / .72);color:#fff;padding:5px 10px;border-radius:5px}
3249
+ .cmp-label--before{left:12px}
3250
+ .cmp-label--after{right:12px}
3251
+
3252
+ /* ---------- chrome: idle, kiosk, overview, presenter ---------- */
3253
+ body.has-foot .hud,body.has-foot .hint{bottom:40px}
3254
+ .hud,.hint,.dots,.rail{transition:opacity .4s}
3255
+ body.idle .hud,body.idle .hint,body.idle .dots{opacity:0}
3256
+ body.kiosk .hud,body.kiosk .hint,body.kiosk .dots{display:none}
3257
+ body.kiosk .deck{cursor:none}
3258
+ .overview{position:fixed;inset:0;z-index:80;background:hsl(196 100% 6% / .94);display:flex;flex-wrap:wrap;align-content:flex-start;gap:16px;padding:40px 48px;overflow-y:auto}
3259
+ .ov-item{position:relative;padding:0;border:2px solid transparent;border-radius:8px;background:none;cursor:pointer}
3260
+ .ov-item:hover,.ov-item:focus-visible{border-color:#fff;outline:0}
3261
+ .ov-current{border-color:var(--red)}
3262
+ .ov-thumb{position:relative;overflow:hidden;border-radius:6px;background:var(--paper)}
3263
+ .ov-item>span{position:absolute;left:6px;bottom:6px;font-family:var(--font-mono);font-size:11px;background:hsl(196 100% 6% / .8);color:#fff;padding:2px 6px;border-radius:4px}
3264
+ body.presenter{margin:0;height:100vh;display:grid;grid-template-rows:1fr auto;background:#0b1d24;color:#fff;font-family:var(--font-body)}
3265
+ .pv-main{display:grid;grid-template-columns:2fr 1fr;gap:20px;padding:20px;min-height:0}
3266
+ .pv-cur,.pv-next{position:relative;min-width:0}
3267
+ .pv-cur .ov-thumb,.pv-next .ov-thumb{box-shadow:0 10px 30px rgba(0,0,0,.4)}
3268
+ .pv-side{display:flex;flex-direction:column;gap:12px;min-width:0}
3269
+ .pv-label{font-family:var(--font-mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:hsl(0 0% 100% / .6)}
3270
+ .pv-clock{margin-top:auto;display:flex;flex-direction:column;gap:4px;font-family:var(--font-mono)}
3271
+ .pv-elapsed{font-size:46px;font-weight:700;color:#fff}
3272
+ .pv-time,.pv-count{color:hsl(0 0% 100% / .7);font-size:14px}
3273
+ .pv-notes{border-top:3px solid var(--red);padding:16px 24px 22px;max-height:34vh;overflow-y:auto;background:var(--navy)}
3274
+ .pv-notes .notes__body{font-size:19px;max-width:none}
3275
+ .pv-cue{margin-left:12px;color:hsl(0 0% 100% / .5)}
3276
+
3277
+ @media print{
3278
+ .slide-foot{position:absolute}
3279
+ [data-build] .b-item.b-hidden{opacity:1;transform:none}
3280
+ .media-frame iframe{display:none}
3281
+ .media-frame .media-fallback{display:flex!important}
3282
+ .cmp-before-clip{clip-path:inset(0 50% 0 0)}
3283
+ .overview,.hidden-badge{display:none}
3284
+ }
3285
+ `;
1449
3286
 
1450
3287
  // src/render.ts
1451
3288
  var PREVIEW_SIZE = { width: 1280, height: 720 };
@@ -1466,39 +3303,146 @@ function collectImageSources(value, out = /* @__PURE__ */ new Set()) {
1466
3303
  }
1467
3304
  var CHECK_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 L10 17.5 L19 7"/></svg>';
1468
3305
  async function renderDeck(input, options = {}) {
1469
- const result = validateDeck(input);
3306
+ const result = validateDeck(input, { brands: options.brands });
1470
3307
  if (!result.ok || !result.deck) throw new DeckError(result.issues);
1471
3308
  const deck = result.deck;
3309
+ const brand = resolveBrand(deck.brand, options.brands);
3310
+ const visible = deck.slides.filter((sl) => options.includeHidden || !sl.hidden);
3311
+ const qrUrls = /* @__PURE__ */ new Set();
3312
+ const chartReqs = /* @__PURE__ */ new Map();
3313
+ const extraImages = /* @__PURE__ */ new Set();
3314
+ for (const slide of visible) {
3315
+ const prep = getLayout(slide.layout).prepare?.(slide);
3316
+ prep?.qr?.forEach((u) => qrUrls.add(u));
3317
+ prep?.charts?.forEach((c) => chartReqs.set(c.key, c));
3318
+ prep?.images?.forEach((u) => extraImages.add(u));
3319
+ }
1472
3320
  const resolve = options.resolveImage ?? passThrough;
1473
3321
  const images = /* @__PURE__ */ new Map();
3322
+ const brandImages = [brand.logo, brand.logoOnDark, brand.favicon].flatMap(
3323
+ (i) => i ? [i.src] : []
3324
+ );
3325
+ const requiredImages = /* @__PURE__ */ new Set([...collectImageSources(visible), ...brandImages]);
3326
+ const pendingImages = [.../* @__PURE__ */ new Set([...requiredImages, ...extraImages])];
3327
+ let nextImage = 0;
3328
+ let imageError;
3329
+ let imageFailed = false;
1474
3330
  await Promise.all(
1475
- [...collectImageSources(deck.slides)].map(async (src) => images.set(src, await resolve(src)))
3331
+ Array.from({ length: Math.min(4, pendingImages.length) }, async () => {
3332
+ while (!imageFailed && nextImage < pendingImages.length) {
3333
+ const src = pendingImages[nextImage++];
3334
+ try {
3335
+ images.set(src, await resolve(src));
3336
+ } catch (error) {
3337
+ if (requiredImages.has(src)) {
3338
+ imageFailed = true;
3339
+ imageError = error;
3340
+ return;
3341
+ }
3342
+ images.set(src, src);
3343
+ }
3344
+ }
3345
+ })
1476
3346
  );
3347
+ if (imageFailed) throw imageError;
1477
3348
  const qrs = /* @__PURE__ */ new Map();
1478
- for (const slide of deck.slides) {
1479
- if (slide.layout === "qr" && !slide.image) {
1480
- const url = slide.url;
1481
- if (!url) continue;
1482
- qrs.set(
1483
- url,
1484
- await QRCode.toString(url, {
1485
- type: "svg",
1486
- margin: 0,
1487
- errorCorrectionLevel: "M",
1488
- color: { dark: "#002e3fff", light: "#ffffffff" }
1489
- })
1490
- );
1491
- }
3349
+ await Promise.all(
3350
+ [...qrUrls].map(async (url) => {
3351
+ try {
3352
+ qrs.set(
3353
+ url,
3354
+ await QRCode.toString(url, {
3355
+ type: "svg",
3356
+ margin: 0,
3357
+ errorCorrectionLevel: "M",
3358
+ color: { dark: "#002e3fff", light: "#ffffffff" }
3359
+ })
3360
+ );
3361
+ } catch {
3362
+ throw new DeckError([
3363
+ {
3364
+ level: "error",
3365
+ path: ["slides"],
3366
+ message: "A QR code could not be generated. Shorten its URL and try again."
3367
+ }
3368
+ ]);
3369
+ }
3370
+ })
3371
+ );
3372
+ const charts = /* @__PURE__ */ new Map();
3373
+ for (const [key, req] of chartReqs) {
3374
+ charts.set(
3375
+ key,
3376
+ await renderChartSvg(req.spec, {
3377
+ width: req.width,
3378
+ height: req.height,
3379
+ palette: brand.stock ? void 0 : brand.palette
3380
+ })
3381
+ );
3382
+ }
3383
+ const imageBudget = options.maxInlineImageBytes;
3384
+ if (imageBudget !== void 0 && (!Number.isFinite(imageBudget) || imageBudget < 0)) {
3385
+ throw new RangeError("maxInlineImageBytes must be a finite nonnegative number.");
1492
3386
  }
3387
+ let emittedImageBytes = 0;
3388
+ let imagePath = ["brand"];
3389
+ const imageSizes = /* @__PURE__ */ new Map();
3390
+ const warnedImages = /* @__PURE__ */ new Set();
3391
+ const image2 = (src, path = imagePath) => {
3392
+ const resolved = images.get(src) ?? src;
3393
+ if (imageBudget === void 0 || !/^data:/i.test(resolved)) return resolved;
3394
+ let bytes = imageSizes.get(resolved);
3395
+ if (bytes === void 0) {
3396
+ bytes = escapedImageBytes(resolved);
3397
+ imageSizes.set(resolved, bytes);
3398
+ }
3399
+ if (emittedImageBytes + bytes <= imageBudget) {
3400
+ emittedImageBytes += bytes;
3401
+ return resolved;
3402
+ }
3403
+ if (/^https:\/\//i.test(src)) {
3404
+ if (!warnedImages.has(src)) {
3405
+ warnedImages.add(src);
3406
+ result.issues.push({
3407
+ level: "warning",
3408
+ path,
3409
+ message: "An image remains at its original HTTPS URL because the inline image budget was reached."
3410
+ });
3411
+ }
3412
+ return src;
3413
+ }
3414
+ throw new DeckError([
3415
+ {
3416
+ level: "error",
3417
+ path,
3418
+ message: "This embedded image exceeds the rendered inline image budget. Use smaller images or an HTTPS image URL."
3419
+ }
3420
+ ]);
3421
+ };
3422
+ const icon = brand.favicon ? image2(brand.favicon.src, ["brand", "favicon"]) : image2(
3423
+ favicon(brand, (src) => images.get(src) ?? src),
3424
+ ["brand", "favicon"]
3425
+ );
3426
+ const logo = () => deck.settings.logo === "none" ? "" : brandLogo(brand, image2);
1493
3427
  const ctx = {
1494
3428
  deck,
1495
- image: (src) => images.get(src) ?? src,
1496
- qr: (url) => qrs.get(url) ?? ""
3429
+ image: image2,
3430
+ qr: (url) => qrs.get(url) ?? "",
3431
+ chart: (key) => charts.get(key) ?? "",
3432
+ logo,
3433
+ brandName: brand.name
1497
3434
  };
1498
3435
  const sections = [];
1499
3436
  const notes = [];
1500
3437
  const slideMap = [];
3438
+ const footerText = [brand.footer, deck.settings.confidential].filter(Boolean);
3439
+ const wantFooter = deck.settings.slideNumbers || footerText.length > 0 || deck.settings.logo === "all";
1501
3440
  deck.slides.forEach((slide, i) => {
3441
+ if (slide.hidden && !options.includeHidden) {
3442
+ slideMap.push([]);
3443
+ return;
3444
+ }
3445
+ imagePath = ["slides", i];
1502
3446
  const layout = getLayout(slide.layout);
1503
3447
  const rendered = layout.render(slide, ctx);
1504
3448
  const list = Array.isArray(rendered) ? rendered : [rendered];
@@ -1508,6 +3452,14 @@ async function renderDeck(input, options = {}) {
1508
3452
  list.forEach((r, j) => {
1509
3453
  const classes = ["slide", `layout-${layout.name}`, ...r.classes ?? []];
1510
3454
  if (bg !== "paper") classes.push("solid", `bg-${bg}`);
3455
+ let style = "";
3456
+ if (slide.backgroundImage) {
3457
+ const overlay = slide.overlay ?? "dark";
3458
+ classes.push("has-bg", `overlay-${overlay}`);
3459
+ if (overlay !== "light" && !classes.includes("solid")) classes.push("solid");
3460
+ style = ` style="--bg-img:url(&quot;${escapeAttr(ctx.image(slide.backgroundImage.src))}&quot;)"`;
3461
+ }
3462
+ if (slide.hidden) classes.push("is-hidden");
1511
3463
  let chrome = "";
1512
3464
  if (slide.step && deck.journey) {
1513
3465
  classes.push("has-step");
@@ -1515,14 +3467,17 @@ async function renderDeck(input, options = {}) {
1515
3467
  }
1516
3468
  const strip = slide.showJourney && deck.journey ? roadmap(deck.journey.steps, slide.step, true) : "";
1517
3469
  const id = slide.id ? list.length > 1 ? `${slide.id}-${j + 1}` : slide.id : "";
3470
+ const number = sections.length + 1;
3471
+ const foot = wantFooter && layout.name !== "title" ? `<footer class="slide-foot" aria-hidden="true"><span class="foot-l">${deck.settings.logo === "all" ? `<span class="foot-logo">${logo()}</span>` : ""}${footerText.map((t) => `<span>${escapeHtml(t)}</span>`).join("")}</span>${deck.settings.slideNumbers ? `<span class="foot-n">${number}</span>` : ""}</footer>` : "";
1518
3472
  sections.push(
1519
- `<section class="${classes.join(" ")}" data-track="${escapeAttr(track)}" data-index="${i}"${id ? ` id="${escapeAttr(id)}"` : ""} aria-label="Slide ${sections.length + 1}">${chrome}<div class="slide__inner">${strip}${r.html}</div></section>`
3473
+ `<section class="${classes.join(" ")}" data-track="${escapeAttr(track)}" data-index="${i}"${id ? ` id="${escapeAttr(id)}"` : ""}${style} aria-label="Slide ${number}">${chrome}<div class="slide__inner">${strip}${r.html}</div>${foot}${slide.hidden ? '<span class="hidden-badge">Hidden</span>' : ""}</section>`
1520
3474
  );
1521
3475
  notes.push({ html: prose(r.notes ?? slide.notes), time: r.time ?? slide.time });
1522
3476
  });
1523
3477
  });
3478
+ const withNotes = options.speakerNotes !== false;
1524
3479
  const json = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
1525
- const stripData = (_, v) => typeof v === "string" && v.startsWith("data:") ? "(embedded)" : v;
3480
+ const stripData = (key, v) => !withNotes && key === "notes" ? void 0 : typeof v === "string" && /^data:/i.test(v) ? "(embedded)" : v;
1526
3481
  const source = options.embedSource === false ? "" : `<script type="application/json" id="deck-source">${JSON.stringify(deck, stripData).replace(/</g, "\\u003c")}</script>
1527
3482
  `;
1528
3483
  const html = `<!doctype html>
@@ -1530,27 +3485,74 @@ async function renderDeck(input, options = {}) {
1530
3485
  <head>
1531
3486
  <meta charset="utf-8">
1532
3487
  <meta name="viewport" content="width=device-width, initial-scale=1">
1533
- <title>${escapeHtml(deck.title)} &middot; Classic Homes</title>
3488
+ <title>${escapeHtml(deck.title)} &middot; ${escapeHtml(brand.name)}</title>
3489
+ <link rel="icon" href="${escapeAttr(icon)}">
1534
3490
  ${deck.description ? `<meta name="description" content="${escapeAttr(deck.description)}">
1535
3491
  ` : ""}<meta name="generator" content="@classic-homes/slides">
1536
3492
  <style id="brand-fonts">${fontsCss()}</style>
1537
- <style>${tokensCss()}${DECK_CSS}</style>
3493
+ <style>${tokensCss(brand.stock ? void 0 : brand.palette)}${DECK_CSS}${DECK_CSS_EXTRA}</style>
1538
3494
  </head>
1539
- <body${options.initialSlide ? ` data-initial-slide="${Math.max(0, Math.floor(options.initialSlide))}"` : ""}>
3495
+ <body${wantFooter ? ' class="has-foot"' : ""}${options.initialSlide ? ` data-initial-slide="${Math.max(0, Math.floor(options.initialSlide))}"` : ""}${deck.settings.autoAdvance ? ` data-auto-advance="${deck.settings.autoAdvance}"` : ""}${deck.settings.loop ? " data-loop" : ""}${options.allowEmbeddedKiosk ? " data-embedded-kiosk" : ""}>
1540
3496
  <div class="rail" id="rail"></div>
1541
3497
  <main class="deck" id="deck">
1542
3498
  ${sections.join("\n")}
1543
3499
  </main>
1544
3500
  <nav class="dots" id="dots" aria-label="Slide navigation"></nav>
1545
- <div class="hud"><span class="counter" id="counter" aria-live="polite"></span><button class="btn" id="notesBtn" aria-label="Toggle speaker notes">&#9636; notes (N)</button></div>
1546
- <div class="hint"><kbd>&uarr;</kbd><kbd>&darr;</kbd> or <kbd>Space</kbd> navigate &middot; <kbd>N</kbd> notes &middot; <kbd>F</kbd> fullscreen</div>
1547
- <aside class="notes" id="notes" aria-live="polite"><div class="notes__head"><span class="tag">Speaker notes</span><span class="time" id="notesTime"></span></div><div class="notes__body" id="notesBody"></div></aside>
1548
- <script type="application/json" id="deck-notes">${json(notes)}</script>
3501
+ ${withNotes ? `<div class="hud"><span class="counter" id="counter" aria-live="polite"></span><button class="btn" id="notesBtn" aria-label="Toggle speaker notes">&#9636; notes (N)</button></div>
3502
+ <div class="hint"><kbd>&uarr;</kbd><kbd>&darr;</kbd> or <kbd>Space</kbd> navigate &middot; <kbd>N</kbd> notes &middot; <kbd>P</kbd> presenter &middot; <kbd>O</kbd> overview &middot; <kbd>F</kbd> fullscreen</div>
3503
+ <aside class="notes" id="notes" aria-live="polite"><div class="notes__head"><span class="tag">Speaker notes</span><span class="time" id="notesTime"></span></div><div class="notes__body" id="notesBody"></div></aside>` : `<div class="hud"><span class="counter" id="counter" aria-live="polite"></span></div>
3504
+ <div class="hint"><kbd>&uarr;</kbd><kbd>&darr;</kbd> or <kbd>Space</kbd> navigate &middot; <kbd>O</kbd> overview &middot; <kbd>F</kbd> fullscreen</div>`}
3505
+ <script type="application/json" id="deck-notes">${json(withNotes ? notes : [])}</script>
1549
3506
  ${source}<script>${RUNTIME_JS}</script>
1550
3507
  </body>
1551
3508
  </html>
1552
3509
  `;
1553
- return { html, deck, slideCount: sections.length, slideMap, warnings: result.issues };
3510
+ return { html, deck, slideCount: sections.length, slideMap, warnings: result.issues, brand };
3511
+ }
3512
+ function escapedImageBytes(value) {
3513
+ let bytes = 0;
3514
+ for (let i = 0; i < value.length; i++) {
3515
+ const code = value.codePointAt(i);
3516
+ if (code === 38 || code === 39)
3517
+ bytes += 5;
3518
+ else if (code === 34)
3519
+ bytes += 6;
3520
+ else if (code === 60 || code === 62)
3521
+ bytes += 4;
3522
+ else if (code < 128) bytes++;
3523
+ else if (code < 2048) bytes += 2;
3524
+ else if (code <= 65535) bytes += 3;
3525
+ else {
3526
+ bytes += 4;
3527
+ i++;
3528
+ }
3529
+ }
3530
+ return bytes;
3531
+ }
3532
+ function brandLogo(brand, image2) {
3533
+ if (brand.logo) {
3534
+ const light = image2(brand.logo.src);
3535
+ const dark = image2((brand.logoOnDark ?? brand.logo).src);
3536
+ return `<span class="logo logo--img" role="img" aria-label="${escapeAttr(brand.name)}"><img class="logo-light" src="${escapeAttr(
3537
+ light
3538
+ )}" alt=""><img class="logo-dark" src="${escapeAttr(dark)}" alt=""></span>`;
3539
+ }
3540
+ if (brand.name === "Classic Homes") return LOGO_SVG;
3541
+ return logoLockup(brand.name, brand.key === "classic-homes");
3542
+ }
3543
+ function favicon(brand, image2) {
3544
+ if (brand.favicon) return image2(brand.favicon.src);
3545
+ if (brand.key !== "classic-homes") {
3546
+ const letter = (brand.name.match(/[A-Za-z0-9]/)?.[0] ?? "\u2022").toUpperCase();
3547
+ const mono = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="${brand.palette.primary}"/><text x="16" y="22.5" text-anchor="middle" font-family="Figtree,Arial,sans-serif" font-size="19" font-weight="700" fill="#fff">${letter}</text></svg>`;
3548
+ return `data:image/svg+xml,${encodeURIComponent(mono)}`;
3549
+ }
3550
+ const colour = brand.stock ? "#c50f22" : brand.palette.primary;
3551
+ const svg = LOGO_MARK_SVG.replace(
3552
+ "<svg ",
3553
+ `<svg xmlns="http://www.w3.org/2000/svg" fill="${colour}" `
3554
+ ).replace(' aria-hidden="true"', "");
3555
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
1554
3556
  }
1555
3557
  function extractDeckSource(html) {
1556
3558
  const m = /<script type="application\/json" id="deck-source">([\s\S]*?)<\/script>/.exec(html);
@@ -1567,7 +3569,7 @@ function fromZod(issue) {
1567
3569
  }
1568
3570
  return { level: "error", path: issue.path, message };
1569
3571
  }
1570
- function validateDeck(input) {
3572
+ function validateDeck(input, options = {}) {
1571
3573
  const parsed = DeckSchema.safeParse(input);
1572
3574
  if (!parsed.success) {
1573
3575
  return { ok: false, issues: parsed.error.issues.map(fromZod) };
@@ -1578,6 +3580,18 @@ function validateDeck(input) {
1578
3580
  const seen = /* @__PURE__ */ new Set();
1579
3581
  deck.slides.forEach((slide, i) => {
1580
3582
  const layout = getLayout(slide.layout);
3583
+ for (const url of layout.prepare?.(slide)?.qr ?? []) {
3584
+ try {
3585
+ if (url.length > 1e4) throw new Error("URL too long");
3586
+ QRCode2.create(url, { errorCorrectionLevel: "M" });
3587
+ } catch {
3588
+ issues.push({
3589
+ level: "error",
3590
+ path: ["slides", i, "url"],
3591
+ message: "This URL is too long for a QR code. Use a shorter URL."
3592
+ });
3593
+ }
3594
+ }
1581
3595
  for (const issue of layout.lint?.(slide) ?? []) {
1582
3596
  issues.push({ ...issue, path: ["slides", i, ...issue.path] });
1583
3597
  }
@@ -1612,6 +3626,22 @@ function validateDeck(input) {
1612
3626
  });
1613
3627
  }
1614
3628
  });
3629
+ const profile = deck.brand?.profile;
3630
+ if (profile && ![CLASSIC_HOMES_BRAND, ...options.brands ?? []].some((b) => b.key === profile)) {
3631
+ const known = [CLASSIC_HOMES_BRAND, ...options.brands ?? []].map((b) => b.key).join(", ");
3632
+ issues.push({
3633
+ level: "error",
3634
+ path: ["brand", "profile"],
3635
+ message: `Unknown brand profile "${profile}". Available: ${known}.`
3636
+ });
3637
+ }
3638
+ if (deck.slides.every((sl) => sl.hidden)) {
3639
+ issues.push({
3640
+ level: "error",
3641
+ path: ["slides"],
3642
+ message: "Every slide is hidden; unhide at least one."
3643
+ });
3644
+ }
1615
3645
  if (deck.slides[0]?.layout !== "title") {
1616
3646
  issues.push({
1617
3647
  level: "warning",
@@ -1654,22 +3684,52 @@ function stringifyDeck(deck) {
1654
3684
  }
1655
3685
 
1656
3686
  export {
1657
- escapeHtml,
1658
- inline,
1659
- prose,
1660
3687
  baseSlide,
3688
+ BrandPaletteSchema,
3689
+ BrandProfileSchema,
3690
+ DeckBrandSchema,
3691
+ CLASSIC_HOMES_PALETTE,
3692
+ CLASSIC_HOMES_BRAND,
3693
+ resolveBrand,
3694
+ checkPalette,
3695
+ validateBrandProfile,
1661
3696
  PALETTE,
1662
3697
  LOGO_SVG,
3698
+ logoLockup,
3699
+ CHART_TYPES,
3700
+ ChartSpec,
3701
+ formatValue,
3702
+ chartOption,
3703
+ renderChartSvg,
3704
+ describeChart,
3705
+ escapeHtml,
3706
+ inline,
3707
+ prose,
3708
+ parseVideoUrl,
3709
+ videoEmbedUrl,
1663
3710
  LAYOUTS,
1664
3711
  getLayout,
3712
+ LAYOUT_CATEGORY_ORDER,
3713
+ LAYOUT_CATEGORIES,
3714
+ findSlide,
3715
+ newSlideId,
3716
+ ensureSlideIds,
3717
+ slideKey,
3718
+ addSlide,
3719
+ updateSlide,
3720
+ moveSlide,
3721
+ removeSlide,
3722
+ outline,
3723
+ SlideSchema,
3724
+ DeckSettingsSchema,
3725
+ DeckSchema,
3726
+ deckEditorFields,
1665
3727
  commonSlideFields,
1666
3728
  layoutEditorInfo,
1667
3729
  allLayoutEditorInfo,
1668
3730
  PLACEHOLDER_IMAGE,
1669
3731
  isPlaceholderImage,
1670
3732
  blankSlide,
1671
- SlideSchema,
1672
- DeckSchema,
1673
3733
  validateDeck,
1674
3734
  formatPath,
1675
3735
  formatIssues,
@@ -1681,4 +3741,4 @@ export {
1681
3741
  parseDeckSource,
1682
3742
  stringifyDeck
1683
3743
  };
1684
- //# sourceMappingURL=chunk-7FROOJCY.js.map
3744
+ //# sourceMappingURL=chunk-GXGDPBAR.js.map