@dex-ai/vue-tui 0.1.10

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/src/render.ts ADDED
@@ -0,0 +1,632 @@
1
+ import type { TElement, TNode, TText } from "./nodes";
2
+ import { getStyle } from "./nodes";
3
+ import type { Theme, TerminalStyle } from "./style";
4
+ import { resolveColor, resolveTextStyle } from "./style";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** Strip ANSI escape codes to measure visible character width. */
11
+ function visibleLength(s: string): number {
12
+ // eslint-disable-next-line no-control-regex
13
+ return s.replace(/\x1b\[[0-9;]*m|\x1b\][^\x1b]*\x1b\\/g, "").length;
14
+ }
15
+
16
+ /** Pad a string to `width` visible characters (right-pad with spaces). */
17
+ function padRight(s: string, width: number): string {
18
+ const vis = visibleLength(s);
19
+ if (vis >= width) return s;
20
+ return s + " ".repeat(width - vis);
21
+ }
22
+
23
+ /** Repeat a character `n` times, guarded for n ≤ 0. */
24
+ function repeat(ch: string, n: number): string {
25
+ return n > 0 ? ch.repeat(n) : "";
26
+ }
27
+
28
+ /** Prefix every line in `lines` with `prefix`. */
29
+ function prefixLines(lines: string[], prefix: string): string[] {
30
+ if (prefix === "") return lines;
31
+ return lines.map((l) => prefix + l);
32
+ }
33
+
34
+ /** Word-wrap a single text segment to fit within `maxWidth` visible chars.
35
+ * Preserves all whitespace — only inserts breaks between words. */
36
+ function wordWrap(text: string, maxWidth: number): string[] {
37
+ if (maxWidth <= 0) return [text];
38
+ if (visibleLength(text) <= maxWidth) return [text];
39
+
40
+ const result: string[] = [];
41
+ let lineStart = 0;
42
+ let lastBreakable = -1; // index after which we can break (after a space)
43
+ let visWidth = 0;
44
+
45
+ for (let i = 0; i < text.length; i++) {
46
+ const ch = text[i]!;
47
+
48
+ // Skip ANSI escape sequences — don't count toward visible width
49
+ if (ch === "\x1b") {
50
+ if (text[i + 1] === "[") {
51
+ // CSI sequence: \x1b[...m
52
+ const end = text.indexOf("m", i);
53
+ if (end !== -1) {
54
+ i = end; // loop will i++ past the 'm'
55
+ continue;
56
+ }
57
+ } else if (text[i + 1] === "]") {
58
+ // OSC sequence: \x1b]...\x1b\\
59
+ const end = text.indexOf("\x1b\\", i);
60
+ if (end !== -1) {
61
+ i = end + 1; // loop will i++ past the '\\'
62
+ continue;
63
+ }
64
+ }
65
+ }
66
+
67
+ visWidth++;
68
+
69
+ if (visWidth > maxWidth) {
70
+ if (lastBreakable > lineStart) {
71
+ // Break at last space
72
+ result.push(text.slice(lineStart, lastBreakable + 1));
73
+ lineStart = lastBreakable + 1;
74
+ } else {
75
+ // No breakable point — hard break at current position
76
+ result.push(text.slice(lineStart, i));
77
+ lineStart = i;
78
+ }
79
+ visWidth = visibleLength(text.slice(lineStart, i + 1));
80
+ lastBreakable = -1;
81
+ }
82
+
83
+ if (ch === " ") {
84
+ lastBreakable = i;
85
+ }
86
+ }
87
+
88
+ // Remainder
89
+ if (lineStart < text.length) {
90
+ result.push(text.slice(lineStart));
91
+ }
92
+
93
+ return result.length > 0 ? result : [""];
94
+ }
95
+
96
+ /** Expand a text string (may contain \n) into wrapped lines. */
97
+ function expandText(text: string, maxWidth: number): string[] {
98
+ const segments = text.split("\n");
99
+ const result: string[] = [];
100
+ for (const seg of segments) {
101
+ const wrapped = wordWrap(seg, maxWidth);
102
+ for (const line of wrapped) {
103
+ result.push(line);
104
+ }
105
+ }
106
+ return result.length > 0 ? result : [""];
107
+ }
108
+
109
+ /** Build a divider line of `─` characters at the given width. */
110
+ function dividerLine(width: number, color: string, reset: string): string {
111
+ const bar = repeat("─", width);
112
+ return color !== "" ? color + bar + reset : bar;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Render context
117
+ // ---------------------------------------------------------------------------
118
+
119
+ interface RenderContext {
120
+ width: number;
121
+ theme: Theme;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Core render dispatch
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Render a TNode into an array of string lines.
130
+ * `ctx` carries the available width and theme.
131
+ */
132
+ function renderNode(node: TNode, ctx: RenderContext): string[] {
133
+ if (node.type === "text") {
134
+ const text = (node as TText).text;
135
+ // Skip empty text nodes (whitespace artifacts from template compilation)
136
+ if (text === "") return [];
137
+ return expandText(text, ctx.width);
138
+ }
139
+ if (node.type === "comment") return [];
140
+ return renderElement(node as TElement, ctx);
141
+ }
142
+
143
+ function renderElement(el: TElement, ctx: RenderContext): string[] {
144
+ const style = getStyle(el);
145
+
146
+ // display: none → skip
147
+ if (style.display === "none") return [];
148
+
149
+ // Render content based on element type
150
+ let lines: string[];
151
+ switch (el.tag) {
152
+ case "root":
153
+ case "stack":
154
+ lines = renderStack(el, style, ctx);
155
+ break;
156
+ case "line":
157
+ lines = renderLine(el, style, ctx);
158
+ break;
159
+ case "row":
160
+ lines = renderRow(el, style, ctx);
161
+ break;
162
+ case "text":
163
+ lines = renderTextEl(el, style, ctx);
164
+ break;
165
+ case "spacer":
166
+ lines = [""];
167
+ break;
168
+ case "indent":
169
+ lines = renderIndent(el, style, ctx);
170
+ break;
171
+ case "list":
172
+ lines = renderList(el, style, ctx);
173
+ break;
174
+ default:
175
+ // Unknown tag — treat like a stack
176
+ lines = renderStack(el, style, ctx);
177
+ break;
178
+ }
179
+
180
+ // Apply common box model to ALL elements
181
+ lines = applyBoxModel(lines, style, ctx);
182
+
183
+ return lines;
184
+ }
185
+
186
+ /**
187
+ * Common box model pass — applies padding, borders, margins, height, width
188
+ * to any element's rendered content lines.
189
+ */
190
+ function applyBoxModel(lines: string[], style: TerminalStyle, ctx: RenderContext): string[] {
191
+ lines = applyPaddingLeftRight(lines, style, ctx.width);
192
+ lines = applyBorders(lines, style, ctx.width, ctx.theme);
193
+ lines = applyMargins(lines, style, ctx.width);
194
+ lines = applyHeight(lines, style);
195
+ lines = applyMaxHeight(lines, style);
196
+ lines = applyWidth(lines, style, ctx.width, ctx.theme);
197
+ return lines;
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Tag renderers
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /** Vertical stack: each child's lines are appended sequentially. */
205
+ function renderStack(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
206
+ const innerWidth = computeInnerWidth(style, ctx.width);
207
+ const innerCtx: RenderContext = { ...ctx, width: innerWidth };
208
+
209
+ let lines: string[] = [];
210
+
211
+ // paddingTop
212
+ const pt = style.paddingTop ?? 0;
213
+ for (let i = 0; i < pt; i++) lines.push("");
214
+
215
+ for (const child of el.children) {
216
+ const childLines = renderNode(child, innerCtx);
217
+ for (const l of childLines) lines.push(l);
218
+ }
219
+
220
+ // paddingBottom
221
+ const pb = style.paddingBottom ?? 0;
222
+ for (let i = 0; i < pb; i++) lines.push("");
223
+
224
+ return lines;
225
+ }
226
+
227
+ /** A single terminal row: children rendered inline (concatenated). */
228
+ function renderLine(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
229
+ const innerWidth = computeInnerWidth(style, ctx.width);
230
+ const innerCtx: RenderContext = { ...ctx, width: innerWidth };
231
+
232
+ // Collect inline text from all children
233
+ const parts: string[] = [];
234
+ for (const child of el.children) {
235
+ const childLines = renderNode(child, innerCtx);
236
+ // For inline rendering, join child lines without injecting spaces
237
+ parts.push(childLines.join(""));
238
+ }
239
+ const joined = parts.join("");
240
+
241
+ // If whiteSpace is nowrap, don't word-wrap — return as a single line
242
+ if (style.whiteSpace === "nowrap") {
243
+ return [joined];
244
+ }
245
+
246
+ // Expand the joined string through newline + wrap
247
+ const lines = expandText(joined, innerWidth);
248
+
249
+ return lines;
250
+ }
251
+
252
+ /** Row: children rendered side-by-side on the same line(s). */
253
+ function renderRow(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
254
+ const innerWidth = computeInnerWidth(style, ctx.width);
255
+
256
+ // Determine per-child widths
257
+ const children = el.children.filter((c) => {
258
+ if (c.type === "comment") return false;
259
+ if (c.type === "element") {
260
+ const cs = getStyle(c as TElement);
261
+ if (cs.display === "none") return false;
262
+ }
263
+ return true;
264
+ });
265
+
266
+ if (children.length === 0) return [];
267
+
268
+ // Distribute width: fixed-width children take their share, rest share equally
269
+ const childWidths = distributeWidths(children, innerWidth, ctx);
270
+
271
+ // Render each child at its allocated width
272
+ const columns: string[][] = children.map((child, i) => {
273
+ const w = childWidths[i] ?? 1;
274
+ return renderNode(child, { ...ctx, width: w });
275
+ });
276
+
277
+ // Merge columns into rows (zip with padding to max height)
278
+ const maxLines = columns.reduce((m, col) => Math.max(m, col.length), 0);
279
+ const result: string[] = [];
280
+ for (let r = 0; r < maxLines; r++) {
281
+ let rowStr = "";
282
+ for (let c = 0; c < columns.length; c++) {
283
+ const col = columns[c];
284
+ const w = childWidths[c] ?? 1;
285
+ const cell = col !== undefined && r < col.length ? (col[r] ?? "") : "";
286
+ rowStr += padRight(cell, w);
287
+ }
288
+ result.push(rowStr);
289
+ }
290
+
291
+ return result;
292
+ }
293
+
294
+ /** Distribute column widths for row children. */
295
+ function distributeWidths(
296
+ children: TNode[],
297
+ totalWidth: number,
298
+ ctx: RenderContext,
299
+ ): number[] {
300
+ let remaining = totalWidth;
301
+ const widths: (number | null)[] = children.map((child) => {
302
+ if (child.type !== "element") return null;
303
+ const s = getStyle(child as TElement);
304
+ if (typeof s.width === "number") {
305
+ remaining -= s.width;
306
+ return s.width;
307
+ }
308
+ if (s.width === "100%") return null; // flex
309
+ return null;
310
+ });
311
+
312
+ const flexCount = widths.filter((w) => w === null).length;
313
+ const flexWidth = flexCount > 0 ? Math.floor(remaining / flexCount) : 0;
314
+
315
+ return widths.map((w) => (w === null ? Math.max(0, flexWidth) : w));
316
+ }
317
+
318
+ /**
319
+ * Apply a reverse-video block cursor at position `cursorAt` within expanded lines.
320
+ * Accounts for line wrapping — `cursorAt` is an offset into the original flat text.
321
+ */
322
+ function applyCursor(
323
+ lines: string[],
324
+ cursorAt: number,
325
+ open: string,
326
+ close: string,
327
+ theme: Theme,
328
+ ): string[] {
329
+ // Find which line and column the cursor falls on
330
+ let remaining = cursorAt;
331
+ let cursorLine = -1;
332
+ let cursorCol = 0;
333
+
334
+ for (let i = 0; i < lines.length; i++) {
335
+ const lineLen = visibleLength(lines[i]!);
336
+ if (remaining <= lineLen) {
337
+ cursorLine = i;
338
+ cursorCol = remaining;
339
+ break;
340
+ }
341
+ remaining -= lineLen;
342
+ }
343
+
344
+ // If cursorAt is past all text, put cursor at end of last line
345
+ if (cursorLine === -1) {
346
+ cursorLine = lines.length - 1;
347
+ cursorCol = visibleLength(lines[cursorLine]!);
348
+ }
349
+
350
+ const result: string[] = [];
351
+ for (let i = 0; i < lines.length; i++) {
352
+ const line = lines[i]!;
353
+ if (i === cursorLine) {
354
+ const before = line.slice(0, cursorCol);
355
+ const charAtCursor = line[cursorCol] ?? "";
356
+ const cursorChar = charAtCursor || " "; // space if at end of line
357
+ const after = charAtCursor ? line.slice(cursorCol + 1) : "";
358
+
359
+ let rendered = "";
360
+ if (open) rendered += open;
361
+ rendered += before;
362
+ if (open) rendered += close;
363
+ rendered += theme.reverse + cursorChar + theme.reset;
364
+ if (after) {
365
+ if (open) rendered += open;
366
+ rendered += after;
367
+ if (open) rendered += close;
368
+ }
369
+ result.push(rendered);
370
+ } else {
371
+ result.push(open !== "" ? open + line + close : line);
372
+ }
373
+ }
374
+ return result;
375
+ }
376
+
377
+ /** Inline styled text element. */
378
+ function renderTextEl(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
379
+ const innerWidth = computeInnerWidth(style, ctx.width);
380
+ const { open, close } = resolveTextStyle(style, ctx.theme);
381
+
382
+ // OSC 8 hyperlink wrapping
383
+ const href = typeof el.props["href"] === "string" ? (el.props["href"] as string) : "";
384
+ const linkOpen = href ? `\x1b]8;;${href}\x1b\\` : "";
385
+ const linkClose = href ? `\x1b]8;;\x1b\\` : "";
386
+
387
+ // Gather raw text from children
388
+ const rawParts: string[] = [];
389
+ for (const child of el.children) {
390
+ if (child.type === "text") {
391
+ rawParts.push((child as TText).text);
392
+ } else if (child.type === "element") {
393
+ // Nested text elements — render and join
394
+ const nested = renderNode(child, { ...ctx, width: innerWidth });
395
+ rawParts.push(nested.join("\n"));
396
+ }
397
+ }
398
+ const raw = rawParts.join("");
399
+
400
+ // Handle cursorAt prop — render a reverse-video block cursor at the given position
401
+ const cursorAt = typeof el.props["cursorAt"] === "number" ? (el.props["cursorAt"] as number) : -1;
402
+
403
+ const expanded = expandText(raw, innerWidth);
404
+
405
+ if (cursorAt >= 0) {
406
+ return applyCursor(expanded, cursorAt, open, close, ctx.theme);
407
+ }
408
+
409
+ // Combine style + link wrapping
410
+ const fullOpen = linkOpen + open;
411
+ const fullClose = close + linkClose;
412
+
413
+ const styled = expanded.map((line) =>
414
+ fullOpen !== "" || fullClose !== "" ? fullOpen + line + fullClose : line,
415
+ );
416
+ return styled;
417
+ }
418
+
419
+ /** Indent: adds `level * 2` spaces prefix to all child lines. */
420
+ function renderIndent(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
421
+ const level = typeof el.props["level"] === "number" ? el.props["level"] : 1;
422
+ const spaces = repeat(" ", level * 2);
423
+ const innerWidth = Math.max(0, ctx.width - level * 2);
424
+ const innerCtx: RenderContext = { ...ctx, width: innerWidth };
425
+
426
+ let lines: string[] = [];
427
+ for (const child of el.children) {
428
+ const childLines = renderNode(child, innerCtx);
429
+ for (const l of childLines) lines.push(l);
430
+ }
431
+
432
+ lines = prefixLines(lines, spaces);
433
+
434
+ return lines;
435
+ }
436
+
437
+ /**
438
+ * List: renders children as list items with bullet or number prefixes.
439
+ *
440
+ * Props:
441
+ * - `ordered` (boolean): if true, uses "1. ", "2. " etc. Default: false (bullet "• ")
442
+ * - `start` (number): starting number for ordered lists. Default: 1
443
+ */
444
+ function renderList(el: TElement, style: TerminalStyle, ctx: RenderContext): string[] {
445
+ const ordered = el.props["ordered"] === true;
446
+ const start = typeof el.props["start"] === "number" ? (el.props["start"] as number) : 1;
447
+
448
+ // Determine prefix width — for ordered, depends on max number length
449
+ const itemCount = el.children.filter(
450
+ (c) => c.type === "element" || c.type === "text",
451
+ ).length;
452
+ const maxNum = start + itemCount - 1;
453
+ const numWidth = ordered ? String(maxNum).length : 0;
454
+ const prefixWidth = ordered ? numWidth + 2 : 2; // "N. " or "• "
455
+
456
+ const innerWidth = Math.max(0, computeInnerWidth(style, ctx.width) - prefixWidth);
457
+ const innerCtx: RenderContext = { ...ctx, width: innerWidth };
458
+ const continuation = repeat(" ", prefixWidth);
459
+
460
+ let lines: string[] = [];
461
+ let index = 0;
462
+
463
+ for (const child of el.children) {
464
+ if (child.type === "comment") continue;
465
+
466
+ const childLines = renderNode(child, innerCtx);
467
+ const bullet = ordered
468
+ ? String(start + index).padStart(numWidth) + ". "
469
+ : "• ";
470
+
471
+ for (let i = 0; i < childLines.length; i++) {
472
+ const prefix = i === 0 ? bullet : continuation;
473
+ lines.push(prefix + (childLines[i] ?? ""));
474
+ }
475
+
476
+ index++;
477
+ }
478
+
479
+ return lines;
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Box-model helpers
484
+ // ---------------------------------------------------------------------------
485
+
486
+ function computeInnerWidth(style: TerminalStyle, availableWidth: number): number {
487
+ let w = availableWidth;
488
+
489
+ if (typeof style.width === "number") w = style.width;
490
+ else if (style.width === "100%") w = availableWidth;
491
+
492
+ if (style.maxWidth !== undefined) w = Math.min(w, style.maxWidth);
493
+ if (style.minWidth !== undefined) w = Math.max(w, style.minWidth);
494
+
495
+ const pl = style.paddingLeft ?? 0;
496
+ const pr = style.paddingRight ?? 0;
497
+ const ml = style.marginLeft ?? 0;
498
+ const mr = style.marginRight ?? 0;
499
+ // Left border takes 2 chars (char + space), right border takes 2 chars (space + char)
500
+ const bl = (style.borderLeft === "solid" || style.borderLeft === "heavy") ? 2 : 0;
501
+ const br = (style.borderRight === "solid" || style.borderRight === "heavy") ? 2 : 0;
502
+
503
+ w = Math.max(0, w - pl - pr - ml - mr - bl - br);
504
+ return w;
505
+ }
506
+
507
+ function applyPaddingLeftRight(
508
+ lines: string[],
509
+ style: TerminalStyle,
510
+ _innerWidth: number,
511
+ ): string[] {
512
+ const pl = style.paddingLeft ?? 0;
513
+ const pr = style.paddingRight ?? 0;
514
+ if (pl === 0 && pr === 0) return lines;
515
+ const leftPad = repeat(" ", pl);
516
+ const rightPad = repeat(" ", pr);
517
+ return lines.map((l) => leftPad + l + rightPad);
518
+ }
519
+
520
+ function applyBorders(
521
+ lines: string[],
522
+ style: TerminalStyle,
523
+ width: number,
524
+ theme: Theme,
525
+ ): string[] {
526
+ // Left/right border characters
527
+ const hasLeft = style.borderLeft === "solid" || style.borderLeft === "heavy";
528
+ const hasRight = style.borderRight === "solid" || style.borderRight === "heavy";
529
+
530
+ if (hasLeft || hasRight) {
531
+ const leftChar = style.borderLeft === "heavy" ? "▎" : "│";
532
+ const rightChar = style.borderRight === "heavy" ? "▕" : "│";
533
+ const leftColor = resolveColor(style.borderLeftColor ?? style.borderColor, theme);
534
+ const rightColor = resolveColor(style.borderRightColor ?? style.borderColor, theme);
535
+ const leftPrefix = leftColor ? leftColor + leftChar + theme.reset + " " : leftChar + " ";
536
+ const rightSuffix = rightColor ? " " + rightColor + rightChar + theme.reset : " " + rightChar;
537
+
538
+ lines = lines.map((l) =>
539
+ (hasLeft ? leftPrefix : "") + l + (hasRight ? rightSuffix : ""),
540
+ );
541
+ }
542
+
543
+ // Top/bottom borders
544
+ const result: string[] = [];
545
+
546
+ if (style.borderTop === "solid") {
547
+ const color = resolveColor(style.borderTopColor ?? style.borderColor, theme);
548
+ result.push(dividerLine(width, color, theme.reset));
549
+ }
550
+
551
+ for (const l of lines) result.push(l);
552
+
553
+ if (style.borderBottom === "solid") {
554
+ const color = resolveColor(style.borderBottomColor ?? style.borderColor, theme);
555
+ result.push(dividerLine(width, color, theme.reset));
556
+ }
557
+
558
+ return result;
559
+ }
560
+
561
+ function applyMargins(lines: string[], style: TerminalStyle, _width: number): string[] {
562
+ const mt = style.marginTop ?? 0;
563
+ const mb = style.marginBottom ?? 0;
564
+ const ml = style.marginLeft ?? 0;
565
+
566
+ let result = lines;
567
+
568
+ if (ml > 0) {
569
+ const leftPad = repeat(" ", ml);
570
+ result = result.map((l) => leftPad + l);
571
+ }
572
+
573
+ const top: string[] = [];
574
+ for (let i = 0; i < mt; i++) top.push("");
575
+ const bottom: string[] = [];
576
+ for (let i = 0; i < mb; i++) bottom.push("");
577
+
578
+ return [...top, ...result, ...bottom];
579
+ }
580
+
581
+ function applyHeight(lines: string[], style: TerminalStyle): string[] {
582
+ if (typeof style.height !== "number") return lines;
583
+ const h = style.height;
584
+ if (lines.length >= h) return lines.slice(0, h);
585
+ // Pad with empty lines to reach minHeight
586
+ const result = [...lines];
587
+ while (result.length < h) result.push("");
588
+ return result;
589
+ }
590
+
591
+ function applyMaxHeight(lines: string[], style: TerminalStyle): string[] {
592
+ if (style.maxHeight === undefined) return lines;
593
+ const max =
594
+ typeof style.maxHeight === "number" ? style.maxHeight : lines.length;
595
+ if (lines.length <= max) return lines;
596
+
597
+ if (style.overflow === "collapse") {
598
+ const hidden = lines.length - max + 1; // +1 for the indicator line
599
+ const truncated = lines.slice(0, max - 1);
600
+ truncated.push(`… +${hidden} lines`);
601
+ return truncated;
602
+ }
603
+
604
+ // hidden / scroll / default: just truncate
605
+ return lines.slice(0, max);
606
+ }
607
+
608
+ function applyWidth(
609
+ lines: string[],
610
+ style: TerminalStyle,
611
+ availableWidth: number,
612
+ _theme: Theme,
613
+ ): string[] {
614
+ if (style.width !== "100%") return lines;
615
+ return lines.map((l) => padRight(l, availableWidth));
616
+ }
617
+
618
+ // ---------------------------------------------------------------------------
619
+ // Public entry point
620
+ // ---------------------------------------------------------------------------
621
+
622
+ /**
623
+ * Walk the TElement tree and produce an array of terminal lines.
624
+ *
625
+ * @param root The root TElement (tag = "root")
626
+ * @param width Available terminal width in columns
627
+ * @param theme The active theme
628
+ */
629
+ export function renderToLines(root: TElement, width: number, theme: Theme): string[] {
630
+ const ctx: RenderContext = { width, theme };
631
+ return renderElement(root, ctx);
632
+ }