@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,182 @@
1
+ /**
2
+ * StatSummaryRenderer — Key statistics display with optional prose section.
3
+ *
4
+ * renderer_config keys:
5
+ * stats: (string | {key, label, format?})[] — fields to show as stat cards
6
+ * prose_section: string — key of a longer narrative field
7
+ * layout: "stats_above_prose" | "prose_above_stats" (default: stats_above_prose)
8
+ * prose_endpoint: string — for useProseExtraction
9
+ */
10
+
11
+ import React, { useMemo } from 'react';
12
+ import { RendererProps } from '../types';
13
+ import { useProseExtraction } from '../hooks/useProseExtraction';
14
+
15
+ interface StatDef {
16
+ key: string;
17
+ label: string;
18
+ format?: 'text' | 'list' | 'number' | 'badge';
19
+ }
20
+
21
+ function renderStatValue(value: unknown): React.ReactNode {
22
+ if (value == null) {
23
+ return <span style={{ color: 'var(--dt-text-faint)', fontStyle: 'italic' }}>Not available</span>;
24
+ }
25
+
26
+ if (Array.isArray(value)) {
27
+ return (
28
+ <div style={{ display: 'flex', flexWrap: 'wrap' as const, gap: '4px', marginTop: '4px' }}>
29
+ {value.map((item, i) => (
30
+ <span
31
+ key={i}
32
+ style={{
33
+ display: 'inline-block',
34
+ padding: '2px 8px',
35
+ borderRadius: '4px',
36
+ fontSize: '12px',
37
+ fontWeight: 500,
38
+ background: 'var(--dt-page-accent-bg, rgba(181, 52, 58, 0.08))',
39
+ color: 'var(--dt-page-accent, #b5343a)',
40
+ border: '1px solid var(--dt-page-accent-border, rgba(181, 52, 58, 0.2))',
41
+ }}
42
+ >
43
+ {String(item).replace(/_/g, ' ')}
44
+ </span>
45
+ ))}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ if (typeof value === 'number') {
51
+ return <span style={{ fontSize: '18px', fontWeight: 600, color: 'var(--dt-text-default)' }}>{value}</span>;
52
+ }
53
+
54
+ return (
55
+ <div style={{ fontSize: '13px', color: 'var(--dt-text-default)', lineHeight: '1.5', marginTop: '2px' }}>
56
+ {String(value)}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ export function StatSummaryRenderer({ data, config }: RendererProps) {
62
+ const rawStats = config.stats as (string | StatDef)[] | undefined;
63
+ const proseSection = config.prose_section as string | undefined;
64
+ const layout = (config.layout as string) || 'stats_above_prose';
65
+ const proseEndpoint = config.prose_endpoint as string | undefined;
66
+
67
+ const { data: extractedData, loading, error, isProseMode } = useProseExtraction<unknown>(
68
+ data as unknown,
69
+ config._jobId as string | undefined,
70
+ proseEndpoint || 'data',
71
+ { apiPathPrefix: config._apiPathPrefix as string | undefined }
72
+ );
73
+
74
+ const workingData = (isProseMode ? extractedData : data) as Record<string, unknown> | null;
75
+
76
+ const stats: StatDef[] = useMemo(() => {
77
+ if (!rawStats) return [];
78
+ return rawStats.map(s =>
79
+ typeof s === 'string' ? { key: s, label: s.replace(/_/g, ' ') } : s
80
+ );
81
+ }, [rawStats]);
82
+
83
+ if (loading) {
84
+ return (
85
+ <div style={{ padding: '2rem', textAlign: 'center' as const }}>
86
+ <div className="gen-extracting-spinner" />
87
+ <p>Loading summary data...</p>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ if (error) {
93
+ return (
94
+ <div className="gen-extraction-error" style={{ padding: '1rem' }}>
95
+ <p>Could not load summary: {error}</p>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ if (!workingData) {
101
+ return <p className="gen-empty">No summary data available.</p>;
102
+ }
103
+
104
+ // Auto-detect stats from data keys if none configured
105
+ const effectiveStats = stats.length > 0
106
+ ? stats
107
+ : Object.keys(workingData)
108
+ .filter(k => !k.startsWith('_') && k !== proseSection)
109
+ .map(k => ({ key: k, label: k.replace(/_/g, ' ') }));
110
+
111
+ const proseText = proseSection ? (workingData[proseSection] as string | undefined) : undefined;
112
+
113
+ const statsBlock = effectiveStats.length > 0 ? (
114
+ <div style={{
115
+ display: 'grid',
116
+ gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
117
+ gap: '12px',
118
+ marginBottom: proseText ? '16px' : '0',
119
+ }}>
120
+ {effectiveStats.map(stat => {
121
+ const value = workingData[stat.key];
122
+ if (value == null) return null;
123
+ return (
124
+ <div
125
+ key={stat.key}
126
+ style={{
127
+ background: 'var(--color-surface-elev, #f5f3f0)',
128
+ border: '1px solid var(--color-border, #e2e5e9)',
129
+ borderRadius: '8px',
130
+ padding: '12px 14px',
131
+ }}
132
+ >
133
+ <div style={{
134
+ fontSize: '11px',
135
+ fontWeight: 600,
136
+ color: 'var(--dt-text-faint)',
137
+ textTransform: 'uppercase' as const,
138
+ letterSpacing: '0.05em',
139
+ marginBottom: '4px',
140
+ }}>
141
+ {stat.label}
142
+ </div>
143
+ {renderStatValue(value)}
144
+ </div>
145
+ );
146
+ })}
147
+ </div>
148
+ ) : null;
149
+
150
+ const proseBlock = proseText ? (
151
+ <div style={{
152
+ background: 'var(--color-surface-elev, #f5f3f0)',
153
+ border: '1px solid var(--color-border, #e2e5e9)',
154
+ borderRadius: '8px',
155
+ padding: '16px',
156
+ }}>
157
+ {String(proseText).split('\n').map((p, i) =>
158
+ p.trim() ? (
159
+ <p key={i} style={{ fontSize: '13px', color: 'var(--dt-text-muted)', lineHeight: '1.6', margin: '0 0 8px 0' }}>
160
+ {p}
161
+ </p>
162
+ ) : null
163
+ )}
164
+ </div>
165
+ ) : null;
166
+
167
+ return (
168
+ <div className="gen-stat-summary-renderer">
169
+ {isProseMode ? (
170
+ <div className="gen-prose-badge">
171
+ <span className="gen-prose-indicator">Extracted from analytical prose</span>
172
+ </div>
173
+ ) : null}
174
+
175
+ {layout === 'prose_above_stats' ? (
176
+ <>{proseBlock}{statsBlock}</>
177
+ ) : (
178
+ <>{statsBlock}{proseBlock}</>
179
+ )}
180
+ </div>
181
+ );
182
+ }
@@ -0,0 +1,470 @@
1
+ /**
2
+ * TableRenderer — Multi-table display with LLM-driven table design.
3
+ *
4
+ * When receiving prose-mode data, requests table-format extraction from the LLM.
5
+ * The LLM decides how many tables to create, what dimensions/columns to use,
6
+ * and what data to put in rows — producing 3-5 meaningful analytical tables.
7
+ *
8
+ * Also supports config-driven single-table mode for structured data.
9
+ *
10
+ * renderer_config keys:
11
+ * columns: (string | {key, label, width?})[] — column definitions (single-table mode)
12
+ * sortable: boolean — click column headers to sort (default: true)
13
+ * filterable: boolean — text filter input above tables
14
+ * items_path: string — dotted path to extract array from data
15
+ * prose_endpoint: string — base section name (auto-appends _table)
16
+ */
17
+
18
+ import React, { useState, useMemo } from 'react';
19
+ import { RendererProps } from '../types';
20
+ import { useProseExtraction } from '../hooks/useProseExtraction';
21
+
22
+ interface ColumnDef {
23
+ key: string;
24
+ label: string;
25
+ width?: string;
26
+ }
27
+
28
+ interface TableDef {
29
+ title: string;
30
+ description?: string;
31
+ columns: ColumnDef[];
32
+ rows: Record<string, unknown>[];
33
+ }
34
+
35
+ interface MultiTableData {
36
+ tables: TableDef[];
37
+ summary_note?: string;
38
+ }
39
+
40
+ function isMultiTableData(data: unknown): data is MultiTableData {
41
+ return (
42
+ data != null &&
43
+ typeof data === 'object' &&
44
+ Array.isArray((data as MultiTableData).tables) &&
45
+ (data as MultiTableData).tables.length > 0
46
+ );
47
+ }
48
+
49
+ function normalizeToArray(data: unknown): Array<Record<string, unknown>> {
50
+ if (Array.isArray(data)) return data;
51
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
52
+ return Object.entries(data as Record<string, unknown>).map(([key, value]) => {
53
+ if (value && typeof value === 'object') {
54
+ return { _key: key, ...(value as Record<string, unknown>) };
55
+ }
56
+ return { _key: key, value };
57
+ });
58
+ }
59
+ return [];
60
+ }
61
+
62
+ function getPath(obj: unknown, path: string): unknown {
63
+ if (!path) return obj;
64
+ const parts = path.split('.');
65
+ let current: unknown = obj;
66
+ for (const part of parts) {
67
+ if (current == null || typeof current !== 'object') return undefined;
68
+ current = (current as Record<string, unknown>)[part];
69
+ }
70
+ return current;
71
+ }
72
+
73
+ function formatCellValue(value: unknown): string {
74
+ if (value == null) return '\u2014';
75
+ if (typeof value === 'number') return String(value);
76
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
77
+ if (Array.isArray(value)) return value.map(String).join(', ');
78
+ if (typeof value === 'object') return JSON.stringify(value);
79
+ return String(value);
80
+ }
81
+
82
+ // ── Single Table Component ──────────────────────────────
83
+ function SingleTable({
84
+ table,
85
+ sortable,
86
+ tableIndex,
87
+ }: {
88
+ table: TableDef;
89
+ sortable: boolean;
90
+ tableIndex: number;
91
+ }) {
92
+ const [sortCol, setSortCol] = useState<string | null>(null);
93
+ const [sortAsc, setSortAsc] = useState(true);
94
+
95
+ const columns: ColumnDef[] = useMemo(() => {
96
+ if (table.columns && table.columns.length > 0) {
97
+ return table.columns.map(col =>
98
+ typeof col === 'string'
99
+ ? { key: col, label: (col as string).replace(/_/g, ' ') }
100
+ : col
101
+ );
102
+ }
103
+ if (table.rows.length === 0) return [];
104
+ return Object.keys(table.rows[0])
105
+ .filter(k => !k.startsWith('_'))
106
+ .map(k => ({ key: k, label: k.replace(/_/g, ' ') }));
107
+ }, [table.columns, table.rows]);
108
+
109
+ const sortedRows = useMemo(() => {
110
+ if (!sortable || !sortCol) return table.rows;
111
+ return [...table.rows].sort((a, b) => {
112
+ const aVal = a[sortCol] ?? '';
113
+ const bVal = b[sortCol] ?? '';
114
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
115
+ return sortAsc ? aVal - bVal : bVal - aVal;
116
+ }
117
+ const cmp = String(aVal).localeCompare(String(bVal));
118
+ return sortAsc ? cmp : -cmp;
119
+ });
120
+ }, [table.rows, sortCol, sortAsc, sortable]);
121
+
122
+ const handleSort = (colKey: string) => {
123
+ if (!sortable) return;
124
+ if (sortCol === colKey) {
125
+ setSortAsc(!sortAsc);
126
+ } else {
127
+ setSortCol(colKey);
128
+ setSortAsc(true);
129
+ }
130
+ };
131
+
132
+ if (columns.length === 0 || table.rows.length === 0) return null;
133
+
134
+ // Alternate subtle row tints per table
135
+ const accentHues = [0, 210, 35, 280, 150]; // red, blue, amber, purple, teal
136
+ const hue = accentHues[tableIndex % accentHues.length];
137
+
138
+ return (
139
+ <div style={{
140
+ background: 'var(--color-surface-elev, #f5f3f0)',
141
+ border: '1px solid var(--color-border, #e2e5e9)',
142
+ borderRadius: '10px',
143
+ overflow: 'hidden',
144
+ }}>
145
+ {/* Table header */}
146
+ <div style={{
147
+ padding: '14px 18px 10px',
148
+ borderBottom: '1px solid var(--color-border, #e2e5e9)',
149
+ }}>
150
+ <h4 style={{
151
+ margin: '0 0 2px 0',
152
+ fontSize: '14px',
153
+ fontWeight: 700,
154
+ color: 'var(--dt-text-default)',
155
+ letterSpacing: '-0.01em',
156
+ }}>
157
+ {table.title}
158
+ </h4>
159
+ {table.description ? (
160
+ <p style={{
161
+ margin: 0,
162
+ fontSize: '12px',
163
+ color: 'var(--dt-text-muted)',
164
+ lineHeight: '1.4',
165
+ }}>
166
+ {table.description}
167
+ </p>
168
+ ) : null}
169
+ </div>
170
+
171
+ {/* Table content */}
172
+ <div style={{ overflowX: 'auto' as const }}>
173
+ <table style={{
174
+ width: '100%',
175
+ borderCollapse: 'collapse' as const,
176
+ fontSize: '13px',
177
+ }}>
178
+ <thead>
179
+ <tr>
180
+ {columns.map(col => (
181
+ <th
182
+ key={col.key}
183
+ onClick={() => handleSort(col.key)}
184
+ style={{
185
+ textAlign: 'left' as const,
186
+ padding: '10px 14px',
187
+ borderBottom: `2px solid hsla(${hue}, 40%, 50%, 0.25)`,
188
+ color: 'var(--dt-text-muted)',
189
+ fontSize: '11px',
190
+ fontWeight: 700,
191
+ textTransform: 'uppercase' as const,
192
+ letterSpacing: '0.06em',
193
+ cursor: sortable ? 'pointer' : 'default',
194
+ userSelect: 'none' as const,
195
+ whiteSpace: 'nowrap' as const,
196
+ width: col.width || 'auto',
197
+ background: `hsla(${hue}, 30%, 50%, 0.04)`,
198
+ transition: 'background 0.15s',
199
+ }}
200
+ >
201
+ {col.label}
202
+ {sortable && sortCol === col.key ? (
203
+ <span style={{ marginLeft: '4px', fontSize: '9px' }}>
204
+ {sortAsc ? '\u25B2' : '\u25BC'}
205
+ </span>
206
+ ) : sortable ? (
207
+ <span style={{ marginLeft: '4px', fontSize: '9px', opacity: 0.3 }}>
208
+ {'\u25B2'}
209
+ </span>
210
+ ) : null}
211
+ </th>
212
+ ))}
213
+ </tr>
214
+ </thead>
215
+ <tbody>
216
+ {sortedRows.map((row, idx) => (
217
+ <tr
218
+ key={idx}
219
+ style={{
220
+ borderBottom: idx < sortedRows.length - 1
221
+ ? '1px solid var(--color-border, #e2e5e9)'
222
+ : 'none',
223
+ background: idx % 2 === 1
224
+ ? `hsla(${hue}, 20%, 50%, 0.03)`
225
+ : 'transparent',
226
+ }}
227
+ >
228
+ {columns.map((col, colIdx) => (
229
+ <td
230
+ key={col.key}
231
+ style={{
232
+ padding: '10px 14px',
233
+ color: colIdx === 0 ? 'var(--dt-text-default)' : 'var(--dt-text-muted)',
234
+ fontWeight: colIdx === 0 ? 600 : 400,
235
+ lineHeight: '1.45',
236
+ verticalAlign: 'top' as const,
237
+ maxWidth: '400px',
238
+ }}
239
+ >
240
+ {formatCellValue(row[col.key])}
241
+ </td>
242
+ ))}
243
+ </tr>
244
+ ))}
245
+ </tbody>
246
+ </table>
247
+ </div>
248
+
249
+ {/* Row count */}
250
+ <div style={{
251
+ padding: '6px 14px',
252
+ fontSize: '11px',
253
+ color: 'var(--dt-text-faint)',
254
+ textAlign: 'right' as const,
255
+ borderTop: '1px solid var(--color-border, #e2e5e9)',
256
+ background: `hsla(${hue}, 20%, 50%, 0.02)`,
257
+ }}>
258
+ {table.rows.length} row{table.rows.length !== 1 ? 's' : ''}
259
+ </div>
260
+ </div>
261
+ );
262
+ }
263
+
264
+ // ── Main TableRenderer ──────────────────────────────────
265
+ export function TableRenderer({ data, config }: RendererProps) {
266
+ const rawColumns = config.columns as (string | ColumnDef)[] | undefined;
267
+ const sortable = (config.sortable as boolean) ?? true;
268
+ const filterable = (config.filterable as boolean) ?? false;
269
+ const itemsPath = config.items_path as string | undefined;
270
+ const proseEndpoint = config.prose_endpoint as string | undefined;
271
+
272
+ // For table rendering, use the _table variant of the prose endpoint
273
+ // so the LLM produces multi-table structured output
274
+ const tableEndpoint = proseEndpoint ? `${proseEndpoint}_table` : 'data';
275
+
276
+ const { data: extractedData, loading, error, isProseMode } = useProseExtraction<unknown>(
277
+ data as unknown,
278
+ config._jobId as string | undefined,
279
+ tableEndpoint,
280
+ { apiPathPrefix: config._apiPathPrefix as string | undefined }
281
+ );
282
+
283
+ const workingData = isProseMode ? extractedData : data;
284
+
285
+ const [filterText, setFilterText] = useState('');
286
+
287
+ // ── Multi-table mode (LLM-generated tables) ──
288
+ if (loading) {
289
+ return (
290
+ <div style={{ padding: '2rem', textAlign: 'center' as const }}>
291
+ <div className="gen-extracting-spinner" />
292
+ <p style={{ color: 'var(--dt-text-muted)', fontSize: '13px' }}>
293
+ Designing analytical tables...
294
+ </p>
295
+ </div>
296
+ );
297
+ }
298
+
299
+ if (error) {
300
+ return (
301
+ <div className="gen-extraction-error" style={{ padding: '1rem' }}>
302
+ <p>Could not load table data: {error}</p>
303
+ </div>
304
+ );
305
+ }
306
+
307
+ if (!workingData) {
308
+ return <p className="gen-empty">No data available for table display.</p>;
309
+ }
310
+
311
+ // Check if we got multi-table format from the LLM
312
+ if (isMultiTableData(workingData)) {
313
+ const { tables, summary_note } = workingData;
314
+
315
+ // Filter across all tables if filterable
316
+ const filteredTables = filterable && filterText.trim()
317
+ ? tables.map(t => ({
318
+ ...t,
319
+ rows: t.rows.filter(row =>
320
+ t.columns.some(col => {
321
+ const val = row[col.key];
322
+ return val != null && String(val).toLowerCase().includes(filterText.toLowerCase());
323
+ })
324
+ ),
325
+ })).filter(t => t.rows.length > 0)
326
+ : tables;
327
+
328
+ return (
329
+ <div className="gen-table-renderer">
330
+ {isProseMode ? (
331
+ <div className="gen-prose-badge">
332
+ <span className="gen-prose-indicator">Extracted from analytical prose</span>
333
+ </div>
334
+ ) : null}
335
+
336
+ {filterable ? (
337
+ <div style={{ marginBottom: '16px' }}>
338
+ <input
339
+ type="text"
340
+ placeholder="Filter across all tables..."
341
+ value={filterText}
342
+ onChange={e => setFilterText(e.target.value)}
343
+ style={{
344
+ width: '100%',
345
+ padding: '8px 12px',
346
+ border: '1px solid var(--color-border, #e2e5e9)',
347
+ borderRadius: '6px',
348
+ fontSize: '13px',
349
+ background: 'var(--color-surface-elev, #f5f3f0)',
350
+ color: 'var(--dt-text-default)',
351
+ boxSizing: 'border-box' as const,
352
+ }}
353
+ />
354
+ </div>
355
+ ) : null}
356
+
357
+ <div style={{ display: 'flex', flexDirection: 'column' as const, gap: '20px' }}>
358
+ {filteredTables.map((table, idx) => (
359
+ <SingleTable
360
+ key={idx}
361
+ table={table}
362
+ sortable={sortable}
363
+ tableIndex={idx}
364
+ />
365
+ ))}
366
+ </div>
367
+
368
+ {summary_note ? (
369
+ <div style={{
370
+ marginTop: '16px',
371
+ padding: '12px 16px',
372
+ background: 'var(--dt-surface-alt, rgba(100, 116, 139, 0.06))',
373
+ borderRadius: '8px',
374
+ borderLeft: '3px solid var(--dt-border-light, rgba(100, 116, 139, 0.25))',
375
+ }}>
376
+ <p style={{
377
+ margin: 0,
378
+ fontSize: '13px',
379
+ color: 'var(--dt-text-muted)',
380
+ lineHeight: '1.55',
381
+ fontStyle: 'italic' as const,
382
+ }}>
383
+ {summary_note}
384
+ </p>
385
+ </div>
386
+ ) : null}
387
+
388
+ <div style={{
389
+ marginTop: '8px',
390
+ fontSize: '11px',
391
+ color: 'var(--dt-text-faint)',
392
+ textAlign: 'right' as const,
393
+ }}>
394
+ {filteredTables.length} table{filteredTables.length !== 1 ? 's' : ''}
395
+ {' \u00B7 '}
396
+ {filteredTables.reduce((sum, t) => sum + t.rows.length, 0)} total rows
397
+ </div>
398
+ </div>
399
+ );
400
+ }
401
+
402
+ // ── Single-table fallback (config-driven for structured data) ──
403
+ const columns: ColumnDef[] = rawColumns
404
+ ? rawColumns.map(col =>
405
+ typeof col === 'string' ? { key: col, label: col.replace(/_/g, ' ') } : col
406
+ )
407
+ : [];
408
+
409
+ const extracted = itemsPath ? getPath(workingData, itemsPath) : workingData;
410
+ const rows = normalizeToArray(extracted);
411
+
412
+ if (rows.length === 0) {
413
+ return <p className="gen-empty">No data available for this table.</p>;
414
+ }
415
+
416
+ // Build a single TableDef and delegate to SingleTable
417
+ const autoColumns: ColumnDef[] = columns.length > 0
418
+ ? columns
419
+ : Object.keys(rows[0])
420
+ .filter(k => !k.startsWith('_'))
421
+ .map(k => ({ key: k, label: k.replace(/_/g, ' ') }));
422
+
423
+ const singleTable: TableDef = {
424
+ title: '',
425
+ columns: autoColumns,
426
+ rows,
427
+ };
428
+
429
+ return (
430
+ <div className="gen-table-renderer">
431
+ {filterable ? (
432
+ <div style={{ marginBottom: '12px' }}>
433
+ <input
434
+ type="text"
435
+ placeholder="Filter rows..."
436
+ value={filterText}
437
+ onChange={e => setFilterText(e.target.value)}
438
+ style={{
439
+ width: '100%',
440
+ padding: '6px 10px',
441
+ border: '1px solid var(--color-border, #e2e5e9)',
442
+ borderRadius: '4px',
443
+ fontSize: '13px',
444
+ background: 'var(--color-surface-elev, #f5f3f0)',
445
+ color: 'var(--dt-text-default)',
446
+ boxSizing: 'border-box' as const,
447
+ }}
448
+ />
449
+ </div>
450
+ ) : null}
451
+
452
+ <SingleTable
453
+ table={filterable && filterText.trim()
454
+ ? {
455
+ ...singleTable,
456
+ rows: singleTable.rows.filter(row =>
457
+ autoColumns.some(col => {
458
+ const val = row[col.key];
459
+ return val != null && String(val).toLowerCase().includes(filterText.toLowerCase());
460
+ })
461
+ ),
462
+ }
463
+ : singleTable
464
+ }
465
+ sortable={sortable}
466
+ tableIndex={0}
467
+ />
468
+ </div>
469
+ );
470
+ }