tui-cap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/svg.js ADDED
@@ -0,0 +1,767 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultRenderOptions = defaultRenderOptions;
4
+ exports.cellForeground = cellForeground;
5
+ exports.measureGrid = measureGrid;
6
+ exports.renderSvg = renderSvg;
7
+ const palette_1 = require("./palette");
8
+ /** Build a full set of render options, overriding defaults as needed. */
9
+ function defaultRenderOptions(themeName = 'dark', overrides = {}) {
10
+ const chromeStyle = overrides.chromeStyle ?? 'mac';
11
+ return {
12
+ theme: palette_1.THEMES[themeName],
13
+ fontFamily: "'SFMono-Regular', 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace",
14
+ fontSize: 14,
15
+ // SF Mono's true per-column advance: advanceWidth 1266 / unitsPerEm 2048 =
16
+ // 0.618164 em (8.654px at 14px), confirmed against the font file and Figma's
17
+ // own measurement. This MUST be the font's real metric, not a hand-tuned
18
+ // value: renderers that honour `textLength` (Chrome, Illustrator, the raster
19
+ // exporter) flow text to the grid either way, but Figma ignores `textLength`
20
+ // and draws every glyph at the font's true advance — so only a real-metric
21
+ // grid keeps Figma's text aligned with the column-pinned rects and borders.
22
+ advance: 0.618164,
23
+ // Terminals draw rows edge to edge with no inter-line gap; 1.25 keeps block
24
+ // bars and the mascot gapless and matches the default terminal cell height.
25
+ // The GUI exposes a slider and the CLI a --line-height flag to fine-tune.
26
+ lineHeight: 1.25,
27
+ padding: 24,
28
+ chrome: true,
29
+ chromeStyle,
30
+ title: 'GitHub Copilot CLI',
31
+ // Windows 11 title bars round the top corners at 7px (Figma spec); macOS
32
+ // Tahoe rounds at ~20px (corner radius ≈ half the title-bar height, measured
33
+ // off a real Terminal).
34
+ radius: chromeStyle === 'windows' || chromeStyle === 'windows-inactive' ? 7 : 20,
35
+ ...overrides,
36
+ };
37
+ }
38
+ /**
39
+ * A single glyph in the Unicode Box Drawing block (U+2500–U+257F): the rules,
40
+ * borders and rounded corners (╭ ╮ ╰ ╯ │ ─ ├ ┤ …) that frame dialogs and
41
+ * sub-menus. Every such glyph is broken into its own absolutely-positioned run
42
+ * (see `buildRuns`) so a box border lands on its exact grid column no matter how
43
+ * a renderer distributes `textLength` spacing — see the comment there for why.
44
+ */
45
+ const BOX_DRAWING = /^[\u2500-\u257F]$/;
46
+ const EMOJI_CAPABLE = /\p{Emoji}/u;
47
+ const EMOJI_DEFAULT = /\p{Emoji_Presentation}/u;
48
+ /**
49
+ * Pin emoji-capable-but-text-default glyphs (⚠ ✔ ▶ ™ …) to TEXT presentation by
50
+ * appending VS15 (U+FE0E). The terminal rendered them as a single-cell monochrome
51
+ * glyph; Illustrator, Chrome and other SVG renderers otherwise substitute a colour
52
+ * emoji, and VS15 forces the monochrome text glyph back for them.
53
+ *
54
+ * NOTE: Figma is the exception — its text engine force-renders U+26A0-class code
55
+ * points as colour emoji and ignores both VS15 and the assigned font (verified via
56
+ * figma.createNodeFromSvg + screenshot across Menlo/Apple Symbols/Arial Unicode MS/
57
+ * Segoe UI Symbol). There is no pure-SVG override; the Figma-native export tracked
58
+ * as a deferred follow-up is the fix there. We still emit VS15 because it is correct
59
+ * for the SVG's primary targets (Illustrator + raster) and is inert in Figma.
60
+ *
61
+ * Scoped to non-ASCII code points so characters that merely carry the Emoji property
62
+ * (digits, `#`, `*`) are left alone, skipped for genuinely wide / emoji-presentation
63
+ * glyphs (🚀), and a no-op when a variation selector already trails the base char.
64
+ */
65
+ function pinTextPresentation(char, width) {
66
+ if (width === 1 &&
67
+ char.length === 1 &&
68
+ char.codePointAt(0) >= 0x2000 &&
69
+ EMOJI_CAPABLE.test(char) &&
70
+ !EMOJI_DEFAULT.test(char)) {
71
+ return `${char}\uFE0E`;
72
+ }
73
+ return char;
74
+ }
75
+ function isBlank(cell) {
76
+ return (cell.char === ' ' &&
77
+ cell.bg === null &&
78
+ !cell.inverse &&
79
+ !cell.underline &&
80
+ !cell.strike);
81
+ }
82
+ function effective(cell, theme) {
83
+ let fill = cell.fg ?? theme.foreground;
84
+ let bg = cell.bg;
85
+ if (cell.inverse) {
86
+ const newFill = cell.bg ?? theme.background;
87
+ bg = cell.fg ?? theme.foreground;
88
+ fill = newFill;
89
+ }
90
+ const decoration = [cell.underline ? 'underline' : '', cell.strike ? 'line-through' : '']
91
+ .filter(Boolean)
92
+ .join(' ') || null;
93
+ return {
94
+ fill,
95
+ bg,
96
+ weight: cell.bold ? 700 : 400,
97
+ italic: cell.italic,
98
+ decoration,
99
+ underlineStyle: cell.underline ? cell.underlineStyle ?? 'single' : undefined,
100
+ underlineColor: cell.underline ? cell.underlineColor ?? null : null,
101
+ opacity: cell.dim ? 0.6 : 1,
102
+ visible: !cell.invisible,
103
+ };
104
+ }
105
+ /**
106
+ * The resolved foreground (glyph) colour a cell renders with — honouring an
107
+ * explicit fg override, the theme default, and reverse video — as a hex string.
108
+ * Used by the GUI eyedropper to sample a character's on-screen colour.
109
+ */
110
+ function cellForeground(cell, theme) {
111
+ return effective(cell, theme).fill;
112
+ }
113
+ // Map our underline style names to the CSS `text-decoration-style` keyword.
114
+ // 'single' is the default and is expressed via the plain `text-decoration`
115
+ // presentation attribute instead, so it isn't listed here.
116
+ const CSS_UNDERLINE_STYLE = {
117
+ double: 'double',
118
+ curly: 'wavy',
119
+ dotted: 'dotted',
120
+ dashed: 'dashed',
121
+ };
122
+ const FULL_CELL = [0, 0, 1, 1];
123
+ const BLOCK_GEOMETRY = {
124
+ '\u2580': { rects: [[0, 0, 1, 1 / 2]] }, // ▀ upper half
125
+ '\u2581': { rects: [[0, 7 / 8, 1, 1 / 8]] }, // ▁ lower 1/8
126
+ '\u2582': { rects: [[0, 6 / 8, 1, 2 / 8]] }, // ▂ lower 1/4
127
+ '\u2583': { rects: [[0, 5 / 8, 1, 3 / 8]] }, // ▃ lower 3/8
128
+ '\u2584': { rects: [[0, 1 / 2, 1, 1 / 2]] }, // ▄ lower half
129
+ '\u2585': { rects: [[0, 3 / 8, 1, 5 / 8]] }, // ▅ lower 5/8
130
+ '\u2586': { rects: [[0, 2 / 8, 1, 6 / 8]] }, // ▆ lower 3/4
131
+ '\u2587': { rects: [[0, 1 / 8, 1, 7 / 8]] }, // ▇ lower 7/8
132
+ '\u2588': { rects: [FULL_CELL] }, // █ full block
133
+ '\u2589': { rects: [[0, 0, 7 / 8, 1]] }, // ▉ left 7/8
134
+ '\u258A': { rects: [[0, 0, 6 / 8, 1]] }, // ▊ left 3/4
135
+ '\u258B': { rects: [[0, 0, 5 / 8, 1]] }, // ▋ left 5/8
136
+ '\u258C': { rects: [[0, 0, 1 / 2, 1]] }, // ▌ left half
137
+ '\u258D': { rects: [[0, 0, 3 / 8, 1]] }, // ▍ left 3/8
138
+ '\u258E': { rects: [[0, 0, 2 / 8, 1]] }, // ▎ left 1/4
139
+ '\u258F': { rects: [[0, 0, 1 / 8, 1]] }, // ▏ left 1/8
140
+ '\u2590': { rects: [[1 / 2, 0, 1 / 2, 1]] }, // ▐ right half
141
+ '\u2591': { rects: [FULL_CELL], opacity: 0.25 }, // ░ light shade
142
+ '\u2592': { rects: [FULL_CELL], opacity: 0.5 }, // ▒ medium shade
143
+ '\u2593': { rects: [FULL_CELL], opacity: 0.75 }, // ▓ dark shade
144
+ '\u2594': { rects: [[0, 0, 1, 1 / 8]] }, // ▔ upper 1/8
145
+ '\u2595': { rects: [[7 / 8, 0, 1 / 8, 1]] }, // ▕ right 1/8
146
+ '\u2596': { rects: [[0, 1 / 2, 1 / 2, 1 / 2]] }, // ▖ lower left
147
+ '\u2597': { rects: [[1 / 2, 1 / 2, 1 / 2, 1 / 2]] }, // ▗ lower right
148
+ '\u2598': { rects: [[0, 0, 1 / 2, 1 / 2]] }, // ▘ upper left
149
+ '\u2599': { rects: [[0, 0, 1 / 2, 1], [1 / 2, 1 / 2, 1 / 2, 1 / 2]] }, // ▙
150
+ '\u259A': { rects: [[0, 0, 1 / 2, 1 / 2], [1 / 2, 1 / 2, 1 / 2, 1 / 2]] }, // ▚
151
+ '\u259B': { rects: [[0, 0, 1, 1 / 2], [0, 1 / 2, 1 / 2, 1 / 2]] }, // ▛
152
+ '\u259C': { rects: [[0, 0, 1, 1 / 2], [1 / 2, 1 / 2, 1 / 2, 1 / 2]] }, // ▜
153
+ '\u259D': { rects: [[1 / 2, 0, 1 / 2, 1 / 2]] }, // ▝ upper right
154
+ '\u259E': { rects: [[1 / 2, 0, 1 / 2, 1 / 2], [0, 1 / 2, 1 / 2, 1 / 2]] }, // ▞
155
+ '\u259F': { rects: [[1 / 2, 0, 1 / 2, 1], [0, 1 / 2, 1 / 2, 1 / 2]] }, // ▟
156
+ };
157
+ function sig(r) {
158
+ return [
159
+ r.fill,
160
+ r.bg ?? '_',
161
+ r.weight,
162
+ r.italic ? 'i' : '',
163
+ r.decoration ?? '',
164
+ r.underlineStyle ?? '',
165
+ r.underlineColor ?? '',
166
+ r.opacity,
167
+ r.visible ? 'v' : 'h',
168
+ ].join('|');
169
+ }
170
+ function buildRuns(cells, theme) {
171
+ // Trim trailing blank cells so the SVG crops tightly to content.
172
+ let end = cells.length;
173
+ while (end > 0 && isBlank(cells[end - 1]))
174
+ end--;
175
+ const runs = [];
176
+ let current = null;
177
+ for (let i = 0; i < end; i++) {
178
+ const cell = cells[i];
179
+ const e = effective(cell, theme);
180
+ const wide = cell.width >= 2;
181
+ const block = BLOCK_GEOMETRY[cell.char] !== undefined;
182
+ const box = BOX_DRAWING.test(cell.char);
183
+ const char = block ? cell.char : pinTextPresentation(cell.char, cell.width);
184
+ const next = {
185
+ startCol: cell.col,
186
+ widthCols: cell.width,
187
+ text: char,
188
+ ...e,
189
+ wide,
190
+ block,
191
+ box,
192
+ };
193
+ // A double-width cell, or a block-element cell drawn as geometry, must stand
194
+ // alone. Every run is absolutely positioned by an explicit `x`, but inside a
195
+ // run the font advances each glyph by its own (single-column) advance.
196
+ // Merging a wide glyph would push the remainder of the run one column left
197
+ // per wide cell; block cells are not emitted as text at all. Breaking the
198
+ // run here — and forbidding anything from merging onto a wide/block run —
199
+ // keeps every following run pinned to its true column.
200
+ //
201
+ // Box-drawing glyphs (borders, rules, the rounded corners of dialog and
202
+ // sub-menu frames) are isolated for a related reason: a box border is
203
+ // reached on different rows by runs of very different length — a single `│`
204
+ // on a body row, a 3-glyph `│ │` on an input-box row, a 100+-glyph
205
+ // `│ ╭───╮ │` on a frame edge. `lengthAdjust="spacing"` distributes the
206
+ // gap between our cell width and a renderer's (often wider, substituted)
207
+ // monospace advance across each run independently, so the SAME logical
208
+ // column lands at slightly different x per run length and the border
209
+ // zig-zags / doubles in Figma. Pinning every box glyph to its own
210
+ // single-glyph run gives each its own absolute `x`, so borders, corners and
211
+ // rules align column-perfectly in every renderer while keeping the exact
212
+ // font glyph (rounded corners included).
213
+ const mergeable = current !== null &&
214
+ !wide &&
215
+ !block &&
216
+ !box &&
217
+ !current.wide &&
218
+ !current.block &&
219
+ !current.box &&
220
+ sig(current) === sig(next) &&
221
+ cell.col === current.startCol + current.widthCols;
222
+ if (mergeable) {
223
+ current.text += char;
224
+ current.widthCols += cell.width;
225
+ }
226
+ else {
227
+ if (current)
228
+ runs.push(current);
229
+ current = next;
230
+ }
231
+ }
232
+ if (current)
233
+ runs.push(current);
234
+ // The emit loop groups column-contiguous foreground runs (text + the single
235
+ // spaces between them) into one <text> per stretch, so prose stays a single
236
+ // editable object and Figma flows colour changes seamlessly. Here we only need
237
+ // to break a run at interior gaps of 2+ spaces — which every SVG renderer
238
+ // collapses to one space — so the segment after the gap starts its own run and
239
+ // pins to its true column (a dialog box's interior padding can't pull its right
240
+ // border left). Leading whitespace is peeled into its own run so the first
241
+ // glyph keeps its own absolute x even though Figma trims a node's leading
242
+ // whitespace.
243
+ return runs
244
+ .flatMap((run) => splitAtInteriorGaps(run))
245
+ .flatMap(pinAfterLeadingSpace);
246
+ }
247
+ /**
248
+ * Split a run at interior gaps of two or more spaces so each visible segment is
249
+ * pinned to its own column.
250
+ *
251
+ * A run is positioned only by an explicit `x` on its first cell; the font then
252
+ * advances each later glyph itself. SVG renderers (Figma, headless Chrome, many
253
+ * editor previews) collapse a run of 2+ spaces to a single space, so a row that
254
+ * is one uniform-style run — a dialog/sub-menu box row like
255
+ * "│ Prompt: │" or a selected menu item "❯ 1. Yes " — loses its
256
+ * interior padding and its trailing border/segment slides left. Pinning such a
257
+ * run's end with `textLength` would instead stretch the few surviving glyphs
258
+ * across the whole row. Splitting the run at every 2+-space gap gives each
259
+ * visible segment (a border, a label, a nested border) its own absolute `x` on
260
+ * its true column, so it lands correctly regardless of how the renderer treats
261
+ * whitespace, and each segment is short and gap-free so `textLength` pins it
262
+ * without stretching.
263
+ *
264
+ * The split keeps full column coverage: the gap stretches are emitted as their
265
+ * own space-only sub-runs (skipped by the text pass but still drawn as
266
+ * background rects), so selection bars and other backgrounds tile exactly as
267
+ * before. Single spaces are never a gap, so ordinary words ("GitHub Copilot
268
+ * CLI", "❯ ok") stay one run and flow together in the emit loop. Wide/block/box
269
+ * runs are always a single cell and are never split. Column offsets are counted
270
+ * excluding VS15 (U+FE0E), which `pinTextPresentation` appends as a zero-width
271
+ * selector.
272
+ */
273
+ function splitAtInteriorGaps(run) {
274
+ if (run.wide || run.block || run.box)
275
+ return [run];
276
+ if (!/ {2,}/.test(run.text))
277
+ return [run];
278
+ const text = run.text;
279
+ const colsOf = (s) => {
280
+ let n = 0;
281
+ for (const cp of s)
282
+ if (cp !== '\uFE0E')
283
+ n++;
284
+ return n;
285
+ };
286
+ const parts = [];
287
+ let startCol = run.startCol;
288
+ const emit = (slice) => {
289
+ if (slice === '')
290
+ return;
291
+ const widthCols = colsOf(slice);
292
+ parts.push({ ...run, text: slice, startCol, widthCols });
293
+ startCol += widthCols;
294
+ };
295
+ const gap = / {2,}/g;
296
+ let cursor = 0;
297
+ let m;
298
+ while ((m = gap.exec(text)) !== null) {
299
+ emit(text.slice(cursor, m.index));
300
+ emit(m[0]);
301
+ cursor = m.index + m[0].length;
302
+ }
303
+ emit(text.slice(cursor));
304
+ return parts;
305
+ }
306
+ /**
307
+ * Pin a glyph that follows a gap to its true column.
308
+ *
309
+ * A run is positioned only by an explicit `x` on its first cell; the font then
310
+ * advances every later glyph by its own width. A run that is a long stretch of
311
+ * spaces followed by a visible glyph — the right-edge box border / scrollbar,
312
+ * which shares the white fill of the empty cells before it, so it merges into
313
+ * the same run — therefore lets that glyph ride N space-advances past the run's
314
+ * `x`. In any renderer whose space advance differs from our cell width by δ
315
+ * (Figma and some editor SVG previews substitute the monospace font) the glyph
316
+ * drifts by N·δ, so the right border zig-zags row to row: pinned on rows where
317
+ * its colour breaks the run, drifted on rows where it doesn't.
318
+ *
319
+ * Splitting the leading whitespace into its own run gives the trailing glyph its
320
+ * own absolute `x`, so it lands on its true column in every renderer. Both halves
321
+ * keep the same style (and background), so bars and gaps are unchanged. Runs with
322
+ * no leading whitespace (ordinary words like "GitHub Copilot CLI") and pure
323
+ * whitespace runs (separators/indents) are returned untouched, and wide/block
324
+ * runs — always a single cell — are never split.
325
+ */
326
+ function pinAfterLeadingSpace(run) {
327
+ if (run.wide || run.block || run.box)
328
+ return [run];
329
+ const lead = run.text.length - run.text.replace(/^ +/, '').length;
330
+ if (lead === 0 || lead === run.text.length)
331
+ return [run];
332
+ return [
333
+ { ...run, text: run.text.slice(0, lead), widthCols: lead },
334
+ {
335
+ ...run,
336
+ text: run.text.slice(lead),
337
+ startCol: run.startCol + lead,
338
+ widthCols: run.widthCols - lead,
339
+ },
340
+ ];
341
+ }
342
+ function escapeXml(s) {
343
+ return s
344
+ .replace(/&/g, '&amp;')
345
+ .replace(/</g, '&lt;')
346
+ .replace(/>/g, '&gt;')
347
+ .replace(/"/g, '&quot;');
348
+ }
349
+ function roundedTopRect(w, h, r) {
350
+ return `M0,${h} L0,${r} Q0,0 ${r},0 L${w - r},0 Q${w},0 ${w},${r} L${w},${h} Z`;
351
+ }
352
+ /**
353
+ * Windows 11 caption buttons (minimize, maximize, close) drawn as line/rect
354
+ * glyphs flush to the right edge of the title bar. Each button occupies a
355
+ * 46px-wide cell (the Windows DIP convention); only the glyphs are drawn so the
356
+ * bar reads as "at rest" with no hover backgrounds.
357
+ */
358
+ function winCaptionButtons(width, cy, fg, opacity) {
359
+ const capW = 46;
360
+ const g = 5; // half glyph size (10px icons)
361
+ const s = `stroke="${fg}" stroke-opacity="${opacity}" stroke-width="1" fill="none" shape-rendering="geometricPrecision"`;
362
+ const closeCx = width - capW / 2;
363
+ const maxCx = width - capW * 1.5;
364
+ const minCx = width - capW * 2.5;
365
+ return (`<line x1="${minCx - g}" y1="${cy}" x2="${minCx + g}" y2="${cy}" ${s}/>` +
366
+ `<rect x="${maxCx - g}" y="${cy - g}" width="${g * 2}" height="${g * 2}" rx="1" ${s}/>` +
367
+ `<line x1="${closeCx - g}" y1="${cy - g}" x2="${closeCx + g}" y2="${cy + g}" ${s}/>` +
368
+ `<line x1="${closeCx + g}" y1="${cy - g}" x2="${closeCx - g}" y2="${cy + g}" ${s}/>`);
369
+ }
370
+ /**
371
+ * The content extent of a set of per-row runs, in grid columns/rows: the
372
+ * right-most occupied column and the bottom-most occupied row (each at least 1).
373
+ * Shared by `renderSvg` (auto-fit) and `measureGrid` so a measured size always
374
+ * matches what `renderSvg` would draw.
375
+ */
376
+ function measureRuns(runsByRow) {
377
+ let rows = 0;
378
+ let cols = 1;
379
+ runsByRow.forEach((runs, y) => {
380
+ if (runs.length === 0)
381
+ return;
382
+ rows = y + 1;
383
+ const last = runs[runs.length - 1];
384
+ cols = Math.max(cols, last.startCol + last.widthCols);
385
+ });
386
+ return { cols, rows: Math.max(rows, 1) };
387
+ }
388
+ /**
389
+ * Measure a grid's content extent (columns × rows) exactly as `renderSvg` would
390
+ * auto-fit it. Used to compute a uniform animation canvas size across frames.
391
+ */
392
+ function measureGrid(grid, theme) {
393
+ return measureRuns(grid.lines.map((cells) => buildRuns(cells, theme)));
394
+ }
395
+ /** Render a styled grid into a self-contained, editable SVG document. */
396
+ function renderSvg(grid, opts) {
397
+ const { theme } = opts;
398
+ const cellW = opts.fontSize * opts.advance;
399
+ const cellH = Math.round(opts.fontSize * opts.lineHeight);
400
+ const runsByRow = grid.lines.map((cells) => buildRuns(cells, theme));
401
+ // Auto-fit canvas to the content actually present.
402
+ const fit = measureRuns(runsByRow);
403
+ // For uniform animation frames the caller can pin a minimum canvas size so
404
+ // every frame shares identical dimensions; real content never gets clipped.
405
+ const usedCols = Math.max(fit.cols, opts.fixedCols ?? 0);
406
+ const usedRows = Math.max(fit.rows, opts.fixedRows ?? 0);
407
+ // A typing cursor can sit one cell past the last glyph (an empty cell that
408
+ // background trimming has already dropped), so widen the canvas to keep it on
409
+ // screen. Clamp to a sane cell so a stray caret can't blow up the dimensions.
410
+ const cursor = opts.cursor && opts.cursor.row >= 0 && opts.cursor.col >= 0 ? opts.cursor : null;
411
+ const canvasCols = cursor ? Math.max(usedCols, cursor.col + 1) : usedCols;
412
+ const canvasRows = cursor ? Math.max(usedRows, cursor.row + 1) : usedRows;
413
+ const isWindows = opts.chromeStyle === 'windows' || opts.chromeStyle === 'windows-inactive';
414
+ const barH = opts.chrome ? (isWindows ? 32 : 40) : 0;
415
+ const contentLeft = opts.padding;
416
+ const contentTop = barH + opts.padding;
417
+ const width = Math.ceil(canvasCols * cellW + opts.padding * 2);
418
+ const height = Math.ceil(canvasRows * cellH + opts.padding * 2 + barH);
419
+ const x = (col) => +(contentLeft + col * cellW).toFixed(2);
420
+ const lineTop = (row) => contentTop + row * cellH;
421
+ const baseline = (row) => +(lineTop(row) + opts.fontSize).toFixed(2);
422
+ // Emit a block-element glyph as one or more geometric rects filling the cell,
423
+ // so it tiles edge to edge instead of leaving a line-height gap.
424
+ const blockRectsFor = (char, col, row, fill, opacity) => {
425
+ const geom = BLOCK_GEOMETRY[char];
426
+ if (!geom)
427
+ return [];
428
+ const cellLeft = x(col);
429
+ const cellRight = x(col + 1);
430
+ const cellWpx = cellRight - cellLeft;
431
+ const cellTop = lineTop(row);
432
+ const op = opacity * (geom.opacity ?? 1);
433
+ const opAttr = op !== 1 ? ` fill-opacity="${+op.toFixed(3)}"` : '';
434
+ return geom.rects.map(([fx, fy, fw, fh]) => {
435
+ const rx = +(cellLeft + cellWpx * fx).toFixed(2);
436
+ const ry = +(cellTop + cellH * fy).toFixed(2);
437
+ const rw = +(cellWpx * fw).toFixed(2);
438
+ const rh = +(cellH * fh).toFixed(2);
439
+ return (`<rect x="${rx}" y="${ry}" width="${rw}" height="${rh}" ` +
440
+ `fill="${fill}"${opAttr} shape-rendering="crispEdges"/>`);
441
+ });
442
+ };
443
+ const bgRects = [];
444
+ const blockRects = [];
445
+ const textLines = [];
446
+ runsByRow.forEach((runs, y) => {
447
+ if (runs.length === 0)
448
+ return;
449
+ for (const run of runs) {
450
+ if (run.bg) {
451
+ bgRects.push(`<rect x="${x(run.startCol)}" y="${lineTop(y)}" ` +
452
+ `width="${+(run.widthCols * cellW).toFixed(2)}" height="${cellH}" ` +
453
+ `fill="${run.bg}" shape-rendering="crispEdges"/>`);
454
+ }
455
+ // Block-element glyphs are drawn geometrically (gapless), on top of any bg.
456
+ if (run.block && run.visible) {
457
+ blockRects.push(...blockRectsFor(run.text, run.startCol, y, run.fill, run.opacity));
458
+ }
459
+ }
460
+ // Emit the row's text by grouping each run of column-contiguous foreground
461
+ // glyphs into ONE <text> element, with a <tspan> per colour/style segment.
462
+ //
463
+ // Figma's SVG import honours `x` on a <text> but *ignores* it on a child
464
+ // <tspan>: it flows a text node's tspans continuously at the font's own
465
+ // advance. Our grid uses SF Mono's true advance (see `advance`), so that flow
466
+ // lands on the same columns as the background rects and borders. A SINGLE
467
+ // <text> per contiguous stretch is therefore the faithful unit: the single
468
+ // spaces between words are real glyphs that flow (never collapse), a colour
469
+ // change is just the next tspan picking up where the last left off (so a long
470
+ // link can't overflow onto an adjacent period — the "Capture." overlap), and
471
+ // the whole sentence stays ONE editable object instead of a litter of
472
+ // per-word nodes. We only START A NEW <text> where the
473
+ // glyphs are genuinely discontiguous and must pin to their own column:
474
+ // • box / wide / block glyphs — each owns its cell (borders must tile, a
475
+ // wide glyph spans two columns), so they are emitted standalone;
476
+ // • background runs (selection bars, tab pills) — kept whole so their fill
477
+ // <rect> tiles as one span;
478
+ // • a real gap of 2+ spaces — collapsed to one space by Figma/Chrome, so it
479
+ // is dropped and the following segment pins to its true column (without
480
+ // this a box's interior padding would pull its right border left).
481
+ // Pure-whitespace runs carry no glyph (any background is a <rect>); a single
482
+ // space *between* two segments flows inside the group, but a leading/trailing
483
+ // one is dropped so the group still begins on its true column (Figma trims a
484
+ // node's leading whitespace).
485
+ const styleAttrs = (run) => {
486
+ const a = [`fill="${run.fill}"`];
487
+ if (run.weight !== 400)
488
+ a.push(`font-weight="${run.weight}"`);
489
+ if (run.italic)
490
+ a.push('font-style="italic"');
491
+ if (run.decoration) {
492
+ // A non-single style or a distinct underline color needs the CSS
493
+ // longhands (the `text-decoration` shorthand attribute can't carry
494
+ // them). Plain single underline / strike stay on the simple, widely
495
+ // supported presentation attribute.
496
+ const styleKey = run.underlineStyle ? CSS_UNDERLINE_STYLE[run.underlineStyle] : undefined;
497
+ if (styleKey || run.underlineColor) {
498
+ const parts = [`text-decoration-line:${run.decoration}`];
499
+ if (styleKey)
500
+ parts.push(`text-decoration-style:${styleKey}`);
501
+ if (run.underlineColor)
502
+ parts.push(`text-decoration-color:${run.underlineColor}`);
503
+ a.push(`style="${parts.join(';')}"`);
504
+ }
505
+ else {
506
+ a.push(`text-decoration="${run.decoration}"`);
507
+ }
508
+ }
509
+ if (run.opacity !== 1)
510
+ a.push(`fill-opacity="${run.opacity}"`);
511
+ return a;
512
+ };
513
+ const isStandalone = (run) => run.wide || run.block || run.box || !!run.bg;
514
+ const isBlankRun = (run) => run.text.trim() === '';
515
+ // The font *face* Figma resolves for a run. Figma's SVG import assigns ONE
516
+ // fontName (family + weight + italic) per <text> node — it honours per-<tspan>
517
+ // `fill` (so colour mixes inside one node) but IGNORES per-<tspan> weight and
518
+ // style, so a mixed-weight line collapses to a single weight. The family is
519
+ // global here, so weight+italic is the whole key; a change in it must split
520
+ // into a new pinned <text> (see the grouping loop), while colour/decoration
521
+ // stay as <tspan>s within one node.
522
+ const fontFace = (run) => `${run.weight}|${run.italic ? 'i' : ''}`;
523
+ // Emit a standalone run (border / wide glyph / selection bar) as its own
524
+ // column-pinned <text>, its end pinned to its true column with textLength so
525
+ // it can't ride the substitute font's advance and drift the frame.
526
+ const emitStandalone = (run) => {
527
+ const text = run.wide ? run.text : run.text.replace(/ +$/, '');
528
+ if (text.trim() === '')
529
+ return;
530
+ const widthCols = run.widthCols - (run.text.length - text.length);
531
+ const attrs = [
532
+ `x="${x(run.startCol)}"`,
533
+ `y="${baseline(y)}"`,
534
+ ...styleAttrs(run),
535
+ `textLength="${+(widthCols * cellW).toFixed(2)}"`,
536
+ `lengthAdjust="${run.wide ? 'spacingAndGlyphs' : 'spacing'}"`,
537
+ ];
538
+ textLines.push(`<text ${attrs.join(' ')}>${escapeXml(text)}</text>`);
539
+ };
540
+ // Emit a contiguous group as one <text>, coalescing adjacent same-style runs
541
+ // into a single tspan (so a uniform sentence is one plain <text>, and a
542
+ // multi-colour line is one <text> with a tspan per colour). textLength pins
543
+ // the group's end to its column span for renderers that honour it (Chrome,
544
+ // the still/MP4 rasteriser); Figma ignores it but flows the tspans, which is
545
+ // what keeps the colour boundaries seamless there.
546
+ const emitGroup = (group) => {
547
+ const segs = [];
548
+ for (const run of group) {
549
+ const attrs = styleAttrs(run);
550
+ const key = attrs.join(' ');
551
+ const last = segs[segs.length - 1];
552
+ if (last && last.key === key) {
553
+ last.text += run.text;
554
+ last.cols += run.widthCols;
555
+ }
556
+ else {
557
+ segs.push({ attrs, key, text: run.text, cols: run.widthCols });
558
+ }
559
+ }
560
+ // Rebalance whitespace at <tspan> boundaries. Figma preserves a trailing
561
+ // space on a tspan (verified) but TRIMS a leading or pure-whitespace one —
562
+ // so a single space that lands at the START of a tspan vanishes there. This
563
+ // bites the leading-icon rows: a coloured bullet "●", a "$" prompt, or a
564
+ // "·" separator is its own colour, the following space is the default
565
+ // colour, and the body is yet another colour, so the space becomes a
566
+ // standalone `<tspan> </tspan>` that Figma drops ("●No copilot…"). Move each
567
+ // segment's leading whitespace onto the previous segment's trailing edge,
568
+ // where Figma keeps it; spaces draw nothing so the colour they carry is
569
+ // irrelevant, and the concatenated text (and textLength) is unchanged, so
570
+ // Chrome/raster render identically. When removing a differently-styled space
571
+ // makes two same-style segments adjacent (e.g. a grey path, a default-colour
572
+ // space, then a grey "[⎇ main]"), re-coalesce them into one tspan so the
573
+ // output stays minimal.
574
+ const balanced = [];
575
+ for (const seg of segs) {
576
+ const lead = seg.text.length - seg.text.replace(/^ +/, '').length;
577
+ if (lead > 0 && balanced.length > 0) {
578
+ const prev = balanced[balanced.length - 1];
579
+ prev.text += seg.text.slice(0, lead);
580
+ prev.cols += lead;
581
+ seg.text = seg.text.slice(lead);
582
+ seg.cols -= lead;
583
+ }
584
+ if (seg.text.length === 0)
585
+ continue;
586
+ const prev = balanced[balanced.length - 1];
587
+ if (prev && prev.key === seg.key) {
588
+ prev.text += seg.text;
589
+ prev.cols += seg.cols;
590
+ }
591
+ else {
592
+ balanced.push(seg);
593
+ }
594
+ }
595
+ segs.length = 0;
596
+ segs.push(...balanced);
597
+ if (segs.length === 0)
598
+ return;
599
+ // Trailing spaces draw nothing and Figma trims them; drop them from the
600
+ // last segment so they don't pad textLength.
601
+ const last = segs[segs.length - 1];
602
+ const trimmed = last.text.replace(/ +$/, '');
603
+ last.cols -= last.text.length - trimmed.length;
604
+ last.text = trimmed;
605
+ if (last.text === '')
606
+ segs.pop();
607
+ if (segs.length === 0)
608
+ return;
609
+ // Figma trims a text node's LEADING whitespace, so the first glyph must own
610
+ // the pin column. A group that began right after a weight split can start on
611
+ // the separating space (it took the new weight); drop that leading space and
612
+ // advance the pin. The empty columns are reclaimed by this `x` pin, and
613
+ // textLength shrinks to match, so Chrome/raster stay pixel-identical.
614
+ let pinCol = group[0].startCol;
615
+ const lead0 = segs[0].text.length - segs[0].text.replace(/^ +/, '').length;
616
+ if (lead0 > 0) {
617
+ segs[0].text = segs[0].text.slice(lead0);
618
+ segs[0].cols -= lead0;
619
+ pinCol += lead0;
620
+ if (segs[0].text === '') {
621
+ segs.shift();
622
+ if (segs.length === 0)
623
+ return;
624
+ }
625
+ }
626
+ const totalCols = segs.reduce((n, s) => n + s.cols, 0);
627
+ const head = [
628
+ `x="${x(pinCol)}"`,
629
+ `y="${baseline(y)}"`,
630
+ `textLength="${+(totalCols * cellW).toFixed(2)}"`,
631
+ `lengthAdjust="spacing"`,
632
+ ];
633
+ if (segs.length === 1) {
634
+ const attrs = [head[0], head[1], ...segs[0].attrs, head[2], head[3]];
635
+ textLines.push(`<text ${attrs.join(' ')}>${escapeXml(segs[0].text)}</text>`);
636
+ }
637
+ else {
638
+ const body = segs
639
+ .map((s) => `<tspan ${s.attrs.join(' ')}>${escapeXml(s.text)}</tspan>`)
640
+ .join('');
641
+ textLines.push(`<text ${[`fill="${group[0].fill}"`, ...head].join(' ')}>${body}</text>`);
642
+ }
643
+ };
644
+ for (let i = 0; i < runs.length;) {
645
+ const run = runs[i];
646
+ if (!run.visible || run.block) {
647
+ i++;
648
+ continue;
649
+ }
650
+ if (isStandalone(run)) {
651
+ emitStandalone(run);
652
+ i++;
653
+ continue;
654
+ }
655
+ if (isBlankRun(run)) {
656
+ // A separator / indent / 2+-space gap between groups: carries no glyph,
657
+ // and the next group pins itself, so drop it.
658
+ i++;
659
+ continue;
660
+ }
661
+ // Start a contiguous group at this glyph run and pull in the following
662
+ // flow runs (more glyphs, and the single spaces between them) until a
663
+ // standalone glyph, a 2+-space gap, a weight/italic change, or the end of
664
+ // the row. A weight/italic change must break the group because Figma can't
665
+ // render mixed weight inside one <text> (see `fontFace`); the single space
666
+ // at such a boundary is weight-neutral and rides along, its column reclaimed
667
+ // by the next group's `x` pin.
668
+ const group = [run];
669
+ const groupFace = fontFace(run);
670
+ i++;
671
+ while (i < runs.length) {
672
+ const cur = runs[i];
673
+ if (!cur.visible || cur.block || isStandalone(cur))
674
+ break;
675
+ if (isBlankRun(cur) && cur.widthCols >= 2)
676
+ break;
677
+ if (!isBlankRun(cur) && fontFace(cur) !== groupFace)
678
+ break;
679
+ group.push(cur);
680
+ i++;
681
+ }
682
+ while (group.length && isBlankRun(group[group.length - 1]))
683
+ group.pop();
684
+ if (group.length)
685
+ emitGroup(group);
686
+ }
687
+ });
688
+ const cy = barH / 2;
689
+ // The typing cursor: a solid foreground block at the caret cell, drawn on top
690
+ // of the row's text so it reads as a real block caret.
691
+ const cursorRects = cursor
692
+ ? blockRectsFor('\u2588', cursor.col, cursor.row, theme.foreground, 1)
693
+ : [];
694
+ // Window controls: macOS traffic lights (left) or Windows caption buttons (right).
695
+ // Traffic-light geometry measured off a real macOS Tahoe Terminal and scaled to
696
+ // the 40px bar: Ø18 lights, 29px center-to-center, first light inset 20px,
697
+ // vertically centered (cy). Decoupled from content padding so they hug the corner.
698
+ const lightR = 9;
699
+ const lightGap = 29;
700
+ const lightInset = 20;
701
+ // macOS traffic-light fills. On an inactive (unfocused) window macOS drops the
702
+ // red/yellow/green and draws all three as the same flat grey (#464a4c,
703
+ // measured off a real Terminal screenshot).
704
+ const macLights = opts.chromeStyle === 'mac-inactive'
705
+ ? ['#464a4c', '#464a4c', '#464a4c']
706
+ : ['#ff5f57', '#febc2e', '#28c840'];
707
+ // Windows 11 title bar (Figma "SVG Import Diagnostic" nodes 52:22797 active /
708
+ // 52:22744 inactive): #202020 fill, rgba(117,117,117,0.4) border, white title
709
+ // + caption glyphs. An inactive (unfocused) window dims the title and glyphs to
710
+ // 36% white; the fill and border are unchanged.
711
+ const winInactive = opts.chromeStyle === 'windows-inactive';
712
+ const winFg = '#ffffff';
713
+ const winOpacity = winInactive ? 0.36 : 1;
714
+ const controls = !opts.chrome
715
+ ? ''
716
+ : isWindows
717
+ ? winCaptionButtons(width, cy, winFg, winOpacity)
718
+ : macLights
719
+ .map((c, i) => `<circle cx="${lightInset + i * lightGap}" cy="${cy}" r="${lightR}" fill="${c}"/>`)
720
+ .join('');
721
+ // macOS centers the title; Windows left-aligns it where the app name sits.
722
+ const titleEl = !opts.chrome
723
+ ? ''
724
+ : isWindows
725
+ ? `<text x="${opts.padding}" y="${cy + 4}" text-anchor="start" ` +
726
+ `font-family="'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif" font-size="12" ` +
727
+ `fill="${winFg}" fill-opacity="${winOpacity}">${escapeXml(opts.title)}</text>`
728
+ : `<text x="${width / 2}" y="${cy + 4}" text-anchor="middle" ` +
729
+ `font-family="-apple-system, 'Segoe UI', sans-serif" font-size="13" ` +
730
+ `fill="${theme.foreground}" fill-opacity="0.65">${escapeXml(opts.title)}</text>`;
731
+ // Windows chrome uses the Figma title-bar fill (#202020); macOS keeps the
732
+ // Primer muted token.
733
+ const chromeFill = isWindows ? '#202020' : theme.chrome;
734
+ const header = opts.chrome
735
+ ? `<path d="${roundedTopRect(width, barH, opts.radius)}" fill="${chromeFill}"/>` +
736
+ controls +
737
+ titleEl
738
+ : '';
739
+ const defs = opts.fontFace
740
+ ? [`<defs><style type="text/css"><![CDATA[${opts.fontFace}]]></style></defs>`]
741
+ : [];
742
+ const frameRect = `x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${opts.radius}"`;
743
+ // Windows 11 frames the whole window in a subtle rgba(117,117,117,0.4) border
744
+ // (Figma); macOS keeps the Primer border token.
745
+ const frameBorder = isWindows ? '#757575' : theme.border;
746
+ const frameBorderOpacity = isWindows ? 0.4 : 1;
747
+ return [
748
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
749
+ `viewBox="0 0 ${width} ${height}" font-family="${escapeXml(opts.fontFamily)}" ` +
750
+ `font-size="${opts.fontSize}">`,
751
+ ...defs,
752
+ // Background fill only. The 1px frame stroke is emitted LAST (below) so it
753
+ // wraps the whole window — chrome bar included — instead of being painted
754
+ // over by the chrome fill.
755
+ `<rect ${frameRect} fill="${theme.background}"/>`,
756
+ header,
757
+ `<g xml:space="preserve">`,
758
+ ...bgRects,
759
+ ...blockRects,
760
+ ...textLines,
761
+ ...cursorRects,
762
+ `</g>`,
763
+ `<rect ${frameRect} fill="none" stroke="${frameBorder}" stroke-opacity="${frameBorderOpacity}" stroke-width="1"/>`,
764
+ `</svg>`,
765
+ '',
766
+ ].join('\n');
767
+ }