@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,19 @@
1
+ /**
2
+ * ProvenanceSectionIcon — Stub for the provenance icon in accordion section headers.
3
+ *
4
+ * Consumer apps that support provenance tracking should override this component
5
+ * by importing and registering their own implementation.
6
+ */
7
+
8
+ import React from 'react';
9
+
10
+ interface ProvenanceSectionIconProps {
11
+ sectionKey: string;
12
+ config: unknown;
13
+ children_payloads?: unknown;
14
+ }
15
+
16
+ /** Default no-op stub — renders nothing. Consumer apps override for provenance UI. */
17
+ export function ProvenanceSectionIcon(_props: ProvenanceSectionIconProps) {
18
+ return null;
19
+ }
@@ -0,0 +1,609 @@
1
+ /**
2
+ * AccordionRenderer — Generic collapsible sections renderer.
3
+ *
4
+ * Reads section definitions from renderer_config.sections, renders each
5
+ * as a collapsible panel. Supports prose mode via useProseExtraction.
6
+ *
7
+ * All sections dispatch through a resilient fallback chain:
8
+ * 1. Check section_renderers[key] for a configured sub-renderer
9
+ * 2. Pre-render compatibility check: skip if data type mismatches renderer
10
+ * 3. SubRendererFallback wrapper: catch null output via useLayoutEffect
11
+ * 4. Auto-detect sub-renderer from data shape
12
+ * 5. GenericSectionRenderer as final fallback (handles any data)
13
+ *
14
+ * renderer_config keys:
15
+ * sections: Array<{key, title}> — sections to render
16
+ * expand_first: boolean — auto-expand first section
17
+ * prose_endpoint: string — endpoint key for prose extraction
18
+ * section_renderers: Record<string, {renderer_type, config?, sub_renderers?}>
19
+ *
20
+ * Per-section polish keys (threaded via config._*):
21
+ * _onPolishSection: (sectionKey, feedback) => void
22
+ * _onResetSection: (sectionKey) => void
23
+ * _sectionPolishState: Record<string, 'idle'|'polishing'|'polished'|'error'>
24
+ * _section_overrides: Record<string, {style_overrides, renderer_config_patch?}>
25
+ * _section_descriptions: Record<string, string> — section subtitle text from polish
26
+ */
27
+
28
+ import React, { useState, useEffect, useRef } from 'react';
29
+ import { RendererProps } from '../types';
30
+ import { useProseExtraction } from '../hooks/useProseExtraction';
31
+ import { resolveSubRenderer, autoDetectSubRenderer } from '../sub-renderers/SubRenderers';
32
+ import { isRendererCompatible, SubRendererFallback, GenericSectionRenderer } from '../dispatch/SubRendererDispatch';
33
+ import { ProvenanceSectionIcon } from '../provenance/ProvenanceSectionIcon';
34
+ import { StyleOverrides } from '../types/styles';
35
+ import { useDesignTokens } from '../tokens/DesignTokenContext';
36
+ // CSS: import '@caii/analysis-renderers/styles';
37
+
38
+ interface SectionDef {
39
+ key: string;
40
+ title: string;
41
+ }
42
+
43
+ type SectionPolishState = 'idle' | 'polishing' | 'polished' | 'error';
44
+
45
+ /**
46
+ * Extract a short preview string from various data shapes.
47
+ * Used to show a hint of section content when collapsed.
48
+ */
49
+ function extractPreviewText(data: unknown, maxLen = 80): string {
50
+ if (typeof data === 'string') {
51
+ const clean = data.replace(/\n/g, ' ').trim();
52
+ return clean.length > maxLen ? clean.slice(0, maxLen) + '\u2026' : clean;
53
+ }
54
+ if (Array.isArray(data) && data.length > 0) {
55
+ const first = data[0];
56
+ if (typeof first === 'string') {
57
+ const clean = first.replace(/\n/g, ' ').trim();
58
+ return clean.length > maxLen ? clean.slice(0, maxLen) + '\u2026' : clean;
59
+ }
60
+ if (typeof first === 'object' && first !== null) {
61
+ const obj = first as Record<string, unknown>;
62
+ for (const key of ['name', 'title', 'term', 'summary', 'description', 'commitment']) {
63
+ if (typeof obj[key] === 'string') {
64
+ const val = (obj[key] as string).replace(/\n/g, ' ').trim();
65
+ return val.length > maxLen ? val.slice(0, maxLen) + '\u2026' : val;
66
+ }
67
+ }
68
+ }
69
+ }
70
+ if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
71
+ const obj = data as Record<string, unknown>;
72
+ for (const key of ['summary', 'description', 'overview', 'analysis', 'assessment']) {
73
+ if (typeof obj[key] === 'string') {
74
+ const val = (obj[key] as string).replace(/\n/g, ' ').trim();
75
+ return val.length > maxLen ? val.slice(0, maxLen) + '\u2026' : val;
76
+ }
77
+ }
78
+ }
79
+ return '';
80
+ }
81
+
82
+ export function AccordionRenderer({ data, config }: RendererProps) {
83
+ const { getSemanticColor } = useDesignTokens();
84
+ const sections = (config.sections as SectionDef[]) || [];
85
+ const expandFirst = config.expand_first as boolean | undefined;
86
+ const proseEndpoint = (config.prose_endpoint as string) || 'conditions';
87
+ const styleOverrides = config._style_overrides as StyleOverrides | undefined;
88
+
89
+ // Section descriptions from polish (threaded by Phase 1 or available directly)
90
+ const sectionDescriptions = config._section_descriptions as Record<string, string> | undefined;
91
+
92
+ // Per-section polish controls (threaded from GenealogyPage)
93
+ const onPolishSection = config._onPolishSection as
94
+ | ((sectionKey: string, feedback: string) => void)
95
+ | undefined;
96
+ const onResetSection = config._onResetSection as
97
+ | ((sectionKey: string) => void)
98
+ | undefined;
99
+ const sectionPolishState = config._sectionPolishState as
100
+ | Record<string, SectionPolishState>
101
+ | undefined;
102
+ const sectionOverrides = config._section_overrides as
103
+ | Record<string, { style_overrides: StyleOverrides; renderer_config_patch?: Record<string, unknown> }>
104
+ | undefined;
105
+ // Capture mode support (threaded from CaptureContext → V2TabContent)
106
+ const captureMode = config._captureMode as boolean | undefined;
107
+ const onCapture = config._onCapture as
108
+ | ((sel: Record<string, unknown>) => void)
109
+ | undefined;
110
+ const captureJobId = config._captureJobId as string | undefined;
111
+ const captureViewKey = config._captureViewKey as string | undefined;
112
+ const captureSourceType = config._captureSourceType as string | undefined;
113
+ const captureEntityId = config._captureEntityId as string | undefined;
114
+ const captureStatusMap = config._captureStatusMap as Record<string, Array<{
115
+ destination: string | null;
116
+ research_status: string | null;
117
+ has_answer: boolean;
118
+ }>> | undefined;
119
+
120
+ const provenanceEnabled = config._provenanceEnabled as boolean | undefined;
121
+ const provenanceChildren = config._provenanceChildren as
122
+ | Array<{ view_key: string; view_name: string; engine_key: string | null; renderer_type: string; [key: string]: unknown }>
123
+ | undefined;
124
+
125
+ // Prose extraction
126
+ const { data: extractedData, loading, error, isProseMode } = useProseExtraction<unknown>(
127
+ data as unknown,
128
+ config._jobId as string | undefined,
129
+ proseEndpoint,
130
+ { apiPathPrefix: config._apiPathPrefix as string | undefined }
131
+ );
132
+
133
+ const workingData = (isProseMode ? extractedData : data) as Record<string, unknown> | null;
134
+
135
+ // Track which sections are expanded
136
+ const [expandedSections, setExpandedSections] = useState<Set<string>>(() => {
137
+ if (expandFirst && sections.length > 0) {
138
+ return new Set([sections[0].key]);
139
+ }
140
+ return new Set<string>();
141
+ });
142
+
143
+ // Track ever-expanded for animation (keep content in DOM after first expand)
144
+ const [everExpanded, setEverExpanded] = useState<Set<string>>(() => {
145
+ if (expandFirst && sections.length > 0) {
146
+ return new Set([sections[0].key]);
147
+ }
148
+ return new Set<string>();
149
+ });
150
+
151
+ // Track which sections have the feedback row open
152
+ const [feedbackOpen, setFeedbackOpen] = useState<Set<string>>(new Set());
153
+ // Track feedback text per section
154
+ const [feedbackText, setFeedbackText] = useState<Record<string, string>>({});
155
+
156
+ // ── Deep-link support: auto-expand, scroll, highlight target section ──
157
+ const deepLinkSection = config._deepLinkSection as string | null | undefined;
158
+ const onDeepLinkConsumed = config._onDeepLinkConsumed as (() => void) | undefined;
159
+ const deepLinkProcessedRef = useRef(false);
160
+
161
+ useEffect(() => {
162
+ if (!deepLinkSection || deepLinkProcessedRef.current) return;
163
+ const targetSection = sections.find(s => s.key === deepLinkSection);
164
+ if (!targetSection) {
165
+ console.warn(`[DeepLink] section_key "${deepLinkSection}" not found in accordion sections`);
166
+ onDeepLinkConsumed?.();
167
+ return;
168
+ }
169
+ // Expand the target section
170
+ setExpandedSections(prev => { const next = new Set(prev); next.add(deepLinkSection); return next; });
171
+ setEverExpanded(prev => { const next = new Set(prev); next.add(deepLinkSection); return next; });
172
+ deepLinkProcessedRef.current = true;
173
+
174
+ // Wait for DOM to render the expanded section, then scroll + highlight
175
+ requestAnimationFrame(() => {
176
+ setTimeout(() => {
177
+ const el = document.getElementById(`section-${deepLinkSection}`);
178
+ if (el) {
179
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
180
+ el.classList.add('gen-accordion-section--highlighted');
181
+ setTimeout(() => el.classList.remove('gen-accordion-section--highlighted'), 2000);
182
+ }
183
+ onDeepLinkConsumed?.();
184
+ }, 150);
185
+ });
186
+ }, [deepLinkSection, sections, onDeepLinkConsumed]);
187
+
188
+ const toggleSection = (key: string) => {
189
+ setExpandedSections(prev => {
190
+ const next = new Set(prev);
191
+ if (next.has(key)) {
192
+ next.delete(key);
193
+ } else {
194
+ next.add(key);
195
+ }
196
+ return next;
197
+ });
198
+ // Track ever-expanded so content stays in DOM for animation
199
+ setEverExpanded(prev => {
200
+ if (prev.has(key)) return prev;
201
+ const next = new Set(prev);
202
+ next.add(key);
203
+ return next;
204
+ });
205
+ };
206
+
207
+ const toggleFeedback = (key: string) => {
208
+ setFeedbackOpen(prev => {
209
+ const next = new Set(prev);
210
+ if (next.has(key)) {
211
+ next.delete(key);
212
+ } else {
213
+ next.add(key);
214
+ }
215
+ return next;
216
+ });
217
+ };
218
+
219
+ const handlePolishClick = (sectionKey: string) => {
220
+ if (onPolishSection) {
221
+ onPolishSection(sectionKey, feedbackText[sectionKey] || '');
222
+ setFeedbackOpen(prev => {
223
+ const next = new Set(prev);
224
+ next.delete(sectionKey);
225
+ return next;
226
+ });
227
+ }
228
+ };
229
+
230
+ if (loading) {
231
+ return (
232
+ <div className="gen-conditions-tab">
233
+ <div className="gen-extracting-notice">
234
+ <div className="gen-extracting-spinner" />
235
+ <p>Preparing structured view from analytical prose...</p>
236
+ <p className="gen-extracting-detail">
237
+ Extracting structured data for display.
238
+ </p>
239
+ </div>
240
+ </div>
241
+ );
242
+ }
243
+
244
+ if (error) {
245
+ return (
246
+ <div className="gen-conditions-tab">
247
+ <div className="gen-extraction-error">
248
+ <p>Could not extract structured data: {error}</p>
249
+ <p className="gen-extraction-fallback">
250
+ Try refreshing the page or running the analysis again.
251
+ </p>
252
+ </div>
253
+ </div>
254
+ );
255
+ }
256
+
257
+ if (!workingData) {
258
+ return <p className="gen-empty">No data available yet.</p>;
259
+ }
260
+
261
+ // Check if synthetic_judgment is already included as an accordion section
262
+ const hasSyntheticSection = sections.some(s => s.key === 'synthetic_judgment');
263
+ // Check if counterfactual_analysis is handled (either as 'counterfactual_analysis' or legacy 'counterfactuals')
264
+ const hasCounterfactualSection = sections.some(s => s.key === 'counterfactual_analysis' || s.key === 'counterfactuals');
265
+
266
+ return (
267
+ <div className="gen-conditions-tab">
268
+ {isProseMode && (
269
+ <div className="gen-prose-mode-badge">
270
+ Schema-on-read: extracted from analytical prose
271
+ </div>
272
+ )}
273
+
274
+ {sections.map(section => {
275
+ // Resolve section data — with backward-compat fallback for renamed keys
276
+ let sectionData = workingData[section.key];
277
+ if (!sectionData && section.key === 'counterfactuals') {
278
+ sectionData = workingData.counterfactual_analysis;
279
+ }
280
+ if (!sectionData) return null;
281
+
282
+ const isExpanded = expandedSections.has(section.key);
283
+ const hasEverExpanded = everExpanded.has(section.key);
284
+ const polishState = sectionPolishState?.[section.key] || 'idle';
285
+ const hasOverride = !!sectionOverrides?.[section.key];
286
+ const isFeedbackOpen = feedbackOpen.has(section.key);
287
+
288
+ // Per-section style overrides take precedence over view-level
289
+ const effectiveSO = sectionOverrides?.[section.key]?.style_overrides || styleOverrides;
290
+
291
+ // Section description from polish or config
292
+ const description = sectionDescriptions?.[section.key];
293
+
294
+ // Preview text for collapsed state
295
+ const previewText = !isExpanded ? extractPreviewText(sectionData) : '';
296
+
297
+ // Accent color for border and badge
298
+ const accentColor = effectiveSO?.accent_color;
299
+
300
+ return (
301
+ <div
302
+ key={section.key}
303
+ id={`section-${section.key}`}
304
+ className={`gen-accordion-section ${isExpanded ? 'gen-accordion-section--expanded' : ''}`}
305
+ >
306
+ {/* Section Header */}
307
+ <div
308
+ className="gen-accordion-header"
309
+ onClick={() => toggleSection(section.key)}
310
+ style={{
311
+ ...(effectiveSO?.section_header || {}),
312
+ borderLeftColor: accentColor || undefined,
313
+ }}
314
+ >
315
+ <div className="gen-accordion-header-row">
316
+ <span className={`gen-accordion-chevron ${isExpanded ? 'gen-accordion-chevron--open' : ''}`}>
317
+ &#x25B8;
318
+ </span>
319
+
320
+ <span
321
+ className="gen-accordion-title"
322
+ style={effectiveSO?.section_title || undefined}
323
+ >
324
+ {section.title}
325
+ </span>
326
+
327
+ {/* Item count badge for arrays */}
328
+ {Array.isArray(sectionData) && (
329
+ <span
330
+ className="gen-accordion-count"
331
+ style={accentColor ? { backgroundColor: accentColor, color: '#fff' } : undefined}
332
+ >
333
+ {sectionData.length}
334
+ </span>
335
+ )}
336
+
337
+ {/* Provenance section icon */}
338
+ {provenanceEnabled && (
339
+ <ProvenanceSectionIcon
340
+ sectionKey={section.key}
341
+ config={(config.section_renderers as Record<string, unknown> | undefined)?.[section.key] as any}
342
+ children_payloads={provenanceChildren}
343
+ />
344
+ )}
345
+
346
+ {/* Capture status dots — always visible */}
347
+ {(() => {
348
+ const statusKey = `${captureViewKey || ''}::${section.key}`;
349
+ const statuses = captureStatusMap?.[statusKey];
350
+ if (!statuses?.length) return null;
351
+ const hasArsenal = statuses.some(s => s.destination === 'arsenal');
352
+ const hasResearchAnswered = statuses.some(s => s.destination === 'research_todo' && s.has_answer);
353
+ const hasResearchPending = statuses.some(s => s.destination === 'research_todo' && !s.has_answer);
354
+ return (
355
+ <span className="capture-status-dots" onClick={e => e.stopPropagation()}>
356
+ {hasArsenal && <span className="capture-status-dot capture-status-dot--arsenal" title="Sent to Arsenal" />}
357
+ {hasResearchAnswered && <span className="capture-status-dot capture-status-dot--answered" title="Research answered" />}
358
+ {hasResearchPending && <span className="capture-status-dot capture-status-dot--research" title="Research question pending" />}
359
+ </span>
360
+ );
361
+ })()}
362
+
363
+ {/* Capture button — shown only in capture mode */}
364
+ {captureMode && onCapture && (
365
+ <button
366
+ className="section-capture-btn"
367
+ title="Capture this section"
368
+ onClick={e => {
369
+ e.stopPropagation();
370
+ onCapture({
371
+ source_view_key: captureViewKey || '',
372
+ source_section_key: section.key,
373
+ source_renderer_type: 'accordion',
374
+ content_type: 'section',
375
+ selected_text: previewText || extractPreviewText(sectionData) || section.title || section.key,
376
+ structured_data: sectionData,
377
+ context_title: `${captureViewKey || 'Analysis'} > ${section.title || section.key}`,
378
+ source_type: (captureSourceType || 'analysis') as string,
379
+ entity_id: captureEntityId || captureJobId || '',
380
+ depth_level: 'L1_section',
381
+ });
382
+ }}
383
+ style={{
384
+ marginLeft: onPolishSection ? '0' : 'auto',
385
+ background: 'none',
386
+ border: '1px solid #475569',
387
+ borderRadius: '4px',
388
+ color: '#94a3b8',
389
+ cursor: 'pointer',
390
+ padding: '2px 6px',
391
+ fontSize: '0.75rem',
392
+ lineHeight: 1,
393
+ }}
394
+ >
395
+ &#x1F4CC;
396
+ </button>
397
+ )}
398
+
399
+ {/* Per-section polish controls — right side of header */}
400
+ {onPolishSection && (
401
+ <span
402
+ className="section-polish-controls"
403
+ onClick={e => e.stopPropagation()}
404
+ style={{
405
+ marginLeft: 'auto',
406
+ display: 'inline-flex',
407
+ alignItems: 'center',
408
+ gap: '0.375rem',
409
+ }}
410
+ >
411
+ {/* Polishing spinner */}
412
+ {polishState === 'polishing' && (
413
+ <span className="section-polish-spinner" title="Polishing..." />
414
+ )}
415
+
416
+ {/* Polished checkmark */}
417
+ {hasOverride && polishState !== 'polishing' && (
418
+ <span
419
+ style={{ color: getSemanticColor('severity', 'low')?.text || '#16a34a', fontSize: '0.85rem', cursor: 'default' }}
420
+ title="Section polished"
421
+ >
422
+ &#10003;
423
+ </span>
424
+ )}
425
+
426
+ {/* Reset link */}
427
+ {hasOverride && onResetSection && (
428
+ <button
429
+ className="section-polish-btn section-polish-reset"
430
+ onClick={() => onResetSection(section.key)}
431
+ title="Reset section polish"
432
+ >
433
+ Reset
434
+ </button>
435
+ )}
436
+
437
+ {/* Pencil button to open feedback row */}
438
+ {!hasOverride && polishState !== 'polishing' && (
439
+ <button
440
+ className="section-polish-btn section-polish-pencil"
441
+ onClick={() => toggleFeedback(section.key)}
442
+ title="Polish this section"
443
+ >
444
+ &#9998;
445
+ </button>
446
+ )}
447
+
448
+ {/* Error indicator */}
449
+ {polishState === 'error' && (
450
+ <span style={{ color: getSemanticColor('severity', 'high')?.text || '#dc2626', fontSize: '0.72rem' }}>failed</span>
451
+ )}
452
+ </span>
453
+ )}
454
+ </div>
455
+
456
+ {/* Section description (from polish or config) */}
457
+ {description && (
458
+ <div
459
+ className="gen-accordion-description"
460
+ style={effectiveSO?.section_description || undefined}
461
+ >
462
+ {description}
463
+ </div>
464
+ )}
465
+
466
+ {/* Preview text when collapsed */}
467
+ {!isExpanded && previewText && (
468
+ <div className="gen-accordion-preview">
469
+ {previewText}
470
+ </div>
471
+ )}
472
+ </div>
473
+
474
+ {/* Feedback row — collapsible input below header */}
475
+ {isFeedbackOpen && !hasOverride && polishState !== 'polishing' && (
476
+ <div className="section-polish-feedback-row">
477
+ <input
478
+ type="text"
479
+ className="section-polish-feedback-input"
480
+ placeholder="Optional: describe what to improve..."
481
+ value={feedbackText[section.key] || ''}
482
+ onChange={e =>
483
+ setFeedbackText(prev => ({ ...prev, [section.key]: e.target.value }))
484
+ }
485
+ onKeyDown={e => {
486
+ if (e.key === 'Enter') handlePolishClick(section.key);
487
+ }}
488
+ />
489
+ <button
490
+ className="section-polish-btn section-polish-go"
491
+ onClick={() => handlePolishClick(section.key)}
492
+ >
493
+ Polish
494
+ </button>
495
+ <button
496
+ className="section-polish-btn section-polish-cancel"
497
+ onClick={() => toggleFeedback(section.key)}
498
+ >
499
+ &times;
500
+ </button>
501
+ </div>
502
+ )}
503
+
504
+ {/* Collapsible content with smooth animation */}
505
+ <div className={`gen-section-collapse ${isExpanded ? 'gen-section-expanded' : ''}`}>
506
+ <div className="gen-section-collapse-inner">
507
+ {hasEverExpanded && (
508
+ <div className="gen-section-content" style={effectiveSO?.section_content || undefined}>
509
+ {(() => {
510
+ // Sub-renderer dispatch with resilient fallback chain:
511
+ // 1. Try configured renderer (if compatible with data)
512
+ // 2. If incompatible or unresolved, try nested_sections
513
+ // 3. Auto-detect renderer based on data shape
514
+ // 4. GenericSectionRenderer as final fallback
515
+
516
+ // Forward capture config so sub-renderers (CardGrid, Card, etc.) show capture buttons
517
+ const captureForward = {
518
+ _captureMode: captureMode,
519
+ _onCapture: onCapture,
520
+ _captureJobId: captureJobId,
521
+ _captureViewKey: captureViewKey,
522
+ _parentSectionKey: section.key,
523
+ _parentSectionTitle: section.title,
524
+ };
525
+
526
+ const sectionHints = config.section_renderers as Record<string, { renderer_type: string; config?: Record<string, unknown>; sub_renderers?: Record<string, { renderer_type: string; config?: Record<string, unknown> }> }> | undefined;
527
+ const hint = sectionHints?.[section.key];
528
+ if (hint) {
529
+ const SectionRenderer = resolveSubRenderer(hint.renderer_type);
530
+ const subConfig = { ...(hint.config || {}), _style_overrides: effectiveSO, ...captureForward };
531
+
532
+ if (SectionRenderer) {
533
+ // Pre-render compatibility check: skip renderer if
534
+ // data type doesn't match (e.g. chip_grid given a string)
535
+ if (!isRendererCompatible(hint.renderer_type, sectionData, hint.config)) {
536
+ console.warn(
537
+ `[AccordionRenderer] Configured '${hint.renderer_type}' incompatible with ${Array.isArray(sectionData) ? 'array' : typeof sectionData} data for section '${section.key}' — falling through to auto-detection`
538
+ );
539
+ } else {
540
+ // Wrap in SubRendererFallback for defense-in-depth:
541
+ // catches cases where data type matches but content
542
+ // still causes null (e.g. empty array, wrong item shape)
543
+ return (
544
+ <SubRendererFallback
545
+ Renderer={SectionRenderer}
546
+ data={sectionData}
547
+ config={subConfig}
548
+ sectionKey={section.key}
549
+ />
550
+ );
551
+ }
552
+ }
553
+
554
+ // nested_sections: pass sub_renderers to generic
555
+ if (hint.sub_renderers) {
556
+ return <GenericSectionRenderer data={sectionData} subRenderers={hint.sub_renderers} />;
557
+ }
558
+ }
559
+
560
+ // Auto-detect the best sub-renderer based on data shape
561
+ const autoRenderer = autoDetectSubRenderer(sectionData);
562
+ if (autoRenderer) {
563
+ const AutoComp = resolveSubRenderer(autoRenderer);
564
+ if (AutoComp) {
565
+ return <AutoComp data={sectionData} config={{ _style_overrides: effectiveSO, ...captureForward }} />;
566
+ }
567
+ }
568
+
569
+ // Final fallback: generic renderer handles any data shape
570
+ return <GenericSectionRenderer data={sectionData} />;
571
+ })()}
572
+ </div>
573
+ )}
574
+ </div>
575
+ </div>
576
+ </div>
577
+ );
578
+ })}
579
+
580
+ {/* Synthetic judgment — always visible if present AND not already an accordion section */}
581
+ {!hasSyntheticSection && workingData.synthetic_judgment ? (
582
+ <div className="gen-synthetic-judgment">
583
+ <h3>Synthetic Judgment</h3>
584
+ <div className="gen-judgment-text">
585
+ {String(workingData.synthetic_judgment).split('\n').map((p, i) => (
586
+ <p key={i}>{p}</p>
587
+ ))}
588
+ </div>
589
+ </div>
590
+ ) : null}
591
+
592
+ {/* Counterfactual analysis — standalone if not already in sections config */}
593
+ {!hasCounterfactualSection && workingData.counterfactual_analysis ? (
594
+ <div className="gen-counterfactual">
595
+ <h3>Counterfactual Analysis</h3>
596
+ <p className="gen-section-desc">
597
+ What the argument would look like without the author's prior work
598
+ </p>
599
+ <div className="gen-counterfactual-text">
600
+ {String(workingData.counterfactual_analysis).split('\n').map((p, i) => (
601
+ <p key={i}>{p}</p>
602
+ ))}
603
+ </div>
604
+ </div>
605
+ ) : null}
606
+ </div>
607
+ );
608
+ }
609
+