@classic-homes/slides 0.2.0 → 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.
- package/README.md +74 -25
- package/dist/{chunk-CKVKX2ZD.js → chunk-2IPLZ3SA.js} +37 -7
- package/dist/chunk-2IPLZ3SA.js.map +1 -0
- package/dist/{chunk-TM3TNVHB.js → chunk-GXGDPBAR.js} +2717 -493
- package/dist/chunk-GXGDPBAR.js.map +1 -0
- package/dist/chunk-YI7A4EWX.js +107 -0
- package/dist/chunk-YI7A4EWX.js.map +1 -0
- package/dist/cli.js +15 -5
- package/dist/cli.js.map +1 -1
- package/dist/echarts-render-2QVSBZ6V.js +50 -0
- package/dist/echarts-render-2QVSBZ6V.js.map +1 -0
- package/dist/index.d.ts +149 -4
- package/dist/index.js +72 -133
- package/dist/index.js.map +1 -1
- package/dist/{mcp-5R6YD3QW.js → mcp-PUA45IS7.js} +17 -14
- package/dist/mcp-PUA45IS7.js.map +1 -0
- package/dist/node.d.ts +4 -2
- package/dist/node.js +2 -2
- package/dist/render-DcyKuBRD.d.ts +580 -0
- package/package.json +13 -3
- package/dist/chunk-CKVKX2ZD.js.map +0 -1
- package/dist/chunk-TM3TNVHB.js.map +0 -1
- package/dist/chunk-TXNQKBWJ.js +0 -69
- package/dist/chunk-TXNQKBWJ.js.map +0 -1
- package/dist/chunk-VONVGYRL.js +0 -52
- package/dist/chunk-VONVGYRL.js.map +0 -1
- package/dist/mcp-5R6YD3QW.js.map +0 -1
- package/dist/render-CZ4g2fa9.d.ts +0 -136
|
@@ -1,56 +1,154 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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:
|
|
69
|
-
redLogo:
|
|
70
|
-
teal:
|
|
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:
|
|
73
|
-
navy:
|
|
74
|
-
plum:
|
|
75
|
-
sage:
|
|
76
|
-
stone:
|
|
77
|
-
grey:
|
|
78
|
-
ink:
|
|
79
|
-
inkSoft:
|
|
80
|
-
inkFaint:
|
|
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:${
|
|
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:${
|
|
89
|
-
--teal:${
|
|
90
|
-
--navy:${
|
|
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,118 +216,511 @@ 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
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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/
|
|
125
|
-
import { z } from "zod";
|
|
126
|
-
var
|
|
127
|
-
"
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
"
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
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;
|
|
177
277
|
}
|
|
178
|
-
function
|
|
179
|
-
|
|
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;
|
|
180
292
|
}
|
|
181
|
-
function
|
|
182
|
-
|
|
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;
|
|
183
313
|
}
|
|
184
|
-
function
|
|
185
|
-
|
|
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];
|
|
186
317
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
)
|
|
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
|
+
};
|
|
193
546
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const classes = [cls, image2.size === "natural" ? "natural" : ""].filter(Boolean).join(" ");
|
|
199
|
-
const src = ctx.image(image2.src);
|
|
200
|
-
const loading = src.startsWith("data:") ? "" : ' loading="lazy"';
|
|
201
|
-
return `<img${classes ? ` class="${classes}"` : ""} src="${escapeAttr(src)}" alt="${escapeAttr(image2.alt)}"${style.length ? ` style="${style.join(";")}"` : ""}${loading} decoding="async">`;
|
|
547
|
+
var renderer;
|
|
548
|
+
function loadChartRenderer() {
|
|
549
|
+
renderer ??= import("./echarts-render-2QVSBZ6V.js").then((m) => m.createRenderer());
|
|
550
|
+
return renderer;
|
|
202
551
|
}
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
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"`);
|
|
212
562
|
}
|
|
213
|
-
function
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
path,
|
|
219
|
-
message: `${list.length} ${what}; more than ${limit} crowds the slide. Split it into two slides.`
|
|
220
|
-
}
|
|
221
|
-
];
|
|
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(". ")}.`;
|
|
222
568
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
569
|
+
|
|
570
|
+
// src/inline.ts
|
|
571
|
+
var ESCAPES = {
|
|
572
|
+
"&": "&",
|
|
573
|
+
"<": "<",
|
|
574
|
+
">": ">",
|
|
575
|
+
'"': """,
|
|
576
|
+
"'": "'"
|
|
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";
|
|
666
|
+
|
|
667
|
+
// src/layouts/parts.ts
|
|
668
|
+
function eyebrow(text, sub) {
|
|
669
|
+
if (!text) return "";
|
|
670
|
+
const clean = sub?.replace(/^\s*\/\s*/, "");
|
|
671
|
+
return `<p class="eyebrow">${inline(text)}${clean ? ` <span class="act">/ ${inline(clean)}</span>` : ""}</p>`;
|
|
672
|
+
}
|
|
673
|
+
function heading(text, level = 2, cls = "") {
|
|
674
|
+
return `<h${level}${cls ? ` class="${cls}"` : ""}>${inline(text)}</h${level}>`;
|
|
675
|
+
}
|
|
676
|
+
function footnote(text) {
|
|
677
|
+
return text ? `<p class="footnote">${inline(text)}</p>` : "";
|
|
678
|
+
}
|
|
679
|
+
function lead(text, cls = "lead") {
|
|
680
|
+
return text ? `<p class="${cls}">${inline(text)}</p>` : "";
|
|
681
|
+
}
|
|
682
|
+
function items(list, marker = "square") {
|
|
683
|
+
if (!list?.length) return "";
|
|
684
|
+
const cls = marker === "check" ? "clean check" : "clean";
|
|
685
|
+
return `<ul class="${cls}">${list.map(
|
|
686
|
+
(i) => `<li class="b-item">${inline(i.text)}${i.sub ? `<span class="sub">${inline(i.sub)}</span>` : ""}</li>`
|
|
687
|
+
).join("")}</ul>`;
|
|
688
|
+
}
|
|
689
|
+
function img(image2, ctx, cls = "") {
|
|
690
|
+
const style = [];
|
|
691
|
+
if (image2.fit === "contain") style.push("object-fit:contain");
|
|
692
|
+
if (image2.focus) style.push(`object-position:${image2.focus.replace(/[;"<>]/g, "")}`);
|
|
693
|
+
const classes = [cls, image2.size === "natural" ? "natural" : ""].filter(Boolean).join(" ");
|
|
694
|
+
const src = ctx.image(image2.src);
|
|
695
|
+
const loading = src.startsWith("data:") ? "" : ' loading="lazy"';
|
|
696
|
+
return `<img${classes ? ` class="${classes}"` : ""} src="${escapeAttr(src)}" alt="${escapeAttr(image2.alt)}"${style.length ? ` style="${style.join(";")}"` : ""}${loading} decoding="async">`;
|
|
697
|
+
}
|
|
698
|
+
function maxLen(value, limit, path, what) {
|
|
699
|
+
if (!value || value.length <= limit) return [];
|
|
700
|
+
return [
|
|
701
|
+
{
|
|
702
|
+
level: "warning",
|
|
703
|
+
path,
|
|
704
|
+
message: `${what} is ${value.length} characters; keep it under ${limit} so the slide stays readable. Move detail to the speaker notes.`
|
|
705
|
+
}
|
|
706
|
+
];
|
|
707
|
+
}
|
|
708
|
+
function maxCount(list, limit, path, what) {
|
|
709
|
+
if (!list || list.length <= limit) return [];
|
|
710
|
+
return [
|
|
711
|
+
{
|
|
712
|
+
level: "warning",
|
|
713
|
+
path,
|
|
714
|
+
message: `${list.length} ${what}; more than ${limit} crowds the slide. Split it into two slides.`
|
|
715
|
+
}
|
|
716
|
+
];
|
|
717
|
+
}
|
|
718
|
+
function missingAlt(images, path) {
|
|
719
|
+
return (images ?? []).flatMap(
|
|
720
|
+
(im, i) => im.alt ? [] : [
|
|
721
|
+
{
|
|
722
|
+
level: "warning",
|
|
723
|
+
path: [...path, i, "alt"],
|
|
229
724
|
message: "Image has no alt text. Describe it for people using screen readers."
|
|
230
725
|
}
|
|
231
726
|
]
|
|
@@ -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:
|
|
253
|
-
logo:
|
|
254
|
-
align:
|
|
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">${
|
|
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:
|
|
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:
|
|
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:
|
|
317
|
-
logo:
|
|
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">${
|
|
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:
|
|
837
|
+
items: z4.array(Item).min(1),
|
|
343
838
|
marker: Marker,
|
|
344
|
-
columns:
|
|
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",
|
|
@@ -359,186 +855,697 @@ var bullets = defineLayout({
|
|
|
359
855
|
"Certified funds to close"
|
|
360
856
|
]
|
|
361
857
|
},
|
|
362
|
-
lint: (s) => [
|
|
363
|
-
...maxLen(s.title, 80, ["title"], "Title"),
|
|
364
|
-
...maxCount(s.items, s.columns === 2 ? 10 : 6, ["items"], "items"),
|
|
365
|
-
...s.items.flatMap((i, n) => maxLen(i.text, 110, ["items", n, "text"], "Item"))
|
|
366
|
-
],
|
|
858
|
+
lint: (s) => [
|
|
859
|
+
...maxLen(s.title, 80, ["title"], "Title"),
|
|
860
|
+
...maxCount(s.items, s.columns === 2 ? 10 : 6, ["items"], "items"),
|
|
861
|
+
...s.items.flatMap((i, n) => maxLen(i.text, 110, ["items", n, "text"], "Item"))
|
|
862
|
+
],
|
|
863
|
+
render: (s) => {
|
|
864
|
+
const half = Math.ceil(s.items.length / 2);
|
|
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);
|
|
866
|
+
return {
|
|
867
|
+
html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}${s.build ? `<div data-build>${list}</div>` : list}${footnote(s.footnote)}`
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
var cards = defineLayout({
|
|
872
|
+
name: "cards",
|
|
873
|
+
description: "Headline plus a grid of 2\u20134 columns of cards (tag, title, body, optional photo).",
|
|
874
|
+
whenToUse: "Three to eight peers of the same kind: services, options, people, phases.",
|
|
875
|
+
shape: {
|
|
876
|
+
eyebrow: Text.optional(),
|
|
877
|
+
eyebrowSub: Text.optional(),
|
|
878
|
+
title: Text,
|
|
879
|
+
lead: Text.optional(),
|
|
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({
|
|
884
|
+
tag: Text.optional(),
|
|
885
|
+
title: Text,
|
|
886
|
+
body: Text.optional(),
|
|
887
|
+
image: Image.optional()
|
|
888
|
+
})
|
|
889
|
+
).min(1),
|
|
890
|
+
footnote: Text.optional().describe("Closing line under the cards."),
|
|
891
|
+
build: z4.boolean().default(false).describe("Reveal one card per click while presenting.")
|
|
892
|
+
},
|
|
893
|
+
example: {
|
|
894
|
+
layout: "cards",
|
|
895
|
+
eyebrow: "Your team",
|
|
896
|
+
title: "Who you will work with",
|
|
897
|
+
cards: [
|
|
898
|
+
{
|
|
899
|
+
tag: "Sales",
|
|
900
|
+
title: "New Home Specialist",
|
|
901
|
+
body: "Your first call for contract questions."
|
|
902
|
+
},
|
|
903
|
+
{ tag: "Design", title: "Design Consultant", body: "Guides your selections at the studio." },
|
|
904
|
+
{ tag: "Build", title: "Superintendent", body: "Runs your site from foundation to finish." }
|
|
905
|
+
]
|
|
906
|
+
},
|
|
907
|
+
lint: (s) => [
|
|
908
|
+
...maxLen(s.title, 80, ["title"], "Title"),
|
|
909
|
+
...maxCount(s.cards, 8, ["cards"], "cards"),
|
|
910
|
+
...s.cards.flatMap((c, n) => [
|
|
911
|
+
...maxLen(c.body, 160, ["cards", n, "body"], "Card body"),
|
|
912
|
+
...missingAlt(c.image ? [c.image] : [], ["cards", n, "image"])
|
|
913
|
+
])
|
|
914
|
+
],
|
|
915
|
+
render: (s, ctx) => ({
|
|
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(
|
|
918
|
+
c.title
|
|
919
|
+
)}</h3>${c.body ? `<p>${inline(c.body)}</p>` : ""}</div>`}</div>`
|
|
920
|
+
).join("")}</div>${footnote(s.footnote)}`
|
|
921
|
+
})
|
|
922
|
+
});
|
|
923
|
+
var stats = defineLayout({
|
|
924
|
+
name: "stats",
|
|
925
|
+
description: "Headline plus a row of big numbers with short labels.",
|
|
926
|
+
whenToUse: "Two to six figures worth remembering: timelines, counts, percentages.",
|
|
927
|
+
shape: {
|
|
928
|
+
eyebrow: Text.optional(),
|
|
929
|
+
eyebrowSub: Text.optional(),
|
|
930
|
+
title: Text,
|
|
931
|
+
lead: Text.optional(),
|
|
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
|
+
})
|
|
941
|
+
).min(1)
|
|
942
|
+
},
|
|
943
|
+
example: {
|
|
944
|
+
layout: "stats",
|
|
945
|
+
eyebrow: "By the numbers",
|
|
946
|
+
title: "What the timeline looks like",
|
|
947
|
+
stats: [
|
|
948
|
+
{ value: "60", label: "days from contract to final selections" },
|
|
949
|
+
{ value: "14", label: "construction stages" },
|
|
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
|
+
}
|
|
958
|
+
]
|
|
959
|
+
},
|
|
960
|
+
lint: (s) => [
|
|
961
|
+
...maxLen(s.title, 80, ["title"], "Title"),
|
|
962
|
+
...maxCount(s.stats, 6, ["stats"], "stats")
|
|
963
|
+
],
|
|
964
|
+
render: (s) => ({
|
|
965
|
+
html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}<div class="stat-row">${s.stats.map(
|
|
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>`
|
|
967
|
+
).join("")}</div>`
|
|
968
|
+
})
|
|
969
|
+
});
|
|
970
|
+
var comparison = defineLayout({
|
|
971
|
+
name: "comparison",
|
|
972
|
+
description: "Two or three side-by-side columns, each with a coloured heading and a list.",
|
|
973
|
+
whenToUse: "Contrasting audiences, before/after, responsibilities split between parties, takeaways.",
|
|
974
|
+
shape: {
|
|
975
|
+
eyebrow: Text.optional(),
|
|
976
|
+
eyebrowSub: Text.optional(),
|
|
977
|
+
title: Text,
|
|
978
|
+
lead: Text.optional(),
|
|
979
|
+
columns: z4.array(
|
|
980
|
+
z4.object({
|
|
981
|
+
heading: Text,
|
|
982
|
+
tone: Track.default("value"),
|
|
983
|
+
text: Text.optional(),
|
|
984
|
+
items: z4.array(Item).optional(),
|
|
985
|
+
marker: Marker
|
|
986
|
+
})
|
|
987
|
+
).min(2).max(3)
|
|
988
|
+
},
|
|
989
|
+
example: {
|
|
990
|
+
layout: "comparison",
|
|
991
|
+
eyebrow: "Who does what",
|
|
992
|
+
title: "Warranty, split simply",
|
|
993
|
+
columns: [
|
|
994
|
+
{
|
|
995
|
+
heading: "Classic Homes covers",
|
|
996
|
+
tone: "value",
|
|
997
|
+
items: ["Workmanship, year one", "Structural, ten years"]
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
heading: "You look after",
|
|
1001
|
+
tone: "build",
|
|
1002
|
+
items: ["Seasonal maintenance", "Filters, caulk, grading"]
|
|
1003
|
+
}
|
|
1004
|
+
]
|
|
1005
|
+
},
|
|
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
|
+
},
|
|
367
1327
|
render: (s) => {
|
|
368
|
-
const
|
|
369
|
-
const
|
|
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("");
|
|
370
1332
|
return {
|
|
371
|
-
html: `${eyebrow(s.eyebrow, s.eyebrowSub)}${heading(s.title)}${lead(s.lead)}${
|
|
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>`
|
|
372
1347
|
};
|
|
373
1348
|
}
|
|
374
1349
|
});
|
|
375
|
-
var
|
|
376
|
-
name: "
|
|
377
|
-
description: "
|
|
378
|
-
whenToUse: "
|
|
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.",
|
|
379
1354
|
shape: {
|
|
380
1355
|
eyebrow: Text.optional(),
|
|
381
|
-
eyebrowSub: Text.optional(),
|
|
382
1356
|
title: Text,
|
|
383
1357
|
lead: Text.optional(),
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
cards: z2.array(
|
|
387
|
-
z2.object({
|
|
388
|
-
tag: Text.optional(),
|
|
389
|
-
title: Text,
|
|
390
|
-
body: Text.optional(),
|
|
391
|
-
image: Image.optional()
|
|
392
|
-
})
|
|
393
|
-
).min(1),
|
|
394
|
-
footnote: Text.optional().describe("Closing line under the cards.")
|
|
1358
|
+
steps: z6.array(Step).min(3).max(6),
|
|
1359
|
+
center: Text.optional().describe("Word in the middle of the circle.")
|
|
395
1360
|
},
|
|
396
1361
|
example: {
|
|
397
|
-
layout: "
|
|
398
|
-
eyebrow: "
|
|
399
|
-
title: "
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
{ tag: "Design", title: "Design Consultant", body: "Guides your selections at the studio." },
|
|
407
|
-
{ tag: "Build", title: "Superintendent", body: "Runs your site from foundation to finish." }
|
|
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" }
|
|
408
1371
|
]
|
|
409
1372
|
},
|
|
410
|
-
lint: (s) => [
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
+
}
|
|
425
1392
|
});
|
|
426
|
-
var
|
|
427
|
-
name: "
|
|
428
|
-
description: "
|
|
429
|
-
whenToUse: "
|
|
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.",
|
|
430
1397
|
shape: {
|
|
431
1398
|
eyebrow: Text.optional(),
|
|
432
|
-
eyebrowSub: Text.optional(),
|
|
433
1399
|
title: Text,
|
|
434
1400
|
lead: Text.optional(),
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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)
|
|
438
1410
|
},
|
|
439
1411
|
example: {
|
|
440
|
-
layout: "
|
|
441
|
-
eyebrow: "
|
|
442
|
-
title: "
|
|
443
|
-
|
|
444
|
-
{
|
|
445
|
-
{
|
|
446
|
-
{
|
|
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
|
+
}
|
|
447
1425
|
]
|
|
448
1426
|
},
|
|
449
|
-
lint: (s) =>
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
+
}
|
|
458
1471
|
});
|
|
459
|
-
var
|
|
460
|
-
name: "
|
|
461
|
-
description: "
|
|
462
|
-
whenToUse: "
|
|
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.",
|
|
463
1476
|
shape: {
|
|
464
1477
|
eyebrow: Text.optional(),
|
|
465
|
-
eyebrowSub: Text.optional(),
|
|
466
1478
|
title: Text,
|
|
467
1479
|
lead: Text.optional(),
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
text: Text.optional(),
|
|
473
|
-
items: z2.array(Item).optional(),
|
|
474
|
-
marker: Marker
|
|
475
|
-
})
|
|
476
|
-
).min(2).max(3)
|
|
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).")
|
|
477
1484
|
},
|
|
478
1485
|
example: {
|
|
479
|
-
layout: "
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
tone: "build",
|
|
491
|
-
items: ["Seasonal maintenance", "Filters, caulk, grading"]
|
|
492
|
-
}
|
|
493
|
-
]
|
|
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
|
|
494
1497
|
},
|
|
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
|
-
],
|
|
501
1498
|
render: (s) => ({
|
|
502
|
-
html: `${eyebrow(s.eyebrow
|
|
503
|
-
(
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
511
|
-
name: "
|
|
512
|
-
description: "A
|
|
513
|
-
whenToUse: "
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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: "
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
538
|
-
var Group =
|
|
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:
|
|
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:
|
|
554
|
-
images:
|
|
555
|
-
arrange:
|
|
556
|
-
mediaSide:
|
|
557
|
-
badge:
|
|
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:
|
|
614
|
-
columns:
|
|
615
|
-
aspect:
|
|
616
|
-
captions:
|
|
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:
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
)}
|
|
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:
|
|
676
|
-
start:
|
|
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:
|
|
680
|
-
total:
|
|
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:
|
|
684
|
-
|
|
1829
|
+
stages: z7.array(
|
|
1830
|
+
z7.object({
|
|
685
1831
|
image: Image,
|
|
686
|
-
badge:
|
|
687
|
-
number:
|
|
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:
|
|
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:
|
|
756
|
-
url:
|
|
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:
|
|
759
|
-
label:
|
|
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">${
|
|
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:
|
|
796
|
-
|
|
1942
|
+
contacts: z7.array(
|
|
1943
|
+
z7.object({
|
|
797
1944
|
name: Text,
|
|
798
1945
|
role: Text.optional(),
|
|
799
|
-
phone:
|
|
800
|
-
email:
|
|
801
|
-
url:
|
|
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:
|
|
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,109 +2021,338 @@ 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
|
+
}
|
|
883
2148
|
|
|
884
2149
|
// src/schema/deck.ts
|
|
885
|
-
import { z as
|
|
2150
|
+
import { z as z8 } from "zod";
|
|
886
2151
|
var slideSchemas = LAYOUTS.map(
|
|
887
|
-
(layout) =>
|
|
2152
|
+
(layout) => z8.object({ layout: z8.literal(layout.name), ...baseSlide, ...layout.shape }).strict().describe(`${layout.description} Use for: ${layout.whenToUse}`)
|
|
888
2153
|
);
|
|
889
|
-
var SlideSchema =
|
|
890
|
-
var
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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.")
|
|
899
2175
|
}).optional().describe(
|
|
900
2176
|
'Optional numbered journey (agenda). Enables the journey layout, step rails and "you are here" strips.'
|
|
901
2177
|
),
|
|
902
|
-
|
|
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)
|
|
903
2183
|
}).strict();
|
|
904
2184
|
|
|
905
|
-
// src/
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
2185
|
+
// src/editor.ts
|
|
2186
|
+
import { z as z9 } from "zod";
|
|
2187
|
+
var MULTILINE = /* @__PURE__ */ new Set(["lead", "body", "text", "quote", "footnote", "sub", "notes", "caption"]);
|
|
2188
|
+
var LABELS = {
|
|
2189
|
+
eyebrowSub: "Eyebrow subtitle",
|
|
2190
|
+
showJourney: "Show journey strip",
|
|
2191
|
+
stepDone: "Step completed",
|
|
2192
|
+
mediaSide: "Photo side",
|
|
2193
|
+
imageAspect: "Photo shape",
|
|
2194
|
+
numberFrom: "First badge number",
|
|
2195
|
+
url: "URL",
|
|
2196
|
+
id: "Slide id"
|
|
2197
|
+
};
|
|
2198
|
+
function labelFor(key) {
|
|
2199
|
+
if (LABELS[key]) return LABELS[key];
|
|
2200
|
+
const words = key.replace(/([A-Z])/g, " $1").toLowerCase();
|
|
2201
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
2202
|
+
}
|
|
2203
|
+
function unwrap(schema) {
|
|
2204
|
+
let s = schema;
|
|
2205
|
+
let required = true;
|
|
2206
|
+
let dflt;
|
|
2207
|
+
let description = schema.description;
|
|
2208
|
+
for (; ; ) {
|
|
2209
|
+
description ??= s.description;
|
|
2210
|
+
if (s instanceof z9.ZodOptional || s instanceof z9.ZodNullable) {
|
|
2211
|
+
required = false;
|
|
2212
|
+
s = s.unwrap();
|
|
2213
|
+
} else if (s instanceof z9.ZodDefault) {
|
|
2214
|
+
required = false;
|
|
2215
|
+
dflt = s._def.defaultValue();
|
|
2216
|
+
s = s._def.innerType;
|
|
2217
|
+
} else if (s instanceof z9.ZodEffects && s !== Image && s !== Item) {
|
|
2218
|
+
s = s.innerType();
|
|
2219
|
+
} else break;
|
|
2220
|
+
}
|
|
2221
|
+
return { inner: s, required, default: dflt, description };
|
|
2222
|
+
}
|
|
2223
|
+
function isText(s) {
|
|
2224
|
+
return s === Text || s instanceof z9.ZodString && s.description === Text.description;
|
|
2225
|
+
}
|
|
2226
|
+
function describeField(key, schema) {
|
|
2227
|
+
const { inner, required, default: dflt, description } = unwrap(schema);
|
|
2228
|
+
const base = {
|
|
2229
|
+
key,
|
|
2230
|
+
label: labelFor(key),
|
|
2231
|
+
required,
|
|
2232
|
+
...description ? { description } : {},
|
|
2233
|
+
...dflt !== void 0 ? { default: dflt } : {},
|
|
2234
|
+
...MULTILINE.has(key) ? { multiline: true } : {}
|
|
2235
|
+
};
|
|
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
|
+
}
|
|
2247
|
+
if (inner === Item) return { ...base, kind: "item" };
|
|
2248
|
+
if (isText(inner)) return { ...base, kind: "inline" };
|
|
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
|
+
};
|
|
944
2256
|
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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) {
|
|
2263
|
+
return {
|
|
2264
|
+
...base,
|
|
2265
|
+
kind: "number",
|
|
2266
|
+
integer: inner.isInt,
|
|
2267
|
+
...inner.minValue !== null ? { min: inner.minValue } : {},
|
|
2268
|
+
...inner.maxValue !== null ? { max: inner.maxValue } : {}
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
if (inner instanceof z9.ZodUnion) {
|
|
2272
|
+
const opts = inner.options;
|
|
2273
|
+
if (opts.every((o) => o instanceof z9.ZodLiteral)) {
|
|
2274
|
+
return {
|
|
2275
|
+
...base,
|
|
2276
|
+
kind: "enum",
|
|
2277
|
+
options: opts.map((o) => o.value)
|
|
2278
|
+
};
|
|
951
2279
|
}
|
|
952
|
-
|
|
953
|
-
if (deck.slides[0]?.layout !== "title") {
|
|
954
|
-
issues.push({
|
|
955
|
-
level: "warning",
|
|
956
|
-
path: ["slides", 0, "layout"],
|
|
957
|
-
message: 'Decks usually open with a "title" slide.'
|
|
958
|
-
});
|
|
2280
|
+
return { ...base, kind: "string" };
|
|
959
2281
|
}
|
|
960
|
-
|
|
2282
|
+
if (inner instanceof z9.ZodArray) {
|
|
2283
|
+
const el = inner.element;
|
|
2284
|
+
return {
|
|
2285
|
+
...base,
|
|
2286
|
+
kind: "list",
|
|
2287
|
+
...el instanceof z9.ZodArray ? { hint: "grid" } : {},
|
|
2288
|
+
of: { ...describeField("", el), label: labelFor(key.replace(/s$/, "")) },
|
|
2289
|
+
...inner._def.minLength ? { min: inner._def.minLength.value } : {},
|
|
2290
|
+
...inner._def.maxLength ? { max: inner._def.maxLength.value } : {}
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
if (inner instanceof z9.ZodObject) {
|
|
2294
|
+
const shape = inner.shape;
|
|
2295
|
+
const isPoint = shape.x instanceof z9.ZodNumber && shape.y instanceof z9.ZodNumber;
|
|
2296
|
+
return {
|
|
2297
|
+
...base,
|
|
2298
|
+
kind: "group",
|
|
2299
|
+
...isPoint ? { hint: "point" } : {},
|
|
2300
|
+
fields: Object.entries(inner.shape).map(
|
|
2301
|
+
([k, v]) => describeField(k, v)
|
|
2302
|
+
)
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
return { ...base, kind: "string" };
|
|
961
2306
|
}
|
|
962
|
-
function
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
+
};
|
|
967
2316
|
}
|
|
968
|
-
function
|
|
969
|
-
return
|
|
2317
|
+
function commonSlideFields() {
|
|
2318
|
+
return Object.entries(baseSlide).map(([k, v]) => describeField(k, v));
|
|
2319
|
+
}
|
|
2320
|
+
function layoutEditorInfo(name) {
|
|
2321
|
+
const layout = getLayout(name);
|
|
2322
|
+
if (!layout) return void 0;
|
|
2323
|
+
return {
|
|
2324
|
+
name: layout.name,
|
|
2325
|
+
category: LAYOUT_CATEGORIES[layout.name],
|
|
2326
|
+
description: layout.description,
|
|
2327
|
+
whenToUse: layout.whenToUse,
|
|
2328
|
+
defaults: layout.defaults ?? {},
|
|
2329
|
+
fields: Object.entries(layout.shape).map(
|
|
2330
|
+
([k, v]) => describeField(k, v)
|
|
2331
|
+
),
|
|
2332
|
+
example: layout.example
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
function allLayoutEditorInfo() {
|
|
2336
|
+
return LAYOUTS.map((l) => layoutEditorInfo(l.name));
|
|
2337
|
+
}
|
|
2338
|
+
var PLACEHOLDER_IMAGE = "data:image/svg+xml;base64," + btoa(
|
|
2339
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000"><rect width="1600" height="1000" fill="#ecebe6"/><rect x="24" y="24" width="1552" height="952" fill="none" stroke="#b9bcb4" stroke-width="6" stroke-dasharray="28 20"/><text x="800" y="520" text-anchor="middle" font-family="Figtree,Arial,sans-serif" font-size="64" fill="#7a7a7a">Add a photo</text></svg>'
|
|
2340
|
+
);
|
|
2341
|
+
function isPlaceholderImage(src) {
|
|
2342
|
+
return src === PLACEHOLDER_IMAGE;
|
|
2343
|
+
}
|
|
2344
|
+
function blankSlide(name) {
|
|
2345
|
+
const layout = getLayout(name);
|
|
2346
|
+
if (!layout) return void 0;
|
|
2347
|
+
const slide = JSON.parse(
|
|
2348
|
+
JSON.stringify(layout.example),
|
|
2349
|
+
(key, value) => key === "src" && typeof value === "string" && !/^(https?:|data:)/.test(value) ? PLACEHOLDER_IMAGE : value
|
|
2350
|
+
);
|
|
2351
|
+
return { ...slide, id: newSlideId() };
|
|
970
2352
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
${formatIssues(issues.filter((i) => i.level === "error"))}`);
|
|
975
|
-
this.issues = issues;
|
|
976
|
-
this.name = "DeckError";
|
|
977
|
-
}
|
|
978
|
-
issues;
|
|
979
|
-
};
|
|
2353
|
+
|
|
2354
|
+
// src/validate.ts
|
|
2355
|
+
import QRCode2 from "qrcode";
|
|
980
2356
|
|
|
981
2357
|
// src/render.ts
|
|
982
2358
|
import QRCode from "qrcode";
|
|
@@ -984,6 +2360,55 @@ import QRCode from "qrcode";
|
|
|
984
2360
|
// src/runtime.ts
|
|
985
2361
|
var RUNTIME_JS = String.raw`
|
|
986
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;
|
|
987
2412
|
const NOTES = JSON.parse(document.getElementById('deck-notes').textContent || '[]');
|
|
988
2413
|
const deck = document.getElementById('deck');
|
|
989
2414
|
const slides = [...deck.querySelectorAll('.slide')];
|
|
@@ -993,6 +2418,8 @@ var RUNTIME_JS = String.raw`
|
|
|
993
2418
|
const notesTime = document.getElementById('notesTime');
|
|
994
2419
|
const rail = document.getElementById('rail');
|
|
995
2420
|
const counter = document.getElementById('counter');
|
|
2421
|
+
const body = document.body;
|
|
2422
|
+
const embedded = window.parent !== window;
|
|
996
2423
|
|
|
997
2424
|
slides.forEach((s, i) => {
|
|
998
2425
|
const b = document.createElement('button');
|
|
@@ -1002,23 +2429,72 @@ var RUNTIME_JS = String.raw`
|
|
|
1002
2429
|
});
|
|
1003
2430
|
const dots = [...dotsWrap.children];
|
|
1004
2431
|
|
|
1005
|
-
|
|
1006
|
-
|
|
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;
|
|
1007
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]);
|
|
1008
2481
|
dots.forEach((d, j) => d.setAttribute('aria-current', j === i ? 'true' : 'false'));
|
|
1009
|
-
const
|
|
1010
|
-
const track = s.getAttribute('data-track');
|
|
2482
|
+
const track = slides[i].getAttribute('data-track');
|
|
1011
2483
|
rail.style.background = track === 'build' ? 'var(--teal)' : track === 'neutral' ? 'var(--navy)' : 'var(--red)';
|
|
1012
2484
|
counter.textContent = (i + 1) + ' / ' + slides.length;
|
|
1013
|
-
if (notes
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
}
|
|
1018
|
-
|
|
2485
|
+
if (notes?.classList.contains('open')) fillNotes();
|
|
2486
|
+
try {
|
|
2487
|
+
if (location.hash !== '#slide-' + (i + 1)) history.replaceState(null, '', '#slide-' + (i + 1));
|
|
2488
|
+
} catch (_) {}
|
|
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 || '';
|
|
1019
2496
|
}
|
|
1020
2497
|
|
|
1021
|
-
// Current slide comes from the scroll position, the single source of truth.
|
|
1022
2498
|
const clamp = (n) => Math.max(0, Math.min(slides.length - 1, n));
|
|
1023
2499
|
const currentIndex = () => clamp(Math.round(deck.scrollTop / deck.clientHeight));
|
|
1024
2500
|
function updateRail() {
|
|
@@ -1027,39 +2503,232 @@ var RUNTIME_JS = String.raw`
|
|
|
1027
2503
|
}
|
|
1028
2504
|
|
|
1029
2505
|
let ticking = false;
|
|
2506
|
+
let jumping = false;
|
|
1030
2507
|
deck.addEventListener('scroll', () => {
|
|
1031
2508
|
if (ticking) return;
|
|
1032
2509
|
ticking = true;
|
|
1033
2510
|
requestAnimationFrame(() => {
|
|
1034
2511
|
ticking = false;
|
|
1035
2512
|
updateRail();
|
|
1036
|
-
|
|
1037
|
-
if (i !== idx) setActive(i);
|
|
2513
|
+
if (!jumping) setActive(currentIndex());
|
|
1038
2514
|
});
|
|
1039
2515
|
}, { passive: true });
|
|
1040
2516
|
|
|
1041
|
-
|
|
2517
|
+
// Scroll the deck element itself; scrolling the slide into view would also move an embedding page.
|
|
2518
|
+
function go(n, instant, fromKeys) {
|
|
2519
|
+
const t = clamp(n);
|
|
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
|
+
});
|
|
1042
2533
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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);
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
window.addEventListener('message', (e) => {
|
|
2552
|
+
const d = e.data;
|
|
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;
|
|
2568
|
+
if (d.type === 'classic-slides:go' && Number.isInteger(d.index)) go(d.index, true);
|
|
2569
|
+
else if (d.type === 'classic-slides:notes') { if (notes && notes.classList.contains('open') !== !!d.open) toggleNotes(); }
|
|
1051
2570
|
});
|
|
1052
2571
|
|
|
1053
|
-
function
|
|
1054
|
-
|
|
2572
|
+
function onKey(e) {
|
|
2573
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
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);
|
|
2597
|
+
|
|
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);
|
|
1055
2601
|
function toggleFs() {
|
|
1056
2602
|
if (!document.fullscreenElement) document.documentElement.requestFullscreen?.();
|
|
1057
2603
|
else document.exitFullscreen?.();
|
|
1058
2604
|
}
|
|
1059
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 ----------
|
|
1060
2721
|
const m = /^#slide-(\d+)$/.exec(location.hash);
|
|
1061
|
-
|
|
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);
|
|
1062
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();
|
|
1063
2732
|
})();
|
|
1064
2733
|
`;
|
|
1065
2734
|
|
|
@@ -1067,13 +2736,14 @@ var RUNTIME_JS = String.raw`
|
|
|
1067
2736
|
var DECK_CSS = String.raw`
|
|
1068
2737
|
*{box-sizing:border-box}
|
|
1069
2738
|
html{scroll-behavior:smooth}
|
|
1070
|
-
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
|
|
2739
|
+
@media (prefers-reduced-motion:reduce){html,.deck{scroll-behavior:auto}}
|
|
1071
2740
|
body{margin:0;font-family:var(--font-body);color:var(--ink);background:var(--paper);-webkit-font-smoothing:antialiased}
|
|
1072
2741
|
a{color:var(--teal)}
|
|
1073
2742
|
img{display:block;max-width:100%}
|
|
1074
2743
|
|
|
1075
2744
|
/* ---------- deck + slide frame ---------- */
|
|
1076
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}}
|
|
1077
2747
|
.slide{height:100vh;scroll-snap-align:start;scroll-snap-stop:always;position:relative;display:flex;flex-direction:column;justify-content:center;
|
|
1078
2748
|
padding:clamp(24px,5.5vh,76px) clamp(28px,8vw,116px);overflow:hidden;
|
|
1079
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)}
|
|
@@ -1372,8 +3042,250 @@ img.natural,.media img.natural,.photo-grid img.natural{width:auto;max-width:min(
|
|
|
1372
3042
|
.rail,.hud,.dots,.notes,.hint{display:none!important}
|
|
1373
3043
|
}
|
|
1374
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
|
+
`;
|
|
1375
3286
|
|
|
1376
3287
|
// src/render.ts
|
|
3288
|
+
var PREVIEW_SIZE = { width: 1280, height: 720 };
|
|
1377
3289
|
var passThrough = (src) => {
|
|
1378
3290
|
if (/^(data:|https?:)/.test(src)) return src;
|
|
1379
3291
|
throw new Error(
|
|
@@ -1391,46 +3303,163 @@ function collectImageSources(value, out = /* @__PURE__ */ new Set()) {
|
|
|
1391
3303
|
}
|
|
1392
3304
|
var CHECK_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 L10 17.5 L19 7"/></svg>';
|
|
1393
3305
|
async function renderDeck(input, options = {}) {
|
|
1394
|
-
const result = validateDeck(input);
|
|
3306
|
+
const result = validateDeck(input, { brands: options.brands });
|
|
1395
3307
|
if (!result.ok || !result.deck) throw new DeckError(result.issues);
|
|
1396
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
|
+
}
|
|
1397
3320
|
const resolve = options.resolveImage ?? passThrough;
|
|
1398
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;
|
|
1399
3330
|
await Promise.all(
|
|
1400
|
-
|
|
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
|
+
})
|
|
1401
3346
|
);
|
|
3347
|
+
if (imageFailed) throw imageError;
|
|
1402
3348
|
const qrs = /* @__PURE__ */ new Map();
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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
|
+
);
|
|
1417
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.");
|
|
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);
|
|
1418
3427
|
const ctx = {
|
|
1419
3428
|
deck,
|
|
1420
|
-
image:
|
|
1421
|
-
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
|
|
1422
3434
|
};
|
|
1423
3435
|
const sections = [];
|
|
1424
3436
|
const notes = [];
|
|
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";
|
|
1425
3440
|
deck.slides.forEach((slide, i) => {
|
|
3441
|
+
if (slide.hidden && !options.includeHidden) {
|
|
3442
|
+
slideMap.push([]);
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
imagePath = ["slides", i];
|
|
1426
3446
|
const layout = getLayout(slide.layout);
|
|
1427
3447
|
const rendered = layout.render(slide, ctx);
|
|
1428
3448
|
const list = Array.isArray(rendered) ? rendered : [rendered];
|
|
1429
3449
|
const track = slide.track ?? layout.defaults?.track ?? "value";
|
|
1430
3450
|
const bg = slide.background ?? layout.defaults?.background ?? "paper";
|
|
3451
|
+
slideMap.push(list.map((_, j) => sections.length + j));
|
|
1431
3452
|
list.forEach((r, j) => {
|
|
1432
3453
|
const classes = ["slide", `layout-${layout.name}`, ...r.classes ?? []];
|
|
1433
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("${escapeAttr(ctx.image(slide.backgroundImage.src))}")"`;
|
|
3461
|
+
}
|
|
3462
|
+
if (slide.hidden) classes.push("is-hidden");
|
|
1434
3463
|
let chrome = "";
|
|
1435
3464
|
if (slide.step && deck.journey) {
|
|
1436
3465
|
classes.push("has-step");
|
|
@@ -1438,14 +3467,17 @@ async function renderDeck(input, options = {}) {
|
|
|
1438
3467
|
}
|
|
1439
3468
|
const strip = slide.showJourney && deck.journey ? roadmap(deck.journey.steps, slide.step, true) : "";
|
|
1440
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>` : "";
|
|
1441
3472
|
sections.push(
|
|
1442
|
-
`<section class="${classes.join(" ")}" data-track="${escapeAttr(track)}" data-index="${i}"${id ? ` id="${escapeAttr(id)}"` : ""} aria-label="Slide ${
|
|
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>`
|
|
1443
3474
|
);
|
|
1444
3475
|
notes.push({ html: prose(r.notes ?? slide.notes), time: r.time ?? slide.time });
|
|
1445
3476
|
});
|
|
1446
3477
|
});
|
|
3478
|
+
const withNotes = options.speakerNotes !== false;
|
|
1447
3479
|
const json = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
|
|
1448
|
-
const stripData = (
|
|
3480
|
+
const stripData = (key, v) => !withNotes && key === "notes" ? void 0 : typeof v === "string" && /^data:/i.test(v) ? "(embedded)" : v;
|
|
1449
3481
|
const source = options.embedSource === false ? "" : `<script type="application/json" id="deck-source">${JSON.stringify(deck, stripData).replace(/</g, "\\u003c")}</script>
|
|
1450
3482
|
`;
|
|
1451
3483
|
const html = `<!doctype html>
|
|
@@ -1453,33 +3485,191 @@ async function renderDeck(input, options = {}) {
|
|
|
1453
3485
|
<head>
|
|
1454
3486
|
<meta charset="utf-8">
|
|
1455
3487
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1456
|
-
<title>${escapeHtml(deck.title)} ·
|
|
3488
|
+
<title>${escapeHtml(deck.title)} · ${escapeHtml(brand.name)}</title>
|
|
3489
|
+
<link rel="icon" href="${escapeAttr(icon)}">
|
|
1457
3490
|
${deck.description ? `<meta name="description" content="${escapeAttr(deck.description)}">
|
|
1458
3491
|
` : ""}<meta name="generator" content="@classic-homes/slides">
|
|
1459
3492
|
<style id="brand-fonts">${fontsCss()}</style>
|
|
1460
|
-
<style>${tokensCss()}${DECK_CSS}</style>
|
|
3493
|
+
<style>${tokensCss(brand.stock ? void 0 : brand.palette)}${DECK_CSS}${DECK_CSS_EXTRA}</style>
|
|
1461
3494
|
</head>
|
|
1462
|
-
<body>
|
|
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" : ""}>
|
|
1463
3496
|
<div class="rail" id="rail"></div>
|
|
1464
3497
|
<main class="deck" id="deck">
|
|
1465
3498
|
${sections.join("\n")}
|
|
1466
3499
|
</main>
|
|
1467
3500
|
<nav class="dots" id="dots" aria-label="Slide navigation"></nav>
|
|
1468
|
-
|
|
1469
|
-
<div class="hint"><kbd>↑</kbd><kbd>↓</kbd> or <kbd>Space</kbd> navigate · <kbd>N</kbd> notes · <kbd>F</kbd> fullscreen</div>
|
|
1470
|
-
<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>
|
|
1471
|
-
<
|
|
3501
|
+
${withNotes ? `<div class="hud"><span class="counter" id="counter" aria-live="polite"></span><button class="btn" id="notesBtn" aria-label="Toggle speaker notes">▤ notes (N)</button></div>
|
|
3502
|
+
<div class="hint"><kbd>↑</kbd><kbd>↓</kbd> or <kbd>Space</kbd> navigate · <kbd>N</kbd> notes · <kbd>P</kbd> presenter · <kbd>O</kbd> overview · <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>↑</kbd><kbd>↓</kbd> or <kbd>Space</kbd> navigate · <kbd>O</kbd> overview · <kbd>F</kbd> fullscreen</div>`}
|
|
3505
|
+
<script type="application/json" id="deck-notes">${json(withNotes ? notes : [])}</script>
|
|
1472
3506
|
${source}<script>${RUNTIME_JS}</script>
|
|
1473
3507
|
</body>
|
|
1474
3508
|
</html>
|
|
1475
3509
|
`;
|
|
1476
|
-
return { html, deck, slideCount: sections.length, 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)}`;
|
|
1477
3556
|
}
|
|
1478
3557
|
function extractDeckSource(html) {
|
|
1479
3558
|
const m = /<script type="application\/json" id="deck-source">([\s\S]*?)<\/script>/.exec(html);
|
|
1480
3559
|
return m ? JSON.parse(m[1]) : void 0;
|
|
1481
3560
|
}
|
|
1482
3561
|
|
|
3562
|
+
// src/validate.ts
|
|
3563
|
+
function fromZod(issue) {
|
|
3564
|
+
let message = issue.message;
|
|
3565
|
+
if (issue.code === "invalid_union_discriminator") {
|
|
3566
|
+
message = `Unknown layout. Use one of: ${issue.options.join(", ")}.`;
|
|
3567
|
+
} else if (issue.code === "unrecognized_keys") {
|
|
3568
|
+
message = `Unknown field(s) ${issue.keys.map((k) => `"${k}"`).join(", ")} for this layout.`;
|
|
3569
|
+
}
|
|
3570
|
+
return { level: "error", path: issue.path, message };
|
|
3571
|
+
}
|
|
3572
|
+
function validateDeck(input, options = {}) {
|
|
3573
|
+
const parsed = DeckSchema.safeParse(input);
|
|
3574
|
+
if (!parsed.success) {
|
|
3575
|
+
return { ok: false, issues: parsed.error.issues.map(fromZod) };
|
|
3576
|
+
}
|
|
3577
|
+
const deck = parsed.data;
|
|
3578
|
+
const issues = [];
|
|
3579
|
+
const steps = deck.journey?.steps.length ?? 0;
|
|
3580
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3581
|
+
deck.slides.forEach((slide, i) => {
|
|
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
|
+
}
|
|
3595
|
+
for (const issue of layout.lint?.(slide) ?? []) {
|
|
3596
|
+
issues.push({ ...issue, path: ["slides", i, ...issue.path] });
|
|
3597
|
+
}
|
|
3598
|
+
if (collectImageSources(slide).has(PLACEHOLDER_IMAGE)) {
|
|
3599
|
+
issues.push({
|
|
3600
|
+
level: "warning",
|
|
3601
|
+
path: ["slides", i],
|
|
3602
|
+
message: "Replace the placeholder photo before presenting."
|
|
3603
|
+
});
|
|
3604
|
+
}
|
|
3605
|
+
if (slide.id) {
|
|
3606
|
+
if (seen.has(slide.id))
|
|
3607
|
+
issues.push({
|
|
3608
|
+
level: "error",
|
|
3609
|
+
path: ["slides", i, "id"],
|
|
3610
|
+
message: `Duplicate slide id "${slide.id}".`
|
|
3611
|
+
});
|
|
3612
|
+
seen.add(slide.id);
|
|
3613
|
+
}
|
|
3614
|
+
if ((slide.step || slide.showJourney || slide.layout === "journey") && !steps) {
|
|
3615
|
+
issues.push({
|
|
3616
|
+
level: "error",
|
|
3617
|
+
path: ["slides", i, slide.step ? "step" : "layout"],
|
|
3618
|
+
message: "This slide uses the journey, but the deck has no journey.steps defined."
|
|
3619
|
+
});
|
|
3620
|
+
}
|
|
3621
|
+
if (slide.step && steps && slide.step > steps) {
|
|
3622
|
+
issues.push({
|
|
3623
|
+
level: "error",
|
|
3624
|
+
path: ["slides", i, "step"],
|
|
3625
|
+
message: `Step ${slide.step} is past the last journey step (${steps}).`
|
|
3626
|
+
});
|
|
3627
|
+
}
|
|
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
|
+
}
|
|
3645
|
+
if (deck.slides[0]?.layout !== "title") {
|
|
3646
|
+
issues.push({
|
|
3647
|
+
level: "warning",
|
|
3648
|
+
path: ["slides", 0, "layout"],
|
|
3649
|
+
message: 'Decks usually open with a "title" slide.'
|
|
3650
|
+
});
|
|
3651
|
+
}
|
|
3652
|
+
return { ok: !issues.some((i) => i.level === "error"), deck, issues };
|
|
3653
|
+
}
|
|
3654
|
+
function formatPath(path) {
|
|
3655
|
+
if (!path.length) return "deck";
|
|
3656
|
+
return path.map(
|
|
3657
|
+
(p, i) => typeof p === "number" ? path[i - 1] === "slides" ? `slide ${p + 1}` : String(p + 1) : p
|
|
3658
|
+
).filter((p) => p !== "slides").join(" \u203A ");
|
|
3659
|
+
}
|
|
3660
|
+
function formatIssues(issues) {
|
|
3661
|
+
return issues.map((i) => `${i.level === "error" ? "\u2716" : "\u26A0"} ${formatPath(i.path)}: ${i.message}`).join("\n");
|
|
3662
|
+
}
|
|
3663
|
+
var DeckError = class extends Error {
|
|
3664
|
+
constructor(issues) {
|
|
3665
|
+
super(`Deck is not valid:
|
|
3666
|
+
${formatIssues(issues.filter((i) => i.level === "error"))}`);
|
|
3667
|
+
this.issues = issues;
|
|
3668
|
+
this.name = "DeckError";
|
|
3669
|
+
}
|
|
3670
|
+
issues;
|
|
3671
|
+
};
|
|
3672
|
+
|
|
1483
3673
|
// src/parse.ts
|
|
1484
3674
|
import YAML from "yaml";
|
|
1485
3675
|
function parseDeckSource(text) {
|
|
@@ -1494,27 +3684,61 @@ function stringifyDeck(deck) {
|
|
|
1494
3684
|
}
|
|
1495
3685
|
|
|
1496
3686
|
export {
|
|
1497
|
-
escapeHtml,
|
|
1498
|
-
inline,
|
|
1499
|
-
prose,
|
|
1500
|
-
Text,
|
|
1501
|
-
Image,
|
|
1502
|
-
Item,
|
|
1503
3687
|
baseSlide,
|
|
3688
|
+
BrandPaletteSchema,
|
|
3689
|
+
BrandProfileSchema,
|
|
3690
|
+
DeckBrandSchema,
|
|
3691
|
+
CLASSIC_HOMES_PALETTE,
|
|
3692
|
+
CLASSIC_HOMES_BRAND,
|
|
3693
|
+
resolveBrand,
|
|
3694
|
+
checkPalette,
|
|
3695
|
+
validateBrandProfile,
|
|
1504
3696
|
PALETTE,
|
|
1505
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,
|
|
1506
3710
|
LAYOUTS,
|
|
1507
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,
|
|
1508
3723
|
SlideSchema,
|
|
3724
|
+
DeckSettingsSchema,
|
|
1509
3725
|
DeckSchema,
|
|
3726
|
+
deckEditorFields,
|
|
3727
|
+
commonSlideFields,
|
|
3728
|
+
layoutEditorInfo,
|
|
3729
|
+
allLayoutEditorInfo,
|
|
3730
|
+
PLACEHOLDER_IMAGE,
|
|
3731
|
+
isPlaceholderImage,
|
|
3732
|
+
blankSlide,
|
|
1510
3733
|
validateDeck,
|
|
1511
3734
|
formatPath,
|
|
1512
3735
|
formatIssues,
|
|
1513
3736
|
DeckError,
|
|
3737
|
+
PREVIEW_SIZE,
|
|
1514
3738
|
collectImageSources,
|
|
1515
3739
|
renderDeck,
|
|
1516
3740
|
extractDeckSource,
|
|
1517
3741
|
parseDeckSource,
|
|
1518
3742
|
stringifyDeck
|
|
1519
3743
|
};
|
|
1520
|
-
//# sourceMappingURL=chunk-
|
|
3744
|
+
//# sourceMappingURL=chunk-GXGDPBAR.js.map
|