@cosmicdrift/kumiko-renderer-web 0.39.0 → 0.40.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.
|
|
3
|
+
"version": "0.40.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>",
|
|
@@ -495,6 +495,75 @@ describe("KumikoScreen", () => {
|
|
|
495
495
|
expect(await screen.findByText("Version conflict for entity r1")).toBeTruthy();
|
|
496
496
|
});
|
|
497
497
|
|
|
498
|
+
// 284/2: der HIT-Zweig von dispatcherErrorText — bekannter i18nKey
|
|
499
|
+
// mit Params → der ÜBERSETZTE, interpolierte Text landet im Toast
|
|
500
|
+
// (nicht error.message). Genau die Logik, die die Funktion rechtfertigt.
|
|
501
|
+
test("entityList rowActions writeHandler: bekannter i18nKey → übersetzter Toast mit interpolierten Params", async () => {
|
|
502
|
+
const dispatcher = makeDispatcher({
|
|
503
|
+
query: (async () => ({
|
|
504
|
+
isSuccess: true,
|
|
505
|
+
data: {
|
|
506
|
+
rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
|
|
507
|
+
nextCursor: null,
|
|
508
|
+
},
|
|
509
|
+
})) as unknown as Dispatcher["query"],
|
|
510
|
+
write: (async () => ({
|
|
511
|
+
isSuccess: false,
|
|
512
|
+
error: {
|
|
513
|
+
code: "validation_error",
|
|
514
|
+
httpStatus: 422,
|
|
515
|
+
i18nKey: "kumiko.validation.too-short",
|
|
516
|
+
i18nParams: { min: 5 },
|
|
517
|
+
message: "raw fallback — must NOT appear",
|
|
518
|
+
},
|
|
519
|
+
})) as unknown as Dispatcher["write"],
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const screenWithDelete: EntityListScreenDefinition = {
|
|
523
|
+
id: "task-list",
|
|
524
|
+
type: "entityList",
|
|
525
|
+
entity: "task",
|
|
526
|
+
columns: ["title"],
|
|
527
|
+
rowActions: [
|
|
528
|
+
{
|
|
529
|
+
id: "delete",
|
|
530
|
+
label: "actions.delete",
|
|
531
|
+
handler: "tasks:write:task:delete",
|
|
532
|
+
confirm: "actions.delete-confirm",
|
|
533
|
+
style: "danger",
|
|
534
|
+
},
|
|
535
|
+
],
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const { ToastProvider } = await import("../primitives/toast");
|
|
539
|
+
const { LocaleProvider, createStaticLocaleResolver, kumikoDefaultTranslations } = await import(
|
|
540
|
+
"@cosmicdrift/kumiko-renderer"
|
|
541
|
+
);
|
|
542
|
+
const user = userEvent.setup();
|
|
543
|
+
render(
|
|
544
|
+
<LocaleProvider
|
|
545
|
+
resolver={createStaticLocaleResolver()}
|
|
546
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
547
|
+
>
|
|
548
|
+
<ToastProvider>
|
|
549
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
550
|
+
<KumikoScreen
|
|
551
|
+
schema={{ ...schema, screens: [screenWithDelete] }}
|
|
552
|
+
qn="tasks:screen:task-list"
|
|
553
|
+
/>
|
|
554
|
+
</DispatcherProvider>
|
|
555
|
+
</ToastProvider>
|
|
556
|
+
</LocaleProvider>,
|
|
557
|
+
);
|
|
558
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
559
|
+
|
|
560
|
+
await user.click(screen.getByTestId("row-r1-action-delete"));
|
|
561
|
+
await user.click(screen.getByTestId("row-r1-action-delete-dialog-confirm"));
|
|
562
|
+
|
|
563
|
+
expect(await screen.findByText("Too short (at least 5 characters).")).toBeTruthy();
|
|
564
|
+
expect(screen.queryByText("raw fallback — must NOT appear")).toBeNull();
|
|
565
|
+
});
|
|
566
|
+
|
|
498
567
|
// Tier 2.7e-1: rowAction kind="navigate" — Click ruft nav.navigate
|
|
499
568
|
// mit screen-id, ggf. mit URL-Search-Params aus params(row).
|
|
500
569
|
// Reihenfolge ist Teil des Contracts: navigate ZUERST, dann
|
|
@@ -567,6 +636,66 @@ describe("KumikoScreen", () => {
|
|
|
567
636
|
expect(calls.map((c) => c.kind)).toEqual(["navigate", "setSearchParams"]);
|
|
568
637
|
});
|
|
569
638
|
|
|
639
|
+
// 284/3: derselbe Contract gegen die ECHTE useBrowserNavApi statt
|
|
640
|
+
// memoryNav — der eigentliche Bug-Mechanismus (pushState verwirft die
|
|
641
|
+
// Query, setSearchParams muss auf der NEUEN URL landen) ist nur hier
|
|
642
|
+
// real verifiziert. Kombination navigate + entityId + params.
|
|
643
|
+
test("entityList rowActions kind=navigate: echte useBrowserNavApi → Pfad-Segmente + ?param auf der neuen URL", async () => {
|
|
644
|
+
window.history.replaceState(null, "", "/task-list");
|
|
645
|
+
|
|
646
|
+
const dispatcher = makeDispatcher({
|
|
647
|
+
query: (async () => ({
|
|
648
|
+
isSuccess: true,
|
|
649
|
+
data: {
|
|
650
|
+
rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
|
|
651
|
+
nextCursor: null,
|
|
652
|
+
},
|
|
653
|
+
})) as unknown as Dispatcher["query"],
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const screenWithNav: EntityListScreenDefinition = {
|
|
657
|
+
id: "task-list",
|
|
658
|
+
type: "entityList",
|
|
659
|
+
entity: "task",
|
|
660
|
+
columns: ["title"],
|
|
661
|
+
rowActions: [
|
|
662
|
+
{
|
|
663
|
+
kind: "navigate",
|
|
664
|
+
id: "edit",
|
|
665
|
+
label: "actions.edit",
|
|
666
|
+
screen: "task-edit",
|
|
667
|
+
entityId: "id",
|
|
668
|
+
params: { map: { from: "title" } },
|
|
669
|
+
},
|
|
670
|
+
],
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
674
|
+
const { useBrowserNavApi } = await import("../app/nav");
|
|
675
|
+
function BrowserNav({ children }: { readonly children: React.ReactNode }): React.ReactNode {
|
|
676
|
+
const api = useBrowserNavApi({ hasWorkspaces: false });
|
|
677
|
+
return <NavProvider value={api}>{children}</NavProvider>;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const user = userEvent.setup();
|
|
681
|
+
render(
|
|
682
|
+
<BrowserNav>
|
|
683
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
684
|
+
<KumikoScreen
|
|
685
|
+
schema={{ ...schema, screens: [screenWithNav] }}
|
|
686
|
+
qn="tasks:screen:task-list"
|
|
687
|
+
/>
|
|
688
|
+
</DispatcherProvider>
|
|
689
|
+
</BrowserNav>,
|
|
690
|
+
);
|
|
691
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
692
|
+
|
|
693
|
+
await user.click(screen.getByTestId("row-r1-action-edit"));
|
|
694
|
+
|
|
695
|
+
await waitFor(() => expect(window.location.pathname).toBe("/task-edit/r1"));
|
|
696
|
+
expect(new URLSearchParams(window.location.search).get("from")).toBe("Alpha");
|
|
697
|
+
});
|
|
698
|
+
|
|
570
699
|
// entityId-Variante: entityEdit-Targets brauchen die Id als PFAD-
|
|
571
700
|
// Segment (route.entityId), nicht als Search-Param — sonst öffnet
|
|
572
701
|
// der Edit-Screen im Create-Mode (Prod-Bug 2026-06-07, Bug 3).
|
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
8
8
|
import { act, render, screen } from "@testing-library/react";
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
__resetStoredModeAppliedForTests,
|
|
12
|
+
applyStoredThemeMode,
|
|
13
|
+
THEME_STORAGE_KEY,
|
|
14
|
+
useBrowserTokensApi,
|
|
15
|
+
} from "../tokens";
|
|
11
16
|
|
|
12
17
|
function Probe(): ReactNode {
|
|
13
18
|
const api = useBrowserTokensApi();
|
|
@@ -55,6 +60,20 @@ describe("useBrowserTokensApi — Theme-Persistenz", () => {
|
|
|
55
60
|
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
|
56
61
|
});
|
|
57
62
|
|
|
63
|
+
test("Mount-Restore: erster Hook-Mount übernimmt die gespeicherte Wahl ins DOM", () => {
|
|
64
|
+
// Der eigentliche Headline-Pfad (286/1): nicht applyStoredThemeMode
|
|
65
|
+
// isoliert, sondern die Glue im Hook — localStorage VOR dem Mount
|
|
66
|
+
// geseedet, der Mount selbst muss die .dark-Class setzen.
|
|
67
|
+
__resetStoredModeAppliedForTests();
|
|
68
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, "dark");
|
|
69
|
+
document.documentElement.classList.remove("dark");
|
|
70
|
+
|
|
71
|
+
render(<Probe />);
|
|
72
|
+
|
|
73
|
+
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
|
74
|
+
expect(screen.getByTestId("mode").textContent).toBe("dark");
|
|
75
|
+
});
|
|
76
|
+
|
|
58
77
|
test("applyStoredThemeMode restored die gespeicherte Wahl (Reload-Simulation)", () => {
|
|
59
78
|
// "Reload": Class weg (frisches HTML), aber localStorage hat dark.
|
|
60
79
|
window.localStorage.setItem(THEME_STORAGE_KEY, "dark");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConfigValueSource } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import type { ConfigScope, ConfigValueSource } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
|
|
@@ -12,9 +12,42 @@ const SOURCE_CONFIG: Record<ConfigValueSource, { labelKey: string; bg: string; t
|
|
|
12
12
|
missing: { labelKey: "kumiko.config.source.missing", bg: "#fee2e2", text: "#991b1b" },
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
const SOURCE_ORDER: readonly ConfigValueSource[] = [
|
|
16
|
+
"user-row",
|
|
17
|
+
"tenant-row",
|
|
18
|
+
"system-row",
|
|
19
|
+
"app-override",
|
|
20
|
+
"computed",
|
|
21
|
+
"default",
|
|
22
|
+
"missing",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function scopeToSource(scope: ConfigScope): ConfigValueSource {
|
|
26
|
+
if (scope === "user") return "user-row";
|
|
27
|
+
if (scope === "tenant") return "tenant-row";
|
|
28
|
+
return "system-row";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ConfigSourceBadge({
|
|
32
|
+
source,
|
|
33
|
+
screenScope,
|
|
34
|
+
}: {
|
|
35
|
+
readonly source: ConfigValueSource;
|
|
36
|
+
readonly screenScope?: ConfigScope;
|
|
37
|
+
}): ReactNode {
|
|
16
38
|
const t = useTranslation();
|
|
17
|
-
|
|
39
|
+
// Gleiche Kollaps-Regel wie toDisplayLevels (config-cascade.tsx):
|
|
40
|
+
// Operator-Quellen oberhalb des Screen-Scopes erscheinen für Nicht-
|
|
41
|
+
// Operator-Screens als neutrales "Vorgabe"-Badge — sonst leakte das
|
|
42
|
+
// Badge die System-Quelle, die die Cascade-View bewusst versteckt.
|
|
43
|
+
let effective = source;
|
|
44
|
+
if (screenScope !== undefined && screenScope !== "system") {
|
|
45
|
+
const scopeIdx = SOURCE_ORDER.indexOf(scopeToSource(screenScope));
|
|
46
|
+
if (SOURCE_ORDER.indexOf(source) > scopeIdx && source !== "missing") {
|
|
47
|
+
effective = "default";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const cfg = SOURCE_CONFIG[effective];
|
|
18
51
|
|
|
19
52
|
return (
|
|
20
53
|
<span
|
package/src/tokens.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type Tokens,
|
|
6
6
|
type TokensApi,
|
|
7
7
|
} from "@cosmicdrift/kumiko-renderer";
|
|
8
|
-
import { useSyncExternalStore } from "react";
|
|
8
|
+
import { useState, useSyncExternalStore } from "react";
|
|
9
9
|
|
|
10
10
|
// Web-spezifische TokensApi-Impl. Theme-Toggle via `.dark`-Class auf
|
|
11
11
|
// <html>. Die echten Farben leben in styles.css; hier ist nur die
|
|
@@ -68,6 +68,13 @@ export function applyStoredThemeMode(): void {
|
|
|
68
68
|
|
|
69
69
|
let storedModeApplied = false;
|
|
70
70
|
|
|
71
|
+
/** Nur für Tests: der once-per-page-load-Guard ist ein Module-Singleton —
|
|
72
|
+
* ohne Reset wäre der Mount-Restore-Pfad nach der ersten Render im
|
|
73
|
+
* Testfile strukturell unerreichbar. */
|
|
74
|
+
export function __resetStoredModeAppliedForTests(): void {
|
|
75
|
+
storedModeApplied = false;
|
|
76
|
+
}
|
|
77
|
+
|
|
71
78
|
/** Hook der eine TokensApi für den Browser baut. Wird von
|
|
72
79
|
* createKumikoApp genutzt; App-Code der einen eigenen Token-State
|
|
73
80
|
* braucht (z.B. User-Präferenz aus localStorage) kann selber
|
|
@@ -75,10 +82,16 @@ let storedModeApplied = false;
|
|
|
75
82
|
export function useBrowserTokensApi(): TokensApi {
|
|
76
83
|
// Einmal pro Page-Load: gespeicherte Wahl anwenden. Lazy statt
|
|
77
84
|
// Modul-Side-Effect, damit Import ohne DOM (SSR/Tests) safe bleibt.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
// Als useState-Lazy-Initializer statt nackt im Render-Body: der
|
|
86
|
+
// DOM-Side-Effect lief sonst potenziell in einem verworfenen
|
|
87
|
+
// Concurrent-Render (React darf Render-Bodies wiederholen/abbrechen).
|
|
88
|
+
useState(() => {
|
|
89
|
+
if (!storedModeApplied && typeof document !== "undefined") {
|
|
90
|
+
storedModeApplied = true;
|
|
91
|
+
applyStoredThemeMode();
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
});
|
|
82
95
|
const mode = useSyncExternalStore(themeTick.subscribe, readCurrentMode, () => "dark" as const);
|
|
83
96
|
return {
|
|
84
97
|
tokens: cssVarTokens,
|