@cosmicdrift/kumiko-renderer-web 0.224.1 → 0.225.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.224.1",
3
+ "version": "0.225.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.224.1",
20
- "@cosmicdrift/kumiko-headless": "0.224.1",
21
- "@cosmicdrift/kumiko-renderer": "0.224.1",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.225.0",
20
+ "@cosmicdrift/kumiko-headless": "0.225.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.225.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",
@@ -64,7 +64,7 @@
64
64
  "@types/react-dom": "^19.2.3",
65
65
  "jsdom": "^29.1.1",
66
66
  "tailwindcss": "^4.3.0",
67
- "@cosmicdrift/kumiko-locale-de": "0.224.1"
67
+ "@cosmicdrift/kumiko-locale-de": "0.225.0"
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import type { DashboardScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
4
4
  import type { ExtensionSectionProps, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
@@ -7,10 +7,12 @@ import {
7
7
  DispatcherProvider,
8
8
  ExtensionSectionsProvider,
9
9
  KumikoScreen,
10
+ NavProvider,
10
11
  } from "@cosmicdrift/kumiko-renderer";
11
12
  import userEvent from "@testing-library/user-event";
12
13
  import type { ReactNode } from "react";
13
14
  import { WebDashboardBody } from "../app/dashboard-body";
15
+ import { useBrowserNavApi } from "../app/nav";
14
16
  import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
15
17
 
16
18
  const dashboardScreen: DashboardScreenDefinition = {
@@ -153,7 +155,16 @@ const richSchema: FeatureSchema = {
153
155
  screens: [richScreen],
154
156
  };
155
157
 
158
+ function BrowserNav({ children }: { readonly children: ReactNode }): ReactNode {
159
+ const nav = useBrowserNavApi({ hasWorkspaces: false });
160
+ return <NavProvider value={nav}>{children}</NavProvider>;
161
+ }
162
+
156
163
  describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
164
+ afterEach(() => {
165
+ window.history.replaceState(null, "", "/");
166
+ });
167
+
157
168
  test("stat-group rendert Sektions-Titel + genestete Stat-Panels", async () => {
158
169
  const dispatcher = createMockDispatcher({
159
170
  query: (async (type: string) => {
@@ -353,6 +364,8 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
353
364
  });
354
365
 
355
366
  test("Filter-Wechsel refetcht Stat- UND Feed-Panel mit neuem Payload", async () => {
367
+ window.history.replaceState(null, "", "/rich");
368
+
356
369
  const calls: { readonly type: string; readonly payload: unknown }[] = [];
357
370
  const dispatcher = createMockDispatcher({
358
371
  query: (async (type: string, payload: unknown) => {
@@ -374,11 +387,13 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
374
387
  });
375
388
  const user = userEvent.setup();
376
389
  render(
377
- <DispatcherProvider dispatcher={dispatcher}>
378
- <DashboardBodyProvider value={WebDashboardBody}>
379
- <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
380
- </DashboardBodyProvider>
381
- </DispatcherProvider>,
390
+ <BrowserNav>
391
+ <DispatcherProvider dispatcher={dispatcher}>
392
+ <DashboardBodyProvider value={WebDashboardBody}>
393
+ <KumikoScreen schema={richSchema} qn="widgets:screen:rich" />
394
+ </DashboardBodyProvider>
395
+ </DispatcherProvider>
396
+ </BrowserNav>,
382
397
  );
383
398
  await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
384
399
  await waitFor(() => expect(screen.getByText("EU-Event")).toBeTruthy());
@@ -390,4 +405,55 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
390
405
  await waitFor(() => expect(screen.getByText("38.120 $")).toBeTruthy());
391
406
  await waitFor(() => expect(screen.getByText("US-Event")).toBeTruthy());
392
407
  });
408
+
409
+ test("Filter-Wert aus URL-Search-Param vorbelegt: Deep-Link ?tenantId=t-2 landet in der Panel-Query", async () => {
410
+ window.history.replaceState(null, "", "/rich?tenantId=t-2");
411
+
412
+ const calls: { readonly type: string; readonly payload: unknown }[] = [];
413
+ const dispatcher = createMockDispatcher({
414
+ query: (async (type: string, payload: unknown) => {
415
+ calls.push({ type, payload });
416
+ return { isSuccess: true, data: { value: "92.753 €" } };
417
+ }) as unknown as Dispatcher["query"],
418
+ });
419
+
420
+ const deepLinkScreen: DashboardScreenDefinition = {
421
+ id: "rich",
422
+ type: "dashboard",
423
+ filter: {
424
+ id: "tenantId",
425
+ label: "widgets:dashboard:filter-tenant",
426
+ kind: "select",
427
+ options: [{ value: "t-2", label: "widgets:dashboard:filter-tenant-t2" }],
428
+ },
429
+ panels: [
430
+ {
431
+ kind: "stat",
432
+ id: "kpi",
433
+ label: "widgets:dashboard:kpi",
434
+ query: "widgets:query:metrics:kpi",
435
+ valueField: "value",
436
+ },
437
+ ],
438
+ };
439
+ const deepLinkSchema: FeatureSchema = {
440
+ featureName: "widgets",
441
+ entities: {},
442
+ screens: [deepLinkScreen],
443
+ };
444
+
445
+ render(
446
+ <BrowserNav>
447
+ <DispatcherProvider dispatcher={dispatcher}>
448
+ <DashboardBodyProvider value={WebDashboardBody}>
449
+ <KumikoScreen schema={deepLinkSchema} qn="widgets:screen:rich" />
450
+ </DashboardBodyProvider>
451
+ </DispatcherProvider>
452
+ </BrowserNav>,
453
+ );
454
+
455
+ await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
456
+ const kpiCall = calls.find((c) => c.type === "widgets:query:metrics:kpi");
457
+ expect(kpiCall?.payload).toEqual({ tenantId: "t-2" });
458
+ });
393
459
  });
@@ -0,0 +1,305 @@
1
+ // projectionDetail record header + metrics band + tabs (fw record-screen-type).
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
6
+ import type { FeatureSchema, NavApi } from "@cosmicdrift/kumiko-renderer";
7
+ import { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
8
+ import userEvent from "@testing-library/user-event";
9
+ import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
10
+
11
+ const baseScreen: ProjectionDetailScreenDefinition = {
12
+ id: "rent-detail",
13
+ type: "projectionDetail",
14
+ query: "rentals:query:rent:detail",
15
+ idParam: "id",
16
+ layout: {
17
+ sections: [{ title: "Rent", fields: ["description"] }],
18
+ },
19
+ };
20
+
21
+ const rowData = {
22
+ description: "Loft 4B",
23
+ tenantName: "Jamie Rivera",
24
+ address: "12 Canal St",
25
+ state: "active",
26
+ balance: "120",
27
+ overdueDays: "3",
28
+ };
29
+
30
+ function schemaFor(screen: ProjectionDetailScreenDefinition): FeatureSchema {
31
+ return { featureName: "rentals", entities: {}, screens: [screen] };
32
+ }
33
+
34
+ function dispatcherReturning(
35
+ data: Readonly<Record<string, unknown>>,
36
+ ): Dispatcher & { readonly calls: { readonly type: string; readonly payload: unknown }[] } {
37
+ const calls: { type: string; payload: unknown }[] = [];
38
+ const query = (async (type: string, payload: unknown) => {
39
+ calls.push({ type, payload });
40
+ if (type === "rentals:query:rent:detail") return { isSuccess: true, data };
41
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
42
+ }) as unknown as Dispatcher["query"];
43
+ const dispatcher = createMockDispatcher({ query });
44
+ return Object.assign(dispatcher, { calls });
45
+ }
46
+
47
+ describe("KumikoScreen / projectionDetail — record header + metrics band", () => {
48
+ test("renders header title/subtitle/status from the query row's named columns", async () => {
49
+ const headerScreen: ProjectionDetailScreenDefinition = {
50
+ ...baseScreen,
51
+ header: { title: "tenantName", subtitle: "address", status: "state" },
52
+ };
53
+ const dispatcher = dispatcherReturning(rowData);
54
+
55
+ render(
56
+ <DispatcherProvider dispatcher={dispatcher}>
57
+ <KumikoScreen
58
+ schema={schemaFor(headerScreen)}
59
+ qn="rentals:screen:rent-detail"
60
+ entityId="rent-1"
61
+ />
62
+ </DispatcherProvider>,
63
+ );
64
+
65
+ await waitFor(() => screen.getByTestId("render-edit-form"));
66
+ expect(screen.getByTestId("kumiko-screen-projection-detail-title").textContent).toBe(
67
+ "Jamie Rivera",
68
+ );
69
+ expect(screen.getByTestId("kumiko-screen-projection-detail-subtitle").textContent).toBe(
70
+ "12 Canal St",
71
+ );
72
+ expect(screen.getByTestId("kumiko-screen-projection-detail-status").textContent).toBe("active");
73
+ });
74
+
75
+ test("renders the metrics band with fieldLabels-translated labels and query-row values", async () => {
76
+ const metricsScreen: ProjectionDetailScreenDefinition = {
77
+ ...baseScreen,
78
+ metrics: ["balance", "overdueDays"],
79
+ fieldLabels: {
80
+ balance: "rentals.detail.metric.balance",
81
+ overdueDays: "rentals.detail.metric.overdueDays",
82
+ },
83
+ };
84
+ const dispatcher = dispatcherReturning(rowData);
85
+
86
+ render(
87
+ <DispatcherProvider dispatcher={dispatcher}>
88
+ <KumikoScreen
89
+ schema={schemaFor(metricsScreen)}
90
+ qn="rentals:screen:rent-detail"
91
+ entityId="rent-1"
92
+ />
93
+ </DispatcherProvider>,
94
+ );
95
+
96
+ await waitFor(() => screen.getByTestId("render-edit-form"));
97
+ expect(
98
+ screen.getByTestId("kumiko-screen-projection-detail-metric-balance-label").textContent,
99
+ ).toBe("rentals.detail.metric.balance");
100
+ expect(
101
+ screen.getByTestId("kumiko-screen-projection-detail-metric-balance-value").textContent,
102
+ ).toBe("120");
103
+ expect(
104
+ screen.getByTestId("kumiko-screen-projection-detail-metric-overdueDays-value").textContent,
105
+ ).toBe("3");
106
+ });
107
+
108
+ test("without header/metrics, neither renders — regression protection for existing screens", async () => {
109
+ const dispatcher = dispatcherReturning(rowData);
110
+
111
+ render(
112
+ <DispatcherProvider dispatcher={dispatcher}>
113
+ <KumikoScreen
114
+ schema={schemaFor(baseScreen)}
115
+ qn="rentals:screen:rent-detail"
116
+ entityId="rent-1"
117
+ />
118
+ </DispatcherProvider>,
119
+ );
120
+
121
+ await waitFor(() => screen.getByTestId("render-edit-form"));
122
+ expect(screen.queryByTestId("kumiko-screen-projection-detail-title")).toBeNull();
123
+ expect(screen.queryByTestId("kumiko-screen-projection-detail-metrics")).toBeNull();
124
+ });
125
+ });
126
+
127
+ describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
128
+ const tabsScreen: ProjectionDetailScreenDefinition = {
129
+ ...baseScreen,
130
+ layout: {
131
+ mode: "tabs",
132
+ sections: [
133
+ { id: "overview", title: "Overview", fields: ["description"] },
134
+ {
135
+ id: "payments",
136
+ kind: "relatedList",
137
+ title: "Payments",
138
+ query: "rentals:query:rent:payments",
139
+ columns: [{ field: "amount", label: "Amount" }],
140
+ },
141
+ {
142
+ id: "invoices",
143
+ kind: "relatedList",
144
+ title: "Invoices",
145
+ query: "rentals:query:rent:invoices",
146
+ columns: [{ field: "amount", label: "Amount" }],
147
+ },
148
+ ],
149
+ },
150
+ };
151
+
152
+ function navWithTab(tab: string | undefined): {
153
+ readonly navApi: NavApi;
154
+ readonly setSearchParamsCalls: Readonly<Record<string, string | null>>[];
155
+ } {
156
+ const setSearchParamsCalls: Readonly<Record<string, string | null>>[] = [];
157
+ return {
158
+ navApi: {
159
+ route: undefined,
160
+ navigate: () => {},
161
+ replace: () => {},
162
+ hrefFor: () => "",
163
+ searchParams: tab !== undefined ? { tab } : {},
164
+ setSearchParams: (updates) => setSearchParamsCalls.push(updates),
165
+ },
166
+ setSearchParamsCalls,
167
+ };
168
+ }
169
+
170
+ test("fires exactly one query on first render — the inactive tabs' relatedList queries never fire", async () => {
171
+ const dispatcher = dispatcherReturning(rowData);
172
+ const { navApi } = navWithTab(undefined);
173
+
174
+ render(
175
+ <NavProvider value={navApi}>
176
+ <DispatcherProvider dispatcher={dispatcher}>
177
+ <KumikoScreen
178
+ schema={schemaFor(tabsScreen)}
179
+ qn="rentals:screen:rent-detail"
180
+ entityId="rent-1"
181
+ />
182
+ </DispatcherProvider>
183
+ </NavProvider>,
184
+ );
185
+
186
+ await waitFor(() => screen.getByTestId("render-edit-form"));
187
+ expect(dispatcher.calls).toHaveLength(1);
188
+ expect(dispatcher.calls[0]?.type).toBe("rentals:query:rent:detail");
189
+ });
190
+
191
+ test("?tab= selects the matching section — its content renders, the others don't", async () => {
192
+ const dispatcher = dispatcherReturning(rowData);
193
+ const { navApi } = navWithTab("payments");
194
+
195
+ render(
196
+ <NavProvider value={navApi}>
197
+ <DispatcherProvider dispatcher={dispatcher}>
198
+ <KumikoScreen
199
+ schema={schemaFor(tabsScreen)}
200
+ qn="rentals:screen:rent-detail"
201
+ entityId="rent-1"
202
+ />
203
+ </DispatcherProvider>
204
+ </NavProvider>,
205
+ );
206
+
207
+ await waitFor(() =>
208
+ expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
209
+ );
210
+ expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:invoices")).toBe(false);
211
+ expect(screen.queryByTestId("field-description")).toBeNull();
212
+ });
213
+
214
+ test("an unknown ?tab= value falls back to the first section", async () => {
215
+ const dispatcher = dispatcherReturning(rowData);
216
+ const { navApi } = navWithTab("does-not-exist");
217
+
218
+ render(
219
+ <NavProvider value={navApi}>
220
+ <DispatcherProvider dispatcher={dispatcher}>
221
+ <KumikoScreen
222
+ schema={schemaFor(tabsScreen)}
223
+ qn="rentals:screen:rent-detail"
224
+ entityId="rent-1"
225
+ />
226
+ </DispatcherProvider>
227
+ </NavProvider>,
228
+ );
229
+
230
+ await waitFor(() => screen.getByTestId("field-description"));
231
+ expect(dispatcher.calls).toHaveLength(1);
232
+ });
233
+
234
+ test("clicking a tab calls nav.setSearchParams with the tab's id", async () => {
235
+ const dispatcher = dispatcherReturning(rowData);
236
+ const { navApi, setSearchParamsCalls } = navWithTab(undefined);
237
+ const user = userEvent.setup();
238
+
239
+ render(
240
+ <NavProvider value={navApi}>
241
+ <DispatcherProvider dispatcher={dispatcher}>
242
+ <KumikoScreen
243
+ schema={schemaFor(tabsScreen)}
244
+ qn="rentals:screen:rent-detail"
245
+ entityId="rent-1"
246
+ />
247
+ </DispatcherProvider>
248
+ </NavProvider>,
249
+ );
250
+
251
+ const trigger = await waitFor(() =>
252
+ screen.getByTestId("kumiko-screen-projection-detail-tabs-payments"),
253
+ );
254
+ await user.click(trigger);
255
+
256
+ expect(setSearchParamsCalls).toContainEqual({ tab: "payments" });
257
+ });
258
+ });
259
+
260
+ // Only guard against a solon-shaped screen (relatedList-heavy) silently regressing.
261
+ describe("KumikoScreen / projectionDetail — unchanged for a solon-shaped screen", () => {
262
+ const solonShapedScreen: ProjectionDetailScreenDefinition = {
263
+ ...baseScreen,
264
+ layout: {
265
+ sections: [
266
+ { title: "Rent", fields: ["description"] },
267
+ {
268
+ kind: "relatedList",
269
+ title: "Payments",
270
+ query: "rentals:query:rent:payments",
271
+ columns: [{ field: "amount", label: "Amount" }],
272
+ },
273
+ {
274
+ kind: "relatedList",
275
+ title: "Invoices",
276
+ query: "rentals:query:rent:invoices",
277
+ columns: [{ field: "amount", label: "Amount" }],
278
+ },
279
+ ],
280
+ },
281
+ };
282
+
283
+ test("all sections render stacked, no tabs, all relatedList queries fire", async () => {
284
+ const dispatcher = dispatcherReturning(rowData);
285
+
286
+ render(
287
+ <DispatcherProvider dispatcher={dispatcher}>
288
+ <KumikoScreen
289
+ schema={schemaFor(solonShapedScreen)}
290
+ qn="rentals:screen:rent-detail"
291
+ entityId="rent-1"
292
+ />
293
+ </DispatcherProvider>,
294
+ );
295
+
296
+ await waitFor(() => screen.getByTestId("field-description"));
297
+ await waitFor(() =>
298
+ expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
299
+ );
300
+ await waitFor(() =>
301
+ expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:invoices")).toBe(true),
302
+ );
303
+ expect(screen.queryByTestId("kumiko-screen-projection-detail-tabs")).toBeNull();
304
+ });
305
+ });
@@ -44,11 +44,12 @@ import {
44
44
  type DashboardBodyProps,
45
45
  extensionSectionName,
46
46
  useExtensionSectionComponent,
47
+ useNav,
47
48
  usePrimitives,
48
49
  useQuery,
49
50
  useTranslation,
50
51
  } from "@cosmicdrift/kumiko-renderer";
51
- import { type ReactNode, useEffect, useState } from "react";
52
+ import { type ReactNode, useEffect } from "react";
52
53
  import { TimeseriesChart, type TimeseriesPoint } from "../widgets/charts";
53
54
  import { FeedList, type FeedRow } from "../widgets/feed-list";
54
55
  import { ProgressList, type ProgressListRow } from "../widgets/progress-list";
@@ -337,18 +338,18 @@ function CustomPanelBody({
337
338
  );
338
339
  }
339
340
 
340
- // Screen-Filter: hält den gewählten Wert, rendert die Combobox, und liefert
341
- // den Payload-Merge für jede Panel-Query. Statische `options` werden als
342
- // i18n-Keys übersetzt; `optionsQuery`-Ergebnisse sind Server-Daten und werden
343
- // unverändert übernommen.
341
+ // Value lives in the URL under `filter.id` (like useListUrlState's
342
+ // `<screenId>.page`, same replaceState) so a `navigate` rowAction with
343
+ // `params` can deep-link onto this filter.
344
344
  function useFilterParams(screen: DashboardScreenDefinition): {
345
345
  readonly params: Readonly<Record<string, unknown>>;
346
346
  readonly picker: ReactNode;
347
347
  } {
348
348
  const { Field, Input } = usePrimitives();
349
349
  const t = useTranslation();
350
+ const nav = useNav();
350
351
  const filter = screen.filter;
351
- const [value, setValue] = useState("");
352
+ const value = filter !== undefined ? (nav.searchParams[filter.id] ?? "") : "";
352
353
  const optionsQueryResult = useQuery<{
353
354
  readonly rows: readonly { readonly value: string; readonly label: string }[];
354
355
  }>(filter?.optionsQuery ?? "", {}, { enabled: filter?.optionsQuery !== undefined });
@@ -373,7 +374,9 @@ function useFilterParams(screen: DashboardScreenDefinition): {
373
374
  name={filter.id}
374
375
  options={options}
375
376
  value={value}
376
- onChange={setValue}
377
+ onChange={(next: string) =>
378
+ nav.setSearchParams({ [filter.id]: next === "" ? null : next })
379
+ }
377
380
  placeholder={filter.placeholder !== undefined ? t(filter.placeholder) : allLabel}
378
381
  />
379
382
  </Field>
@@ -107,6 +107,8 @@ import { DefaultLightbox } from "./lightbox";
107
107
  import { LocatedTimestampInput } from "./located-timestamp-input";
108
108
  import { DefaultModal } from "./modal";
109
109
  import { currencyDecimals, formatMoney, MoneyInput } from "./money-input";
110
+ import { DefaultStatusBadge } from "./status-badge";
111
+ import { DefaultTabs } from "./tabs";
110
112
  import { TimestampInput } from "./timestamp-input";
111
113
  import { useToast } from "./toast";
112
114
  import { TzInput } from "./tz-input";
@@ -1963,8 +1965,8 @@ function DefaultLink({
1963
1965
  );
1964
1966
  }
1965
1967
 
1966
- function DefaultProgress({ value, testId }: ProgressProps): ReactNode {
1967
- return <ProgressBar value={value} testId={testId} />;
1968
+ function DefaultProgress({ value, tone, testId }: ProgressProps): ReactNode {
1969
+ return <ProgressBar value={value} tone={tone} testId={testId} />;
1968
1970
  }
1969
1971
 
1970
1972
  function DefaultStepBar({
@@ -2090,4 +2092,6 @@ export const defaultPrimitives: CorePrimitives = {
2090
2092
  Progress: DefaultProgress,
2091
2093
  StepBar: DefaultStepBar,
2092
2094
  WizardStepGroup: DefaultWizardStepGroup,
2095
+ Tabs: DefaultTabs,
2096
+ StatusBadge: DefaultStatusBadge,
2093
2097
  };
@@ -0,0 +1,11 @@
1
+ import type { StatusBadgeProps } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+ import { StatusBadge } from "../widgets/status-badge";
4
+
5
+ export function DefaultStatusBadge({ value, tone, testId }: StatusBadgeProps): ReactNode {
6
+ return (
7
+ <StatusBadge tone={tone ?? "muted"} testId={testId}>
8
+ {value}
9
+ </StatusBadge>
10
+ );
11
+ }
@@ -0,0 +1,23 @@
1
+ // Delegates keyboard nav + ARIA (role=tablist/tab) to Radix instead of hand-rolling it.
2
+
3
+ import type { TabsProps } from "@cosmicdrift/kumiko-renderer";
4
+ import type { ReactNode } from "react";
5
+ import { Tabs, TabsList, TabsTrigger } from "../ui/tabs";
6
+
7
+ export function DefaultTabs({ items, activeId, onSelect, testId }: TabsProps): ReactNode {
8
+ return (
9
+ <Tabs value={activeId} onValueChange={onSelect} data-testid={testId}>
10
+ <TabsList variant="line">
11
+ {items.map((item) => (
12
+ <TabsTrigger
13
+ key={item.id}
14
+ value={item.id}
15
+ data-testid={testId !== undefined ? `${testId}-${item.id}` : undefined}
16
+ >
17
+ {item.label}
18
+ </TabsTrigger>
19
+ ))}
20
+ </TabsList>
21
+ </Tabs>
22
+ );
23
+ }
@@ -0,0 +1,92 @@
1
+ // @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
2
+ "use client"
3
+
4
+ import * as React from "react"
5
+ import { cva, type VariantProps } from "class-variance-authority"
6
+ import { Tabs as TabsPrimitive } from "radix-ui"
7
+
8
+ import { cn } from "../lib/cn"
9
+
10
+ function Tabs({
11
+ className,
12
+ orientation = "horizontal",
13
+ ...props
14
+ }: React.ComponentProps<typeof TabsPrimitive.Root>) {
15
+ return (
16
+ <TabsPrimitive.Root
17
+ data-slot="tabs"
18
+ data-orientation={orientation}
19
+ orientation={orientation}
20
+ className={cn(
21
+ "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
22
+ className
23
+ )}
24
+ {...props}
25
+ />
26
+ )
27
+ }
28
+
29
+ const tabsListVariants = cva(
30
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
31
+ {
32
+ variants: {
33
+ variant: {
34
+ default: "bg-muted",
35
+ line: "gap-1 bg-transparent",
36
+ },
37
+ },
38
+ defaultVariants: {
39
+ variant: "default",
40
+ },
41
+ }
42
+ )
43
+
44
+ function TabsList({
45
+ className,
46
+ variant = "default",
47
+ ...props
48
+ }: React.ComponentProps<typeof TabsPrimitive.List> &
49
+ VariantProps<typeof tabsListVariants>) {
50
+ return (
51
+ <TabsPrimitive.List
52
+ data-slot="tabs-list"
53
+ data-variant={variant}
54
+ className={cn(tabsListVariants({ variant }), className)}
55
+ {...props}
56
+ />
57
+ )
58
+ }
59
+
60
+ function TabsTrigger({
61
+ className,
62
+ ...props
63
+ }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
64
+ return (
65
+ <TabsPrimitive.Trigger
66
+ data-slot="tabs-trigger"
67
+ className={cn(
68
+ "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
69
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
70
+ "data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
71
+ "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
72
+ className
73
+ )}
74
+ {...props}
75
+ />
76
+ )
77
+ }
78
+
79
+ function TabsContent({
80
+ className,
81
+ ...props
82
+ }: React.ComponentProps<typeof TabsPrimitive.Content>) {
83
+ return (
84
+ <TabsPrimitive.Content
85
+ data-slot="tabs-content"
86
+ className={cn("flex-1 outline-none", className)}
87
+ {...props}
88
+ />
89
+ )
90
+ }
91
+
92
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -1,6 +1,16 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { cn } from "../lib/cn";
3
3
 
4
+ type ProgressTone = "default" | "warn" | "danger";
5
+
6
+ // Fully spelled out (no interpolation) — Tailwind's scanner only picks up
7
+ // literal class strings, not `bg-status-${tone}`.
8
+ const FILL_TONE: Record<ProgressTone, string> = {
9
+ default: "bg-primary",
10
+ warn: "bg-status-warn",
11
+ danger: "bg-status-critical",
12
+ };
13
+
4
14
  /**
5
15
  * Progress bar, `value` 0..1 (clamped).
6
16
  *
@@ -11,10 +21,12 @@ import { cn } from "../lib/cn";
11
21
  */
12
22
  export function ProgressBar({
13
23
  value,
24
+ tone = "default",
14
25
  className,
15
26
  testId,
16
27
  }: {
17
28
  readonly value: number;
29
+ readonly tone?: ProgressTone;
18
30
  readonly className?: string;
19
31
  readonly testId?: string;
20
32
  }): ReactNode {
@@ -32,7 +44,7 @@ export function ProgressBar({
32
44
  className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
33
45
  >
34
46
  <div
35
- className="absolute inset-y-0 left-0 rounded-full bg-primary"
47
+ className={cn("absolute inset-y-0 left-0 rounded-full", FILL_TONE[tone])}
36
48
  style={{ width: `${pct * 100}%` }}
37
49
  />
38
50
  </div>
@@ -83,6 +83,7 @@ export function StatCard({
83
83
  trend,
84
84
  spark,
85
85
  testId,
86
+ children,
86
87
  }: {
87
88
  readonly icon?: ReactNode;
88
89
  readonly label: string;
@@ -94,6 +95,9 @@ export function StatCard({
94
95
  readonly trend?: string;
95
96
  readonly spark?: readonly number[];
96
97
  readonly testId?: string;
98
+ /** Extra content below the built-in fields (e.g. a usage meter) — kept
99
+ * inside the same card chrome instead of stacking a second bordered box. */
100
+ readonly children?: ReactNode;
97
101
  }): ReactNode {
98
102
  const { Card } = usePrimitives();
99
103
  return (
@@ -140,6 +144,7 @@ export function StatCard({
140
144
  {spark !== undefined && (
141
145
  <Sparkline points={spark} className={cn("mt-2 h-7 w-full", TONE_VALUE[tone])} />
142
146
  )}
147
+ {children !== undefined && <div className="mt-3">{children}</div>}
143
148
  </Card>
144
149
  );
145
150
  }