@contractspec/lib.example-shared-ui 0.0.0-canary-20260113170453

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.
@@ -0,0 +1,282 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useMemo } from 'react';
4
+ import { Button } from '@contractspec/lib.design-system';
5
+ import { Card } from '@contractspec/lib.ui-kit-web/ui/card';
6
+ import { Badge } from '@contractspec/lib.ui-kit-web/ui/badge';
7
+ import type { TemplateId } from './lib/types';
8
+ import { type SpecSuggestion, useEvolution } from './hooks/useEvolution';
9
+
10
+ export interface EvolutionSidebarProps {
11
+ templateId: TemplateId;
12
+ /** Whether sidebar is expanded */
13
+ expanded?: boolean;
14
+ /** Toggle expanded state */
15
+ onToggle?: () => void;
16
+ /** Callback for logging actions */
17
+ onLog?: (message: string) => void;
18
+ /** Navigate to full evolution mode */
19
+ onOpenEvolution?: () => void;
20
+ }
21
+
22
+ /**
23
+ * Compact sidebar for Evolution Engine.
24
+ * Shows top anomalies, pending suggestions, and quick actions.
25
+ * Collapsible by default.
26
+ */
27
+ export function EvolutionSidebar({
28
+ templateId,
29
+ expanded = false,
30
+ onToggle,
31
+ onLog,
32
+ onOpenEvolution,
33
+ }: EvolutionSidebarProps) {
34
+ const {
35
+ anomalies,
36
+ suggestions,
37
+ loading,
38
+ approveSuggestion,
39
+ rejectSuggestion,
40
+ operationCount,
41
+ } = useEvolution(templateId);
42
+
43
+ const pendingSuggestions = useMemo(
44
+ () => suggestions.filter((s) => s.status === 'pending'),
45
+ [suggestions]
46
+ );
47
+
48
+ const topAnomalies = useMemo(
49
+ () =>
50
+ anomalies
51
+ .sort((a, b) => {
52
+ const severityOrder = { high: 0, medium: 1, low: 2 };
53
+ return severityOrder[a.severity] - severityOrder[b.severity];
54
+ })
55
+ .slice(0, 3),
56
+ [anomalies]
57
+ );
58
+
59
+ const handleApprove = useCallback(
60
+ (id: string) => {
61
+ approveSuggestion(id);
62
+ onLog?.(`Approved suggestion ${id.slice(0, 8)}`);
63
+ },
64
+ [approveSuggestion, onLog]
65
+ );
66
+
67
+ const handleReject = useCallback(
68
+ (id: string) => {
69
+ rejectSuggestion(id);
70
+ onLog?.(`Rejected suggestion ${id.slice(0, 8)}`);
71
+ },
72
+ [rejectSuggestion, onLog]
73
+ );
74
+
75
+ // Collapsed view - just show badge
76
+ if (!expanded) {
77
+ return (
78
+ <button
79
+ onClick={onToggle}
80
+ className="flex items-center gap-2 rounded-lg border border-violet-500/30 bg-violet-500/10 px-3 py-2 text-sm transition hover:bg-violet-500/20"
81
+ type="button"
82
+ >
83
+ <span>🤖</span>
84
+ <span>Evolution</span>
85
+ {pendingSuggestions.length > 0 && (
86
+ <Badge
87
+ variant="secondary"
88
+ className="border-amber-500/30 bg-amber-500/20 text-amber-400"
89
+ >
90
+ {pendingSuggestions.length}
91
+ </Badge>
92
+ )}
93
+ {anomalies.length > 0 && pendingSuggestions.length === 0 && (
94
+ <Badge variant="destructive">{anomalies.length}</Badge>
95
+ )}
96
+ </button>
97
+ );
98
+ }
99
+
100
+ // Expanded view
101
+ return (
102
+ <Card className="w-80 overflow-hidden">
103
+ {/* Header */}
104
+ <div className="flex items-center justify-between border-b border-violet-500/20 bg-violet-500/5 px-3 py-2">
105
+ <div className="flex items-center gap-2">
106
+ <span>🤖</span>
107
+ <span className="text-sm font-semibold">Evolution</span>
108
+ </div>
109
+ <div className="flex items-center gap-1">
110
+ {onOpenEvolution && (
111
+ <Button variant="ghost" size="sm" onPress={onOpenEvolution}>
112
+ Expand
113
+ </Button>
114
+ )}
115
+ <button
116
+ onClick={onToggle}
117
+ className="text-muted-foreground hover:text-foreground p-1"
118
+ type="button"
119
+ title="Collapse"
120
+ >
121
+ ✕
122
+ </button>
123
+ </div>
124
+ </div>
125
+
126
+ <div className="max-h-96 overflow-y-auto p-3">
127
+ {/* Stats */}
128
+ <div className="mb-3 flex items-center justify-between text-xs">
129
+ <span className="text-muted-foreground">
130
+ {operationCount} ops tracked
131
+ </span>
132
+ <div className="flex items-center gap-2">
133
+ {anomalies.length > 0 && (
134
+ <Badge variant="destructive">{anomalies.length} anomalies</Badge>
135
+ )}
136
+ {pendingSuggestions.length > 0 && (
137
+ <Badge
138
+ variant="secondary"
139
+ className="border-amber-500/30 bg-amber-500/20 text-amber-400"
140
+ >
141
+ {pendingSuggestions.length} pending
142
+ </Badge>
143
+ )}
144
+ </div>
145
+ </div>
146
+
147
+ {loading && (
148
+ <div className="text-muted-foreground py-4 text-center text-sm">
149
+ Generating suggestions...
150
+ </div>
151
+ )}
152
+
153
+ {/* Top Anomalies */}
154
+ {topAnomalies.length > 0 && (
155
+ <div className="mb-4">
156
+ <p className="mb-2 text-xs font-semibold text-violet-400 uppercase">
157
+ Top Issues
158
+ </p>
159
+ <div className="space-y-2">
160
+ {topAnomalies.map((anomaly, index) => (
161
+ <div
162
+ key={`${anomaly.operation.name}-${index}`}
163
+ className="rounded border border-amber-500/20 bg-amber-500/5 p-2 text-xs"
164
+ >
165
+ <div className="flex items-center gap-2">
166
+ <span>
167
+ {anomaly.severity === 'high'
168
+ ? '🔴'
169
+ : anomaly.severity === 'medium'
170
+ ? '🟠'
171
+ : '🟡'}
172
+ </span>
173
+ <span className="truncate font-medium">
174
+ {anomaly.operation.name}
175
+ </span>
176
+ </div>
177
+ <p className="text-muted-foreground mt-1 truncate">
178
+ {anomaly.description}
179
+ </p>
180
+ </div>
181
+ ))}
182
+ </div>
183
+ </div>
184
+ )}
185
+
186
+ {/* Pending Suggestions */}
187
+ {pendingSuggestions.length > 0 && (
188
+ <div>
189
+ <p className="mb-2 text-xs font-semibold text-violet-400 uppercase">
190
+ Pending Suggestions
191
+ </p>
192
+ <div className="space-y-2">
193
+ {pendingSuggestions.slice(0, 3).map((suggestion) => (
194
+ <CompactSuggestionCard
195
+ key={suggestion.id}
196
+ suggestion={suggestion}
197
+ onApprove={handleApprove}
198
+ onReject={handleReject}
199
+ />
200
+ ))}
201
+ {pendingSuggestions.length > 3 && (
202
+ <p className="text-muted-foreground text-center text-xs">
203
+ +{pendingSuggestions.length - 3} more suggestions
204
+ </p>
205
+ )}
206
+ </div>
207
+ </div>
208
+ )}
209
+
210
+ {/* Empty State */}
211
+ {anomalies.length === 0 &&
212
+ pendingSuggestions.length === 0 &&
213
+ !loading && (
214
+ <div className="text-muted-foreground py-4 text-center text-xs">
215
+ No issues detected. Keep coding!
216
+ </div>
217
+ )}
218
+ </div>
219
+
220
+ {/* Footer */}
221
+ {onOpenEvolution && (
222
+ <div className="border-t border-violet-500/20 p-2">
223
+ <Button
224
+ variant="ghost"
225
+ size="sm"
226
+ className="w-full"
227
+ onPress={onOpenEvolution}
228
+ >
229
+ Open Evolution Dashboard →
230
+ </Button>
231
+ </div>
232
+ )}
233
+ </Card>
234
+ );
235
+ }
236
+
237
+ /**
238
+ * Compact suggestion card for sidebar
239
+ */
240
+ function CompactSuggestionCard({
241
+ suggestion,
242
+ onApprove,
243
+ onReject,
244
+ }: {
245
+ suggestion: SpecSuggestion;
246
+ onApprove: (id: string) => void;
247
+ onReject: (id: string) => void;
248
+ }) {
249
+ return (
250
+ <div className="rounded border border-violet-500/20 bg-violet-500/5 p-2">
251
+ <div className="flex items-start justify-between gap-2">
252
+ <div className="min-w-0 flex-1">
253
+ <p className="truncate text-xs font-medium">
254
+ {suggestion.proposal.summary}
255
+ </p>
256
+ <div className="mt-1 flex items-center gap-2 text-xs">
257
+ <Badge variant="secondary">{suggestion.priority}</Badge>
258
+ <span className="text-muted-foreground">
259
+ {(suggestion.confidence * 100).toFixed(0)}%
260
+ </span>
261
+ </div>
262
+ </div>
263
+ </div>
264
+ <div className="mt-2 flex justify-end gap-1">
265
+ <button
266
+ onClick={() => onReject(suggestion.id)}
267
+ className="rounded px-2 py-0.5 text-xs text-red-400 hover:bg-red-400/10"
268
+ type="button"
269
+ >
270
+ Reject
271
+ </button>
272
+ <button
273
+ onClick={() => onApprove(suggestion.id)}
274
+ className="rounded bg-violet-500/20 px-2 py-0.5 text-xs text-violet-400 hover:bg-violet-500/30"
275
+ type="button"
276
+ >
277
+ Approve
278
+ </button>
279
+ </div>
280
+ </div>
281
+ );
282
+ }
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+
3
+ import { RefreshCw, Shield } from 'lucide-react';
4
+ import { useState } from 'react';
5
+
6
+ import { useTemplateRuntime } from './lib/runtime-context';
7
+
8
+ export function LocalDataIndicator() {
9
+ const { projectId, templateId, template, installer } = useTemplateRuntime();
10
+ const [isResetting, setIsResetting] = useState(false);
11
+
12
+ const handleReset = async () => {
13
+ setIsResetting(true);
14
+ try {
15
+ await installer.install(templateId, { projectId });
16
+ } finally {
17
+ setIsResetting(false);
18
+ }
19
+ };
20
+
21
+ return (
22
+ <div className="border-border bg-muted/40 text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs">
23
+ <Shield className="h-3.5 w-3.5 text-violet-400" />
24
+ <span>
25
+ Local runtime ·{' '}
26
+ <span className="text-foreground font-semibold">{template.name}</span>
27
+ </span>
28
+ <button
29
+ type="button"
30
+ className="border-border text-muted-foreground hover:text-foreground inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold"
31
+ onClick={handleReset}
32
+ disabled={isResetting}
33
+ >
34
+ <RefreshCw className="h-3 w-3" />
35
+ {isResetting ? 'Resetting…' : 'Reset data'}
36
+ </button>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,389 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import {
5
+ Button,
6
+ ErrorState,
7
+ LoaderBlock,
8
+ } from '@contractspec/lib.design-system';
9
+ import { Card } from '@contractspec/lib.ui-kit-web/ui/card';
10
+ import { Badge } from '@contractspec/lib.ui-kit-web/ui/badge';
11
+ import type { PresentationTarget } from '@contractspec/lib.contracts';
12
+ import type { TransformEngine } from '@contractspec/lib.contracts';
13
+ import type { TemplateId } from './lib/types';
14
+
15
+ import { useTemplateRuntime } from './lib/runtime-context';
16
+
17
+ export interface MarkdownViewProps {
18
+ /** Optional override, otherwise comes from context */
19
+ templateId?: TemplateId;
20
+ presentationId?: string;
21
+ className?: string;
22
+ }
23
+
24
+ interface MarkdownOutput {
25
+ mimeType: string;
26
+ body: string;
27
+ }
28
+
29
+ /**
30
+ * MarkdownView renders template presentations as markdown using TransformEngine.
31
+ * It allows switching between available presentations for the template.
32
+ */
33
+ export function MarkdownView({
34
+ templateId: propTemplateId,
35
+ presentationId,
36
+ className,
37
+ }: MarkdownViewProps) {
38
+ const {
39
+ engine,
40
+ template,
41
+ templateId: contextTemplateId,
42
+ resolvePresentation,
43
+ fetchData,
44
+ } = useTemplateRuntime();
45
+
46
+ // Prefer prop if given, else context
47
+ const templateId = propTemplateId ?? contextTemplateId;
48
+ const presentations = (template?.presentations as string[]) ?? [];
49
+
50
+ const [selectedPresentation, setSelectedPresentation] = useState<string>('');
51
+ const [markdownContent, setMarkdownContent] = useState<string>('');
52
+ const [loading, setLoading] = useState(false);
53
+ const [error, setError] = useState<Error | null>(null);
54
+
55
+ // Initialize selected presentation
56
+ useEffect(() => {
57
+ if (presentationId && presentations.includes(presentationId)) {
58
+ setSelectedPresentation(presentationId);
59
+ } else if (presentations.length > 0 && !selectedPresentation) {
60
+ setSelectedPresentation(presentations[0] ?? '');
61
+ }
62
+ }, [presentationId, presentations, selectedPresentation]);
63
+
64
+ // Render markdown when presentation changes
65
+ const renderMarkdown = useCallback(async () => {
66
+ if (!selectedPresentation || !engine) return;
67
+
68
+ setLoading(true);
69
+ setError(null);
70
+
71
+ try {
72
+ if (!resolvePresentation) {
73
+ throw new Error('resolvePresentation not available in runtime context');
74
+ }
75
+
76
+ const descriptor = resolvePresentation(selectedPresentation);
77
+
78
+ if (!descriptor) {
79
+ throw new Error(
80
+ `Presentation descriptor not found: ${selectedPresentation}`
81
+ );
82
+ }
83
+
84
+ // Fetch data for this presentation using the data fetcher from context
85
+ const dataResult = await fetchData(selectedPresentation);
86
+
87
+ // Render to markdown using the engine with data context
88
+ const result = await engine.render<MarkdownOutput>(
89
+ 'markdown' as PresentationTarget,
90
+ descriptor as Parameters<TransformEngine['render']>[1],
91
+ { data: dataResult.data } // Pass data in context for schema-driven rendering
92
+ );
93
+
94
+ setMarkdownContent(result.body);
95
+ } catch (err) {
96
+ setError(
97
+ err instanceof Error ? err : new Error('Failed to render markdown')
98
+ );
99
+ } finally {
100
+ setLoading(false);
101
+ }
102
+ }, [
103
+ selectedPresentation,
104
+ templateId,
105
+ engine,
106
+ resolvePresentation,
107
+ fetchData,
108
+ ]);
109
+
110
+ useEffect(() => {
111
+ renderMarkdown();
112
+ }, [renderMarkdown]);
113
+
114
+ if (!presentations.length) {
115
+ return (
116
+ <Card className={className}>
117
+ <div className="p-6 text-center">
118
+ <p className="text-muted-foreground">
119
+ No presentations available for this template.
120
+ </p>
121
+ </div>
122
+ </Card>
123
+ );
124
+ }
125
+
126
+ // Copy markdown to clipboard
127
+ const handleCopy = useCallback(() => {
128
+ if (markdownContent) {
129
+ navigator.clipboard.writeText(markdownContent);
130
+ }
131
+ }, [markdownContent]);
132
+
133
+ return (
134
+ <div className={className}>
135
+ {/* Presentation Selector */}
136
+ <div className="mb-4 flex flex-wrap items-center gap-2">
137
+ <span className="text-muted-foreground text-sm font-medium">
138
+ Presentation:
139
+ </span>
140
+ {presentations.map((name) => (
141
+ <Button
142
+ key={name}
143
+ variant={selectedPresentation === name ? 'default' : 'outline'}
144
+ size="sm"
145
+ onPress={() => setSelectedPresentation(name)}
146
+ >
147
+ {formatPresentationName(name)}
148
+ </Button>
149
+ ))}
150
+ <div className="ml-auto flex items-center gap-2">
151
+ <Badge variant="secondary">LLM-friendly</Badge>
152
+ <Button
153
+ variant="outline"
154
+ size="sm"
155
+ onPress={handleCopy}
156
+ disabled={!markdownContent || loading}
157
+ >
158
+ Copy
159
+ </Button>
160
+ </div>
161
+ </div>
162
+
163
+ {/* Content Area */}
164
+ <Card className="overflow-hidden">
165
+ {loading && <LoaderBlock label="Rendering markdown..." />}
166
+
167
+ {error && (
168
+ <ErrorState
169
+ title="Render failed"
170
+ description={error.message}
171
+ onRetry={renderMarkdown}
172
+ retryLabel="Retry"
173
+ />
174
+ )}
175
+
176
+ {!loading && !error && markdownContent && (
177
+ <div className="p-6">
178
+ <MarkdownRenderer content={markdownContent} />
179
+ </div>
180
+ )}
181
+
182
+ {!loading && !error && !markdownContent && (
183
+ <div className="p-6 text-center">
184
+ <p className="text-muted-foreground">
185
+ Select a presentation to view its markdown output.
186
+ </p>
187
+ </div>
188
+ )}
189
+ </Card>
190
+ </div>
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Simple markdown renderer using pre-formatted display
196
+ * For production, consider using react-markdown or similar
197
+ */
198
+ function MarkdownRenderer({ content }: { content: string }) {
199
+ const lines = content.split('\n');
200
+ const rendered: React.ReactNode[] = [];
201
+ let i = 0;
202
+
203
+ while (i < lines.length) {
204
+ const line = lines[i] ?? '';
205
+
206
+ // Check for table (starts with | and next line is separator)
207
+ if (line.startsWith('|') && lines[i + 1]?.match(/^\|[\s-|]+\|$/)) {
208
+ const tableLines: string[] = [line];
209
+ i++;
210
+ // Collect all table lines
211
+ while (i < lines.length && (lines[i]?.startsWith('|') ?? false)) {
212
+ tableLines.push(lines[i] ?? '');
213
+ i++;
214
+ }
215
+ rendered.push(renderTable(tableLines, rendered.length));
216
+ continue;
217
+ }
218
+
219
+ // Headers
220
+ if (line.startsWith('# ')) {
221
+ rendered.push(
222
+ <h1 key={i} className="mb-4 text-2xl font-bold">
223
+ {line.slice(2)}
224
+ </h1>
225
+ );
226
+ } else if (line.startsWith('## ')) {
227
+ rendered.push(
228
+ <h2 key={i} className="mt-6 mb-3 text-xl font-semibold">
229
+ {line.slice(3)}
230
+ </h2>
231
+ );
232
+ } else if (line.startsWith('### ')) {
233
+ rendered.push(
234
+ <h3 key={i} className="mt-4 mb-2 text-lg font-medium">
235
+ {line.slice(4)}
236
+ </h3>
237
+ );
238
+ }
239
+ // Blockquotes
240
+ else if (line.startsWith('> ')) {
241
+ rendered.push(
242
+ <blockquote
243
+ key={i}
244
+ className="text-muted-foreground my-2 border-l-4 border-violet-500/50 pl-4 italic"
245
+ >
246
+ {line.slice(2)}
247
+ </blockquote>
248
+ );
249
+ }
250
+ // List items
251
+ else if (line.startsWith('- ')) {
252
+ rendered.push(
253
+ <li key={i} className="ml-4 list-disc">
254
+ {formatInlineMarkdown(line.slice(2))}
255
+ </li>
256
+ );
257
+ }
258
+ // Bold text (lines starting with **)
259
+ else if (line.startsWith('**') && line.includes(':**')) {
260
+ const [label, ...rest] = line.split(':**');
261
+ rendered.push(
262
+ <p key={i} className="my-1">
263
+ <strong>{label?.slice(2)}:</strong>
264
+ {rest.join(':**')}
265
+ </p>
266
+ );
267
+ }
268
+ // Italic text (lines starting with _)
269
+ else if (line.startsWith('_') && line.endsWith('_')) {
270
+ rendered.push(
271
+ <p key={i} className="text-muted-foreground my-1 italic">
272
+ {line.slice(1, -1)}
273
+ </p>
274
+ );
275
+ }
276
+ // Empty lines
277
+ else if (!line.trim()) {
278
+ rendered.push(<div key={i} className="h-2" />);
279
+ }
280
+ // Regular text
281
+ else {
282
+ rendered.push(
283
+ <p key={i} className="my-1">
284
+ {formatInlineMarkdown(line)}
285
+ </p>
286
+ );
287
+ }
288
+
289
+ i++;
290
+ }
291
+
292
+ return (
293
+ <div className="prose prose-sm dark:prose-invert max-w-none">
294
+ {rendered}
295
+ </div>
296
+ );
297
+ }
298
+
299
+ /**
300
+ * Render a markdown table
301
+ */
302
+ function renderTable(lines: string[], keyPrefix: number): React.ReactNode {
303
+ if (lines.length < 2) return null;
304
+
305
+ const parseRow = (row: string) =>
306
+ row
307
+ .split('|')
308
+ .slice(1, -1)
309
+ .map((cell) => cell.trim());
310
+
311
+ const headers = parseRow(lines[0] ?? '');
312
+ // Skip separator line (index 1)
313
+ const dataRows = lines.slice(2).map(parseRow);
314
+
315
+ return (
316
+ <div key={`table-${keyPrefix}`} className="my-4 overflow-x-auto">
317
+ <table className="border-border min-w-full border-collapse border text-sm">
318
+ <thead>
319
+ <tr className="bg-muted/50">
320
+ {headers.map((header, idx) => (
321
+ <th
322
+ key={idx}
323
+ className="border-border border px-3 py-2 text-left font-semibold"
324
+ >
325
+ {header}
326
+ </th>
327
+ ))}
328
+ </tr>
329
+ </thead>
330
+ <tbody>
331
+ {dataRows.map((row, rowIdx) => (
332
+ <tr key={rowIdx} className="hover:bg-muted/30">
333
+ {row.map((cell, cellIdx) => (
334
+ <td key={cellIdx} className="border-border border px-3 py-2">
335
+ {formatInlineMarkdown(cell)}
336
+ </td>
337
+ ))}
338
+ </tr>
339
+ ))}
340
+ </tbody>
341
+ </table>
342
+ </div>
343
+ );
344
+ }
345
+
346
+ /**
347
+ * Format inline markdown (bold, code)
348
+ */
349
+ function formatInlineMarkdown(text: string): React.ReactNode {
350
+ // Handle **bold** text
351
+ const parts = text.split(/(\*\*[^*]+\*\*)/g);
352
+ return parts.map((part, i) => {
353
+ if (part.startsWith('**') && part.endsWith('**')) {
354
+ return <strong key={i}>{part.slice(2, -2)}</strong>;
355
+ }
356
+ // Handle `code` text
357
+ if (part.includes('`')) {
358
+ const codeParts = part.split(/(`[^`]+`)/g);
359
+ return codeParts.map((cp, j) => {
360
+ if (cp.startsWith('`') && cp.endsWith('`')) {
361
+ return (
362
+ <code
363
+ key={`${i}-${j}`}
364
+ className="rounded bg-violet-500/10 px-1.5 py-0.5 font-mono text-sm"
365
+ >
366
+ {cp.slice(1, -1)}
367
+ </code>
368
+ );
369
+ }
370
+ return cp;
371
+ });
372
+ }
373
+ return part;
374
+ });
375
+ }
376
+
377
+ /**
378
+ * Format presentation name for display
379
+ */
380
+ function formatPresentationName(name: string): string {
381
+ // Extract the last part after the last dot
382
+ const parts = name.split('.');
383
+ const lastPart = parts[parts.length - 1] ?? name;
384
+ // Convert kebab-case to Title Case
385
+ return lastPart
386
+ .split('-')
387
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
388
+ .join(' ');
389
+ }