@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 +4 -4
- package/src/__tests__/config-edit.test.tsx +5 -0
- package/src/__tests__/located-timestamp-input.test.tsx +18 -2
- package/src/__tests__/primitives.test.tsx +84 -1
- package/src/primitives/index.tsx +11 -3
- package/src/ui/sidebar.tsx +3 -0
- package/src/version/__tests__/update-checker.test.ts +18 -1
- package/src/version/update-checker.tsx +22 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
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("
|
|
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 } =
|
|
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
|
+
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -1431,12 +1431,12 @@ function DefaultSection({ title, subtitle, children, actions, testId }: SectionP
|
|
|
1431
1431
|
</h3>
|
|
1432
1432
|
)}
|
|
1433
1433
|
{subtitle !== undefined && (
|
|
1434
|
-
<
|
|
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
|
-
</
|
|
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
|
-
{
|
|
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 && (
|
package/src/ui/sidebar.tsx
CHANGED
|
@@ -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
|
|
45
|
-
if (
|
|
46
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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);
|