@svadmin/surface 0.1.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.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # @svadmin/surface
2
+
3
+ `@svadmin/surface` renders a small, versioned JSON contract as a trusted Svelte dashboard. It is an optional browser package and does not change the public contract of `@svadmin/core`.
4
+
5
+ The MVP supports Svelte 5 + Vite, a fixed 12-column grid, four built-in widgets, read-only `DataProvider` queries, explicit access checks, field projection, and manual host refresh.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add @svadmin/surface @svadmin/core @svadmin/ui @tanstack/svelte-query svelte zod
11
+ ```
12
+
13
+ Import protocol types and validation from the DOM-free root entry. Import rendering code from the Svelte subpath.
14
+
15
+ ```ts
16
+ import { validateSurfaceSpec, type SurfacePolicy, type SurfaceSpec } from '@svadmin/surface';
17
+ import {
18
+ DEFAULT_SURFACE_CATALOG_VERSION,
19
+ SurfaceRenderer,
20
+ defaultSurfaceCatalog,
21
+ defineSurfaceCatalog,
22
+ } from '@svadmin/surface/svelte';
23
+ ```
24
+
25
+ ## Minimal example
26
+
27
+ ```svelte
28
+ <script lang="ts">
29
+ import type { SurfacePolicy, SurfaceSpec } from '@svadmin/surface';
30
+ import {
31
+ DEFAULT_SURFACE_CATALOG_VERSION,
32
+ SurfaceRenderer,
33
+ } from '@svadmin/surface/svelte';
34
+
35
+ const policy = {
36
+ resources: {
37
+ products: {
38
+ readFields: ['id', 'name', 'stock'],
39
+ sortFields: ['stock'],
40
+ maxPageSize: 25,
41
+ },
42
+ },
43
+ } satisfies SurfacePolicy;
44
+
45
+ const spec = {
46
+ schemaVersion: 'surface/v1',
47
+ catalogVersion: DEFAULT_SURFACE_CATALOG_VERSION,
48
+ surfaceId: 'inventory',
49
+ title: 'Inventory',
50
+ layout: { type: 'grid', columns: 12, gap: 'md' },
51
+ dataSources: [
52
+ { id: 'products', type: 'resource-list', resource: 'products', pageSize: 10 },
53
+ ],
54
+ widgets: [
55
+ {
56
+ id: 'count',
57
+ type: 'metric',
58
+ props: { label: 'Products', format: 'number' },
59
+ binding: { sourceId: 'products', pointer: '/total' },
60
+ placement: { columnSpan: 3 },
61
+ },
62
+ {
63
+ id: 'stock',
64
+ type: 'bar-chart',
65
+ props: { title: 'Stock', labelField: 'name', valueField: 'stock' },
66
+ binding: { sourceId: 'products', pointer: '/items' },
67
+ placement: { columnSpan: 9 },
68
+ },
69
+ ],
70
+ } satisfies SurfaceSpec;
71
+
72
+ let renderer = $state<{ refresh(sourceId?: string): Promise<void> }>();
73
+ </script>
74
+
75
+ <button type="button" onclick={() => renderer?.refresh()}>Refresh</button>
76
+ <SurfaceRenderer bind:this={renderer} {spec} {policy} />
77
+ ```
78
+
79
+ When rendered inside `AdminApp`, the renderer resolves the configured provider for each resource. A trusted host may instead pass `dataProvider`, which is still narrowed to `getList` and `getOne`.
80
+
81
+ ## Built-in catalog
82
+
83
+ | Type | Binding | Purpose |
84
+ | --- | --- | --- |
85
+ | `metric` | `/total` on `resource-list`, or one readable field on `resource-one` | Numeric KPI |
86
+ | `resource-table` | `/items` | Read-only table, at most eight columns |
87
+ | `bar-chart` | `/items` | Zero-dependency SVG bar chart |
88
+ | `line-chart` | `/items` | Zero-dependency SVG line chart |
89
+
90
+ The catalog version is `svadmin/v1`. `catalogVersion` must match exactly.
91
+
92
+ Custom catalogs are trusted runtime configuration. Every registration must use a strict Zod v4 object schema and a trusted Svelte component. Item widgets that read record fields must expose those fields through `getReferencedFields`; validation then checks them against `SurfacePolicy.readFields`.
93
+
94
+ ```ts
95
+ const catalog = defineSurfaceCatalog({
96
+ version: 'acme/v1',
97
+ widgets: [{
98
+ type: 'status-list',
99
+ dataKind: 'items',
100
+ propsSchema: z.object({ statusField: z.string() }).strict(),
101
+ getReferencedFields: (props) => [props.statusField as string],
102
+ component: StatusList,
103
+ }],
104
+ });
105
+ ```
106
+
107
+ ## Security boundary
108
+
109
+ Surface specs are untrusted data. The renderer validates the whole document before sending any query. It rejects unknown components and sources, duplicate or overlong IDs, invalid props, dangerous pointers, mismatched versions, policy violations, and configured limits.
110
+
111
+ Specs cannot contain HTML, Svelte, JavaScript, event handlers, Tailwind classes, style declarations, colors, URLs, SQL, provider selection, `meta`, arbitrary requests, or mutation actions. Provider records are projected to `readFields`, and selected values containing `Date`, `File`, `BigInt`, functions, cycles, `NaN`, or infinities fail instead of being converted.
112
+
113
+ The browser access-control check is display gating only. The backend must independently authorize every request and must not trust the spec, policy, or projected fields received from a browser.
114
+
115
+ MVP limits are eight data sources, 24 widgets, 100 rows per page, eight filters, three sorters, 64-character IDs, 64 levels of JSON nesting, and 10,000 JSON nodes. A source is loaded once and shared. Generation checks discard stale responses.
116
+
117
+ Supported Core/UI ranges and the minimum packed-consumer matrix are published in `compatibility.json`.
118
+
119
+ ## Out of scope for v1
120
+
121
+ SSR/Lite, Agent generation, storage, revisions, JSON Patch, actions, mutations, automatic refresh, arbitrary URLs, client aggregation, Canvas, and iframe rendering are intentionally absent. Aggregated metrics should come from a backend summary resource and bind through `resource-one`.
122
+
123
+ 中文指南见文档站的“声明式 Surface”。
@@ -0,0 +1,22 @@
1
+ {
2
+ "surface": "0.1.x",
3
+ "minimumSupported": {
4
+ "@svadmin/core": "0.34.2",
5
+ "@svadmin/ui": "0.40.6",
6
+ "svelte": "5.56.8"
7
+ },
8
+ "testedCombinations": [
9
+ {
10
+ "name": "minimum-supported",
11
+ "core": "0.34.2",
12
+ "ui": "0.40.6",
13
+ "svelte": "5.56.8"
14
+ },
15
+ {
16
+ "name": "workspace-packed",
17
+ "core": "workspace tarball",
18
+ "ui": "workspace tarball",
19
+ "svelte": "workspace override"
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,2 @@
1
+ import type { SurfaceSourceDataState, SurfaceWidget, SurfaceWidgetDataState } from './types.js';
2
+ export declare function resolveSurfaceWidgetData(widget: SurfaceWidget, sourceStates: Readonly<Record<string, SurfaceSourceDataState>>): SurfaceWidgetDataState;
@@ -0,0 +1,55 @@
1
+ import { decodedJsonPointerToken } from './json.js';
2
+ function readJsonPointer(root, pointer) {
3
+ if (pointer === '')
4
+ return root;
5
+ if (!pointer.startsWith('/'))
6
+ return undefined;
7
+ let current = root;
8
+ for (const encodedToken of pointer.slice(1).split('/')) {
9
+ const token = decodedJsonPointerToken(encodedToken);
10
+ if (token === null || current === null || typeof current !== 'object')
11
+ return undefined;
12
+ if (Array.isArray(current)) {
13
+ if (!/^(?:0|[1-9][0-9]*)$/u.test(token))
14
+ return undefined;
15
+ const index = Number(token);
16
+ if (index >= current.length)
17
+ return undefined;
18
+ current = current[index];
19
+ continue;
20
+ }
21
+ const objectValue = current;
22
+ if (!Object.hasOwn(objectValue, token))
23
+ return undefined;
24
+ current = objectValue[token];
25
+ }
26
+ return current;
27
+ }
28
+ function missingPointerState(sourceId) {
29
+ return {
30
+ status: 'error',
31
+ sourceId,
32
+ error: {
33
+ code: 'binding_pointer_not_found',
34
+ sourceId,
35
+ message: 'The validated binding pointer was not found in the provider result',
36
+ },
37
+ };
38
+ }
39
+ export function resolveSurfaceWidgetData(widget, sourceStates) {
40
+ if (!widget.binding)
41
+ return { status: 'unbound' };
42
+ const sourceId = widget.binding.sourceId;
43
+ const sourceState = Object.hasOwn(sourceStates, sourceId) ? sourceStates[sourceId] : undefined;
44
+ if (!sourceState)
45
+ return { status: 'loading', sourceId };
46
+ if (sourceState.status !== 'ready')
47
+ return sourceState;
48
+ const value = readJsonPointer(sourceState.value, widget.binding.pointer ?? '');
49
+ if (value === undefined)
50
+ return missingPointerState(sourceId);
51
+ if (value === null || (Array.isArray(value) && value.length === 0)) {
52
+ return { status: 'empty', sourceId };
53
+ }
54
+ return { status: 'ready', sourceId, value };
55
+ }
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+ export declare const metricPropsSchema: z.ZodObject<{
3
+ label: z.ZodString;
4
+ format: z.ZodEnum<{
5
+ number: "number";
6
+ currency: "currency";
7
+ percent: "percent";
8
+ }>;
9
+ currency: z.ZodOptional<z.ZodString>;
10
+ description: z.ZodOptional<z.ZodString>;
11
+ }, z.core.$strict>;
12
+ export declare const resourceTablePropsSchema: z.ZodObject<{
13
+ title: z.ZodString;
14
+ emptyLabel: z.ZodOptional<z.ZodString>;
15
+ columns: z.ZodArray<z.ZodObject<{
16
+ field: z.ZodString;
17
+ label: z.ZodString;
18
+ format: z.ZodOptional<z.ZodEnum<{
19
+ number: "number";
20
+ boolean: "boolean";
21
+ text: "text";
22
+ date: "date";
23
+ }>>;
24
+ }, z.core.$strict>>;
25
+ }, z.core.$strict>;
26
+ export declare const barChartPropsSchema: z.ZodObject<{
27
+ title: z.ZodString;
28
+ labelField: z.ZodString;
29
+ valueField: z.ZodString;
30
+ showValues: z.ZodOptional<z.ZodBoolean>;
31
+ }, z.core.$strict>;
32
+ export declare const lineChartPropsSchema: z.ZodObject<{
33
+ title: z.ZodString;
34
+ labelField: z.ZodString;
35
+ valueField: z.ZodString;
36
+ showDots: z.ZodOptional<z.ZodBoolean>;
37
+ fill: z.ZodOptional<z.ZodBoolean>;
38
+ }, z.core.$strict>;
39
+ export type MetricProps = z.infer<typeof metricPropsSchema>;
40
+ export type ResourceTableProps = z.infer<typeof resourceTablePropsSchema>;
41
+ export type BarChartProps = z.infer<typeof barChartPropsSchema>;
42
+ export type LineChartProps = z.infer<typeof lineChartPropsSchema>;
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod';
2
+ const catalogFieldSchema = z.string().min(1).max(64).regex(/^[A-Za-z][A-Za-z0-9_-]*$/u);
3
+ export const metricPropsSchema = z.object({
4
+ label: z.string().min(1).max(80),
5
+ format: z.enum(['number', 'currency', 'percent']),
6
+ currency: z.string().regex(/^[A-Z]{3}$/u).optional(),
7
+ description: z.string().min(1).max(160).optional(),
8
+ }).strict().superRefine((props, context) => {
9
+ if (props.format === 'currency' && !props.currency) {
10
+ context.addIssue({ code: 'custom', path: ['currency'], message: 'Currency format requires an ISO currency code' });
11
+ }
12
+ if (props.format !== 'currency' && props.currency) {
13
+ context.addIssue({ code: 'custom', path: ['currency'], message: 'Currency is only valid with currency format' });
14
+ }
15
+ });
16
+ export const resourceTablePropsSchema = z.object({
17
+ title: z.string().min(1).max(80),
18
+ emptyLabel: z.string().min(1).max(80).optional(),
19
+ columns: z.array(z.object({
20
+ field: catalogFieldSchema,
21
+ label: z.string().min(1).max(60),
22
+ format: z.enum(['text', 'number', 'date', 'boolean']).optional(),
23
+ }).strict()).min(1).max(8),
24
+ }).strict();
25
+ export const barChartPropsSchema = z.object({
26
+ title: z.string().min(1).max(80),
27
+ labelField: catalogFieldSchema,
28
+ valueField: catalogFieldSchema,
29
+ showValues: z.boolean().optional(),
30
+ }).strict();
31
+ export const lineChartPropsSchema = z.object({
32
+ title: z.string().min(1).max(80),
33
+ labelField: catalogFieldSchema,
34
+ valueField: catalogFieldSchema,
35
+ showDots: z.boolean().optional(),
36
+ fill: z.boolean().optional(),
37
+ }).strict();
@@ -0,0 +1,79 @@
1
+ import type { Component } from 'svelte';
2
+ import type { JsonObject, SurfaceCatalog, SurfaceWidgetDataState, SurfaceWidgetDefinition } from './types.js';
3
+ export declare const DEFAULT_SURFACE_CATALOG_VERSION: "svadmin/v1";
4
+ export interface SurfaceWidgetRendererProps {
5
+ readonly widgetId: string;
6
+ readonly props: JsonObject;
7
+ readonly data: SurfaceWidgetDataState;
8
+ }
9
+ export interface SurfaceWidgetRegistration extends SurfaceWidgetDefinition {
10
+ readonly component: Component<SurfaceWidgetRendererProps>;
11
+ }
12
+ export interface SurfaceRenderCatalog extends SurfaceCatalog {
13
+ readonly widgets: readonly SurfaceWidgetRegistration[];
14
+ }
15
+ export declare function defineSurfaceCatalog<const TCatalog extends SurfaceRenderCatalog>(catalog: TCatalog): TCatalog;
16
+ declare function tableFields(props: JsonObject): readonly string[];
17
+ declare function barChartFields(props: JsonObject): readonly string[];
18
+ declare function lineChartFields(props: JsonObject): readonly string[];
19
+ export declare const defaultSurfaceCatalog: {
20
+ readonly version: "svadmin/v1";
21
+ readonly widgets: readonly [{
22
+ readonly type: "metric";
23
+ readonly dataKind: "scalar";
24
+ readonly propsSchema: import("zod").ZodObject<{
25
+ label: import("zod").ZodString;
26
+ format: import("zod").ZodEnum<{
27
+ number: "number";
28
+ currency: "currency";
29
+ percent: "percent";
30
+ }>;
31
+ currency: import("zod").ZodOptional<import("zod").ZodString>;
32
+ description: import("zod").ZodOptional<import("zod").ZodString>;
33
+ }, import("zod/v4/core").$strict>;
34
+ readonly component: Component<SurfaceWidgetRendererProps, {}, "">;
35
+ }, {
36
+ readonly type: "resource-table";
37
+ readonly dataKind: "items";
38
+ readonly propsSchema: import("zod").ZodObject<{
39
+ title: import("zod").ZodString;
40
+ emptyLabel: import("zod").ZodOptional<import("zod").ZodString>;
41
+ columns: import("zod").ZodArray<import("zod").ZodObject<{
42
+ field: import("zod").ZodString;
43
+ label: import("zod").ZodString;
44
+ format: import("zod").ZodOptional<import("zod").ZodEnum<{
45
+ number: "number";
46
+ boolean: "boolean";
47
+ text: "text";
48
+ date: "date";
49
+ }>>;
50
+ }, import("zod/v4/core").$strict>>;
51
+ }, import("zod/v4/core").$strict>;
52
+ readonly getReferencedFields: typeof tableFields;
53
+ readonly component: Component<SurfaceWidgetRendererProps, {}, "">;
54
+ }, {
55
+ readonly type: "bar-chart";
56
+ readonly dataKind: "items";
57
+ readonly propsSchema: import("zod").ZodObject<{
58
+ title: import("zod").ZodString;
59
+ labelField: import("zod").ZodString;
60
+ valueField: import("zod").ZodString;
61
+ showValues: import("zod").ZodOptional<import("zod").ZodBoolean>;
62
+ }, import("zod/v4/core").$strict>;
63
+ readonly getReferencedFields: typeof barChartFields;
64
+ readonly component: Component<SurfaceWidgetRendererProps, {}, "">;
65
+ }, {
66
+ readonly type: "line-chart";
67
+ readonly dataKind: "items";
68
+ readonly propsSchema: import("zod").ZodObject<{
69
+ title: import("zod").ZodString;
70
+ labelField: import("zod").ZodString;
71
+ valueField: import("zod").ZodString;
72
+ showDots: import("zod").ZodOptional<import("zod").ZodBoolean>;
73
+ fill: import("zod").ZodOptional<import("zod").ZodBoolean>;
74
+ }, import("zod/v4/core").$strict>;
75
+ readonly getReferencedFields: typeof lineChartFields;
76
+ readonly component: Component<SurfaceWidgetRendererProps, {}, "">;
77
+ }];
78
+ };
79
+ export {};
@@ -0,0 +1,60 @@
1
+ import BarChartWidget from './components/BarChartWidget.svelte';
2
+ import LineChartWidget from './components/LineChartWidget.svelte';
3
+ import MetricWidget from './components/MetricWidget.svelte';
4
+ import ResourceTableWidget from './components/ResourceTableWidget.svelte';
5
+ import { barChartPropsSchema, lineChartPropsSchema, metricPropsSchema, resourceTablePropsSchema, } from './builtin-schemas.js';
6
+ export const DEFAULT_SURFACE_CATALOG_VERSION = 'svadmin/v1';
7
+ export function defineSurfaceCatalog(catalog) {
8
+ if (catalog.version.length === 0)
9
+ throw new Error('Surface catalog version must not be empty');
10
+ const widgetTypes = new Set();
11
+ for (const widget of catalog.widgets) {
12
+ if (widgetTypes.has(widget.type))
13
+ throw new Error(`Duplicate surface widget type "${widget.type}"`);
14
+ widgetTypes.add(widget.type);
15
+ }
16
+ return catalog;
17
+ }
18
+ function tableFields(props) {
19
+ return resourceTablePropsSchema.parse(props).columns.map((column) => column.field);
20
+ }
21
+ function barChartFields(props) {
22
+ const chartProps = barChartPropsSchema.parse(props);
23
+ return [chartProps.labelField, chartProps.valueField];
24
+ }
25
+ function lineChartFields(props) {
26
+ const chartProps = lineChartPropsSchema.parse(props);
27
+ return [chartProps.labelField, chartProps.valueField];
28
+ }
29
+ export const defaultSurfaceCatalog = defineSurfaceCatalog({
30
+ version: DEFAULT_SURFACE_CATALOG_VERSION,
31
+ widgets: [
32
+ {
33
+ type: 'metric',
34
+ dataKind: 'scalar',
35
+ propsSchema: metricPropsSchema,
36
+ component: MetricWidget,
37
+ },
38
+ {
39
+ type: 'resource-table',
40
+ dataKind: 'items',
41
+ propsSchema: resourceTablePropsSchema,
42
+ getReferencedFields: tableFields,
43
+ component: ResourceTableWidget,
44
+ },
45
+ {
46
+ type: 'bar-chart',
47
+ dataKind: 'items',
48
+ propsSchema: barChartPropsSchema,
49
+ getReferencedFields: barChartFields,
50
+ component: BarChartWidget,
51
+ },
52
+ {
53
+ type: 'line-chart',
54
+ dataKind: 'items',
55
+ propsSchema: lineChartPropsSchema,
56
+ getReferencedFields: lineChartFields,
57
+ component: LineChartWidget,
58
+ },
59
+ ],
60
+ });
@@ -0,0 +1,70 @@
1
+ <script lang="ts">
2
+ import BarChart from '@svadmin/ui/components/charts/BarChart.svelte';
3
+ import CardContent from '@svadmin/ui/components/ui/card/card-content.svelte';
4
+ import CardHeader from '@svadmin/ui/components/ui/card/card-header.svelte';
5
+ import CardTitle from '@svadmin/ui/components/ui/card/card-title.svelte';
6
+ import Card from '@svadmin/ui/components/ui/card/card.svelte';
7
+ import { barChartPropsSchema } from '../builtin-schemas.js';
8
+ import type { SurfaceWidgetRendererProps } from '../catalog.js';
9
+ import { asChartPoints, compactChartLabel } from '../widget-data.js';
10
+
11
+ let { props, data }: SurfaceWidgetRendererProps = $props();
12
+
13
+ const chartProps = $derived(barChartPropsSchema.parse(props));
14
+ const points = $derived(data.status === 'ready'
15
+ ? asChartPoints(data.value, chartProps.labelField, chartProps.valueField)
16
+ : null);
17
+ const chartData = $derived(points?.ok
18
+ ? points.value.map((point) => ({ ...point, label: compactChartLabel(point.label, 12) }))
19
+ : []);
20
+ </script>
21
+
22
+ <Card>
23
+ <CardHeader>
24
+ <CardTitle>{chartProps.title}</CardTitle>
25
+ </CardHeader>
26
+ <CardContent>
27
+ {#if data.status === 'loading'}
28
+ <div class="chart-state chart-loading" role="status" aria-label="Loading {chartProps.title}"></div>
29
+ {:else if data.status === 'empty'}
30
+ <p class="chart-state" role="status">No chart data</p>
31
+ {:else if data.status === 'error'}
32
+ <p class="chart-state" role="alert">{data.error.message}</p>
33
+ {:else if data.status === 'ready' && points?.ok}
34
+ <div class="chart-frame" role="img" aria-label={chartProps.title}>
35
+ <BarChart data={chartData} showValues={chartProps.showValues ?? true} />
36
+ </div>
37
+ {:else}
38
+ <p class="chart-state" role="alert">{points && !points.ok ? points.message : 'Chart data is unavailable'}</p>
39
+ {/if}
40
+ </CardContent>
41
+ </Card>
42
+
43
+ <style>
44
+ .chart-state {
45
+ display: grid;
46
+ min-height: 12rem;
47
+ margin: 0;
48
+ place-items: center;
49
+ color: var(--muted-foreground);
50
+ }
51
+
52
+ .chart-frame :global(svg) {
53
+ min-width: 0 !important;
54
+ }
55
+
56
+ .chart-loading {
57
+ border-radius: 0.5rem;
58
+ background: linear-gradient(90deg, var(--muted), var(--card), var(--muted));
59
+ background-size: 200% 100%;
60
+ animation: surface-pulse 1.5s ease-in-out infinite;
61
+ }
62
+
63
+ @keyframes surface-pulse {
64
+ to { background-position: -200% 0; }
65
+ }
66
+
67
+ @media (prefers-reduced-motion: reduce) {
68
+ .chart-loading { animation: none; }
69
+ }
70
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { SurfaceWidgetRendererProps } from '../catalog.js';
2
+ declare const BarChartWidget: import("svelte").Component<SurfaceWidgetRendererProps, {}, "">;
3
+ type BarChartWidget = ReturnType<typeof BarChartWidget>;
4
+ export default BarChartWidget;
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ import LineChart from '@svadmin/ui/components/charts/LineChart.svelte';
3
+ import CardContent from '@svadmin/ui/components/ui/card/card-content.svelte';
4
+ import CardHeader from '@svadmin/ui/components/ui/card/card-header.svelte';
5
+ import CardTitle from '@svadmin/ui/components/ui/card/card-title.svelte';
6
+ import Card from '@svadmin/ui/components/ui/card/card.svelte';
7
+ import { lineChartPropsSchema } from '../builtin-schemas.js';
8
+ import type { SurfaceWidgetRendererProps } from '../catalog.js';
9
+ import { asChartPoints, compactChartLabel } from '../widget-data.js';
10
+
11
+ let { props, data }: SurfaceWidgetRendererProps = $props();
12
+
13
+ const chartProps = $derived(lineChartPropsSchema.parse(props));
14
+ const points = $derived(data.status === 'ready'
15
+ ? asChartPoints(data.value, chartProps.labelField, chartProps.valueField)
16
+ : null);
17
+ const chartData = $derived(points?.ok
18
+ ? points.value.map((point) => ({ ...point, label: compactChartLabel(point.label, 8) }))
19
+ : []);
20
+ </script>
21
+
22
+ <Card>
23
+ <CardHeader>
24
+ <CardTitle>{chartProps.title}</CardTitle>
25
+ </CardHeader>
26
+ <CardContent>
27
+ {#if data.status === 'loading'}
28
+ <div class="chart-state chart-loading" role="status" aria-label="Loading {chartProps.title}"></div>
29
+ {:else if data.status === 'empty'}
30
+ <p class="chart-state" role="status">No chart data</p>
31
+ {:else if data.status === 'error'}
32
+ <p class="chart-state" role="alert">{data.error.message}</p>
33
+ {:else if data.status === 'ready' && points?.ok}
34
+ <div class="chart-frame" role="img" aria-label={chartProps.title}>
35
+ <LineChart
36
+ data={chartData}
37
+ showDots={chartProps.showDots ?? true}
38
+ fill={chartProps.fill ?? true}
39
+ />
40
+ </div>
41
+ {:else}
42
+ <p class="chart-state" role="alert">{points && !points.ok ? points.message : 'Chart data is unavailable'}</p>
43
+ {/if}
44
+ </CardContent>
45
+ </Card>
46
+
47
+ <style>
48
+ .chart-state {
49
+ display: grid;
50
+ min-height: 12rem;
51
+ margin: 0;
52
+ place-items: center;
53
+ color: var(--muted-foreground);
54
+ }
55
+
56
+ .chart-frame :global(svg) {
57
+ min-width: 0 !important;
58
+ }
59
+
60
+ .chart-loading {
61
+ border-radius: 0.5rem;
62
+ background: linear-gradient(90deg, var(--muted), var(--card), var(--muted));
63
+ background-size: 200% 100%;
64
+ animation: surface-pulse 1.5s ease-in-out infinite;
65
+ }
66
+
67
+ @keyframes surface-pulse {
68
+ to { background-position: -200% 0; }
69
+ }
70
+
71
+ @media (prefers-reduced-motion: reduce) {
72
+ .chart-loading { animation: none; }
73
+ }
74
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { SurfaceWidgetRendererProps } from '../catalog.js';
2
+ declare const LineChartWidget: import("svelte").Component<SurfaceWidgetRendererProps, {}, "">;
3
+ type LineChartWidget = ReturnType<typeof LineChartWidget>;
4
+ export default LineChartWidget;
@@ -0,0 +1,72 @@
1
+ <script lang="ts">
2
+ import StatsCard from '@svadmin/ui/components/StatsCard.svelte';
3
+ import { metricPropsSchema } from '../builtin-schemas.js';
4
+ import type { SurfaceWidgetRendererProps } from '../catalog.js';
5
+
6
+ let { widgetId, props, data }: SurfaceWidgetRendererProps = $props();
7
+
8
+ const metricProps = $derived(metricPropsSchema.parse(props));
9
+ const formattedValue = $derived.by(() => {
10
+ if (data.status !== 'ready') return '—';
11
+ const value = data.value;
12
+ if (typeof value !== 'number') return '—';
13
+ if (metricProps.format === 'currency') {
14
+ return new Intl.NumberFormat('en-US', {
15
+ style: 'currency',
16
+ currency: metricProps.currency,
17
+ }).format(value);
18
+ }
19
+ if (metricProps.format === 'percent') {
20
+ return new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 }).format(value);
21
+ }
22
+ return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value);
23
+ });
24
+ </script>
25
+
26
+ <article aria-labelledby="{widgetId}-label" aria-busy={data.status === 'loading'}>
27
+ <span id="{widgetId}-label" class="visually-hidden">{metricProps.label}</span>
28
+ {#if data.status === 'loading'}
29
+ <StatsCard label={metricProps.label} value="" loading />
30
+ {:else if data.status === 'ready' && typeof data.value === 'number'}
31
+ <StatsCard label={metricProps.label} value={formattedValue} />
32
+ {#if metricProps.description}
33
+ <p class="metric-description">{metricProps.description}</p>
34
+ {/if}
35
+ {:else}
36
+ <div class="metric-state" role={data.status === 'error' ? 'alert' : 'status'}>
37
+ <strong>{metricProps.label}</strong>
38
+ <span>{data.status === 'error' ? data.error.message : data.status === 'empty' ? 'No data' : 'Metric data is invalid'}</span>
39
+ </div>
40
+ {/if}
41
+ </article>
42
+
43
+ <style>
44
+ .visually-hidden {
45
+ position: absolute;
46
+ width: 1px;
47
+ height: 1px;
48
+ padding: 0;
49
+ margin: -1px;
50
+ overflow: hidden;
51
+ clip: rect(0, 0, 0, 0);
52
+ white-space: nowrap;
53
+ border: 0;
54
+ }
55
+
56
+ .metric-description {
57
+ margin: 0.5rem 0 0;
58
+ color: var(--muted-foreground);
59
+ font-size: 0.8125rem;
60
+ }
61
+
62
+ .metric-state {
63
+ display: grid;
64
+ gap: 0.35rem;
65
+ min-height: 6rem;
66
+ padding: 1.25rem;
67
+ border: 1px solid var(--border);
68
+ border-radius: 0.75rem;
69
+ background: var(--card);
70
+ color: var(--muted-foreground);
71
+ }
72
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { SurfaceWidgetRendererProps } from '../catalog.js';
2
+ declare const MetricWidget: import("svelte").Component<SurfaceWidgetRendererProps, {}, "">;
3
+ type MetricWidget = ReturnType<typeof MetricWidget>;
4
+ export default MetricWidget;