@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,608 @@
1
+ /**
2
+ * CardGridRenderer — Generic card grid with pluggable cell renderers.
3
+ *
4
+ * Layout shell that handles:
5
+ * - Data normalization (arrays stay as-is, Records are flattened)
6
+ * - Grouping by a configurable field
7
+ * - Summary bar with distribution counts
8
+ * - Variable card sizing with hero card pattern
9
+ * - Hover elevation and card type indicators
10
+ * - Style override consumption for polish integration
11
+ * - Expandable cards (optional)
12
+ *
13
+ * renderer_config keys:
14
+ * cell_renderer: string — key into cellRenderers registry
15
+ * group_by: string — field to group items by (optional)
16
+ * group_style_map: string — category name for getCategoryColor lookups
17
+ * group_descriptions: Record — optional per-group description text
18
+ * columns: number — grid columns (default: 2)
19
+ * expandable: boolean — cards expand on click (default: false)
20
+ * summary_fields: string[] — fields for summary bar above grid
21
+ */
22
+
23
+ import React, { useState, useMemo, useEffect, useRef } from 'react';
24
+ import { RendererProps } from '../types';
25
+ import { cellRenderers, DefaultCardCell } from '../cells';
26
+ import { useDesignTokens } from '../tokens/DesignTokenContext';
27
+ import { useProseExtraction } from '../hooks/useProseExtraction';
28
+ import { StyleOverrides, getSO } from '../types/styles';
29
+ type CaptureSelection = Record<string, unknown>;
30
+ import { DistributionSummary } from '../sub-renderers/SubRenderers';
31
+
32
+ // ── Content length estimation ─────────────────────────────
33
+
34
+ /** Estimate the text content length of an item for hero detection */
35
+ function estimateContentLength(item: Record<string, unknown>): number {
36
+ let total = 0;
37
+ for (const value of Object.values(item)) {
38
+ if (typeof value === 'string') total += value.length;
39
+ }
40
+ return total;
41
+ }
42
+
43
+ // ── Prose mode rendering helpers ─────────────────────────
44
+
45
+ function ProseLoadingState() {
46
+ return (
47
+ <div style={{ padding: '2rem', textAlign: 'center' as const }}>
48
+ <div className="gen-extracting-spinner" />
49
+ <p>Preparing structured view from analytical prose...</p>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ function ProseErrorState({ error }: { error: string }) {
55
+ return (
56
+ <div className="gen-extraction-error" style={{ padding: '1rem' }}>
57
+ <p>Could not extract structured data: {error}</p>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ // ── Data normalization ───────────────────────────────────
63
+
64
+ function normalizeToArray(data: unknown): Array<Record<string, unknown>> {
65
+ if (Array.isArray(data)) return data;
66
+
67
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
68
+ const obj = data as Record<string, unknown>;
69
+ const entries = Object.entries(obj);
70
+
71
+ // Detect pattern: object whose values are arrays of objects
72
+ // e.g. { enabling_conditions: [{...}], constraining_conditions: [{...}] }
73
+ // Flatten into a single array with _category tag for grouping
74
+ const arrayOfObjectEntries = entries.filter(
75
+ ([, v]) => Array.isArray(v) && v.length > 0 && v[0] != null && typeof v[0] === 'object'
76
+ );
77
+
78
+ if (arrayOfObjectEntries.length > 0) {
79
+ return arrayOfObjectEntries.flatMap(([key, arr]) =>
80
+ (arr as Record<string, unknown>[]).map(item => ({
81
+ _category: key,
82
+ ...item,
83
+ }))
84
+ );
85
+ }
86
+
87
+ // Fallback: Record<string, T> → flatten to array with docKey field
88
+ return entries.map(([key, value]) => {
89
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
90
+ return { docKey: key, ...value as Record<string, unknown> };
91
+ }
92
+ return { docKey: key, value };
93
+ });
94
+ }
95
+
96
+ return [];
97
+ }
98
+
99
+ // ── Grouping ─────────────────────────────────────────────
100
+
101
+ interface Group {
102
+ key: string;
103
+ label: string;
104
+ style: { bg: string; text: string; border: string; label?: string };
105
+ items: Array<Record<string, unknown>>;
106
+ }
107
+
108
+ function groupItems(
109
+ items: Array<Record<string, unknown>>,
110
+ groupBy: string | undefined,
111
+ styleMapKey: string | undefined,
112
+ getCategoryColor?: (category: string, key: string) => { bg: string; text: string; border: string; label?: string } | null,
113
+ ): Group[] {
114
+ if (!groupBy) {
115
+ return [{
116
+ key: '__all__',
117
+ label: '',
118
+ style: { bg: 'transparent', text: 'inherit', border: 'transparent' },
119
+ items,
120
+ }];
121
+ }
122
+
123
+ const category = styleMapKey || undefined;
124
+ const defaultStyle = { bg: 'var(--dt-surface-alt)', text: 'var(--dt-text-muted)', border: 'var(--dt-border-light)', label: undefined as string | undefined };
125
+
126
+ const grouped: Record<string, Array<Record<string, unknown>>> = {};
127
+ for (const item of items) {
128
+ const groupValue = String(item[groupBy] || 'unknown');
129
+ if (!grouped[groupValue]) grouped[groupValue] = [];
130
+ grouped[groupValue].push(item);
131
+ }
132
+
133
+ // Sort groups by count (most frequent first)
134
+ return Object.entries(grouped)
135
+ .sort((a, b) => b[1].length - a[1].length)
136
+ .map(([key, groupItems]) => {
137
+ const mapStyle = category && getCategoryColor ? getCategoryColor(category, key) : null;
138
+ // Format label: "enabling_conditions" → "Enabling Conditions"
139
+ const rawLabel = key.replace(/_/g, ' ');
140
+ const titleLabel = rawLabel.replace(/\b\w/g, c => c.toUpperCase());
141
+ return {
142
+ key,
143
+ label: mapStyle?.label || titleLabel,
144
+ style: mapStyle || defaultStyle,
145
+ items: groupItems,
146
+ };
147
+ });
148
+ }
149
+
150
+ // ── Main Component ───────────────────────────────────────
151
+
152
+ /**
153
+ * Navigate a dotted path into an object.
154
+ */
155
+ function getPath(obj: unknown, path: string): unknown {
156
+ if (!path) return obj;
157
+ const parts = path.split('.');
158
+ let current: unknown = obj;
159
+ for (const part of parts) {
160
+ if (current == null || typeof current !== 'object') return undefined;
161
+ current = (current as Record<string, unknown>)[part];
162
+ }
163
+ return current;
164
+ }
165
+
166
+ export function CardGridRenderer({ data, config }: RendererProps) {
167
+ const { getCategoryColor, getSemanticColor, getLabel } = useDesignTokens();
168
+
169
+ const cellRendererKey = config.cell_renderer as string | undefined;
170
+ const groupBy = config.group_by as string | undefined;
171
+ const groupStyleMap = config.group_style_map as string | undefined;
172
+ const expandable = config.expandable as boolean | undefined;
173
+ const itemsPath = config.items_path as string | undefined;
174
+ const proseEndpoint = config.prose_endpoint as string | undefined;
175
+
176
+ // Group scroll-to targeting (from URL ?group= param)
177
+ const targetGroup = config._targetGroup as string | undefined;
178
+ const onGroupConsumed = config._onGroupConsumed as (() => void) | undefined;
179
+
180
+ // Capture mode
181
+ const captureMode = config._captureMode as boolean | undefined;
182
+ const onCapture = config._onCapture as
183
+ | ((sel: Record<string, unknown>) => void)
184
+ | undefined;
185
+ const captureJobId = config._captureJobId as string | undefined;
186
+ const captureViewKey = config._captureViewKey as string | undefined;
187
+ const captureSourceType = config._captureSourceType as string | undefined;
188
+ const captureEntityId = config._captureEntityId as string | undefined;
189
+
190
+ const so = getSO(config);
191
+
192
+ const [activeFilter, setActiveFilter] = useState<string | null>(null);
193
+
194
+ // Prose extraction — detect if the data has _prose_output marker
195
+ const { data: extractedData, loading, error, isProseMode } = useProseExtraction<unknown>(
196
+ data as unknown,
197
+ config._jobId as string | undefined,
198
+ proseEndpoint || 'data',
199
+ { apiPathPrefix: config._apiPathPrefix as string | undefined }
200
+ );
201
+
202
+ const workingData = isProseMode ? extractedData : data;
203
+ const workingObj = workingData as Record<string, unknown> | undefined;
204
+
205
+ // Resolve cell renderer
206
+ const CellRenderer = cellRendererKey
207
+ ? cellRenderers[cellRendererKey] || DefaultCardCell
208
+ : DefaultCardCell;
209
+
210
+ // Extract items array from the data using items_path
211
+ const rawItems = useMemo(() => {
212
+ if (!workingData) return [];
213
+ const extracted = itemsPath ? getPath(workingData, itemsPath) : workingData;
214
+ return normalizeToArray(extracted);
215
+ }, [workingData, itemsPath]);
216
+
217
+ // Auto-detect group_by: if items have _category (from flattening), group by it
218
+ const effectiveGroupBy = groupBy || (
219
+ rawItems.length > 0 && rawItems[0]._category ? '_category' : undefined
220
+ );
221
+
222
+ // Group items
223
+ const groups = useMemo(
224
+ () => groupItems(rawItems, effectiveGroupBy, groupStyleMap, getCategoryColor),
225
+ [rawItems, effectiveGroupBy, groupStyleMap, getCategoryColor]
226
+ );
227
+
228
+ // Apply active filter
229
+ const visibleGroups = activeFilter
230
+ ? groups.filter(g => g.key === activeFilter)
231
+ : groups;
232
+
233
+ if (loading) return <ProseLoadingState />;
234
+ if (error) return <ProseErrorState error={error} />;
235
+
236
+ if (!workingData) {
237
+ return <p className="gen-empty">No data available yet.</p>;
238
+ }
239
+
240
+ if (rawItems.length === 0) {
241
+ return <p className="gen-empty">No items found.</p>;
242
+ }
243
+
244
+ const hasGroups = groups.length > 1 || groups[0]?.key !== '__all__';
245
+
246
+ // Summary panel config — either from view definition or legacy tactic_patterns fallback
247
+ const summaryConfig = config.summary as {
248
+ data_path?: string;
249
+ renderer_config?: Record<string, unknown>;
250
+ } | undefined;
251
+ const summaryDataPath = summaryConfig?.data_path || 'distribution';
252
+ const summaryData = workingObj?.[summaryDataPath] as Record<string, unknown> | undefined;
253
+ const hasSummary = Boolean(summaryData);
254
+
255
+ const handleFilterClick = (groupKey: string) => {
256
+ if (!groupKey) { setActiveFilter(null); return; }
257
+ setActiveFilter(prev => prev === groupKey ? null : groupKey);
258
+ };
259
+
260
+ return (
261
+ <div className="ar-card-grid" style={so?.view_wrapper || undefined}>
262
+ {/* Prose mode badge */}
263
+ {isProseMode && (
264
+ <div className="gen-prose-badge">
265
+ <span className="gen-prose-indicator">Extracted from analytical prose</span>
266
+ </div>
267
+ )}
268
+
269
+ {/* Distribution summary with integrated bar chart */}
270
+ {hasSummary && (
271
+ <DistributionSummary
272
+ data={summaryData}
273
+ config={{
274
+ ...summaryConfig?.renderer_config,
275
+ _onFilterClick: handleFilterClick,
276
+ _activeFilter: activeFilter,
277
+ _groups: hasGroups ? groups : undefined,
278
+ }}
279
+ />
280
+ )}
281
+
282
+ {/* Flat filter chips — only when no distribution summary (non-tactic views) */}
283
+ {hasGroups && !hasSummary && (
284
+ <div className="ar-grid-summary">
285
+ <span className="ar-grid-total">
286
+ {rawItems.length} item{rawItems.length !== 1 ? 's' : ''}
287
+ </span>
288
+ <div className="ar-grid-dist">
289
+ {groups.map(group => (
290
+ <button
291
+ key={group.key}
292
+ type="button"
293
+ className={`ar-grid-dist-tag ${activeFilter === group.key ? 'ar-grid-dist-tag--active' : ''}`}
294
+ style={{
295
+ background: activeFilter === group.key ? group.style.text : group.style.bg,
296
+ color: activeFilter === group.key ? 'var(--dt-text-inverse)' : group.style.text,
297
+ borderColor: group.style.border,
298
+ }}
299
+ onClick={() => handleFilterClick(group.key)}
300
+ >
301
+ {group.label}: {group.items.length}
302
+ </button>
303
+ ))}
304
+ {activeFilter && (
305
+ <button
306
+ type="button"
307
+ className="ar-grid-dist-clear"
308
+ onClick={() => setActiveFilter(null)}
309
+ >
310
+ Show all
311
+ </button>
312
+ )}
313
+ </div>
314
+ </div>
315
+ )}
316
+
317
+ {/* Grouped card grid */}
318
+ {visibleGroups.map(group => {
319
+ // Sort items within group: major first, then moderate, then minor
320
+ const severityWeight: Record<string, number> = { major: 3, moderate: 2, minor: 1 };
321
+ const sortedItems = [...group.items].sort((a, b) => {
322
+ const wa = severityWeight[String(a.severity || 'minor').toLowerCase()] || 0;
323
+ const wb = severityWeight[String(b.severity || 'minor').toLowerCase()] || 0;
324
+ return wb - wa;
325
+ });
326
+ const majorCount = sortedItems.filter(i => String(i.severity || '').toLowerCase() === 'major').length;
327
+ const description = (config.group_descriptions as Record<string, string> | undefined)?.[group.key];
328
+
329
+ return (
330
+ <GroupSection
331
+ key={group.key}
332
+ group={group}
333
+ sortedItems={sortedItems}
334
+ majorCount={majorCount}
335
+ description={description}
336
+ hasGroups={hasGroups}
337
+ config={config}
338
+ expandable={expandable}
339
+ CellRenderer={CellRenderer}
340
+ so={so}
341
+ isTarget={targetGroup === group.key}
342
+ onTargetConsumed={targetGroup === group.key ? onGroupConsumed : undefined}
343
+ />
344
+ );
345
+ })}
346
+ </div>
347
+ );
348
+ }
349
+
350
+ // ── Group Section ─────────────────────────────────────────
351
+
352
+ function GroupSection({
353
+ group,
354
+ sortedItems,
355
+ majorCount,
356
+ description,
357
+ hasGroups,
358
+ config,
359
+ expandable,
360
+ CellRenderer,
361
+ so,
362
+ isTarget,
363
+ onTargetConsumed,
364
+ }: {
365
+ group: Group;
366
+ sortedItems: Array<Record<string, unknown>>;
367
+ majorCount: number;
368
+ description: string | undefined;
369
+ hasGroups: boolean;
370
+ config: Record<string, unknown>;
371
+ expandable?: boolean;
372
+ CellRenderer: React.ComponentType<{ item: Record<string, unknown>; config: Record<string, unknown> }>;
373
+ so: StyleOverrides | undefined;
374
+ isTarget?: boolean;
375
+ onTargetConsumed?: () => void;
376
+ }) {
377
+ const groupRef = useRef<HTMLDivElement>(null);
378
+ const [highlight, setHighlight] = useState(false);
379
+
380
+ // Scroll-to + highlight when this is the target group
381
+ useEffect(() => {
382
+ if (!isTarget || !groupRef.current) return;
383
+ const el = groupRef.current;
384
+ // Small delay to ensure layout is settled
385
+ const timer = setTimeout(() => {
386
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
387
+ setHighlight(true);
388
+ // Remove highlight after animation
389
+ const fadeTimer = setTimeout(() => {
390
+ setHighlight(false);
391
+ onTargetConsumed?.();
392
+ }, 2000);
393
+ return () => clearTimeout(fadeTimer);
394
+ }, 300);
395
+ return () => clearTimeout(timer);
396
+ }, [isTarget, onTargetConsumed]);
397
+
398
+ // Derive subtle background tint from group color
399
+ const groupTintBg = hasGroups && group.style.text !== 'inherit'
400
+ ? `${group.style.text}06`
401
+ : undefined;
402
+
403
+ const groupStyle: React.CSSProperties | undefined = groupTintBg
404
+ ? {
405
+ background: groupTintBg,
406
+ borderRadius: 'var(--radius-lg, 12px)',
407
+ padding: 'var(--space-lg, 1.5rem)',
408
+ ...(highlight ? { outline: '2px solid var(--dt-accent, #6366f1)', outlineOffset: '2px', transition: 'outline-color 0.5s' } : {}),
409
+ }
410
+ : (highlight ? { outline: '2px solid var(--dt-accent, #6366f1)', outlineOffset: '2px', transition: 'outline-color 0.5s' } : undefined);
411
+
412
+ return (
413
+ <div
414
+ ref={groupRef}
415
+ id={`group-${group.key}`}
416
+ className={hasGroups ? 'ar-grid-group ar-grid-group--enhanced' : ''}
417
+ style={groupStyle}
418
+ >
419
+ {hasGroups && (
420
+ <div
421
+ className="ar-grid-group-header ar-grid-group-header--enhanced"
422
+ style={{ '--group-accent': group.style.text, '--group-border': group.style.border } as React.CSSProperties}
423
+ >
424
+ <div className="ar-grid-group-rule" style={{ background: `linear-gradient(to right, ${group.style.text}, ${group.style.border}40)` }} />
425
+ <h3 style={so?.section_title || undefined}>
426
+ <span
427
+ className="ar-grid-group-badge"
428
+ style={{ background: group.style.bg, color: group.style.text, borderColor: group.style.border }}
429
+ >
430
+ {group.label}
431
+ </span>
432
+ <span className="ar-grid-group-count">
433
+ {group.items.length} item{group.items.length !== 1 ? 's' : ''}
434
+ </span>
435
+ {majorCount > 0 && (
436
+ <span className="ar-grid-major-pill">{majorCount} major</span>
437
+ )}
438
+ </h3>
439
+ {description && (
440
+ <p className="ar-grid-group-desc" style={so?.section_description || undefined}>{description}</p>
441
+ )}
442
+ </div>
443
+ )}
444
+ <div className="ar-grid-cards ar-grid-cards--variable" style={so?.items_container || undefined}>
445
+ {sortedItems.map((item, idx) => {
446
+ const isHero = idx === 0 && sortedItems.length > 1;
447
+ const isLongContent = estimateContentLength(item) > 200;
448
+ const spanFull = isHero || isLongContent;
449
+ const isSingleCard = sortedItems.length === 1;
450
+
451
+ return (
452
+ <CardWrapper
453
+ key={String(item.id || item.docKey || idx)}
454
+ item={item}
455
+ config={config}
456
+ expandable={expandable}
457
+ CellRenderer={CellRenderer}
458
+ groupStyle={group.style}
459
+ isHero={spanFull}
460
+ isSingleCard={isSingleCard}
461
+ so={so}
462
+ />
463
+ );
464
+ })}
465
+ </div>
466
+ </div>
467
+ );
468
+ }
469
+
470
+ // ── Card Wrapper (handles expansion, hero sizing, type indicators) ──
471
+
472
+ function CardWrapper({
473
+ item,
474
+ config,
475
+ expandable,
476
+ CellRenderer,
477
+ groupStyle,
478
+ isHero,
479
+ isSingleCard,
480
+ so,
481
+ }: {
482
+ item: Record<string, unknown>;
483
+ config: Record<string, unknown>;
484
+ expandable?: boolean;
485
+ CellRenderer: React.ComponentType<{ item: Record<string, unknown>; config: Record<string, unknown> }>;
486
+ groupStyle: { text: string; bg?: string; border?: string };
487
+ isHero: boolean;
488
+ isSingleCard: boolean;
489
+ so: StyleOverrides | undefined;
490
+ }) {
491
+ const { getSemanticColor, getLabel } = useDesignTokens();
492
+ const [expanded, setExpanded] = useState(false);
493
+
494
+ // Default cell renderer always gets expand/collapse (text is clamped by CSS)
495
+ const isDefaultCell = CellRenderer === DefaultCardCell;
496
+ const canExpand = expandable || isDefaultCell;
497
+
498
+ // Determine severity for type indicator dot
499
+ const severityKey = String(item.severity || '').toLowerCase();
500
+ const severityStyle = getSemanticColor('severity', severityKey);
501
+
502
+ // Card type from _category or type
503
+ const cardType = String(item._category || item.type || '');
504
+
505
+ // Build card class names
506
+ const cardClasses = [
507
+ 'ar-grid-card',
508
+ 'ar-grid-card--enhanced',
509
+ expanded ? 'expanded' : '',
510
+ isHero ? 'ar-grid-card--hero' : '',
511
+ isSingleCard ? 'ar-grid-card--single' : '',
512
+ ].filter(Boolean).join(' ');
513
+
514
+ // Build inline styles: merge base with style overrides
515
+ const baseCardStyle: Record<string, string> = {
516
+ borderLeftColor: groupStyle.text,
517
+ cursor: canExpand ? 'pointer' : 'default',
518
+ };
519
+ const cardStyle = isHero
520
+ ? { ...baseCardStyle, ...so?.hero_card, ...so?.card }
521
+ : { ...baseCardStyle, ...so?.card };
522
+
523
+ // Capture button (rendered outside JSX to avoid TS2746)
524
+ const captureBtn = config._captureMode && config._onCapture ? (
525
+ <button
526
+ key="capture-btn"
527
+ title="Capture this card"
528
+ onClick={e => {
529
+ e.stopPropagation();
530
+ const onCap = config._onCapture as (sel: CaptureSelection) => void;
531
+ const title = String(item.title || item.name || item.id || '');
532
+ const parentSectionKey = config._parentSectionKey as string | undefined;
533
+ const parentSectionTitle = config._parentSectionTitle as string | undefined;
534
+ onCap({
535
+ source_view_key: String(config._captureViewKey || ''),
536
+ source_item_index: undefined,
537
+ source_renderer_type: 'card_grid',
538
+ content_type: 'card',
539
+ selected_text: (item.summary || item.analysis || item.description || JSON.stringify(item)).toString().slice(0, 500),
540
+ structured_data: item,
541
+ context_title: parentSectionKey
542
+ ? `${config._captureViewKey || 'Analysis'} > ${parentSectionTitle || ''} > ${title}`
543
+ : `${config._captureViewKey || 'Analysis'} > ${title}`,
544
+ source_type: ((config._captureSourceType as string) || 'analysis') as string,
545
+ entity_id: String(config._captureEntityId || config._captureJobId || ''),
546
+ depth_level: parentSectionKey ? 'L2_element' : 'L1_section',
547
+ parent_context: parentSectionKey ? {
548
+ section_key: parentSectionKey,
549
+ section_title: parentSectionTitle || '',
550
+ } : undefined,
551
+ });
552
+ }}
553
+ style={{
554
+ position: 'absolute' as const,
555
+ top: '6px',
556
+ right: '6px',
557
+ background: 'rgba(255,255,255,0.9)',
558
+ border: '1px solid #ccc',
559
+ borderRadius: '4px',
560
+ cursor: 'pointer',
561
+ padding: '2px 5px',
562
+ fontSize: '0.7rem',
563
+ lineHeight: 1,
564
+ zIndex: 2,
565
+ }}
566
+ >
567
+ &#x1F4CC;
568
+ </button>
569
+ ) : null;
570
+
571
+ return (
572
+ <div
573
+ className={cardClasses}
574
+ style={cardStyle}
575
+ onClick={canExpand ? () => setExpanded(!expanded) : undefined}
576
+ >
577
+ {captureBtn}
578
+ {/* Type indicator dot */}
579
+ {(severityStyle || cardType) && (
580
+ <div className="ar-card-type-indicator">
581
+ {severityStyle && (
582
+ <span
583
+ className="ar-card-type-dot"
584
+ style={{ background: severityStyle.text }}
585
+ title={`Severity: ${severityKey}`}
586
+ />
587
+ )}
588
+ {cardType && (
589
+ <span className="ar-card-type-label" style={so?.badge || undefined}>
590
+ {getLabel('tactic', cardType) || cardType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
591
+ </span>
592
+ )}
593
+ </div>
594
+ )}
595
+
596
+ {/* Card content from cell renderer */}
597
+ <div className="ar-card-content" style={so?.card_body || undefined}>
598
+ <CellRenderer item={item} config={config} />
599
+ </div>
600
+
601
+ {canExpand && (
602
+ <div className="card-cell-expand-hint">
603
+ {expanded ? 'click to collapse' : 'click to expand'}
604
+ </div>
605
+ )}
606
+ </div>
607
+ );
608
+ }