@the-syllabus/analysis-renderers 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/cells/RelationshipCardCell.d.ts +10 -0
  2. package/dist/cells/RelationshipCardCell.d.ts.map +1 -0
  3. package/dist/cells/RelationshipCardCell.js +91 -0
  4. package/dist/cells/RelationshipCardCell.js.map +1 -0
  5. package/dist/cells/TacticCardCell.d.ts +12 -0
  6. package/dist/cells/TacticCardCell.d.ts.map +1 -0
  7. package/dist/cells/TacticCardCell.js +77 -0
  8. package/dist/cells/TacticCardCell.js.map +1 -0
  9. package/dist/cells/TemplateCardCell.d.ts +29 -0
  10. package/dist/cells/TemplateCardCell.d.ts.map +1 -0
  11. package/dist/cells/TemplateCardCell.js +202 -0
  12. package/dist/cells/TemplateCardCell.js.map +1 -0
  13. package/dist/cells/index.d.ts +15 -0
  14. package/dist/cells/index.d.ts.map +1 -0
  15. package/dist/cells/index.js +85 -0
  16. package/dist/cells/index.js.map +1 -0
  17. package/dist/components/ConditionCards.d.ts +18 -0
  18. package/dist/components/ConditionCards.d.ts.map +1 -0
  19. package/dist/components/ConditionCards.js +28 -0
  20. package/dist/components/ConditionCards.js.map +1 -0
  21. package/dist/components/EvidenceTrail.d.ts +54 -0
  22. package/dist/components/EvidenceTrail.d.ts.map +1 -0
  23. package/dist/components/EvidenceTrail.js +98 -0
  24. package/dist/components/EvidenceTrail.js.map +1 -0
  25. package/dist/dispatch/SubRendererDispatch.d.ts +39 -0
  26. package/dist/dispatch/SubRendererDispatch.d.ts.map +1 -0
  27. package/dist/dispatch/SubRendererDispatch.js +153 -0
  28. package/dist/dispatch/SubRendererDispatch.js.map +1 -0
  29. package/dist/hooks/useProseExtraction.d.ts +38 -0
  30. package/dist/hooks/useProseExtraction.d.ts.map +1 -0
  31. package/dist/hooks/useProseExtraction.js +93 -0
  32. package/dist/hooks/useProseExtraction.js.map +1 -0
  33. package/dist/index.d.ts +32 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +38 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/provenance/ProvenanceSectionIcon.d.ts +15 -0
  38. package/dist/provenance/ProvenanceSectionIcon.d.ts.map +1 -0
  39. package/dist/provenance/ProvenanceSectionIcon.js +11 -0
  40. package/dist/provenance/ProvenanceSectionIcon.js.map +1 -0
  41. package/dist/renderers/AccordionRenderer.d.ts +29 -0
  42. package/dist/renderers/AccordionRenderer.d.ts.map +1 -0
  43. package/dist/renderers/AccordionRenderer.js +315 -0
  44. package/dist/renderers/AccordionRenderer.js.map +1 -0
  45. package/dist/renderers/CardGridRenderer.d.ts +24 -0
  46. package/dist/renderers/CardGridRenderer.d.ts.map +1 -0
  47. package/dist/renderers/CardGridRenderer.js +321 -0
  48. package/dist/renderers/CardGridRenderer.js.map +1 -0
  49. package/dist/renderers/CardRenderer.d.ts +27 -0
  50. package/dist/renderers/CardRenderer.d.ts.map +1 -0
  51. package/dist/renderers/CardRenderer.js +337 -0
  52. package/dist/renderers/CardRenderer.js.map +1 -0
  53. package/dist/renderers/IdeaEvolutionRenderer.d.ts +16 -0
  54. package/dist/renderers/IdeaEvolutionRenderer.d.ts.map +1 -0
  55. package/dist/renderers/IdeaEvolutionRenderer.js +187 -0
  56. package/dist/renderers/IdeaEvolutionRenderer.js.map +1 -0
  57. package/dist/renderers/ProseRenderer.d.ts +10 -0
  58. package/dist/renderers/ProseRenderer.d.ts.map +1 -0
  59. package/dist/renderers/ProseRenderer.js +42 -0
  60. package/dist/renderers/ProseRenderer.js.map +1 -0
  61. package/dist/renderers/RawJsonRenderer.d.ts +8 -0
  62. package/dist/renderers/RawJsonRenderer.d.ts.map +1 -0
  63. package/dist/renderers/RawJsonRenderer.js +17 -0
  64. package/dist/renderers/RawJsonRenderer.js.map +1 -0
  65. package/dist/renderers/StatSummaryRenderer.d.ts +12 -0
  66. package/dist/renderers/StatSummaryRenderer.d.ts.map +1 -0
  67. package/dist/renderers/StatSummaryRenderer.js +93 -0
  68. package/dist/renderers/StatSummaryRenderer.js.map +1 -0
  69. package/dist/renderers/SynthesisRenderer.d.ts +15 -0
  70. package/dist/renderers/SynthesisRenderer.d.ts.map +1 -0
  71. package/dist/renderers/SynthesisRenderer.js +60 -0
  72. package/dist/renderers/SynthesisRenderer.js.map +1 -0
  73. package/dist/renderers/TableRenderer.d.ts +19 -0
  74. package/dist/renderers/TableRenderer.d.ts.map +1 -0
  75. package/dist/renderers/TableRenderer.js +273 -0
  76. package/dist/renderers/TableRenderer.js.map +1 -0
  77. package/dist/styles/accordion.css +376 -0
  78. package/dist/styles/index.css +5 -0
  79. package/dist/styles/renderers.css +1049 -0
  80. package/dist/sub-renderers/SubRenderers.d.ts +73 -0
  81. package/dist/sub-renderers/SubRenderers.d.ts.map +1 -0
  82. package/dist/sub-renderers/SubRenderers.js +2462 -0
  83. package/dist/sub-renderers/SubRenderers.js.map +1 -0
  84. package/dist/tokens/DesignTokenContext.d.ts +40 -0
  85. package/dist/tokens/DesignTokenContext.d.ts.map +1 -0
  86. package/dist/tokens/DesignTokenContext.js +408 -0
  87. package/dist/tokens/DesignTokenContext.js.map +1 -0
  88. package/dist/types/designTokens.d.ts +220 -0
  89. package/dist/types/designTokens.d.ts.map +1 -0
  90. package/dist/types/designTokens.js +8 -0
  91. package/dist/types/designTokens.js.map +1 -0
  92. package/dist/types/index.d.ts +32 -0
  93. package/dist/types/index.d.ts.map +1 -0
  94. package/dist/types/index.js +5 -0
  95. package/dist/types/index.js.map +1 -0
  96. package/dist/types/styles.d.ts +38 -0
  97. package/dist/types/styles.d.ts.map +1 -0
  98. package/dist/types/styles.js +14 -0
  99. package/dist/types/styles.js.map +1 -0
  100. package/dist/utils/tokenFlattener.d.ts +14 -0
  101. package/dist/utils/tokenFlattener.d.ts.map +1 -0
  102. package/dist/utils/tokenFlattener.js +56 -0
  103. package/dist/utils/tokenFlattener.js.map +1 -0
  104. package/package.json +31 -0
  105. package/src/cells/TemplateCardCell.tsx +439 -0
  106. package/src/cells/index.ts +98 -0
  107. package/src/components/ConditionCards.tsx +109 -0
  108. package/src/components/EvidenceTrail.tsx +203 -0
  109. package/src/dispatch/SubRendererDispatch.tsx +282 -0
  110. package/src/hooks/useProseExtraction.ts +125 -0
  111. package/src/index.ts +82 -0
  112. package/src/provenance/ProvenanceSectionIcon.tsx +19 -0
  113. package/src/renderers/AccordionRenderer.tsx +609 -0
  114. package/src/renderers/CardGridRenderer.tsx +608 -0
  115. package/src/renderers/CardRenderer.tsx +517 -0
  116. package/src/renderers/ProseRenderer.tsx +85 -0
  117. package/src/renderers/RawJsonRenderer.tsx +37 -0
  118. package/src/renderers/StatSummaryRenderer.tsx +182 -0
  119. package/src/renderers/TableRenderer.tsx +470 -0
  120. package/src/styles/accordion.css +376 -0
  121. package/src/styles/index.css +5 -0
  122. package/src/styles/renderers.css +1049 -0
  123. package/src/sub-renderers/SubRenderers.tsx +3487 -0
  124. package/src/tokens/DesignTokenContext.tsx +502 -0
  125. package/src/types/designTokens.ts +236 -0
  126. package/src/types/index.ts +53 -0
  127. package/src/types/styles.ts +44 -0
  128. package/src/utils/tokenFlattener.ts +64 -0
