@cosmicdrift/kumiko-renderer-web 0.130.1 → 0.131.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.130.1",
3
+ "version": "0.131.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.130.1",
20
- "@cosmicdrift/kumiko-headless": "0.130.1",
21
- "@cosmicdrift/kumiko-renderer": "0.130.1",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.131.0",
20
+ "@cosmicdrift/kumiko-headless": "0.131.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.131.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { DashboardScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
4
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
5
+ import {
6
+ DashboardBodyProvider,
7
+ DispatcherProvider,
8
+ KumikoScreen,
9
+ } from "@cosmicdrift/kumiko-renderer";
10
+ import { WebDashboardBody } from "../app/dashboard-body";
11
+ import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
12
+
13
+ const dashboardScreen: DashboardScreenDefinition = {
14
+ id: "overview",
15
+ type: "dashboard",
16
+ panels: [
17
+ {
18
+ kind: "stat",
19
+ id: "uptime",
20
+ label: "status:dashboard:uptime",
21
+ query: "status:query:monitor:uptime-stat",
22
+ valueField: "value",
23
+ subField: "sub",
24
+ toneField: "tone",
25
+ },
26
+ {
27
+ kind: "list",
28
+ id: "latest",
29
+ label: "status:dashboard:latest",
30
+ query: "status:query:incident:latest",
31
+ columns: [{ field: "name", label: "status:dashboard:col-name" }],
32
+ },
33
+ ],
34
+ };
35
+
36
+ const schema: FeatureSchema = {
37
+ featureName: "status",
38
+ entities: {},
39
+ screens: [dashboardScreen],
40
+ };
41
+
42
+ function makeDispatcher(): Dispatcher {
43
+ return createMockDispatcher({
44
+ query: (async (type: string) => {
45
+ if (type === "status:query:monitor:uptime-stat") {
46
+ return {
47
+ isSuccess: true,
48
+ data: { value: "99,98 %", sub: "letzte 90 Tage", tone: "positive" },
49
+ };
50
+ }
51
+ return {
52
+ isSuccess: true,
53
+ data: { rows: [{ id: "i1", name: "API-Ausfall" }], nextCursor: null },
54
+ };
55
+ }) as unknown as Dispatcher["query"],
56
+ });
57
+ }
58
+
59
+ describe("KumikoScreen dashboard", () => {
60
+ test("ohne registrierten Body → Placeholder-Banner", () => {
61
+ render(
62
+ <DispatcherProvider dispatcher={makeDispatcher()}>
63
+ <KumikoScreen schema={schema} qn="status:screen:overview" />
64
+ </DispatcherProvider>,
65
+ );
66
+ expect(screen.getByTestId("kumiko-screen-dashboard-placeholder")).toBeTruthy();
67
+ });
68
+
69
+ test("WebDashboardBody rendert Stat- und List-Panels aus den Queries", async () => {
70
+ render(
71
+ <DispatcherProvider dispatcher={makeDispatcher()}>
72
+ <DashboardBodyProvider value={WebDashboardBody}>
73
+ <KumikoScreen schema={schema} qn="status:screen:overview" />
74
+ </DashboardBodyProvider>
75
+ </DispatcherProvider>,
76
+ );
77
+
78
+ await waitFor(() => expect(screen.getByText("99,98 %")).toBeTruthy());
79
+ expect(screen.getByTestId("dashboard-overview")).toBeTruthy();
80
+ // Stat-Panel: Label (Key-Fallback), Wert, Sub-Zeile aus dem Query-Record.
81
+ expect(screen.getByText("status:dashboard:uptime")).toBeTruthy();
82
+ expect(screen.getByText("letzte 90 Tage")).toBeTruthy();
83
+ // List-Panel: Row aus der paged envelope.
84
+ await waitFor(() => expect(screen.getByText("API-Ausfall")).toBeTruthy());
85
+ });
86
+ });
@@ -241,6 +241,30 @@ describe("NavTree", () => {
241
241
  expect(container.querySelectorAll("svg").length).toBe(2);
242
242
  });
243
243
 
