@yaebal/preview 0.0.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/lib/index.js ADDED
@@ -0,0 +1,591 @@
1
+ /**
2
+ * @yaebal/preview — render a telegram-style chat from plain objects to an SVG string.
3
+ * zero runtime, no `<foreignObject>` (so it rasterizes and survives github's SVG
4
+ * sanitizer). media fields use the real `@yaebal/types` shapes, so you can hand it a
5
+ * `ctx.message` almost verbatim; add `src` to show real pixels (a `file_id` has none).
6
+ *
7
+ * import { renderChat } from "@yaebal/preview";
8
+ * import { md } from "@yaebal/fmt";
9
+ *
10
+ * renderChat([
11
+ * { from: "user", text: "/start", time: "23:33", status: "read" },
12
+ * { from: "bot", name: "yaebal", ...md`hello, **unknown** person`, time: "23:33" },
13
+ * { from: "bot", name: "yaebal", photo: [], src: "cat.jpg", caption: "a cat" },
14
+ * { from: "bot", name: "yaebal", voice: { duration: 7 } },
15
+ * { from: "bot", name: "yaebal", poll: { question: "tabs?", options: [...] } },
16
+ * ], { theme: "light" });
17
+ */
18
+ const PALETTES = {
19
+ light: {
20
+ bg0: "#d8e8c7",
21
+ bg1: "#c2dcae",
22
+ out: "#e4f7d2",
23
+ outText: "#0c1f0c",
24
+ in: "#ffffff",
25
+ inText: "#0b1320",
26
+ meta: "#8aa18c",
27
+ tick: "#54b757",
28
+ link: "#3a8fd6",
29
+ name: "#3a8fd6",
30
+ code: "#bb4d3a",
31
+ media: "#c4d0d9",
32
+ media2: "#aebccb",
33
+ bar: "#54a0e0",
34
+ barTrack: "rgba(0,0,0,0.08)",
35
+ scrim: "rgba(0,0,0,0.4)",
36
+ button: "#ffffff",
37
+ buttonText: "#3a8fd6",
38
+ buttonStroke: "rgba(0,0,0,0.06)",
39
+ cardLine: "rgba(0,0,0,0.08)",
40
+ },
41
+ dark: {
42
+ bg0: "#0e1621",
43
+ bg1: "#1a2733",
44
+ out: "#2b5278",
45
+ outText: "#ffffff",
46
+ in: "#182533",
47
+ inText: "#ffffff",
48
+ meta: "#7d8e9e",
49
+ tick: "#64b5f6",
50
+ link: "#6ab3f3",
51
+ name: "#6ab3f3",
52
+ code: "#e2a07a",
53
+ media: "#2a3744",
54
+ media2: "#1f2a35",
55
+ bar: "#5fa8dd",
56
+ barTrack: "rgba(255,255,255,0.1)",
57
+ scrim: "rgba(0,0,0,0.45)",
58
+ button: "#17212b",
59
+ buttonText: "#7da6c9",
60
+ buttonStroke: "rgba(255,255,255,0.06)",
61
+ cardLine: "rgba(255,255,255,0.08)",
62
+ },
63
+ };
64
+ const AVATAR_COLORS = ["#e17076", "#7bc862", "#65aadd", "#a695e7", "#ee7aae", "#6ec9cb", "#faa774"];
65
+ const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif";
66
+ const MONO = "'SF Mono','JetBrains Mono','Roboto Mono',Consolas,monospace";
67
+ const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
68
+ const esc = (s) => s.replace(/[&<>"]/g, (c) => ESC[c] ?? c);
69
+ const hash = (s) => {
70
+ let h = 0;
71
+ for (let i = 0; i < s.length; i++)
72
+ h = (h * 31 + s.charCodeAt(i)) >>> 0;
73
+ return h;
74
+ };
75
+ const round = (n) => Math.round(n * 10) / 10;
76
+ const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
77
+ const dur = (s) => {
78
+ const n = Math.max(0, Math.round(s ?? 0));
79
+ return `${Math.floor(n / 60)}:${String(n % 60).padStart(2, "0")}`;
80
+ };
81
+ // layout constants
82
+ const FS = 14;
83
+ const LH = 19;
84
+ const ASC = 13;
85
+ const PADX = 11;
86
+ const PADY = 7;
87
+ const charW = FS * 0.54;
88
+ const monoW = FS * 0.6;
89
+ const LINKY = new Set([
90
+ "text_link",
91
+ "url",
92
+ "mention",
93
+ "text_mention",
94
+ "hashtag",
95
+ "cashtag",
96
+ "bot_command",
97
+ "email",
98
+ "phone_number",
99
+ ]);
100
+ function styleFor(entities, i) {
101
+ const s = {};
102
+ for (const e of entities) {
103
+ if (i < e.offset || i >= e.offset + e.length)
104
+ continue;
105
+ if (e.type === "bold")
106
+ s.b = true;
107
+ else if (e.type === "italic")
108
+ s.i = true;
109
+ else if (e.type === "underline")
110
+ s.u = true;
111
+ else if (e.type === "strikethrough")
112
+ s.st = true;
113
+ else if (e.type === "code" || e.type === "pre")
114
+ s.mono = true;
115
+ else if (e.type === "spoiler")
116
+ s.spoiler = true;
117
+ else if (LINKY.has(e.type))
118
+ s.link = true;
119
+ }
120
+ return s;
121
+ }
122
+ const runKey = (r, space) => `${space ? 1 : 0}${r.b ? "b" : ""}${r.i ? "i" : ""}${r.u ? "u" : ""}${r.st ? "s" : ""}${r.mono ? "m" : ""}${r.spoiler ? "x" : ""}${r.link ? "l" : ""}`;
123
+ function tokenize(text, entities) {
124
+ const paras = [];
125
+ let runs = [];
126
+ let cur = null;
127
+ let curKey = "";
128
+ for (let i = 0; i < text.length; i++) {
129
+ const ch = text[i] ?? "";
130
+ if (ch === "\n") {
131
+ if (cur)
132
+ runs.push(cur);
133
+ cur = null;
134
+ curKey = "";
135
+ paras.push(runs);
136
+ runs = [];
137
+ continue;
138
+ }
139
+ const space = ch === " ";
140
+ const st = styleFor(entities, i);
141
+ const key = runKey(st, space);
142
+ if (cur && key === curKey)
143
+ cur.text += ch;
144
+ else {
145
+ if (cur)
146
+ runs.push(cur);
147
+ cur = { ...st, text: ch, space };
148
+ curKey = key;
149
+ }
150
+ }
151
+ if (cur)
152
+ runs.push(cur);
153
+ paras.push(runs);
154
+ return paras;
155
+ }
156
+ const runW = (r) => r.text.length * (r.mono ? monoW : charW);
157
+ /** wrap styled runs into lines that fit `maxW` px. */
158
+ function wrapRuns(paras, maxW) {
159
+ const lines = [];
160
+ for (const para of paras) {
161
+ // hard-break oversized non-space runs first
162
+ const toks = [];
163
+ for (const r of para) {
164
+ if (r.space) {
165
+ toks.push(r);
166
+ continue;
167
+ }
168
+ let t = r.text;
169
+ const cw = r.mono ? monoW : charW;
170
+ const max = Math.max(1, Math.floor(maxW / cw));
171
+ while (t.length > max) {
172
+ toks.push({ ...r, text: t.slice(0, max), space: false });
173
+ t = t.slice(max);
174
+ }
175
+ toks.push({ ...r, text: t, space: false });
176
+ }
177
+ let line = [];
178
+ let w = 0;
179
+ for (const t of toks) {
180
+ const tw = runW(t);
181
+ if (!t.space && w + tw > maxW && line.length) {
182
+ while (line.length && line[line.length - 1]?.space)
183
+ line.pop();
184
+ lines.push(line);
185
+ line = [t];
186
+ w = tw;
187
+ }
188
+ else {
189
+ line.push(t);
190
+ w += tw;
191
+ }
192
+ }
193
+ lines.push(line);
194
+ }
195
+ return lines;
196
+ }
197
+ function layoutText(text, entities, maxW, base, p) {
198
+ const lines = wrapRuns(tokenize(text, entities), maxW);
199
+ let w = 0;
200
+ for (const ln of lines)
201
+ w = Math.max(w, ln.reduce((a, r) => a + runW(r), 0));
202
+ const h = lines.length * LH;
203
+ return {
204
+ w: Math.ceil(w),
205
+ h,
206
+ render: (x, y) => {
207
+ let out = "";
208
+ lines.forEach((ln, li) => {
209
+ // merge adjacent same-style runs so each styled span is one tspan
210
+ const sig = (r) => `${r.b ? 1 : 0}${r.i ? 1 : 0}${r.u ? 1 : 0}${r.st ? 1 : 0}${r.mono ? 1 : 0}${r.spoiler ? 1 : 0}${r.link ? 1 : 0}`;
211
+ const merged = [];
212
+ for (const r of ln) {
213
+ const last = merged[merged.length - 1];
214
+ if (last && sig(last) === sig(r))
215
+ last.text += r.text;
216
+ else
217
+ merged.push({ ...r });
218
+ }
219
+ let spans = "";
220
+ for (const r of merged) {
221
+ const shown = r.spoiler ? "█".repeat(r.text.length) : esc(r.text);
222
+ const a = [];
223
+ const fill = r.spoiler ? p.meta : r.link ? p.link : r.mono ? p.code : base;
224
+ a.push(`fill="${fill}"`);
225
+ if (r.b)
226
+ a.push(`font-weight="600"`);
227
+ if (r.i)
228
+ a.push(`font-style="italic"`);
229
+ const deco = [r.u ? "underline" : "", r.st ? "line-through" : ""]
230
+ .filter(Boolean)
231
+ .join(" ");
232
+ if (deco)
233
+ a.push(`text-decoration="${deco}"`);
234
+ if (r.mono)
235
+ a.push(`font-family="${MONO}"`);
236
+ spans += `<tspan ${a.join(" ")}>${shown}</tspan>`;
237
+ }
238
+ out += `<text x="${round(x)}" y="${round(y + li * LH + ASC)}" font-size="${FS}" font-family="${FONT}">${spans}</text>`;
239
+ });
240
+ return out;
241
+ },
242
+ };
243
+ }
244
+ function rr(x, y, w, h, tl, tr, br, bl) {
245
+ return `M${round(x + tl)},${round(y)}h${round(w - tl - tr)}a${tr},${tr} 0 0 1 ${tr},${tr}v${round(h - tr - br)}a${br},${br} 0 0 1 ${-br},${br}h${round(-(w - br - bl))}a${bl},${bl} 0 0 1 ${-bl},${-bl}v${round(-(h - bl - tl))}a${tl},${tl} 0 0 1 ${tl},${-tl}z`;
246
+ }
247
+ function ticks(x, y, status, color) {
248
+ // telegram proportions: short down-stroke into a vertex, longer up-stroke; the
249
+ // double tick offsets the second check by ~half its width so both read clearly.
250
+ const one = (ox) => `<path d="M${round(ox)},${round(y)} l2.3,2.6 l5.6,-6.4" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`;
251
+ return status === "sent" ? one(x + 3) : one(x) + one(x + 4.6);
252
+ }
253
+ /** time + ticks at a right edge; `scrim` draws a dark pill (over media). */
254
+ function meta(rightX, bottomY, time, status, out, p, scrim) {
255
+ if (!time && !status)
256
+ return "";
257
+ const tickW = out && status ? 15 : 0;
258
+ const tw = time.length * (11 * 0.55);
259
+ let s = "";
260
+ const color = scrim ? "#fff" : p.meta;
261
+ if (scrim) {
262
+ const pw = tw + tickW + 16;
263
+ s += `<rect x="${round(rightX - pw)}" y="${round(bottomY - 17)}" width="${round(pw)}" height="18" rx="9" fill="${p.scrim}"/>`;
264
+ }
265
+ if (time)
266
+ s += `<text x="${round(rightX - tickW)}" y="${round(bottomY - 4)}" font-size="11" fill="${color}" text-anchor="end" font-family="${FONT}">${esc(time)}</text>`;
267
+ if (out && status)
268
+ s += ticks(round(rightX - 12), round(bottomY - 8), status, scrim ? "#fff" : p.tick);
269
+ return s;
270
+ }
271
+ const MW = 252; // picture media width
272
+ function picture(src, natW, natH, p, clip, spoiler, overlay) {
273
+ const ar = natW && natH ? natH / natW : 0.62;
274
+ const w = MW;
275
+ const h = clamp(Math.round(w * ar), 120, 320);
276
+ return {
277
+ w,
278
+ h,
279
+ render: (x, y) => {
280
+ let s = `<clipPath id="${clip}"><rect x="${round(x)}" y="${round(y)}" width="${w}" height="${h}" rx="14"/></clipPath><g clip-path="url(#${clip})">`;
281
+ if (src && !spoiler)
282
+ s += `<image href="${esc(src)}" x="${round(x)}" y="${round(y)}" width="${w}" height="${h}" preserveAspectRatio="xMidYMid slice"/>`;
283
+ else {
284
+ // no real pixels (a file_id has none) — paint a vivid, deterministic
285
+ // "photo" so media never reads as an empty grey box.
286
+ const seed = hash(clip);
287
+ const h1 = seed % 360;
288
+ const h2 = (h1 + 35 + (seed % 70)) % 360;
289
+ s += `<linearGradient id="${clip}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${h1} 60% 60%)"/><stop offset="1" stop-color="hsl(${h2} 64% 42%)"/></linearGradient>`;
290
+ s += `<rect x="${round(x)}" y="${round(y)}" width="${w}" height="${h}" fill="url(#${clip}g)"/>`;
291
+ const cx = x + w / 2;
292
+ const cy = y + h / 2;
293
+ if (spoiler)
294
+ s += `<text x="${round(cx)}" y="${round(cy + 8)}" font-size="24" text-anchor="middle">👁️‍🗨️</text>`;
295
+ else
296
+ s += `<path d="M${round(cx - 15)},${round(cy + 9)} l10,-13 l7,8 l4,-5 l8,11 z" fill="rgba(255,255,255,0.78)"/><circle cx="${round(cx + 9)}" cy="${round(cy - 8)}" r="4.5" fill="rgba(255,255,255,0.78)"/>`;
297
+ }
298
+ s += "</g>";
299
+ s += overlay(x, y, w, h);
300
+ return s;
301
+ },
302
+ };
303
+ }
304
+ const playGlyph = (cx, cy) => `<circle cx="${round(cx)}" cy="${round(cy)}" r="22" fill="rgba(0,0,0,0.45)"/><path d="M${round(cx - 6)},${round(cy - 9)} l15,9 l-15,9 z" fill="#fff"/>`;
305
+ const badge = (x, y, label) => `<rect x="${round(x + 8)}" y="${round(y + 8)}" width="${label.length * 7 + 12}" height="18" rx="9" fill="rgba(0,0,0,0.45)"/><text x="${round(x + 14)}" y="${round(y + 21)}" font-size="11" font-weight="600" fill="#fff" font-family="${FONT}">${esc(label)}</text>`;
306
+ const durPill = (x, bottomY, text) => `<rect x="${round(x + 8)}" y="${round(bottomY - 26)}" width="${text.length * 6.5 + 12}" height="18" rx="9" fill="rgba(0,0,0,0.45)"/><text x="${round(x + 14)}" y="${round(bottomY - 13)}" font-size="11" font-weight="500" fill="#fff" font-family="${FONT}">${esc(text)}</text>`;
307
+ /** a 252-wide single-row card (audio/voice/document/contact). */
308
+ function card(p, base, h, draw) {
309
+ return { w: MW, h, render: (x, y) => draw(x, y) };
310
+ }
311
+ function waveform(x, y, color, track) {
312
+ const heights = [5, 9, 14, 8, 16, 11, 6, 13, 9, 17, 7, 12, 10, 15, 6, 11, 8, 5];
313
+ let s = "";
314
+ heights.forEach((hh, i) => {
315
+ const bx = x + i * 5;
316
+ const col = i < 7 ? color : track;
317
+ s += `<rect x="${round(bx)}" y="${round(y - hh / 2)}" width="2.4" height="${hh}" rx="1.2" fill="${col}"/>`;
318
+ });
319
+ return s;
320
+ }
321
+ function mapTile(x, y, w, h, p, clip) {
322
+ let s = `<clipPath id="${clip}"><rect x="${round(x)}" y="${round(y)}" width="${w}" height="${h}" rx="14"/></clipPath><g clip-path="url(#${clip})">`;
323
+ s += `<rect x="${round(x)}" y="${round(y)}" width="${w}" height="${h}" fill="${p.media}"/>`;
324
+ for (let i = 1; i < 5; i++)
325
+ s += `<line x1="${round(x)}" y1="${round(y + (i * h) / 5)}" x2="${round(x + w)}" y2="${round(y + (i * h) / 5)}" stroke="${p.media2}" stroke-width="6"/>`;
326
+ s += `<line x1="${round(x + w * 0.55)}" y1="${round(y)}" x2="${round(x + w * 0.45)}" y2="${round(y + h)}" stroke="${p.media2}" stroke-width="8"/>`;
327
+ const px = x + w / 2;
328
+ const py = y + h / 2;
329
+ s += `<path d="M${round(px)},${round(py - 14)} a8,8 0 0 1 8,8 c0,6 -8,14 -8,14 c0,0 -8,-8 -8,-14 a8,8 0 0 1 8,-8 z" fill="#e74c3c"/><circle cx="${round(px)}" cy="${round(py - 6)}" r="3" fill="#fff"/></g>`;
330
+ return s;
331
+ }
332
+ /** render a telegram-style chat to an SVG string. */
333
+ export function renderChat(messages, options = {}) {
334
+ const W = Math.max(240, Math.round(options.width ?? 380));
335
+ const p = PALETTES[options.theme === "dark" ? "dark" : "light"];
336
+ const PAD = 14;
337
+ const GAP = 10;
338
+ const AV = 30;
339
+ const AVGAP = 8;
340
+ const RAD = 16;
341
+ const TAIL = 5;
342
+ const BTN_H = 34;
343
+ const BTN_GAP = 4;
344
+ const maxBubbleW = Math.round(W * 0.76);
345
+ const body = [];
346
+ let clipN = 0;
347
+ let y = PAD;
348
+ for (const m of messages) {
349
+ const out = m.from === "user";
350
+ const indent = out ? 0 : AV + AVGAP;
351
+ const base = out ? p.outText : p.inText;
352
+ const time = m.time ?? "";
353
+ // sticker = standalone, no bubble
354
+ if (m.sticker) {
355
+ const sz = 116;
356
+ const sx = out ? W - PAD - sz : PAD + indent;
357
+ if (m.src) {
358
+ const c = `c${clipN++}`;
359
+ body.push(`<clipPath id="${c}"><rect x="${round(sx)}" y="${round(y)}" width="${sz}" height="${sz}" rx="10"/></clipPath><image href="${esc(m.src)}" x="${round(sx)}" y="${round(y)}" width="${sz}" height="${sz}" clip-path="url(#${c})" preserveAspectRatio="xMidYMid meet"/>`);
360
+ }
361
+ else {
362
+ body.push(`<text x="${round(sx + sz / 2)}" y="${round(y + sz / 2 + 30)}" font-size="84" text-anchor="middle">${esc(m.sticker.emoji ?? "🎈")}</text>`);
363
+ }
364
+ body.push(meta(out ? W - PAD : sx + sz, y + sz, time, m.status, out, p, false));
365
+ y += sz + GAP;
366
+ continue;
367
+ }
368
+ // build content blocks
369
+ const blocks = [];
370
+ const pic = (natW, natH, ov) => picture(m.src, natW, natH, p, `c${clipN++}`, !!m.spoiler, ov ?? (() => ""));
371
+ if (m.photo)
372
+ blocks.push({ block: pic(m.photo.at(-1)?.width, m.photo.at(-1)?.height), bleed: true });
373
+ else if (m.animation)
374
+ blocks.push({
375
+ block: pic(m.animation.width, m.animation.height, (x, y2, w) => badge(x, y2, "GIF")),
376
+ bleed: true,
377
+ });
378
+ else if (m.video)
379
+ blocks.push({
380
+ block: pic(m.video.width, m.video.height, (x, y2, w, h) => playGlyph(x + w / 2, y2 + h / 2) + durPill(x, y2 + h, dur(m.video?.duration))),
381
+ bleed: true,
382
+ });
383
+ else if (m.location) {
384
+ const h = 132;
385
+ blocks.push({
386
+ block: { w: MW, h, render: (x, y2) => mapTile(x, y2, MW, h, p, `c${clipN++}`) },
387
+ bleed: true,
388
+ });
389
+ }
390
+ else if (m.venue) {
391
+ const mh = 120;
392
+ const v = m.venue;
393
+ blocks.push({
394
+ block: {
395
+ w: MW,
396
+ h: mh + 44,
397
+ render: (x, y2) => {
398
+ let s = mapTile(x, y2, MW, mh, p, `c${clipN++}`);
399
+ s += `<text x="${round(x)}" y="${round(y2 + mh + 17)}" font-size="13.5" font-weight="600" fill="${base}" font-family="${FONT}">${esc(v.title)}</text>`;
400
+ s += `<text x="${round(x)}" y="${round(y2 + mh + 35)}" font-size="12" fill="${p.meta}" font-family="${FONT}">${esc(v.address)}</text>`;
401
+ return s;
402
+ },
403
+ },
404
+ bleed: true,
405
+ });
406
+ }
407
+ if (m.voice) {
408
+ const d = m.voice.duration;
409
+ blocks.push({
410
+ block: card(p, base, 40, (x, y2) => {
411
+ const cy = y2 + 20;
412
+ return `<circle cx="${round(x + 18)}" cy="${round(cy)}" r="18" fill="${p.bar}"/><path d="M${round(x + 14)},${round(cy - 7)} l9,7 l-9,7 z" fill="#fff"/>${waveform(x + 44, cy, p.bar, p.barTrack)}<text x="${round(x + 44)}" y="${round(cy + 18)}" font-size="11" fill="${p.meta}" font-family="${FONT}">${dur(d)}</text>`;
413
+ }),
414
+ bleed: false,
415
+ });
416
+ }
417
+ else if (m.audio) {
418
+ const a = m.audio;
419
+ blocks.push({
420
+ block: card(p, base, 44, (x, y2) => {
421
+ const cy = y2 + 22;
422
+ const title = a.title ?? a.file_name ?? "audio";
423
+ const sub = a.performer ?? dur(a.duration);
424
+ return `<circle cx="${round(x + 22)}" cy="${round(cy)}" r="22" fill="${p.bar}"/><path d="M${round(x + 17)},${round(cy - 9)} l13,9 l-13,9 z" fill="#fff"/><text x="${round(x + 54)}" y="${round(cy - 2)}" font-size="13.5" font-weight="600" fill="${base}" font-family="${FONT}">${esc(title)}</text><text x="${round(x + 54)}" y="${round(cy + 15)}" font-size="12" fill="${p.meta}" font-family="${FONT}">${esc(sub)}</text>`;
425
+ }),
426
+ bleed: false,
427
+ });
428
+ }
429
+ else if (m.document) {
430
+ const d = m.document;
431
+ const kb = d.file_size
432
+ ? `${Math.max(1, Math.round(d.file_size / 1024))} KB`
433
+ : (d.mime_type ?? "file");
434
+ blocks.push({
435
+ block: card(p, base, 44, (x, y2) => {
436
+ const cy = y2 + 22;
437
+ return `<rect x="${round(x)}" y="${round(y2)}" width="44" height="44" rx="12" fill="${p.bar}"/><path d="M${round(x + 14)},${round(cy - 11)} h11 l5,5 v17 h-16 z" fill="rgba(255,255,255,0.9)"/><text x="${round(x + 54)}" y="${round(cy - 2)}" font-size="13.5" font-weight="600" fill="${base}" font-family="${FONT}">${esc(d.file_name ?? "document")}</text><text x="${round(x + 54)}" y="${round(cy + 15)}" font-size="12" fill="${p.meta}" font-family="${FONT}">${esc(kb)}</text>`;
438
+ }),
439
+ bleed: false,
440
+ });
441
+ }
442
+ else if (m.contact) {
443
+ const c = m.contact;
444
+ const nm = `${c.first_name}${c.last_name ? ` ${c.last_name}` : ""}`;
445
+ const col = AVATAR_COLORS[hash(nm) % AVATAR_COLORS.length] ?? "#65aadd";
446
+ blocks.push({
447
+ block: card(p, base, 44, (x, y2) => {
448
+ const cy = y2 + 22;
449
+ return `<circle cx="${round(x + 22)}" cy="${round(cy)}" r="22" fill="${col}"/><text x="${round(x + 22)}" y="${round(cy + 6)}" font-size="17" font-weight="600" fill="#fff" text-anchor="middle" font-family="${FONT}">${esc(nm[0]?.toUpperCase() ?? "?")}</text><text x="${round(x + 54)}" y="${round(cy - 2)}" font-size="13.5" font-weight="600" fill="${base}" font-family="${FONT}">${esc(nm)}</text><text x="${round(x + 54)}" y="${round(cy + 15)}" font-size="12" fill="${p.meta}" font-family="${FONT}">${esc(c.phone_number)}</text>`;
450
+ }),
451
+ bleed: false,
452
+ });
453
+ }
454
+ else if (m.poll) {
455
+ const poll = m.poll;
456
+ const total = Math.max(1, poll.total_voter_count || poll.options.reduce((a, o) => a + o.voter_count, 0));
457
+ const opts = poll.options;
458
+ const rowH = 34;
459
+ const h = 26 + opts.length * rowH + 16;
460
+ blocks.push({
461
+ block: {
462
+ w: maxBubbleW - PADX * 2,
463
+ h,
464
+ render: (x, y2) => {
465
+ const w = maxBubbleW - PADX * 2;
466
+ let s = `<text x="${round(x)}" y="${round(y2 + 15)}" font-size="14" font-weight="600" fill="${base}" font-family="${FONT}">${esc(poll.question)}</text>`;
467
+ s += `<text x="${round(x)}" y="${round(y2 + 15)}" dx="0" font-size="11" fill="${p.meta}" font-family="${FONT}" text-anchor="end" transform="translate(${round(w)},0)">${poll.is_anonymous === false ? "Public" : "Anonymous"}</text>`;
468
+ opts.forEach((o, i) => {
469
+ const oy = y2 + 26 + i * rowH;
470
+ const pct = Math.round((o.voter_count / total) * 100);
471
+ s += `<text x="${round(x)}" y="${round(oy + 12)}" font-size="13" fill="${base}" font-family="${FONT}">${esc(o.text)}</text>`;
472
+ s += `<text x="${round(x + w)}" y="${round(oy + 12)}" font-size="12" fill="${p.meta}" text-anchor="end" font-family="${FONT}">${pct}%</text>`;
473
+ s += `<rect x="${round(x)}" y="${round(oy + 18)}" width="${round(w)}" height="4" rx="2" fill="${p.barTrack}"/>`;
474
+ s += `<rect x="${round(x)}" y="${round(oy + 18)}" width="${round((w * pct) / 100)}" height="4" rx="2" fill="${p.bar}"/>`;
475
+ });
476
+ return s;
477
+ },
478
+ },
479
+ bleed: false,
480
+ });
481
+ }
482
+ const hasBleed = blocks.some((b) => b.bleed);
483
+ // text/caption
484
+ const tText = m.text ?? "";
485
+ const cText = m.caption ?? "";
486
+ const ents = m.entities ?? [];
487
+ const cEnts = m.captionEntities ?? [];
488
+ const innerMax = hasBleed || blocks.length ? MW - PADX * 2 : maxBubbleW - PADX * 2;
489
+ if (tText)
490
+ blocks.push({
491
+ block: layoutText(tText, ents, blocks.length ? innerMax : maxBubbleW - PADX * 2, base, p),
492
+ bleed: false,
493
+ });
494
+ if (cText)
495
+ blocks.push({ block: layoutText(cText, cEnts, innerMax, base, p), bleed: false });
496
+ if (!blocks.length && !m.buttons?.length) {
497
+ y += GAP;
498
+ continue;
499
+ }
500
+ let bubbleW = 0;
501
+ if (blocks.length) {
502
+ // bubble width: bleed media → media width; else widest padded block
503
+ const bleedW = hasBleed ? MW : 0;
504
+ const padW = Math.max(0, ...blocks.filter((b) => !b.bleed).map((b) => b.block.w)) +
505
+ (blocks.some((b) => !b.bleed) ? PADX * 2 : 0);
506
+ const lastIsText = !blocks[blocks.length - 1]?.bleed && (!!tText || !!cText);
507
+ const metaInline = lastIsText
508
+ ? time
509
+ ? time.length * 6 + (out && m.status ? 16 : 0) + 8
510
+ : 0
511
+ : 0;
512
+ bubbleW = Math.max(bleedW, padW);
513
+ if (lastIsText) {
514
+ const lastW = blocks[blocks.length - 1]?.block.w ?? 0;
515
+ bubbleW = Math.max(bubbleW, Math.min(maxBubbleW, lastW + PADX * 2 + metaInline));
516
+ }
517
+ bubbleW = clamp(bubbleW, 60, maxBubbleW);
518
+ // stack height
519
+ let inner = 0;
520
+ blocks.forEach((b, i) => {
521
+ inner += b.block.h;
522
+ if (i < blocks.length - 1)
523
+ inner += b.bleed && !blocks[i + 1]?.bleed ? PADY : 6;
524
+ });
525
+ // name sits on its own row above the first non-bleed block — count its height
526
+ const nameH = !out && m.name && !blocks[0]?.bleed ? LH : 0;
527
+ const padTop = blocks[0]?.bleed ? 0 : PADY;
528
+ const padBot = lastIsText ? PADY : blocks[blocks.length - 1]?.bleed ? 0 : PADY;
529
+ const bubbleH = padTop + nameH + inner + padBot;
530
+ const bx = out ? W - PAD - bubbleW : PAD + indent;
531
+ const by = y;
532
+ const path = out
533
+ ? rr(bx, by, bubbleW, bubbleH, RAD, RAD, TAIL, RAD)
534
+ : rr(bx, by, bubbleW, bubbleH, RAD, RAD, RAD, TAIL);
535
+ body.push(`<path d="${path}" fill="${out ? p.out : p.in}"/>`);
536
+ // avatar
537
+ if (!out) {
538
+ const who = m.name ?? "";
539
+ const glyph = options.avatar ?? (who ? who[0]?.toUpperCase() : undefined) ?? "🤖";
540
+ const ac = AVATAR_COLORS[hash(who || "bot") % AVATAR_COLORS.length] ?? "#7bc862";
541
+ const cy = by + bubbleH - AV / 2;
542
+ body.push(`<circle cx="${round(PAD + AV / 2)}" cy="${round(cy)}" r="${AV / 2}" fill="${ac}"/><text x="${round(PAD + AV / 2)}" y="${round(cy + 5)}" font-size="14" font-weight="600" fill="#fff" text-anchor="middle" font-family="${FONT}">${esc(glyph)}</text>`);
543
+ }
544
+ // name (incoming, above first padded block)
545
+ let cursor = by + padTop;
546
+ if (!out && m.name && !blocks[0]?.bleed) {
547
+ body.push(`<text x="${round(bx + PADX)}" y="${round(cursor + 13)}" font-size="13" font-weight="600" fill="${p.name}" font-family="${FONT}">${esc(m.name)}</text>`);
548
+ cursor += LH;
549
+ }
550
+ // blocks
551
+ blocks.forEach((b, i) => {
552
+ const bxr = b.bleed ? bx : bx + PADX;
553
+ body.push(b.block.render(bxr, cursor));
554
+ cursor += b.block.h;
555
+ if (i < blocks.length - 1)
556
+ cursor += b.bleed && !blocks[i + 1]?.bleed ? PADY : 6;
557
+ });
558
+ // meta
559
+ const onScrim = !lastIsText && hasBleed;
560
+ const metaRight = onScrim ? bx + bubbleW - 8 : bx + bubbleW - PADX;
561
+ // sit time/ticks on the last text line's baseline (bubble bottom − padBot − descent)
562
+ const metaBottom = onScrim
563
+ ? by + (blocks[0]?.block.h ?? bubbleH)
564
+ : by + bubbleH - padBot - (LH - ASC) + 4;
565
+ body.push(meta(metaRight, metaBottom, time, m.status, out, p, onScrim));
566
+ y = by + bubbleH;
567
+ }
568
+ // buttons
569
+ if (m.buttons?.length) {
570
+ let byy = blocks.length ? y + 6 : y;
571
+ const rowW = Math.max(bubbleW, MW);
572
+ const bxx = out ? W - PAD - rowW : PAD + indent;
573
+ for (const row of m.buttons) {
574
+ const n = Math.max(1, row.length);
575
+ const cw = (rowW - (n - 1) * BTN_GAP) / n;
576
+ row.forEach((label, i) => {
577
+ const ux = bxx + i * (cw + BTN_GAP);
578
+ body.push(`<rect x="${round(ux)}" y="${round(byy)}" width="${round(cw)}" height="${BTN_H}" rx="8" fill="${p.button}" stroke="${p.buttonStroke}"/><text x="${round(ux + cw / 2)}" y="${round(byy + BTN_H / 2 + 4)}" font-size="13" font-weight="500" fill="${p.buttonText}" text-anchor="middle" font-family="${FONT}">${esc(label)}</text>`);
579
+ });
580
+ byy += BTN_H + BTN_GAP;
581
+ }
582
+ y = byy - BTN_GAP;
583
+ }
584
+ y += GAP;
585
+ }
586
+ const H = Math.round(y - GAP + PAD);
587
+ const defs = `<defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${p.bg0}"/><stop offset="1" stop-color="${p.bg1}"/></linearGradient></defs>`;
588
+ const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${FONT}">`;
589
+ return `${open}${defs}<rect width="${W}" height="${H}" fill="url(#bg)"/>${body.join("")}</svg>`;
590
+ }
591
+ //# sourceMappingURL=index.js.map