@@ -0,0 +1,3487 @@
1
+ /**
2
+ * SubRenderers — Section-level rendering components for accordion sections.
3
+ *
4
+ * These are small, focused renderers that handle one section's data within
5
+ * an accordion. The AccordionRenderer dispatches to these based on
6
+ * config.section_renderers[sectionKey].renderer_type.
7
+ *
8
+ * Available sub-renderers:
9
+ * chip_grid — Array of strings/objects → weighted chip cloud
10
+ * definition_list — Array of "Term: Definition" strings or objects → glossary layout
11
+ * mini_card_list — Array of objects → hero + grid insight cards
12
+ * key_value_table — Object or [{key, value}] → styled two-column table
13
+ * prose_block — String → formatted analysis with lede, blockquotes
14
+ * stat_row — Object → monospace stat cards
15
+ * comparison_panel — Array of objects → side-by-side comparison with headers
16
+ * timeline_strip — Array of objects with stages → evolution arc with progression
17
+ * evidence_trail — Vertical chain of evidence steps with dot markers and connectors
18
+ * ordered_flow — Ordered sequence of content units with connecting line and category badges
19
+ * intensity_matrix — Dashboard rows with horizontal intensity bars for quantitative dimensions
20
+ * move_repertoire — Grouped card list with collapsible category headers and count badges
21
+ * dialectical_pair — Two-panel tension visualization for thesis/antithesis contrasts
22
+ * rich_description_list — Stacked items with colored borders for paragraph-length descriptions
23
+ * phase_timeline — Connected timeline with prominent phase nodes for temporal data
24
+ * distribution_summary — Visual bar chart with dominant highlight, counts, and optional narrative
25
+ */
26
+
27
+ import React, { useState } from 'react';
28
+ import { EvidenceTrailSubRenderer } from '../components/EvidenceTrail';
29
+ import { EnableConditionsSubRenderer, ConstrainConditionsSubRenderer } from '../components/ConditionCards';
30
+ import { StyleOverrides, getSO } from '../types/styles';
31
+ import { useDesignTokens } from '../tokens/DesignTokenContext';
32
+ import type { SubRendererProps } from '../types';
33
+ // CaptureSelection is passed via config._onCapture — no direct type import needed
34
+ type CaptureSelection = Record<string, unknown>;
35
+
36
+ // Re-export SubRendererProps for backward compat
37
+ export type { SubRendererProps } from '../types';
38
+
39
+ // ── Registry ─────────────────────────────────────────────
40
+
41
+ const SUB_RENDERER_MAP: Record<string, React.FC<SubRendererProps>> = {
42
+ chip_grid: ChipGrid,
43
+ definition_list: DefinitionList,
44
+ mini_card_list: MiniCardList,
45
+ key_value_table: KeyValueTable,
46
+ prose_block: ProseBlock,
47
+ stat_row: StatRow,
48
+ comparison_panel: ComparisonPanel,
49
+ timeline_strip: TimelineStrip,
50
+ evidence_trail: EvidenceTrailSubRenderer,
51
+ enabling_conditions: EnableConditionsSubRenderer,
52
+ constraining_conditions: ConstrainConditionsSubRenderer,
53
+ ordered_flow: OrderedFlow,
54
+ intensity_matrix: IntensityMatrix,
55
+ move_repertoire: MoveRepertoire,
56
+ grouped_card_list: MoveRepertoire, // alias for generic usage
57
+ dialectical_pair: DialecticalPair,
58
+ rich_description_list: RichDescriptionList,
59
+ phase_timeline: PhaseTimeline,
60
+ distribution_summary: DistributionSummary,
61
+ };
62
+
63
+ export function resolveSubRenderer(rendererType: string): React.FC<SubRendererProps> | null {
64
+ return SUB_RENDERER_MAP[rendererType] || null;
65
+ }
66
+
67
+ /**
68
+ * Auto-detect the best sub-renderer for a data shape.
69
+ * Used by AccordionRenderer when no section_renderer is explicitly configured.
70
+ *
71
+ * Decision tree:
72
+ * string → prose_block
73
+ * string[] → chip_grid
74
+ * object[] with nested array fields → timeline_strip (concept evolution, stage progressions)
75
+ * object[] with title+description → mini_card_list
76
+ * object[] short items (≤3 fields, all short) → chip_grid
77
+ * flat object (no nested arrays) → key_value_table
78
+ * object with only numeric values → stat_row
79
+ */
80
+ export function autoDetectSubRenderer(data: unknown): string | null {
81
+ if (data === null || data === undefined) return null;
82
+
83
+ if (typeof data === 'string') return 'prose_block';
84
+
85
+ if (Array.isArray(data) && data.length > 0) {
86
+ if (data.every(d => typeof d === 'string')) {
87
+ // Check if strings look like "Term: Definition" → use definition_list
88
+ const strs = data as string[];
89
+ const defCount = strs.filter(s => {
90
+ const colonIdx = s.indexOf(':');
91
+ // Term before colon (1-60 chars), definition after colon (10+ chars)
92
+ return colonIdx > 1 && colonIdx < 60 && s.length > colonIdx + 10;
93
+ }).length;
94
+ if (defCount >= strs.length * 0.5) return 'definition_list';
95
+ return 'chip_grid';
96
+ }
97
+
98
+ const firstObj = data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined;
99
+ if (firstObj) {
100
+ const entries = Object.entries(firstObj);
101
+ const hasArrayField = entries.some(([, v]) => Array.isArray(v) && (v as unknown[]).length > 0);
102
+ const fieldCount = entries.length;
103
+ const shortStringCount = entries.filter(([, v]) => typeof v === 'string' && (v as string).length < 60).length;
104
+ const longStringCount = entries.filter(([, v]) => typeof v === 'string' && (v as string).length >= 60).length;
105
+
106
+ if (hasArrayField) return 'timeline_strip';
107
+ if (fieldCount <= 3 && shortStringCount >= fieldCount - 1 && longStringCount === 0) return 'chip_grid';
108
+ if (longStringCount > 0) return 'mini_card_list';
109
+ return 'mini_card_list';
110
+ }
111
+ }
112
+
113
+ if (typeof data === 'object' && !Array.isArray(data)) {
114
+ const obj = data as Record<string, unknown>;
115
+ const entries = Object.entries(obj).filter(([, v]) => v !== null && v !== undefined);
116
+ if (entries.length > 0 && entries.every(([, v]) => typeof v === 'number')) return 'stat_row';
117
+ const allScalar = entries.every(([, v]) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean');
118
+ if (allScalar) return 'key_value_table';
119
+ }
120
+
121
+ return null;
122
+ }
123
+
124
+ // ── Helpers ──────────────────────────────────────────────
125
+
126
+ function getField(obj: Record<string, unknown>, field: string | undefined): string {
127
+ if (!field) return '';
128
+ const val = obj[field];
129
+ if (val === null || val === undefined) return '';
130
+ return String(val);
131
+ }
132
+
133
+ function getNumField(obj: Record<string, unknown>, field: string | undefined): number | null {
134
+ if (!field) return null;
135
+ const val = obj[field];
136
+ if (typeof val === 'number') return val;
137
+ if (typeof val === 'string') {
138
+ const n = parseFloat(val);
139
+ return isNaN(n) ? null : n;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ interface AutoFields {
145
+ title?: string;
146
+ subtitle?: string;
147
+ description?: string;
148
+ badge?: string;
149
+ label?: string;
150
+ count?: string;
151
+ key?: string;
152
+ value?: string;
153
+ }
154
+
155
+ const TITLE_HINTS = ['name', 'title', 'label', 'term', 'concept', 'framework_name', 'heading'];
156
+ const SUBTITLE_HINTS = ['type', 'category', 'kind', 'centrality', 'status', 'role', 'level'];
157
+ const DESC_HINTS = ['description', 'summary', 'definition', 'explanation', 'text', 'content', 'methodological_signature'];
158
+ const KEY_HINTS = ['key', 'term', 'concept', 'name', 'label'];
159
+ const VALUE_HINTS = ['value', 'definition', 'meaning', 'description', 'explanation'];
160
+
161
+ function autoDetectFields(sample: Record<string, unknown>): AutoFields {
162
+ const result: AutoFields = {};
163
+ const entries = Object.entries(sample);
164
+
165
+ const shortStrings: string[] = [];
166
+ const longStrings: string[] = [];
167
+ const numericFields: string[] = [];
168
+
169
+ for (const [k, v] of entries) {
170
+ if (typeof v === 'string') {
171
+ if (v.length > 80) longStrings.push(k);
172
+ else shortStrings.push(k);
173
+ } else if (typeof v === 'number') {
174
+ numericFields.push(k);
175
+ }
176
+ }
177
+
178
+ result.title = shortStrings.find(k => TITLE_HINTS.includes(k)) || shortStrings[0];
179
+ result.label = result.title;
180
+
181
+ const remaining = shortStrings.filter(k => k !== result.title);
182
+ result.subtitle = remaining.find(k => SUBTITLE_HINTS.includes(k)) || remaining[0];
183
+
184
+ result.description = longStrings.find(k => DESC_HINTS.includes(k)) || longStrings[0];
185
+
186
+ result.badge = numericFields[0];
187
+ result.count = numericFields[0];
188
+
189
+ result.key = shortStrings.find(k => KEY_HINTS.includes(k)) || shortStrings[0];
190
+ const valCandidates = [...longStrings, ...shortStrings.filter(k => k !== result.key)];
191
+ result.value = valCandidates.find(k => VALUE_HINTS.includes(k)) || valCandidates[0];
192
+
193
+ return result;
194
+ }
195
+
196
+ function resolveField(config: Record<string, unknown>, configKey: string, auto: AutoFields, autoKey: keyof AutoFields): string | undefined {
197
+ return (config[configKey] as string | undefined) || auto[autoKey];
198
+ }
199
+
200
+ // ── Color Utilities ──────────────────────────────────────
201
+
202
+ function parseAccentHSL(hex: string | undefined): { h: number; s: number; l: number } {
203
+ if (!hex || !hex.startsWith('#') || hex.length < 7) return { h: 220, s: 55, l: 45 };
204
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
205
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
206
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
207
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
208
+ let h = 0, s = 0;
209
+ const l = (max + min) / 2;
210
+ if (max !== min) {
211
+ const d = max - min;
212
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
213
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
214
+ else if (max === g) h = ((b - r) / d + 2) / 6;
215
+ else h = ((r - g) / d + 4) / 6;
216
+ }
217
+ return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
218
+ }
219
+
220
+ /** Render inline markdown: **bold** → accent-underlined bold, *italic* → em */
221
+ function renderInlineMarkdown(text: string, accentColor: string): React.ReactNode[] {
222
+ const parts: React.ReactNode[] = [];
223
+ let lastIndex = 0;
224
+ const regex = /(\*\*(.+?)\*\*|\*(.+?)\*)/g;
225
+ let match;
226
+ while ((match = regex.exec(text)) !== null) {
227
+ if (match.index > lastIndex) {
228
+ parts.push(text.slice(lastIndex, match.index));
229
+ }
230
+ if (match[2]) {
231
+ parts.push(
232
+ <strong key={match.index} style={{
233
+ fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
234
+ textDecoration: 'underline',
235
+ textDecorationColor: accentColor,
236
+ textUnderlineOffset: '3px',
237
+ textDecorationThickness: '2px',
238
+ }}>
239
+ {match[2]}
240
+ </strong>
241
+ );
242
+ } else if (match[3]) {
243
+ parts.push(<em key={match.index}>{match[3]}</em>);
244
+ }
245
+ lastIndex = match.index + match[0].length;
246
+ }
247
+ if (lastIndex < text.length) {
248
+ parts.push(text.slice(lastIndex));
249
+ }
250
+ return parts.length > 0 ? parts : [text];
251
+ }
252
+
253
+ // ── ChipGrid ─────────────────────────────────────────────
254
+
255
+ function ChipGrid({ data, config }: SubRendererProps) {
256
+ const [expandedIdx, setExpandedIdx] = React.useState<number | null>(null);
257
+ const { getChipWeight, tokens } = useDesignTokens();
258
+ const so = getSO(config);
259
+
260
+ if (!data || !Array.isArray(data)) return null;
261
+
262
+ const firstObj = data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined;
263
+ const auto = firstObj ? autoDetectFields(firstObj) : {};
264
+ const labelField = resolveField(config, 'label_field', auto, 'label');
265
+ const countField = resolveField(config, 'count_field', auto, 'count');
266
+ const subtitleField = resolveField(config, 'subtitle_field', auto, 'subtitle');
267
+ const descField = resolveField(config, 'description_field', auto, 'description');
268
+
269
+ const hasDetails = firstObj && (
270
+ (descField && getField(firstObj, descField).length > 0) ||
271
+ Object.values(firstObj).some(v => Array.isArray(v))
272
+ );
273
+
274
+ // Collect numeric values for size variation
275
+ const numericValues: number[] = [];
276
+ if (countField) {
277
+ data.forEach(item => {
278
+ if (typeof item === 'object' && item !== null) {
279
+ const n = getNumField(item as Record<string, unknown>, countField);
280
+ if (n !== null) numericValues.push(n);
281
+ }
282
+ });
283
+ }
284
+ const hasNumeric = numericValues.length > 0;
285
+ const minVal = hasNumeric ? Math.min(...numericValues) : 0;
286
+ const maxVal = hasNumeric ? Math.max(...numericValues) : 1;
287
+ const valRange = maxVal - minVal || 1;
288
+
289
+ // Build chip items with weight for sorting
290
+ const chipItems = data.map((item, i) => {
291
+ const label = typeof item === 'string'
292
+ ? item
293
+ : typeof item === 'object' && item !== null
294
+ ? getField(item as Record<string, unknown>, labelField)
295
+ || String(Object.values(item as Record<string, unknown>).find(v => typeof v === 'string' && (v as string).length < 80) || `Item ${i + 1}`)
296
+ : String(item);
297
+
298
+ const subtitle = typeof item === 'object' && item !== null && subtitleField
299
+ ? getField(item as Record<string, unknown>, subtitleField)
300
+ : '';
301
+
302
+ const count = typeof item === 'object' && item !== null && countField
303
+ ? getNumField(item as Record<string, unknown>, countField)
304
+ : null;
305
+
306
+ const weight = hasNumeric && count !== null
307
+ ? (count - minVal) / valRange
308
+ : 0.5;
309
+
310
+ return { item, label, subtitle, count, weight, originalIndex: i };
311
+ });
312
+
313
+ // Sort by weight descending for weighted cloud layout
314
+ const sortedChips = [...chipItems].sort((a, b) => b.weight - a.weight);
315
+
316
+ return (
317
+ <div>
318
+ <div style={{
319
+ display: 'flex', gap: 'var(--space-sm, 0.5rem)', flexWrap: 'wrap',
320
+ padding: 'var(--space-md, 0.75rem)',
321
+ backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
322
+ borderRadius: 'var(--radius-lg, 12px)',
323
+ border: '1px solid var(--color-border-light, #eef0f2)',
324
+ ...so?.items_container,
325
+ }}>
326
+ {sortedChips.map(({ item, label, subtitle, count, weight, originalIndex }) => {
327
+ const chipColors = getChipWeight(weight);
328
+ const colors = { ...chipColors, headerBg: tokens.components.chip_header_bg, headerText: tokens.components.chip_header_text };
329
+ const isExpanded = expandedIdx === originalIndex;
330
+ const isClickable = hasDetails && typeof item === 'object' && item !== null;
331
+
332
+ // Size variation: large chips (weight > 0.7) get bigger padding/font
333
+ const sizeClass = weight > 0.7 ? 'large' : weight > 0.3 ? 'medium' : 'small';
334
+ const padH = sizeClass === 'large' ? '18px' : sizeClass === 'medium' ? '14px' : '10px';
335
+ const padV = sizeClass === 'large' ? '8px' : sizeClass === 'medium' ? '6px' : '5px';
336
+ const fontSize = sizeClass === 'large'
337
+ ? 'var(--type-body, 0.9375rem)'
338
+ : sizeClass === 'medium'
339
+ ? 'var(--type-caption, 0.8125rem)'
340
+ : 'var(--type-label, 0.6875rem)';
341
+
342
+ return (
343
+ <span
344
+ key={originalIndex}
345
+ onClick={isClickable ? () => setExpandedIdx(isExpanded ? null : originalIndex) : undefined}
346
+ title={isClickable ? 'Click to expand details' : undefined}
347
+ style={{
348
+ display: 'inline-flex', alignItems: 'center',
349
+ gap: 'var(--space-xs, 0.25rem)',
350
+ padding: `${padV} ${padH}`,
351
+ borderRadius: 'var(--radius-pill, 9999px)',
352
+ fontSize,
353
+ fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
354
+ backgroundColor: isExpanded ? colors.headerBg : colors.bg,
355
+ color: isExpanded ? colors.headerText : colors.text,
356
+ border: `1.5px solid ${isExpanded ? colors.headerBg : colors.border}`,
357
+ boxShadow: isExpanded
358
+ ? 'var(--shadow-md, 0 4px 6px rgba(0,0,0,0.05))'
359
+ : 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
360
+ cursor: isClickable ? 'pointer' : 'default',
361
+ transition: `all var(--duration-fast, 150ms) var(--ease-out, ease)`,
362
+ ...so?.chip,
363
+ }}
364
+ >
365
+ <span style={so?.chip_label}>{label}</span>
366
+ {subtitle && (
367
+ <span style={{
368
+ fontSize: 'var(--type-label, 0.6875rem)',
369
+ fontWeight: 'var(--weight-medium, 500)' as unknown as number,
370
+ padding: '1px 7px', borderRadius: 'var(--radius-pill, 9999px)',
371
+ backgroundColor: isExpanded ? 'rgba(255,255,255,0.25)' : colors.headerBg,
372
+ color: colors.headerText,
373
+ letterSpacing: '0.02em',
374
+ ...so?.badge,
375
+ }}>{subtitle}</span>
376
+ )}
377
+ {count !== null && (
378
+ <span style={{
379
+ fontSize: 'var(--type-label, 0.6875rem)',
380
+ fontWeight: 'var(--weight-bold, 700)' as unknown as number,
381
+ backgroundColor: isExpanded ? 'rgba(255,255,255,0.25)' : colors.headerBg,
382
+ color: colors.headerText,
383
+ borderRadius: 'var(--radius-pill, 9999px)',
384
+ padding: '1px 6px',
385
+ ...so?.badge,
386
+ }}>
387
+ {count}
388
+ </span>
389
+ )}
390
+ </span>
391
+ );
392
+ })}
393
+ </div>
394
+
395
+ {/* Expanded detail — inline card */}
396
+ {expandedIdx !== null && typeof data[expandedIdx] === 'object' && data[expandedIdx] !== null && (() => {
397
+ const obj = data[expandedIdx] as Record<string, unknown>;
398
+ const label = getField(obj, labelField);
399
+ const subtitle = getField(obj, subtitleField);
400
+ const desc = descField ? getField(obj, descField) : '';
401
+ const expandChipColors = getChipWeight(0.6);
402
+ const colors = { ...expandChipColors, headerBg: tokens.components.chip_header_bg, headerText: tokens.components.chip_header_text };
403
+
404
+ const skipKeys = new Set([labelField, subtitleField, descField].filter(Boolean) as string[]);
405
+ const remaining = Object.entries(obj).filter(([k, v]) => !skipKeys.has(k) && v !== null && v !== undefined && v !== '');
406
+ const arrayFields = remaining.filter(([, v]) => Array.isArray(v));
407
+ const scalarFields = remaining.filter(([, v]) => !Array.isArray(v) && typeof v === 'string' && (v as string).length > 40);
408
+ const shortFields = remaining.filter(([, v]) => !Array.isArray(v) && (typeof v !== 'string' || (v as string).length <= 40));
409
+
410
+ return (
411
+ <div style={{
412
+ margin: 'var(--space-sm, 0.5rem) 0',
413
+ borderRadius: 'var(--radius-md, 8px)',
414
+ overflow: 'hidden',
415
+ border: `1.5px solid ${colors.border}`,
416
+ boxShadow: 'var(--shadow-md, 0 4px 6px rgba(0,0,0,0.05))',
417
+ ...so?.chip_expanded,
418
+ }}>
419
+ {/* Card header */}
420
+ <div style={{
421
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
422
+ backgroundColor: colors.headerBg,
423
+ display: 'flex', alignItems: 'center', gap: 'var(--space-sm, 0.5rem)',
424
+ }}>
425
+ <strong style={{
426
+ fontSize: 'var(--type-subheading, 1.125rem)',
427
+ color: colors.headerText,
428
+ }}>{label}</strong>
429
+ {subtitle && (
430
+ <span style={{
431
+ fontSize: 'var(--type-label, 0.6875rem)',
432
+ fontWeight: 'var(--weight-medium, 500)' as unknown as number,
433
+ padding: '2px 8px', borderRadius: 'var(--radius-pill, 9999px)',
434
+ backgroundColor: 'rgba(255,255,255,0.2)',
435
+ color: colors.headerText,
436
+ letterSpacing: '0.02em',
437
+ }}>{subtitle}</span>
438
+ )}
439
+ <span
440
+ onClick={() => setExpandedIdx(null)}
441
+ style={{
442
+ marginLeft: 'auto', cursor: 'pointer',
443
+ fontSize: 'var(--type-label, 0.6875rem)',
444
+ color: 'rgba(255,255,255,0.7)',
445
+ padding: '2px 8px', borderRadius: 'var(--radius-sm, 4px)',
446
+ transition: `opacity var(--duration-fast, 150ms)`,
447
+ }}
448
+ >close</span>
449
+ </div>
450
+
451
+ {/* Card body */}
452
+ <div style={{
453
+ padding: 'var(--space-md, 1rem)',
454
+ backgroundColor: colors.bg,
455
+ }}>
456
+ {desc && (
457
+ <p style={{
458
+ margin: '0 0 var(--space-sm, 0.5rem) 0',
459
+ fontSize: 'var(--type-body, 0.9375rem)',
460
+ color: 'var(--color-text, #1a1d23)',
461
+ lineHeight: 'var(--leading-relaxed, 1.65)',
462
+ }}>{desc}</p>
463
+ )}
464
+
465
+ {shortFields.length > 0 && (
466
+ <div style={{
467
+ display: 'flex', gap: 'var(--space-md, 0.75rem)', flexWrap: 'wrap',
468
+ marginBottom: arrayFields.length > 0 || scalarFields.length > 0 ? 'var(--space-sm, 0.5rem)' : 0,
469
+ }}>
470
+ {shortFields.map(([k, v]) => (
471
+ <span key={k} style={{ fontSize: 'var(--type-caption, 0.8125rem)', color: 'var(--color-text-muted, #6b7280)' }}>
472
+ <span className="gen-inline-label">
473
+ {k.replace(/_/g, ' ')}:
474
+ </span>{' '}
475
+ {String(v)}
476
+ </span>
477
+ ))}
478
+ </div>
479
+ )}
480
+
481
+ {scalarFields.map(([k, v]) => (
482
+ <div key={k} style={{ marginBottom: 'var(--space-xs, 0.375rem)' }}>
483
+ <span className="gen-inline-label" style={{ display: 'block', marginBottom: 'var(--space-2xs, 0.125rem)' }}>
484
+ {k.replace(/_/g, ' ')}
485
+ </span>
486
+ <span style={{
487
+ fontSize: 'var(--type-caption, 0.8125rem)',
488
+ color: 'var(--color-text-muted, #6b7280)',
489
+ lineHeight: 'var(--leading-normal, 1.5)',
490
+ }}>{String(v)}</span>
491
+ </div>
492
+ ))}
493
+
494
+ {arrayFields.map(([k, v]) => (
495
+ <div key={k} style={{ marginTop: 'var(--space-xs, 0.375rem)' }}>
496
+ <span className="gen-inline-label" style={{ marginRight: 'var(--space-xs, 0.375rem)' }}>
497
+ {k.replace(/_/g, ' ')}:
498
+ </span>
499
+ <span style={{ display: 'inline-flex', gap: 'var(--space-xs, 0.25rem)', flexWrap: 'wrap' }}>
500
+ {(v as unknown[]).map((chip, ci) => (
501
+ <span key={ci} className="gen-keyword-tag">{String(chip)}</span>
502
+ ))}
503
+ </span>
504
+ </div>
505
+ ))}
506
+ </div>
507
+ </div>
508
+ );
509
+ })()}
510
+ </div>
511
+ );
512
+ }
513
+
514
+ // ── DefinitionList → Glossary/Vocabulary ─────────────────
515
+
516
+ /**
517
+ * Purpose-built renderer for glossary/vocabulary data.
518
+ * Handles:
519
+ * - Array of "Term: Definition" strings (splits on first colon)
520
+ * - Array of objects with term/definition fields
521
+ * Renders as a visually rich definition list with:
522
+ * - Prominent term styling with accent color
523
+ * - Clear definition text
524
+ * - Alternating subtle backgrounds
525
+ * - Compact, scannable layout
526
+ */
527
+ function DefinitionList({ data, config }: SubRendererProps) {
528
+ const [expandedIdx, setExpandedIdx] = React.useState<number | null>(null);
529
+ const { tokens } = useDesignTokens();
530
+ const so = getSO(config);
531
+
532
+ // Capture mode support (threaded from AccordionRenderer)
533
+ const captureMode = config._captureMode as boolean | undefined;
534
+ const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
535
+ const captureJobId = config._captureJobId as string | undefined;
536
+ const captureViewKey = config._captureViewKey as string | undefined;
537
+ const captureSourceType = config._captureSourceType as string | undefined;
538
+ const captureEntityId = config._captureEntityId as string | undefined;
539
+ const parentSectionKey = config._parentSectionKey as string | undefined;
540
+ const parentSectionTitle = config._parentSectionTitle as string | undefined;
541
+
542
+ if (!data || !Array.isArray(data) || data.length === 0) return null;
543
+
544
+ // Parse items into {term, definition} pairs
545
+ const termField = config.term_field as string | undefined;
546
+ const defField = config.definition_field as string | undefined;
547
+
548
+ const items = data.map((item, i) => {
549
+ if (typeof item === 'string') {
550
+ // Split "Term: Definition" on first colon
551
+ const colonIdx = item.indexOf(':');
552
+ if (colonIdx > 0 && colonIdx < 80) {
553
+ return {
554
+ term: item.slice(0, colonIdx).trim(),
555
+ definition: item.slice(colonIdx + 1).trim(),
556
+ };
557
+ }
558
+ return { term: `Entry ${i + 1}`, definition: item };
559
+ }
560
+ if (typeof item === 'object' && item !== null) {
561
+ const obj = item as Record<string, unknown>;
562
+ const t = getField(obj, termField) || getField(obj, 'term') || getField(obj, 'name') || getField(obj, 'concept') || getField(obj, 'label') || '';
563
+ const d = getField(obj, defField) || getField(obj, 'definition') || getField(obj, 'description') || getField(obj, 'meaning') || '';
564
+ return { term: t || `Entry ${i + 1}`, definition: d || JSON.stringify(obj) };
565
+ }
566
+ return { term: `Entry ${i + 1}`, definition: String(item) };
567
+ });
568
+
569
+ // Color rotation for visual variety using series palette
570
+ const palette = tokens.primitives.series_palette;
571
+ const termColor = (idx: number) => {
572
+ const color = palette[idx % palette.length];
573
+ return {
574
+ termBg: color,
575
+ termText: 'var(--dt-text-inverse)',
576
+ dotColor: color,
577
+ hoverBg: tokens.surfaces.surface_alt,
578
+ };
579
+ };
580
+
581
+ return (
582
+ <div style={{
583
+ display: 'flex',
584
+ flexDirection: 'column',
585
+ gap: '2px',
586
+ borderRadius: 'var(--radius-lg, 12px)',
587
+ overflow: 'hidden',
588
+ border: '1px solid var(--color-border-light, #eef0f2)',
589
+ boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
590
+ ...so?.items_container,
591
+ }}>
592
+ {items.map((item, i) => {
593
+ const colors = termColor(i);
594
+ const isExpanded = expandedIdx === i;
595
+ const isLong = item.definition.length > 180;
596
+ const displayDef = !isLong || isExpanded
597
+ ? item.definition
598
+ : item.definition.slice(0, 180) + '...';
599
+
600
+ return (
601
+ <div
602
+ key={i}
603
+ onClick={isLong ? () => setExpandedIdx(isExpanded ? null : i) : undefined}
604
+ style={{
605
+ display: 'grid',
606
+ gridTemplateColumns: 'auto 1fr',
607
+ gap: 0,
608
+ backgroundColor: i % 2 === 0
609
+ ? 'var(--color-surface, #ffffff)'
610
+ : 'var(--color-surface-alt, #f8f9fa)',
611
+ cursor: isLong ? 'pointer' : 'default',
612
+ transition: 'background-color 150ms ease',
613
+ }}
614
+ >
615
+ {/* Term column */}
616
+ <div style={{
617
+ padding: '10px 14px',
618
+ display: 'flex',
619
+ alignItems: 'flex-start',
620
+ gap: '8px',
621
+ minWidth: '200px',
622
+ maxWidth: '280px',
623
+ borderRight: `3px solid ${colors.dotColor}`,
624
+ backgroundColor: i % 2 === 0 ? tokens.surfaces.surface_alt : tokens.surfaces.surface_inset,
625
+ }}>
626
+ <span style={{
627
+ display: 'inline-block',
628
+ width: '7px',
629
+ height: '7px',
630
+ borderRadius: '50%',
631
+ backgroundColor: colors.dotColor,
632
+ marginTop: '6px',
633
+ flexShrink: 0,
634
+ }} />
635
+ <span style={{
636
+ fontSize: 'var(--type-caption, 0.8125rem)',
637
+ fontWeight: 'var(--weight-bold, 700)' as unknown as number,
638
+ color: tokens.surfaces.text_default,
639
+ lineHeight: '1.3',
640
+ letterSpacing: '0.01em',
641
+ ...so?.stat_label,
642
+ }}>
643
+ {item.term}
644
+ </span>
645
+ </div>
646
+
647
+ {/* Definition column */}
648
+ <div style={{
649
+ padding: '10px 16px',
650
+ fontSize: 'var(--type-body, 0.9375rem)',
651
+ fontWeight: 'var(--weight-normal, 400)' as unknown as number,
652
+ color: 'var(--color-text, #1a1d23)',
653
+ lineHeight: 'var(--leading-relaxed, 1.6)',
654
+ display: 'flex',
655
+ alignItems: 'flex-start',
656
+ gap: '8px',
657
+ }}>
658
+ <div style={{ flex: 1 }}>
659
+ {renderInlineMarkdown(displayDef, colors.dotColor)}
660
+ {isLong && (
661
+ <span className="gen-show-more-link" style={{ marginLeft: '6px' }}>
662
+ {isExpanded ? 'show less' : 'show more'}
663
+ </span>
664
+ )}
665
+ </div>
666
+ {captureMode && onCapture && (
667
+ <button
668
+ title="Capture this item"
669
+ onClick={e => {
670
+ e.stopPropagation();
671
+ onCapture({
672
+ source_view_key: captureViewKey || '',
673
+ source_section_key: parentSectionKey,
674
+ source_item_index: i,
675
+ source_renderer_type: 'definition_list',
676
+ content_type: 'item',
677
+ selected_text: `${item.term}: ${item.definition}`.slice(0, 500),
678
+ structured_data: data[i],
679
+ context_title: parentSectionKey
680
+ ? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${item.term}`
681
+ : `${captureViewKey || 'Analysis'} > ${item.term}`,
682
+ source_type: (captureSourceType || 'analysis') as string,
683
+ entity_id: captureEntityId || captureJobId || '',
684
+ depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
685
+ parent_context: parentSectionKey ? {
686
+ section_key: parentSectionKey,
687
+ section_title: parentSectionTitle || '',
688
+ } : undefined,
689
+ });
690
+ }}
691
+ style={{
692
+ flexShrink: 0,
693
+ background: 'none',
694
+ border: '1px solid var(--color-border, #ccc)',
695
+ borderRadius: '4px',
696
+ color: 'var(--dt-text-faint, #94a3b8)',
697
+ cursor: 'pointer',
698
+ padding: '2px 6px',
699
+ fontSize: '0.7rem',
700
+ lineHeight: 1,
701
+ marginTop: '2px',
702
+ }}
703
+ >
704
+ &#x1F4CC;
705
+ </button>
706
+ )}
707
+ </div>
708
+ </div>
709
+ );
710
+ })}
711
+ </div>
712
+ );
713
+ }
714
+
715
+ // ── MiniCardList → Insight Cards ─────────────────────────
716
+
717
+ function MiniCardList({ data, config }: SubRendererProps) {
718
+ const [expandedCards, setExpandedCards] = React.useState<Set<number>>(new Set());
719
+ const [hoveredIdx, setHoveredIdx] = React.useState<number | null>(null);
720
+ const { tokens } = useDesignTokens();
721
+
722
+ // Capture mode support (threaded from AccordionRenderer)
723
+ const captureMode = config._captureMode as boolean | undefined;
724
+ const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
725
+ const captureJobId = config._captureJobId as string | undefined;
726
+ const captureViewKey = config._captureViewKey as string | undefined;
727
+ const captureSourceType = config._captureSourceType as string | undefined;
728
+ const captureEntityId = config._captureEntityId as string | undefined;
729
+ const parentSectionKey = config._parentSectionKey as string | undefined;
730
+ const parentSectionTitle = config._parentSectionTitle as string | undefined;
731
+
732
+ if (!data || !Array.isArray(data)) return null;
733
+ const so = getSO(config);
734
+
735
+ const firstObj = data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined;
736
+ const auto = firstObj ? autoDetectFields(firstObj) : {};
737
+ const titleField = resolveField(config, 'title_field', auto, 'title');
738
+ const subtitleField = resolveField(config, 'subtitle_field', auto, 'subtitle');
739
+ const badgeField = resolveField(config, 'badge_field', auto, 'badge');
740
+ const descriptionField = resolveField(config, 'description_field', auto, 'description');
741
+
742
+ // Hero card is opt-in: set hero:true in config to enable the hero pattern
743
+ const useHero = config.hero === true;
744
+
745
+ // Determine hero card: highest significance/importance/priority, or first
746
+ const SIGNIFICANCE_KEYS = ['significance', 'importance', 'priority', 'weight', 'relevance'];
747
+ let heroIdx = useHero ? 0 : -1;
748
+ if (useHero && data.length > 1 && firstObj) {
749
+ const sigField = Object.keys(firstObj).find(k => SIGNIFICANCE_KEYS.includes(k));
750
+ if (sigField) {
751
+ let maxVal = -Infinity;
752
+ data.forEach((item, i) => {
753
+ if (typeof item === 'object' && item !== null) {
754
+ const val = getNumField(item as Record<string, unknown>, sigField);
755
+ if (val !== null && val > maxVal) { maxVal = val; heroIdx = i; }
756
+ }
757
+ });
758
+ }
759
+ }
760
+
761
+ const toggleExpand = (idx: number) => {
762
+ setExpandedCards(prev => {
763
+ const next = new Set(prev);
764
+ if (next.has(idx)) next.delete(idx);
765
+ else next.add(idx);
766
+ return next;
767
+ });
768
+ };
769
+
770
+ const DESC_TRUNCATE_LEN = Infinity; // Show full text — no truncation
771
+
772
+ const renderCard = (item: unknown, idx: number, isHero: boolean) => {
773
+ if (typeof item !== 'object' || item === null) return null;
774
+ const obj = item as Record<string, unknown>;
775
+
776
+ const title = getField(obj, titleField);
777
+ const subtitle = getField(obj, subtitleField);
778
+ const badge = getField(obj, badgeField);
779
+ const description = getField(obj, descriptionField);
780
+
781
+ const shownFields = new Set([titleField, subtitleField, badgeField, descriptionField]);
782
+ const remaining = Object.entries(obj).filter(
783
+ ([k, v]) => !shownFields.has(k) && v !== null && v !== undefined && v !== ''
784
+ );
785
+ const chipFields = remaining.filter(([, v]) => Array.isArray(v) && (v as unknown[]).every(x => typeof x === 'string'));
786
+ const scalarFields = remaining.filter(([, v]) => !Array.isArray(v) || !(v as unknown[]).every(x => typeof x === 'string'));
787
+
788
+ const seriesColor = tokens.primitives.series_palette[idx % tokens.primitives.series_palette.length];
789
+ const colors = {
790
+ headerBg: seriesColor,
791
+ headerText: tokens.surfaces.text_on_accent,
792
+ accent: seriesColor,
793
+ lightBg: tokens.surfaces.surface_alt,
794
+ darkText: tokens.surfaces.text_default,
795
+ border: tokens.surfaces.border_default,
796
+ };
797
+ const isHovered = hoveredIdx === idx;
798
+ const isContentExpanded = expandedCards.has(idx);
799
+ const needsTruncation = !isHero && description.length > DESC_TRUNCATE_LEN;
800
+
801
+ return (
802
+ <div
803
+ key={idx}
804
+ onMouseEnter={() => setHoveredIdx(idx)}
805
+ onMouseLeave={() => setHoveredIdx(null)}
806
+ style={{
807
+ borderRadius: 'var(--radius-md, 8px)',
808
+ overflow: 'hidden',
809
+ borderLeft: `4px solid ${colors.accent}`,
810
+ border: `1px solid var(--color-border, #e2e5e9)`,
811
+ borderLeftWidth: '4px',
812
+ borderLeftColor: colors.accent,
813
+ boxShadow: isHovered
814
+ ? 'var(--shadow-md, 0 4px 6px rgba(0,0,0,0.05))'
815
+ : 'var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.06))',
816
+ transform: isHovered ? 'translateY(-2px)' : 'none',
817
+ transition: `box-shadow var(--duration-fast, 150ms) var(--ease-out, ease), transform var(--duration-fast, 150ms) var(--ease-out, ease)`,
818
+ backgroundColor: 'var(--color-surface, #ffffff)',
819
+ ...(isHero ? so?.hero_card : {}),
820
+ ...so?.card,
821
+ }}
822
+ >
823
+ {/* Header bar */}
824
+ <div style={{
825
+ padding: isHero
826
+ ? 'var(--space-md, 1rem) var(--space-lg, 1.5rem)'
827
+ : 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
828
+ display: 'flex', alignItems: 'center', gap: 'var(--space-sm, 0.5rem)', flexWrap: 'wrap',
829
+ ...so?.card_header,
830
+ }}>
831
+ {title && (
832
+ <strong style={{
833
+ fontSize: isHero
834
+ ? 'var(--type-heading, 1.375rem)'
835
+ : 'var(--type-body, 0.9375rem)',
836
+ fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
837
+ color: 'var(--color-text, #1a1d23)',
838
+ lineHeight: 'var(--leading-tight, 1.2)',
839
+ }}>
840
+ {title}
841
+ </strong>
842
+ )}
843
+ {subtitle && (
844
+ <span style={{
845
+ fontSize: 'var(--type-label, 0.6875rem)',
846
+ fontWeight: 'var(--weight-medium, 500)' as unknown as number,
847
+ color: colors.headerBg,
848
+ padding: '2px 10px',
849
+ borderRadius: 'var(--radius-pill, 9999px)',
850
+ backgroundColor: colors.lightBg,
851
+ letterSpacing: '0.02em',
852
+ ...so?.badge,
853
+ }}>{subtitle}</span>
854
+ )}
855
+ {badge && (
856
+ <span style={{
857
+ fontSize: 'var(--type-label, 0.6875rem)',
858
+ fontWeight: 'var(--weight-bold, 700)' as unknown as number,
859
+ padding: '2px 8px',
860
+ borderRadius: 'var(--radius-pill, 9999px)',
861
+ backgroundColor: colors.lightBg,
862
+ color: colors.darkText,
863
+ marginLeft: 'auto',
864
+ ...so?.badge,
865
+ }}>
866
+ {badge}
867
+ </span>
868
+ )}
869
+ {captureMode && onCapture && (
870
+ <button
871
+ title="Capture this card"
872
+ onClick={e => {
873
+ e.stopPropagation();
874
+ onCapture({
875
+ source_view_key: captureViewKey || '',
876
+ source_item_index: idx,
877
+ source_renderer_type: 'mini_card_list',
878
+ content_type: 'card',
879
+ selected_text: `${title}: ${description}`.slice(0, 500),
880
+ structured_data: obj,
881
+ context_title: parentSectionKey
882
+ ? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${title || `Card ${idx + 1}`}`
883
+ : `${captureViewKey || 'Analysis'} > ${title || `Card ${idx + 1}`}`,
884
+ source_type: (captureSourceType || 'analysis') as string,
885
+ entity_id: captureEntityId || captureJobId || '',
886
+ depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
887
+ parent_context: parentSectionKey ? {
888
+ section_key: parentSectionKey,
889
+ section_title: parentSectionTitle || '',
890
+ } : undefined,
891
+ });
892
+ }}
893
+ style={{
894
+ background: 'none',
895
+ border: '1px solid var(--color-border, #ccc)',
896
+ borderRadius: '4px',
897
+ color: 'var(--dt-text-faint, #94a3b8)',
898
+ cursor: 'pointer',
899
+ padding: '2px 6px',
900
+ fontSize: '0.7rem',
901
+ lineHeight: 1,
902
+ marginLeft: badge ? '0' : 'auto',
903
+ }}
904
+ >
905
+ &#x1F4CC;
906
+ </button>
907
+ )}
908
+ </div>
909
+
910
+ {/* Description body */}
911
+ {description && (
912
+ <div style={{
913
+ padding: isHero
914
+ ? '0 var(--space-lg, 1.5rem) var(--space-md, 1rem)'
915
+ : '0 var(--space-md, 1rem) var(--space-sm, 0.5rem)',
916
+ ...so?.card_body,
917
+ }}>
918
+ <p style={{
919
+ fontSize: isHero
920
+ ? 'var(--type-body, 0.9375rem)'
921
+ : 'var(--type-caption, 0.8125rem)',
922
+ color: 'var(--color-text, #1a1d23)',
923
+ lineHeight: 'var(--leading-relaxed, 1.65)',
924
+ margin: 0,
925
+ ...so?.prose,
926
+ }}>
927
+ {needsTruncation && !isContentExpanded
928
+ ? description.slice(0, DESC_TRUNCATE_LEN) + '...'
929
+ : description}
930
+ </p>
931
+ {needsTruncation && (
932
+ <button
933
+ className="gen-show-more-link"
934
+ onClick={(e) => { e.stopPropagation(); toggleExpand(idx); }}
935
+ style={{
936
+ marginTop: 'var(--space-xs, 0.25rem)',
937
+ }}
938
+ >
939
+ {isContentExpanded ? 'show less' : 'show more'}
940
+ </button>
941
+ )}
942
+ </div>
943
+ )}
944
+
945
+ {/* Scalar fields */}
946
+ {scalarFields.length > 0 && (
947
+ <div style={{
948
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
949
+ backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
950
+ borderTop: '1px solid var(--color-border-light, #eef0f2)',
951
+ }}>
952
+ {scalarFields.map(([key, value]) => (
953
+ <div key={key} style={{ marginBottom: 'var(--space-2xs, 0.25rem)' }}>
954
+ <span className="gen-inline-label">
955
+ {key.replace(/_/g, ' ')}:
956
+ </span>
957
+ <span style={{
958
+ marginLeft: 'var(--space-xs, 0.375rem)',
959
+ fontSize: 'var(--type-caption, 0.8125rem)',
960
+ color: 'var(--color-text-muted, #6b7280)',
961
+ }}>
962
+ {typeof value === 'object' ? JSON.stringify(value) : String(value)}
963
+ </span>
964
+ </div>
965
+ ))}
966
+ </div>
967
+ )}
968
+
969
+ {/* Chip fields */}
970
+ {chipFields.length > 0 && (
971
+ <div style={{
972
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
973
+ backgroundColor: colors.lightBg,
974
+ borderTop: '1px solid var(--color-border-light, #eef0f2)',
975
+ }}>
976
+ {chipFields.map(([key, value]) => (
977
+ <div key={key} style={{ marginBottom: 'var(--space-xs, 0.375rem)' }}>
978
+ <span className="gen-inline-label" style={{
979
+ display: 'block',
980
+ marginBottom: 'var(--space-2xs, 0.25rem)',
981
+ }}>
982
+ {key.replace(/_/g, ' ')}
983
+ </span>
984
+ <div style={{ display: 'flex', gap: 'var(--space-xs, 0.25rem)', flexWrap: 'wrap' }}>
985
+ {(value as string[]).map((v, vi) => (
986
+ <span key={vi} className="gen-keyword-tag">{String(v)}</span>
987
+ ))}
988
+ </div>
989
+ </div>
990
+ ))}
991
+ </div>
992
+ )}
993
+ </div>
994
+ );
995
+ };
996
+
997
+ // Single card → render as hero only if hero mode enabled
998
+ if (data.length === 1) {
999
+ return (
1000
+ <div style={{ ...so?.items_container }}>
1001
+ {renderCard(data[0], 0, useHero)}
1002
+ </div>
1003
+ );
1004
+ }
1005
+
1006
+ // Default: uniform grid layout
1007
+ if (!useHero) {
1008
+ return (
1009
+ <div style={{
1010
+ display: 'grid',
1011
+ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
1012
+ gap: 'var(--space-md, 1rem)',
1013
+ ...so?.items_container,
1014
+ }}>
1015
+ {data.map((item, i) => renderCard(item, i, false))}
1016
+ </div>
1017
+ );
1018
+ }
1019
+
1020
+ // Multi-card layout: hero on top, rest in 2-column grid
1021
+ return (
1022
+ <div style={{
1023
+ display: 'flex', flexDirection: 'column',
1024
+ gap: 'var(--space-md, 1rem)',
1025
+ ...so?.items_container,
1026
+ }}>
1027
+ {renderCard(data[heroIdx], heroIdx, true)}
1028
+
1029
+ {data.length > 1 && (
1030
+ <div style={{
1031
+ display: 'grid',
1032
+ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
1033
+ gap: 'var(--space-md, 1rem)',
1034
+ }}>
1035
+ {data.map((item, i) => {
1036
+ if (i === heroIdx) return null;
1037
+ return renderCard(item, i, false);
1038
+ })}
1039
+ </div>
1040
+ )}
1041
+ </div>
1042
+ );
1043
+ }
1044
+
1045
+ // ── KeyValueTable ────────────────────────────────────────
1046
+
1047
+ function KeyValueTable({ data, config }: SubRendererProps) {
1048
+ const { tokens } = useDesignTokens();
1049
+ const so = getSO(config);
1050
+
1051
+ const firstObj = Array.isArray(data)
1052
+ ? data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined
1053
+ : undefined;
1054
+ const auto = firstObj ? autoDetectFields(firstObj) : {};
1055
+ const keyField = resolveField(config, 'key_field', auto, 'key');
1056
+ const valueField = resolveField(config, 'value_field', auto, 'value');
1057
+
1058
+ let rows: Array<{ key: string; value: string }> = [];
1059
+
1060
+ if (Array.isArray(data)) {
1061
+ rows = data.map(item => {
1062
+ if (typeof item !== 'object' || item === null) return { key: '', value: String(item) };
1063
+ const obj = item as Record<string, unknown>;
1064
+ return {
1065
+ key: getField(obj, keyField) || Object.keys(obj)[0] || '',
1066
+ value: getField(obj, valueField) || String(Object.values(obj).find(v => typeof v === 'string' && v.length > 20) ?? Object.values(obj)[1] ?? ''),
1067
+ };
1068
+ });
1069
+ } else if (typeof data === 'object' && data !== null) {
1070
+ rows = Object.entries(data as Record<string, unknown>).map(([k, v]) => ({
1071
+ key: k,
1072
+ value: typeof v === 'object' ? JSON.stringify(v) : String(v ?? ''),
1073
+ }));
1074
+ }
1075
+
1076
+ if (rows.length === 0) return null;
1077
+
1078
+ const isNumeric = (val: string) => /^[\d,.]+%?$/.test(val.trim());
1079
+
1080
+ return (
1081
+ <div style={{
1082
+ borderRadius: 'var(--radius-md, 8px)',
1083
+ overflow: 'hidden',
1084
+ border: '1px solid var(--color-border, #e2e5e9)',
1085
+ boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
1086
+ ...so?.card,
1087
+ }}>
1088
+ <table style={{ width: '100%', borderCollapse: 'collapse' }}>
1089
+ <tbody>
1090
+ {rows.map((row, i) => (
1091
+ <tr key={i} style={{
1092
+ backgroundColor: i % 2 === 0
1093
+ ? 'var(--color-surface, #ffffff)'
1094
+ : 'var(--color-surface-alt, #f8f9fa)',
1095
+ }}>
1096
+ <td style={{
1097
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
1098
+ fontSize: 'var(--type-label, 0.6875rem)',
1099
+ fontWeight: 'var(--weight-medium, 500)' as unknown as number,
1100
+ color: tokens.surfaces.text_default,
1101
+ width: '30%', verticalAlign: 'top',
1102
+ textTransform: 'capitalize' as const,
1103
+ letterSpacing: '0.02em',
1104
+ borderRight: `2px solid ${tokens.surfaces.border_accent}`,
1105
+ backgroundColor: i % 2 === 0
1106
+ ? tokens.surfaces.surface_alt
1107
+ : tokens.surfaces.surface_inset,
1108
+ ...so?.stat_label,
1109
+ }}>
1110
+ {row.key.replace(/_/g, ' ')}
1111
+ </td>
1112
+ <td style={{
1113
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
1114
+ fontSize: 'var(--type-body, 0.9375rem)',
1115
+ fontWeight: isNumeric(row.value)
1116
+ ? ('var(--weight-semibold, 600)' as unknown as number)
1117
+ : ('var(--weight-normal, 400)' as unknown as number),
1118
+ fontFamily: isNumeric(row.value) ? 'var(--font-mono, monospace)' : 'inherit',
1119
+ color: 'var(--color-text, #1a1d23)',
1120
+ lineHeight: 'var(--leading-normal, 1.5)',
1121
+ ...(isNumeric(row.value) ? so?.stat_number : {}),
1122
+ }}>
1123
+ {row.value}
1124
+ </td>
1125
+ </tr>
1126
+ ))}
1127
+ </tbody>
1128
+ </table>
1129
+ </div>
1130
+ );
1131
+ }
1132
+
1133
+ // ── ProseBlock → Formatted Analysis ─────────────────────
1134
+
1135
+ interface ProseSegment {
1136
+ type: 'paragraph' | 'blockquote' | 'hr' | 'heading';
1137
+ content: string;
1138
+ level?: number;
1139
+ }
1140
+
1141
+ function parseProseContent(text: string): ProseSegment[] {
1142
+ const lines = text.split('\n');
1143
+ const segments: ProseSegment[] = [];
1144
+ let currentParagraph: string[] = [];
1145
+ let currentBlockquote: string[] = [];
1146
+
1147
+ function flushParagraph() {
1148
+ if (currentParagraph.length > 0) {
1149
+ const joined = currentParagraph.join(' ').trim();
1150
+ if (joined) segments.push({ type: 'paragraph', content: joined });
1151
+ currentParagraph = [];
1152
+ }
1153
+ }
1154
+
1155
+ function flushBlockquote() {
1156
+ if (currentBlockquote.length > 0) {
1157
+ const joined = currentBlockquote.join(' ').trim();
1158
+ if (joined) segments.push({ type: 'blockquote', content: joined });
1159
+ currentBlockquote = [];
1160
+ }
1161
+ }
1162
+
1163
+ for (const line of lines) {
1164
+ const trimmed = line.trim();
1165
+
1166
+ if (/^(---+|\*\*\*+)$/.test(trimmed)) {
1167
+ flushParagraph();
1168
+ flushBlockquote();
1169
+ segments.push({ type: 'hr', content: '' });
1170
+ continue;
1171
+ }
1172
+
1173
+ const headingMatch = trimmed.match(/^(#{1,3})\s+(.+)/);
1174
+ if (headingMatch) {
1175
+ flushParagraph();
1176
+ flushBlockquote();
1177
+ segments.push({ type: 'heading', content: headingMatch[2], level: headingMatch[1].length });
1178
+ continue;
1179
+ }
1180
+
1181
+ if (trimmed.startsWith('>')) {
1182
+ flushParagraph();
1183
+ currentBlockquote.push(trimmed.replace(/^>\s*/, ''));
1184
+ continue;
1185
+ }
1186
+
1187
+ if (!trimmed) {
1188
+ flushBlockquote();
1189
+ flushParagraph();
1190
+ continue;
1191
+ }
1192
+
1193
+ flushBlockquote();
1194
+ currentParagraph.push(trimmed);
1195
+ }
1196
+
1197
+ flushParagraph();
1198
+ flushBlockquote();
1199
+
1200
+ return segments;
1201
+ }
1202
+
1203
+ function ProseBlock({ data, config }: SubRendererProps) {
1204
+ const { tokens } = useDesignTokens();
1205
+ const so = getSO(config);
1206
+ if (!data) return null;
1207
+
1208
+ const text = typeof data === 'string'
1209
+ ? data
1210
+ : typeof data === 'object' && data !== null
1211
+ ? Object.values(data).filter(v => typeof v === 'string').join('\n\n')
1212
+ : String(data);
1213
+
1214
+ if (!text.trim()) return null;
1215
+
1216
+ const accentHex = so?.accent_color || tokens.components.page_accent;
1217
+ const segments = parseProseContent(text);
1218
+
1219
+ let paragraphIndex = 0;
1220
+
1221
+ return (
1222
+ <div style={{
1223
+ fontSize: 'var(--type-body, 0.9375rem)',
1224
+ lineHeight: 'var(--leading-relaxed, 1.65)',
1225
+ color: 'var(--color-text, #1a1d23)',
1226
+ ...so?.prose,
1227
+ }}>
1228
+ {segments.map((segment, i) => {
1229
+ if (segment.type === 'hr') {
1230
+ return (
1231
+ <hr key={i} style={{
1232
+ border: 'none',
1233
+ height: '1px',
1234
+ backgroundColor: 'var(--color-border, #e2e5e9)',
1235
+ margin: 'var(--space-lg, 1.5rem) var(--space-xl, 2rem)',
1236
+ }} />
1237
+ );
1238
+ }
1239
+
1240
+ if (segment.type === 'heading') {
1241
+ const headingSizes: Record<number, string> = {
1242
+ 1: 'var(--type-heading, 1.375rem)',
1243
+ 2: 'var(--type-subheading, 1.125rem)',
1244
+ 3: 'var(--type-body, 0.9375rem)',
1245
+ };
1246
+ return (
1247
+ <p key={i} style={{
1248
+ fontSize: headingSizes[segment.level || 3],
1249
+ fontWeight: 'var(--weight-bold, 700)' as unknown as number,
1250
+ color: tokens.surfaces.text_default,
1251
+ marginTop: 'var(--space-lg, 1.5rem)',
1252
+ marginBottom: 'var(--space-sm, 0.5rem)',
1253
+ lineHeight: 'var(--leading-tight, 1.2)',
1254
+ }}>
1255
+ {segment.content}
1256
+ </p>
1257
+ );
1258
+ }
1259
+
1260
+ if (segment.type === 'blockquote') {
1261
+ return (
1262
+ <blockquote key={i} style={{
1263
+ margin: 'var(--space-md, 1rem) 0',
1264
+ padding: 'var(--space-md, 1rem) var(--space-lg, 1.5rem)',
1265
+ borderLeft: `4px solid ${accentHex}`,
1266
+ backgroundColor: tokens.components.prose_blockquote_bg,
1267
+ borderRadius: '0 var(--radius-md, 8px) var(--radius-md, 8px) 0',
1268
+ fontStyle: 'italic',
1269
+ color: 'var(--color-text-muted, #6b7280)',
1270
+ lineHeight: 'var(--leading-loose, 1.8)',
1271
+ ...so?.prose_quote,
1272
+ }}>
1273
+ {renderInlineMarkdown(segment.content, accentHex)}
1274
+ </blockquote>
1275
+ );
1276
+ }
1277
+
1278
+ // Paragraph: first paragraph is lede
1279
+ const isLede = paragraphIndex === 0;
1280
+ paragraphIndex++;
1281
+
1282
+ if (isLede) {
1283
+ return (
1284
+ <p key={i} style={{
1285
+ fontSize: 'var(--type-subheading, 1.125rem)',
1286
+ fontWeight: 'var(--weight-medium, 500)' as unknown as number,
1287
+ lineHeight: 'var(--leading-snug, 1.35)',
1288
+ color: 'var(--color-text, #1a1d23)',
1289
+ marginTop: 0,
1290
+ marginBottom: 'var(--space-md, 1rem)',
1291
+ ...so?.prose_lede,
1292
+ }}>
1293
+ {renderInlineMarkdown(segment.content, accentHex)}
1294
+ </p>
1295
+ );
1296
+ }
1297
+
1298
+ return (
1299
+ <p key={i} style={{
1300
+ fontSize: 'var(--type-body, 0.9375rem)',
1301
+ fontWeight: 'var(--weight-normal, 400)' as unknown as number,
1302
+ lineHeight: 'var(--leading-relaxed, 1.65)',
1303
+ color: 'var(--color-text, #1a1d23)',
1304
+ marginTop: 0,
1305
+ marginBottom: 'var(--space-md, 1rem)',
1306
+ ...so?.prose_body,
1307
+ }}>
1308
+ {renderInlineMarkdown(segment.content, accentHex)}
1309
+ </p>
1310
+ );
1311
+ })}
1312
+ </div>
1313
+ );
1314
+ }
1315
+
1316
+ // ── StatRow ──────────────────────────────────────────────
1317
+
1318
+ function StatRow({ data, config }: SubRendererProps) {
1319
+ const { tokens } = useDesignTokens();
1320
+ if (!data || typeof data !== 'object') return null;
1321
+ const so = getSO(config);
1322
+
1323
+ const obj = data as Record<string, unknown>;
1324
+ const entries = Object.entries(obj).filter(([, v]) => v !== null && v !== undefined);
1325
+
1326
+ if (entries.length === 0) return null;
1327
+
1328
+ return (
1329
+ <div style={{
1330
+ display: 'grid',
1331
+ gridTemplateColumns: `repeat(${Math.min(entries.length, 4)}, 1fr)`,
1332
+ gap: 'var(--space-md, 0.75rem)',
1333
+ ...so?.items_container,
1334
+ }}>
1335
+ {entries.map(([key, value]) => (
1336
+ <div key={key} style={{
1337
+ padding: 'var(--space-md, 1rem)',
1338
+ borderRadius: 'var(--radius-md, 8px)',
1339
+ backgroundColor: 'var(--color-surface, #ffffff)',
1340
+ border: '1px solid var(--color-border-light, #eef0f2)',
1341
+ boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
1342
+ textAlign: 'center' as const,
1343
+ ...so?.card,
1344
+ }}>
1345
+ <div style={{
1346
+ fontSize: 'var(--type-number, 1.25rem)',
1347
+ fontFamily: 'var(--font-mono, monospace)',
1348
+ fontWeight: 'var(--weight-bold, 700)' as unknown as number,
1349
+ color: tokens.components.stat_number_color,
1350
+ lineHeight: 'var(--leading-tight, 1.2)',
1351
+ ...so?.stat_number,
1352
+ }}>
1353
+ {typeof value === 'number' ? value.toLocaleString() : String(value)}
1354
+ </div>
1355
+ <div className="gen-inline-label" style={{
1356
+ marginTop: 'var(--space-xs, 0.25rem)',
1357
+ ...so?.stat_label,
1358
+ }}>
1359
+ {key.replace(/_/g, ' ')}
1360
+ </div>
1361
+ </div>
1362
+ ))}
1363
+ </div>
1364
+ );
1365
+ }
1366
+
1367
+ // ── ComparisonPanel ──────────────────────────────────────
1368
+
1369
+ function ComparisonPanel({ data, config }: SubRendererProps) {
1370
+ const { tokens } = useDesignTokens();
1371
+ if (!data || !Array.isArray(data)) return null;
1372
+ const so = getSO(config);
1373
+
1374
+ // Capture mode support (threaded from AccordionRenderer)
1375
+ const captureMode = config._captureMode as boolean | undefined;
1376
+ const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
1377
+ const captureJobId = config._captureJobId as string | undefined;
1378
+ const captureViewKey = config._captureViewKey as string | undefined;
1379
+ const captureSourceType = config._captureSourceType as string | undefined;
1380
+ const captureEntityId = config._captureEntityId as string | undefined;
1381
+ const parentSectionKey = config._parentSectionKey as string | undefined;
1382
+ const parentSectionTitle = config._parentSectionTitle as string | undefined;
1383
+
1384
+ const firstObj = data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined;
1385
+ const longStrings = firstObj
1386
+ ? Object.entries(firstObj).filter(([, v]) => typeof v === 'string' && (v as string).length > 20).map(([k]) => k)
1387
+ : [];
1388
+ const leftField = (config.left_field as string | undefined) || longStrings[0];
1389
+ const rightField = (config.right_field as string | undefined) || longStrings[1];
1390
+
1391
+ const leftLabel = leftField ? leftField.replace(/_/g, ' ') : 'Left';
1392
+ const rightLabel = rightField ? rightField.replace(/_/g, ' ') : 'Right';
1393
+
1394
+ return (
1395
+ <div style={{
1396
+ display: 'flex', flexDirection: 'column',
1397
+ gap: 'var(--space-md, 0.75rem)',
1398
+ ...so?.items_container,
1399
+ }}>
1400
+ {/* Column headers */}
1401
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px' }}>
1402
+ <div style={{
1403
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
1404
+ backgroundColor: tokens.primitives.series_palette[0],
1405
+ color: tokens.surfaces.text_on_accent,
1406
+ fontSize: 'var(--type-caption, 0.8125rem)',
1407
+ fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
1408
+ textTransform: 'uppercase' as const,
1409
+ letterSpacing: '0.06em',
1410
+ borderRadius: 'var(--radius-md, 8px) 0 0 0',
1411
+ ...so?.card_header,
1412
+ }}>
1413
+ {leftLabel}
1414
+ </div>
1415
+ <div style={{
1416
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
1417
+ backgroundColor: tokens.primitives.series_palette[1],
1418
+ color: 'var(--dt-text-inverse)',
1419
+ fontSize: 'var(--type-caption, 0.8125rem)',
1420
+ fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
1421
+ textTransform: 'uppercase' as const,
1422
+ letterSpacing: '0.06em',
1423
+ borderRadius: '0 var(--radius-md, 8px) 0 0',
1424
+ ...so?.card_header,
1425
+ }}>
1426
+ {rightLabel}
1427
+ </div>
1428
+ </div>
1429
+
1430
+ {data.map((item, i) => {
1431
+ if (typeof item !== 'object' || item === null) return null;
1432
+ const obj = item as Record<string, unknown>;
1433
+
1434
+ const left = getField(obj, leftField);
1435
+ const right = getField(obj, rightField);
1436
+
1437
+ const otherFields = Object.entries(obj).filter(
1438
+ ([k, v]) => k !== leftField && k !== rightField && v !== null && v !== undefined && v !== ''
1439
+ );
1440
+
1441
+ // Build a label for this comparison row
1442
+ const rowLabel = otherFields.length > 0
1443
+ ? otherFields.map(([k, v]) => `${k.replace(/_/g, ' ')}: ${String(v)}`).join(', ')
1444
+ : `Row ${i + 1}`;
1445
+
1446
+ return (
1447
+ <div key={i} style={{
1448
+ borderRadius: 'var(--radius-md, 8px)',
1449
+ border: '1px solid var(--color-border, #e2e5e9)',
1450
+ overflow: 'hidden',
1451
+ boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
1452
+ }}>
1453
+ {/* Header bar with other fields + capture button */}
1454
+ {(otherFields.length > 0 || (captureMode && onCapture)) && (
1455
+ <div style={{
1456
+ padding: 'var(--space-xs, 0.375rem) var(--space-md, 0.75rem)',
1457
+ backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
1458
+ fontSize: 'var(--type-label, 0.6875rem)',
1459
+ color: 'var(--color-text-muted, #6b7280)',
1460
+ borderBottom: '1px solid var(--color-border-light, #eef0f2)',
1461
+ display: 'flex', gap: 'var(--space-sm, 0.5rem)', flexWrap: 'wrap',
1462
+ alignItems: 'center',
1463
+ }}>
1464
+ {otherFields.map(([k, v]) => (
1465
+ <span key={k}>
1466
+ <strong style={{ textTransform: 'capitalize' as const }}>{k.replace(/_/g, ' ')}</strong>: {String(v)}
1467
+ </span>
1468
+ ))}
1469
+ {captureMode && onCapture && (
1470
+ <button
1471
+ title="Capture this comparison"
1472
+ onClick={e => {
1473
+ e.stopPropagation();
1474
+ onCapture({
1475
+ source_view_key: captureViewKey || '',
1476
+ source_section_key: parentSectionKey,
1477
+ source_item_index: i,
1478
+ source_renderer_type: 'comparison_panel',
1479
+ content_type: 'item',
1480
+ selected_text: `${leftLabel}: ${left || '—'} vs ${rightLabel}: ${right || '—'}`.slice(0, 500),
1481
+ structured_data: obj,
1482
+ context_title: parentSectionKey
1483
+ ? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${rowLabel}`
1484
+ : `${captureViewKey || 'Analysis'} > ${rowLabel}`,
1485
+ source_type: (captureSourceType || 'analysis') as string,
1486
+ entity_id: captureEntityId || captureJobId || '',
1487
+ depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
1488
+ parent_context: parentSectionKey ? {
1489
+ section_key: parentSectionKey,
1490
+ section_title: parentSectionTitle || '',
1491
+ } : undefined,
1492
+ });
1493
+ }}
1494
+ style={{
1495
+ marginLeft: 'auto',
1496
+ flexShrink: 0,
1497
+ background: 'none',
1498
+ border: '1px solid var(--color-border, #ccc)',
1499
+ borderRadius: '4px',
1500
+ color: 'var(--dt-text-faint, #94a3b8)',
1501
+ cursor: 'pointer',
1502
+ padding: '2px 6px',
1503
+ fontSize: '0.7rem',
1504
+ lineHeight: 1,
1505
+ }}
1506
+ >
1507
+ &#x1F4CC;
1508
+ </button>
1509
+ )}
1510
+ </div>
1511
+ )}
1512
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr' }}>
1513
+ <div style={{
1514
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
1515
+ fontSize: 'var(--type-body, 0.9375rem)',
1516
+ color: 'var(--color-text, #1a1d23)',
1517
+ lineHeight: 'var(--leading-normal, 1.5)',
1518
+ ...so?.card_body,
1519
+ }}>
1520
+ {left || <span style={{ color: 'var(--color-text-faint, #9ca3af)', fontStyle: 'italic' }}>—</span>}
1521
+ </div>
1522
+ {/* Vertical divider */}
1523
+ <div style={{
1524
+ width: '2px',
1525
+ background: `linear-gradient(to bottom, ${tokens.surfaces.border_light}, ${tokens.surfaces.border_accent}, ${tokens.surfaces.border_light})`,
1526
+ }} />
1527
+ <div style={{
1528
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
1529
+ fontSize: 'var(--type-body, 0.9375rem)',
1530
+ color: 'var(--color-text, #1a1d23)',
1531
+ lineHeight: 'var(--leading-normal, 1.5)',
1532
+ ...so?.card_body,
1533
+ }}>
1534
+ {right || <span style={{ color: 'var(--color-text-faint, #9ca3af)', fontStyle: 'italic' }}>—</span>}
1535
+ </div>
1536
+ </div>
1537
+ </div>
1538
+ );
1539
+ })}
1540
+ </div>
1541
+ );
1542
+ }
1543
+
1544
+ // ── TimelineStrip → Evolution Arc ────────────────────────
1545
+
1546
+ function TimelineStrip({ data, config }: SubRendererProps) {
1547
+ const { tokens } = useDesignTokens();
1548
+ const so = getSO(config);
1549
+ const [expandedCard, setExpandedCard] = React.useState<string | null>(null);
1550
+ if (!data || !Array.isArray(data)) return null;
1551
+
1552
+ // TimelineStrip uses HSL-derived progressive coloring for evolution arcs.
1553
+ // Derive accent HSL from the token system's accent color.
1554
+ const accent = parseAccentHSL(so?.accent_color || tokens.components.page_accent);
1555
+
1556
+ const firstObj = data.find(d => typeof d === 'object' && d !== null) as Record<string, unknown> | undefined;
1557
+ const auto = firstObj ? autoDetectFields(firstObj) : {};
1558
+ const labelField = resolveField(config, 'label_field', auto, 'label');
1559
+ const stagesField = (config.stages_field as string | undefined)
1560
+ || (firstObj ? Object.entries(firstObj).find(([, v]) => Array.isArray(v))?.[0] : undefined);
1561
+
1562
+ function renderStageNode(stage: unknown, j: number, totalStages: number) {
1563
+ // Visual progression: size and saturation increase from left to right
1564
+ const progress = totalStages > 1 ? j / (totalStages - 1) : 0.5;
1565
+ const saturation = Math.max(accent.s * 0.3, 12) + progress * 30;
1566
+ const bgLightness = 96 - progress * 8;
1567
+ const textLightness = 30 - progress * 10;
1568
+ const borderSat = Math.max(accent.s * 0.4, 15) + progress * 20;
1569
+
1570
+ // Size increases with progress
1571
+ const padV = `${0.4 + progress * 0.3}rem`;
1572
+ const padH = `${0.6 + progress * 0.4}rem`;
1573
+ const fontSize = progress > 0.6
1574
+ ? 'var(--type-caption, 0.8125rem)'
1575
+ : 'var(--type-label, 0.6875rem)';
1576
+
1577
+ if (typeof stage === 'string') {
1578
+ return (
1579
+ <div style={{
1580
+ padding: `${padV} ${padH}`,
1581
+ borderRadius: 'var(--radius-md, 8px)',
1582
+ backgroundColor: `hsl(${accent.h}, ${saturation}%, ${bgLightness}%)`,
1583
+ border: `1.5px solid hsl(${accent.h}, ${borderSat}%, ${78 - progress * 10}%)`,
1584
+ fontSize,
1585
+ fontWeight: 'var(--weight-medium, 500)' as unknown as number,
1586
+ lineHeight: 'var(--leading-snug, 1.35)',
1587
+ color: `hsl(${accent.h}, ${Math.min(accent.s + 10, 75)}%, ${textLightness}%)`,
1588
+ minWidth: `${130 + progress * 40}px`,
1589
+ maxWidth: '280px',
1590
+ flexShrink: 0,
1591
+ boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
1592
+ ...so?.timeline_node,
1593
+ }}>
1594
+ {stage}
1595
+ </div>
1596
+ );
1597
+ }
1598
+
1599
+ if (typeof stage === 'object' && stage !== null) {
1600
+ const obj = stage as Record<string, unknown>;
1601
+ const stageAuto = autoDetectFields(obj);
1602
+ const primaryLabel = getField(obj, 'form') || getField(obj, stageAuto.title)
1603
+ || getField(obj, 'label') || getField(obj, 'name');
1604
+ const periodLabel = getField(obj, 'period') || getField(obj, 'era') || getField(obj, 'date') || getField(obj, 'year');
1605
+ const secondaryFields = Object.entries(obj).filter(
1606
+ ([k, v]) => !['form', 'name', 'title', 'label', 'period', 'era', 'date', 'year'].includes(k)
1607
+ && typeof v === 'string' && (v as string).length > 0
1608
+ );
1609
+
1610
+ return (
1611
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
1612
+ {/* Temporal marker above */}
1613
+ {periodLabel && (
1614
+ <div style={{
1615
+ fontSize: 'var(--type-label, 0.6875rem)',
1616
+ fontFamily: 'var(--font-mono, monospace)',
1617
+ fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
1618
+ color: `hsl(${accent.h}, ${Math.min(accent.s + 10, 70)}%, 45%)`,
1619
+ letterSpacing: '0.04em',
1620
+ marginBottom: 'var(--space-xs, 0.25rem)',
1621
+ textAlign: 'center' as const,
1622
+ }}>
1623
+ {periodLabel}
1624
+ </div>
1625
+ )}
1626
+ {/* Stage card */}
1627
+ <div style={{
1628
+ padding: `${padV} ${padH}`,
1629
+ borderRadius: 'var(--radius-md, 8px)',
1630
+ backgroundColor: `hsl(${accent.h}, ${saturation}%, ${bgLightness}%)`,
1631
+ border: `1.5px solid hsl(${accent.h}, ${borderSat}%, ${78 - progress * 10}%)`,
1632
+ fontSize,
1633
+ lineHeight: 'var(--leading-snug, 1.35)',
1634
+ color: `hsl(${accent.h}, ${Math.min(accent.s + 10, 75)}%, ${textLightness}%)`,
1635
+ minWidth: `${130 + progress * 40}px`,
1636
+ maxWidth: '280px',
1637
+ boxShadow: 'var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.04))',
1638
+ ...so?.timeline_node,
1639
+ }}>
1640
+ <div style={{
1641
+ fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
1642
+ marginBottom: secondaryFields.length > 0 ? 'var(--space-2xs, 0.25rem)' : 0,
1643
+ }}>
1644
+ {primaryLabel || 'Stage ' + (j + 1)}
1645
+ </div>
1646
+ {secondaryFields.slice(0, 2).map(([k, v]) => (
1647
+ <div key={k} style={{
1648
+ fontSize: 'var(--type-label, 0.6875rem)',
1649
+ color: `hsl(${accent.h}, ${Math.max(accent.s * 0.5, 15)}%, ${40 - progress * 5}%)`,
1650
+ marginTop: 'var(--space-2xs, 0.15rem)',
1651
+ }}>
1652
+ <span className="gen-inline-label">
1653
+ {k.replace(/_/g, ' ')}:
1654
+ </span>{' '}
1655
+ {String(v).length > 80 ? String(v).slice(0, 77) + '...' : String(v)}
1656
+ </div>
1657
+ ))}
1658
+ </div>
1659
+ </div>
1660
+ );
1661
+ }
1662
+
1663
+ return (
1664
+ <div style={{
1665
+ padding: `${padV} ${padH}`,
1666
+ borderRadius: 'var(--radius-md, 8px)',
1667
+ backgroundColor: `hsl(${accent.h}, ${saturation}%, ${bgLightness}%)`,
1668
+ border: `1px solid hsl(${accent.h}, ${borderSat}%, ${80 - progress * 8}%)`,
1669
+ fontSize: 'var(--type-label, 0.6875rem)',
1670
+ color: `hsl(${accent.h}, ${Math.min(accent.s + 10, 70)}%, ${textLightness}%)`,
1671
+ flexShrink: 0,
1672
+ ...so?.timeline_node,
1673
+ }}>
1674
+ {String(stage)}
1675
+ </div>
1676
+ );
1677
+ }
1678
+
1679
+ /** Render the connecting arrow between stages with gradient */
1680
+ function renderConnector(j: number, totalStages: number) {
1681
+ const progress = totalStages > 1 ? j / (totalStages - 1) : 0;
1682
+ const nextProgress = totalStages > 1 ? (j + 1) / (totalStages - 1) : 1;
1683
+ const startLight = 82 - progress * 25;
1684
+ const endLight = 82 - nextProgress * 25;
1685
+ const startSat = Math.max(accent.s * 0.3, 12) + progress * 25;
1686
+ const endSat = Math.max(accent.s * 0.3, 12) + nextProgress * 25;
1687
+
1688
+ return (
1689
+ <div style={{
1690
+ display: 'flex', alignItems: 'center',
1691
+ flexShrink: 0, padding: '0 2px',
1692
+ alignSelf: 'center',
1693
+ ...so?.timeline_connector,
1694
+ }}>
1695
+ <div style={{
1696
+ width: '24px', height: '3px',
1697
+ background: `linear-gradient(to right, hsl(${accent.h}, ${startSat}%, ${startLight}%), hsl(${accent.h}, ${endSat}%, ${endLight}%))`,
1698
+ borderRadius: '2px',
1699
+ position: 'relative' as const,
1700
+ }}>
1701
+ <div style={{
1702
+ position: 'absolute' as const, right: '-5px', top: '-4px',
1703
+ width: 0, height: 0,
1704
+ borderTop: '5.5px solid transparent',
1705
+ borderBottom: '5.5px solid transparent',
1706
+ borderLeft: `8px solid hsl(${accent.h}, ${endSat}%, ${endLight}%)`,
1707
+ }} />
1708
+ </div>
1709
+ </div>
1710
+ );
1711
+ }
1712
+
1713
+ return (
1714
+ <div style={{
1715
+ display: 'flex', flexDirection: 'column',
1716
+ gap: 'var(--space-lg, 1.25rem)',
1717
+ ...so?.items_container,
1718
+ }}>
1719
+ {data.map((item, i) => {
1720
+ if (typeof item !== 'object' || item === null) return null;
1721
+ const obj = item as Record<string, unknown>;
1722
+ const label = getField(obj, labelField);
1723
+ const stages = stagesField ? obj[stagesField] : null;
1724
+ const cardId = `${label || i}`;
1725
+ const isExpanded = expandedCard === cardId;
1726
+
1727
+ const metaFields = Object.entries(obj).filter(
1728
+ ([k, v]) => k !== labelField && k !== stagesField && v !== null && v !== undefined && v !== ''
1729
+ );
1730
+
1731
+ return (
1732
+ <div key={i} style={{
1733
+ minWidth: 0, overflow: 'hidden',
1734
+ borderRadius: 'var(--radius-lg, 12px)',
1735
+ border: '1px solid var(--color-border, #e2e5e9)',
1736
+ backgroundColor: 'var(--color-surface, #ffffff)',
1737
+ boxShadow: 'var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.06))',
1738
+ ...so?.card,
1739
+ }}>
1740
+ {/* Header */}
1741
+ {label && (
1742
+ <div
1743
+ onClick={() => setExpandedCard(isExpanded ? null : cardId)}
1744
+ style={{
1745
+ fontSize: 'var(--type-body, 0.9375rem)',
1746
+ fontWeight: 'var(--weight-semibold, 600)' as unknown as number,
1747
+ color: 'var(--color-text, #1a1d23)',
1748
+ padding: 'var(--space-sm, 0.625rem) var(--space-md, 1rem)',
1749
+ backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
1750
+ borderBottom: '1px solid var(--color-border-light, #eef0f2)',
1751
+ cursor: metaFields.length > 0 ? 'pointer' : 'default',
1752
+ display: 'flex', alignItems: 'center',
1753
+ gap: 'var(--space-sm, 0.5rem)',
1754
+ ...so?.card_header,
1755
+ }}>
1756
+ {label}
1757
+ {Array.isArray(stages) && (
1758
+ <span style={{
1759
+ fontSize: 'var(--type-label, 0.6875rem)',
1760
+ fontWeight: 'var(--weight-medium, 500)' as unknown as number,
1761
+ color: 'var(--color-text-faint, #9ca3af)',
1762
+ backgroundColor: tokens.surfaces.surface_inset,
1763
+ padding: '2px 8px',
1764
+ borderRadius: 'var(--radius-pill, 9999px)',
1765
+ ...so?.badge,
1766
+ }}>
1767
+ {stages.length} stages
1768
+ </span>
1769
+ )}
1770
+ </div>
1771
+ )}
1772
+
1773
+ {/* Timeline strip */}
1774
+ {Array.isArray(stages) && stages.length > 0 && (
1775
+ <div style={{
1776
+ padding: 'var(--space-md, 0.75rem)',
1777
+ overflowX: 'auto' as const,
1778
+ }}>
1779
+ <div style={{
1780
+ display: 'flex', alignItems: 'flex-end',
1781
+ gap: 0,
1782
+ minWidth: 'min-content',
1783
+ }}>
1784
+ {stages.map((stage, j) => (
1785
+ <React.Fragment key={j}>
1786
+ {renderStageNode(stage, j, stages.length)}
1787
+ {j < stages.length - 1 && renderConnector(j, stages.length)}
1788
+ </React.Fragment>
1789
+ ))}
1790
+ </div>
1791
+ </div>
1792
+ )}
1793
+
1794
+ {/* Expanded metadata */}
1795
+ {isExpanded && metaFields.length > 0 && (
1796
+ <div style={{
1797
+ padding: 'var(--space-sm, 0.625rem) var(--space-md, 1rem)',
1798
+ borderTop: '1px solid var(--color-border-light, #eef0f2)',
1799
+ backgroundColor: 'var(--color-surface, #ffffff)',
1800
+ }}>
1801
+ {metaFields.map(([k, v]) => (
1802
+ <div key={k} style={{ marginBottom: 'var(--space-2xs, 0.25rem)' }}>
1803
+ <span className="gen-inline-label">
1804
+ {k.replace(/_/g, ' ')}:
1805
+ </span>
1806
+ <span style={{
1807
+ marginLeft: 'var(--space-xs, 0.375rem)',
1808
+ fontSize: 'var(--type-caption, 0.8125rem)',
1809
+ color: 'var(--color-text, #1a1d23)',
1810
+ }}>
1811
+ {typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v)}
1812
+ </span>
1813
+ </div>
1814
+ ))}
1815
+ </div>
1816
+ )}
1817
+
1818
+ {/* Non-stage fallback */}
1819
+ {(!stages || !Array.isArray(stages) || stages.length === 0) && metaFields.length > 0 && (
1820
+ <div style={{
1821
+ padding: 'var(--space-sm, 0.625rem) var(--space-md, 1rem)',
1822
+ fontSize: 'var(--type-caption, 0.8125rem)',
1823
+ color: 'var(--color-text-muted, #6b7280)',
1824
+ }}>
1825
+ {metaFields.map(([k, v]) => (
1826
+ <div key={k} style={{ marginBottom: 'var(--space-2xs, 0.25rem)' }}>
1827
+ <strong style={{
1828
+ textTransform: 'capitalize' as const,
1829
+ fontSize: 'var(--type-label, 0.6875rem)',
1830
+ color: 'var(--color-text-muted, #6b7280)',
1831
+ }}>
1832
+ {k.replace(/_/g, ' ')}:
1833
+ </strong>{' '}
1834
+ {typeof v === 'string' ? v : JSON.stringify(v)}
1835
+ </div>
1836
+ ))}
1837
+ </div>
1838
+ )}
1839
+ </div>
1840
+ );
1841
+ })}
1842
+ </div>
1843
+ );
1844
+ }
1845
+
1846
+ // ── IntensityMatrix → Dashboard-style Intensity Bars ────────
1847
+ //
1848
+ // Each row: title + category badge + horizontal bar + expandable description.
1849
+ // Bars width proportional to ordinal intensity level. Sorted by intensity (highest first).
1850
+ //
1851
+ // Config:
1852
+ // title_field — row title
1853
+ // subtitle_field — category badge
1854
+ // intensity_field — ordinal intensity value
1855
+ // intensity_scale — ordered levels, e.g. ['low', 'medium', 'high']
1856
+ // description_field — expandable text
1857
+ // sort_by_intensity — sort by intensity descending (default true)
1858
+
1859
+ function IntensityMatrix({ data, config }: SubRendererProps) {
1860
+ const [expandedIdx, setExpandedIdx] = React.useState<Set<number>>(new Set());
1861
+ const { tokens, getSemanticColor } = useDesignTokens();
1862
+
1863
+ const captureMode = config._captureMode as boolean | undefined;
1864
+ const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
1865
+ const captureJobId = config._captureJobId as string | undefined;
1866
+ const captureViewKey = config._captureViewKey as string | undefined;
1867
+ const captureSourceType = config._captureSourceType as string | undefined;
1868
+ const captureEntityId = config._captureEntityId as string | undefined;
1869
+ const parentSectionKey = config._parentSectionKey as string | undefined;
1870
+ const parentSectionTitle = config._parentSectionTitle as string | undefined;
1871
+
1872
+ if (!data || !Array.isArray(data) || data.length === 0) return null;
1873
+
1874
+ const titleField = (config.title_field as string) || 'name';
1875
+ const subtitleField = (config.subtitle_field as string) || undefined;
1876
+ const intensityField = (config.intensity_field as string) || 'intensity';
1877
+ const descField = (config.description_field as string) || 'description';
1878
+ const sortByIntensity = config.sort_by_intensity !== false;
1879
+
1880
+ // Build intensity scale from config or infer from data
1881
+ const configScale = config.intensity_scale as string[] | undefined;
1882
+ const intensityScale: string[] = configScale
1883
+ || (() => {
1884
+ const vals = new Set<string>();
1885
+ data.forEach(item => {
1886
+ if (typeof item === 'object' && item !== null) {
1887
+ const v = (item as Record<string, unknown>)[intensityField];
1888
+ if (typeof v === 'string') vals.add(v.toLowerCase());
1889
+ }
1890
+ });
1891
+ // Default ordering heuristic
1892
+ const ordered = ['rare', 'low', 'minimal', 'occasional', 'moderate', 'medium', 'frequent', 'significant', 'high', 'very_high', 'critical'];
1893
+ const found = ordered.filter(l => vals.has(l));
1894
+ return found.length > 0 ? found : Array.from(vals);
1895
+ })();
1896
+
1897
+ // Map intensity value to 0–1 fraction
1898
+ const getIntensityFraction = (val: string): number => {
1899
+ const lower = val.toLowerCase().replace(/\s+/g, '_');
1900
+ const idx = intensityScale.findIndex(s => s.toLowerCase().replace(/\s+/g, '_') === lower);
1901
+ if (idx === -1) return 0.5;
1902
+ if (intensityScale.length <= 1) return 1;
1903
+ return (idx + 1) / intensityScale.length;
1904
+ };
1905
+
1906
+ // Color for intensity bar — use semantic severity or series palette
1907
+ const getBarColor = (fraction: number): string => {
1908
+ const palette = tokens.primitives.series_palette;
1909
+ if (fraction >= 0.8) {
1910
+ const sem = getSemanticColor('severity', 'high');
1911
+ return sem?.bg || palette[0];
1912
+ }
1913
+ if (fraction >= 0.5) {
1914
+ const sem = getSemanticColor('severity', 'medium');
1915
+ return sem?.bg || palette[4];
1916
+ }
1917
+ const sem = getSemanticColor('severity', 'low');
1918
+ return sem?.bg || palette[2];
1919
+ };
1920
+
1921
+ const getBarTextColor = (fraction: number): string => {
1922
+ if (fraction >= 0.8) {
1923
+ const sem = getSemanticColor('severity', 'high');
1924
+ return sem?.text || tokens.surfaces.text_default;
1925
+ }
1926
+ if (fraction >= 0.5) {
1927
+ const sem = getSemanticColor('severity', 'medium');
1928
+ return sem?.text || tokens.surfaces.text_default;
1929
+ }
1930
+ const sem = getSemanticColor('severity', 'low');
1931
+ return sem?.text || tokens.surfaces.text_default;
1932
+ };
1933
+
1934
+ // Prepare and optionally sort items
1935
+ const items = data
1936
+ .map((item, origIdx) => {
1937
+ if (typeof item !== 'object' || item === null) return null;
1938
+ const obj = item as Record<string, unknown>;
1939
+ const intensityVal = getField(obj, intensityField);
1940
+ const fraction = getIntensityFraction(intensityVal);
1941
+ return { obj, origIdx, intensityVal, fraction };
1942
+ })
1943
+ .filter(Boolean) as Array<{
1944
+ obj: Record<string, unknown>;
1945
+ origIdx: number;
1946
+ intensityVal: string;
1947
+ fraction: number;
1948
+ }>;
1949
+
1950
+ if (sortByIntensity) {
1951
+ items.sort((a, b) => b.fraction - a.fraction);
1952
+ }
1953
+
1954
+ const toggleExpand = (idx: number) => {
1955
+ setExpandedIdx(prev => {
1956
+ const next = new Set(prev);
1957
+ if (next.has(idx)) next.delete(idx);
1958
+ else next.add(idx);
1959
+ return next;
1960
+ });
1961
+ };
1962
+
1963
+ const DESC_LIMIT = Infinity; // Show full text — no truncation
1964
+
1965
+ return (
1966
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
1967
+ {items.map((item, displayIdx) => {
1968
+ const { obj, origIdx, intensityVal, fraction } = item;
1969
+ const title = getField(obj, titleField);
1970
+ const subtitle = subtitleField ? getField(obj, subtitleField) : '';
1971
+ const desc = getField(obj, descField);
1972
+ const isExpanded = expandedIdx.has(displayIdx);
1973
+ const needsTruncation = desc.length > DESC_LIMIT;
1974
+ const barColor = getBarColor(fraction);
1975
+ const barTextColor = getBarTextColor(fraction);
1976
+ const barWidth = Math.max(fraction * 100, 8); // minimum 8% for visibility
1977
+
1978
+ return (
1979
+ <div
1980
+ key={origIdx}
1981
+ style={{
1982
+ backgroundColor: 'var(--color-surface, #ffffff)',
1983
+ borderLeft: `3px solid ${barColor}`,
1984
+ padding: '0',
1985
+ borderRadius: '0 var(--radius-sm, 4px) var(--radius-sm, 4px) 0',
1986
+ }}
1987
+ >
1988
+ {/* Main row */}
1989
+ <div
1990
+ style={{
1991
+ display: 'grid',
1992
+ gridTemplateColumns: '1fr auto minmax(80px, 160px)',
1993
+ gap: 'var(--space-sm, 0.5rem)',
1994
+ alignItems: 'center',
1995
+ padding: 'var(--space-xs, 0.375rem) var(--space-md, 1rem)',
1996
+ cursor: desc ? 'pointer' : 'default',
1997
+ }}
1998
+ onClick={() => desc && toggleExpand(displayIdx)}
1999
+ >
2000
+ {/* Title + subtitle */}
2001
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--space-sm, 0.5rem)', flexWrap: 'wrap', minWidth: 0 }}>
2002
+ <span style={{
2003
+ fontWeight: 600,
2004
+ fontSize: 'var(--type-body, 0.9375rem)',
2005
+ color: 'var(--color-text, #1a1d23)',
2006
+ lineHeight: 'var(--leading-snug, 1.4)',
2007
+ }}>
2008
+ {title}
2009
+ </span>
2010
+ {subtitle && (
2011
+ <span style={{
2012
+ fontSize: 'var(--type-label, 0.6875rem)',
2013
+ fontWeight: 500,
2014
+ color: 'var(--color-text-muted, #6b7280)',
2015
+ padding: '1px 8px',
2016
+ borderRadius: 'var(--radius-pill, 9999px)',
2017
+ backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
2018
+ whiteSpace: 'nowrap',
2019
+ }}>
2020
+ {subtitle}
2021
+ </span>
2022
+ )}
2023
+ {captureMode && onCapture && (
2024
+ <button
2025
+ title="Capture this item"
2026
+ onClick={e => {
2027
+ e.stopPropagation();
2028
+ onCapture({
2029
+ source_view_key: captureViewKey || '',
2030
+ source_item_index: origIdx,
2031
+ source_renderer_type: 'intensity_matrix',
2032
+ content_type: 'item',
2033
+ selected_text: `${title} [${intensityVal}]: ${desc}`.slice(0, 500),
2034
+ structured_data: obj,
2035
+ context_title: parentSectionKey
2036
+ ? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${title}`
2037
+ : `${captureViewKey || 'Analysis'} > ${title}`,
2038
+ source_type: (captureSourceType || 'analysis') as string,
2039
+ entity_id: captureEntityId || captureJobId || '',
2040
+ depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
2041
+ parent_context: parentSectionKey ? {
2042
+ section_key: parentSectionKey,
2043
+ section_title: parentSectionTitle || '',
2044
+ } : undefined,
2045
+ });
2046
+ }}
2047
+ style={{
2048
+ background: 'none',
2049
+ border: '1px solid var(--color-border, #ccc)',
2050
+ borderRadius: '4px',
2051
+ color: 'var(--dt-text-faint, #94a3b8)',
2052
+ cursor: 'pointer',
2053
+ padding: '2px 6px',
2054
+ fontSize: '0.7rem',
2055
+ lineHeight: 1,
2056
+ }}
2057
+ >
2058
+ &#x1F4CC;
2059
+ </button>
2060
+ )}
2061
+ </div>
2062
+
2063
+ {/* Intensity label */}
2064
+ <span style={{
2065
+ fontSize: 'var(--type-label, 0.6875rem)',
2066
+ fontWeight: 700,
2067
+ color: barTextColor,
2068
+ textTransform: 'uppercase',
2069
+ letterSpacing: '0.04em',
2070
+ whiteSpace: 'nowrap',
2071
+ }}>
2072
+ {intensityVal.replace(/_/g, ' ')}
2073
+ </span>
2074
+
2075
+ {/* Intensity bar */}
2076
+ <div style={{
2077
+ height: '8px',
2078
+ backgroundColor: 'var(--color-surface-alt, #f0f1f3)',
2079
+ borderRadius: '4px',
2080
+ overflow: 'hidden',
2081
+ }}>
2082
+ <div style={{
2083
+ width: `${barWidth}%`,
2084
+ height: '100%',
2085
+ backgroundColor: barColor,
2086
+ borderRadius: '4px',
2087
+ transition: 'width 300ms ease',
2088
+ }} />
2089
+ </div>
2090
+ </div>
2091
+
2092
+ {/* Expandable description */}
2093
+ {desc && isExpanded && (
2094
+ <div style={{
2095
+ padding: '0 var(--space-md, 1rem) var(--space-sm, 0.5rem)',
2096
+ fontSize: 'var(--type-caption, 0.8125rem)',
2097
+ color: 'var(--color-text-muted, #6b7280)',
2098
+ lineHeight: 'var(--leading-relaxed, 1.65)',
2099
+ borderTop: '1px solid var(--color-border-light, #eef0f2)',
2100
+ marginTop: '2px',
2101
+ paddingTop: 'var(--space-xs, 0.375rem)',
2102
+ }}>
2103
+ {desc}
2104
+ </div>
2105
+ )}
2106
+ {desc && !isExpanded && needsTruncation && (
2107
+ <div style={{
2108
+ padding: '0 var(--space-md, 1rem) var(--space-xs, 0.25rem)',
2109
+ fontSize: 'var(--type-label, 0.6875rem)',
2110
+ color: 'var(--color-text-faint, #9ca3af)',
2111
+ }}>
2112
+ {desc.slice(0, DESC_LIMIT)}...
2113
+ </div>
2114
+ )}
2115
+ </div>
2116
+ );
2117
+ })}
2118
+ </div>
2119
+ );
2120
+ }
2121
+
2122
+ // ── MoveRepertoire → Grouped Intellectual Gestures ──────────
2123
+ //
2124
+ // Items grouped by a category field. Each group has a colored header with
2125
+ // count badge. Items within groups are compact rows. Groups are collapsible.
2126
+ //
2127
+ // Config:
2128
+ // title_field — item title
2129
+ // group_field — field to group by
2130
+ // description_field — item description
2131
+ // badge_field — optional extra badge per item
2132
+ // collapse_groups — start collapsed (default false)
2133
+
2134
+ function MoveRepertoire({ data, config }: SubRendererProps) {
2135
+ const { tokens } = useDesignTokens();
2136
+ const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set());
2137
+ const [expandedDescs, setExpandedDescs] = React.useState<Set<string>>(new Set());
2138
+ const [initialized, setInitialized] = React.useState(false);
2139
+
2140
+ const captureMode = config._captureMode as boolean | undefined;
2141
+ const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
2142
+ const captureJobId = config._captureJobId as string | undefined;
2143
+ const captureViewKey = config._captureViewKey as string | undefined;
2144
+ const captureSourceType = config._captureSourceType as string | undefined;
2145
+ const captureEntityId = config._captureEntityId as string | undefined;
2146
+ const parentSectionKey = config._parentSectionKey as string | undefined;
2147
+ const parentSectionTitle = config._parentSectionTitle as string | undefined;
2148
+
2149
+ if (!data || !Array.isArray(data) || data.length === 0) return null;
2150
+
2151
+ const titleField = (config.title_field as string) || 'name';
2152
+ const groupField = (config.group_field as string) || 'type';
2153
+ const descField = (config.description_field as string) || 'description';
2154
+ const badgeField = (config.badge_field as string) || undefined;
2155
+ const startCollapsed = config.collapse_groups === true;
2156
+
2157
+ // Group items
2158
+ const groups = new Map<string, Array<{ obj: Record<string, unknown>; origIdx: number }>>();
2159
+ data.forEach((item, idx) => {
2160
+ if (typeof item !== 'object' || item === null) return;
2161
+ const obj = item as Record<string, unknown>;
2162
+ const groupVal = getField(obj, groupField) || 'Other';
2163
+ if (!groups.has(groupVal)) groups.set(groupVal, []);
2164
+ groups.get(groupVal)!.push({ obj, origIdx: idx });
2165
+ });
2166
+
2167
+ // Initialize collapsed state on first render with data
2168
+ if (!initialized && startCollapsed && groups.size > 0) {
2169
+ setCollapsedGroups(new Set(groups.keys()));
2170
+ setInitialized(true);
2171
+ }
2172
+
2173
+ const toggleGroup = (group: string) => {
2174
+ setCollapsedGroups(prev => {
2175
+ const next = new Set(prev);
2176
+ if (next.has(group)) next.delete(group);
2177
+ else next.add(group);
2178
+ return next;
2179
+ });
2180
+ };
2181
+
2182
+ const toggleDesc = (key: string) => {
2183
+ setExpandedDescs(prev => {
2184
+ const next = new Set(prev);
2185
+ if (next.has(key)) next.delete(key);
2186
+ else next.add(key);
2187
+ return next;
2188
+ });
2189
+ };
2190
+
2191
+ const palette = tokens.primitives.series_palette;
2192
+ const DESC_LIMIT = Infinity; // Show full text — no truncation
2193
+
2194
+ const groupEntries = Array.from(groups.entries());
2195
+
2196
+ return (
2197
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md, 1rem)' }}>
2198
+ {groupEntries.map(([groupName, items], groupIdx) => {
2199
+ const isCollapsed = collapsedGroups.has(groupName);
2200
+ const groupColor = palette[groupIdx % palette.length];
2201
+
2202
+ return (
2203
+ <div key={groupName} style={{
2204
+ borderRadius: 'var(--radius-md, 8px)',
2205
+ border: '1px solid var(--color-border, #e2e5e9)',
2206
+ overflow: 'hidden',
2207
+ }}>
2208
+ {/* Group header */}
2209
+ <div
2210
+ onClick={() => toggleGroup(groupName)}
2211
+ style={{
2212
+ display: 'flex',
2213
+ alignItems: 'center',
2214
+ gap: 'var(--space-sm, 0.5rem)',
2215
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
2216
+ backgroundColor: groupColor,
2217
+ color: tokens.surfaces.text_on_accent,
2218
+ cursor: 'pointer',
2219
+ userSelect: 'none',
2220
+ }}
2221
+ >
2222
+ <span style={{
2223
+ fontSize: '11px',
2224
+ transition: 'transform 150ms ease',
2225
+ transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
2226
+ display: 'inline-block',
2227
+ }}>
2228
+ &#9662;
2229
+ </span>
2230
+ <span style={{
2231
+ fontWeight: 600,
2232
+ fontSize: 'var(--type-body, 0.9375rem)',
2233
+ textTransform: 'capitalize',
2234
+ }}>
2235
+ {groupName.replace(/_/g, ' ')}
2236
+ </span>
2237
+ <span style={{
2238
+ marginLeft: 'auto',
2239
+ fontSize: 'var(--type-label, 0.6875rem)',
2240
+ fontWeight: 700,
2241
+ backgroundColor: 'rgba(255,255,255,0.25)',
2242
+ padding: '1px 8px',
2243
+ borderRadius: 'var(--radius-pill, 9999px)',
2244
+ }}>
2245
+ {items.length}
2246
+ </span>
2247
+ </div>
2248
+
2249
+ {/* Group items */}
2250
+ {!isCollapsed && (
2251
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
2252
+ {items.map(({ obj, origIdx }, itemIdx) => {
2253
+ const title = getField(obj, titleField);
2254
+ const desc = getField(obj, descField);
2255
+ const badge = badgeField ? getField(obj, badgeField) : '';
2256
+ const descKey = `${groupName}-${itemIdx}`;
2257
+ const isDescExpanded = expandedDescs.has(descKey);
2258
+ const needsTruncation = desc.length > DESC_LIMIT;
2259
+
2260
+ return (
2261
+ <div
2262
+ key={itemIdx}
2263
+ style={{
2264
+ padding: 'var(--space-xs, 0.375rem) var(--space-md, 1rem)',
2265
+ borderBottom: itemIdx < items.length - 1 ? '1px solid var(--color-border-light, #eef0f2)' : 'none',
2266
+ backgroundColor: itemIdx % 2 === 0 ? 'var(--color-surface, #ffffff)' : 'var(--color-surface-alt, #fafbfc)',
2267
+ }}
2268
+ >
2269
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--space-sm, 0.5rem)', flexWrap: 'wrap' }}>
2270
+ <span style={{
2271
+ fontWeight: 600,
2272
+ fontSize: 'var(--type-body, 0.9375rem)',
2273
+ color: 'var(--color-text, #1a1d23)',
2274
+ lineHeight: 'var(--leading-snug, 1.4)',
2275
+ }}>
2276
+ {title}
2277
+ </span>
2278
+ {badge && (
2279
+ <span style={{
2280
+ fontSize: 'var(--type-label, 0.6875rem)',
2281
+ fontWeight: 500,
2282
+ color: groupColor,
2283
+ padding: '1px 8px',
2284
+ borderRadius: 'var(--radius-pill, 9999px)',
2285
+ backgroundColor: 'var(--color-surface-alt, #f8f9fa)',
2286
+ }}>
2287
+ {badge}
2288
+ </span>
2289
+ )}
2290
+ {captureMode && onCapture && (
2291
+ <button
2292
+ title="Capture this item"
2293
+ onClick={e => {
2294
+ e.stopPropagation();
2295
+ onCapture({
2296
+ source_view_key: captureViewKey || '',
2297
+ source_item_index: origIdx,
2298
+ source_renderer_type: 'move_repertoire',
2299
+ content_type: 'item',
2300
+ selected_text: `[${groupName}] ${title}: ${desc}`.slice(0, 500),
2301
+ structured_data: obj,
2302
+ context_title: parentSectionKey
2303
+ ? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${groupName} > ${title}`
2304
+ : `${captureViewKey || 'Analysis'} > ${groupName} > ${title}`,
2305
+ source_type: (captureSourceType || 'analysis') as string,
2306
+ entity_id: captureEntityId || captureJobId || '',
2307
+ depth_level: 'L2_element',
2308
+ parent_context: parentSectionKey ? {
2309
+ section_key: parentSectionKey,
2310
+ section_title: parentSectionTitle || '',
2311
+ } : undefined,
2312
+ });
2313
+ }}
2314
+ style={{
2315
+ background: 'none',
2316
+ border: '1px solid var(--color-border, #ccc)',
2317
+ borderRadius: '4px',
2318
+ color: 'var(--dt-text-faint, #94a3b8)',
2319
+ cursor: 'pointer',
2320
+ padding: '2px 6px',
2321
+ fontSize: '0.7rem',
2322
+ lineHeight: 1,
2323
+ marginLeft: 'auto',
2324
+ }}
2325
+ >
2326
+ &#x1F4CC;
2327
+ </button>
2328
+ )}
2329
+ </div>
2330
+ {desc && (
2331
+ <div
2332
+ style={{
2333
+ marginTop: '2px',
2334
+ fontSize: 'var(--type-caption, 0.8125rem)',
2335
+ color: 'var(--color-text-muted, #6b7280)',
2336
+ lineHeight: 'var(--leading-relaxed, 1.6)',
2337
+ cursor: needsTruncation ? 'pointer' : 'default',
2338
+ }}
2339
+ onClick={() => needsTruncation && toggleDesc(descKey)}
2340
+ >
2341
+ {needsTruncation && !isDescExpanded
2342
+ ? desc.slice(0, DESC_LIMIT) + '...'
2343
+ : desc}
2344
+ </div>
2345
+ )}
2346
+ </div>
2347
+ );
2348
+ })}
2349
+ </div>
2350
+ )}
2351
+ </div>
2352
+ );
2353
+ })}
2354
+ </div>
2355
+ );
2356
+ }
2357
+
2358
+ // ── DialecticalPair → Tension/Contrast Visualization ────────
2359
+ //
2360
+ // Two panels with a central tension indicator. Left = thesis/foregrounded,
2361
+ // right = antithesis/suppressed. Central node shows relationship type.
2362
+ //
2363
+ // Config:
2364
+ // left_key, right_key — field names or sub-section keys for left/right
2365
+ // left_label, right_label — panel headers
2366
+ // relationship_label — central node text (default "vs")
2367
+ // left_title_field, right_title_field — title fields within items
2368
+ // left_description_field, right_description_field — description fields
2369
+
2370
+ function DialecticalPair({ data, config }: SubRendererProps) {
2371
+ const { tokens } = useDesignTokens();
2372
+ const [expandedLeft, setExpandedLeft] = React.useState<Set<number>>(new Set());
2373
+ const [expandedRight, setExpandedRight] = React.useState<Set<number>>(new Set());
2374
+
2375
+ const captureMode = config._captureMode as boolean | undefined;
2376
+ const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
2377
+ const captureJobId = config._captureJobId as string | undefined;
2378
+ const captureViewKey = config._captureViewKey as string | undefined;
2379
+ const captureSourceType = config._captureSourceType as string | undefined;
2380
+ const captureEntityId = config._captureEntityId as string | undefined;
2381
+ const parentSectionKey = config._parentSectionKey as string | undefined;
2382
+ const parentSectionTitle = config._parentSectionTitle as string | undefined;
2383
+
2384
+ const leftKey = (config.left_key as string) || 'left';
2385
+ const rightKey = (config.right_key as string) || 'right';
2386
+ const leftLabel = (config.left_label as string) || leftKey.replace(/_/g, ' ');
2387
+ const rightLabel = (config.right_label as string) || rightKey.replace(/_/g, ' ');
2388
+ const relationshipLabel = (config.relationship_label as string) || 'vs';
2389
+
2390
+ const leftTitleField = (config.left_title_field as string) || undefined;
2391
+ const rightTitleField = (config.right_title_field as string) || undefined;
2392
+ const leftDescField = (config.left_description_field as string) || undefined;
2393
+ const rightDescField = (config.right_description_field as string) || undefined;
2394
+
2395
+ // Resolve data shape: could be {left_key: [...], right_key: [...]} or [{left_field, right_field}, ...]
2396
+ let leftItems: Array<Record<string, unknown>> = [];
2397
+ let rightItems: Array<Record<string, unknown>> = [];
2398
+
2399
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
2400
+ // Object with left/right sub-arrays
2401
+ const obj = data as Record<string, unknown>;
2402
+ const rawLeft = obj[leftKey];
2403
+ const rawRight = obj[rightKey];
2404
+ if (Array.isArray(rawLeft)) leftItems = rawLeft.filter(x => typeof x === 'object' && x !== null) as Array<Record<string, unknown>>;
2405
+ if (Array.isArray(rawRight)) rightItems = rawRight.filter(x => typeof x === 'object' && x !== null) as Array<Record<string, unknown>>;
2406
+ } else if (Array.isArray(data)) {
2407
+ // Array of paired objects — split using left_key/right_key as field names
2408
+ data.forEach(item => {
2409
+ if (typeof item !== 'object' || item === null) return;
2410
+ const obj = item as Record<string, unknown>;
2411
+ // If fields exist, treat as paired data
2412
+ const leftVal = obj[leftKey];
2413
+ const rightVal = obj[rightKey];
2414
+ if (leftVal !== undefined || rightVal !== undefined) {
2415
+ leftItems.push({ text: leftVal, ...obj });
2416
+ rightItems.push({ text: rightVal, ...obj });
2417
+ }
2418
+ });
2419
+ }
2420
+
2421
+ if (leftItems.length === 0 && rightItems.length === 0) return null;
2422
+
2423
+ const palette = tokens.primitives.series_palette;
2424
+ const leftColor = palette[0];
2425
+ const rightColor = palette[1];
2426
+ const DESC_LIMIT = Infinity; // Show full text — no truncation
2427
+
2428
+ const autoFindTitle = (obj: Record<string, unknown>, explicitField?: string): string => {
2429
+ if (explicitField && obj[explicitField]) return String(obj[explicitField]);
2430
+ for (const k of TITLE_HINTS) {
2431
+ if (obj[k] && typeof obj[k] === 'string') return String(obj[k]);
2432
+ }
2433
+ // Fallback: first short string
2434
+ const firstShort = Object.entries(obj).find(([, v]) => typeof v === 'string' && (v as string).length < 80);
2435
+ return firstShort ? String(firstShort[1]) : '';
2436
+ };
2437
+
2438
+ const autoFindDesc = (obj: Record<string, unknown>, explicitField?: string): string => {
2439
+ if (explicitField && obj[explicitField]) return String(obj[explicitField]);
2440
+ for (const k of DESC_HINTS) {
2441
+ if (obj[k] && typeof obj[k] === 'string') return String(obj[k]);
2442
+ }
2443
+ // Fallback: longest string
2444
+ let longest = '';
2445
+ Object.values(obj).forEach(v => {
2446
+ if (typeof v === 'string' && v.length > longest.length) longest = v;
2447
+ });
2448
+ return longest;
2449
+ };
2450
+
2451
+ const toggleLeft = (idx: number) => {
2452
+ setExpandedLeft(prev => {
2453
+ const next = new Set(prev);
2454
+ if (next.has(idx)) next.delete(idx); else next.add(idx);
2455
+ return next;
2456
+ });
2457
+ };
2458
+ const toggleRight = (idx: number) => {
2459
+ setExpandedRight(prev => {
2460
+ const next = new Set(prev);
2461
+ if (next.has(idx)) next.delete(idx); else next.add(idx);
2462
+ return next;
2463
+ });
2464
+ };
2465
+
2466
+ const renderPanel = (
2467
+ items: Array<Record<string, unknown>>,
2468
+ side: 'left' | 'right',
2469
+ color: string,
2470
+ titleFieldOverride: string | undefined,
2471
+ descFieldOverride: string | undefined,
2472
+ expandedSet: Set<number>,
2473
+ toggleFn: (idx: number) => void,
2474
+ ) => (
2475
+ <div style={{
2476
+ flex: 1,
2477
+ display: 'flex',
2478
+ flexDirection: 'column',
2479
+ maxHeight: '400px',
2480
+ overflowY: 'auto',
2481
+ }}>
2482
+ {items.map((obj, idx) => {
2483
+ const title = autoFindTitle(obj, titleFieldOverride);
2484
+ const desc = autoFindDesc(obj, descFieldOverride);
2485
+ const isExpanded = expandedSet.has(idx);
2486
+ const needsTruncation = desc.length > DESC_LIMIT;
2487
+
2488
+ return (
2489
+ <div
2490
+ key={idx}
2491
+ style={{
2492
+ padding: 'var(--space-xs, 0.375rem) var(--space-sm, 0.5rem)',
2493
+ borderBottom: idx < items.length - 1 ? '1px solid var(--color-border-light, #eef0f2)' : 'none',
2494
+ cursor: needsTruncation ? 'pointer' : 'default',
2495
+ }}
2496
+ onClick={() => needsTruncation && toggleFn(idx)}
2497
+ >
2498
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--space-xs, 0.25rem)' }}>
2499
+ <span style={{
2500
+ fontWeight: 600,
2501
+ fontSize: 'var(--type-caption, 0.8125rem)',
2502
+ color: 'var(--color-text, #1a1d23)',
2503
+ lineHeight: 'var(--leading-snug, 1.4)',
2504
+ }}>
2505
+ {title}
2506
+ </span>
2507
+ {captureMode && onCapture && (
2508
+ <button
2509
+ title={`Capture ${side} item`}
2510
+ onClick={e => {
2511
+ e.stopPropagation();
2512
+ onCapture({
2513
+ source_view_key: captureViewKey || '',
2514
+ source_item_index: idx,
2515
+ source_renderer_type: 'dialectical_pair',
2516
+ content_type: 'item',
2517
+ selected_text: `[${side === 'left' ? leftLabel : rightLabel}] ${title}: ${desc}`.slice(0, 500),
2518
+ structured_data: obj,
2519
+ context_title: parentSectionKey
2520
+ ? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${side === 'left' ? leftLabel : rightLabel} > ${title}`
2521
+ : `${captureViewKey || 'Analysis'} > ${side === 'left' ? leftLabel : rightLabel} > ${title}`,
2522
+ source_type: (captureSourceType || 'analysis') as string,
2523
+ entity_id: captureEntityId || captureJobId || '',
2524
+ depth_level: 'L2_element',
2525
+ parent_context: parentSectionKey ? {
2526
+ section_key: parentSectionKey,
2527
+ section_title: parentSectionTitle || '',
2528
+ } : undefined,
2529
+ });
2530
+ }}
2531
+ style={{
2532
+ background: 'none',
2533
+ border: '1px solid var(--color-border, #ccc)',
2534
+ borderRadius: '4px',
2535
+ color: 'var(--dt-text-faint, #94a3b8)',
2536
+ cursor: 'pointer',
2537
+ padding: '2px 6px',
2538
+ fontSize: '0.7rem',
2539
+ lineHeight: 1,
2540
+ marginLeft: 'auto',
2541
+ flexShrink: 0,
2542
+ }}
2543
+ >
2544
+ &#x1F4CC;
2545
+ </button>
2546
+ )}
2547
+ </div>
2548
+ {desc && (
2549
+ <div style={{
2550
+ marginTop: '2px',
2551
+ fontSize: 'var(--type-label, 0.6875rem)',
2552
+ color: 'var(--color-text-muted, #6b7280)',
2553
+ lineHeight: 'var(--leading-relaxed, 1.6)',
2554
+ }}>
2555
+ {needsTruncation && !isExpanded
2556
+ ? desc.slice(0, DESC_LIMIT) + '...'
2557
+ : desc}
2558
+ </div>
2559
+ )}
2560
+ </div>
2561
+ );
2562
+ })}
2563
+ </div>
2564
+ );
2565
+
2566
+ return (
2567
+ <div style={{
2568
+ borderRadius: 'var(--radius-md, 8px)',
2569
+ border: '1px solid var(--color-border, #e2e5e9)',
2570
+ overflow: 'hidden',
2571
+ }}>
2572
+ {/* Column headers */}
2573
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr' }}>
2574
+ <div style={{
2575
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
2576
+ backgroundColor: leftColor,
2577
+ color: tokens.surfaces.text_on_accent,
2578
+ fontSize: 'var(--type-caption, 0.8125rem)',
2579
+ fontWeight: 600,
2580
+ textTransform: 'uppercase',
2581
+ letterSpacing: '0.06em',
2582
+ textAlign: 'center',
2583
+ }}>
2584
+ {leftLabel}
2585
+ </div>
2586
+ {/* Central tension node */}
2587
+ <div style={{
2588
+ display: 'flex',
2589
+ alignItems: 'center',
2590
+ justifyContent: 'center',
2591
+ padding: '0 var(--space-sm, 0.5rem)',
2592
+ backgroundColor: tokens.surfaces.surface_alt,
2593
+ position: 'relative',
2594
+ }}>
2595
+ <span style={{
2596
+ fontSize: 'var(--type-label, 0.6875rem)',
2597
+ fontWeight: 800,
2598
+ color: tokens.surfaces.text_muted,
2599
+ textTransform: 'uppercase',
2600
+ letterSpacing: '0.1em',
2601
+ padding: '2px 10px',
2602
+ borderRadius: 'var(--radius-pill, 9999px)',
2603
+ border: `2px solid ${tokens.surfaces.border_accent}`,
2604
+ backgroundColor: tokens.surfaces.surface_default,
2605
+ whiteSpace: 'nowrap',
2606
+ }}>
2607
+ {relationshipLabel}
2608
+ </span>
2609
+ </div>
2610
+ <div style={{
2611
+ padding: 'var(--space-sm, 0.5rem) var(--space-md, 1rem)',
2612
+ backgroundColor: rightColor,
2613
+ color: tokens.surfaces.text_on_accent,
2614
+ fontSize: 'var(--type-caption, 0.8125rem)',
2615
+ fontWeight: 600,
2616
+ textTransform: 'uppercase',
2617
+ letterSpacing: '0.06em',
2618
+ textAlign: 'center',
2619
+ }}>
2620
+ {rightLabel}
2621
+ </div>
2622
+ </div>
2623
+
2624
+ {/* Panels */}
2625
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr' }}>
2626
+ {renderPanel(leftItems, 'left', leftColor, leftTitleField, leftDescField, expandedLeft, toggleLeft)}
2627
+ {/* Vertical tension line */}
2628
+ <div style={{
2629
+ width: '3px',
2630
+ background: `linear-gradient(to bottom, ${leftColor}, ${tokens.surfaces.border_accent}, ${rightColor})`,
2631
+ }} />
2632
+ {renderPanel(rightItems, 'right', rightColor, rightTitleField, rightDescField, expandedRight, toggleRight)}
2633
+ </div>
2634
+ </div>
2635
+ );
2636
+ }
2637
+
2638
+ // ── OrderedFlow → Sequential Content Units ───────────────
2639
+ //
2640
+ // Generic renderer for any ordered sequence of content units:
2641
+ // chapters, argument steps, methodology phases, dialectical moves,
2642
+ // policy stages, evidence links, etc.
2643
+ //
2644
+ // Config:
2645
+ // title_field — primary label (auto-strips "Chapter N:" / "Step N:" prefixes)
2646
+ // subtitle_field — category/role badge (colored by design token semantic lookup)
2647
+ // description_field — expandable detail text
2648
+ // number_field — explicit numbering field (falls back to extracting from title or index)
2649
+ // strip_prefix — regex to strip from title (default: /^(Chapter|Step|Phase|Stage)\s+\d+[.:]\s*/i)
2650
+
2651
+ function OrderedFlow({ data, config }: SubRendererProps) {
2652
+ const [expandedIdx, setExpandedIdx] = React.useState<number | null>(null);
2653
+ const { tokens, getCategoryColor } = useDesignTokens();
2654
+
2655
+ if (!data || !Array.isArray(data) || data.length === 0) return null;
2656
+
2657
+ const titleField = (config.title_field as string) || 'title';
2658
+ const subtitleField = (config.subtitle_field as string) || 'type';
2659
+ const descriptionField = (config.description_field as string) || 'description';
2660
+ const numberField = (config.number_field as string) || undefined;
2661
+ const stripPrefix = config.strip_prefix as string | undefined;
2662
+
2663
+ const prefixRegex = stripPrefix
2664
+ ? new RegExp(stripPrefix, 'i')
2665
+ : /^(Chapter|Step|Phase|Stage|Part|Section|Move)\s+\d+[.:]\s*/i;
2666
+
2667
+ const lineColor = tokens.surfaces?.border_default || '#e2e5e9';
2668
+
2669
+ return (
2670
+ <div style={{ position: 'relative', paddingLeft: '36px' }}>
2671
+ {/* Vertical connecting line */}
2672
+ <div style={{
2673
+ position: 'absolute',
2674
+ left: '11px',
2675
+ top: '12px',
2676
+ bottom: '12px',
2677
+ width: '2px',
2678
+ backgroundColor: lineColor,
2679
+ }} />
2680
+
2681
+ {data.map((item, idx) => {
2682
+ if (typeof item !== 'object' || item === null) return null;
2683
+ const obj = item as Record<string, unknown>;
2684
+ const title = getField(obj, titleField);
2685
+ const category = getField(obj, subtitleField);
2686
+ const categoryLower = category.toLowerCase();
2687
+ const desc = getField(obj, descriptionField);
2688
+ const isExpanded = expandedIdx === idx;
2689
+ const isLast = idx === data.length - 1;
2690
+
2691
+ // Resolve step number: explicit field → extract from title → index
2692
+ let stepLabel: string;
2693
+ if (numberField && obj[numberField] !== undefined) {
2694
+ stepLabel = String(obj[numberField]);
2695
+ } else {
2696
+ const numMatch = title.match(/(\d+)/);
2697
+ stepLabel = numMatch ? numMatch[1] : String(idx + 1);
2698
+ }
2699
+
2700
+ // Strip common prefixes for cleaner display
2701
+ const cleanTitle = title.replace(prefixRegex, '');
2702
+
2703
+ // Color from design tokens: try categorical lookup, then series palette fallback
2704
+ const catColor = getCategoryColor?.(subtitleField, categoryLower);
2705
+ const dotColor = catColor?.text
2706
+ || tokens.primitives.series_palette[idx % tokens.primitives.series_palette.length];
2707
+
2708
+ const DESC_LIMIT = Infinity; // Show full text — no truncation
2709
+ const needsTruncation = desc.length > DESC_LIMIT;
2710
+ const displayDesc = isExpanded || !needsTruncation
2711
+ ? desc
2712
+ : desc.slice(0, DESC_LIMIT) + '...';
2713
+
2714
+ return (
2715
+ <div
2716
+ key={idx}
2717
+ onClick={() => needsTruncation && setExpandedIdx(isExpanded ? null : idx)}
2718
+ style={{
2719
+ position: 'relative',
2720
+ paddingBottom: isLast ? 0 : 'var(--space-xs, 0.375rem)',
2721
+ marginBottom: isLast ? 0 : 'var(--space-xs, 0.375rem)',
2722
+ cursor: needsTruncation ? 'pointer' : 'default',
2723
+ }}
2724
+ >
2725
+ {/* Step dot */}
2726
+ <div style={{
2727
+ position: 'absolute',
2728
+ left: '-36px',
2729
+ top: '2px',
2730
+ width: '24px',
2731
+ height: '24px',
2732
+ borderRadius: '50%',
2733
+ backgroundColor: dotColor,
2734
+ color: '#fff',
2735
+ display: 'flex',
2736
+ alignItems: 'center',
2737
+ justifyContent: 'center',
2738
+ fontSize: '11px',
2739
+ fontWeight: 700,
2740
+ zIndex: 1,
2741
+ fontFamily: 'var(--font-mono, monospace)',
2742
+ }}>
2743
+ {stepLabel}
2744
+ </div>
2745
+
2746
+ {/* Title + category */}
2747
+ <div style={{
2748
+ display: 'flex',
2749
+ alignItems: 'baseline',
2750
+ gap: 'var(--space-sm, 0.5rem)',
2751
+ flexWrap: 'wrap',
2752
+ lineHeight: 'var(--leading-snug, 1.4)',
2753
+ }}>
2754
+ <span style={{
2755
+ fontWeight: 600,
2756
+ fontSize: 'var(--type-body, 0.9375rem)',
2757
+ color: 'var(--color-text, #1a1d23)',
2758
+ }}>
2759
+ {cleanTitle}
2760
+ </span>
2761
+
2762
+ {category && (
2763
+ <span style={{
2764
+ fontSize: '10px',
2765
+ fontWeight: 600,
2766
+ textTransform: 'uppercase',
2767
+ letterSpacing: '0.06em',
2768
+ color: dotColor,
2769
+ opacity: 0.85,
2770
+ }}>
2771
+ {category}
2772
+ </span>
2773
+ )}
2774
+ </div>
2775
+
2776
+ {/* Description */}
2777
+ {desc && (
2778
+ <div style={{
2779
+ marginTop: '2px',
2780
+ fontSize: 'var(--type-caption, 0.8125rem)',
2781
+ color: 'var(--color-text-muted, #6b7280)',
2782
+ lineHeight: 'var(--leading-relaxed, 1.6)',
2783
+ }}>
2784
+ {displayDesc}
2785
+ </div>
2786
+ )}
2787
+ </div>
2788
+ );
2789
+ })}
2790
+ </div>
2791
+ );
2792
+ }
2793
+
2794
+ // ── RichDescriptionList → Readable Stacked Items ──────────
2795
+ //
2796
+ // Vertically-stacked list where each item gets room to breathe.
2797
+ // Handles BOTH string arrays ("Label: description") and object arrays.
2798
+ //
2799
+ // Config:
2800
+ // title_field — For object arrays: field name for label
2801
+ // description_field — For object arrays: field name for description
2802
+ // separator — For string arrays: split label from description (default ":")
2803
+ // max_visible_chars — Auto-collapse longer descriptions (default 200)
2804
+ // badge_fields — Optional extra fields to show as badges
2805
+
2806
+ function RichDescriptionList({ data, config }: SubRendererProps) {
2807
+ const [expandedIdx, setExpandedIdx] = React.useState<Set<number>>(new Set());
2808
+ const { tokens } = useDesignTokens();
2809
+
2810
+ const captureMode = config._captureMode as boolean | undefined;
2811
+ const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
2812
+ const captureJobId = config._captureJobId as string | undefined;
2813
+ const captureViewKey = config._captureViewKey as string | undefined;
2814
+ const captureSourceType = config._captureSourceType as string | undefined;
2815
+ const captureEntityId = config._captureEntityId as string | undefined;
2816
+ const parentSectionKey = config._parentSectionKey as string | undefined;
2817
+ const parentSectionTitle = config._parentSectionTitle as string | undefined;
2818
+
2819
+ if (!data || !Array.isArray(data) || data.length === 0) return null;
2820
+
2821
+ const titleField = config.title_field as string | undefined;
2822
+ const descriptionField = config.description_field as string | undefined;
2823
+ const separator = (config.separator as string) || ':';
2824
+ const maxChars = (config.max_visible_chars as number) || Infinity; // Show full text — no truncation
2825
+ const badgeFields = (config.badge_fields as string[]) || [];
2826
+
2827
+ const palette = tokens.primitives.series_palette;
2828
+
2829
+ // Parse items — handle string arrays and object arrays
2830
+ const items: Array<{ label: string; description: string; badges: string[]; raw: unknown }> = data.map(item => {
2831
+ if (typeof item === 'string') {
2832
+ // Parse "Label: description..." format
2833
+ const sepIdx = item.indexOf(separator);
2834
+ if (sepIdx > 0 && sepIdx < 60) {
2835
+ return {
2836
+ label: item.slice(0, sepIdx).trim(),
2837
+ description: item.slice(sepIdx + separator.length).trim(),
2838
+ badges: [],
2839
+ raw: item,
2840
+ };
2841
+ }
2842
+ // No separator found — use first ~40 chars as label
2843
+ const firstSpace = item.indexOf(' ', 30);
2844
+ if (firstSpace > 0) {
2845
+ return { label: item.slice(0, firstSpace), description: item.slice(firstSpace + 1), badges: [], raw: item };
2846
+ }
2847
+ return { label: item, description: '', badges: [], raw: item };
2848
+ }
2849
+
2850
+ if (typeof item === 'object' && item !== null) {
2851
+ const obj = item as Record<string, unknown>;
2852
+ const label = titleField ? getField(obj, titleField) : '';
2853
+ const desc = descriptionField ? getField(obj, descriptionField) : '';
2854
+ const badges = badgeFields.map(f => getField(obj, f)).filter(Boolean);
2855
+
2856
+ // If no explicit fields, auto-detect
2857
+ if (!label && !desc) {
2858
+ const auto = autoDetectFields(obj);
2859
+ return {
2860
+ label: auto.title ? getField(obj, auto.title) : '',
2861
+ description: auto.description ? getField(obj, auto.description) : '',
2862
+ badges,
2863
+ raw: item,
2864
+ };
2865
+ }
2866
+ return { label, description: desc, badges, raw: item };
2867
+ }
2868
+
2869
+ return { label: String(item), description: '', badges: [], raw: item };
2870
+ });
2871
+
2872
+ const toggleExpand = (idx: number) => {
2873
+ setExpandedIdx(prev => {
2874
+ const next = new Set(prev);
2875
+ if (next.has(idx)) next.delete(idx); else next.add(idx);
2876
+ return next;
2877
+ });
2878
+ };
2879
+
2880
+ return (
2881
+ <div style={{
2882
+ display: 'flex',
2883
+ flexDirection: 'column',
2884
+ gap: 0,
2885
+ borderRadius: 'var(--radius-md, 8px)',
2886
+ border: '1px solid var(--color-border, #e2e5e9)',
2887
+ overflow: 'hidden',
2888
+ }}>
2889
+ {items.map((item, idx) => {
2890
+ const color = palette[idx % palette.length];
2891
+ const isExpanded = expandedIdx.has(idx);
2892
+ const needsTruncation = item.description.length > maxChars;
2893
+ const isLast = idx === items.length - 1;
2894
+
2895
+ return (
2896
+ <div
2897
+ key={idx}
2898
+ style={{
2899
+ display: 'flex',
2900
+ borderBottom: isLast ? 'none' : '1px solid var(--color-border-light, #eef0f2)',
2901
+ }}
2902
+ >
2903
+ {/* Colored left border */}
2904
+ <div style={{
2905
+ width: '4px',
2906
+ backgroundColor: color,
2907
+ flexShrink: 0,
2908
+ }} />
2909
+
2910
+ {/* Content */}
2911
+ <div style={{
2912
+ flex: 1,
2913
+ padding: 'var(--space-sm, 0.75rem) var(--space-md, 1rem)',
2914
+ }}>
2915
+ {/* Header row: dot + label + count + capture */}
2916
+ <div style={{
2917
+ display: 'flex',
2918
+ alignItems: 'baseline',
2919
+ gap: 'var(--space-xs, 0.25rem)',
2920
+ }}>
2921
+ <span style={{
2922
+ color,
2923
+ fontSize: '14px',
2924
+ lineHeight: 1,
2925
+ flexShrink: 0,
2926
+ }}>●</span>
2927
+ <span style={{
2928
+ fontWeight: 700,
2929
+ fontSize: 'var(--type-caption, 0.8125rem)',
2930
+ textTransform: 'uppercase',
2931
+ letterSpacing: '0.04em',
2932
+ color: 'var(--color-text, #1a1d23)',
2933
+ lineHeight: 'var(--leading-snug, 1.4)',
2934
+ }}>
2935
+ {item.label || `Item ${idx + 1}`}
2936
+ </span>
2937
+
2938
+ {/* Badges */}
2939
+ {item.badges.map((badge, bi) => (
2940
+ <span key={bi} style={{
2941
+ fontSize: '10px',
2942
+ fontWeight: 600,
2943
+ textTransform: 'uppercase',
2944
+ letterSpacing: '0.05em',
2945
+ padding: '1px 6px',
2946
+ borderRadius: 'var(--radius-pill, 9999px)',
2947
+ backgroundColor: `${palette[(idx + bi + 1) % palette.length]}22`,
2948
+ color: palette[(idx + bi + 1) % palette.length],
2949
+ }}>
2950
+ {badge}
2951
+ </span>
2952
+ ))}
2953
+
2954
+ {/* Count badge */}
2955
+ <span style={{
2956
+ marginLeft: 'auto',
2957
+ fontSize: '10px',
2958
+ fontWeight: 500,
2959
+ color: 'var(--dt-text-faint, #94a3b8)',
2960
+ flexShrink: 0,
2961
+ }}>
2962
+ [{idx + 1}/{items.length}]
2963
+ </span>
2964
+
2965
+ {/* Capture button */}
2966
+ {captureMode && onCapture && (
2967
+ <button
2968
+ title="Capture this item"
2969
+ onClick={e => {
2970
+ e.stopPropagation();
2971
+ onCapture({
2972
+ source_view_key: captureViewKey || '',
2973
+ source_item_index: idx,
2974
+ source_renderer_type: 'rich_description_list',
2975
+ content_type: 'item',
2976
+ selected_text: `${item.label}: ${item.description}`.slice(0, 500),
2977
+ structured_data: item.raw,
2978
+ context_title: parentSectionKey
2979
+ ? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${item.label}`
2980
+ : `${captureViewKey || 'Analysis'} > ${item.label}`,
2981
+ source_type: (captureSourceType || 'analysis') as string,
2982
+ entity_id: captureEntityId || captureJobId || '',
2983
+ depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
2984
+ parent_context: parentSectionKey ? {
2985
+ section_key: parentSectionKey,
2986
+ section_title: parentSectionTitle || '',
2987
+ } : undefined,
2988
+ });
2989
+ }}
2990
+ style={{
2991
+ background: 'none',
2992
+ border: '1px solid var(--color-border, #ccc)',
2993
+ borderRadius: '4px',
2994
+ color: 'var(--dt-text-faint, #94a3b8)',
2995
+ cursor: 'pointer',
2996
+ padding: '2px 6px',
2997
+ fontSize: '0.7rem',
2998
+ lineHeight: 1,
2999
+ flexShrink: 0,
3000
+ }}
3001
+ >
3002
+ &#x1F4CC;
3003
+ </button>
3004
+ )}
3005
+ </div>
3006
+
3007
+ {/* Description */}
3008
+ {item.description && (
3009
+ <div style={{
3010
+ marginTop: 'var(--space-2xs, 0.25rem)',
3011
+ fontSize: 'var(--type-caption, 0.8125rem)',
3012
+ color: 'var(--color-text-muted, #6b7280)',
3013
+ lineHeight: 'var(--leading-relaxed, 1.6)',
3014
+ }}>
3015
+ {needsTruncation && !isExpanded
3016
+ ? item.description.slice(0, maxChars) + '...'
3017
+ : item.description
3018
+ }
3019
+ {needsTruncation && (
3020
+ <button
3021
+ onClick={(e) => { e.stopPropagation(); toggleExpand(idx); }}
3022
+ className="gen-show-more-link"
3023
+ style={{
3024
+ background: 'none',
3025
+ border: 'none',
3026
+ color,
3027
+ cursor: 'pointer',
3028
+ fontSize: '0.75rem',
3029
+ padding: '0 4px',
3030
+ marginLeft: '4px',
3031
+ fontWeight: 600,
3032
+ }}
3033
+ >
3034
+ {isExpanded ? 'collapse ▴' : 'expand ▾'}
3035
+ </button>
3036
+ )}
3037
+ </div>
3038
+ )}
3039
+ </div>
3040
+ </div>
3041
+ );
3042
+ })}
3043
+ </div>
3044
+ );
3045
+ }
3046
+
3047
+ // ── PhaseTimeline → Connected Phase Nodes ──────────────────
3048
+ //
3049
+ // Horizontal connected timeline with prominent phase nodes. Designed for
3050
+ // temporal/sequential data stored as an OBJECT with a phases array and
3051
+ // optional mode badge.
3052
+ //
3053
+ // Config:
3054
+ // phases_field — key containing the phases array (default "phases")
3055
+ // label_field — field within each phase for the label (default "label")
3056
+ // description_field — field within each phase for the description (default "description")
3057
+ // mode_field — optional top-level field for a mode badge (default "mode")
3058
+
3059
+ function PhaseTimeline({ data, config }: SubRendererProps) {
3060
+ const [expandedIdx, setExpandedIdx] = React.useState<Set<number>>(new Set());
3061
+ const { tokens } = useDesignTokens();
3062
+
3063
+ const captureMode = config._captureMode as boolean | undefined;
3064
+ const onCapture = config._onCapture as ((sel: CaptureSelection) => void) | undefined;
3065
+ const captureJobId = config._captureJobId as string | undefined;
3066
+ const captureViewKey = config._captureViewKey as string | undefined;
3067
+ const captureSourceType = config._captureSourceType as string | undefined;
3068
+ const captureEntityId = config._captureEntityId as string | undefined;
3069
+ const parentSectionKey = config._parentSectionKey as string | undefined;
3070
+ const parentSectionTitle = config._parentSectionTitle as string | undefined;
3071
+
3072
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
3073
+
3074
+ const obj = data as Record<string, unknown>;
3075
+ const phasesField = (config.phases_field as string) || 'phases';
3076
+ const labelField = (config.label_field as string) || 'label';
3077
+ const descField = (config.description_field as string) || 'description';
3078
+ const modeField = (config.mode_field as string) || 'mode';
3079
+
3080
+ const rawPhases = obj[phasesField];
3081
+ if (!Array.isArray(rawPhases) || rawPhases.length === 0) return null;
3082
+
3083
+ const mode = obj[modeField] as string | undefined;
3084
+ const palette = tokens.primitives.series_palette;
3085
+ const DESC_LIMIT = Infinity; // Show full text — no truncation
3086
+
3087
+ const phases = rawPhases
3088
+ .filter(p => typeof p === 'object' && p !== null)
3089
+ .map(p => p as Record<string, unknown>);
3090
+
3091
+ const toggleExpand = (idx: number) => {
3092
+ setExpandedIdx(prev => {
3093
+ const next = new Set(prev);
3094
+ if (next.has(idx)) next.delete(idx); else next.add(idx);
3095
+ return next;
3096
+ });
3097
+ };
3098
+
3099
+ return (
3100
+ <div>
3101
+ {/* Mode badge */}
3102
+ {mode && (
3103
+ <div style={{
3104
+ marginBottom: 'var(--space-sm, 0.75rem)',
3105
+ display: 'flex',
3106
+ justifyContent: 'center',
3107
+ }}>
3108
+ <span style={{
3109
+ fontSize: '10px',
3110
+ fontWeight: 700,
3111
+ textTransform: 'uppercase',
3112
+ letterSpacing: '0.08em',
3113
+ padding: '3px 12px',
3114
+ borderRadius: 'var(--radius-pill, 9999px)',
3115
+ backgroundColor: `${palette[0]}18`,
3116
+ color: palette[0],
3117
+ border: `1px solid ${palette[0]}40`,
3118
+ }}>
3119
+ {mode} temporality
3120
+ </span>
3121
+ </div>
3122
+ )}
3123
+
3124
+ {/* Timeline — horizontal layout */}
3125
+ <div style={{
3126
+ display: 'flex',
3127
+ alignItems: 'flex-start',
3128
+ gap: 0,
3129
+ overflow: 'hidden',
3130
+ }}>
3131
+ {phases.map((phase, idx) => {
3132
+ const label = getField(phase, labelField);
3133
+ const desc = getField(phase, descField);
3134
+ const color = palette[idx % palette.length];
3135
+ const isExpanded = expandedIdx.has(idx);
3136
+ const needsTruncation = desc.length > DESC_LIMIT;
3137
+ const isLast = idx === phases.length - 1;
3138
+
3139
+ return (
3140
+ <React.Fragment key={idx}>
3141
+ {/* Phase node */}
3142
+ <div style={{
3143
+ flex: 1,
3144
+ display: 'flex',
3145
+ flexDirection: 'column',
3146
+ alignItems: 'center',
3147
+ minWidth: 0,
3148
+ }}>
3149
+ {/* Node circle */}
3150
+ <div style={{
3151
+ width: '36px',
3152
+ height: '36px',
3153
+ borderRadius: '50%',
3154
+ backgroundColor: color,
3155
+ color: '#fff',
3156
+ display: 'flex',
3157
+ alignItems: 'center',
3158
+ justifyContent: 'center',
3159
+ fontSize: '14px',
3160
+ fontWeight: 700,
3161
+ flexShrink: 0,
3162
+ zIndex: 1,
3163
+ fontFamily: 'var(--font-mono, monospace)',
3164
+ boxShadow: `0 0 0 3px ${color}30`,
3165
+ }}>
3166
+ {idx + 1}
3167
+ </div>
3168
+
3169
+ {/* Label card */}
3170
+ <div style={{
3171
+ marginTop: 'var(--space-xs, 0.375rem)',
3172
+ textAlign: 'center',
3173
+ padding: 'var(--space-xs, 0.375rem) var(--space-sm, 0.5rem)',
3174
+ borderRadius: 'var(--radius-md, 8px)',
3175
+ border: `1px solid ${color}40`,
3176
+ backgroundColor: `${color}08`,
3177
+ width: '100%',
3178
+ minHeight: '44px',
3179
+ display: 'flex',
3180
+ flexDirection: 'column',
3181
+ alignItems: 'center',
3182
+ justifyContent: 'center',
3183
+ }}>
3184
+ <span style={{
3185
+ fontWeight: 700,
3186
+ fontSize: 'var(--type-label, 0.6875rem)',
3187
+ textTransform: 'uppercase',
3188
+ letterSpacing: '0.04em',
3189
+ color: 'var(--color-text, #1a1d23)',
3190
+ lineHeight: 'var(--leading-snug, 1.3)',
3191
+ }}>
3192
+ {label || `Phase ${idx + 1}`}
3193
+ </span>
3194
+ </div>
3195
+
3196
+ {/* Description below node */}
3197
+ {desc && (
3198
+ <div
3199
+ onClick={() => needsTruncation && toggleExpand(idx)}
3200
+ style={{
3201
+ marginTop: 'var(--space-2xs, 0.25rem)',
3202
+ fontSize: 'var(--type-label, 0.6875rem)',
3203
+ color: 'var(--color-text-muted, #6b7280)',
3204
+ lineHeight: 'var(--leading-relaxed, 1.6)',
3205
+ textAlign: 'center',
3206
+ padding: '0 var(--space-2xs, 0.125rem)',
3207
+ cursor: needsTruncation ? 'pointer' : 'default',
3208
+ }}
3209
+ >
3210
+ {needsTruncation && !isExpanded
3211
+ ? desc.slice(0, DESC_LIMIT) + '...'
3212
+ : desc
3213
+ }
3214
+ {needsTruncation && (
3215
+ <button
3216
+ onClick={e => { e.stopPropagation(); toggleExpand(idx); }}
3217
+ style={{
3218
+ background: 'none',
3219
+ border: 'none',
3220
+ color,
3221
+ cursor: 'pointer',
3222
+ fontSize: '0.7rem',
3223
+ padding: '0 3px',
3224
+ marginLeft: '2px',
3225
+ fontWeight: 600,
3226
+ }}
3227
+ >
3228
+ {isExpanded ? '▴' : '▾'}
3229
+ </button>
3230
+ )}
3231
+ </div>
3232
+ )}
3233
+
3234
+ {/* Capture button */}
3235
+ {captureMode && onCapture && (
3236
+ <button
3237
+ title="Capture this phase"
3238
+ onClick={e => {
3239
+ e.stopPropagation();
3240
+ onCapture({
3241
+ source_view_key: captureViewKey || '',
3242
+ source_item_index: idx,
3243
+ source_renderer_type: 'phase_timeline',
3244
+ content_type: 'item',
3245
+ selected_text: `[Phase ${idx + 1}] ${label}: ${desc}`.slice(0, 500),
3246
+ structured_data: phase,
3247
+ context_title: parentSectionKey
3248
+ ? `${captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > Phase: ${label}`
3249
+ : `${captureViewKey || 'Analysis'} > Phase: ${label}`,
3250
+ source_type: (captureSourceType || 'analysis') as string,
3251
+ entity_id: captureEntityId || captureJobId || '',
3252
+ depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
3253
+ parent_context: parentSectionKey ? {
3254
+ section_key: parentSectionKey,
3255
+ section_title: parentSectionTitle || '',
3256
+ } : undefined,
3257
+ });
3258
+ }}
3259
+ style={{
3260
+ marginTop: 'var(--space-2xs, 0.25rem)',
3261
+ background: 'none',
3262
+ border: '1px solid var(--color-border, #ccc)',
3263
+ borderRadius: '4px',
3264
+ color: 'var(--dt-text-faint, #94a3b8)',
3265
+ cursor: 'pointer',
3266
+ padding: '2px 6px',
3267
+ fontSize: '0.7rem',
3268
+ lineHeight: 1,
3269
+ }}
3270
+ >
3271
+ &#x1F4CC;
3272
+ </button>
3273
+ )}
3274
+ </div>
3275
+
3276
+ {/* Connector line between nodes */}
3277
+ {!isLast && (
3278
+ <div style={{
3279
+ display: 'flex',
3280
+ alignItems: 'center',
3281
+ paddingTop: '18px',
3282
+ flexShrink: 0,
3283
+ }}>
3284
+ <div style={{
3285
+ width: '28px',
3286
+ height: '2px',
3287
+ background: `linear-gradient(to right, ${color}, ${palette[(idx + 1) % palette.length]})`,
3288
+ }} />
3289
+ <div style={{
3290
+ width: 0,
3291
+ height: 0,
3292
+ borderTop: '4px solid transparent',
3293
+ borderBottom: '4px solid transparent',
3294
+ borderLeft: `6px solid ${palette[(idx + 1) % palette.length]}`,
3295
+ }} />
3296
+ </div>
3297
+ )}
3298
+ </React.Fragment>
3299
+ );
3300
+ })}
3301
+ </div>
3302
+ </div>
3303
+ );
3304
+ }
3305
+
3306
+
3307
+ // ── Distribution Summary ────────────────────────────────
3308
+ /**
3309
+ * Visual distribution bar chart with optional dominant pattern highlight
3310
+ * and collapsible narrative. Configurable via JSON view definitions.
3311
+ *
3312
+ * Data shape (object):
3313
+ * {
3314
+ * distribution: Record<string, number> | Array<{ key: string; count: number; major_count?: number }>
3315
+ * dominant?: string
3316
+ * narrative?: string
3317
+ * }
3318
+ *
3319
+ * Field mapping config (for data that doesn't use these exact field names):
3320
+ * distribution_field?: string -- field name for distribution data (default: "distribution")
3321
+ * dominant_field?: string -- field name for dominant item (default: "dominant")
3322
+ * narrative_field?: string -- field name for narrative text (default: "narrative")
3323
+ *
3324
+ * Display config:
3325
+ * category?: string -- design token category for colors (e.g., "tactic", "relationship")
3326
+ * dominant_label?: string -- label above dominant value (default: "Dominant")
3327
+ * count_noun?: string -- noun for total count (default: "items")
3328
+ * type_noun?: string -- noun for type count (default: "types")
3329
+ * severity_value?: string -- label for severity badges (default: "major")
3330
+ *
3331
+ * Interactive mode (passed by CardGridRenderer via _ prefix):
3332
+ * _onFilterClick?: (key: string) => void -- makes bars clickable
3333
+ * _activeFilter?: string | null -- highlights active bar
3334
+ * _groups?: Group[] -- live groups (overrides distribution field)
3335
+ */
3336
+ export function DistributionSummary({ data, config }: SubRendererProps) {
3337
+ const { getCategoryColor, getLabel } = useDesignTokens();
3338
+ const [narrativeExpanded, setNarrativeExpanded] = useState(false);
3339
+
3340
+ const obj = (data && typeof data === 'object' && !Array.isArray(data))
3341
+ ? data as Record<string, unknown>
3342
+ : null;
3343
+ if (!obj) return null;
3344
+
3345
+ // Config
3346
+ const category = config.category as string | undefined;
3347
+ const dominantLabel = (config.dominant_label as string) || 'Dominant';
3348
+ const countNoun = (config.count_noun as string) || 'items';
3349
+ const typeNoun = (config.type_noun as string) || 'types';
3350
+ const severityValue = (config.severity_value as string) || 'major';
3351
+
3352
+ // Field mapping (allows data to use different field names)
3353
+ const distField = (config.distribution_field as string) || 'distribution';
3354
+ const domField = (config.dominant_field as string) || 'dominant';
3355
+ const narrField = (config.narrative_field as string) || 'narrative';
3356
+
3357
+ // Interactive mode (injected by CardGridRenderer)
3358
+ const onFilterClick = config._onFilterClick as ((key: string) => void) | undefined;
3359
+ const activeFilter = config._activeFilter as string | null | undefined;
3360
+ const liveGroups = config._groups as Array<{
3361
+ key: string; label: string;
3362
+ style: { bg: string; text: string; border: string };
3363
+ items: Array<Record<string, unknown>>;
3364
+ }> | undefined;
3365
+
3366
+ // Build entries — prefer live groups (interactive) over static distribution
3367
+ const formatName = (key: string) =>
3368
+ (category ? getLabel(category, key) : null) || key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
3369
+
3370
+ let entries: Array<{ key: string; label: string; count: number; majorCount: number;
3371
+ colors: { bg: string; text: string; border: string } }> = [];
3372
+
3373
+ const fallbackColors = { bg: '#f1f5f9', text: '#64748b', border: '#e2e8f0' };
3374
+
3375
+ if (liveGroups && liveGroups.length > 0) {
3376
+ entries = liveGroups.map(g => ({
3377
+ key: g.key,
3378
+ label: g.label,
3379
+ count: g.items.length,
3380
+ majorCount: g.items.filter(i => String(i.severity || '').toLowerCase() === severityValue).length,
3381
+ colors: (category ? getCategoryColor(category, g.key) : null) || g.style || fallbackColors,
3382
+ }));
3383
+ } else {
3384
+ const rawDist = obj[distField];
3385
+ if (rawDist && typeof rawDist === 'object' && !Array.isArray(rawDist)) {
3386
+ entries = Object.entries(rawDist as Record<string, number>)
3387
+ .map(([key, count]) => ({
3388
+ key, count: Number(count) || 0, majorCount: 0,
3389
+ label: formatName(key),
3390
+ colors: (category ? getCategoryColor(category, key) : null) || fallbackColors,
3391
+ }))
3392
+ .sort((a, b) => b.count - a.count);
3393
+ } else if (Array.isArray(rawDist)) {
3394
+ entries = (rawDist as Array<Record<string, unknown>>).map(item => {
3395
+ const key = String(item.key || '');
3396
+ return {
3397
+ key, count: Number(item.count || 0), majorCount: Number(item.major_count || 0),
3398
+ label: String(item.label || '') || formatName(key),
3399
+ colors: (category ? getCategoryColor(category, key) : null) || fallbackColors,
3400
+ };
3401
+ }).sort((a, b) => b.count - a.count);
3402
+ }
3403
+ }
3404
+
3405
+ if (entries.length === 0) return null;
3406
+
3407
+ const totalCount = entries.reduce((sum, e) => sum + e.count, 0);
3408
+ const maxCount = Math.max(...entries.map(e => e.count));
3409
+ const dominant = obj[domField] as string | undefined;
3410
+ const narrative = obj[narrField] as string | undefined;
3411
+ const isInteractive = Boolean(onFilterClick);
3412
+
3413
+ return (
3414
+ <div className="ar-dist-summary">
3415
+ {/* Header: dominant + total count */}
3416
+ <div className="gen-summary-header">
3417
+ {dominant && (
3418
+ <div className="gen-summary-stat">
3419
+ <span className="gen-stat-label">{dominantLabel}</span>
3420
+ <span className="gen-stat-value">{formatName(dominant)}</span>
3421
+ </div>
3422
+ )}
3423
+ <div className="gen-summary-counts">
3424
+ <span className="gen-summary-count-big">{totalCount}</span>
3425
+ <span className="gen-summary-count-label">{countNoun} across {entries.length} {typeNoun}</span>
3426
+ </div>
3427
+ </div>
3428
+
3429
+ {/* Distribution bars */}
3430
+ <div className="gen-dist-bars">
3431
+ {entries.map(entry => {
3432
+ const pct = Math.max(8, (entry.count / maxCount) * 100);
3433
+ const isActive = activeFilter === entry.key;
3434
+ const BarTag = isInteractive ? 'button' : 'div';
3435
+
3436
+ return (
3437
+ <BarTag
3438
+ key={entry.key}
3439
+ type={isInteractive ? 'button' : undefined}
3440
+ className={`gen-dist-bar-row ${isActive ? 'gen-dist-bar-row--active' : ''}`}
3441
+ onClick={isInteractive ? () => onFilterClick!(entry.key) : undefined}
3442
+ >
3443
+ <span className="gen-dist-bar-label">{entry.label}</span>
3444
+ <span className="gen-dist-bar-track">
3445
+ <span
3446
+ className="gen-dist-bar-fill"
3447
+ style={{
3448
+ width: `${pct}%`,
3449
+ backgroundColor: entry.colors.text,
3450
+ opacity: isActive ? 1 : 0.7,
3451
+ }}
3452
+ />
3453
+ </span>
3454
+ <span className="gen-dist-bar-count" style={{ color: entry.colors.text }}>
3455
+ {entry.count}
3456
+ </span>
3457
+ {entry.majorCount > 0 && (
3458
+ <span className="gen-dist-bar-severity">{entry.majorCount} {severityValue}</span>
3459
+ )}
3460
+ </BarTag>
3461
+ );
3462
+ })}
3463
+ {isInteractive && activeFilter && (
3464
+ <button type="button" className="gen-dist-bar-clear" onClick={() => onFilterClick!('')}>
3465
+ Clear filter
3466
+ </button>
3467
+ )}
3468
+ </div>
3469
+
3470
+ {/* Narrative */}
3471
+ {narrative && (
3472
+ <>
3473
+ <div className={`gen-pattern-narrative-wrap ${narrativeExpanded ? 'gen-pattern-narrative-wrap--expanded' : ''}`}>
3474
+ <p className="gen-pattern-narrative">{narrative}</p>
3475
+ </div>
3476
+ <button
3477
+ type="button"
3478
+ className="gen-narrative-toggle"
3479
+ onClick={() => setNarrativeExpanded(!narrativeExpanded)}
3480
+ >
3481
+ {narrativeExpanded ? 'Show less' : 'Read full analysis'}
3482
+ </button>
3483
+ </>
3484
+ )}
3485
+ </div>
3486
+ );
3487
+ }