244
+ test("server, mail, download, rocket lösen auf ein Icon auf (Config-/SMTP-Nav)", () => {
245
+ // Config-Settings-Hub leitet Child-Nav-Icons aus dem mask.icon der
246
+ // ConfigKey ab (smtp-host="server", from="mail", subscription="rocket");
247
+ // fehlten sie in NAV_ICONS, rendert das Nav blank statt Icon.
248
+ const schema = {
249
+ featureName: "app",
250
+ entities: {},
251
+ screens: [
252
+ { id: "a", type: "entityList", entity: "x", columns: [] },
253
+ { id: "b", type: "entityList", entity: "x", columns: [] },
254
+ { id: "c", type: "entityList", entity: "x", columns: [] },
255
+ { id: "d", type: "entityList", entity: "x", columns: [] },
256
+ ],
257
+ navs: [
258
+ { id: "a", label: "SMTP", screen: "a", order: 10, icon: "server" },
259
+ { id: "b", label: "From", screen: "b", order: 20, icon: "mail" },
260
+ { id: "c", label: "Export", screen: "c", order: 30, icon: "download" },
261
+ { id: "d", label: "Billing", screen: "d", order: 40, icon: "rocket" },
262
+ ],
263
+ } as FeatureSchema;
264
+ const { container } = render(<NavTree schema={schema} />);
265
+ expect(container.querySelectorAll("svg").length).toBe(4);
266
+ });
267
+
244
268
  test("palette, link und share rendern Lucide-Icons (Share-/Branding-Nav)", () => {
245
269
  const schema = {
246
270
  featureName: "app",
@@ -542,3 +566,65 @@ describe("NavTree dynamic provider nodes", () => {
542
566
  expect(active()).toBe(0); // Unmount baut alles ab → kein Leak
543
567
  });
544
568
  });
569
+
570
+ describe("NavTree Suchfeld", () => {
571
+ // Das Suchfeld ist das einzige <input> im Baum; über die Rolle statt den
572
+ // i18n-Placeholder gefunden (i18n-Bundle resolvt im Worktree gegen den
573
+ // Haupt-Checkout, wo der neue Key noch fehlt — in CI greift er).
574
+
575
+ test("Filter blendet Nicht-Treffer aus, hält Treffer + deren Ancestors", () => {
576
+ render(<NavTree schema={makeSchema()} />);
577
+ const input = screen.getByRole("textbox") as HTMLInputElement;
578
+
579
+ fireEvent.change(input, { target: { value: "active" } });
580
+
581
+ // Treffer selbst sichtbar (case-insensitiv).
582
+ expect(screen.getByText("Active")).toBeTruthy();
583
+ // Nicht-Treffer-Geschwister weg.
584
+ expect(screen.queryByText("Backlog")).toBeNull();
585
+ // Ancestors bleiben, damit der Treffer erreichbar bleibt.
586
+ expect(screen.getByText("Items")).toBeTruthy();
587
+ expect(screen.getByText("Data")).toBeTruthy();
588
+ });
589
+
590
+ test("Filter ohne Treffer leert den Baum; Leeren stellt alles wieder her", () => {
591
+ render(<NavTree schema={makeSchema()} />);
592
+ const input = screen.getByRole("textbox") as HTMLInputElement;
593
+
594
+ fireEvent.change(input, { target: { value: "zzz-nichts" } });
595
+ expect(screen.queryByText("Items")).toBeNull();
596
+ expect(screen.queryByText("Data")).toBeNull();
597
+
598
+ fireEvent.change(input, { target: { value: "" } });
599
+ expect(screen.getByText("Backlog")).toBeTruthy();
600
+ expect(screen.getByText("Data")).toBeTruthy();
601
+ });
602
+ });
603
+
604
+ describe("NavTree folder-open Icon", () => {
605
+ test("Folder-Knoten zeigt folder-open wenn expanded, folder wenn collapsed", async () => {
606
+ const provider: TreeChildrenSubscribe = () => (emit) => {
607
+ emit([{ label: "page", icon: "folder", children: [pageLeaf("hero")] }]);
608
+ return () => {};
609
+ };
610
+ let container: HTMLElement | undefined;
611
+ await act(async () => {
612
+ container = renderDynamic({
613
+ schema: dynamicSchema(),
614
+ providers: new Map([["cms:nav:content", provider]]),
615
+ }).container;
616
+ });
617
+ // Default-expanded → offener Ordner, kein geschlossener.
618
+ expect(container?.querySelector(".lucide-folder-open")).toBeTruthy();
619
+ expect(container?.querySelector(".lucide-folder")).toBeNull();
620
+
621
+ // Der einzige aria-gelabelte Chevron gehört dem "page"-Sub-Ordner
622
+ // (der Provider-Container toggelt inline, ohne aria-Label).
623
+ const chevron = screen.getByRole("button", { name: /Expand|Collapse/ });
624
+ await act(async () => {
625
+ fireEvent.click(chevron);
626
+ });
627
+ expect(container?.querySelector(".lucide-folder")).toBeTruthy();
628
+ expect(container?.querySelector(".lucide-folder-open")).toBeNull();
629
+ });
630
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
4
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
5
+ import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
6
+ import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
7
+
8
+ // writeHandler-Row/Toolbar-Actions auf projectionList — der entityList-
9
+ // Dispatch-Pfad gilt jetzt auch hier (vorher v1: nur navigate).
10
+
11
+ const projectionScreen: ProjectionListScreenDefinition = {
12
+ id: "maintenance-list",
13
+ type: "projectionList",
14
+ query: "status:query:maintenance:upcoming",
15
+ columns: [{ field: "name", label: "status:col:name" }],
16
+ rowActions: [
17
+ {
18
+ kind: "writeHandler",
19
+ id: "start",
20
+ label: "status:action:start",
21
+ handler: "status:write:maintenance:start",
22
+ },
23
+ ],
24
+ toolbarActions: [
25
+ {
26
+ kind: "writeHandler",
27
+ id: "sync",
28
+ label: "status:action:sync",
29
+ handler: "status:write:maintenance:sync",
30
+ payload: { source: "manual" },
31
+ },
32
+ ],
33
+ };
34
+
35
+ const schema: FeatureSchema = {
36
+ featureName: "status",
37
+ entities: {},
38
+ screens: [projectionScreen],
39
+ };
40
+
41
+ function makeDispatcher(write: Dispatcher["write"]): Dispatcher {
42
+ return {
43
+ ...createMockDispatcher({
44
+ query: (async () => ({
45
+ isSuccess: true,
46
+ data: { rows: [{ id: "m1", name: "DB-Upgrade" }], nextCursor: null },
47
+ })) as unknown as Dispatcher["query"],
48
+ }),
49
+ write,
50
+ };
51
+ }
52
+
53
+ describe("projectionList writeHandler-Actions", () => {
54
+ test("Row-Action dispatcht den Handler mit Default-Payload {id}", async () => {
55
+ const write = mock(async (_type: string, _payload: unknown) => ({
56
+ isSuccess: true,
57
+ data: {},
58
+ }));
59
+ render(
60
+ <DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
61
+ <KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
62
+ </DispatcherProvider>,
63
+ );
64
+ await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
65
+
66
+ fireEvent.click(screen.getByRole("button", { name: "status:action:start" }));
67
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
68
+ expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:start");
69
+ expect(write.mock.calls[0]?.[1]).toEqual({ id: "m1" });
70
+ });
71
+
72
+ test("Toolbar-Action dispatcht den Handler mit deklariertem Payload", async () => {
73
+ const write = mock(async (_type: string, _payload: unknown) => ({
74
+ isSuccess: true,
75
+ data: {},
76
+ }));
77
+ render(
78
+ <DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
79
+ <KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
80
+ </DispatcherProvider>,
81
+ );
82
+ await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
83
+
84
+ fireEvent.click(screen.getByRole("button", { name: "status:action:sync" }));
85
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
86
+ expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:sync");
87
+ expect(write.mock.calls[0]?.[1]).toEqual({ source: "manual" });
88
+ });
89
+ });
@@ -11,6 +11,7 @@ import {
11
11
  type ColumnRendererComponent,
12
12
  ColumnRenderersProvider,
13
13
  CustomScreensProvider,
14
+ DashboardBodyProvider,
14
15
  DispatcherProvider,
15
16
  type ExtensionSectionComponent,
16
17
  ExtensionSectionsProvider,
@@ -38,6 +39,7 @@ import { useBrowserTokensApi } from "../tokens";
38
39
  import { UpdateChecker } from "../version/update-checker";
39
40
  import { createBrowserLocaleResolver } from "./browser-locale";
40
41
  import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
42
+ import { WebDashboardBody } from "./dashboard-body";
41
43
  import { useBrowserNavApi } from "./nav";
42
44
  import { NavProvidersProvider } from "./nav-providers-context";
43
45
  import { type ResolverComponent, ResolversProvider } from "./resolvers-context";
@@ -285,20 +287,22 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
285
287
  <PrimitivesProvider value={primitives}>
286
288
  <DispatcherProvider dispatcher={dispatcher}>
287
289
  <LiveEventsProvider value={liveEvents}>
288
- <CustomScreensProvider value={customScreens}>
289
- <ColumnRenderersProvider value={columnRenderers}>
290
- <ExtensionSectionsProvider value={extensionSectionComponents}>
291
- <NavProvidersProvider value={navProviders} entities={navEntities}>
292
- <ResolversProvider resolvers={resolvers}>
293
- <ToastProvider>
294
- <UpdateChecker />
295
- {stackWrappers(providers, stackWrappers(gates, screenNode))}
296
- </ToastProvider>
297
- </ResolversProvider>
298
- </NavProvidersProvider>
299
- </ExtensionSectionsProvider>
300
- </ColumnRenderersProvider>
301
- </CustomScreensProvider>
290
+ <DashboardBodyProvider value={WebDashboardBody}>
291
+ <CustomScreensProvider value={customScreens}>
292
+ <ColumnRenderersProvider value={columnRenderers}>
293
+ <ExtensionSectionsProvider value={extensionSectionComponents}>
294
+ <NavProvidersProvider value={navProviders} entities={navEntities}>
295
+ <ResolversProvider resolvers={resolvers}>
296
+ <ToastProvider>
297
+ <UpdateChecker />
298
+ {stackWrappers(providers, stackWrappers(gates, screenNode))}
299
+ </ToastProvider>
300
+ </ResolversProvider>
301
+ </NavProvidersProvider>
302
+ </ExtensionSectionsProvider>
303
+ </ColumnRenderersProvider>
304
+ </CustomScreensProvider>
305
+ </DashboardBodyProvider>
302
306
  </LiveEventsProvider>
303
307
  </DispatcherProvider>
304
308
  </PrimitivesProvider>
@@ -0,0 +1,146 @@
1
+ // Web-Implementierung des dashboard-Screen-Typs: rendert die deklarierten
2
+ // Panels über das Widget-Kit (StatCard, TimeseriesChart, QueryTable) in
3
+ // einem responsiven Grid. Registriert via DashboardBodyProvider in
4
+ // createKumikoApp — der KumikoScreen-Switch bleibt plattform-agnostisch.
5
+ //
6
+ // Panel-Daten-Contracts (siehe DashboardPanelDefinition in kumiko-framework):
7
+ // stat → flaches Record, valueField/subField/toneField zeigen auf
8
+ // anzeigefertige Werte (der Query-Handler formatiert).
9
+ // chart → { points: { atMs, value | null }[], windowStartMs, windowEndMs }
10
+ // list → paged envelope { rows, nextCursor, total? } wie projectionList.
11
+
12
+ import type {
13
+ DashboardChartPanel,
14
+ DashboardListPanel,
15
+ DashboardStatPanel,
16
+ } from "@cosmicdrift/kumiko-framework/ui-types";
17
+ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
18
+ import { type DashboardBodyProps, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
+ import type { ReactNode } from "react";
20
+ import { TimeseriesChart, type TimeseriesPoint } from "../widgets/charts";
21
+ import { QueryTable } from "../widgets/query-table";
22
+ import { SectionCard } from "../widgets/section-card";
23
+ import { StatCard, type StatTone } from "../widgets/stat";
24
+ import { ErrorState, LoadingState } from "../widgets/states";
25
+
26
+ const STAT_TONES: ReadonlySet<string> = new Set(["default", "positive", "warn"]);
27
+
28
+ function StatPanelBody({
29
+ panel,
30
+ label,
31
+ }: {
32
+ readonly panel: DashboardStatPanel;
33
+ readonly label: string;
34
+ }): ReactNode {
35
+ const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
36
+ panel.query,
37
+ {},
38
+ { live: true },
39
+ );
40
+ if (loading && data === null) return <LoadingState rows={2} />;
41
+ if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
42
+ const record = data ?? {};
43
+ const rawTone = panel.toneField !== undefined ? record[panel.toneField] : undefined;
44
+ const tone =
45
+ typeof rawTone === "string" && STAT_TONES.has(rawTone) ? (rawTone as StatTone) : "default";
46
+ const sub = panel.subField !== undefined ? record[panel.subField] : undefined;
47
+ return (
48
+ <StatCard
49
+ label={label}
50
+ value={String(record[panel.valueField] ?? "—")}
51
+ tone={tone}
52
+ {...(sub !== undefined && sub !== null && { sub: String(sub) })}
53
+ testId={`dashboard-panel-${panel.id}`}
54
+ />
55
+ );
56
+ }
57
+
58
+ type TimeseriesEnvelope = {
59
+ readonly points: readonly TimeseriesPoint[];
60
+ readonly windowStartMs: number;
61
+ readonly windowEndMs: number;
62
+ };
63
+
64
+ function ChartPanelBody({
65
+ panel,
66
+ label,
67
+ }: {
68
+ readonly panel: DashboardChartPanel;
69
+ readonly label: string;
70
+ }): ReactNode {
71
+ const t = useTranslation();
72
+ const { data, error, loading, refetch } = useQuery<TimeseriesEnvelope>(
73
+ panel.query,
74
+ {},
75
+ { live: true },
76
+ );
77
+ if (loading && data === null) return <LoadingState rows={3} />;
78
+ if (error !== null) return <ErrorState error={error} onRetry={() => void refetch()} />;
79
+ return (
80
+ <SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
81
+ <TimeseriesChart
82
+ points={data?.points ?? []}
83
+ windowStartMs={data?.windowStartMs ?? 0}
84
+ windowEndMs={data?.windowEndMs ?? 1}
85
+ ariaLabel={label}
86
+ emptyContent={t("kumiko.list.no-entries")}
87
+ />
88
+ </SectionCard>
89
+ );
90
+ }
91
+
92
+ function ListPanelBody({
93
+ panel,
94
+ label,
95
+ }: {
96
+ readonly panel: DashboardListPanel;
97
+ readonly label: string;
98
+ }): ReactNode {
99
+ const t = useTranslation();
100
+ return (
101
+ <SectionCard title={label} testId={`dashboard-panel-${panel.id}`}>
102
+ <QueryTable<{ readonly rows: readonly Readonly<Record<string, unknown>>[] }>
103
+ query={panel.query}
104
+ live
105
+ columns={panel.columns.map((c) => {
106
+ const normalized = normalizeListColumn(c);
107
+ return {
108
+ field: normalized.field,
109
+ label: t(normalized.label ?? normalized.field),
110
+ };
111
+ })}
112
+ rows={(data) => data.rows}
113
+ />
114
+ </SectionCard>
115
+ );
116
+ }
117
+
118
+ export function WebDashboardBody({ screen, translate }: DashboardBodyProps): ReactNode {
119
+ const t = useTranslation();
120
+ const effectiveTranslate = translate ?? t;
121
+ return (
122
+ <div
123
+ className="grid gap-4 p-6 sm:grid-cols-2 lg:grid-cols-4"
124
+ data-testid={`dashboard-${screen.id}`}
125
+ >
126
+ {screen.panels.map((panel) => {
127
+ const label = effectiveTranslate(panel.label);
128
+ if (panel.kind === "stat") {
129
+ return <StatPanelBody key={panel.id} panel={panel} label={label} />;
130
+ }
131
+ if (panel.kind === "chart") {
132
+ return (
133
+ <div key={panel.id} className="sm:col-span-2 lg:col-span-4">
134
+ <ChartPanelBody panel={panel} label={label} />
135
+ </div>
136
+ );
137
+ }
138
+ return (
139
+ <div key={panel.id} className="sm:col-span-2 lg:col-span-4">
140
+ <ListPanelBody panel={panel} label={label} />
141
+ </div>
142
+ );
143
+ })}
144
+ </div>
145
+ );
146
+ }
package/src/index.ts CHANGED
@@ -154,3 +154,31 @@ export {
154
154
  useBrowserTokensApi,
155
155
  } from "./tokens";
156
156
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
157
+ export type {
158
+ QueryTableColumn,
159
+ QueryTableProps,
160
+ StatDelta,
161
+ StatTone,
162
+ StatusBarEntry,
163
+ StatusTone,
164
+ TimeseriesPoint,
165
+ } from "./widgets";
166
+ export {
167
+ CollapsibleSection,
168
+ DetailList,
169
+ EmptyState,
170
+ ErrorState,
171
+ LoadingState,
172
+ MiniStat,
173
+ ModeSwitch,
174
+ ProgressBar,
175
+ QueryTable,
176
+ SectionCard,
177
+ Sparkline,
178
+ STATUS_TONE_TEXT,
179
+ StatCard,
180
+ StatusBadge,
181
+ StatusBarChart,
182
+ smoothPath,
183
+ TimeseriesChart,
184
+ } from "./widgets";