@xfxstudio/claworld 2026.7.13-testing.1 → 2026.7.13-testing.2

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.
@@ -0,0 +1,475 @@
1
+ import {
2
+ clipDisplay,
3
+ displayCols,
4
+ ellipsizeText,
5
+ escapeXml,
6
+ fontFamily,
7
+ textUnits,
8
+ wrapText,
9
+ } from './transcript-report-stylekit.js';
10
+
11
+ const CANVAS_MARGIN = 24;
12
+ const FRAME_MARGIN = 16;
13
+ const HEADER_Y = 48;
14
+ const HEADER_CARD_HEIGHT_ONE_LINE = 92;
15
+ const HEADER_CARD_HEIGHT_TWO_LINES = 110;
16
+ const HEADER_BOTTOM_PAD = 20;
17
+ const BODY_TOP_GAP = 24;
18
+ const PAGE_BOTTOM = 54;
19
+ const ITEM_GAP = 22;
20
+ const TIME_ROW_HEIGHT = 42;
21
+ const ELLIPSIS_HEIGHT = 34;
22
+ const BUBBLE_PAD_X = 32;
23
+ const BUBBLE_PAD_Y = 22;
24
+ const LABEL_HEIGHT = 30;
25
+ const LABEL_OVERLAP = 18;
26
+ const LABEL_RAISE = 9;
27
+ const LABEL_MAX_COLS = 14;
28
+ const BUBBLE_MAX_RATIO = 0.64;
29
+ const BUBBLE_MIN_WIDTH = 200;
30
+ const FONT_SIZE = 18;
31
+ const SMALL_FONT_SIZE = 12;
32
+ const LABEL_FONT_SIZE = 15;
33
+ const TITLE_FONT_SIZE = 34;
34
+ const LINE_HEIGHT = 29;
35
+ const HEADER_SUBTITLE_MAX_UNITS = 33;
36
+ const HEADER_SUBTITLE_MAX_LINES = 2;
37
+ const HEADER_SUBTITLE_LINE_HEIGHT = 19;
38
+ const TAG_HEIGHT = 58;
39
+ const TAG_ICON_SIZE = 30;
40
+ const TAG_ICON_GAP = 12;
41
+ const TAG_ICON_TOP_GAP = 8;
42
+ const TAG_FALLBACK_MAX_COLS = 10;
43
+ const TEXT_UNIT_PX = 18;
44
+ const BLACK = '#090909';
45
+
46
+ const THEME = Object.freeze({
47
+ paper: '#FBF8EF',
48
+ headerFill: '#FEF5D8',
49
+ muted: '#222222',
50
+ leftFill: '#EFFFF5',
51
+ leftLabel: '#62E69D',
52
+ rightFill: '#EFE0FF',
53
+ rightLabel: '#B785FF',
54
+ timeFill: '#FFFDF7',
55
+ });
56
+
57
+ const TAG_ICON_THEMES = Object.freeze({
58
+ like: ['#D8F4FF', '#58B7FF'],
59
+ dislike: ['#FFE0EA', '#FF6A9A'],
60
+ 'request end': ['#FFF0A8', '#FF9F2F'],
61
+ });
62
+
63
+ const fixed = (value, places = 1) => Number(value).toFixed(places);
64
+
65
+ function labelText(label) {
66
+ return clipDisplay(String(label || 'AGENT').toUpperCase(), LABEL_MAX_COLS);
67
+ }
68
+
69
+ function labelWidth(label) {
70
+ return Math.max(86, Math.min(158, displayCols(label) * 11 + 30));
71
+ }
72
+
73
+ function tagName(tag) {
74
+ return String(tag ?? '').trim().toLowerCase();
75
+ }
76
+
77
+ function fallbackTagLabel(tag) {
78
+ return clipDisplay(String(tag || 'tag'), TAG_FALLBACK_MAX_COLS);
79
+ }
80
+
81
+ function tagWidth(tag) {
82
+ const normalized = tagName(tag);
83
+ if (['like', 'dislike', 'request end'].includes(normalized)) return TAG_ICON_SIZE;
84
+ return Math.max(58, Math.min(126, displayCols(fallbackTagLabel(normalized)) * 8 + 24));
85
+ }
86
+
87
+ function tagRowUnits(tags) {
88
+ if (!tags.length) return 0;
89
+ const width = tags.reduce((sum, tag) => sum + tagWidth(tag), 0)
90
+ + Math.max(0, tags.length - 1) * TAG_ICON_GAP;
91
+ return width / TEXT_UNIT_PX;
92
+ }
93
+
94
+ export function measureTranscriptItem(item, width) {
95
+ const contentWidth = Math.max(270, Math.trunc(width * BUBBLE_MAX_RATIO));
96
+ if (item.kind === 'ellipsis') {
97
+ return {
98
+ kind: 'ellipsis',
99
+ message: null,
100
+ lines: [],
101
+ width: contentWidth,
102
+ height: ELLIPSIS_HEIGHT,
103
+ omittedCount: item.omitted,
104
+ label: item.label,
105
+ };
106
+ }
107
+ if (item.kind === 'time') {
108
+ return {
109
+ kind: 'time',
110
+ message: null,
111
+ lines: [],
112
+ width: contentWidth,
113
+ height: TIME_ROW_HEIGHT,
114
+ label: item.label,
115
+ };
116
+ }
117
+
118
+ const { message } = item;
119
+ const maxUnits = Math.max(18, (contentWidth - BUBBLE_PAD_X * 2) / TEXT_UNIT_PX);
120
+ const lines = wrapText(message.text, maxUnits);
121
+ const labelUnits = textUnits(labelText(message.participantLabel)) + 4;
122
+ const contentUnits = Math.max(
123
+ ...lines.map((line) => textUnits(line)),
124
+ tagRowUnits(message.tags),
125
+ labelUnits,
126
+ 10,
127
+ );
128
+ const bubbleWidth = Math.trunc(Math.min(
129
+ contentWidth,
130
+ Math.max(BUBBLE_MIN_WIDTH, contentUnits * TEXT_UNIT_PX + BUBBLE_PAD_X * 2),
131
+ ));
132
+ const textHeight = lines.length * LINE_HEIGHT;
133
+ const tagHeight = message.tags.length ? TAG_HEIGHT : 0;
134
+ const bubbleHeight = BUBBLE_PAD_Y * 2 + textHeight + tagHeight;
135
+ return {
136
+ kind: 'message',
137
+ message,
138
+ lines,
139
+ width: bubbleWidth,
140
+ height: LABEL_HEIGHT - LABEL_OVERLAP + bubbleHeight + 10,
141
+ tagHeight,
142
+ textHeight,
143
+ };
144
+ }
145
+
146
+ function headerSubtitleLines(subtitle) {
147
+ const lines = wrapText(String(subtitle ?? '').trim(), HEADER_SUBTITLE_MAX_UNITS);
148
+ if (lines.length <= HEADER_SUBTITLE_MAX_LINES) return lines;
149
+ const visible = lines.slice(0, HEADER_SUBTITLE_MAX_LINES - 1);
150
+ const remainder = lines.slice(HEADER_SUBTITLE_MAX_LINES - 1)
151
+ .map((line) => line.trim())
152
+ .filter(Boolean)
153
+ .join(' ');
154
+ visible.push(ellipsizeText(remainder, HEADER_SUBTITLE_MAX_UNITS, '…'));
155
+ return visible;
156
+ }
157
+
158
+ function headerCardHeight(subtitle) {
159
+ return headerSubtitleLines(subtitle).length > 1
160
+ ? HEADER_CARD_HEIGHT_TWO_LINES
161
+ : HEADER_CARD_HEIGHT_ONE_LINE;
162
+ }
163
+
164
+ function headerHeight(subtitle) {
165
+ return HEADER_Y + headerCardHeight(subtitle) + HEADER_BOTTOM_PAD;
166
+ }
167
+
168
+ function positions(width, bubbleWidth, label, side) {
169
+ const currentLabelWidth = labelWidth(label);
170
+ const inset = CANVAS_MARGIN + 38;
171
+ if (side === 'right') {
172
+ const bubbleX = width - inset - bubbleWidth;
173
+ return {
174
+ bubbleX,
175
+ labelX: bubbleX + bubbleWidth - currentLabelWidth - 16,
176
+ labelWidth: currentLabelWidth,
177
+ align: 'right',
178
+ };
179
+ }
180
+ const bubbleX = inset;
181
+ return {
182
+ bubbleX,
183
+ labelX: bubbleX + 16,
184
+ labelWidth: currentLabelWidth,
185
+ align: 'left',
186
+ };
187
+ }
188
+
189
+ export function paginateTranscriptItems(items, width, maxHeight, title, subtitle) {
190
+ const pageGroups = [[]];
191
+ const currentHeaderHeight = headerHeight(subtitle);
192
+ let used = currentHeaderHeight + BODY_TOP_GAP + PAGE_BOTTOM;
193
+ items.forEach((item, index) => {
194
+ const itemHeight = item.height + ITEM_GAP;
195
+ let neededHeight = itemHeight;
196
+ if (item.kind === 'time' && index + 1 < items.length) {
197
+ neededHeight += items[index + 1].height + ITEM_GAP;
198
+ }
199
+ if (pageGroups.at(-1).length && used + neededHeight > maxHeight) {
200
+ pageGroups.push([]);
201
+ used = currentHeaderHeight + BODY_TOP_GAP + PAGE_BOTTOM;
202
+ }
203
+ pageGroups.at(-1).push(item);
204
+ used += itemHeight;
205
+ });
206
+
207
+ return pageGroups.map((pageItems, pageIndex) => {
208
+ let y = currentHeaderHeight + BODY_TOP_GAP;
209
+ const layoutItems = [];
210
+ for (const item of pageItems) {
211
+ if (item.kind === 'ellipsis' || item.kind === 'time') {
212
+ layoutItems.push({ kind: item.kind, y, height: item.height, label: item.label });
213
+ y += item.height + ITEM_GAP;
214
+ continue;
215
+ }
216
+ const label = labelText(item.message.participantLabel);
217
+ const position = positions(width, item.width, label, item.message.side);
218
+ const bubbleY = y + LABEL_HEIGHT - LABEL_OVERLAP;
219
+ const bubbleHeight = BUBBLE_PAD_Y * 2 + item.textHeight + item.tagHeight;
220
+ layoutItems.push({
221
+ kind: 'message',
222
+ y,
223
+ ...position,
224
+ bubbleY,
225
+ labelY: y - LABEL_RAISE,
226
+ label,
227
+ width: item.width,
228
+ bubbleHeight,
229
+ lines: item.lines,
230
+ message: item.message,
231
+ tagHeight: item.tagHeight,
232
+ textHeight: item.textHeight,
233
+ });
234
+ y += item.height + ITEM_GAP;
235
+ }
236
+ return {
237
+ page: pageIndex + 1,
238
+ width,
239
+ height: Math.max(520, Math.min(maxHeight, y + PAGE_BOTTOM)),
240
+ items: layoutItems,
241
+ title,
242
+ subtitle,
243
+ footer: 'visit claworld.love',
244
+ };
245
+ });
246
+ }
247
+
248
+ function svgDefs() {
249
+ return [
250
+ '<defs>',
251
+ `<style><![CDATA[text { font-family: ${fontFamily()}; letter-spacing: 0; } .message-row:hover rect:last-of-type { filter: url(#comicLift); }]]></style>`,
252
+ '<pattern id="comicGridMinor" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M 32 0 L 0 0 0 32" fill="none" stroke="#BED1D8" stroke-width="1" stroke-opacity="0.62"/></pattern>',
253
+ '<pattern id="comicGridMajor" width="128" height="128" patternUnits="userSpaceOnUse"><path d="M 128 0 L 0 0 0 128" fill="none" stroke="#AABFC8" stroke-width="1.4" stroke-opacity="0.72"/></pattern>',
254
+ '<linearGradient id="headerAccent" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#47B6FF"/><stop offset="52%" stop-color="#FF4EB4"/><stop offset="100%" stop-color="#FF8A2A"/></linearGradient>',
255
+ '<linearGradient id="leftAccent" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#58E58F"/><stop offset="100%" stop-color="#47B6FF"/></linearGradient>',
256
+ '<linearGradient id="rightAccent" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#A871FF"/><stop offset="100%" stop-color="#FF4EB4"/></linearGradient>',
257
+ '<filter id="comicLift" x="-6%" y="-16%" width="112%" height="132%"><feDropShadow dx="0" dy="2" stdDeviation="1.2" flood-color="#000000" flood-opacity="0.14"/></filter>',
258
+ '</defs>',
259
+ ].join('\n');
260
+ }
261
+
262
+ function starPoints(cx, cy, radius) {
263
+ return [
264
+ [cx, cy - radius],
265
+ [cx + radius * 0.28, cy - radius * 0.28],
266
+ [cx + radius, cy],
267
+ [cx + radius * 0.28, cy + radius * 0.28],
268
+ [cx, cy + radius],
269
+ [cx - radius * 0.28, cy + radius * 0.28],
270
+ [cx - radius, cy],
271
+ [cx - radius * 0.28, cy - radius * 0.28],
272
+ ];
273
+ }
274
+
275
+ function decorativeStarSvg(cx, cy, radius, fill, accent) {
276
+ const points = (offsetX, offsetY) => starPoints(cx + offsetX, cy + offsetY, radius)
277
+ .map(([x, y]) => `${fixed(x)},${fixed(y)}`)
278
+ .join(' ');
279
+ return [
280
+ `<polygon points="${points(3, 5)}" fill="${accent}"/>`,
281
+ `<polygon points="${points(0, 0)}" fill="${fill}" stroke="${BLACK}" stroke-width="3"/>`,
282
+ ].join('\n');
283
+ }
284
+
285
+ function headerTitle(title) {
286
+ const clean = String(title ?? '').trim();
287
+ if (clean.startsWith('@')) return clean;
288
+ return clean ? `@${clean}` : '@claworld';
289
+ }
290
+
291
+ function renderHeader(page) {
292
+ const x = CANVAS_MARGIN + 26;
293
+ const y = HEADER_Y;
294
+ const width = page.width - (CANVAS_MARGIN + 26) * 2;
295
+ const height = headerCardHeight(page.subtitle);
296
+ const title = clipDisplay(headerTitle(page.title), 32);
297
+ const subtitle = headerSubtitleLines(page.subtitle)
298
+ .map((line, index) => `<text class="header-subtitle-line" x="${fixed(x + 35)}" y="${fixed(y + 70 + index * HEADER_SUBTITLE_LINE_HEIGHT)}" font-size="15" font-weight="600" fill="${THEME.muted}">${escapeXml(line)}</text>`)
299
+ .join('\n');
300
+ return [
301
+ `<rect x="${x + 11}" y="${y + 6}" width="${width + 2}" height="${height + 10}" rx="22" fill="${BLACK}"/>`,
302
+ `<rect x="${x + 7}" y="${y + 6}" width="${width}" height="${height + 4}" rx="22" fill="url(#headerAccent)"/>`,
303
+ `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="22" fill="${THEME.headerFill}" stroke="${BLACK}" stroke-width="4"/>`,
304
+ `<text x="${x + 28}" y="${y + 43}" font-size="${TITLE_FONT_SIZE}" font-weight="900" fill="${BLACK}">${escapeXml(title)}</text>`,
305
+ subtitle,
306
+ decorativeStarSvg(x + width - 62, y + 34, 22, '#FFFFFF', 'url(#headerAccent)'),
307
+ `<circle cx="${x + width - 23}" cy="${y + 55}" r="9" fill="#72E3C0" stroke="${BLACK}" stroke-width="3"/>`,
308
+ `<circle cx="${x + width - 25}" cy="${y + 53}" r="9" fill="#72E3C0" stroke="${BLACK}" stroke-width="3"/>`,
309
+ ].join('\n');
310
+ }
311
+
312
+ function diamondSvg(cx, cy, radius, fill) {
313
+ const shadow = `<polygon points="${fixed(cx + 1)},${fixed(cy + 2 - radius)} ${fixed(cx + 1 + radius * 0.46)},${fixed(cy + 2)} ${fixed(cx + 1)},${fixed(cy + 2 + radius)} ${fixed(cx + 1 - radius * 0.46)},${fixed(cy + 2)}" fill="${BLACK}" stroke="${BLACK}" stroke-width="2.5"/>`;
314
+ const front = `<polygon points="${fixed(cx)},${fixed(cy - radius)} ${fixed(cx + radius * 0.46)},${fixed(cy)} ${fixed(cx)},${fixed(cy + radius)} ${fixed(cx - radius * 0.46)},${fixed(cy)}" fill="${fill}" stroke="${BLACK}" stroke-width="2.5"/>`;
315
+ return `${shadow}\n${front}`;
316
+ }
317
+
318
+ function renderTimeSvg(page, item) {
319
+ const label = clipDisplay(item.label, 22);
320
+ const labelWidthValue = Math.max(150, displayCols(label) * 8 + 44);
321
+ const x = page.width / 2 - labelWidthValue / 2;
322
+ const y = item.y + 4;
323
+ return [
324
+ '<g class="time-row">',
325
+ diamondSvg(x - 28, y + 16, 13, '#FF5BE2'),
326
+ `<rect x="${fixed(x + 3)}" y="${y + 4}" width="${labelWidthValue}" height="30" rx="15" fill="${BLACK}"/>`,
327
+ `<rect x="${fixed(x)}" y="${y}" width="${labelWidthValue}" height="30" rx="15" fill="${THEME.timeFill}" stroke="${BLACK}" stroke-width="3"/>`,
328
+ `<text x="${fixed(page.width / 2)}" y="${y + 21}" text-anchor="middle" font-size="${FONT_SIZE}" font-weight="700" fill="${BLACK}">${escapeXml(label)}</text>`,
329
+ diamondSvg(x + labelWidthValue + 28, y + 16, 13, '#5FE0A7'),
330
+ '</g>',
331
+ ].join('\n');
332
+ }
333
+
334
+ function sideColors(side) {
335
+ return side === 'right'
336
+ ? { fill: THEME.rightFill, label: THEME.rightLabel, accent: 'url(#rightAccent)' }
337
+ : { fill: THEME.leftFill, label: THEME.leftLabel, accent: 'url(#leftAccent)' };
338
+ }
339
+
340
+ function bubbleLayersSvg(item, colors) {
341
+ const { bubbleX: x, bubbleY: y, width, bubbleHeight: height } = item;
342
+ return [
343
+ `<rect x="${x + 11}" y="${y + 9}" width="${width + 2}" height="${height + 4}" rx="17" fill="${BLACK}"/>`,
344
+ `<rect x="${x + 11}" y="${y + 9}" width="${width - 3}" height="${height}" rx="17" fill="${colors.accent}" stroke="${BLACK}" stroke-width="3"/>`,
345
+ `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="17" fill="${colors.fill}" stroke="${BLACK}" stroke-width="4"/>`,
346
+ ].join('\n');
347
+ }
348
+
349
+ function thumbIconSvg(x, y, accent, down = false) {
350
+ const paths = [
351
+ `<path d="M${fixed(x + 7.8)} ${fixed(y + 13.3)} H${fixed(x + 12)} V${fixed(y + 24)} H${fixed(x + 7.8)} Z" fill="#FFFFFF" stroke="${BLACK}" stroke-width="2" stroke-linejoin="round"/>`,
352
+ `<path d="M${fixed(x + 12)} ${fixed(y + 23.6)} H${fixed(x + 21.2)} C${fixed(x + 23)} ${fixed(y + 23.6)} ${fixed(x + 24)} ${fixed(y + 22.5)} ${fixed(x + 24.4)} ${fixed(y + 20.9)} L${fixed(x + 25.4)} ${fixed(y + 15.6)} C${fixed(x + 25.8)} ${fixed(y + 13.9)} ${fixed(x + 24.5)} ${fixed(y + 12.2)} ${fixed(x + 22.7)} ${fixed(y + 12.2)} H${fixed(x + 18.6)} L${fixed(x + 19.2)} ${fixed(y + 9.1)} C${fixed(x + 19.5)} ${fixed(y + 7.4)} ${fixed(x + 18.4)} ${fixed(y + 5.7)} ${fixed(x + 16.7)} ${fixed(y + 5.4)} L${fixed(x + 15.8)} ${fixed(y + 5.3)} L${fixed(x + 12)} ${fixed(y + 12.9)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="2" stroke-linejoin="round"/>`,
353
+ ].join('\n');
354
+ if (!down) return paths;
355
+ const centerX = x + TAG_ICON_SIZE / 2;
356
+ const centerY = y + TAG_ICON_SIZE / 2;
357
+ return `<g transform="rotate(180 ${fixed(centerX)} ${fixed(centerY)})">\n${paths}\n</g>`;
358
+ }
359
+
360
+ function requestEndIconSvg(x, y, accent) {
361
+ return [
362
+ `<path d="M${fixed(x + 22)} ${fixed(y + 4)} C${fixed(x + 25.2)} ${fixed(y + 5.4)} ${fixed(x + 26.6)} ${fixed(y + 7.8)} ${fixed(x + 26.5)} ${fixed(y + 10.6)}" fill="none" stroke="${BLACK}" stroke-width="2" stroke-linecap="round"/>`,
363
+ `<path d="M${fixed(x + 9.215, 3)} ${fixed(y + 18.117, 3)} C${fixed(x + 10.043, 3)} ${fixed(y + 22.457, 3)} ${fixed(x + 13.384, 3)} ${fixed(y + 25.036, 3)} ${fixed(x + 17.132, 3)} ${fixed(y + 23.961, 3)} L${fixed(x + 19.44, 3)} ${fixed(y + 23.3, 3)} C${fixed(x + 22.323, 3)} ${fixed(y + 22.473, 3)} ${fixed(x + 23.488, 3)} ${fixed(y + 19.642, 3)} ${fixed(x + 22.538, 3)} ${fixed(y + 16.69, 3)} L${fixed(x + 21.435, 3)} ${fixed(y + 12.845, 3)} L${fixed(x + 9.227, 3)} ${fixed(y + 16.345, 3)} L${fixed(x + 9.215, 3)} ${fixed(y + 18.117, 3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="2" stroke-linejoin="round"/>`,
364
+ `<path d="M${fixed(x + 9.665, 3)} ${fixed(y + 7.897, 3)} L${fixed(x + 9.473, 3)} ${fixed(y + 7.952, 3)} C${fixed(x + 8.756, 3)} ${fixed(y + 8.158, 3)} ${fixed(x + 8.286, 3)} ${fixed(y + 8.709, 3)} ${fixed(x + 8.422, 3)} ${fixed(y + 9.184, 3)} L${fixed(x + 10.192, 3)} ${fixed(y + 15.356, 3)} C${fixed(x + 10.328, 3)} ${fixed(y + 15.831, 3)} ${fixed(x + 11.019, 3)} ${fixed(y + 16.049, 3)} ${fixed(x + 11.736, 3)} ${fixed(y + 15.843, 3)} L${fixed(x + 11.928, 3)} ${fixed(y + 15.788, 3)} C${fixed(x + 12.645, 3)} ${fixed(y + 15.583, 3)} ${fixed(x + 13.115, 3)} ${fixed(y + 15.031, 3)} ${fixed(x + 12.979, 3)} ${fixed(y + 14.557, 3)} L${fixed(x + 11.209, 3)} ${fixed(y + 8.384, 3)} C${fixed(x + 11.073, 3)} ${fixed(y + 7.91, 3)} ${fixed(x + 10.382, 3)} ${fixed(y + 7.692, 3)} ${fixed(x + 9.665, 3)} ${fixed(y + 7.897, 3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
365
+ `<path d="M${fixed(x + 11.93, 3)} ${fixed(y + 5.999, 3)} L${fixed(x + 11.738, 3)} ${fixed(y + 6.055, 3)} C${fixed(x + 11.021, 3)} ${fixed(y + 6.26, 3)} ${fixed(x + 10.553, 3)} ${fixed(y + 6.82, 3)} ${fixed(x + 10.692, 3)} ${fixed(y + 7.306, 3)} L${fixed(x + 12.729, 3)} ${fixed(y + 14.408, 3)} C${fixed(x + 12.868, 3)} ${fixed(y + 14.894, 3)} ${fixed(x + 13.562, 3)} ${fixed(y + 15.122, 3)} ${fixed(x + 14.279, 3)} ${fixed(y + 14.916, 3)} L${fixed(x + 14.471, 3)} ${fixed(y + 14.861, 3)} C${fixed(x + 15.188, 3)} ${fixed(y + 14.655, 3)} ${fixed(x + 15.656, 3)} ${fixed(y + 14.095, 3)} ${fixed(x + 15.516, 3)} ${fixed(y + 13.609, 3)} L${fixed(x + 13.48, 3)} ${fixed(y + 6.507, 3)} C${fixed(x + 13.341, 3)} ${fixed(y + 6.021, 3)} ${fixed(x + 12.647, 3)} ${fixed(y + 5.794, 3)} ${fixed(x + 11.93, 3)} ${fixed(y + 5.999, 3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
366
+ `<path d="M${fixed(x + 14.636, 3)} ${fixed(y + 5.64, 3)} L${fixed(x + 14.443, 3)} ${fixed(y + 5.695, 3)} C${fixed(x + 13.727, 3)} ${fixed(y + 5.9, 3)} ${fixed(x + 13.258, 3)} ${fixed(y + 6.457, 3)} ${fixed(x + 13.396, 3)} ${fixed(y + 6.938, 3)} L${fixed(x + 15.338, 3)} ${fixed(y + 13.714, 3)} C${fixed(x + 15.476, 3)} ${fixed(y + 14.195, 3)} ${fixed(x + 16.169, 3)} ${fixed(y + 14.418, 3)} ${fixed(x + 16.886, 3)} ${fixed(y + 14.213, 3)} L${fixed(x + 17.078, 3)} ${fixed(y + 14.157, 3)} C${fixed(x + 17.795, 3)} ${fixed(y + 13.952, 3)} ${fixed(x + 18.264, 3)} ${fixed(y + 13.395, 3)} ${fixed(x + 18.126, 3)} ${fixed(y + 12.914, 3)} L${fixed(x + 16.183, 3)} ${fixed(y + 6.139, 3)} C${fixed(x + 16.045, 3)} ${fixed(y + 5.658, 3)} ${fixed(x + 15.352, 3)} ${fixed(y + 5.434, 3)} ${fixed(x + 14.636, 3)} ${fixed(y + 5.64, 3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
367
+ `<path d="M${fixed(x + 17.8, 3)} ${fixed(y + 6.085, 3)} L${fixed(x + 17.702, 3)} ${fixed(y + 6.113, 3)} C${fixed(x + 16.971, 3)} ${fixed(y + 6.322, 3)} ${fixed(x + 16.482, 3)} ${fixed(y + 6.851, 3)} ${fixed(x + 16.609, 3)} ${fixed(y + 7.294, 3)} L${fixed(x + 18.176, 3)} ${fixed(y + 12.759, 3)} C${fixed(x + 18.303, 3)} ${fixed(y + 13.202, 3)} ${fixed(x + 18.998, 3)} ${fixed(y + 13.391, 3)} ${fixed(x + 19.729, 3)} ${fixed(y + 13.182, 3)} L${fixed(x + 19.827, 3)} ${fixed(y + 13.154, 3)} C${fixed(x + 20.557, 3)} ${fixed(y + 12.944, 3)} ${fixed(x + 21.046, 3)} ${fixed(y + 12.415, 3)} ${fixed(x + 20.919, 3)} ${fixed(y + 11.972, 3)} L${fixed(x + 19.352, 3)} ${fixed(y + 6.507, 3)} C${fixed(x + 19.225, 3)} ${fixed(y + 6.065, 3)} ${fixed(x + 18.53, 3)} ${fixed(y + 5.875, 3)} ${fixed(x + 17.8, 3)} ${fixed(y + 6.085, 3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
368
+ `<path d="M${fixed(x + 9.546, 3)} ${fixed(y + 19.999, 3)} L${fixed(x + 6, 3)} ${fixed(y + 17.791, 3)} C${fixed(x + 4.736, 3)} ${fixed(y + 17.009, 3)} ${fixed(x + 5.668, 3)} ${fixed(y + 15.181, 3)} ${fixed(x + 7.138, 3)} ${fixed(y + 15.592, 3)} L${fixed(x + 10.615, 3)} ${fixed(y + 17.196, 3)} L${fixed(x + 9.546, 3)} ${fixed(y + 19.999, 3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8" stroke-linejoin="round"/>`,
369
+ `<rect x="${fixed(x + 10.887, 3)}" y="${fixed(y + 14.834, 3)}" width="9.584" height="3.800" transform="rotate(-16.9438 ${fixed(x + 10.887, 3)} ${fixed(y + 14.834, 3)})" fill="${accent}"/>`,
370
+ `<path d="M${fixed(x + 10.5)} ${fixed(y + 19.5)} L${fixed(x + 11)} ${fixed(y + 20)} L${fixed(x + 11.5)} ${fixed(y + 17)} L${fixed(x + 9.5)} ${fixed(y + 17.5)} L${fixed(x + 9)} ${fixed(y + 18.5)} L${fixed(x + 10.5)} ${fixed(y + 19.5)} Z" fill="${accent}"/>`,
371
+ ].join('\n');
372
+ }
373
+
374
+ function fallbackTagSvg(tag, x, y) {
375
+ const label = fallbackTagLabel(tag);
376
+ const width = tagWidth(tag);
377
+ return [
378
+ `<g class="tag-icon tag-fallback" role="img" aria-label="${escapeXml(label)}">`,
379
+ `<title>${escapeXml(label)}</title>`,
380
+ `<rect x="${fixed(x + 3)}" y="${fixed(y + 4)}" width="${width}" height="${TAG_ICON_SIZE}" rx="9" fill="${BLACK}"/>`,
381
+ `<rect x="${fixed(x)}" y="${fixed(y)}" width="${width}" height="${TAG_ICON_SIZE}" rx="9" fill="#F6F1FF" stroke="${BLACK}" stroke-width="2.5"/>`,
382
+ `<text x="${fixed(x + width / 2)}" y="${fixed(y + 20.5)}" text-anchor="middle" font-size="${LABEL_FONT_SIZE}" font-weight="900" fill="${BLACK}">${escapeXml(label)}</text>`,
383
+ '</g>',
384
+ ].join('\n');
385
+ }
386
+
387
+ function tagIconSvg(tag, x, y) {
388
+ const normalized = tagName(tag);
389
+ if (!['like', 'dislike', 'request end'].includes(normalized)) {
390
+ return fallbackTagSvg(normalized, x, y);
391
+ }
392
+ const [fill, accent] = TAG_ICON_THEMES[normalized];
393
+ const icon = normalized === 'request end'
394
+ ? requestEndIconSvg(x, y, accent)
395
+ : thumbIconSvg(x, y, accent, normalized === 'dislike');
396
+ return [
397
+ `<g class="tag-icon tag-${escapeXml(normalized.replaceAll(' ', '-'))}" role="img" aria-label="${escapeXml(normalized)}">`,
398
+ `<title>${escapeXml(normalized)}</title>`,
399
+ `<rect x="${fixed(x + 3)}" y="${fixed(y + 4)}" width="${TAG_ICON_SIZE}" height="${TAG_ICON_SIZE}" rx="9" fill="${BLACK}"/>`,
400
+ `<rect x="${fixed(x)}" y="${fixed(y)}" width="${TAG_ICON_SIZE}" height="${TAG_ICON_SIZE}" rx="9" fill="${fill}" stroke="${BLACK}" stroke-width="2.5"/>`,
401
+ icon,
402
+ '</g>',
403
+ ].join('\n');
404
+ }
405
+
406
+ function renderTagIconsSvg(tags, x, y) {
407
+ const parts = [`<g class="tag-icons" transform="translate(${fixed(x)} ${fixed(y)})">`];
408
+ let cursor = 0;
409
+ for (const tag of tags) {
410
+ parts.push(tagIconSvg(tag, cursor, 0));
411
+ cursor += tagWidth(tag) + TAG_ICON_GAP;
412
+ }
413
+ parts.push('</g>');
414
+ return parts.join('\n');
415
+ }
416
+
417
+ function renderMessageSvg(item) {
418
+ const { message } = item;
419
+ const colors = sideColors(message.side);
420
+ const accessibleLabel = escapeXml(`${message.participantLabel}: ${ellipsizeText(message.text, 42)}`);
421
+ const parts = [
422
+ `<g class="message-row ${message.side}" role="listitem" aria-label="${accessibleLabel}">`,
423
+ `<title>${accessibleLabel}</title>`,
424
+ bubbleLayersSvg(item, colors),
425
+ `<rect x="${item.labelX}" y="${item.labelY}" width="${item.labelWidth}" height="${LABEL_HEIGHT}" rx="9" fill="${colors.label}" stroke="${BLACK}" stroke-width="3"/>`,
426
+ `<text x="${fixed(item.labelX + item.labelWidth / 2)}" y="${item.labelY + 21}" text-anchor="middle" font-size="${LABEL_FONT_SIZE}" font-weight="900" fill="${BLACK}">${escapeXml(item.label)}</text>`,
427
+ ];
428
+ const textX = item.bubbleX + BUBBLE_PAD_X;
429
+ let textY = item.bubbleY + BUBBLE_PAD_Y + 17;
430
+ for (const line of item.lines) {
431
+ parts.push(`<text x="${textX}" y="${textY}" font-size="${FONT_SIZE}" font-weight="800" fill="${BLACK}">${escapeXml(line)}</text>`);
432
+ textY += LINE_HEIGHT;
433
+ }
434
+ if (message.tags.length) {
435
+ parts.push(renderTagIconsSvg(message.tags, textX, textY + TAG_ICON_TOP_GAP));
436
+ }
437
+ parts.push('</g>');
438
+ return parts.join('\n');
439
+ }
440
+
441
+ export function renderTranscriptPageSvg(page) {
442
+ const titleId = `claworld-report-title-${page.page}`;
443
+ const descriptionId = `claworld-report-desc-${page.page}`;
444
+ const description = `${page.title}. ${page.subtitle}. ${page.items.length} transcript rows.`;
445
+ const parts = [
446
+ `<svg class="comic-grid" xmlns="http://www.w3.org/2000/svg" width="${page.width}" height="${page.height}" viewBox="0 0 ${page.width} ${page.height}" role="img" aria-labelledby="${titleId} ${descriptionId}">`,
447
+ `<title id="${titleId}">${escapeXml(page.title)}</title>`,
448
+ `<desc id="${descriptionId}">${escapeXml(description)}</desc>`,
449
+ svgDefs(),
450
+ `<rect x="0" y="0" width="${page.width}" height="${page.height}" fill="${THEME.paper}"/>`,
451
+ `<rect x="0" y="0" width="${page.width}" height="${page.height}" fill="url(#comicGridMinor)"/>`,
452
+ `<rect x="0" y="0" width="${page.width}" height="${page.height}" fill="url(#comicGridMajor)" opacity="0.46"/>`,
453
+ `<rect x="${FRAME_MARGIN}" y="${FRAME_MARGIN}" width="${page.width - FRAME_MARGIN * 2}" height="${page.height - FRAME_MARGIN * 2}" rx="46" fill="none" stroke="${BLACK}" stroke-width="6"/>`,
454
+ renderHeader(page),
455
+ '<g role="list">',
456
+ ];
457
+ for (const item of page.items) {
458
+ if (item.kind === 'ellipsis') {
459
+ const y = item.y + 7;
460
+ parts.push(`<text x="${fixed(page.width / 2)}" y="${y + 14}" text-anchor="middle" font-size="${SMALL_FONT_SIZE}" fill="#555555">${escapeXml(item.label)}</text>`);
461
+ } else if (item.kind === 'time') {
462
+ parts.push(renderTimeSvg(page, item));
463
+ } else {
464
+ parts.push(renderMessageSvg(item));
465
+ }
466
+ }
467
+ parts.push('</g>');
468
+ if (page.footer) {
469
+ parts.push(`<text x="${fixed(page.width / 2)}" y="${page.height - 24}" text-anchor="middle" font-size="${SMALL_FONT_SIZE}" fill="#444444">${escapeXml(page.footer)}</text>`);
470
+ }
471
+ parts.push('</svg>');
472
+ return parts.join('\n');
473
+ }
474
+
475
+ export const CLAWORLD_TRANSCRIPT_STYLE_NAME = 'claworld-comic-grid';
@@ -0,0 +1,189 @@
1
+ const FULL_WIDTH_RANGES = [
2
+ [0x1100, 0x115f],
3
+ [0x231a, 0x231b],
4
+ [0x2329, 0x232a],
5
+ [0x23e9, 0x23ec],
6
+ [0x23f0, 0x23f0],
7
+ [0x23f3, 0x23f3],
8
+ [0x25fd, 0x25fe],
9
+ [0x2614, 0x2615],
10
+ [0x2648, 0x2653],
11
+ [0x267f, 0x267f],
12
+ [0x2693, 0x2693],
13
+ [0x26a1, 0x26a1],
14
+ [0x26aa, 0x26ab],
15
+ [0x26bd, 0x26be],
16
+ [0x26c4, 0x26c5],
17
+ [0x26ce, 0x26ce],
18
+ [0x26d4, 0x26d4],
19
+ [0x26ea, 0x26ea],
20
+ [0x26f2, 0x26f3],
21
+ [0x26f5, 0x26f5],
22
+ [0x26fa, 0x26fa],
23
+ [0x26fd, 0x26fd],
24
+ [0x2705, 0x2705],
25
+ [0x270a, 0x270b],
26
+ [0x2728, 0x2728],
27
+ [0x274c, 0x274c],
28
+ [0x274e, 0x274e],
29
+ [0x2753, 0x2755],
30
+ [0x2757, 0x2757],
31
+ [0x2795, 0x2797],
32
+ [0x27b0, 0x27b0],
33
+ [0x27bf, 0x27bf],
34
+ [0x2b1b, 0x2b1c],
35
+ [0x2b50, 0x2b50],
36
+ [0x2b55, 0x2b55],
37
+ [0x2e80, 0x303e],
38
+ [0x3040, 0xa4cf],
39
+ [0xac00, 0xd7a3],
40
+ [0xf900, 0xfaff],
41
+ [0xfe10, 0xfe19],
42
+ [0xfe30, 0xfe6f],
43
+ [0xff00, 0xff60],
44
+ [0xffe0, 0xffe6],
45
+ [0x1f004, 0x1f004],
46
+ [0x1f0cf, 0x1f0cf],
47
+ [0x1f18e, 0x1f18e],
48
+ [0x1f191, 0x1f19a],
49
+ [0x1f200, 0x1f202],
50
+ [0x1f210, 0x1f23b],
51
+ [0x1f240, 0x1f248],
52
+ [0x1f250, 0x1f251],
53
+ [0x1f300, 0x1f64f],
54
+ [0x1f680, 0x1f6ff],
55
+ [0x1f900, 0x1f9ff],
56
+ [0x20000, 0x3fffd],
57
+ ];
58
+
59
+ export function escapeXml(value) {
60
+ return String(value ?? '')
61
+ .replaceAll('&', '&amp;')
62
+ .replaceAll('<', '&lt;')
63
+ .replaceAll('>', '&gt;')
64
+ .replaceAll('"', '&quot;')
65
+ .replaceAll("'", '&#x27;');
66
+ }
67
+
68
+ export function fontFamily() {
69
+ return "'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'WenQuanYi Zen Hei', "
70
+ + "'Noto Sans CJK SC', 'Noto Sans SC', 'Source Han Sans SC', 'IPA P Gothic', "
71
+ + "'AR PL UMing CN', 'Arial Unicode MS', -apple-system, BlinkMacSystemFont, "
72
+ + "'Segoe UI', Arial, sans-serif";
73
+ }
74
+
75
+ function isFullWidthCharacter(character) {
76
+ const codePoint = character.codePointAt(0) || 0;
77
+ return FULL_WIDTH_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end);
78
+ }
79
+
80
+ export function charUnits(character) {
81
+ if (character === '\n') return 0;
82
+ if (/\s/u.test(character)) return 0.35;
83
+ return isFullWidthCharacter(character) ? 1 : 0.55;
84
+ }
85
+
86
+ export function textUnits(text) {
87
+ return [...String(text ?? '')].reduce((total, character) => total + charUnits(character), 0);
88
+ }
89
+
90
+ export function displayCols(text) {
91
+ return [...String(text ?? '')].reduce((total, character) => (
92
+ total + (character === '\n' ? 0 : isFullWidthCharacter(character) ? 2 : 1)
93
+ ), 0);
94
+ }
95
+
96
+ function wrapTokens(paragraph) {
97
+ const tokens = [];
98
+ let current = '';
99
+ const flush = () => {
100
+ if (!current) return;
101
+ tokens.push(current);
102
+ current = '';
103
+ };
104
+ for (const character of [...String(paragraph ?? '')]) {
105
+ if (/\s/u.test(character)) {
106
+ flush();
107
+ tokens.push(' ');
108
+ } else if (isFullWidthCharacter(character)) {
109
+ flush();
110
+ tokens.push(character);
111
+ } else {
112
+ current += character;
113
+ }
114
+ }
115
+ flush();
116
+ return tokens;
117
+ }
118
+
119
+ export function wrapText(text, maxUnits) {
120
+ const lines = [];
121
+ for (const paragraph of String(text ?? '').split(/\r?\n/)) {
122
+ let current = '';
123
+ let currentUnits = 0;
124
+ for (const token of wrapTokens(paragraph)) {
125
+ if (/^\s+$/u.test(token)) {
126
+ const tokenUnits = textUnits(token);
127
+ if (current && currentUnits + tokenUnits <= maxUnits) {
128
+ current += token;
129
+ currentUnits += tokenUnits;
130
+ }
131
+ continue;
132
+ }
133
+ const tokenUnits = textUnits(token);
134
+ if (current && currentUnits + tokenUnits > maxUnits) {
135
+ lines.push(current.trimEnd());
136
+ current = '';
137
+ currentUnits = 0;
138
+ }
139
+ if (tokenUnits > maxUnits) {
140
+ for (const character of [...token]) {
141
+ const units = charUnits(character);
142
+ if (current && currentUnits + units > maxUnits) {
143
+ lines.push(current.trimEnd());
144
+ current = '';
145
+ currentUnits = 0;
146
+ }
147
+ current += character;
148
+ currentUnits += units;
149
+ }
150
+ } else {
151
+ current += token;
152
+ currentUnits += tokenUnits;
153
+ }
154
+ }
155
+ if (current || lines.length === 0) lines.push(current.trimEnd());
156
+ }
157
+ return lines;
158
+ }
159
+
160
+ export function ellipsizeText(text, maxUnits, suffix = '...') {
161
+ const value = String(text ?? '');
162
+ if (textUnits(value) <= maxUnits) return value;
163
+ const allowed = Math.max(0, maxUnits - textUnits(suffix));
164
+ let kept = '';
165
+ let used = 0;
166
+ for (const character of [...value]) {
167
+ const units = charUnits(character);
168
+ if (used + units > allowed) break;
169
+ kept += character;
170
+ used += units;
171
+ }
172
+ return `${kept.trimEnd()}${suffix}`;
173
+ }
174
+
175
+ export function clipDisplay(text, maxCols) {
176
+ const value = String(text ?? '');
177
+ if (displayCols(value) <= maxCols) return value;
178
+ const suffix = '...';
179
+ const target = Math.max(0, maxCols - suffix.length);
180
+ let kept = '';
181
+ let used = 0;
182
+ for (const character of [...value]) {
183
+ const cols = character === '\n' ? 0 : isFullWidthCharacter(character) ? 2 : 1;
184
+ if (used + cols > target) break;
185
+ kept += character;
186
+ used += cols;
187
+ }
188
+ return `${kept.trimEnd()}${suffix}`;
189
+ }