@broadpaper/blocks 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1799 @@
1
+ // src/index.ts
2
+ import { BlockRegistry } from "@broadpaper/core";
3
+
4
+ // src/blocks/structure.ts
5
+ import { defineBlock, cssText } from "@broadpaper/core";
6
+ var sectionBlock = defineBlock({
7
+ type: "section",
8
+ name: "Section",
9
+ category: "structure",
10
+ icon: "rectangle-horizontal",
11
+ hidden: true,
12
+ defaultProps: { padding: 0 },
13
+ container: { accepts: ["@rows", "@blocks"] },
14
+ split: "children",
15
+ styleGroups: ["spacing", "background", "border", "flow"],
16
+ inspector: [
17
+ {
18
+ title: "Section",
19
+ fields: [
20
+ { kind: "text", prop: "label", label: "Label", placeholder: "e.g. Executive summary", allowTokens: false },
21
+ { kind: "number", prop: "padding", label: "Padding", min: 0, max: 120, unit: "px" }
22
+ ]
23
+ }
24
+ ],
25
+ canvas: { label: (p) => p.label ?? "Section" },
26
+ render: ({ h: h3, css, children, styles, props, mode, node }) => {
27
+ const pad = Number(props.padding) || 0;
28
+ const empty = children.length === 0;
29
+ return h3(
30
+ "div",
31
+ { class: ["bp-section__inner", empty && "bp-section__inner--empty"], style: cssText({ ...css, padding: pad ? `${pad}px` : css["padding"] }) },
32
+ h3("div", { class: "bp-stack", style: { gap: `${styles.spacing.blockGap}px` }, "data-dropzone": node.nodeId }, empty && mode === "design" ? h3("div", { class: "bp-empty-section" }, h3("span", { class: "bp-empty-section__title" }, "Empty section"), h3("span", { class: "bp-empty-section__hint" }, "Drag blocks here, or pick one from the panel")) : children)
33
+ );
34
+ }
35
+ });
36
+ var rowBlock = defineBlock({
37
+ type: "row",
38
+ name: "Columns",
39
+ description: "A row of columns",
40
+ category: "layout",
41
+ icon: "columns-3",
42
+ keywords: ["row", "columns", "grid", "layout"],
43
+ defaultProps: { layout: [6, 6], allowSplit: false, verticalAlign: "top" },
44
+ container: { accepts: ["@columns"], direction: "row" },
45
+ split: "children",
46
+ styleGroups: ["spacing", "background", "border", "flow"],
47
+ inspector: [
48
+ {
49
+ title: "Columns",
50
+ fields: [
51
+ {
52
+ kind: "segmented",
53
+ prop: "verticalAlign",
54
+ label: "Vertical align",
55
+ options: [
56
+ { value: "top", label: "Top", icon: "align-start-vertical" },
57
+ { value: "middle", label: "Middle", icon: "align-center-vertical" },
58
+ { value: "bottom", label: "Bottom", icon: "align-end-vertical" },
59
+ { value: "stretch", label: "Stretch", icon: "stretch-vertical" }
60
+ ]
61
+ },
62
+ { kind: "toggle", prop: "allowSplit", label: "Allow splitting across pages", description: "When off, a row that does not fit moves to the next page whole." }
63
+ ]
64
+ }
65
+ ],
66
+ canvas: { label: (p) => `Columns ${p.layout.join(" / ")}` },
67
+ render: ({ h: h3, css, children, styles, props }) => {
68
+ const align = props.verticalAlign === "middle" ? "center" : props.verticalAlign === "bottom" ? "end" : props.verticalAlign === "stretch" ? "stretch" : "start";
69
+ return h3("div", { class: "bp-row__grid", style: cssText({ ...css, display: "grid", "grid-template-columns": "repeat(12, minmax(0, 1fr))", "column-gap": `${styles.spacing.columnGap}px`, "align-items": align }) }, children);
70
+ }
71
+ });
72
+ var columnBlock = defineBlock({
73
+ type: "column",
74
+ name: "Column",
75
+ category: "structure",
76
+ icon: "rectangle-vertical",
77
+ hidden: true,
78
+ defaultProps: { span: 6 },
79
+ container: { accepts: ["@blocks", "@rows"] },
80
+ split: "children",
81
+ styleGroups: ["spacing", "background", "border"],
82
+ render: ({ h: h3, css, children, node, styles, mode }) => {
83
+ const empty = children.length === 0;
84
+ return h3(
85
+ "div",
86
+ { class: ["bp-column__inner", empty && "bp-column__inner--empty"], style: cssText({ ...css, "min-width": "0", height: "100%" }) },
87
+ h3("div", { class: "bp-stack", style: { gap: `${styles.spacing.blockGap}px` }, "data-dropzone": node.nodeId }, empty && mode === "design" ? h3("div", { class: "bp-empty-column" }, "Drop here") : children)
88
+ );
89
+ }
90
+ });
91
+ var repeaterItemBlock = defineBlock({
92
+ type: "repeater-item",
93
+ name: "Repeater item",
94
+ category: "structure",
95
+ hidden: true,
96
+ defaultProps: { index: 0, count: 0 },
97
+ container: { accepts: ["@blocks", "@rows"] },
98
+ split: "children",
99
+ render: ({ h: h3, children, styles }) => h3("div", { class: "bp-stack bp-repeater-item__inner", style: { gap: `${styles.spacing.blockGap}px` } }, children)
100
+ });
101
+ function stack(h3, children, gap, extra = {}) {
102
+ return h3("div", { class: "bp-stack", style: { gap: `${gap}px` }, ...extra }, children);
103
+ }
104
+
105
+ // src/blocks/content.ts
106
+ import { defineBlock as defineBlock2, cssText as cssText2, createRichText, isSafeUrl, resolveValue, isDarkColor, formatDate } from "@broadpaper/core";
107
+
108
+ // src/shared/marks.ts
109
+ import { h } from "@broadpaper/core";
110
+ function svg(size, children) {
111
+ return h(
112
+ "svg",
113
+ { width: size, height: size, viewBox: "0 0 12 12", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", style: { display: "block", "flex-shrink": "0" } },
114
+ children
115
+ );
116
+ }
117
+ function triangleMark(direction, size, color) {
118
+ const d = direction === "up" ? "M6 2 L11 9.5 L1 9.5 Z" : "M6 10 L1 2.5 L11 2.5 Z";
119
+ return svg(size, [h("path", { d, fill: color })]);
120
+ }
121
+ function checkMark(size, color) {
122
+ return svg(size, [h("path", { d: "M2 6.4 L4.8 9.2 L10 3.2", fill: "none", stroke: color, "stroke-width": 1.8, "stroke-linecap": "round", "stroke-linejoin": "round" })]);
123
+ }
124
+ function dotMark(size, color) {
125
+ return svg(size, [h("circle", { cx: 6, cy: 6, r: 2.6, fill: color })]);
126
+ }
127
+
128
+ // src/blocks/content.ts
129
+ var alignField = {
130
+ kind: "segmented",
131
+ prop: "align",
132
+ label: "Align",
133
+ options: [
134
+ { value: "left", label: "Left", icon: "align-left" },
135
+ { value: "center", label: "Centre", icon: "align-center" },
136
+ { value: "right", label: "Right", icon: "align-right" }
137
+ ]
138
+ };
139
+ var headingBlock = defineBlock2({
140
+ type: "heading",
141
+ name: "Heading",
142
+ description: "Section title using a document heading style",
143
+ category: "content",
144
+ icon: "heading",
145
+ keywords: ["title", "h1", "h2", "h3"],
146
+ defaultProps: { text: "Heading", level: "h2" },
147
+ defaultTextStyle: "h2",
148
+ defaultFlow: { keepWithNext: true },
149
+ split: "none",
150
+ styleGroups: ["textStyle", "typography", "alignment", "spacing", "flow"],
151
+ inspector: [
152
+ {
153
+ title: "Heading",
154
+ fields: [
155
+ { kind: "text", prop: "text", label: "Text", placeholder: "Heading text or {{ token }}" },
156
+ {
157
+ kind: "segmented",
158
+ prop: "level",
159
+ label: "Level",
160
+ options: [
161
+ { value: "h1", label: "H1" },
162
+ { value: "h2", label: "H2" },
163
+ { value: "h3", label: "H3" },
164
+ { value: "h4", label: "H4" }
165
+ ]
166
+ }
167
+ ]
168
+ }
169
+ ],
170
+ canvas: { inlineEdit: { prop: "text", kind: "text" }, label: (p) => p.text?.slice(0, 40) || "Heading" },
171
+ render: ({ h: h3, css, text, props, partAttrs, mode }) => {
172
+ const content = text("text");
173
+ const empty = content.length === 0 || content.length === 1 && content[0] === "";
174
+ return h3(props.level || "h2", { class: ["bp-heading", `bp-heading--${props.level}`], style: cssText2(css), ...partAttrs("text") }, empty && mode === "design" ? h3("span", { class: "bp-placeholder-text" }, "Heading") : content);
175
+ }
176
+ });
177
+ var textBlock = defineBlock2({
178
+ type: "text",
179
+ name: "Text",
180
+ description: "Rich text with inline data tokens",
181
+ category: "content",
182
+ icon: "text",
183
+ keywords: ["paragraph", "rich text", "body", "copy"],
184
+ defaultProps: () => ({ content: createRichText("") }),
185
+ defaultTextStyle: "body",
186
+ split: "lines",
187
+ measure: "text",
188
+ styleGroups: ["textStyle", "typography", "alignment", "spacing", "background", "border", "flow"],
189
+ inspector: [{ title: "Text", fields: [{ kind: "richtext", prop: "content", label: "Content" }] }],
190
+ canvas: { inlineEdit: { prop: "content", kind: "richtext" }, label: () => "Text" },
191
+ render: ({ h: h3, css, richText, partAttrs, mode, node }) => {
192
+ const doc = node.richText.content ?? node.props.content;
193
+ const isEmpty = !doc || doc.content.every((b) => b.type === "paragraph" && b.content.length === 0);
194
+ return h3("div", { class: ["bp-text", isEmpty && "bp-text--empty"], style: cssText2(css), ...partAttrs("text") }, isEmpty && mode === "design" ? h3("p", { class: "bp-p bp-placeholder-text" }, "Type or drop a field here\u2026") : richText("content"));
195
+ }
196
+ });
197
+ var imageBlock = defineBlock2({
198
+ type: "image",
199
+ name: "Image",
200
+ description: "Static or data-bound image",
201
+ category: "content",
202
+ icon: "image",
203
+ keywords: ["picture", "photo", "graphic"],
204
+ defaultProps: { src: "", alt: "", fit: "contain", width: null, height: null, align: "left", radius: 0, link: "" },
205
+ split: "none",
206
+ styleGroups: ["spacing", "border", "flow"],
207
+ inspector: [
208
+ {
209
+ title: "Image",
210
+ fields: [
211
+ { kind: "image", prop: "src", label: "Source", allowBinding: true },
212
+ { kind: "text", prop: "alt", label: "Alt text", allowTokens: true },
213
+ { kind: "text", prop: "link", label: "Link URL", allowTokens: true }
214
+ ]
215
+ },
216
+ {
217
+ title: "Size",
218
+ fields: [
219
+ { kind: "number", prop: "width", label: "Width", min: 8, max: 2e3, unit: "px" },
220
+ { kind: "number", prop: "height", label: "Height", min: 8, max: 2e3, unit: "px" },
221
+ {
222
+ kind: "segmented",
223
+ prop: "fit",
224
+ label: "Fit",
225
+ options: [
226
+ { value: "contain", label: "Contain" },
227
+ { value: "cover", label: "Cover" }
228
+ ]
229
+ },
230
+ alignField,
231
+ { kind: "number", prop: "radius", label: "Corner radius", min: 0, max: 200, unit: "px" }
232
+ ]
233
+ }
234
+ ],
235
+ validate: ({ props }) => {
236
+ const src = String(props.src ?? "");
237
+ if (src && !src.includes("{{") && !isSafeUrl(src, { allowData: true })) return [{ severity: "warning", code: "invalid-url", message: `Image URL "${src.slice(0, 60)}" is not an http(s) or data:image URL`, prop: "src" }];
238
+ return [];
239
+ },
240
+ render: ({ h: h3, css, props, mode, node, width: avail }) => {
241
+ const src = typeof props.src === "string" ? props.src : "";
242
+ const safe = src && isSafeUrl(src, { allowData: true });
243
+ const w = props.width ?? void 0;
244
+ const hgt = props.height ?? void 0;
245
+ const justify = props.align === "center" ? "center" : props.align === "right" ? "flex-end" : "flex-start";
246
+ const imgStyle = {
247
+ width: w ? `${Math.min(w, avail)}px` : hgt ? "auto" : "100%",
248
+ height: hgt ? `${hgt}px` : "auto",
249
+ "max-width": "100%",
250
+ "object-fit": props.fit,
251
+ "border-radius": props.radius ? `${props.radius}px` : void 0,
252
+ display: "block"
253
+ };
254
+ let img;
255
+ if (safe) img = h3("img", { src, alt: props.alt || "", style: imgStyle, draggable: "false" });
256
+ else if (mode === "design") {
257
+ const bound = node.bindings.src;
258
+ img = h3("div", { class: "bp-image-placeholder", style: { width: w ? `${Math.min(w, avail)}px` : "100%", height: `${hgt ?? 140}px`, "border-radius": props.radius ? `${props.radius}px` : void 0 } }, h3("span", { class: "bp-image-placeholder__label" }, bound ? bound.label : src ? "Invalid image URL" : "Choose an image"));
259
+ } else img = h3("div", { style: { width: w ? `${w}px` : "100%", height: `${hgt ?? 0}px` } });
260
+ const linked = props.link && isSafeUrl(props.link) ? h3("a", { href: props.link, rel: "noopener", style: { display: "block", width: w ? `${Math.min(w, avail)}px` : "100%" } }, img) : img;
261
+ return h3("div", { class: "bp-image", style: cssText2({ ...css, display: "flex", "justify-content": justify }) }, linked);
262
+ }
263
+ });
264
+ var logoBlock = defineBlock2({
265
+ type: "logo",
266
+ name: "Logo",
267
+ description: "The active brand's logo",
268
+ category: "content",
269
+ icon: "badge-check",
270
+ keywords: ["brand", "theme"],
271
+ defaultProps: { variant: "auto", height: 32, align: "left" },
272
+ split: "none",
273
+ styleGroups: ["spacing", "flow"],
274
+ inspector: [
275
+ {
276
+ title: "Logo",
277
+ fields: [
278
+ { kind: "number", prop: "height", label: "Height", min: 8, max: 400, unit: "px" },
279
+ alignField,
280
+ {
281
+ kind: "select",
282
+ prop: "variant",
283
+ label: "Variant",
284
+ options: [
285
+ { value: "auto", label: "Automatic" },
286
+ { value: "light", label: "Standard" },
287
+ { value: "dark", label: "For dark backgrounds" }
288
+ ]
289
+ }
290
+ ]
291
+ }
292
+ ],
293
+ render: ({ h: h3, css, props, theme, mode, style }) => {
294
+ const onDark = props.variant === "dark" || props.variant === "auto" && !!style.background && isDarkColor(style.background);
295
+ const src = onDark && theme.logoDark || theme.logo;
296
+ const justify = props.align === "center" ? "center" : props.align === "right" ? "flex-end" : "flex-start";
297
+ const content = src && isSafeUrl(src, { allowData: true }) ? h3("img", { src, alt: theme.logoAlt ?? `${theme.name} logo`, style: { height: `${props.height}px`, width: "auto", "max-width": "100%", display: "block" }, draggable: "false" }) : mode === "design" ? h3("div", { class: "bp-logo-placeholder", style: { height: `${props.height}px` } }, theme.name || "Logo") : h3("div", { style: { height: `${props.height}px` } });
298
+ return h3("div", { class: "bp-logo", style: cssText2({ ...css, display: "flex", "justify-content": justify }) }, content);
299
+ }
300
+ });
301
+ var dividerBlock = defineBlock2({
302
+ type: "divider",
303
+ name: "Divider",
304
+ category: "content",
305
+ icon: "minus",
306
+ keywords: ["rule", "line", "separator", "hr"],
307
+ defaultProps: { thickness: 1, color: "$colors.border", lineStyle: "solid", width: 100 },
308
+ split: "none",
309
+ styleGroups: ["spacing", "flow"],
310
+ inspector: [
311
+ {
312
+ title: "Divider",
313
+ fields: [
314
+ { kind: "number", prop: "thickness", label: "Thickness", min: 1, max: 20, unit: "px" },
315
+ { kind: "color", prop: "color", label: "Colour" },
316
+ {
317
+ kind: "segmented",
318
+ prop: "lineStyle",
319
+ label: "Style",
320
+ options: [
321
+ { value: "solid", label: "Solid" },
322
+ { value: "dashed", label: "Dashed" },
323
+ { value: "dotted", label: "Dotted" }
324
+ ]
325
+ },
326
+ { kind: "number", prop: "width", label: "Width", min: 5, max: 100, unit: "%" }
327
+ ]
328
+ }
329
+ ],
330
+ render: ({ h: h3, css, props, theme }) => h3("div", { class: "bp-divider", style: cssText2({ ...css, display: "flex", "justify-content": "center" }) }, h3("div", { style: { width: `${props.width}%`, "border-top": `${props.thickness}px ${props.lineStyle} ${resolveValue(props.color, theme, theme.colors.border)}` } }))
331
+ });
332
+ var spacerBlock = defineBlock2({
333
+ type: "spacer",
334
+ name: "Spacer",
335
+ category: "content",
336
+ icon: "move-vertical",
337
+ keywords: ["gap", "space", "padding"],
338
+ defaultProps: { height: 24 },
339
+ split: "none",
340
+ styleGroups: ["flow"],
341
+ inspector: [{ title: "Spacer", fields: [{ kind: "number", prop: "height", label: "Height", min: 1, max: 600, unit: "px" }] }],
342
+ render: ({ h: h3, props, mode }) => h3("div", { class: ["bp-spacer", mode === "design" && "bp-spacer--design"], style: { height: `${props.height}px` } })
343
+ });
344
+ var buttonBlock = defineBlock2({
345
+ type: "button",
346
+ name: "Button",
347
+ description: "A hyperlink styled as a button",
348
+ category: "content",
349
+ icon: "mouse-pointer-click",
350
+ keywords: ["link", "cta", "url"],
351
+ defaultProps: { label: "View online", url: "https://", variant: "filled", color: "$colors.primary", align: "left", size: "md" },
352
+ split: "none",
353
+ styleGroups: ["typography", "spacing", "flow"],
354
+ inspector: [
355
+ {
356
+ title: "Button",
357
+ fields: [
358
+ { kind: "text", prop: "label", label: "Label" },
359
+ { kind: "text", prop: "url", label: "URL", placeholder: "https://\u2026 or {{ token }}" },
360
+ {
361
+ kind: "segmented",
362
+ prop: "variant",
363
+ label: "Style",
364
+ options: [
365
+ { value: "filled", label: "Filled" },
366
+ { value: "outline", label: "Outline" },
367
+ { value: "link", label: "Link" }
368
+ ]
369
+ },
370
+ { kind: "color", prop: "color", label: "Colour" },
371
+ {
372
+ kind: "segmented",
373
+ prop: "size",
374
+ label: "Size",
375
+ options: [
376
+ { value: "sm", label: "S" },
377
+ { value: "md", label: "M" },
378
+ { value: "lg", label: "L" }
379
+ ]
380
+ },
381
+ alignField
382
+ ]
383
+ }
384
+ ],
385
+ validate: ({ props }) => {
386
+ const url = String(props.url ?? "");
387
+ return url && !url.includes("{{") && !isSafeUrl(url) ? [{ severity: "warning", code: "invalid-url", message: `Button URL "${url}" is not a valid http(s) or mailto link`, prop: "url" }] : [];
388
+ },
389
+ render: ({ h: h3, css, props, theme, text, style }) => {
390
+ const color = resolveValue(props.color, theme, theme.colors.primary);
391
+ const pad = props.size === "sm" ? "5px 10px" : props.size === "lg" ? "11px 22px" : "8px 16px";
392
+ const fontSize = props.size === "sm" ? style.fontSize - 1 : props.size === "lg" ? style.fontSize + 2 : style.fontSize;
393
+ const base = { display: "inline-block", padding: props.variant === "link" ? "0" : pad, "border-radius": `${theme.radius ?? 6}px`, "font-weight": "600", "font-size": `${fontSize}px`, "text-decoration": props.variant === "link" ? "underline" : "none", "font-family": style.fontFamily, "line-height": "1.2" };
394
+ if (props.variant === "filled") Object.assign(base, { background: color, color: isDarkColor(color) ? "#FFFFFF" : theme.colors.text });
395
+ else if (props.variant === "outline") Object.assign(base, { border: `1.5px solid ${color}`, color });
396
+ else Object.assign(base, { color });
397
+ const url = typeof props.url === "string" ? props.url : "";
398
+ const justify = props.align === "center" ? "center" : props.align === "right" ? "flex-end" : "flex-start";
399
+ return h3("div", { class: "bp-button", style: cssText2({ ...css, display: "flex", "justify-content": justify }) }, h3("a", { class: "bp-button__link", href: isSafeUrl(url) ? url : void 0, rel: "noopener", style: base }, text("label", "Button")));
400
+ }
401
+ });
402
+ var calloutBlock = defineBlock2({
403
+ type: "callout",
404
+ name: "Callout",
405
+ description: "Highlighted note or warning",
406
+ category: "content",
407
+ icon: "message-square-warning",
408
+ keywords: ["note", "warning", "alert", "notice", "info"],
409
+ defaultProps: () => ({ title: "Important", content: createRichText("Add the note here."), tone: "info", icon: true }),
410
+ defaultTextStyle: "body",
411
+ defaultFlow: { keepTogether: true },
412
+ split: "none",
413
+ styleGroups: ["typography", "spacing", "flow"],
414
+ inspector: [
415
+ {
416
+ title: "Callout",
417
+ fields: [
418
+ { kind: "text", prop: "title", label: "Title" },
419
+ { kind: "richtext", prop: "content", label: "Content" },
420
+ {
421
+ kind: "select",
422
+ prop: "tone",
423
+ label: "Tone",
424
+ options: [
425
+ { value: "info", label: "Information" },
426
+ { value: "success", label: "Success" },
427
+ { value: "warning", label: "Warning" },
428
+ { value: "danger", label: "Danger" },
429
+ { value: "neutral", label: "Neutral" }
430
+ ]
431
+ },
432
+ { kind: "toggle", prop: "icon", label: "Show icon" }
433
+ ]
434
+ }
435
+ ],
436
+ canvas: { inlineEdit: { prop: "content", kind: "richtext" }, label: (p) => p.title || "Callout" },
437
+ render: ({ h: h3, css, props, theme, text, richText, partAttrs }) => {
438
+ const tones = { info: theme.colors.secondary, success: theme.colors.success ?? "#1E8E5A", warning: theme.colors.warning ?? "#C77D0A", danger: theme.colors.danger ?? "#C0392B", neutral: theme.colors.mutedText };
439
+ const accent = tones[props.tone] ?? theme.colors.secondary;
440
+ const glyphs = { info: "i", warning: "!", danger: "!" };
441
+ const icon = glyphs[props.tone] ?? (props.tone === "success" ? checkMark(11, "#fff") : props.tone === "neutral" ? dotMark(11, "#fff") : "i");
442
+ return h3(
443
+ "div",
444
+ { class: ["bp-callout", `bp-callout--${props.tone}`], style: cssText2({ ...css, display: "flex", gap: "12px", padding: css["padding"] ?? "12px 14px", "border-left": `3px solid ${accent}`, background: css["background"] ?? mixWithWhite(accent), "border-radius": `${theme.radius ?? 6}px` }) },
445
+ props.icon ? h3("div", { class: "bp-callout__icon", style: { color: "#fff", background: accent, "font-weight": "700", "font-size": "11px", width: "18px", height: "18px", "border-radius": "9px", display: "flex", "align-items": "center", "justify-content": "center", "flex-shrink": "0", "margin-top": "1px" } }, icon) : null,
446
+ h3("div", { class: "bp-callout__body", style: { flex: "1", "min-width": "0" } }, props.title ? h3("div", { class: "bp-callout__title", style: { "font-weight": "600", "margin-bottom": "3px" } }, text("title")) : null, h3("div", { class: "bp-callout__content", ...partAttrs("text") }, richText("content")))
447
+ );
448
+ }
449
+ });
450
+ function mixWithWhite(hex, amount = 0.92) {
451
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
452
+ if (!m) return "transparent";
453
+ const n = parseInt(m[1], 16);
454
+ const c = [n >> 16 & 255, n >> 8 & 255, n & 255].map((v) => Math.round(v + (255 - v) * amount));
455
+ return `#${c.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
456
+ }
457
+ var quoteBlock = defineBlock2({
458
+ type: "quote",
459
+ name: "Quote",
460
+ category: "content",
461
+ icon: "quote",
462
+ keywords: ["blockquote", "testimonial", "citation"],
463
+ defaultProps: () => ({ content: createRichText("A memorable statement."), attribution: "" }),
464
+ defaultTextStyle: "lead",
465
+ defaultStyle: { fontStyle: "italic" },
466
+ defaultFlow: { keepTogether: true },
467
+ split: "none",
468
+ styleGroups: ["textStyle", "typography", "alignment", "spacing", "flow"],
469
+ inspector: [
470
+ {
471
+ title: "Quote",
472
+ fields: [
473
+ { kind: "richtext", prop: "content", label: "Quote" },
474
+ { kind: "text", prop: "attribution", label: "Attribution", placeholder: "\u2014 Name, role" }
475
+ ]
476
+ }
477
+ ],
478
+ canvas: { inlineEdit: { prop: "content", kind: "richtext" } },
479
+ render: ({ h: h3, css, props, theme, richText, text, partAttrs }) => h3(
480
+ "blockquote",
481
+ { class: "bp-quote", style: cssText2({ ...css, margin: "0", "padding-left": "16px", "border-left": `3px solid ${theme.colors.accent}` }) },
482
+ h3("div", { class: "bp-quote__content", ...partAttrs("text") }, richText("content")),
483
+ props.attribution ? h3("div", { class: "bp-quote__attribution", style: { "font-style": "normal", "font-size": `${Math.max(8, Math.round(Number(css["font-size"]?.replace("px", "")) * 0.8))}px`, color: theme.colors.mutedText, "margin-top": "6px" } }, text("attribution")) : null
484
+ )
485
+ });
486
+ var fieldBlock = defineBlock2({
487
+ type: "field",
488
+ name: "Field",
489
+ description: "A labelled data value",
490
+ category: "data",
491
+ icon: "tag",
492
+ keywords: ["key value", "label", "dynamic text", "binding", "data"],
493
+ defaultProps: { label: "Label", value: "", layout: "stacked", fallback: "\u2014", labelWidth: 40 },
494
+ defaultTextStyle: "body",
495
+ split: "none",
496
+ styleGroups: ["typography", "alignment", "spacing", "flow"],
497
+ inspector: [
498
+ {
499
+ title: "Field",
500
+ fields: [
501
+ { kind: "text", prop: "label", label: "Label" },
502
+ { kind: "binding", prop: "value", label: "Value", placeholder: "Pick a field" },
503
+ { kind: "text", prop: "fallback", label: "Fallback when empty", allowTokens: false },
504
+ {
505
+ kind: "segmented",
506
+ prop: "layout",
507
+ label: "Layout",
508
+ options: [
509
+ { value: "stacked", label: "Stacked" },
510
+ { value: "inline", label: "Inline" }
511
+ ]
512
+ },
513
+ { kind: "number", prop: "labelWidth", label: "Label width", min: 10, max: 80, unit: "%", when: (p) => p.layout === "inline" }
514
+ ]
515
+ }
516
+ ],
517
+ canvas: { label: (p) => p.label || "Field" },
518
+ render: ({ h: h3, css, props, theme, text, mode, node }) => {
519
+ const b = node.bindings.value;
520
+ const raw = props.value;
521
+ const display = raw === void 0 || raw === null || raw === "" ? props.fallback : String(raw);
522
+ const value = mode === "design" && b ? h3("span", { class: "bp-token", "data-expr": b.expr, title: b.expr }, b.label) : display;
523
+ const label = h3("div", { class: "bp-field__label", style: { color: theme.colors.mutedText, "font-size": "0.85em", "text-transform": "uppercase", "letter-spacing": "0.04em", "font-weight": "600" } }, text("label"));
524
+ const val = h3("div", { class: "bp-field__value", style: { "font-weight": "500" } }, value);
525
+ if (props.layout === "inline") return h3("div", { class: "bp-field bp-field--inline", style: cssText2({ ...css, display: "flex", gap: "8px", "align-items": "baseline" }) }, h3("div", { style: { flex: `0 0 ${props.labelWidth}%` } }, label), h3("div", { style: { flex: "1", "min-width": "0" } }, val));
526
+ return h3("div", { class: "bp-field", style: cssText2({ ...css, display: "flex", "flex-direction": "column", gap: "2px" }) }, label, val);
527
+ }
528
+ });
529
+ var kpiBlock = defineBlock2({
530
+ type: "kpi",
531
+ name: "Statistic",
532
+ description: "Big number with label and trend",
533
+ category: "data",
534
+ icon: "trending-up",
535
+ keywords: ["kpi", "metric", "number", "stat", "big number"],
536
+ defaultProps: { label: "Portfolio value", value: "", caption: "", trend: "", trendFormat: "percent", accent: "$colors.primary", align: "left", size: "md" },
537
+ defaultTextStyle: "body",
538
+ defaultFlow: { keepTogether: true },
539
+ split: "none",
540
+ styleGroups: ["typography", "spacing", "background", "border", "flow"],
541
+ inspector: [
542
+ {
543
+ title: "Statistic",
544
+ fields: [
545
+ { kind: "text", prop: "label", label: "Label" },
546
+ { kind: "binding", prop: "value", label: "Value", valueType: ["number", "currency", "percent", "string"] },
547
+ { kind: "binding", prop: "trend", label: "Trend", valueType: ["number", "percent", "string"], placeholder: "e.g. portfolio.change" },
548
+ {
549
+ kind: "segmented",
550
+ prop: "trendFormat",
551
+ label: "Trend format",
552
+ options: [
553
+ { value: "percent", label: "%" },
554
+ { value: "number", label: "#" },
555
+ { value: "text", label: "Aa" }
556
+ ]
557
+ },
558
+ { kind: "text", prop: "caption", label: "Caption" },
559
+ { kind: "color", prop: "accent", label: "Accent" },
560
+ alignField,
561
+ {
562
+ kind: "segmented",
563
+ prop: "size",
564
+ label: "Size",
565
+ options: [
566
+ { value: "sm", label: "S" },
567
+ { value: "md", label: "M" },
568
+ { value: "lg", label: "L" }
569
+ ]
570
+ }
571
+ ]
572
+ }
573
+ ],
574
+ canvas: { label: (p) => p.label || "Statistic" },
575
+ render: ({ h: h3, css, props, theme, text, mode, node, format }) => {
576
+ const accent = resolveValue(props.accent, theme, theme.colors.primary);
577
+ const size = props.size === "sm" ? 20 : props.size === "lg" ? 36 : 28;
578
+ const b = node.bindings.value;
579
+ const valueNode = h3("span", { class: mode === "design" && b ? "bp-cell--bound" : void 0, title: b?.expr }, props.value === void 0 || props.value === null || props.value === "" ? "\u2014" : String(props.value));
580
+ let trendNode = null;
581
+ const tRaw = props.trend;
582
+ if (tRaw !== void 0 && tRaw !== null && tRaw !== "") {
583
+ const n = Number(tRaw);
584
+ const positive = Number.isFinite(n) ? n >= 0 : true;
585
+ const label = props.trendFormat === "percent" && Number.isFinite(n) ? `${n > 0 ? "+" : ""}${new Intl.NumberFormat(format.locale, { style: "percent", maximumFractionDigits: 1 }).format(n)}` : props.trendFormat === "number" && Number.isFinite(n) ? `${n > 0 ? "+" : ""}${new Intl.NumberFormat(format.locale, { maximumFractionDigits: 2 }).format(n)}` : String(tRaw);
586
+ const tb = node.bindings.trend;
587
+ const trendColor = positive ? theme.colors.success ?? "#1E8E5A" : theme.colors.danger ?? "#C0392B";
588
+ trendNode = h3(
589
+ "span",
590
+ { class: ["bp-kpi__trend", positive ? "bp-kpi__trend--up" : "bp-kpi__trend--down", mode === "design" && tb ? "bp-cell--bound" : ""], title: tb?.expr, style: { color: trendColor, "font-weight": "600", "font-size": `${Math.round(size * 0.9)}px`, display: "inline-flex", "align-items": "center", gap: "4px", height: `${Math.round(size * 1.05)}px` } },
591
+ triangleMark(positive ? "up" : "down", Math.max(9, Math.round(size * 0.55)), trendColor),
592
+ h3("span", null, label)
593
+ );
594
+ }
595
+ return h3(
596
+ "div",
597
+ { class: "bp-kpi", style: cssText2({ ...css, "text-align": props.align, "border-top": css["border"] ? void 0 : `3px solid ${accent}`, "padding-top": css["padding"] ? void 0 : "8px" }) },
598
+ h3("div", { class: "bp-kpi__label", style: { color: theme.colors.mutedText, "font-size": "0.9em", "text-transform": "uppercase", "letter-spacing": "0.05em", "font-weight": "600", display: "flex", gap: "6px", "align-items": "center", "justify-content": props.align === "center" ? "center" : props.align === "right" ? "flex-end" : "flex-start", "flex-wrap": "wrap" } }, text("label"), mode === "design" && b ? h3("span", { class: "bp-token", "data-expr": b.expr, style: { "text-transform": "none", "letter-spacing": "0", "font-weight": "500" } }, b.label) : null),
599
+ h3("div", { class: "bp-kpi__value", style: { "font-size": `${size}px`, "font-weight": "700", "line-height": "1.15", color: theme.colors.text, "font-family": theme.typography.headingFont, "letter-spacing": "-0.02em", "margin-top": "4px", display: "flex", "align-items": "baseline", gap: "8px", "justify-content": props.align === "center" ? "center" : props.align === "right" ? "flex-end" : "flex-start", "flex-wrap": "wrap" } }, valueNode, trendNode),
600
+ props.caption ? h3("div", { class: "bp-kpi__caption", style: { color: theme.colors.mutedText, "font-size": "0.9em", "margin-top": "3px" } }, text("caption")) : null
601
+ );
602
+ }
603
+ });
604
+ var signatureBlock = defineBlock2({
605
+ type: "signature",
606
+ name: "Signature",
607
+ description: "Signature line with name and date",
608
+ category: "content",
609
+ icon: "pen-line",
610
+ keywords: ["sign", "approval"],
611
+ defaultProps: { label: "Signed", name: "", role: "", showDate: true, lineWidth: 240 },
612
+ defaultTextStyle: "body",
613
+ defaultFlow: { keepTogether: true },
614
+ split: "none",
615
+ styleGroups: ["typography", "spacing", "flow"],
616
+ inspector: [
617
+ {
618
+ title: "Signature",
619
+ fields: [
620
+ { kind: "text", prop: "label", label: "Label" },
621
+ { kind: "text", prop: "name", label: "Name", placeholder: "{{ adviser.name }}" },
622
+ { kind: "text", prop: "role", label: "Role" },
623
+ { kind: "toggle", prop: "showDate", label: "Date line" },
624
+ { kind: "number", prop: "lineWidth", label: "Line width", min: 80, max: 600, unit: "px" }
625
+ ]
626
+ }
627
+ ],
628
+ render: ({ h: h3, css, props, theme, text }) => {
629
+ const line = (label, content) => h3("div", { style: { width: `${props.lineWidth}px`, "max-width": "100%" } }, h3("div", { style: { height: "36px", "border-bottom": `1px solid ${theme.colors.text}` } }), h3("div", { style: { "font-size": "0.85em", color: theme.colors.mutedText, "margin-top": "4px" } }, label), content.length ? h3("div", { style: { "font-weight": "600", "margin-top": "2px" } }, content) : null);
630
+ return h3("div", { class: "bp-signature", style: cssText2({ ...css, display: "flex", gap: "32px", "flex-wrap": "wrap" }) }, line(props.label || "Signed", [...text("name"), ...props.role ? [h3("div", { style: { "font-weight": "400", color: theme.colors.mutedText } }, text("role"))] : []]), props.showDate ? line("Date", []) : null);
631
+ }
632
+ });
633
+ var dateBlock = defineBlock2({
634
+ type: "date",
635
+ name: "Date",
636
+ description: "Today's date or a bound date field",
637
+ category: "content",
638
+ icon: "calendar",
639
+ keywords: ["today", "generated", "date"],
640
+ defaultProps: { source: "now", value: "", pattern: "d MMMM yyyy", prefix: "" },
641
+ defaultTextStyle: "body",
642
+ split: "none",
643
+ styleGroups: ["textStyle", "typography", "alignment", "spacing", "flow"],
644
+ inspector: [
645
+ {
646
+ title: "Date",
647
+ fields: [
648
+ {
649
+ kind: "segmented",
650
+ prop: "source",
651
+ label: "Source",
652
+ options: [
653
+ { value: "now", label: "Today" },
654
+ { value: "binding", label: "Field" }
655
+ ]
656
+ },
657
+ { kind: "binding", prop: "value", label: "Date field", valueType: ["date", "datetime"], when: (p) => p.source === "binding" },
658
+ { kind: "text", prop: "pattern", label: "Format", placeholder: "d MMMM yyyy", allowTokens: false },
659
+ { kind: "text", prop: "prefix", label: "Prefix", placeholder: "Prepared on " }
660
+ ]
661
+ }
662
+ ],
663
+ render: ({ h: h3, css, props, format, text, mode, node }) => {
664
+ const d = props.source === "now" ? format.now ?? /* @__PURE__ */ new Date() : props.value;
665
+ const b = node.bindings.value;
666
+ const body = mode === "design" && props.source === "binding" && b ? h3("span", { class: "bp-token", "data-expr": b.expr }, b.label) : formatDate(d, props.pattern || void 0, format);
667
+ return h3("div", { class: "bp-date", style: cssText2(css) }, text("prefix"), body);
668
+ }
669
+ });
670
+ var pageNumberBlock = defineBlock2({
671
+ type: "pageNumber",
672
+ name: "Page number",
673
+ category: "content",
674
+ icon: "hash",
675
+ keywords: ["page", "of", "footer", "numbering"],
676
+ defaultProps: { format: "Page {{page.number}} of {{page.total}}", align: "right" },
677
+ defaultTextStyle: "caption",
678
+ split: "none",
679
+ styleGroups: ["textStyle", "typography", "spacing"],
680
+ inspector: [
681
+ {
682
+ title: "Page number",
683
+ fields: [
684
+ { kind: "text", prop: "format", label: "Format", placeholder: "Page {{page.number}} of {{page.total}}" },
685
+ alignField
686
+ ]
687
+ }
688
+ ],
689
+ render: ({ h: h3, css, props, text }) => h3("div", { class: "bp-page-number", style: cssText2({ ...css, "text-align": props.align }) }, text("format"))
690
+ });
691
+ var timestampBlock = defineBlock2({
692
+ type: "timestamp",
693
+ name: "Generated at",
694
+ description: "Date and time the document was generated",
695
+ category: "content",
696
+ icon: "clock",
697
+ keywords: ["generated", "printed", "timestamp", "time"],
698
+ defaultProps: { prefix: "Generated ", pattern: "d MMM yyyy, HH:mm", align: "left" },
699
+ defaultTextStyle: "caption",
700
+ split: "none",
701
+ styleGroups: ["textStyle", "typography", "spacing"],
702
+ inspector: [
703
+ {
704
+ title: "Generated at",
705
+ fields: [
706
+ { kind: "text", prop: "prefix", label: "Prefix" },
707
+ { kind: "text", prop: "pattern", label: "Format", allowTokens: false },
708
+ alignField
709
+ ]
710
+ }
711
+ ],
712
+ render: ({ h: h3, css, props, format, text }) => h3("div", { class: "bp-timestamp", style: cssText2({ ...css, "text-align": props.align }) }, text("prefix"), formatDate(format.now ?? /* @__PURE__ */ new Date(), props.pattern || void 0, format, true))
713
+ });
714
+
715
+ // src/blocks/layout.ts
716
+ import { defineBlock as defineBlock3, cssText as cssText3, resolveValue as resolveValue2 } from "@broadpaper/core";
717
+ var containerBlock = defineBlock3({
718
+ type: "container",
719
+ name: "Container",
720
+ description: "Groups blocks with shared padding and background",
721
+ category: "layout",
722
+ icon: "square-dashed",
723
+ keywords: ["group", "box", "wrapper"],
724
+ defaultProps: { padding: 0, gap: null },
725
+ container: { accepts: ["@blocks", "@rows"] },
726
+ split: "children",
727
+ styleGroups: ["spacing", "background", "border", "flow"],
728
+ inspector: [
729
+ {
730
+ title: "Container",
731
+ fields: [
732
+ { kind: "number", prop: "padding", label: "Padding", min: 0, max: 120, unit: "px" },
733
+ { kind: "number", prop: "gap", label: "Block gap", min: 0, max: 120, unit: "px", description: "Overrides the document block gap" }
734
+ ]
735
+ }
736
+ ],
737
+ render: ({ h: h3, css, children, styles, props, node, mode }) => h3(
738
+ "div",
739
+ { class: "bp-container", style: cssText3({ ...css, padding: props.padding ? `${props.padding}px` : css["padding"] }) },
740
+ h3("div", { class: "bp-stack", style: { gap: `${props.gap ?? styles.spacing.blockGap}px` }, "data-dropzone": node.nodeId }, children.length === 0 && mode === "design" ? h3("div", { class: "bp-empty-column" }, "Drop blocks here") : children)
741
+ )
742
+ });
743
+ var cardBlock = defineBlock3({
744
+ type: "card",
745
+ name: "Card",
746
+ description: "Panel with border, background and optional title",
747
+ category: "layout",
748
+ icon: "panel-top",
749
+ keywords: ["panel", "box", "surface"],
750
+ defaultProps: { title: "", padding: null, accent: false, accentColor: "$colors.primary" },
751
+ container: { accepts: ["@blocks", "@rows"] },
752
+ defaultFlow: { keepTogether: true },
753
+ split: "children",
754
+ styleGroups: ["spacing", "background", "border", "flow"],
755
+ inspector: [
756
+ {
757
+ title: "Card",
758
+ fields: [
759
+ { kind: "text", prop: "title", label: "Title" },
760
+ { kind: "number", prop: "padding", label: "Padding", min: 0, max: 120, unit: "px" },
761
+ { kind: "toggle", prop: "accent", label: "Accent bar" },
762
+ { kind: "color", prop: "accentColor", label: "Accent colour", when: (p) => !!p.accent }
763
+ ]
764
+ }
765
+ ],
766
+ canvas: { label: (p) => p.title || "Card" },
767
+ render: ({ h: h3, css, children, styles, props, theme, text, node, mode }) => {
768
+ const card = styles.card;
769
+ const pad = props.padding ?? card.padding ?? 16;
770
+ const style = {
771
+ ...css,
772
+ background: css["background"] ?? resolveValue2(card.background, theme, theme.colors.surface),
773
+ border: css["border"] ?? `${card.borderWidth ?? 1}px solid ${resolveValue2(card.borderColor, theme, theme.colors.border)}`,
774
+ "border-radius": css["border-radius"] ?? `${card.borderRadius ?? theme.radius ?? 6}px`,
775
+ padding: `${pad}px`,
776
+ "box-shadow": card.shadow ? "0 1px 2px rgba(16,24,40,0.06), 0 1px 3px rgba(16,24,40,0.1)" : void 0,
777
+ "border-top": props.accent ? `3px solid ${resolveValue2(props.accentColor, theme, theme.colors.primary)}` : void 0
778
+ };
779
+ return h3(
780
+ "div",
781
+ { class: "bp-card", style: cssText3(style) },
782
+ props.title ? h3("div", { class: "bp-card__title", style: { "font-family": theme.typography.headingFont, "font-weight": "600", "font-size": "1.1em", "margin-bottom": "8px", color: theme.colors.primary } }, text("title")) : null,
783
+ h3("div", { class: "bp-stack", style: { gap: `${styles.spacing.blockGap}px` }, "data-dropzone": node.nodeId }, children.length === 0 && mode === "design" ? h3("div", { class: "bp-empty-column" }, "Drop blocks here") : children)
784
+ );
785
+ }
786
+ });
787
+ var pageBreakBlock = defineBlock3({
788
+ type: "pageBreak",
789
+ name: "Page break",
790
+ description: "Start the following content on a new page",
791
+ category: "layout",
792
+ icon: "scissors",
793
+ keywords: ["break", "new page"],
794
+ defaultProps: {},
795
+ defaultFlow: { breakAfter: "page" },
796
+ split: "none",
797
+ styleGroups: [],
798
+ render: ({ h: h3, mode }) => mode === "design" ? h3("div", { class: "bp-page-break" }, h3("span", { class: "bp-page-break__label" }, "Page break")) : h3("div", { class: "bp-page-break bp-page-break--print", style: { height: "0" } })
799
+ });
800
+ var keepTogetherBlock = defineBlock3({
801
+ type: "keepTogether",
802
+ name: "Keep together",
803
+ description: "Blocks inside never split across pages",
804
+ category: "layout",
805
+ icon: "link",
806
+ keywords: ["group", "no split", "together"],
807
+ defaultProps: { label: "" },
808
+ container: { accepts: ["@blocks", "@rows"] },
809
+ defaultFlow: { keepTogether: true },
810
+ split: "children",
811
+ styleGroups: ["spacing", "flow"],
812
+ render: ({ h: h3, css, children, styles, node, mode }) => h3("div", { class: "bp-keep-together", style: cssText3(css) }, h3("div", { class: "bp-stack", style: { gap: `${styles.spacing.blockGap}px` }, "data-dropzone": node.nodeId }, children.length === 0 && mode === "design" ? h3("div", { class: "bp-empty-column" }, "Drop blocks here") : children))
813
+ });
814
+ var conditionalBlock = defineBlock3({
815
+ type: "conditional",
816
+ name: "Conditional",
817
+ description: "Show the blocks inside only when a rule is true",
818
+ category: "data",
819
+ icon: "git-branch",
820
+ keywords: ["if", "condition", "rule", "visibility", "show when"],
821
+ defaultProps: () => ({ condition: { op: "and", rules: [] }, label: "" }),
822
+ container: { accepts: ["@blocks", "@rows"], conditional: { conditionProp: "condition" } },
823
+ split: "children",
824
+ styleGroups: ["spacing", "flow"],
825
+ bindings: [{ prop: "condition", kind: "condition" }],
826
+ inspector: [{ title: "Show when", fields: [{ kind: "condition", prop: "condition", label: "Rule", fullWidth: true }] }],
827
+ canvas: { label: (p) => p.label || "Conditional" },
828
+ render: ({ h: h3, css, children, styles, props, node, mode }) => {
829
+ const result = props.__conditionResult !== false;
830
+ if (mode === "preview" && !result) return null;
831
+ return h3(
832
+ "div",
833
+ { class: ["bp-conditional", !result && "bp-conditional--false"], style: cssText3(css) },
834
+ mode === "design" ? h3("div", { class: "bp-conditional__badge", contenteditable: "false" }, result ? "Shown when rule is true" : "Hidden with sample data") : null,
835
+ h3("div", { class: "bp-stack", style: { gap: `${styles.spacing.blockGap}px` }, "data-dropzone": node.nodeId }, children.length === 0 && mode === "design" ? h3("div", { class: "bp-empty-column" }, "Drop conditional blocks here") : children)
836
+ );
837
+ }
838
+ });
839
+ var repeaterBlock = defineBlock3({
840
+ type: "repeater",
841
+ name: "Repeater",
842
+ description: "Repeats the blocks inside for every item in a list",
843
+ category: "data",
844
+ icon: "repeat",
845
+ keywords: ["loop", "for each", "list", "items"],
846
+ defaultProps: { source: "", alias: "item", keepItemTogether: true, gap: null, limit: null, emptyMessage: "", separator: false },
847
+ container: { accepts: ["@blocks", "@rows"], repeat: { collectionProp: "source", aliasProp: "alias", keepItemTogetherProp: "keepItemTogether", limitProp: "limit" } },
848
+ split: "children",
849
+ styleGroups: ["spacing", "flow"],
850
+ bindings: [{ prop: "source", kind: "collection", alias: "alias" }, { prop: "emptyMessage", kind: "template" }],
851
+ inspector: [
852
+ {
853
+ title: "Repeat",
854
+ fields: [
855
+ { kind: "collection", prop: "source", label: "For each item in", placeholder: "Pick a list" },
856
+ { kind: "text", prop: "alias", label: "Item name", description: (p) => `The name each item goes by inside the repeater, e.g. {{ ${typeof p.alias === "string" && p.alias.trim() ? p.alias.trim() : "item"}.name }}`, allowTokens: false },
857
+ { kind: "toggle", prop: "keepItemTogether", label: "Keep each item on one page" },
858
+ { kind: "toggle", prop: "separator", label: "Divider between items" },
859
+ { kind: "number", prop: "gap", label: "Gap between items", min: 0, max: 120, unit: "px" },
860
+ { kind: "number", prop: "limit", label: "Maximum items", min: 1, max: 1e4 },
861
+ { kind: "text", prop: "emptyMessage", label: "Message when empty", description: "Printed instead of the items when the list is empty.", fullWidth: true }
862
+ ]
863
+ }
864
+ ],
865
+ canvas: { label: (p) => p.source ? `Repeat ${p.source}` : "Repeater" },
866
+ render: ({ h: h3, css, children, styles, props, node, mode, theme, text }) => {
867
+ const count = Number(props.__itemCount ?? 0);
868
+ const b = node.bindings.source;
869
+ if (children.length === 0 && mode === "preview") {
870
+ return props.emptyMessage ? h3("div", { class: "bp-repeater__empty", style: cssText3({ ...css, color: theme.colors.mutedText }) }, text("emptyMessage")) : null;
871
+ }
872
+ const gap = props.gap ?? styles.spacing.blockGap;
873
+ const items = props.separator ? children.flatMap((c, i) => i === 0 ? [c] : [h3("div", { class: "bp-repeater__separator", style: { "border-top": `1px solid ${theme.colors.border}` } }), c]) : children;
874
+ return h3(
875
+ "div",
876
+ { class: "bp-repeater", style: cssText3(css) },
877
+ // Design mode draws one iteration and the badge has to admit it, or a
878
+ // three-item repeater and a thirty-item one look identical on the canvas.
879
+ mode === "design" ? h3("div", { class: "bp-repeater__badge", contenteditable: "false" }, b ? `${b.label} \xB7 ${children.length >= count ? `repeats ${count} time${count === 1 ? "" : "s"}` : `showing ${children.length} of ${count} sample items`}` : "Choose a list to repeat over") : null,
880
+ h3("div", { class: "bp-stack bp-repeater__items", style: { gap: `${gap}px` }, "data-dropzone": node.nodeId }, items.length === 0 && mode === "design" ? h3("div", { class: "bp-empty-column" }, "Drop blocks to repeat here") : items)
881
+ );
882
+ }
883
+ });
884
+
885
+ // src/blocks/table.ts
886
+ import { defineBlock as defineBlock4, cssText as cssText4, resolveValue as resolveValue3, toNumber, toDisplayString, compareValues, getField, isDarkColor as isDarkColor2 } from "@broadpaper/core";
887
+ function defaultColumns() {
888
+ return [
889
+ { id: "c1", header: "Name", value: "row.name", width: null },
890
+ { id: "c2", header: "Value", value: "row.value | number", align: "right", width: 30, total: "sum" }
891
+ ];
892
+ }
893
+ function aliasOf(props) {
894
+ const alias = props.alias;
895
+ return typeof alias === "string" && alias.trim() ? alias.trim() : "row";
896
+ }
897
+ function columnName(id, props) {
898
+ if (!id || id === "*") return "Every column";
899
+ const column = (props.columns ?? []).find((c) => c.id === id);
900
+ return column?.header || column?.value || id;
901
+ }
902
+ var tableBlock = defineBlock4({
903
+ type: "table",
904
+ name: "Table",
905
+ description: "Data table bound to a list, with totals, zebra rows and page-spanning headers",
906
+ category: "data",
907
+ icon: "table",
908
+ keywords: ["grid", "rows", "columns", "data", "list"],
909
+ defaultProps: () => ({
910
+ source: "",
911
+ alias: "row",
912
+ columns: defaultColumns(),
913
+ showHeader: true,
914
+ repeatHeader: true,
915
+ zebra: true,
916
+ borders: "inherit",
917
+ compact: false,
918
+ fontSize: null,
919
+ headerBackground: "",
920
+ headerColor: "",
921
+ sortBy: "",
922
+ sortDir: "asc",
923
+ emptyMessage: "No data",
924
+ totalsLabel: "Total",
925
+ conditionalFormats: [],
926
+ designRows: 3,
927
+ rowLimit: null
928
+ }),
929
+ defaultTextStyle: "body",
930
+ defaultFlow: { minOrphans: 2, minWidows: 2 },
931
+ split: "rows",
932
+ measure: "table",
933
+ styleGroups: ["typography", "spacing", "flow"],
934
+ bindings: [
935
+ { prop: "source", kind: "collection", alias: "alias" },
936
+ { prop: "columns", kind: "list", items: [{ prop: "value", kind: "value", perItem: true }, { prop: "header", kind: "template" }, { prop: "totalExpr", kind: "value" }] },
937
+ { prop: "conditionalFormats", kind: "list", items: [{ prop: "condition", kind: "value", perItem: true }] },
938
+ { prop: "emptyMessage", kind: "template" },
939
+ { prop: "totalsLabel", kind: "template" }
940
+ ],
941
+ inspector: [
942
+ {
943
+ title: "Data",
944
+ fields: [
945
+ { kind: "collection", prop: "source", label: "Rows from", placeholder: "Pick a list" },
946
+ { kind: "text", prop: "alias", label: "Row name", description: (p) => `The name each row goes by in column values, e.g. ${aliasOf(p)}.name`, allowTokens: false },
947
+ { kind: "text", prop: "sortBy", label: "Sort by field", placeholder: "e.g. value", allowTokens: false },
948
+ {
949
+ kind: "segmented",
950
+ prop: "sortDir",
951
+ label: "Direction",
952
+ options: [
953
+ { value: "asc", label: "Ascending" },
954
+ { value: "desc", label: "Descending" }
955
+ ],
956
+ when: (p) => !!p.sortBy
957
+ },
958
+ { kind: "number", prop: "rowLimit", label: "Maximum rows", min: 1, max: 1e5 },
959
+ { kind: "text", prop: "emptyMessage", label: "Message when empty", description: "Printed instead of the table when the list has no rows.", fullWidth: true }
960
+ ]
961
+ },
962
+ { title: "Columns", fields: [{ kind: "columns", prop: "columns", label: "Columns", fullWidth: true }] },
963
+ {
964
+ title: "Appearance",
965
+ collapsed: true,
966
+ fields: [
967
+ { kind: "toggle", prop: "showHeader", label: "Header row" },
968
+ { kind: "toggle", prop: "repeatHeader", label: "Repeat header on each page", when: (p) => p.showHeader !== false },
969
+ { kind: "toggle", prop: "zebra", label: "Zebra rows" },
970
+ { kind: "toggle", prop: "compact", label: "Compact rows" },
971
+ {
972
+ kind: "select",
973
+ prop: "borders",
974
+ label: "Borders",
975
+ options: [
976
+ { value: "inherit", label: "Document default" },
977
+ { value: "horizontal", label: "Horizontal lines" },
978
+ { value: "all", label: "Full grid" },
979
+ { value: "outer", label: "Outer only" },
980
+ { value: "none", label: "None" }
981
+ ]
982
+ },
983
+ { kind: "color", prop: "headerBackground", label: "Header background", allowTransparent: true },
984
+ { kind: "color", prop: "headerColor", label: "Header text" },
985
+ { kind: "number", prop: "fontSize", label: "Font size", min: 6, max: 24, unit: "px" },
986
+ { kind: "text", prop: "totalsLabel", label: "Totals label" }
987
+ ]
988
+ },
989
+ {
990
+ title: "Conditional formatting",
991
+ collapsed: true,
992
+ fields: [
993
+ {
994
+ kind: "list",
995
+ prop: "conditionalFormats",
996
+ label: "Rules",
997
+ itemLabel: "Rule",
998
+ fullWidth: true,
999
+ // A rule reads as "Drift · when abs(a.drift) > 0.03" rather than "c5",
1000
+ // which is an internal id the designer never chose and cannot look up.
1001
+ itemSummary: (rule, i, props) => {
1002
+ const column = columnName(String(rule.columnId ?? ""), props);
1003
+ const when = String(rule.condition ?? "").trim();
1004
+ return when ? `${column} \xB7 when ${when}` : `${column} \xB7 Rule ${i + 1}`;
1005
+ },
1006
+ fields: [
1007
+ {
1008
+ kind: "select",
1009
+ prop: "columnId",
1010
+ label: "Column",
1011
+ optionsFrom: (props) => [{ value: "*", label: "Every column" }, ...(props.columns ?? []).map((c) => ({ value: c.id, label: c.header || c.id }))]
1012
+ },
1013
+ { kind: "expression", prop: "condition", label: "When", placeholder: "row.value < 0", description: (p) => `Checked once per row, e.g. ${aliasOf(p)}.value < 0` },
1014
+ { kind: "color", prop: "color", label: "Text colour" },
1015
+ { kind: "color", prop: "background", label: "Background", allowTransparent: true },
1016
+ { kind: "toggle", prop: "bold", label: "Bold" }
1017
+ ]
1018
+ }
1019
+ ]
1020
+ }
1021
+ ],
1022
+ validate: ({ props, checkExpression }) => {
1023
+ const issues = [];
1024
+ if (!Array.isArray(props.columns) || props.columns.length === 0) issues.push({ severity: "warning", code: "empty-content", message: "Table has no columns", prop: "columns" });
1025
+ for (const c of props.columns ?? []) if (!c.value?.trim()) issues.push({ severity: "warning", code: "missing-required", message: `Column "${c.header || c.id}" has no value`, prop: `columns.${c.id}.value` });
1026
+ for (const c of props.columns ?? []) if (c.totalExpr?.trim()) issues.push(...checkExpression(c.totalExpr, `columns.${c.id}.totalExpr`));
1027
+ return issues;
1028
+ },
1029
+ canvas: { label: (p) => p.source ? `Table \xB7 ${p.source}` : "Table" },
1030
+ resolve: ({ props, mode, evaluateIn, evaluate: evaluate2, warn, theme }) => {
1031
+ const alias = props.alias || "row";
1032
+ let items = Array.isArray(props.source) ? props.source : [];
1033
+ if (props.sortBy) {
1034
+ const dir = props.sortDir === "desc" ? -1 : 1;
1035
+ items = [...items].sort((a, b) => compareValues(getField(a, props.sortBy), getField(b, props.sortBy)) * dir);
1036
+ }
1037
+ const total = items.length;
1038
+ if (props.rowLimit) items = items.slice(0, props.rowLimit);
1039
+ const allItems = items;
1040
+ if (mode === "design") items = items.slice(0, Math.max(1, props.designRows || 3));
1041
+ const columns = Array.isArray(props.columns) ? props.columns : [];
1042
+ const formats = Array.isArray(props.conditionalFormats) ? props.conditionalFormats : [];
1043
+ const rows = items.map((item, index) => {
1044
+ const vars = { [alias]: item, item, row: item, index: index + 1, first: index === 0, last: index === items.length - 1, count: items.length };
1045
+ const rowStyle = {};
1046
+ const cellStyles = {};
1047
+ for (const f of formats) {
1048
+ if (!f.condition?.trim()) continue;
1049
+ let ok = false;
1050
+ try {
1051
+ ok = !!evaluateIn(f.condition, vars);
1052
+ } catch {
1053
+ ok = false;
1054
+ }
1055
+ if (!ok) continue;
1056
+ const st = {};
1057
+ if (f.color) st["color"] = resolveValue3(f.color, theme, theme.colors.text);
1058
+ if (f.background) st["background"] = resolveValue3(f.background, theme, "transparent");
1059
+ if (f.bold) st["font-weight"] = "700";
1060
+ if (f.columnId === "*" || !f.columnId) Object.assign(rowStyle, st);
1061
+ else cellStyles[f.columnId] = { ...cellStyles[f.columnId] ?? {}, ...st };
1062
+ }
1063
+ const cells = columns.map((c) => {
1064
+ let text = "";
1065
+ if (c.value?.trim()) {
1066
+ try {
1067
+ text = toDisplayString(evaluateIn(c.value, vars));
1068
+ } catch (e) {
1069
+ warn(`Column "${c.header}" failed: ${e.message}`);
1070
+ }
1071
+ }
1072
+ return { text, style: cellStyles[c.id] };
1073
+ });
1074
+ return { cells, style: Object.keys(rowStyle).length ? rowStyle : void 0 };
1075
+ });
1076
+ let totals = null;
1077
+ if (columns.some((c) => c.total && c.total !== "none" || c.totalExpr?.trim())) {
1078
+ totals = columns.map((c, i) => {
1079
+ if (c.totalExpr?.trim()) {
1080
+ try {
1081
+ return { text: toDisplayString(evaluateIn(c.totalExpr, { rows: allItems, [alias + "s"]: allItems })) };
1082
+ } catch {
1083
+ return { text: "" };
1084
+ }
1085
+ }
1086
+ if (!c.total || c.total === "none") return { text: i === 0 && !columns[0]?.total ? String(props.totalsLabel ?? "Total") : "" };
1087
+ const values = allItems.map((it) => toNumber(evaluateIn(stripPipes(c.value), { [alias]: it, item: it, row: it }))).filter((n2) => n2 !== null);
1088
+ let n = null;
1089
+ if (c.total === "sum") n = values.reduce((a, b) => a + b, 0);
1090
+ else if (c.total === "avg") n = values.length ? values.reduce((a, b) => a + b, 0) / values.length : null;
1091
+ else if (c.total === "count") n = allItems.length;
1092
+ else if (c.total === "min") n = values.length ? Math.min(...values) : null;
1093
+ else if (c.total === "max") n = values.length ? Math.max(...values) : null;
1094
+ const fmt2 = c.totalFormat?.trim() || extractPipes(c.value);
1095
+ const text = n === null ? "" : fmt2 ? toDisplayString(evaluate2(`(${n}) | ${fmt2}`)) : toDisplayString(n);
1096
+ return { text };
1097
+ });
1098
+ if (totals[0] && totals[0].text === "" && !(columns[0]?.total && columns[0].total !== "none")) totals[0] = { text: String(props.totalsLabel ?? "Total") };
1099
+ }
1100
+ return { __rows: rows, __totals: totals, __rowCount: total, __rowsShown: rows.length };
1101
+ },
1102
+ render: ({ h: h3, css, props, theme, styles, mode, text, node, style }) => {
1103
+ const p = props;
1104
+ const columns = Array.isArray(p.columns) ? p.columns : [];
1105
+ const ts = styles.table;
1106
+ const fontSize = p.fontSize ?? ts.fontSize ?? style.fontSize;
1107
+ const borders = p.borders === "inherit" ? ts.borders ?? "horizontal" : p.borders;
1108
+ const borderColor = resolveValue3(ts.borderColor, theme, theme.colors.border);
1109
+ const bw = ts.borderWidth ?? 1;
1110
+ const padY = p.compact ? Math.max(2, (ts.cellPaddingY ?? 6) - 3) : ts.cellPaddingY ?? 6;
1111
+ const padX = ts.cellPaddingX ?? 8;
1112
+ const headerBg = p.headerBackground ? resolveValue3(p.headerBackground, theme, "") : resolveValue3(ts.headerBackground, theme, theme.table?.headerBackground ?? "#F3F4F6");
1113
+ const headerColor = p.headerColor ? resolveValue3(p.headerColor, theme, "") : resolveValue3(ts.headerColor, theme, headerBg && isDarkColor2(headerBg) ? "#FFFFFF" : theme.colors.text);
1114
+ const zebraBg = resolveValue3(ts.zebraBackground, theme, theme.table?.zebraBackground ?? "#FAFAFB");
1115
+ const cellBorder = (isHeader, last) => {
1116
+ const line = `${bw}px solid ${borderColor}`;
1117
+ switch (borders) {
1118
+ case "all":
1119
+ return { border: line };
1120
+ case "horizontal":
1121
+ return { "border-bottom": last ? line : line, ...isHeader ? { "border-bottom": `${bw * 2}px solid ${borderColor}` } : {} };
1122
+ case "outer":
1123
+ return {};
1124
+ default:
1125
+ return {};
1126
+ }
1127
+ };
1128
+ const rows = p.__rows ?? [];
1129
+ const rowsVNodes = rows.map(
1130
+ (r, i) => h3(
1131
+ "tr",
1132
+ { class: ["bp-table__row", p.zebra && i % 2 === 1 && "bp-table__row--alt"], style: { ...p.zebra && i % 2 === 1 ? { background: zebraBg } : {}, ...r.style ?? {} } },
1133
+ r.cells.map((c, ci) => {
1134
+ const col = columns[ci];
1135
+ return h3("td", { class: ["bp-table__cell", mode === "design" && "bp-cell--bound"], style: { padding: `${padY}px ${padX}px`, "text-align": col?.align ?? "left", "font-weight": col?.bold ? "600" : void 0, "vertical-align": "top", "white-space": col?.wrap === false ? "nowrap" : "normal", overflow: "hidden", "text-overflow": "ellipsis", ...cellBorder(false, i === rows.length - 1), ...c.style ?? {} } }, c.text);
1136
+ })
1137
+ )
1138
+ );
1139
+ const specified = columns.reduce((a, c) => a + (c.width ?? 0), 0);
1140
+ const autoCount = columns.filter((c) => !c.width).length;
1141
+ const autoWidth = autoCount ? Math.max(5, (100 - specified) / autoCount) : 0;
1142
+ const colgroup = h3("colgroup", null, columns.map((c) => h3("col", { style: { width: `${c.width ?? autoWidth}%` } })));
1143
+ const thead = p.showHeader !== false ? h3("thead", { class: "bp-table__head" }, h3("tr", null, columns.map((c) => h3("th", { class: "bp-table__th", style: { padding: `${padY}px ${padX}px`, "text-align": c.align ?? "left", background: headerBg || void 0, color: headerColor, "font-weight": String(ts.headerFontWeight ?? 600), "vertical-align": "bottom", ...cellBorder(true, false) } }, ...text(`columns.${columns.indexOf(c)}.header`, c.header))))) : null;
1144
+ const tfoot = p.__totals ? h3("tfoot", { class: "bp-table__foot" }, h3("tr", null, p.__totals.map((c, ci) => h3("td", { class: "bp-table__total", style: { padding: `${padY}px ${padX}px`, "text-align": columns[ci]?.align ?? "left", "font-weight": "700", "border-top": `${bw * 2}px solid ${borderColor}`, ...borders === "all" ? { border: `${bw}px solid ${borderColor}` } : {} } }, c.text)))) : null;
1145
+ const empty = rows.length === 0;
1146
+ const tableStyle = { width: "100%", "border-collapse": "collapse", "table-layout": "fixed", "font-size": `${fontSize}px`, ...borders === "outer" ? { border: `${bw}px solid ${borderColor}` } : {} };
1147
+ const b = node.bindings.source;
1148
+ return h3(
1149
+ "div",
1150
+ { class: "bp-table-wrap", style: cssText4(css) },
1151
+ // Design mode draws a handful of rows to keep the canvas quick, which is
1152
+ // indistinguishable from a table that only has a handful of rows unless
1153
+ // the badge says so.
1154
+ mode === "design" ? h3("div", { class: "bp-table__badge", contenteditable: "false" }, b ? `${b.label} \xB7 ${rowSampleNote(p.__rowsShown ?? 0, p.__rowCount ?? 0)}` : "Choose a list") : null,
1155
+ h3("table", { class: "bp-table", style: tableStyle }, colgroup, thead, h3("tbody", null, empty ? h3("tr", null, h3("td", { colspan: String(Math.max(1, columns.length)), style: { padding: `${padY * 2}px ${padX}px`, color: theme.colors.mutedText, "text-align": "center", "font-style": "italic" } }, ...text("emptyMessage", "No data"))) : rowsVNodes), tfoot)
1156
+ );
1157
+ }
1158
+ });
1159
+ function rowSampleNote(shown, total) {
1160
+ if (total === 0) return "no rows";
1161
+ if (shown >= total) return `${total} row${total === 1 ? "" : "s"}`;
1162
+ return `showing ${shown} of ${total} sample rows`;
1163
+ }
1164
+ function stripPipes(expr) {
1165
+ const idx = expr.indexOf("|");
1166
+ if (idx < 0) return expr;
1167
+ if (expr[idx + 1] === "|") return expr;
1168
+ return expr.slice(0, idx).trim();
1169
+ }
1170
+ function extractPipes(expr) {
1171
+ const idx = expr.indexOf("|");
1172
+ if (idx < 0 || expr[idx + 1] === "|") return "";
1173
+ return expr.slice(idx + 1).trim();
1174
+ }
1175
+ var keyValueListBlock = defineBlock4({
1176
+ type: "keyValueList",
1177
+ name: "Key / value list",
1178
+ description: "A list of labelled values",
1179
+ category: "data",
1180
+ icon: "list",
1181
+ keywords: ["details", "properties", "definition list", "summary"],
1182
+ defaultProps: () => ({
1183
+ items: [
1184
+ { id: "k1", label: "Name", value: "" },
1185
+ { id: "k2", label: "Reference", value: "" }
1186
+ ],
1187
+ layout: "inline",
1188
+ labelWidth: 35,
1189
+ dividers: true,
1190
+ columns: 1
1191
+ }),
1192
+ defaultTextStyle: "body",
1193
+ split: "none",
1194
+ styleGroups: ["typography", "spacing", "background", "border", "flow"],
1195
+ bindings: [{ prop: "items", kind: "list", items: [{ prop: "label", kind: "template" }, { prop: "value", kind: "template" }] }],
1196
+ inspector: [
1197
+ {
1198
+ title: "Items",
1199
+ fields: [
1200
+ { kind: "keyValues", prop: "items", label: "Items", fullWidth: true },
1201
+ {
1202
+ kind: "segmented",
1203
+ prop: "layout",
1204
+ label: "Layout",
1205
+ options: [
1206
+ { value: "inline", label: "Inline" },
1207
+ { value: "stacked", label: "Stacked" },
1208
+ { value: "grid", label: "Grid" }
1209
+ ]
1210
+ },
1211
+ { kind: "number", prop: "columns", label: "Columns", min: 1, max: 4, when: (p) => p.layout === "grid" },
1212
+ { kind: "number", prop: "labelWidth", label: "Label width", min: 10, max: 80, unit: "%", when: (p) => p.layout === "inline" },
1213
+ { kind: "toggle", prop: "dividers", label: "Dividers" }
1214
+ ]
1215
+ }
1216
+ ],
1217
+ render: ({ h: h3, css, props, theme, text }) => {
1218
+ const items = Array.isArray(props.items) ? props.items : [];
1219
+ const divider = props.dividers ? `1px solid ${theme.colors.border}` : void 0;
1220
+ if (props.layout === "grid") {
1221
+ return h3(
1222
+ "div",
1223
+ { class: "bp-kv bp-kv--grid", style: cssText4({ ...css, display: "grid", "grid-template-columns": `repeat(${props.columns || 2}, minmax(0, 1fr))`, gap: "10px 16px" }) },
1224
+ items.map((it, i) => h3("div", { class: "bp-kv__item" }, h3("div", { class: "bp-kv__label", style: { color: theme.colors.mutedText, "font-size": "0.85em", "text-transform": "uppercase", "letter-spacing": "0.04em", "font-weight": "600" } }, text(`items.${i}.label`, it.label)), h3("div", { class: "bp-kv__value", style: { "font-weight": "500" } }, text(`items.${i}.value`, it.value || "\u2014"))))
1225
+ );
1226
+ }
1227
+ return h3(
1228
+ "div",
1229
+ { class: ["bp-kv", `bp-kv--${props.layout}`], style: cssText4(css) },
1230
+ items.map(
1231
+ (it, i) => props.layout === "inline" ? h3("div", { class: "bp-kv__row", style: { display: "flex", gap: "8px", padding: "5px 0", "border-bottom": i < items.length - 1 ? divider : void 0 } }, h3("div", { class: "bp-kv__label", style: { flex: `0 0 ${props.labelWidth}%`, color: theme.colors.mutedText } }, text(`items.${i}.label`, it.label)), h3("div", { class: "bp-kv__value", style: { flex: "1", "min-width": "0", "font-weight": "500" } }, text(`items.${i}.value`, it.value || "\u2014"))) : h3("div", { class: "bp-kv__row", style: { padding: "5px 0", "border-bottom": i < items.length - 1 ? divider : void 0 } }, h3("div", { class: "bp-kv__label", style: { color: theme.colors.mutedText, "font-size": "0.85em" } }, text(`items.${i}.label`, it.label)), h3("div", { class: "bp-kv__value", style: { "font-weight": "500" } }, text(`items.${i}.value`, it.value || "\u2014")))
1232
+ )
1233
+ );
1234
+ }
1235
+ });
1236
+
1237
+ // src/blocks/charts.ts
1238
+ import { defineBlock as defineBlock5, cssText as cssText5, resolveValue as resolveValue4, toNumber as toNumber2, toDisplayString as toDisplayString2, evaluate, Scope } from "@broadpaper/core";
1239
+
1240
+ // src/charts/chart-svg.ts
1241
+ import { scaleBand, scaleLinear, scalePoint } from "d3-scale";
1242
+ import { arc as d3arc, area as d3area, line as d3line, curveMonotoneX, curveLinear, pie as d3pie } from "d3-shape";
1243
+ import { h as h2 } from "@broadpaper/core";
1244
+ var FONT_SIZE = 9;
1245
+ var AXIS_COLOR = "#9AA1AB";
1246
+ function hit(opts, entry) {
1247
+ if (!opts.hits) return {};
1248
+ opts.hits.push(entry);
1249
+ return { "data-bp-hit": String(opts.hits.length - 1) };
1250
+ }
1251
+ function fmt(n) {
1252
+ return Math.round(n * 100) / 100 + "";
1253
+ }
1254
+ function legend(data, opts, y, width) {
1255
+ if (!opts.showLegend || data.series.length === 0) return { node: null, height: 0 };
1256
+ const items = opts.type === "pie" || opts.type === "donut" ? data.categories.map((c, i) => ({ label: c, color: colorAt(data, i) })) : data.series.map((s) => ({ label: s.label, color: s.color }));
1257
+ const perRow = Math.max(1, Math.floor(width / 120));
1258
+ const rows = Math.ceil(items.length / perRow);
1259
+ const node = h2(
1260
+ "g",
1261
+ { class: "bp-chart__legend", transform: `translate(0, ${y})` },
1262
+ items.map((it, i) => {
1263
+ const col = i % perRow;
1264
+ const row = Math.floor(i / perRow);
1265
+ const x = width / perRow * col;
1266
+ return h2("g", { transform: `translate(${fmt(x)}, ${row * 16})` }, h2("rect", { x: 0, y: 2, width: 10, height: 10, rx: 2, fill: it.color }), h2("text", { x: 15, y: 11, "font-size": FONT_SIZE + 1, "font-family": opts.fontFamily, fill: opts.theme.colors.text }, truncate(it.label, Math.floor(width / perRow / 6))));
1267
+ })
1268
+ );
1269
+ return { node, height: rows * 16 + 4 };
1270
+ }
1271
+ function truncate(s, n) {
1272
+ return s.length > n ? s.slice(0, Math.max(1, n - 1)) + "\u2026" : s;
1273
+ }
1274
+ function colorAt(data, i) {
1275
+ const s = data.series[0];
1276
+ const palette = data.series.map((x) => x.color);
1277
+ return palette[i % Math.max(1, palette.length)] ?? s?.color ?? "#888";
1278
+ }
1279
+ function renderChart(data, opts) {
1280
+ const { width, height } = opts;
1281
+ if (opts.type === "pie" || opts.type === "donut") return renderPie(data, opts);
1282
+ const horizontal = opts.type === "horizontalBar";
1283
+ const lg = legend(data, opts, 0, width);
1284
+ const marginTop = lg.height + 6;
1285
+ const marginRight = 8;
1286
+ const marginBottom = 24;
1287
+ const allValues = data.series.flatMap((s) => s.values.map((v) => v ?? 0));
1288
+ const stackedTotals = data.categories.map((_, i) => data.series.reduce((a, s) => a + Math.max(0, s.values[i] ?? 0), 0));
1289
+ const stackedNeg = data.categories.map((_, i) => data.series.reduce((a, s) => a + Math.min(0, s.values[i] ?? 0), 0));
1290
+ const maxV = Math.max(0, ...opts.stacked ? stackedTotals : allValues);
1291
+ const minV = Math.min(0, ...opts.stacked ? stackedNeg : allValues);
1292
+ const vScale = scaleLinear().domain([minV, maxV === minV ? maxV + 1 : maxV]).nice(5);
1293
+ const ticks = vScale.ticks(5);
1294
+ const tickLabels = ticks.map(opts.formatValue);
1295
+ const labelWidth = Math.max(...tickLabels.map((t) => t.length * FONT_SIZE * 0.6), 18);
1296
+ const marginLeft = horizontal ? Math.min(width * 0.35, Math.max(...data.categories.map((c) => c.length)) * FONT_SIZE * 0.6 + 8) : labelWidth + 8;
1297
+ const plotW = Math.max(10, width - marginLeft - marginRight);
1298
+ const plotH = Math.max(10, height - marginTop - marginBottom);
1299
+ const children = [];
1300
+ if (lg.node) children.push(lg.node);
1301
+ const g = (x, y, ...kids) => h2("g", { transform: `translate(${fmt(x)}, ${fmt(y)})` }, kids);
1302
+ if (horizontal) {
1303
+ vScale.range([0, plotW]);
1304
+ const band = scaleBand().domain(data.categories.map((_, i) => String(i))).range([0, plotH]).paddingInner(0.25).paddingOuter(0.1);
1305
+ const inner = scaleBand().domain(data.series.map((_, i) => String(i))).range([0, band.bandwidth()]).padding(0.1);
1306
+ const grid2 = opts.showGrid ? ticks.map((t) => h2("line", { x1: vScale(t), x2: vScale(t), y1: 0, y2: plotH, stroke: opts.theme.colors.border, "stroke-width": 1 })) : [];
1307
+ const axis = ticks.map((t) => h2("text", { x: vScale(t), y: plotH + 14, "text-anchor": "middle", "font-size": FONT_SIZE, "font-family": opts.fontFamily, fill: AXIS_COLOR }, opts.formatValue(t)));
1308
+ const cats = data.categories.map((c, i) => h2("text", { x: -6, y: (band(String(i)) ?? 0) + band.bandwidth() / 2 + 3, "text-anchor": "end", "font-size": FONT_SIZE, "font-family": opts.fontFamily, fill: opts.theme.colors.text }, truncate(c, Math.floor(marginLeft / (FONT_SIZE * 0.6)))));
1309
+ const bars = [];
1310
+ data.categories.forEach((_, ci) => {
1311
+ let posAcc = 0;
1312
+ let negAcc = 0;
1313
+ data.series.forEach((s, si) => {
1314
+ const v = s.values[ci] ?? 0;
1315
+ let x0, x1;
1316
+ if (opts.stacked) {
1317
+ if (v >= 0) {
1318
+ x0 = vScale(posAcc);
1319
+ x1 = vScale(posAcc + v);
1320
+ posAcc += v;
1321
+ } else {
1322
+ x0 = vScale(negAcc + v);
1323
+ x1 = vScale(negAcc);
1324
+ negAcc += v;
1325
+ }
1326
+ } else {
1327
+ x0 = vScale(Math.min(0, v));
1328
+ x1 = vScale(Math.max(0, v));
1329
+ }
1330
+ const y = (band(String(ci)) ?? 0) + (opts.stacked ? 0 : inner(String(si)) ?? 0);
1331
+ const bh = opts.stacked ? band.bandwidth() : inner.bandwidth();
1332
+ bars.push(h2("rect", { x: fmt(x0), y: fmt(y), width: fmt(Math.max(0, x1 - x0)), height: fmt(bh), fill: s.color, rx: 1.5, ...hit(opts, { categoryIndex: ci, category: data.categories[ci] ?? "", seriesIndex: si, series: s.label, value: s.values[ci] ?? null, formatted: opts.formatValue(v), color: s.color }) }));
1333
+ if (opts.showValues && !opts.stacked) bars.push(h2("text", { x: fmt(x1 + 4), y: fmt(y + bh / 2 + 3), "font-size": FONT_SIZE, "font-family": opts.fontFamily, fill: opts.theme.colors.text }, opts.formatValue(v)));
1334
+ });
1335
+ });
1336
+ children.push(g(marginLeft, marginTop, ...grid2, h2("line", { x1: vScale(0), x2: vScale(0), y1: 0, y2: plotH, stroke: AXIS_COLOR }), ...bars, ...axis, ...cats));
1337
+ return svg2(width, height, children);
1338
+ }
1339
+ vScale.range([plotH, 0]);
1340
+ const grid = opts.showGrid ? ticks.map((t) => h2("line", { x1: 0, x2: plotW, y1: fmt(vScale(t)), y2: fmt(vScale(t)), stroke: opts.theme.colors.border, "stroke-width": 1 })) : [];
1341
+ const yAxis = ticks.map((t) => h2("text", { x: -6, y: fmt(vScale(t) + 3), "text-anchor": "end", "font-size": FONT_SIZE, "font-family": opts.fontFamily, fill: AXIS_COLOR }, opts.formatValue(t)));
1342
+ const maxCatLabels = Math.max(1, Math.floor(plotW / 48));
1343
+ const every = Math.ceil(data.categories.length / maxCatLabels);
1344
+ if (opts.type === "bar") {
1345
+ const band = scaleBand().domain(data.categories.map((_, i) => String(i))).range([0, plotW]).paddingInner(0.25).paddingOuter(0.1);
1346
+ const inner = scaleBand().domain(data.series.map((_, i) => String(i))).range([0, band.bandwidth()]).padding(0.1);
1347
+ const bars = [];
1348
+ data.categories.forEach((_, ci) => {
1349
+ let posAcc = 0;
1350
+ let negAcc = 0;
1351
+ data.series.forEach((s, si) => {
1352
+ const v = s.values[ci] ?? 0;
1353
+ let y0, y1;
1354
+ if (opts.stacked) {
1355
+ if (v >= 0) {
1356
+ y1 = vScale(posAcc);
1357
+ y0 = vScale(posAcc + v);
1358
+ posAcc += v;
1359
+ } else {
1360
+ y1 = vScale(negAcc + v);
1361
+ y0 = vScale(negAcc);
1362
+ negAcc += v;
1363
+ }
1364
+ } else {
1365
+ y0 = vScale(Math.max(0, v));
1366
+ y1 = vScale(Math.min(0, v));
1367
+ }
1368
+ const x = (band(String(ci)) ?? 0) + (opts.stacked ? 0 : inner(String(si)) ?? 0);
1369
+ const bw = opts.stacked ? band.bandwidth() : inner.bandwidth();
1370
+ bars.push(h2("rect", { x: fmt(x), y: fmt(y0), width: fmt(bw), height: fmt(Math.max(0, y1 - y0)), fill: s.color, rx: 1.5, ...hit(opts, { categoryIndex: ci, category: data.categories[ci] ?? "", seriesIndex: si, series: s.label, value: s.values[ci] ?? null, formatted: opts.formatValue(v), color: s.color }) }));
1371
+ if (opts.showValues && !opts.stacked) bars.push(h2("text", { x: fmt(x + bw / 2), y: fmt(y0 - 3), "text-anchor": "middle", "font-size": FONT_SIZE - 1, "font-family": opts.fontFamily, fill: opts.theme.colors.text }, opts.formatValue(v)));
1372
+ });
1373
+ });
1374
+ const xAxis2 = data.categories.map((c, i) => i % every === 0 ? h2("text", { x: fmt((band(String(i)) ?? 0) + band.bandwidth() / 2), y: plotH + 14, "text-anchor": "middle", "font-size": FONT_SIZE, "font-family": opts.fontFamily, fill: AXIS_COLOR }, truncate(c, 10)) : null);
1375
+ children.push(g(marginLeft, marginTop, ...grid, ...bars, h2("line", { x1: 0, x2: plotW, y1: fmt(vScale(0)), y2: fmt(vScale(0)), stroke: AXIS_COLOR }), ...yAxis, ...xAxis2));
1376
+ return svg2(width, height, children);
1377
+ }
1378
+ const xScale = scalePoint().domain(data.categories.map((_, i) => String(i))).range([0, plotW]).padding(0.15);
1379
+ const curve = opts.smooth ? curveMonotoneX : curveLinear;
1380
+ const shapes = [];
1381
+ const stackBase = data.categories.map(() => 0);
1382
+ data.series.forEach((s, si) => {
1383
+ const pts = s.values.map((v, i) => ({ i, v: v ?? 0 }));
1384
+ const base = [...stackBase];
1385
+ if (opts.stacked) pts.forEach((p) => stackBase[p.i] = (stackBase[p.i] ?? 0) + p.v);
1386
+ const yOf = (p) => vScale(opts.stacked ? (base[p.i] ?? 0) + p.v : p.v);
1387
+ const y0Of = (p) => vScale(opts.stacked ? base[p.i] ?? 0 : 0);
1388
+ const l = d3line().x((p) => xScale(String(p.i)) ?? 0).y(yOf).curve(curve);
1389
+ if (opts.type === "area") {
1390
+ const a = d3area().x((p) => xScale(String(p.i)) ?? 0).y0(y0Of).y1(yOf).curve(curve);
1391
+ shapes.push(h2("path", { d: a(pts) ?? "", fill: s.color, "fill-opacity": 0.18 }));
1392
+ }
1393
+ shapes.push(h2("path", { d: l(pts) ?? "", fill: "none", stroke: s.color, "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" }));
1394
+ pts.forEach((p) => {
1395
+ const attrs = hit(opts, { categoryIndex: p.i, category: data.categories[p.i] ?? "", seriesIndex: si, series: s.label, value: s.values[p.i] ?? null, formatted: opts.formatValue(p.v), color: s.color });
1396
+ if (pts.length <= 40) shapes.push(h2("circle", { cx: fmt(xScale(String(p.i)) ?? 0), cy: fmt(yOf(p)), r: 2.5, fill: "#fff", stroke: s.color, "stroke-width": 1.5, ...attrs }));
1397
+ else if (opts.hits) opts.hits[opts.hits.length - 1].value = s.values[p.i] ?? null;
1398
+ });
1399
+ if (opts.showValues) pts.forEach((p) => shapes.push(h2("text", { x: fmt(xScale(String(p.i)) ?? 0), y: fmt(yOf(p) - 6), "text-anchor": "middle", "font-size": FONT_SIZE - 1, "font-family": opts.fontFamily, fill: opts.theme.colors.text }, opts.formatValue(p.v))));
1400
+ });
1401
+ const xAxis = data.categories.map((c, i) => i % every === 0 ? h2("text", { x: fmt(xScale(String(i)) ?? 0), y: plotH + 14, "text-anchor": "middle", "font-size": FONT_SIZE, "font-family": opts.fontFamily, fill: AXIS_COLOR }, truncate(c, 10)) : null);
1402
+ children.push(g(marginLeft, marginTop, ...grid, h2("line", { x1: 0, x2: plotW, y1: fmt(vScale(0)), y2: fmt(vScale(0)), stroke: AXIS_COLOR }), ...shapes, ...yAxis, ...xAxis));
1403
+ return svg2(width, height, children);
1404
+ }
1405
+ function renderPie(data, opts) {
1406
+ const { width, height } = opts;
1407
+ const series = data.series[0];
1408
+ const values = data.categories.map((_, i) => Math.max(0, series?.values[i] ?? 0));
1409
+ const total = values.reduce((a, b) => a + b, 0);
1410
+ const legendW = opts.showLegend ? Math.min(160, width * 0.45) : 0;
1411
+ const size = Math.min(height, width - legendW);
1412
+ const r = Math.max(10, size / 2 - 6);
1413
+ const cx = (width - legendW) / 2;
1414
+ const cy = height / 2;
1415
+ const inner = opts.type === "donut" ? r * 0.62 : 0;
1416
+ const pie = d3pie().sort(null).value((d) => d);
1417
+ const arcs = pie(values);
1418
+ const arcGen = d3arc().innerRadius(inner).outerRadius(r).padAngle(0.01).cornerRadius(2);
1419
+ const labelArc = d3arc().innerRadius((inner + r) / 2).outerRadius((inner + r) / 2);
1420
+ const slices = arcs.map((a, i) => {
1421
+ const frac = total > 0 ? values[i] / total : 0;
1422
+ const [lx, ly] = labelArc.centroid(a);
1423
+ const attrs = hit(opts, {
1424
+ categoryIndex: i,
1425
+ category: data.categories[i] ?? "",
1426
+ seriesIndex: 0,
1427
+ series: series?.label ?? "",
1428
+ value: series?.values[i] ?? null,
1429
+ formatted: opts.formatValue(values[i] ?? 0),
1430
+ color: colorAt(data, i),
1431
+ share: frac
1432
+ });
1433
+ return h2("g", null, h2("path", { d: arcGen(a) ?? "", fill: colorAt(data, i), ...attrs }), opts.showValues && frac >= 0.06 ? h2("text", { x: fmt(lx), y: fmt(ly + 3), "text-anchor": "middle", "font-size": FONT_SIZE, "font-family": opts.fontFamily, fill: "#fff", "font-weight": "600" }, opts.formatPercent(frac)) : null);
1434
+ });
1435
+ const children = [h2("g", { transform: `translate(${fmt(cx)}, ${fmt(cy)})` }, total === 0 ? h2("circle", { r, fill: opts.theme.colors.border }) : slices, opts.type === "donut" && opts.innerLabel ? h2("text", { "text-anchor": "middle", y: 4, "font-size": FONT_SIZE + 3, "font-weight": "700", "font-family": opts.fontFamily, fill: opts.theme.colors.text }, opts.innerLabel) : null)];
1436
+ if (opts.showLegend) {
1437
+ const items = data.categories.map((c, i) => ({ label: c, color: colorAt(data, i), frac: total > 0 ? values[i] / total : 0 }));
1438
+ const lineH = 15;
1439
+ const startY = Math.max(0, cy - items.length * lineH / 2);
1440
+ children.push(
1441
+ h2(
1442
+ "g",
1443
+ { transform: `translate(${fmt(width - legendW)}, ${fmt(startY)})` },
1444
+ items.map((it, i) => h2("g", { transform: `translate(0, ${i * lineH})` }, h2("rect", { x: 0, y: 2, width: 9, height: 9, rx: 2, fill: it.color }), h2("text", { x: 14, y: 10, "font-size": FONT_SIZE, "font-family": opts.fontFamily, fill: opts.theme.colors.text }, `${truncate(it.label, Math.floor(legendW / 6) - 6)} ${opts.formatPercent(it.frac)}`)))
1445
+ )
1446
+ );
1447
+ }
1448
+ return svg2(width, height, children);
1449
+ }
1450
+ function svg2(width, height, children) {
1451
+ return h2("svg", { xmlns: "http://www.w3.org/2000/svg", width: fmt(width), height: fmt(height), viewBox: `0 0 ${fmt(width)} ${fmt(height)}`, class: "bp-chart__svg", role: "img", style: { display: "block", overflow: "visible" } }, children);
1452
+ }
1453
+
1454
+ // src/blocks/charts.ts
1455
+ var chartBlock = defineBlock5({
1456
+ type: "chart",
1457
+ name: "Chart",
1458
+ description: "Bar, line, area, pie or donut chart bound to a list",
1459
+ category: "visualisation",
1460
+ icon: "bar-chart-3",
1461
+ keywords: ["graph", "bar", "line", "pie", "donut", "area", "visualisation"],
1462
+ defaultProps: () => ({
1463
+ chartType: "bar",
1464
+ source: "",
1465
+ alias: "item",
1466
+ label: "item.label",
1467
+ series: [{ id: "s1", label: "Value", value: "item.value" }],
1468
+ height: 220,
1469
+ title: "",
1470
+ showLegend: true,
1471
+ showValues: false,
1472
+ showGrid: true,
1473
+ stacked: false,
1474
+ smooth: true,
1475
+ valueFormat: "",
1476
+ percentDecimals: 0,
1477
+ innerLabel: "",
1478
+ limit: null
1479
+ }),
1480
+ defaultTextStyle: "body",
1481
+ defaultFlow: { keepTogether: true },
1482
+ split: "none",
1483
+ styleGroups: ["spacing", "background", "border", "flow"],
1484
+ bindings: [
1485
+ { prop: "source", kind: "collection", alias: "alias" },
1486
+ { prop: "label", kind: "value", perItem: true },
1487
+ { prop: "series", kind: "list", items: [{ prop: "value", kind: "value", perItem: true }, { prop: "label", kind: "template" }] },
1488
+ { prop: "title", kind: "template" },
1489
+ { prop: "innerLabel", kind: "template" }
1490
+ ],
1491
+ inspector: [
1492
+ {
1493
+ title: "Chart",
1494
+ fields: [
1495
+ {
1496
+ kind: "segmented",
1497
+ prop: "chartType",
1498
+ label: "Type",
1499
+ options: [
1500
+ { value: "bar", label: "Bar", icon: "bar-chart-3" },
1501
+ { value: "horizontalBar", label: "Horizontal", icon: "bar-chart-horizontal" },
1502
+ { value: "line", label: "Line", icon: "line-chart" },
1503
+ { value: "area", label: "Area", icon: "area-chart" },
1504
+ { value: "pie", label: "Pie", icon: "pie-chart" },
1505
+ { value: "donut", label: "Donut", icon: "circle-dot" }
1506
+ ]
1507
+ },
1508
+ { kind: "text", prop: "title", label: "Title" },
1509
+ { kind: "number", prop: "height", label: "Height", min: 80, max: 800, unit: "px" }
1510
+ ]
1511
+ },
1512
+ {
1513
+ title: "Data",
1514
+ fields: [
1515
+ { kind: "collection", prop: "source", label: "Data from", placeholder: "Pick a list" },
1516
+ { kind: "text", prop: "alias", label: "Item name", description: (p) => `The name each data point goes by, e.g. ${typeof p.alias === "string" && p.alias.trim() ? p.alias.trim() : "item"}.value`, allowTokens: false },
1517
+ { kind: "expression", prop: "label", label: "Category label", placeholder: "item.name" },
1518
+ { kind: "series", prop: "series", label: "Series", fullWidth: true },
1519
+ { kind: "number", prop: "limit", label: "Maximum items", min: 1, max: 1e3 },
1520
+ { kind: "text", prop: "valueFormat", label: "Value format", placeholder: 'e.g. currency:"GBP":0 or percent', allowTokens: false }
1521
+ ]
1522
+ },
1523
+ {
1524
+ title: "Options",
1525
+ fields: [
1526
+ { kind: "toggle", prop: "showLegend", label: "Legend" },
1527
+ { kind: "toggle", prop: "showValues", label: "Value labels" },
1528
+ { kind: "toggle", prop: "showGrid", label: "Grid lines", when: (p) => p.chartType !== "pie" && p.chartType !== "donut" },
1529
+ { kind: "toggle", prop: "stacked", label: "Stacked", when: (p) => ["bar", "horizontalBar", "line", "area"].includes(String(p.chartType)) },
1530
+ { kind: "toggle", prop: "smooth", label: "Smooth lines", when: (p) => p.chartType === "line" || p.chartType === "area" },
1531
+ { kind: "number", prop: "percentDecimals", label: "Percent decimals", min: 0, max: 3, when: (p) => p.chartType === "pie" || p.chartType === "donut" },
1532
+ { kind: "text", prop: "innerLabel", label: "Centre label", when: (p) => p.chartType === "donut" }
1533
+ ]
1534
+ }
1535
+ ],
1536
+ validate: ({ props }) => {
1537
+ const issues = [];
1538
+ if (!Array.isArray(props.series) || props.series.length === 0) issues.push({ severity: "warning", code: "empty-content", message: "Chart has no series", prop: "series" });
1539
+ return issues;
1540
+ },
1541
+ canvas: { label: (p) => p.title ? `Chart \xB7 ${p.title}` : "Chart" },
1542
+ resolve: ({ props, evaluateIn, theme, warn }) => {
1543
+ const alias = props.alias || "item";
1544
+ let items = Array.isArray(props.source) ? props.source : [];
1545
+ if (props.limit) items = items.slice(0, props.limit);
1546
+ const palette = theme.colors.chart ?? [theme.colors.primary, theme.colors.secondary, theme.colors.accent];
1547
+ const categories = items.map((it, index) => {
1548
+ try {
1549
+ return toDisplayString2(evaluateIn(props.label || `${alias}.label`, { [alias]: it, item: it, index: index + 1 }));
1550
+ } catch {
1551
+ return String(index + 1);
1552
+ }
1553
+ });
1554
+ const series = (Array.isArray(props.series) ? props.series : []).map((s, si) => ({
1555
+ label: s.label || `Series ${si + 1}`,
1556
+ color: s.color ? resolveValue4(s.color, theme, palette[si % palette.length]) : palette[si % palette.length],
1557
+ values: items.map((it, index) => {
1558
+ try {
1559
+ return toNumber2(evaluateIn(s.value, { [alias]: it, item: it, index: index + 1 }));
1560
+ } catch (e) {
1561
+ warn(`Series "${s.label}" failed: ${e.message}`);
1562
+ return null;
1563
+ }
1564
+ })
1565
+ }));
1566
+ if (props.chartType === "pie" || props.chartType === "donut") {
1567
+ const s0 = series[0];
1568
+ if (s0) {
1569
+ const colored = items.map((_, i) => ({ ...s0, color: palette[i % palette.length] }));
1570
+ return { __data: { categories, series: colored.length ? [{ ...s0 }, ...colored.slice(1)] : [] }, __palette: palette };
1571
+ }
1572
+ }
1573
+ return { __data: { categories, series }, __palette: palette };
1574
+ },
1575
+ render: ({ h: h3, css, props, theme, width, mode, node, format, text, style }) => {
1576
+ const p = props;
1577
+ const data = p.__data ?? { categories: [], series: [] };
1578
+ const isPie = p.chartType === "pie" || p.chartType === "donut";
1579
+ if (isPie && data.series.length) {
1580
+ const palette = p.__palette ?? theme.colors.chart ?? [];
1581
+ data.series = data.categories.map((_, i) => ({ ...data.series[0], color: palette[i % Math.max(1, palette.length)] ?? data.series[0].color }));
1582
+ }
1583
+ const formatValue = (n) => {
1584
+ const f = (p.valueFormat || "").trim();
1585
+ if (!f) return new Intl.NumberFormat(format.locale, { notation: Math.abs(n) >= 1e4 ? "compact" : "standard", maximumFractionDigits: 2 }).format(n);
1586
+ try {
1587
+ return toDisplayString2(evaluate(`(${n}) | ${f}`, { scope: new Scope({}), format }));
1588
+ } catch {
1589
+ return String(n);
1590
+ }
1591
+ };
1592
+ const formatPercent = (fr) => new Intl.NumberFormat(format.locale, { style: "percent", maximumFractionDigits: p.percentDecimals ?? 0, minimumFractionDigits: p.percentDecimals ?? 0 }).format(fr);
1593
+ const pad = style.padding;
1594
+ const innerWidth = Math.max(40, width - pad.left - pad.right - (style.border ? style.border.width * 2 : 0));
1595
+ const b = node.bindings.source;
1596
+ const hits = [];
1597
+ const svg3 = data.categories.length ? renderChart(data, { type: p.chartType, width: innerWidth, height: p.height, theme, fontFamily: style.fontFamily, showLegend: p.showLegend, showValues: p.showValues, showGrid: p.showGrid, stacked: p.stacked, smooth: p.smooth, formatValue, formatPercent, innerLabel: typeof p.innerLabel === "string" && p.innerLabel ? p.innerLabel : void 0, hits }) : h3("div", { class: "bp-chart__empty", style: { height: `${p.height}px`, display: "flex", "align-items": "center", "justify-content": "center", color: theme.colors.mutedText, border: `1px dashed ${theme.colors.border}`, "border-radius": `${theme.radius ?? 6}px` } }, mode === "design" ? b ? "No data in the list" : "Choose a list to chart" : "");
1598
+ return h3(
1599
+ "div",
1600
+ {
1601
+ class: ["bp-chart", `bp-chart--${p.chartType}`],
1602
+ style: cssText5(css),
1603
+ ...hits.length ? { "data-bp-chart": JSON.stringify({ type: p.chartType, title: typeof p.title === "string" ? p.title : "", hits }) } : {}
1604
+ },
1605
+ p.title ? h3("div", { class: "bp-chart__title", style: { "font-family": theme.typography.headingFont, "font-weight": "600", "margin-bottom": "6px", color: theme.colors.text } }, text("title")) : null,
1606
+ mode === "design" && b ? h3("div", { class: "bp-chart__badge", contenteditable: "false" }, b.label) : null,
1607
+ svg3
1608
+ );
1609
+ }
1610
+ });
1611
+ var progressBlock = defineBlock5({
1612
+ type: "progress",
1613
+ name: "Progress / gauge",
1614
+ description: "A value against a maximum, as a bar or gauge",
1615
+ category: "visualisation",
1616
+ icon: "gauge",
1617
+ keywords: ["score", "meter", "gauge", "progress", "percentage"],
1618
+ defaultProps: () => ({ value: "", max: 100, label: "Score", caption: "", variant: "bar", color: "$colors.primary", trackColor: "$colors.border", showValue: true, format: "percent", thresholds: [], height: 10 }),
1619
+ defaultTextStyle: "body",
1620
+ defaultFlow: { keepTogether: true },
1621
+ split: "none",
1622
+ styleGroups: ["typography", "spacing", "background", "border", "flow"],
1623
+ inspector: [
1624
+ {
1625
+ title: "Value",
1626
+ fields: [
1627
+ { kind: "binding", prop: "value", label: "Value", valueType: ["number", "percent", "currency"] },
1628
+ { kind: "number", prop: "max", label: "Maximum", min: 1e-4, max: 1e12 },
1629
+ { kind: "text", prop: "label", label: "Label" },
1630
+ { kind: "text", prop: "caption", label: "Caption" }
1631
+ ]
1632
+ },
1633
+ {
1634
+ title: "Appearance",
1635
+ fields: [
1636
+ {
1637
+ kind: "segmented",
1638
+ prop: "variant",
1639
+ label: "Style",
1640
+ options: [
1641
+ { value: "bar", label: "Bar" },
1642
+ { value: "gauge", label: "Gauge" }
1643
+ ]
1644
+ },
1645
+ {
1646
+ kind: "segmented",
1647
+ prop: "format",
1648
+ label: "Show as",
1649
+ options: [
1650
+ { value: "percent", label: "%" },
1651
+ { value: "value", label: "Value" },
1652
+ { value: "fraction", label: "x / max" }
1653
+ ]
1654
+ },
1655
+ { kind: "toggle", prop: "showValue", label: "Show value" },
1656
+ { kind: "color", prop: "color", label: "Colour" },
1657
+ { kind: "color", prop: "trackColor", label: "Track" },
1658
+ { kind: "number", prop: "height", label: "Bar height", min: 2, max: 40, unit: "px", when: (p) => p.variant === "bar" },
1659
+ { kind: "list", prop: "thresholds", label: "Colour by range", itemLabel: "Range", fullWidth: true, fields: [
1660
+ { kind: "number", prop: "upTo", label: "Up to" },
1661
+ { kind: "color", prop: "color", label: "Colour" }
1662
+ ] }
1663
+ ]
1664
+ }
1665
+ ],
1666
+ render: ({ h: h3, css, props, theme, text, mode, node, format }) => {
1667
+ const raw = toNumber2(props.value);
1668
+ const max = Number(props.max) || 100;
1669
+ const value = raw ?? 0;
1670
+ const frac = Math.max(0, Math.min(1, max > 0 ? value / max : 0));
1671
+ let color = resolveValue4(props.color, theme, theme.colors.primary);
1672
+ const sorted = [...props.thresholds ?? []].sort((a, b2) => a.upTo - b2.upTo);
1673
+ for (const t of sorted) {
1674
+ if (value <= t.upTo) {
1675
+ color = resolveValue4(t.color, theme, color);
1676
+ break;
1677
+ }
1678
+ }
1679
+ const track = resolveValue4(props.trackColor, theme, theme.colors.border);
1680
+ const b = node.bindings.value;
1681
+ const display = props.format === "percent" ? new Intl.NumberFormat(format.locale, { style: "percent", maximumFractionDigits: 0 }).format(frac) : props.format === "fraction" ? `${new Intl.NumberFormat(format.locale).format(value)} / ${new Intl.NumberFormat(format.locale).format(max)}` : new Intl.NumberFormat(format.locale, { maximumFractionDigits: 2 }).format(value);
1682
+ const header = h3("div", { style: { display: "flex", "justify-content": "space-between", "align-items": "baseline", gap: "8px", "margin-bottom": "4px" } }, h3("span", { style: { "font-weight": "600" } }, text("label")), props.showValue ? h3("span", { class: ["bp-progress__value", mode === "design" && b ? "bp-cell--bound" : ""], title: b?.expr, style: { color: theme.colors.mutedText, "font-variant-numeric": "tabular-nums" } }, display) : null);
1683
+ if (props.variant === "gauge") {
1684
+ const w = 160;
1685
+ const r = 62;
1686
+ const cx = w / 2;
1687
+ const cy = 70;
1688
+ const arcPath = (f) => {
1689
+ const a0 = Math.PI;
1690
+ const sweep = Math.PI * f;
1691
+ const a1 = a0 + sweep;
1692
+ const x0 = cx + r * Math.cos(a0);
1693
+ const y0 = cy + r * Math.sin(a0);
1694
+ const x1 = cx + r * Math.cos(a1);
1695
+ const y1 = cy + r * Math.sin(a1);
1696
+ const large = sweep > Math.PI ? 1 : 0;
1697
+ return `M ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1}`;
1698
+ };
1699
+ return h3(
1700
+ "div",
1701
+ { class: "bp-progress bp-progress--gauge", style: cssText5({ ...css, display: "flex", "flex-direction": "column", "align-items": "center" }) },
1702
+ h3("svg", { width: w, height: 80, viewBox: `0 0 ${w} 80`, style: { display: "block" } }, h3("path", { d: arcPath(1), fill: "none", stroke: track, "stroke-width": 12, "stroke-linecap": "round" }), frac > 0 ? h3("path", { d: arcPath(frac), fill: "none", stroke: color, "stroke-width": 12, "stroke-linecap": "round" }) : null, h3("text", { x: cx, y: cy - 4, "text-anchor": "middle", "font-size": 20, "font-weight": "700", "font-family": theme.typography.headingFont, fill: theme.colors.text }, display)),
1703
+ h3("div", { style: { "font-weight": "600", "margin-top": "2px" } }, text("label")),
1704
+ props.caption ? h3("div", { style: { color: theme.colors.mutedText, "font-size": "0.9em" } }, text("caption")) : null
1705
+ );
1706
+ }
1707
+ return h3(
1708
+ "div",
1709
+ { class: "bp-progress bp-progress--bar", style: cssText5(css) },
1710
+ header,
1711
+ // The fill's height is stated in pixels rather than as `100%`: a
1712
+ // percentage height needs a parent the layout engine considers to have a
1713
+ // definite one, and the PDF engine does not, so the fill came out a
1714
+ // hairline in the file while looking right in the browser. The track's
1715
+ // own height is the number to use, and it is right here.
1716
+ h3("div", { class: "bp-progress__track", style: { height: `${props.height}px`, background: track, "border-radius": `${props.height}px`, overflow: "hidden" } }, h3("div", { class: "bp-progress__fill", style: { width: `${frac * 100}%`, height: `${props.height}px`, background: color, "border-radius": `${props.height}px` } })),
1717
+ props.caption ? h3("div", { style: { color: theme.colors.mutedText, "font-size": "0.9em", "margin-top": "4px" } }, text("caption")) : null
1718
+ );
1719
+ }
1720
+ });
1721
+
1722
+ // src/index.ts
1723
+ var builtInBlocks = [
1724
+ // structure
1725
+ sectionBlock,
1726
+ rowBlock,
1727
+ columnBlock,
1728
+ repeaterItemBlock,
1729
+ // content
1730
+ headingBlock,
1731
+ textBlock,
1732
+ imageBlock,
1733
+ logoBlock,
1734
+ dividerBlock,
1735
+ spacerBlock,
1736
+ buttonBlock,
1737
+ calloutBlock,
1738
+ quoteBlock,
1739
+ signatureBlock,
1740
+ dateBlock,
1741
+ pageNumberBlock,
1742
+ timestampBlock,
1743
+ // layout
1744
+ containerBlock,
1745
+ cardBlock,
1746
+ pageBreakBlock,
1747
+ keepTogetherBlock,
1748
+ // data
1749
+ fieldBlock,
1750
+ kpiBlock,
1751
+ keyValueListBlock,
1752
+ conditionalBlock,
1753
+ repeaterBlock,
1754
+ tableBlock,
1755
+ // visualisation
1756
+ chartBlock,
1757
+ progressBlock
1758
+ ];
1759
+ function createRegistry(custom = [], opts = {}) {
1760
+ return new BlockRegistry([...builtInBlocks, ...custom], opts);
1761
+ }
1762
+ export {
1763
+ builtInBlocks,
1764
+ buttonBlock,
1765
+ calloutBlock,
1766
+ cardBlock,
1767
+ chartBlock,
1768
+ colorAt,
1769
+ columnBlock,
1770
+ conditionalBlock,
1771
+ containerBlock,
1772
+ createRegistry,
1773
+ dateBlock,
1774
+ dividerBlock,
1775
+ fieldBlock,
1776
+ headingBlock,
1777
+ imageBlock,
1778
+ keepTogetherBlock,
1779
+ keyValueListBlock,
1780
+ kpiBlock,
1781
+ logoBlock,
1782
+ mixWithWhite,
1783
+ pageBreakBlock,
1784
+ pageNumberBlock,
1785
+ progressBlock,
1786
+ quoteBlock,
1787
+ renderChart,
1788
+ repeaterBlock,
1789
+ repeaterItemBlock,
1790
+ rowBlock,
1791
+ sectionBlock,
1792
+ signatureBlock,
1793
+ spacerBlock,
1794
+ stack,
1795
+ tableBlock,
1796
+ textBlock,
1797
+ timestampBlock
1798
+ };
1799
+ //# sourceMappingURL=index.js.map