@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 +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/openclaw/runtime/transcript-report.js +687 -141
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.
|
|
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
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
21
|
+
"version": "2026.7.9-testing.3",
|
|
22
22
|
"configSchema": {
|
|
23
23
|
"type": "object",
|
|
24
24
|
"additionalProperties": false,
|
package/package.json
CHANGED
|
@@ -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
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
|
|
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
|
|
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
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
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
|
|
716
|
-
|
|
717
|
-
for (const
|
|
718
|
-
const
|
|
719
|
-
if (
|
|
720
|
-
if (current) {
|
|
721
|
-
|
|
722
|
-
|
|
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 &&
|
|
899
|
+
if (current && currentUnits + tokenUnits > maxUnits) {
|
|
729
900
|
lines.push(current.trimEnd());
|
|
901
|
+
paragraphHasLine = true;
|
|
730
902
|
current = '';
|
|
731
|
-
|
|
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.
|
|
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
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
|
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:
|
|
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
|
|
788
|
-
const
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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:
|
|
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
|
-
|
|
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, '"');
|
|
851
1139
|
}
|
|
852
1140
|
|
|
853
|
-
function renderSvgPage({ page
|
|
854
|
-
const
|
|
855
|
-
const
|
|
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-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
`<rect
|
|
862
|
-
`<rect
|
|
863
|
-
`<
|
|
864
|
-
`<
|
|
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
|
-
|
|
868
|
-
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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,
|