@r0hitsharma/dashboard-kit 0.12.0-rohit-fork-ci.1

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,410 @@
1
+ import {
2
+ Axis,
3
+ AreaSeries,
4
+ Grid,
5
+ LineSeries,
6
+ Tooltip,
7
+ XYChart,
8
+ chartTheme,
9
+ } from '@r0hitsharma/charting';
10
+ import {
11
+ Badge,
12
+ DataTable,
13
+ Sparkline,
14
+ StatRow,
15
+ StatTile,
16
+ useDataTable,
17
+ type ColumnDef,
18
+ type StatTileTone,
19
+ } from '@r0hitsharma/design-system';
20
+ import {
21
+ useCallback,
22
+ useRef,
23
+ useState,
24
+ type CSSProperties,
25
+ type JSX,
26
+ } from 'react';
27
+
28
+ import {
29
+ useInteractionField,
30
+ type InteractionContextValue,
31
+ } from './interaction.js';
32
+ import type { ThresholdRule, WidgetNode, WidgetTableColumn } from './schema.js';
33
+
34
+ /**
35
+ * The string -> component registry. A manifest's `WidgetNode.component` names a
36
+ * key here; the resolved adapter owns translating that widget's
37
+ * `dataBinding.fields` mapping + `props` into a real component's prop shape, so
38
+ * the manifest stays declarative (field names, not JSX).
39
+ *
40
+ * The registry is an EXTENSIBLE MECHANISM: {@link DEFAULT_REGISTRY} ships a
41
+ * small set of generic adapters over public design-system + charting
42
+ * components, and a consumer merges in their own domain adapters with
43
+ * {@link mergeRegistries} (or replaces it wholesale). The engine itself knows
44
+ * nothing about any specific widget.
45
+ */
46
+ export type RegistryComponentProps = {
47
+ widget: WidgetNode;
48
+ data: Record<string, unknown>[];
49
+ interaction: InteractionContextValue;
50
+ };
51
+
52
+ export type RegistryComponent = (props: RegistryComponentProps) => JSX.Element;
53
+
54
+ export type ComponentRegistry = Record<string, RegistryComponent>;
55
+
56
+ const lastRecord = (
57
+ data: Record<string, unknown>[],
58
+ ): Record<string, unknown> => (data.length > 0 ? data[data.length - 1]! : {});
59
+
60
+ const toNumber = (value: unknown): number =>
61
+ typeof value === 'number' ? value : Number(value ?? Number.NaN);
62
+
63
+ function resolveThresholdTone(
64
+ value: number | undefined,
65
+ thresholds: ThresholdRule[] | undefined,
66
+ ): StatTileTone {
67
+ if (value === undefined || !thresholds) return 'default';
68
+ for (const rule of thresholds) {
69
+ const hit =
70
+ (rule.op === 'gte' && value >= rule.value) ||
71
+ (rule.op === 'lte' && value <= rule.value) ||
72
+ (rule.op === 'gt' && value > rule.value) ||
73
+ (rule.op === 'lt' && value < rule.value) ||
74
+ (rule.op === 'eq' && value === rule.value);
75
+ if (hit) return rule.severity === 'success' ? 'success' : 'critical';
76
+ }
77
+ return 'default';
78
+ }
79
+
80
+ /** Fallback width used only before the first ResizeObserver measurement. */
81
+ const FALLBACK_CHART_WIDTH = 560;
82
+
83
+ /**
84
+ * Sizes a chart's SVG `width` (a hard pixel dimension, not CSS) to whatever
85
+ * width its container is granted, via a `ResizeObserver` on a callback ref. An
86
+ * explicit `widget.props.width` still wins.
87
+ */
88
+ function useChartWidth(explicitWidth: number | undefined) {
89
+ const [measured, setMeasured] = useState(FALLBACK_CHART_WIDTH);
90
+ const observerRef = useRef<ResizeObserver | null>(null);
91
+ const ref = useCallback(
92
+ (el: HTMLDivElement | null) => {
93
+ observerRef.current?.disconnect();
94
+ observerRef.current = null;
95
+ if (explicitWidth != null || !el) return;
96
+ const observer = new ResizeObserver((entries) => {
97
+ const width = entries[0]?.contentRect.width;
98
+ if (width && width > 0) setMeasured(Math.floor(width));
99
+ });
100
+ observer.observe(el);
101
+ observerRef.current = observer;
102
+ },
103
+ [explicitWidth],
104
+ );
105
+ return { ref, width: explicitWidth ?? measured };
106
+ }
107
+
108
+ const CHART_MARGIN = { top: 16, right: 24, bottom: 32, left: 48 };
109
+
110
+ const noteStyle: CSSProperties = {
111
+ color: 'var(--colors-text-muted)',
112
+ fontSize: '0.875rem',
113
+ lineHeight: 1.6,
114
+ margin: 0,
115
+ };
116
+
117
+ /** A free-form note paragraph, text supplied via `props.text`. */
118
+ const NoteWidget: RegistryComponent = ({ widget }) => (
119
+ <p style={noteStyle}>{String(widget.props?.text ?? '')}</p>
120
+ );
121
+
122
+ /**
123
+ * Formats a numeric value per `props.format`. Non-numeric input falls through
124
+ * to its string form (or an em dash).
125
+ */
126
+ function formatStatValue(value: unknown, format: string | undefined): string {
127
+ const numeric = toNumber(value);
128
+ if (!Number.isFinite(numeric)) return String(value ?? '—');
129
+ switch (format) {
130
+ case 'percent':
131
+ return `${(numeric * 100).toFixed(1)}%`;
132
+ case 'ratio':
133
+ return numeric.toFixed(2);
134
+ case 'currency':
135
+ return `$${numeric.toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
136
+ default:
137
+ return String(numeric);
138
+ }
139
+ }
140
+
141
+ /** A single stat tile; value/tone resolved from the bound source + thresholds. */
142
+ const StatWidget: RegistryComponent = ({ widget, data }) => {
143
+ const fields = widget.dataBinding?.fields ?? {};
144
+ const record = lastRecord(data);
145
+ const rawValue = fields.value ? record[fields.value] : undefined;
146
+ const numeric = toNumber(rawValue);
147
+ const tone = resolveThresholdTone(
148
+ Number.isFinite(numeric) ? numeric : undefined,
149
+ widget.thresholds,
150
+ );
151
+ return (
152
+ <StatTile
153
+ label={(widget.props?.label as string) ?? widget.title ?? widget.id}
154
+ value={formatStatValue(
155
+ rawValue,
156
+ widget.props?.format as string | undefined,
157
+ )}
158
+ sub={widget.props?.sub as string | undefined}
159
+ tone={tone}
160
+ />
161
+ );
162
+ };
163
+
164
+ /**
165
+ * A row of stat tiles, one per bound record. Reads its first declared
166
+ * interaction key (`interaction.reads[0]`) as a highlighted-row key: the tile
167
+ * whose label field matches gets a `success` tone, proving `reads` is wired.
168
+ */
169
+ const StatRowWidget: RegistryComponent = ({ widget, data, interaction }) => {
170
+ const fields = widget.dataBinding?.fields ?? {};
171
+ const highlightKey = widget.interaction?.reads?.[0];
172
+ const highlighted = useInteractionField(interaction, highlightKey);
173
+ return (
174
+ <StatRow>
175
+ {data.map((record, index) => {
176
+ const label = fields.label ? String(record[fields.label]) : `#${index}`;
177
+ const value = fields.value ? record[fields.value] : undefined;
178
+ const isHighlighted =
179
+ highlighted != null && String(highlighted) === label;
180
+ return (
181
+ <StatTile
182
+ key={label}
183
+ label={label}
184
+ value={formatStatValue(
185
+ value,
186
+ widget.props?.format as string | undefined,
187
+ )}
188
+ sub={fields.sub ? String(record[fields.sub] ?? '') : undefined}
189
+ tone={isHighlighted ? 'success' : 'default'}
190
+ />
191
+ );
192
+ })}
193
+ </StatRow>
194
+ );
195
+ };
196
+
197
+ function renderCell(
198
+ column: WidgetTableColumn,
199
+ value: unknown,
200
+ ): JSX.Element | string {
201
+ switch (column.render) {
202
+ case 'number':
203
+ return typeof value === 'number'
204
+ ? value.toLocaleString('en-US', { maximumFractionDigits: 2 })
205
+ : String(value ?? '—');
206
+ case 'currency':
207
+ return typeof value === 'number'
208
+ ? `$${value.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
209
+ : String(value ?? '—');
210
+ case 'percent':
211
+ return typeof value === 'number'
212
+ ? `${value.toFixed(1)}%`
213
+ : String(value ?? '—');
214
+ case 'badge': {
215
+ const palette =
216
+ value === 'low' || value === 'success'
217
+ ? 'green'
218
+ : value === 'high' || value === 'critical'
219
+ ? 'red'
220
+ : 'amber';
221
+ return (
222
+ <Badge colorPalette={palette} variant="subtle">
223
+ {String(value ?? '—')}
224
+ </Badge>
225
+ );
226
+ }
227
+ case 'sparkline':
228
+ return Array.isArray(value) ? (
229
+ <Sparkline data={value as number[]} width={96} height={28} area />
230
+ ) : (
231
+ '—'
232
+ );
233
+ default:
234
+ return String(value ?? '—');
235
+ }
236
+ }
237
+
238
+ /**
239
+ * A data table with declarative columns (`props.columns`). Writes its first
240
+ * declared interaction key (`interaction.writes[0]`) on row click — the
241
+ * clicked row's key value — proving `writes` is wired; the same key read by a
242
+ * sibling widget drives cross-highlighting.
243
+ */
244
+ const TableWidget: RegistryComponent = ({ widget, data, interaction }) => {
245
+ const specColumns = widget.props?.columns ?? [];
246
+ const rowKeyField =
247
+ widget.dataBinding?.fields?.rowKey ?? specColumns[0]?.accessorKey;
248
+ const writeKey = widget.interaction?.writes?.[0];
249
+ const highlightKey = widget.interaction?.reads?.[0] ?? writeKey;
250
+ const selected = useInteractionField(interaction, highlightKey);
251
+
252
+ const columns: ColumnDef<Record<string, unknown>>[] = specColumns.map(
253
+ (column) => ({
254
+ accessorKey: column.accessorKey,
255
+ header: column.header,
256
+ cell: (info) => renderCell(column, info.getValue()),
257
+ }),
258
+ );
259
+
260
+ const table = useDataTable(data, columns);
261
+ const getRowKey = useCallback(
262
+ (row: Record<string, unknown>) =>
263
+ rowKeyField ? String(row[rowKeyField]) : '',
264
+ [rowKeyField],
265
+ );
266
+
267
+ return (
268
+ <DataTable
269
+ table={table}
270
+ isLoading={false}
271
+ getRowKey={getRowKey}
272
+ selectedRowKey={selected != null ? String(selected) : undefined}
273
+ onRowClick={
274
+ writeKey && rowKeyField
275
+ ? (row) => interaction.write(writeKey, row[rowKeyField])
276
+ : undefined
277
+ }
278
+ />
279
+ );
280
+ };
281
+
282
+ const chartWrapStyle: CSSProperties = {
283
+ width: '100%',
284
+ minWidth: 0,
285
+ overflowX: 'auto',
286
+ };
287
+
288
+ type SeriesConfig = { key: string; field: string };
289
+
290
+ function readSeries(widget: WidgetNode): SeriesConfig[] {
291
+ const raw = widget.props?.series;
292
+ if (Array.isArray(raw)) return raw as SeriesConfig[];
293
+ // Fall back to a single series bound through `dataBinding.fields.value`.
294
+ const field = widget.dataBinding?.fields?.value;
295
+ return field ? [{ key: widget.title ?? widget.id, field }] : [];
296
+ }
297
+
298
+ function makeChartWidget(kind: 'line' | 'area'): RegistryComponent {
299
+ return function ChartWidget({ widget, data }) {
300
+ const explicitWidth = widget.props?.width as number | undefined;
301
+ const height = (widget.props?.height as number | undefined) ?? 260;
302
+ const { ref, width } = useChartWidth(explicitWidth);
303
+ const xField = (widget.props?.xField as string | undefined) ?? 'x';
304
+ const series = readSeries(widget);
305
+ const label =
306
+ (widget.props?.ariaLabel as string | undefined) ??
307
+ widget.title ??
308
+ `${kind} chart`;
309
+
310
+ if (data.length === 0 || series.length === 0) {
311
+ return <div ref={ref} style={chartWrapStyle} />;
312
+ }
313
+
314
+ const xAccessor = (d: Record<string, unknown>) => d[xField];
315
+ return (
316
+ <div ref={ref} style={chartWrapStyle} role="img" aria-label={label}>
317
+ <XYChart
318
+ theme={chartTheme}
319
+ width={width}
320
+ height={height}
321
+ margin={CHART_MARGIN}
322
+ xScale={{ type: 'band', paddingInner: 0.3 }}
323
+ yScale={{ type: 'linear', nice: true }}
324
+ >
325
+ <Grid columns={false} numTicks={4} />
326
+ <Axis orientation="bottom" numTicks={4} />
327
+ <Axis orientation="left" numTicks={4} />
328
+ {series.map((s) =>
329
+ kind === 'line' ? (
330
+ <LineSeries
331
+ key={s.key}
332
+ dataKey={s.key}
333
+ data={data}
334
+ xAccessor={xAccessor}
335
+ yAccessor={(d) => toNumber(d[s.field])}
336
+ />
337
+ ) : (
338
+ <AreaSeries
339
+ key={s.key}
340
+ dataKey={s.key}
341
+ data={data}
342
+ xAccessor={xAccessor}
343
+ yAccessor={(d) => toNumber(d[s.field])}
344
+ fillOpacity={0.3}
345
+ />
346
+ ),
347
+ )}
348
+ <Tooltip
349
+ snapTooltipToDatumX
350
+ snapTooltipToDatumY
351
+ showSeriesGlyphs
352
+ renderTooltip={({ tooltipData }) => {
353
+ const datum = tooltipData?.nearestDatum?.datum as
354
+ | Record<string, unknown>
355
+ | undefined;
356
+ return datum ? String(datum[xField]) : null;
357
+ }}
358
+ />
359
+ </XYChart>
360
+ </div>
361
+ );
362
+ };
363
+ }
364
+
365
+ /**
366
+ * The default registry: a small set of GENERIC adapters over public
367
+ * design-system + charting components. Nothing domain-specific — a consumer
368
+ * merges in their own component keys with {@link mergeRegistries}.
369
+ */
370
+ export const DEFAULT_REGISTRY: ComponentRegistry = {
371
+ note: NoteWidget,
372
+ stat: StatWidget,
373
+ statRow: StatRowWidget,
374
+ table: TableWidget,
375
+ lineChart: makeChartWidget('line'),
376
+ areaChart: makeChartWidget('area'),
377
+ };
378
+
379
+ /** Merges consumer adapters over the defaults (consumer keys win on collision). */
380
+ export function mergeRegistries(
381
+ ...registries: ComponentRegistry[]
382
+ ): ComponentRegistry {
383
+ return Object.assign({}, DEFAULT_REGISTRY, ...registries);
384
+ }
385
+
386
+ const unknownStyle: CSSProperties = {
387
+ display: 'inline-flex',
388
+ alignItems: 'center',
389
+ gap: '0.375rem',
390
+ padding: '0.25rem 0.5rem',
391
+ borderRadius: '0.375rem',
392
+ borderWidth: '1px',
393
+ borderStyle: 'dashed',
394
+ borderColor: 'var(--colors-border-critical, currentColor)',
395
+ color: 'var(--colors-text-critical, currentColor)',
396
+ fontFamily: 'var(--fonts-mono, monospace)',
397
+ fontSize: '0.75rem',
398
+ };
399
+
400
+ /**
401
+ * Inline marker rendered in place of a widget whose ref or component key does
402
+ * not resolve — one bad manifest entry never blanks the whole dashboard.
403
+ */
404
+ export function UnknownWidget({ label }: { label: string }): JSX.Element {
405
+ return (
406
+ <span style={unknownStyle} role="status">
407
+ unknown widget: {label}
408
+ </span>
409
+ );
410
+ }
@@ -0,0 +1,275 @@
1
+ import { Panel, SplitLayout } from '@r0hitsharma/design-system';
2
+ import { type CSSProperties, type JSX } from 'react';
3
+
4
+ import {
5
+ useLocalInteraction,
6
+ type InteractionContextValue,
7
+ } from './interaction.js';
8
+ import {
9
+ DEFAULT_REGISTRY,
10
+ UnknownWidget,
11
+ type ComponentRegistry,
12
+ } from './registry.js';
13
+ import type {
14
+ DashboardSpec,
15
+ DashboardDataSources,
16
+ LayoutNode,
17
+ SplitLayoutNode,
18
+ } from './schema.js';
19
+ import { validateDashboardSpec, type ManifestIssue } from './validate.js';
20
+
21
+ /**
22
+ * The recursive `DashboardSpec` -> JSX renderer. Walks `spec.layout`
23
+ * (positions/nesting only — see schema.ts for why layout stays separate from
24
+ * widget config and data binding), resolving each `widget` leaf through
25
+ * `spec.widgets` and then through the string -> component `registry`. An
26
+ * unresolved widget ref or component key renders an inline `UnknownWidget`
27
+ * marker instead of throwing, so one bad manifest entry doesn't blank the
28
+ * whole dashboard.
29
+ *
30
+ * A `split` node with `resizable: true` becomes a design-system `SplitLayout`
31
+ * (an Ark `Splitter`), one drag-resizable panel per child; otherwise it's a
32
+ * plain flex box that wraps at narrow widths. Nesting works either way — each
33
+ * panel's content is just another recursive render.
34
+ */
35
+
36
+ /** Default height for a resizable split that declares none (a `column` split must; validation enforces it). */
37
+ const DEFAULT_RESIZABLE_HEIGHT = '420px';
38
+
39
+ const rootStyle: CSSProperties = {
40
+ width: '100%',
41
+ minWidth: 0,
42
+ display: 'flex',
43
+ flexDirection: 'column',
44
+ gap: '1rem',
45
+ };
46
+
47
+ const titleStyle: CSSProperties = {
48
+ fontSize: '1.125rem',
49
+ fontWeight: 600,
50
+ color: 'var(--colors-text-default, currentColor)',
51
+ margin: 0,
52
+ };
53
+
54
+ const invalidStyle: CSSProperties = {
55
+ display: 'flex',
56
+ flexDirection: 'column',
57
+ gap: '0.5rem',
58
+ padding: '1rem',
59
+ borderRadius: '0.5rem',
60
+ borderWidth: '1px',
61
+ borderStyle: 'solid',
62
+ borderColor: 'var(--colors-border-critical, currentColor)',
63
+ background: 'var(--colors-bg-critical, transparent)',
64
+ color: 'var(--colors-text-critical, currentColor)',
65
+ fontSize: '0.875rem',
66
+ };
67
+
68
+ const issueListStyle: CSSProperties = {
69
+ display: 'flex',
70
+ flexDirection: 'column',
71
+ gap: '0.25rem',
72
+ fontFamily: 'var(--fonts-mono, monospace)',
73
+ fontSize: '0.75rem',
74
+ listStyle: 'none',
75
+ padding: 0,
76
+ margin: 0,
77
+ };
78
+
79
+ /**
80
+ * What an invalid manifest renders instead of a dashboard: an annotated list,
81
+ * not a thrown error — the whole point of the gate is that an agent-submitted
82
+ * patch produces a readable rejection a human (or the agent) can act on.
83
+ */
84
+ function InvalidSpec({ issues }: { issues: ManifestIssue[] }): JSX.Element {
85
+ return (
86
+ <div style={invalidStyle} role="alert">
87
+ <strong>
88
+ This dashboard manifest is invalid ({issues.length} issue
89
+ {issues.length === 1 ? '' : 's'}) and was not rendered.
90
+ </strong>
91
+ <ul style={issueListStyle}>
92
+ {issues.map((issue) => (
93
+ <li key={`${issue.path}:${issue.message}`}>
94
+ <code>{issue.path}</code> — {issue.message}
95
+ </li>
96
+ ))}
97
+ </ul>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ type RenderContext = {
103
+ widgets: DashboardSpec['widgets'];
104
+ dataSources: DashboardDataSources;
105
+ registry: ComponentRegistry;
106
+ interaction: InteractionContextValue;
107
+ };
108
+
109
+ function renderWidget(ref: string, ctx: RenderContext): JSX.Element {
110
+ const widget = ctx.widgets[ref];
111
+ if (!widget) {
112
+ return (
113
+ <UnknownWidget label={`layout references unknown widget "${ref}"`} />
114
+ );
115
+ }
116
+ const Adapter = ctx.registry[widget.component];
117
+ if (!Adapter) {
118
+ return <UnknownWidget label={widget.component} />;
119
+ }
120
+ const data = widget.dataBinding
121
+ ? (ctx.dataSources[widget.dataBinding.source] ?? [])
122
+ : [];
123
+ return (
124
+ <Panel
125
+ title={widget.title}
126
+ style={{ width: '100%', minWidth: 0 }}
127
+ data-widget-id={widget.id}
128
+ data-widget-component={widget.component}
129
+ >
130
+ <Adapter widget={widget} data={data} interaction={ctx.interaction} />
131
+ </Panel>
132
+ );
133
+ }
134
+
135
+ function splitContainerStyle(direction: 'row' | 'column'): CSSProperties {
136
+ return {
137
+ display: 'flex',
138
+ flexDirection: direction,
139
+ flexWrap: direction === 'row' ? 'wrap' : 'nowrap',
140
+ gap: '1rem',
141
+ width: '100%',
142
+ minWidth: 0,
143
+ };
144
+ }
145
+
146
+ function ResizableSplit({
147
+ node,
148
+ ctx,
149
+ nodeKey,
150
+ }: {
151
+ node: SplitLayoutNode;
152
+ ctx: RenderContext;
153
+ nodeKey: string;
154
+ }): JSX.Element {
155
+ const panels = node.children.map((child, index) => ({
156
+ id: `${nodeKey}.${index}`,
157
+ size: child.size ?? 1,
158
+ content: renderLayoutNode(child, ctx, `${nodeKey}.${index}`),
159
+ }));
160
+ const height = node.height ?? DEFAULT_RESIZABLE_HEIGHT;
161
+ return (
162
+ <div style={{ width: '100%', minWidth: 0, height }}>
163
+ <SplitLayout
164
+ orientation={node.direction === 'row' ? 'horizontal' : 'vertical'}
165
+ panels={panels}
166
+ />
167
+ </div>
168
+ );
169
+ }
170
+
171
+ export function renderLayoutNode(
172
+ node: LayoutNode,
173
+ ctx: RenderContext,
174
+ key: string,
175
+ ): JSX.Element {
176
+ if (node.type === 'widget') {
177
+ return (
178
+ <div
179
+ key={key}
180
+ style={{
181
+ display: 'flex',
182
+ flexDirection: 'column',
183
+ minWidth: '280px',
184
+ flexGrow: node.size ?? 1,
185
+ flexBasis: 0,
186
+ }}
187
+ >
188
+ {renderWidget(node.ref, ctx)}
189
+ </div>
190
+ );
191
+ }
192
+
193
+ const inner = node.resizable ? (
194
+ <ResizableSplit node={node} ctx={ctx} nodeKey={key} />
195
+ ) : (
196
+ <div style={splitContainerStyle(node.direction)}>
197
+ {node.children.map((child, index) =>
198
+ renderLayoutNode(child, ctx, `${key}.${index}`),
199
+ )}
200
+ </div>
201
+ );
202
+
203
+ return (
204
+ <div
205
+ key={key}
206
+ style={{
207
+ display: 'flex',
208
+ flexDirection: 'column',
209
+ minWidth: 0,
210
+ flexGrow: node.size ?? 1,
211
+ flexBasis: 0,
212
+ }}
213
+ >
214
+ {inner}
215
+ </div>
216
+ );
217
+ }
218
+
219
+ export type DashboardRendererProps = {
220
+ spec: DashboardSpec;
221
+ dataSources: DashboardDataSources;
222
+ /**
223
+ * The string -> component registry. Defaults to {@link DEFAULT_REGISTRY};
224
+ * pass a merged registry (see `mergeRegistries`) to add domain adapters.
225
+ */
226
+ registry?: ComponentRegistry;
227
+ /**
228
+ * A real interaction store (e.g. charting-backed via
229
+ * `useChartingInteraction`). Omit to use a self-contained local store, which
230
+ * every standalone manifest (a story, a preview) can rely on unchanged.
231
+ */
232
+ interaction?: InteractionContextValue;
233
+ /**
234
+ * Skip the zod gate. Only for a spec the caller authored and trusts (where
235
+ * validation is a dev-time assertion, not a trust boundary); leave it on for
236
+ * any spec an agent or a command bar produced.
237
+ */
238
+ skipValidation?: boolean;
239
+ };
240
+
241
+ /**
242
+ * Renders a `DashboardSpec`. Validates first (unless `skipValidation`), showing
243
+ * an annotated rejection for an invalid manifest rather than throwing.
244
+ */
245
+ export function DashboardRenderer({
246
+ spec,
247
+ dataSources,
248
+ registry = DEFAULT_REGISTRY,
249
+ interaction,
250
+ skipValidation = false,
251
+ }: DashboardRendererProps): JSX.Element {
252
+ const localInteraction = useLocalInteraction();
253
+ const resolvedInteraction = interaction ?? localInteraction;
254
+
255
+ if (!skipValidation) {
256
+ const result = validateDashboardSpec(spec, {
257
+ knownComponents: Object.keys(registry),
258
+ });
259
+ if (!result.ok) return <InvalidSpec issues={result.issues} />;
260
+ }
261
+
262
+ const ctx: RenderContext = {
263
+ widgets: spec.widgets,
264
+ dataSources,
265
+ registry,
266
+ interaction: resolvedInteraction,
267
+ };
268
+
269
+ return (
270
+ <div style={rootStyle}>
271
+ {spec.title ? <h2 style={titleStyle}>{spec.title}</h2> : null}
272
+ {renderLayoutNode(spec.layout, ctx, 'root')}
273
+ </div>
274
+ );
275
+ }