@cosmicdrift/kumiko-renderer-web 0.105.1 → 0.108.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.105.1",
3
+ "version": "0.108.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.105.1",
20
- "@cosmicdrift/kumiko-headless": "0.105.1",
21
- "@cosmicdrift/kumiko-renderer": "0.105.1",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.108.0",
20
+ "@cosmicdrift/kumiko-headless": "0.108.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.108.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",
@@ -6,6 +6,11 @@
6
6
  // config:write:set Call mit dem qualifizierten Key + scope
7
7
  // - Save-Button Greying via controller.rebase nach Success
8
8
  // - Loading-State während config:query:values noch läuft
9
+ //
10
+ // `as unknown as Dispatcher["query"/"batch"]` throughout: each inline mock
11
+ // lambda only implements the one overload a given test exercises, never the
12
+ // full overloaded Dispatcher signature — the missing overloads are never
13
+ // called at runtime.
9
14
 
10
15
  import { describe, expect, mock, test } from "bun:test";
11
16
  import type { ConfigEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
@@ -82,13 +82,29 @@ describe("Input kind=locatedTimestamp", () => {
82
82
  expect(atSchema.safeParse(last.at).success).toBe(true);
83
83
  });
84
84
 
85
- test("leeres Datum + Uhrzeit ohne Zone undefined (Feld leer)", () => {
85
+ test("kein Input noch kein onChange (Sanity, Startwert bleibt Sentinel)", () => {
86
86
  let last: { at: string; tz: string } | undefined | "sentinel" = "sentinel";
87
87
  render(<ControlledLocated initial="" onEmit={(v) => (last = v)} />);
88
- // Kein Input → noch kein onChange. Sanity: Startwert blieb Sentinel.
89
88
  expect(last).toBe("sentinel");
90
89
  });
91
90
 
91
+ test("Datum + Uhrzeit wieder leeren (Zone nie gesetzt) → onChange(undefined)", () => {
92
+ const seen: Array<{ at: string; tz: string } | undefined> = [];
93
+ const view = render(<ControlledLocated initial="" onEmit={(v) => seen.push(v)} />);
94
+ const { date, time } = inputs(view.container);
95
+
96
+ fireEvent.change(date, { target: { value: "2026-04-03" } });
97
+ fireEvent.change(time, { target: { value: "10:00" } });
98
+ expect(seen.at(-1)?.at).toBe("2026-04-03T10:00");
99
+
100
+ // Both fields cleared again — the emit() undefined-branch (`nextAt === ""
101
+ // && nextTz === ""`) has zero coverage otherwise; the test above only
102
+ // checks that NO change fires before any input, not the clear-after-fill path.
103
+ fireEvent.change(date, { target: { value: "" } });
104
+ fireEvent.change(time, { target: { value: "" } });
105
+ expect(seen.at(-1)).toBeUndefined();
106
+ });
107
+
92
108
  test("sichtbarer Zonen-Hinweis", () => {
93
109
  const view = render(<ControlledLocated initial="" onEmit={() => {}} />);
94
110
  expect(view.container.textContent ?? "").toMatch(/lokal|local/i);
@@ -12,7 +12,8 @@ import userEvent from "@testing-library/user-event";
12
12
  import { defaultPrimitives } from "../primitives";
13
13
  import { fireEvent, render, screen } from "./test-utils";
14
14
 
15
- const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog } = defaultPrimitives;
15
+ const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog, Card } =
16
+ defaultPrimitives;
16
17
 
17
18
  describe("Button", () => {
18
19
  test("disabled: attribute gesetzt + Tailwind-Klassen für pointer-events/opacity", () => {
@@ -970,3 +971,85 @@ describe("Text variants", () => {
970
971
  expect(el.hasAttribute("data-required")).toBe(true);
971
972
  });
972
973
  });
974
+
975
+ describe("Card", () => {
976
+ test("padded=true (default) adds body padding", () => {
977
+ render(
978
+ <Card testId="c">
979
+ <span>body</span>
980
+ </Card>,
981
+ );
982
+ expect(screen.getByTestId("c").innerHTML).toContain("p-6");
983
+ });
984
+
985
+ test("padded=false renders body without padding classes", () => {
986
+ render(
987
+ <Card testId="c" options={{ padded: false }}>
988
+ <span>body</span>
989
+ </Card>,
990
+ );
991
+ const bodyWrapper = screen.getByText("body").parentElement;
992
+ expect(bodyWrapper?.className.includes("p-6")).toBe(false);
993
+ expect(bodyWrapper?.className.includes("px-6")).toBe(false);
994
+ });
995
+
996
+ test("slots.title/subtitle render a default header", () => {
997
+ render(<Card testId="c" slots={{ title: "Title", subtitle: "Subtitle" }} />);
998
+ expect(screen.getByText("Title")).toBeTruthy();
999
+ expect(screen.getByText("Subtitle")).toBeTruthy();
1000
+ });
1001
+
1002
+ test("no header slots → no header row rendered", () => {
1003
+ render(
1004
+ <Card testId="c">
1005
+ <span>only body</span>
1006
+ </Card>,
1007
+ );
1008
+ // Header row carries "items-start justify-between" — absent means no header.
1009
+ expect(screen.getByTestId("c").innerHTML).not.toContain("justify-between");
1010
+ });
1011
+
1012
+ test("slots.footer renders bordered by default", () => {
1013
+ render(<Card testId="c" slots={{ footer: <span>Footer</span> }} />);
1014
+ const footer = screen.getByText("Footer").parentElement;
1015
+ expect(footer?.className.includes("border-t")).toBe(true);
1016
+ });
1017
+
1018
+ test("footerBordered=false drops the border", () => {
1019
+ render(
1020
+ <Card
1021
+ testId="c"
1022
+ slots={{ footer: <span>Footer</span> }}
1023
+ options={{ footerBordered: false }}
1024
+ />,
1025
+ );
1026
+ const footer = screen.getByText("Footer").parentElement;
1027
+ expect(footer?.className.includes("border-t")).toBe(false);
1028
+ });
1029
+
1030
+ test('radius="lg" uses rounded-lg instead of rounded-xl', () => {
1031
+ render(
1032
+ <Card testId="c" options={{ radius: "lg" }}>
1033
+ x
1034
+ </Card>,
1035
+ );
1036
+ const el = screen.getByTestId("c");
1037
+ expect(el.className.includes("rounded-lg")).toBe(true);
1038
+ expect(el.className.includes("rounded-xl")).toBe(false);
1039
+ });
1040
+
1041
+ test("children=undefined → no body wrapper rendered", () => {
1042
+ render(<Card testId="c" slots={{ title: "Only header" }} />);
1043
+ expect(screen.getByTestId("c").querySelectorAll("div").length).toBeGreaterThan(0);
1044
+ expect(screen.getByTestId("c").innerHTML).not.toContain("grow");
1045
+ });
1046
+
1047
+ test("children=null (explicit, e.g. `cond ? <X/> : null`) → no body wrapper rendered either", () => {
1048
+ render(
1049
+ <Card testId="c" slots={{ title: "Only header" }}>
1050
+ {null}
1051
+ </Card>,
1052
+ );
1053
+ expect(screen.getByTestId("c").innerHTML).not.toContain("grow");
1054
+ });
1055
+ });
@@ -1431,12 +1431,12 @@ function DefaultSection({ title, subtitle, children, actions, testId }: SectionP
1431
1431
  </h3>
1432
1432
  )}
1433
1433
  {subtitle !== undefined && (
1434
- <p
1434
+ <div
1435
1435
  data-testid={testId !== undefined ? `${testId}-subtitle` : undefined}
1436
1436
  className="text-sm text-muted-foreground"
1437
1437
  >
1438
1438
  {subtitle}
1439
- </p>
1439
+ </div>
1440
1440
  )}
1441
1441
  </div>
1442
1442
  ) : null;
@@ -1463,6 +1463,11 @@ function DefaultSection({ title, subtitle, children, actions, testId }: SectionP
1463
1463
 
1464
1464
  // Standalone: eigene Card, Header fließt in den Body (kein Divider).
1465
1465
  // actions = abgehobene Footer-Row (border-t bg-muted/30, wie DefaultForm).
1466
+ // overflow-hidden clips the footer-corner radius correctly for portaled
1467
+ // overlays (Combobox/Select/Tooltip escape to document.body, unaffected).
1468
+ // A non-portaled overlay (e.g. a custom dropdown built directly into
1469
+ // `children`) WOULD get silently clipped — verify this against any new
1470
+ // standalone-section content that renders its own non-portaled overlay.
1466
1471
  return (
1467
1472
  <Card data-testid={testId} className="gap-0 overflow-hidden rounded-lg py-0">
1468
1473
  <CardContent className="flex flex-col gap-4 px-6 py-6">
@@ -1491,6 +1496,7 @@ function DefaultGrid({ columns, children, testId }: GridProps): ReactNode {
1491
1496
  <div
1492
1497
  data-testid={testId}
1493
1498
  className="grid gap-4 grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]"
1499
+ // workaround: duplicate @types/react instances break direct CSSProperties cast
1494
1500
  style={{ "--grid-cols": `repeat(${columns}, minmax(0, 1fr))` } as unknown as CSSProperties}
1495
1501
  >
1496
1502
  {children}
@@ -1589,7 +1595,9 @@ function DefaultCard({ slots, options, className, testId, children }: CardProps)
1589
1595
  )}
1590
1596
  >
1591
1597
  {header}
1592
- {children !== undefined && (
1598
+ {/* != null covers undefined AND explicit null; a `false` child (from
1599
+ `cond && <El/>`) still renders no visible content either way. */}
1600
+ {children != null && (
1593
1601
  <div className={cn("grow", padded && (hasHeader ? "px-6 pb-6" : "p-6"))}>{children}</div>
1594
1602
  )}
1595
1603
  {s.footer !== undefined && (
@@ -134,6 +134,7 @@ function SidebarProvider({
134
134
  data-slot="sidebar-wrapper"
135
135
  style={
136
136
  {
137
+ // workaround: duplicate @types/react instances break direct CSSProperties cast
137
138
  "--sidebar-width": SIDEBAR_WIDTH,
138
139
  "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
139
140
  ...style,
@@ -191,6 +192,7 @@ function Sidebar({
191
192
  className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
192
193
  style={
193
194
  {
195
+ // workaround: duplicate @types/react instances break direct CSSProperties cast
194
196
  "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
195
197
  } as React.CSSProperties
196
198
  }
@@ -630,6 +632,7 @@ function SidebarMenuSkeleton({
630
632
  data-sidebar="menu-skeleton-text"
631
633
  style={
632
634
  {
635
+ // workaround: duplicate @types/react instances break direct CSSProperties cast
633
636
  "--skeleton-width": width,
634
637
  } as React.CSSProperties
635
638
  }
@@ -3,7 +3,7 @@
3
3
  // Invariante — hier die vier Fälle direkt geprüft.
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
- import { shouldShowUpdate } from "../update-checker";
6
+ import { isKumikoBuild, shouldShowUpdate } from "../update-checker";
7
7
 
8
8
  describe("shouldShowUpdate", () => {
9
9
  test("andere Server-id als geladen → Banner", () => {
@@ -23,3 +23,20 @@ describe("shouldShowUpdate", () => {
23
23
  expect(shouldShowUpdate("", { id: "bbb", builtAt: "2026-06-18T00:00:00Z" })).toBe(false);
24
24
  });
25
25
  });
26
+
27
+ describe("isKumikoBuild", () => {
28
+ test("accepts a build-info shape with a non-empty id", () => {
29
+ expect(isKumikoBuild({ id: "abc" })).toBe(true);
30
+ expect(isKumikoBuild({ id: "abc", builtAt: "2026-06-18T00:00:00Z" })).toBe(true);
31
+ });
32
+
33
+ test("rejects an empty/missing/wrong-typed id, and non-object payloads", () => {
34
+ expect(isKumikoBuild({ id: "" })).toBe(false);
35
+ expect(isKumikoBuild({})).toBe(false);
36
+ expect(isKumikoBuild({ id: 123 })).toBe(false);
37
+ expect(isKumikoBuild(null)).toBe(false);
38
+ expect(isKumikoBuild(undefined)).toBe(false);
39
+ expect(isKumikoBuild("abc")).toBe(false);
40
+ expect(isKumikoBuild([])).toBe(false);
41
+ });
42
+ });
@@ -37,13 +37,23 @@ export function shouldShowUpdate(
37
37
  return server.id !== loadedId;
38
38
  }
39
39
 
40
+ export function isKumikoBuild(v: unknown): v is Pick<KumikoBuild, "id"> {
41
+ return (
42
+ typeof v === "object" &&
43
+ v !== null &&
44
+ typeof (v as Record<string, unknown>)["id"] === "string" &&
45
+ (v as Record<string, unknown>)["id"] !== ""
46
+ );
47
+ }
48
+
40
49
  async function fetchServerBuild(): Promise<KumikoBuild | null> {
41
50
  try {
42
51
  const res = await fetch("/build-info.json", { cache: "no-store" });
43
52
  if (!res.ok) return null;
44
- const info = (await res.json()) as Partial<KumikoBuild>;
45
- if (typeof info.id !== "string" || info.id.length === 0) return null;
46
- return { id: info.id, builtAt: typeof info.builtAt === "string" ? info.builtAt : "" };
53
+ const raw: unknown = await res.json();
54
+ if (!isKumikoBuild(raw)) return null;
55
+ const builtAt = (raw as Partial<KumikoBuild>).builtAt;
56
+ return { id: raw.id, builtAt: typeof builtAt === "string" ? builtAt : "" };
47
57
  } catch {
48
58
  // Netzwerkfehler / kaputtes JSON → kein Banner. Nie ein Fake-Update zeigen.
49
59
  return null;
@@ -60,10 +70,17 @@ export function UpdateChecker(): ReactNode {
60
70
  if (!loadedId) return; // Dev / altes Bundle → keine Awareness.
61
71
 
62
72
  let cancelled = false;
73
+ const checking = { current: false };
63
74
  const check = async (): Promise<void> => {
64
75
  if (document.visibilityState !== "visible") return;
65
- const server = await fetchServerBuild();
66
- if (!cancelled && shouldShowUpdate(loadedId, server)) setHasUpdate(true);
76
+ if (checking.current) return; // visibilitychange + focus fire together on tab-return
77
+ checking.current = true;
78
+ try {
79
+ const server = await fetchServerBuild();
80
+ if (!cancelled && shouldShowUpdate(loadedId, server)) setHasUpdate(true);
81
+ } finally {
82
+ checking.current = false;
83
+ }
67
84
  };
68
85
 
69
86
  document.addEventListener("visibilitychange", check);