@cosmicdrift/kumiko-renderer-web 0.105.2 → 0.109.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.2",
3
+ "version": "0.109.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.2",
20
- "@cosmicdrift/kumiko-headless": "0.105.2",
21
- "@cosmicdrift/kumiko-renderer": "0.105.2",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.109.0",
20
+ "@cosmicdrift/kumiko-headless": "0.109.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.109.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";
@@ -1043,4 +1043,13 @@ describe("Card", () => {
1043
1043
  expect(screen.getByTestId("c").querySelectorAll("div").length).toBeGreaterThan(0);
1044
1044
  expect(screen.getByTestId("c").innerHTML).not.toContain("grow");
1045
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
+ });
1046
1055
  });
@@ -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);