@xfxstudio/claworld 2026.7.9-testing.2 → 2026.7.9-testing.3

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/README.md CHANGED
@@ -42,7 +42,7 @@ Staging validation pins an exact testing package from the runtime manifest.
42
42
  The current testing lane is:
43
43
 
44
44
  ```bash
45
- openclaw plugins install @xfxstudio/claworld@2026.7.9-testing.2
45
+ openclaw plugins install @xfxstudio/claworld@2026.7.9-testing.3
46
46
  ```
47
47
 
48
48
  Testing packages default to `https://staging.claworld.love`; stable packages
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "name": "Claworld Persona Relay",
20
20
  "description": "Claworld relay world channel plugin for OpenClaw.",
21
- "version": "2026.7.9-testing.2",
21
+ "version": "2026.7.9-testing.3",
22
22
  "configSchema": {
23
23
  "type": "object",
24
24
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfxstudio/claworld",
3
- "version": "2026.7.9-testing.2",
3
+ "version": "2026.7.9-testing.3",
4
4
  "description": "Claworld channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -31,6 +31,82 @@ const MANUAL_MESSAGE_FIELDS = new Set(['from', 'text', 'createdAt']);
31
31
  const TIME_SPLIT_SECONDS = 5 * 60;
32
32
  const SEGMENT_GAP_SECONDS = 4 * 60 * 60;
33
33
  const ARTIFACT_SCHEMA = 'claworld.transcript_report.v1';
34
+ const CANVAS_MARGIN = 24;
35
+ const FRAME_MARGIN = 16;
36
+ const HEADER_Y = 48;
37
+ const HEADER_CARD_HEIGHT_ONE_LINE = 92;
38
+ const HEADER_CARD_HEIGHT_TWO_LINES = 110;
39
+ const HEADER_BOTTOM_PAD = 20;
40
+ const BODY_TOP_GAP = 24;
41
+ const PAGE_BOTTOM = 54;
42
+ const ITEM_GAP = 22;
43
+ const TIME_ROW_HEIGHT = 42;
44
+ const ELLIPSIS_HEIGHT = 34;
45
+ const BUBBLE_PAD_X = 32;
46
+ const BUBBLE_PAD_Y = 22;
47
+ const LABEL_HEIGHT = 30;
48
+ const LABEL_OVERLAP = 18;
49
+ const LABEL_RAISE = 9;
50
+ const LABEL_MAX_COLS = 14;
51
+ const BUBBLE_MAX_RATIO = 0.64;
52
+ const BUBBLE_MIN_WIDTH = 200;
53
+ const FONT_SIZE = 18;
54
+ const SMALL_FONT_SIZE = 12;
55
+ const LABEL_FONT_SIZE = 15;
56
+ const TITLE_FONT_SIZE = 34;
57
+ const LINE_HEIGHT = 29;
58
+ const HEADER_SUBTITLE_MAX_UNITS = 33;
59
+ const HEADER_SUBTITLE_MAX_LINES = 2;
60
+ const HEADER_SUBTITLE_LINE_HEIGHT = 19;
61
+ const TAG_HEIGHT = 58;
62
+ const TAG_ICON_SIZE = 30;
63
+ const TAG_ICON_GAP = 12;
64
+ const TAG_ICON_TOP_GAP = 8;
65
+ const TAG_FALLBACK_MAX_COLS = 10;
66
+ const TEXT_UNIT_PX = 18;
67
+ const BLACK = '#090909';
68
+ const COMIC_THEME = Object.freeze({
69
+ paper: '#FBF8EF',
70
+ paperWarm: '#FFFDF7',
71
+ headerFill: '#FEF5D8',
72
+ gridMinor: '#BED1D8',
73
+ gridMajor: '#AABFC8',
74
+ ink: BLACK,
75
+ muted: '#222222',
76
+ leftFill: '#EFFFF5',
77
+ leftLabel: '#62E69D',
78
+ leftAccentA: '#58E58F',
79
+ leftAccentB: '#47B6FF',
80
+ rightFill: '#EFE0FF',
81
+ rightLabel: '#B785FF',
82
+ rightAccentA: '#A871FF',
83
+ rightAccentB: '#FF4EB4',
84
+ timeFill: '#FFFDF7',
85
+ timeAccentLeft: '#FF62DE',
86
+ timeAccentRight: '#50D995',
87
+ });
88
+ const TAG_ICON_THEMES = Object.freeze({
89
+ like: ['#D8F4FF', '#58B7FF'],
90
+ dislike: ['#FFE0EA', '#FF6A9A'],
91
+ 'request end': ['#FFF0A8', '#FF9F2F'],
92
+ });
93
+ const COMIC_FONT_FAMILY = [
94
+ "'PingFang SC'",
95
+ "'Hiragino Sans GB'",
96
+ "'Microsoft YaHei'",
97
+ "'WenQuanYi Zen Hei'",
98
+ "'Noto Sans CJK SC'",
99
+ "'Noto Sans SC'",
100
+ "'Source Han Sans SC'",
101
+ "'IPA P Gothic'",
102
+ "'AR PL UMing CN'",
103
+ "'Arial Unicode MS'",
104
+ '-apple-system',
105
+ 'BlinkMacSystemFont',
106
+ "'Segoe UI'",
107
+ 'Arial',
108
+ 'sans-serif',
109
+ ].join(', ');
34
110
 
35
111
  function isObject(value) {
36
112
  return value && typeof value === 'object' && !Array.isArray(value);
@@ -294,15 +370,40 @@ function normalizeTagLabel(value) {
294
370
  }
295
371
 
296
372
  function extractControlTags(text) {
373
+ const source = String(text || '');
374
+ const matches = [];
375
+ const claimedSpans = [];
297
376
  const tags = [];
298
- const cleaned = String(text || '').replace(/\[\[([a-zA-Z0-9_\-\s]+)\]\]|\[([a-zA-Z0-9_\-\s]+)\]/g, (match, doubleTag, singleTag) => {
299
- const label = normalizeTagLabel(doubleTag || singleTag);
300
- if (label) tags.push(label);
301
- return '';
302
- });
377
+ const record = (start, end, label) => {
378
+ if (!label) return;
379
+ if (claimedSpans.some(([claimedStart, claimedEnd]) => start < claimedEnd && end > claimedStart)) return;
380
+ matches.push([start, end, label]);
381
+ claimedSpans.push([start, end]);
382
+ };
383
+ const controlPatterns = [
384
+ { pattern: /\[\[?\s*request[_\s-]*(?:conversation[_\s-]*)?end\s*\]?\]?/gi, label: 'request end' },
385
+ { pattern: /\[\s*requeset\s+end\s*\]/gi, label: 'request end' },
386
+ { pattern: /\[\[?\s*end\s*\]?\]?/gi, label: 'request end' },
387
+ { pattern: /\[\[?\s*like\s*\]?\]?/gi, label: 'like' },
388
+ { pattern: /\[\[?\s*dislike\s*\]?\]?/gi, label: 'dislike' },
389
+ ];
390
+ for (const { pattern, label } of controlPatterns) {
391
+ for (const match of source.matchAll(pattern)) record(match.index, match.index + match[0].length, label);
392
+ }
393
+ for (const match of source.matchAll(/\[\[\s*([A-Za-z0-9][A-Za-z0-9 _-]{0,24})\s*\]\]/g)) {
394
+ record(match.index, match.index + match[0].length, normalizeTagLabel(match[1]));
395
+ }
396
+ let cleaned = '';
397
+ let cursor = 0;
398
+ for (const [start, end, label] of matches.sort((left, right) => left[0] - right[0])) {
399
+ cleaned += source.slice(cursor, start);
400
+ cursor = Math.max(cursor, end);
401
+ if (!tags.includes(label)) tags.push(label);
402
+ }
403
+ cleaned += source.slice(cursor);
303
404
  return {
304
405
  text: cleaned.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(),
305
- tags: [...new Set(tags)],
406
+ tags,
306
407
  };
307
408
  }
308
409
 
@@ -683,64 +784,165 @@ function normalizeHeaderContext({ mode, manual = null, source = null, messages =
683
784
  };
684
785
  }
685
786
 
787
+ function isWideChar(char) {
788
+ const code = char.codePointAt(0) || 0;
789
+ return (
790
+ (code >= 0x1100 && code <= 0x115f)
791
+ || code === 0x2329
792
+ || code === 0x232a
793
+ || (code >= 0x2e80 && code <= 0xa4cf)
794
+ || (code >= 0xac00 && code <= 0xd7a3)
795
+ || (code >= 0xf900 && code <= 0xfaff)
796
+ || (code >= 0xfe10 && code <= 0xfe19)
797
+ || (code >= 0xfe30 && code <= 0xfe6f)
798
+ || (code >= 0xff00 && code <= 0xff60)
799
+ || (code >= 0xffe0 && code <= 0xffe6)
800
+ || (code >= 0x1f300 && code <= 0x1faff)
801
+ || (code >= 0x20000 && code <= 0x3fffd)
802
+ );
803
+ }
804
+
805
+ function charColumns(char) {
806
+ if (char === '\n') return 0;
807
+ return isWideChar(char) ? 2 : 1;
808
+ }
809
+
686
810
  function displayColumns(text) {
687
811
  let columns = 0;
688
- for (const char of String(text || '')) {
689
- const code = char.codePointAt(0);
690
- columns += code > 0x2e80 ? 2 : 1;
691
- }
812
+ for (const char of String(text || '')) columns += charColumns(char);
692
813
  return columns;
693
814
  }
694
815
 
695
- function wrapText(text, maxColumns) {
696
- const source = String(text || '').replace(/\r/g, '').split('\n');
697
- const lines = [];
698
- const pushHardWrappedToken = (token) => {
699
- let current = '';
700
- let columns = 0;
701
- for (const char of String(token || '')) {
702
- const charColumns = displayColumns(char);
703
- if (current && columns + charColumns > maxColumns) {
704
- lines.push(current);
705
- current = '';
706
- columns = 0;
707
- }
708
- current += char;
709
- columns += charColumns;
816
+ function clipDisplay(text, maxColumns) {
817
+ const value = String(text || '');
818
+ if (displayColumns(value) <= maxColumns) return value;
819
+ const suffix = '...';
820
+ const target = Math.max(0, maxColumns - suffix.length);
821
+ let kept = '';
822
+ let used = 0;
823
+ for (const char of value) {
824
+ const columns = charColumns(char);
825
+ if (used + columns > target) break;
826
+ kept += char;
827
+ used += columns;
828
+ }
829
+ return `${kept.trimEnd()}${suffix}`;
830
+ }
831
+
832
+ function charUnits(char) {
833
+ if (char === '\n') return 0;
834
+ if (/\s/u.test(char)) return 0.35;
835
+ return isWideChar(char) ? 1 : 0.55;
836
+ }
837
+
838
+ function textUnits(text) {
839
+ let units = 0;
840
+ for (const char of String(text || '')) units += charUnits(char);
841
+ return units;
842
+ }
843
+
844
+ function ellipsizeText(text, maxUnits, { suffix = '...' } = {}) {
845
+ const value = String(text || '');
846
+ if (textUnits(value) <= maxUnits) return value;
847
+ const allowed = Math.max(0, maxUnits - textUnits(suffix));
848
+ let kept = '';
849
+ let used = 0;
850
+ for (const char of value) {
851
+ const units = charUnits(char);
852
+ if (used + units > allowed) break;
853
+ kept += char;
854
+ used += units;
855
+ }
856
+ return `${kept.trimEnd()}${suffix}`;
857
+ }
858
+
859
+ function wrapTokens(paragraph) {
860
+ const tokens = [];
861
+ let current = '';
862
+ const flush = () => {
863
+ if (current) {
864
+ tokens.push(current);
865
+ current = '';
710
866
  }
711
- if (current) lines.push(current);
712
867
  };
713
- for (const paragraph of source) {
868
+ for (const char of String(paragraph || '')) {
869
+ if (/\s/u.test(char)) {
870
+ flush();
871
+ tokens.push(' ');
872
+ } else if (isWideChar(char)) {
873
+ flush();
874
+ tokens.push(char);
875
+ } else {
876
+ current += char;
877
+ }
878
+ }
879
+ flush();
880
+ return tokens;
881
+ }
882
+
883
+ function wrapText(text, maxUnits) {
884
+ const lines = [];
885
+ const paragraphs = String(text || '').replace(/\r/g, '').split('\n');
886
+ for (const paragraph of paragraphs.length ? paragraphs : ['']) {
714
887
  let current = '';
715
- let columns = 0;
716
- const words = paragraph.includes(' ') ? paragraph.split(/(\s+)/) : [...paragraph];
717
- for (const word of words) {
718
- const wordColumns = displayColumns(word);
719
- if (!/^\s+$/.test(word) && wordColumns > maxColumns) {
720
- if (current) {
721
- lines.push(current.trimEnd());
722
- current = '';
723
- columns = 0;
888
+ let currentUnits = 0;
889
+ let paragraphHasLine = false;
890
+ for (const token of wrapTokens(paragraph)) {
891
+ const tokenUnits = textUnits(token);
892
+ if (/^\s+$/u.test(token)) {
893
+ if (current && currentUnits + tokenUnits <= maxUnits) {
894
+ current += token;
895
+ currentUnits += tokenUnits;
724
896
  }
725
- pushHardWrappedToken(word);
726
897
  continue;
727
898
  }
728
- if (current && columns + wordColumns > maxColumns) {
899
+ if (current && currentUnits + tokenUnits > maxUnits) {
729
900
  lines.push(current.trimEnd());
901
+ paragraphHasLine = true;
730
902
  current = '';
731
- columns = 0;
903
+ currentUnits = 0;
904
+ }
905
+ if (tokenUnits > maxUnits) {
906
+ for (const char of token) {
907
+ const units = charUnits(char);
908
+ if (current && currentUnits + units > maxUnits) {
909
+ lines.push(current.trimEnd());
910
+ paragraphHasLine = true;
911
+ current = '';
912
+ currentUnits = 0;
913
+ }
914
+ current += char;
915
+ currentUnits += units;
916
+ }
917
+ } else {
918
+ current += token;
919
+ currentUnits += tokenUnits;
732
920
  }
733
- current += word;
734
- columns += wordColumns;
735
921
  }
736
- if (current) lines.push(current.trimEnd());
737
- if (!paragraph) lines.push('');
922
+ if (current || !paragraphHasLine) lines.push(current.trimEnd());
738
923
  }
739
924
  return lines.length > 0 ? lines : [''];
740
925
  }
741
926
 
927
+ function formatCompactTimestamp(value) {
928
+ const ms = parseTimestampMs(value);
929
+ if (ms) {
930
+ const date = new Date(ms);
931
+ const pad = (part) => String(part).padStart(2, '0');
932
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
933
+ }
934
+ const text = String(value || '').trim();
935
+ const match = text.match(/(?:(\d{4})[-/])?(\d{1,2})[-/](\d{1,2})[ T](\d{1,2}):(\d{2})/);
936
+ if (match) {
937
+ return `${String(Number(match[2])).padStart(2, '0')}-${String(Number(match[3])).padStart(2, '0')} ${String(Number(match[4])).padStart(2, '0')}:${match[5]}`;
938
+ }
939
+ return '';
940
+ }
941
+
742
942
  function timeLabelForMessage(message) {
743
- return message.createdAtLabel || formatTimestampLabel(message.createdAt);
943
+ return formatCompactTimestamp(message.createdAt || message.timestampMs || message.createdAtLabel)
944
+ || message.createdAtLabel
945
+ || '';
744
946
  }
745
947
 
746
948
  function buildRenderItems(messages = []) {
@@ -765,76 +967,162 @@ function buildRenderItems(messages = []) {
765
967
  return items;
766
968
  }
767
969
 
768
- function estimateMessageLayout(message) {
769
- const maxColumns = message.side === 'local' ? 36 : 36;
770
- const lines = wrapText(message.text, maxColumns);
771
- const tagRows = message.tags.length > 0 ? 1 : 0;
772
- const textWidth = Math.min(
773
- 460,
774
- Math.max(160, Math.max(...lines.map(displayColumns), 10) * 8.5 + 44),
970
+ function renderSide(message = {}) {
971
+ return message.side === 'local' || message.side === 'right' || message.side === 'me'
972
+ ? 'right'
973
+ : 'left';
974
+ }
975
+
976
+ function participantLabel(message = {}, header = {}) {
977
+ if (renderSide(message) === 'right') return header.localLabel || 'You';
978
+ return message.speaker || header.peerLabel || 'Peer';
979
+ }
980
+
981
+ function messageTags(message = {}) {
982
+ return Array.isArray(message.tags) ? message.tags : [];
983
+ }
984
+
985
+ function measureRenderItem(item, width, header) {
986
+ const contentWidth = Math.max(270, Math.trunc(width * BUBBLE_MAX_RATIO));
987
+ if (item.kind === 'ellipsis') {
988
+ return {
989
+ kind: 'ellipsis',
990
+ message: null,
991
+ lines: [],
992
+ width: contentWidth,
993
+ height: ELLIPSIS_HEIGHT,
994
+ omittedCount: item.omitted || 0,
995
+ label: item.label || '',
996
+ };
997
+ }
998
+ if (item.kind === 'time') {
999
+ return {
1000
+ kind: 'time',
1001
+ message: null,
1002
+ lines: [],
1003
+ width: contentWidth,
1004
+ height: TIME_ROW_HEIGHT,
1005
+ label: item.label || '',
1006
+ };
1007
+ }
1008
+
1009
+ const side = renderSide(item.message);
1010
+ const label = participantLabel(item.message, header);
1011
+ const maxUnits = Math.max(18, (contentWidth - BUBBLE_PAD_X * 2) / TEXT_UNIT_PX);
1012
+ const lines = wrapText(item.message.text, maxUnits);
1013
+ const labelUnits = textUnits(labelText(label)) + 4;
1014
+ const contentUnits = Math.max(
1015
+ ...lines.map((line) => textUnits(line)),
1016
+ tagRowUnits(messageTags(item.message)),
1017
+ labelUnits,
1018
+ 10,
775
1019
  );
776
- const height = 24 + lines.length * 20 + tagRows * 25;
1020
+ const bubbleWidth = Math.trunc(Math.min(
1021
+ contentWidth,
1022
+ Math.max(BUBBLE_MIN_WIDTH, contentUnits * TEXT_UNIT_PX + BUBBLE_PAD_X * 2),
1023
+ ));
1024
+ const textHeight = lines.length * LINE_HEIGHT;
1025
+ const tagHeight = messageTags(item.message).length ? TAG_HEIGHT : 0;
1026
+ const bubbleHeight = BUBBLE_PAD_Y * 2 + textHeight + tagHeight;
1027
+ const rowHeight = LABEL_HEIGHT - LABEL_OVERLAP + bubbleHeight + 10;
777
1028
  return {
1029
+ kind: 'message',
1030
+ message: item.message,
1031
+ side,
1032
+ participantLabel: label,
778
1033
  lines,
779
- width: textWidth,
780
- height,
1034
+ width: bubbleWidth,
1035
+ height: rowHeight,
1036
+ tagHeight,
1037
+ textHeight,
781
1038
  };
782
1039
  }
783
1040
 
784
1041
  function layoutPages({ messages, header, maxPageHeight }) {
785
1042
  const width = DEFAULT_WIDTH;
786
1043
  const maxHeight = maxPageHeight || DEFAULT_MAX_PAGE_HEIGHT;
787
- const margin = 32;
788
- const headerHeight = 102;
789
- const bottomMargin = 32;
790
- const pages = [];
791
- let page = {
792
- page: 1,
793
- width,
794
- height: Math.min(maxHeight, Math.max(MIN_PAGE_HEIGHT, headerHeight + bottomMargin)),
795
- elements: [],
796
- };
797
- let y = headerHeight;
1044
+ const measuredItems = buildRenderItems(messages).map((item) => measureRenderItem(item, width, header));
1045
+ const pageBuckets = [[]];
1046
+ const headerHeight = headerHeightForSubtitle(header.subtitle);
1047
+ let used = headerHeight + BODY_TOP_GAP + PAGE_BOTTOM;
1048
+
1049
+ measuredItems.forEach((item, index) => {
1050
+ const itemHeight = item.height + ITEM_GAP;
1051
+ let neededHeight = itemHeight;
1052
+ if (item.kind === 'time' && index + 1 < measuredItems.length) {
1053
+ neededHeight += measuredItems[index + 1].height + ITEM_GAP;
1054
+ }
1055
+ const currentPage = pageBuckets[pageBuckets.length - 1];
1056
+ if (currentPage.length > 0 && used + neededHeight > maxHeight) {
1057
+ pageBuckets.push([]);
1058
+ used = headerHeight + BODY_TOP_GAP + PAGE_BOTTOM;
1059
+ }
1060
+ pageBuckets[pageBuckets.length - 1].push(item);
1061
+ used += itemHeight;
1062
+ });
798
1063
 
799
- const pushPage = () => {
800
- page.height = Math.min(maxHeight, Math.max(MIN_PAGE_HEIGHT, Math.ceil(y + bottomMargin)));
801
- pages.push(page);
802
- page = {
803
- page: pages.length + 1,
1064
+ const pages = pageBuckets.map((pageItems, pageIndex) => {
1065
+ let y = headerHeight + BODY_TOP_GAP;
1066
+ const elements = [];
1067
+ for (const item of pageItems) {
1068
+ if (item.kind === 'ellipsis') {
1069
+ elements.push({
1070
+ kind: 'ellipsis',
1071
+ y,
1072
+ height: item.height,
1073
+ label: item.label,
1074
+ });
1075
+ y += item.height + ITEM_GAP;
1076
+ continue;
1077
+ }
1078
+ if (item.kind === 'time') {
1079
+ elements.push({
1080
+ kind: 'time',
1081
+ y,
1082
+ height: item.height,
1083
+ label: item.label,
1084
+ });
1085
+ y += item.height + ITEM_GAP;
1086
+ continue;
1087
+ }
1088
+ const label = labelText(item.participantLabel);
1089
+ const position = positions(width, item.width, label, item.side);
1090
+ const bubbleY = y + LABEL_HEIGHT - LABEL_OVERLAP;
1091
+ const bubbleHeight = BUBBLE_PAD_Y * 2 + item.textHeight + item.tagHeight;
1092
+ elements.push({
1093
+ kind: 'message',
1094
+ y,
1095
+ x: position.bubbleX,
1096
+ bubbleX: position.bubbleX,
1097
+ bubbleY,
1098
+ labelX: position.labelX,
1099
+ labelY: y - LABEL_RAISE,
1100
+ labelWidth: position.labelWidth,
1101
+ label,
1102
+ align: position.align,
1103
+ width: item.width,
1104
+ height: bubbleHeight,
1105
+ bubbleHeight,
1106
+ lines: item.lines,
1107
+ message: item.message,
1108
+ side: item.side,
1109
+ participantLabel: item.participantLabel,
1110
+ tagHeight: item.tagHeight,
1111
+ textHeight: item.textHeight,
1112
+ });
1113
+ y += item.height + ITEM_GAP;
1114
+ }
1115
+ return {
1116
+ page: pageIndex + 1,
804
1117
  width,
805
- height: MIN_PAGE_HEIGHT,
806
- elements: [],
1118
+ height: Math.max(520, Math.min(maxHeight, Math.ceil(y + PAGE_BOTTOM))),
1119
+ elements,
1120
+ title: header.title,
1121
+ subtitle: header.subtitle,
1122
+ footer: 'visit claworld.love',
807
1123
  };
808
- y = 82;
809
- };
1124
+ });
810
1125
 
811
- for (const item of buildRenderItems(messages)) {
812
- if (item.kind === 'time') {
813
- const height = 28;
814
- if (y + height + bottomMargin > maxHeight && page.elements.length > 0) pushPage();
815
- page.elements.push({ ...item, x: margin, y, width: width - margin * 2, height });
816
- y += height;
817
- continue;
818
- }
819
- const messageLayout = estimateMessageLayout(item.message);
820
- const height = messageLayout.height + 10;
821
- if (y + height + bottomMargin > maxHeight && page.elements.length > 0) pushPage();
822
- const x = item.message.side === 'local'
823
- ? width - margin - messageLayout.width
824
- : margin;
825
- page.elements.push({
826
- kind: 'message',
827
- message: item.message,
828
- layout: messageLayout,
829
- x,
830
- y,
831
- width: messageLayout.width,
832
- height: messageLayout.height,
833
- });
834
- y += height;
835
- }
836
- page.height = Math.min(maxHeight, Math.max(MIN_PAGE_HEIGHT, Math.ceil(y + bottomMargin)));
837
- pages.push(page);
838
1126
  return {
839
1127
  width,
840
1128
  header,
@@ -850,56 +1138,314 @@ function escapeXml(value) {
850
1138
  .replace(/"/g, '&quot;');
851
1139
  }
852
1140
 
853
- function renderSvgPage({ page, header, pageCount }) {
854
- const pageTitle = page.page === 1 ? header.title : `${header.title} (continued)`;
855
- const subtitle = page.page === 1 ? header.subtitle : `Page ${page.page} of ${pageCount}`;
1141
+ function renderSvgPage({ page }) {
1142
+ const titleId = `claworld-report-title-${page.page}`;
1143
+ const descId = `claworld-report-desc-${page.page}`;
1144
+ const desc = `${page.title}. ${page.subtitle}. ${page.elements.length} transcript rows.`;
856
1145
  const parts = [
857
- `<svg xmlns="http://www.w3.org/2000/svg" width="${page.width}" height="${page.height}" viewBox="0 0 ${page.width} ${page.height}" role="img" aria-label="${escapeXml(pageTitle)}">`,
858
- '<style>',
859
- '.bg{fill:#fbf7ef}.panel{fill:#fffdf8;stroke:#222;stroke-width:2}.title{font:700 26px Arial,sans-serif;fill:#161616}.subtitle{font:14px Arial,sans-serif;fill:#5f5b52}.label{font:700 12px Arial,sans-serif;fill:#666}.bubblePeer{fill:#ffffff;stroke:#222;stroke-width:2}.bubbleLocal{fill:#dff3e5;stroke:#1d3b29;stroke-width:2}.msg{font:15px Arial,sans-serif;fill:#171717}.time{font:12px Arial,sans-serif;fill:#736d63}.tag{font:700 11px Arial,sans-serif;fill:#161616}.tagBox{fill:#f6cf4f;stroke:#222;stroke-width:1}.tagEnd{fill:#f0b4a8}.tagLike{fill:#b9e3ff}.tagDislike{fill:#ddd}.shadow{fill:#222;opacity:.16}',
860
- '</style>',
861
- `<rect class="bg" x="0" y="0" width="${page.width}" height="${page.height}"/>`,
862
- `<rect class="panel" x="22" y="18" width="${page.width - 44}" height="${page.height - 36}" rx="18"/>`,
863
- `<text class="title" x="42" y="56">${escapeXml(pageTitle)}</text>`,
864
- `<text class="subtitle" x="42" y="80">${escapeXml(subtitle || 'Claworld transcript')}</text>`,
1146
+ `<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} ${descId}">`,
1147
+ `<title id="${titleId}">${escapeXml(page.title)}</title>`,
1148
+ `<desc id="${descId}">${escapeXml(desc)}</desc>`,
1149
+ svgDefs(),
1150
+ `<rect x="0" y="0" width="${page.width}" height="${page.height}" fill="${COMIC_THEME.paper}"/>`,
1151
+ `<rect x="0" y="0" width="${page.width}" height="${page.height}" fill="url(#comicGridMinor)"/>`,
1152
+ `<rect x="0" y="0" width="${page.width}" height="${page.height}" fill="url(#comicGridMajor)" opacity="0.46"/>`,
1153
+ `<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"/>`,
1154
+ renderHeaderSvg(page),
1155
+ '<g role="list">',
865
1156
  ];
866
-
867
- for (const element of page.elements) {
868
- if (element.kind === 'time') {
869
- parts.push(`<text class="time" x="${page.width / 2}" y="${element.y + 18}" text-anchor="middle">${escapeXml(element.label)}</text>`);
1157
+ for (const item of page.elements) {
1158
+ if (item.kind === 'ellipsis') {
1159
+ parts.push(renderEllipsisSvg(page, item));
870
1160
  continue;
871
1161
  }
872
- const { message, layout, x, y, width, height } = element;
873
- const bubbleClass = message.side === 'local' ? 'bubbleLocal' : 'bubblePeer';
874
- const label = message.side === 'local' ? header.localLabel : (message.speaker || header.peerLabel);
875
- parts.push(`<text class="label" x="${x}" y="${y + 10}">${escapeXml(label)}</text>`);
876
- parts.push(`<rect class="shadow" x="${x + 4}" y="${y + 18}" width="${width}" height="${height}" rx="16"/>`);
877
- parts.push(`<rect class="${bubbleClass}" x="${x}" y="${y + 14}" width="${width}" height="${height}" rx="16"/>`);
878
- const textX = x + 17;
879
- let textY = y + 40;
880
- for (const [index, line] of layout.lines.entries()) {
881
- parts.push(`<text class="msg" x="${textX}" y="${textY + index * 20}">${escapeXml(line)}</text>`);
882
- }
883
- textY += layout.lines.length * 20 + 4;
884
- let tagX = textX;
885
- for (const tag of message.tags) {
886
- const tagWidth = Math.max(48, displayColumns(tag) * 7 + 24);
887
- const className = tag === 'request end'
888
- ? 'tagEnd'
889
- : tag === 'like'
890
- ? 'tagLike'
891
- : tag === 'dislike'
892
- ? 'tagDislike'
893
- : 'tagBox';
894
- parts.push(`<rect class="${className}" x="${tagX}" y="${textY}" width="${tagWidth}" height="18" rx="9"/>`);
895
- parts.push(`<text class="tag" x="${tagX + 12}" y="${textY + 13}">${escapeXml(tag)}</text>`);
896
- tagX += tagWidth + 6;
1162
+ if (item.kind === 'time') {
1163
+ parts.push(renderTimeSvg(page, item));
1164
+ continue;
897
1165
  }
1166
+ parts.push(renderMessageSvg(item));
1167
+ }
1168
+ parts.push('</g>');
1169
+ if (page.footer) {
1170
+ parts.push(`<text x="${(page.width / 2).toFixed(1)}" y="${page.height - 24}" text-anchor="middle" font-size="${SMALL_FONT_SIZE}" fill="#444444">${escapeXml(page.footer)}</text>`);
898
1171
  }
899
1172
  parts.push('</svg>');
900
1173
  return parts.join('\n');
901
1174
  }
902
1175
 
1176
+ function positions(width, bubbleWidth, label, side) {
1177
+ const labelWidth = labelWidthForText(label);
1178
+ const inset = CANVAS_MARGIN + 38;
1179
+ if (side === 'right') {
1180
+ const bubbleX = width - inset - bubbleWidth;
1181
+ const labelX = bubbleX + bubbleWidth - labelWidth - 16;
1182
+ return { bubbleX, labelX, labelWidth, align: 'right' };
1183
+ }
1184
+ const bubbleX = inset;
1185
+ const labelX = bubbleX + 16;
1186
+ return { bubbleX, labelX, labelWidth, align: 'left' };
1187
+ }
1188
+
1189
+ function renderHeaderSvg(page) {
1190
+ const x = CANVAS_MARGIN + 26;
1191
+ const y = HEADER_Y;
1192
+ const width = page.width - (CANVAS_MARGIN + 26) * 2;
1193
+ const height = headerCardHeight(page.subtitle);
1194
+ const title = clipDisplay(headerTitle(page.title), 32);
1195
+ return [
1196
+ `<rect x="${x + 11}" y="${y + 6}" width="${width + 2}" height="${height + 10}" rx="22" fill="${BLACK}"/>`,
1197
+ `<rect x="${x + 7}" y="${y + 6}" width="${width}" height="${height + 4}" rx="22" fill="url(#headerAccent)"/>`,
1198
+ `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="22" fill="${COMIC_THEME.headerFill}" stroke="${BLACK}" stroke-width="4"/>`,
1199
+ `<text x="${x + 28}" y="${y + 43}" font-size="${TITLE_FONT_SIZE}" font-weight="900" fill="${BLACK}">${escapeXml(title)}</text>`,
1200
+ renderHeaderSubtitleSvg(x + 35, y + 70, headerSubtitleLines(page.subtitle)),
1201
+ decorativeStarSvg(x + width - 62, y + 34, 22, '#FFFFFF', 'url(#headerAccent)'),
1202
+ `<circle cx="${x + width - 23}" cy="${y + 55}" r="9" fill="#72E3C0" stroke="${BLACK}" stroke-width="3"/>`,
1203
+ `<circle cx="${x + width - 25}" cy="${y + 53}" r="9" fill="#72E3C0" stroke="${BLACK}" stroke-width="3"/>`,
1204
+ ].join('\n');
1205
+ }
1206
+
1207
+ function headerSubtitleLines(subtitle) {
1208
+ const lines = wrapText(String(subtitle || '').trim(), HEADER_SUBTITLE_MAX_UNITS);
1209
+ if (lines.length <= HEADER_SUBTITLE_MAX_LINES) return lines;
1210
+ const visible = lines.slice(0, HEADER_SUBTITLE_MAX_LINES - 1);
1211
+ const remainder = lines.slice(HEADER_SUBTITLE_MAX_LINES - 1).map((line) => line.trim()).filter(Boolean).join(' ');
1212
+ visible.push(ellipsizeText(remainder, HEADER_SUBTITLE_MAX_UNITS));
1213
+ return visible;
1214
+ }
1215
+
1216
+ function headerCardHeight(subtitle) {
1217
+ return headerSubtitleLines(subtitle).length > 1
1218
+ ? HEADER_CARD_HEIGHT_TWO_LINES
1219
+ : HEADER_CARD_HEIGHT_ONE_LINE;
1220
+ }
1221
+
1222
+ function headerHeightForSubtitle(subtitle) {
1223
+ return HEADER_Y + headerCardHeight(subtitle) + HEADER_BOTTOM_PAD;
1224
+ }
1225
+
1226
+ function renderHeaderSubtitleSvg(x, y, lines) {
1227
+ return lines.map((line, index) => (
1228
+ `<text class="header-subtitle-line" x="${x.toFixed(1)}" y="${(y + index * HEADER_SUBTITLE_LINE_HEIGHT).toFixed(1)}" font-size="15" font-weight="600" fill="${COMIC_THEME.muted}">${escapeXml(line)}</text>`
1229
+ )).join('\n');
1230
+ }
1231
+
1232
+ function renderEllipsisSvg(page, item) {
1233
+ const y = item.y + 7;
1234
+ return `<text x="${(page.width / 2).toFixed(1)}" y="${y + 14}" text-anchor="middle" font-size="${SMALL_FONT_SIZE}" fill="#555555">${escapeXml(item.label)}</text>`;
1235
+ }
1236
+
1237
+ function renderTimeSvg(page, item) {
1238
+ const label = clipDisplay(item.label, 22);
1239
+ const labelWidth = Math.max(150, displayColumns(label) * 8 + 44);
1240
+ const x = page.width / 2 - labelWidth / 2;
1241
+ const y = item.y + 4;
1242
+ return [
1243
+ '<g class="time-row">',
1244
+ diamondSvg(x - 28, y + 16, 13, COMIC_THEME.timeAccentLeft),
1245
+ `<rect x="${(x + 3).toFixed(1)}" y="${y + 4}" width="${labelWidth}" height="30" rx="15" fill="${BLACK}"/>`,
1246
+ `<rect x="${x.toFixed(1)}" y="${y}" width="${labelWidth}" height="30" rx="15" fill="${COMIC_THEME.timeFill}" stroke="${BLACK}" stroke-width="3"/>`,
1247
+ `<text x="${(page.width / 2).toFixed(1)}" y="${y + 21}" text-anchor="middle" font-size="${FONT_SIZE}" font-weight="700" fill="${BLACK}">${escapeXml(label)}</text>`,
1248
+ diamondSvg(x + labelWidth + 28, y + 16, 13, COMIC_THEME.timeAccentRight),
1249
+ '</g>',
1250
+ ].join('\n');
1251
+ }
1252
+
1253
+ function renderMessageSvg(item) {
1254
+ const colors = sideColors(item.side);
1255
+ const messageText = item.message?.text || '';
1256
+ const titleText = escapeXml(`${item.participantLabel}: ${ellipsizeText(messageText, 42)}`);
1257
+ const parts = [
1258
+ `<g class="message-row ${item.side}" role="listitem" aria-label="${titleText}">`,
1259
+ `<title>${titleText}</title>`,
1260
+ bubbleLayersSvg(item, colors),
1261
+ `<rect x="${item.labelX}" y="${item.labelY}" width="${item.labelWidth}" height="${LABEL_HEIGHT}" rx="9" fill="${colors.label}" stroke="${BLACK}" stroke-width="3"/>`,
1262
+ `<text x="${(item.labelX + item.labelWidth / 2).toFixed(1)}" y="${item.labelY + 21}" text-anchor="middle" font-size="${LABEL_FONT_SIZE}" font-weight="900" fill="${BLACK}">${escapeXml(item.label)}</text>`,
1263
+ ];
1264
+ const textX = item.bubbleX + BUBBLE_PAD_X;
1265
+ let textY = item.bubbleY + BUBBLE_PAD_Y + 17;
1266
+ for (const line of item.lines) {
1267
+ parts.push(`<text x="${textX}" y="${textY}" font-size="${FONT_SIZE}" font-weight="800" fill="${BLACK}">${escapeXml(line)}</text>`);
1268
+ textY += LINE_HEIGHT;
1269
+ }
1270
+ const tags = messageTags(item.message);
1271
+ if (tags.length) parts.push(renderTagIconsSvg(tags, textX, textY + TAG_ICON_TOP_GAP));
1272
+ parts.push('</g>');
1273
+ return parts.join('\n');
1274
+ }
1275
+
1276
+ function bubbleLayersSvg(item, colors) {
1277
+ const x = item.bubbleX;
1278
+ const y = item.bubbleY;
1279
+ const width = item.width;
1280
+ const height = item.bubbleHeight;
1281
+ return [
1282
+ `<rect x="${x + 11}" y="${y + 9}" width="${width + 2}" height="${height + 4}" rx="17" fill="${BLACK}"/>`,
1283
+ `<rect x="${x + 11}" y="${y + 9}" width="${width - 3}" height="${height}" rx="17" fill="${colors.accent}" stroke="${BLACK}" stroke-width="3"/>`,
1284
+ `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="17" fill="${colors.fill}" stroke="${BLACK}" stroke-width="4"/>`,
1285
+ ].join('\n');
1286
+ }
1287
+
1288
+ function sideColors(side) {
1289
+ if (side === 'right') {
1290
+ return {
1291
+ fill: COMIC_THEME.rightFill,
1292
+ label: COMIC_THEME.rightLabel,
1293
+ accent: 'url(#rightAccent)',
1294
+ };
1295
+ }
1296
+ return {
1297
+ fill: COMIC_THEME.leftFill,
1298
+ label: COMIC_THEME.leftLabel,
1299
+ accent: 'url(#leftAccent)',
1300
+ };
1301
+ }
1302
+
1303
+ function headerTitle(title) {
1304
+ const clean = String(title || '').trim();
1305
+ if (clean.startsWith('@')) return clean;
1306
+ return clean ? `@${clean}` : '@claworld';
1307
+ }
1308
+
1309
+ function labelText(label) {
1310
+ return clipDisplay(String(label || 'AGENT').toUpperCase(), LABEL_MAX_COLS);
1311
+ }
1312
+
1313
+ function labelWidthForText(label) {
1314
+ return Math.max(86, Math.min(158, displayColumns(label) * 11 + 30));
1315
+ }
1316
+
1317
+ function tagRowUnits(tags) {
1318
+ if (!tags.length) return 0;
1319
+ const width = tags.reduce((sum, tag) => sum + tagWidth(tag), 0) + Math.max(0, tags.length - 1) * TAG_ICON_GAP;
1320
+ return width / TEXT_UNIT_PX;
1321
+ }
1322
+
1323
+ function renderTagIconsSvg(tags, x, y) {
1324
+ const parts = [`<g class="tag-icons" transform="translate(${x.toFixed(1)} ${y.toFixed(1)})">`];
1325
+ let cursor = 0;
1326
+ for (const tag of tags) {
1327
+ parts.push(tagIconSvg(tag, cursor, 0));
1328
+ cursor += tagWidth(tag) + TAG_ICON_GAP;
1329
+ }
1330
+ parts.push('</g>');
1331
+ return parts.join('\n');
1332
+ }
1333
+
1334
+ function tagWidth(tag) {
1335
+ const normalized = tagName(tag);
1336
+ if (['like', 'dislike', 'request end'].includes(normalized)) return TAG_ICON_SIZE;
1337
+ return Math.max(58, Math.min(126, displayColumns(fallbackTagLabel(normalized)) * 8 + 24));
1338
+ }
1339
+
1340
+ function tagIconSvg(tag, x, y) {
1341
+ const normalized = tagName(tag);
1342
+ if (!['like', 'dislike', 'request end'].includes(normalized)) return fallbackTagSvg(normalized, x, y);
1343
+ const [fill, accent] = TAG_ICON_THEMES[normalized] || ['#FFFFFF', '#7DD7FF'];
1344
+ const icon = normalized === 'request end'
1345
+ ? requestEndIconSvg(x, y, accent)
1346
+ : thumbIconSvg(x, y, accent, { down: normalized === 'dislike' });
1347
+ return [
1348
+ `<g class="tag-icon tag-${safeClassToken(normalized)}" role="img" aria-label="${escapeXml(normalized)}">`,
1349
+ `<title>${escapeXml(normalized)}</title>`,
1350
+ `<rect x="${(x + 3).toFixed(1)}" y="${(y + 4).toFixed(1)}" width="${TAG_ICON_SIZE}" height="${TAG_ICON_SIZE}" rx="9" fill="${BLACK}"/>`,
1351
+ `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${TAG_ICON_SIZE}" height="${TAG_ICON_SIZE}" rx="9" fill="${fill}" stroke="${BLACK}" stroke-width="2.5"/>`,
1352
+ icon,
1353
+ '</g>',
1354
+ ].join('\n');
1355
+ }
1356
+
1357
+ function tagName(tag) {
1358
+ return String(tag || '').trim().toLowerCase();
1359
+ }
1360
+
1361
+ function fallbackTagLabel(tag) {
1362
+ return clipDisplay(String(tag || 'tag'), TAG_FALLBACK_MAX_COLS);
1363
+ }
1364
+
1365
+ function fallbackTagSvg(tag, x, y) {
1366
+ const label = fallbackTagLabel(tag);
1367
+ const width = tagWidth(tag);
1368
+ return [
1369
+ `<g class="tag-icon tag-fallback" role="img" aria-label="${escapeXml(label)}">`,
1370
+ `<title>${escapeXml(label)}</title>`,
1371
+ `<rect x="${(x + 3).toFixed(1)}" y="${(y + 4).toFixed(1)}" width="${width}" height="${TAG_ICON_SIZE}" rx="9" fill="${BLACK}"/>`,
1372
+ `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${width}" height="${TAG_ICON_SIZE}" rx="9" fill="#F6F1FF" stroke="${BLACK}" stroke-width="2.5"/>`,
1373
+ `<text x="${(x + width / 2).toFixed(1)}" y="${(y + 20.5).toFixed(1)}" text-anchor="middle" font-size="${LABEL_FONT_SIZE}" font-weight="900" fill="${BLACK}">${escapeXml(label)}</text>`,
1374
+ '</g>',
1375
+ ].join('\n');
1376
+ }
1377
+
1378
+ function thumbIconSvg(x, y, accent, { down = false } = {}) {
1379
+ const paths = [
1380
+ `<path d="M${(x + 7.8).toFixed(1)} ${(y + 13.3).toFixed(1)} H${(x + 12).toFixed(1)} V${(y + 24).toFixed(1)} H${(x + 7.8).toFixed(1)} Z" fill="#FFFFFF" stroke="${BLACK}" stroke-width="2" stroke-linejoin="round"/>`,
1381
+ `<path d="M${(x + 12).toFixed(1)} ${(y + 23.6).toFixed(1)} H${(x + 21.2).toFixed(1)} C${(x + 23).toFixed(1)} ${(y + 23.6).toFixed(1)} ${(x + 24).toFixed(1)} ${(y + 22.5).toFixed(1)} ${(x + 24.4).toFixed(1)} ${(y + 20.9).toFixed(1)} L${(x + 25.4).toFixed(1)} ${(y + 15.6).toFixed(1)} C${(x + 25.8).toFixed(1)} ${(y + 13.9).toFixed(1)} ${(x + 24.5).toFixed(1)} ${(y + 12.2).toFixed(1)} ${(x + 22.7).toFixed(1)} ${(y + 12.2).toFixed(1)} H${(x + 18.6).toFixed(1)} L${(x + 19.2).toFixed(1)} ${(y + 9.1).toFixed(1)} C${(x + 19.5).toFixed(1)} ${(y + 7.4).toFixed(1)} ${(x + 18.4).toFixed(1)} ${(y + 5.7).toFixed(1)} ${(x + 16.7).toFixed(1)} ${(y + 5.4).toFixed(1)} L${(x + 15.8).toFixed(1)} ${(y + 5.3).toFixed(1)} L${(x + 12).toFixed(1)} ${(y + 12.9).toFixed(1)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="2" stroke-linejoin="round"/>`,
1382
+ ].join('\n');
1383
+ if (!down) return paths;
1384
+ const cx = x + TAG_ICON_SIZE / 2;
1385
+ const cy = y + TAG_ICON_SIZE / 2;
1386
+ return `<g transform="rotate(180 ${cx.toFixed(1)} ${cy.toFixed(1)})">\n${paths}\n</g>`;
1387
+ }
1388
+
1389
+ function requestEndIconSvg(x, y, accent) {
1390
+ return [
1391
+ `<path d="M${(x + 22).toFixed(1)} ${(y + 4).toFixed(1)} C${(x + 25.2).toFixed(1)} ${(y + 5.4).toFixed(1)} ${(x + 26.6).toFixed(1)} ${(y + 7.8).toFixed(1)} ${(x + 26.5).toFixed(1)} ${(y + 10.6).toFixed(1)}" fill="none" stroke="${BLACK}" stroke-width="2" stroke-linecap="round"/>`,
1392
+ `<path d="M${(x + 9.215).toFixed(3)} ${(y + 18.117).toFixed(3)} C${(x + 10.043).toFixed(3)} ${(y + 22.457).toFixed(3)} ${(x + 13.384).toFixed(3)} ${(y + 25.036).toFixed(3)} ${(x + 17.132).toFixed(3)} ${(y + 23.961).toFixed(3)} L${(x + 19.44).toFixed(3)} ${(y + 23.3).toFixed(3)} C${(x + 22.323).toFixed(3)} ${(y + 22.473).toFixed(3)} ${(x + 23.488).toFixed(3)} ${(y + 19.642).toFixed(3)} ${(x + 22.538).toFixed(3)} ${(y + 16.69).toFixed(3)} L${(x + 21.435).toFixed(3)} ${(y + 12.845).toFixed(3)} L${(x + 9.227).toFixed(3)} ${(y + 16.345).toFixed(3)} L${(x + 9.215).toFixed(3)} ${(y + 18.117).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="2" stroke-linejoin="round"/>`,
1393
+ `<path d="M${(x + 9.665).toFixed(3)} ${(y + 7.897).toFixed(3)} L${(x + 9.473).toFixed(3)} ${(y + 7.952).toFixed(3)} C${(x + 8.756).toFixed(3)} ${(y + 8.158).toFixed(3)} ${(x + 8.286).toFixed(3)} ${(y + 8.709).toFixed(3)} ${(x + 8.422).toFixed(3)} ${(y + 9.184).toFixed(3)} L${(x + 10.192).toFixed(3)} ${(y + 15.356).toFixed(3)} C${(x + 10.328).toFixed(3)} ${(y + 15.831).toFixed(3)} ${(x + 11.019).toFixed(3)} ${(y + 16.049).toFixed(3)} ${(x + 11.736).toFixed(3)} ${(y + 15.843).toFixed(3)} L${(x + 11.928).toFixed(3)} ${(y + 15.788).toFixed(3)} C${(x + 12.645).toFixed(3)} ${(y + 15.583).toFixed(3)} ${(x + 13.115).toFixed(3)} ${(y + 15.031).toFixed(3)} ${(x + 12.979).toFixed(3)} ${(y + 14.557).toFixed(3)} L${(x + 11.209).toFixed(3)} ${(y + 8.384).toFixed(3)} C${(x + 11.073).toFixed(3)} ${(y + 7.91).toFixed(3)} ${(x + 10.382).toFixed(3)} ${(y + 7.692).toFixed(3)} ${(x + 9.665).toFixed(3)} ${(y + 7.897).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
1394
+ `<path d="M${(x + 11.93).toFixed(3)} ${(y + 5.999).toFixed(3)} L${(x + 11.738).toFixed(3)} ${(y + 6.055).toFixed(3)} C${(x + 11.021).toFixed(3)} ${(y + 6.26).toFixed(3)} ${(x + 10.553).toFixed(3)} ${(y + 6.82).toFixed(3)} ${(x + 10.692).toFixed(3)} ${(y + 7.306).toFixed(3)} L${(x + 12.729).toFixed(3)} ${(y + 14.408).toFixed(3)} C${(x + 12.868).toFixed(3)} ${(y + 14.894).toFixed(3)} ${(x + 13.562).toFixed(3)} ${(y + 15.122).toFixed(3)} ${(x + 14.279).toFixed(3)} ${(y + 14.916).toFixed(3)} L${(x + 14.471).toFixed(3)} ${(y + 14.861).toFixed(3)} C${(x + 15.188).toFixed(3)} ${(y + 14.655).toFixed(3)} ${(x + 15.656).toFixed(3)} ${(y + 14.095).toFixed(3)} ${(x + 15.516).toFixed(3)} ${(y + 13.609).toFixed(3)} L${(x + 13.48).toFixed(3)} ${(y + 6.507).toFixed(3)} C${(x + 13.341).toFixed(3)} ${(y + 6.021).toFixed(3)} ${(x + 12.647).toFixed(3)} ${(y + 5.794).toFixed(3)} ${(x + 11.93).toFixed(3)} ${(y + 5.999).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
1395
+ `<path d="M${(x + 14.636).toFixed(3)} ${(y + 5.64).toFixed(3)} L${(x + 14.443).toFixed(3)} ${(y + 5.695).toFixed(3)} C${(x + 13.727).toFixed(3)} ${(y + 5.9).toFixed(3)} ${(x + 13.258).toFixed(3)} ${(y + 6.457).toFixed(3)} ${(x + 13.396).toFixed(3)} ${(y + 6.938).toFixed(3)} L${(x + 15.338).toFixed(3)} ${(y + 13.714).toFixed(3)} C${(x + 15.476).toFixed(3)} ${(y + 14.195).toFixed(3)} ${(x + 16.169).toFixed(3)} ${(y + 14.418).toFixed(3)} ${(x + 16.886).toFixed(3)} ${(y + 14.213).toFixed(3)} L${(x + 17.078).toFixed(3)} ${(y + 14.157).toFixed(3)} C${(x + 17.795).toFixed(3)} ${(y + 13.952).toFixed(3)} ${(x + 18.264).toFixed(3)} ${(y + 13.395).toFixed(3)} ${(x + 18.126).toFixed(3)} ${(y + 12.914).toFixed(3)} L${(x + 16.183).toFixed(3)} ${(y + 6.139).toFixed(3)} C${(x + 16.045).toFixed(3)} ${(y + 5.658).toFixed(3)} ${(x + 15.352).toFixed(3)} ${(y + 5.434).toFixed(3)} ${(x + 14.636).toFixed(3)} ${(y + 5.64).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
1396
+ `<path d="M${(x + 17.8).toFixed(3)} ${(y + 6.085).toFixed(3)} L${(x + 17.702).toFixed(3)} ${(y + 6.113).toFixed(3)} C${(x + 16.971).toFixed(3)} ${(y + 6.322).toFixed(3)} ${(x + 16.482).toFixed(3)} ${(y + 6.851).toFixed(3)} ${(x + 16.609).toFixed(3)} ${(y + 7.294).toFixed(3)} L${(x + 18.176).toFixed(3)} ${(y + 12.759).toFixed(3)} C${(x + 18.303).toFixed(3)} ${(y + 13.202).toFixed(3)} ${(x + 18.998).toFixed(3)} ${(y + 13.391).toFixed(3)} ${(x + 19.729).toFixed(3)} ${(y + 13.182).toFixed(3)} L${(x + 19.827).toFixed(3)} ${(y + 13.154).toFixed(3)} C${(x + 20.557).toFixed(3)} ${(y + 12.944).toFixed(3)} ${(x + 21.046).toFixed(3)} ${(y + 12.415).toFixed(3)} ${(x + 20.919).toFixed(3)} ${(y + 11.972).toFixed(3)} L${(x + 19.352).toFixed(3)} ${(y + 6.507).toFixed(3)} C${(x + 19.225).toFixed(3)} ${(y + 6.065).toFixed(3)} ${(x + 18.53).toFixed(3)} ${(y + 5.875).toFixed(3)} ${(x + 17.8).toFixed(3)} ${(y + 6.085).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8"/>`,
1397
+ `<path d="M${(x + 9.546).toFixed(3)} ${(y + 19.999).toFixed(3)} L${(x + 6).toFixed(3)} ${(y + 17.791).toFixed(3)} C${(x + 4.736).toFixed(3)} ${(y + 17.009).toFixed(3)} ${(x + 5.668).toFixed(3)} ${(y + 15.181).toFixed(3)} ${(x + 7.138).toFixed(3)} ${(y + 15.592).toFixed(3)} L${(x + 10.615).toFixed(3)} ${(y + 17.196).toFixed(3)} L${(x + 9.546).toFixed(3)} ${(y + 19.999).toFixed(3)} Z" fill="${accent}" stroke="${BLACK}" stroke-width="1.8" stroke-linejoin="round"/>`,
1398
+ `<rect x="${(x + 10.887).toFixed(3)}" y="${(y + 14.834).toFixed(3)}" width="9.584" height="3.800" transform="rotate(-16.9438 ${(x + 10.887).toFixed(3)} ${(y + 14.834).toFixed(3)})" fill="${accent}"/>`,
1399
+ `<path d="M${(x + 10.5).toFixed(1)} ${(y + 19.5).toFixed(1)} L${(x + 11).toFixed(1)} ${(y + 20).toFixed(1)} L${(x + 11.5).toFixed(1)} ${(y + 17).toFixed(1)} L${(x + 9.5).toFixed(1)} ${(y + 17.5).toFixed(1)} L${(x + 9).toFixed(1)} ${(y + 18.5).toFixed(1)} L${(x + 10.5).toFixed(1)} ${(y + 19.5).toFixed(1)} Z" fill="${accent}"/>`,
1400
+ ].join('\n');
1401
+ }
1402
+
1403
+ function decorativeStarSvg(cx, cy, radius, fill, accent) {
1404
+ const backPath = starPoints(cx + 3, cy + 5, radius).map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
1405
+ const frontPath = starPoints(cx, cy, radius).map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
1406
+ return [
1407
+ `<polygon points="${backPath}" fill="${accent}"/>`,
1408
+ `<polygon points="${frontPath}" fill="${fill}" stroke="${BLACK}" stroke-width="3"/>`,
1409
+ ].join('\n');
1410
+ }
1411
+
1412
+ function diamondSvg(cx, cy, radius, fill) {
1413
+ const shadow = `<polygon points="${(cx + 1).toFixed(1)},${(cy + 2 - radius).toFixed(1)} ${(cx + 1 + radius * 0.46).toFixed(1)},${(cy + 2).toFixed(1)} ${(cx + 1).toFixed(1)},${(cy + 2 + radius).toFixed(1)} ${(cx + 1 - radius * 0.46).toFixed(1)},${(cy + 2).toFixed(1)}" fill="${BLACK}" stroke="${BLACK}" stroke-width="2.5"/>`;
1414
+ const front = `<polygon points="${cx.toFixed(1)},${(cy - radius).toFixed(1)} ${(cx + radius * 0.46).toFixed(1)},${cy.toFixed(1)} ${cx.toFixed(1)},${(cy + radius).toFixed(1)} ${(cx - radius * 0.46).toFixed(1)},${cy.toFixed(1)}" fill="${fill}" stroke="${BLACK}" stroke-width="2.5"/>`;
1415
+ return `${shadow}\n${front}`;
1416
+ }
1417
+
1418
+ function starPoints(cx, cy, radius) {
1419
+ return [
1420
+ [cx, cy - radius],
1421
+ [cx + radius * 0.28, cy - radius * 0.28],
1422
+ [cx + radius, cy],
1423
+ [cx + radius * 0.28, cy + radius * 0.28],
1424
+ [cx, cy + radius],
1425
+ [cx - radius * 0.28, cy + radius * 0.28],
1426
+ [cx - radius, cy],
1427
+ [cx - radius * 0.28, cy - radius * 0.28],
1428
+ ];
1429
+ }
1430
+
1431
+ function safeClassToken(value) {
1432
+ return String(value || 'tag').toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || 'tag';
1433
+ }
1434
+
1435
+ function svgDefs() {
1436
+ return [
1437
+ '<defs>',
1438
+ `<style><![CDATA[text { font-family: ${COMIC_FONT_FAMILY}; letter-spacing: 0; } .message-row:hover rect:last-of-type { filter: url(#comicLift); }]]></style>`,
1439
+ '<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>',
1440
+ '<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>',
1441
+ '<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>',
1442
+ '<linearGradient id="leftAccent" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#58E58F"/><stop offset="100%" stop-color="#47B6FF"/></linearGradient>',
1443
+ '<linearGradient id="rightAccent" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#A871FF"/><stop offset="100%" stop-color="#FF4EB4"/></linearGradient>',
1444
+ '<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>',
1445
+ '</defs>',
1446
+ ].join('\n');
1447
+ }
1448
+
903
1449
  function buildBubbleSpec({ mode, source, header, messages, layout, style }) {
904
1450
  return {
905
1451
  schema: ARTIFACT_SCHEMA,