@cosmicdrift/kumiko-renderer-web 0.31.1 → 0.32.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.31.1",
3
+ "version": "0.32.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>",
@@ -0,0 +1,157 @@
1
+ // ConfigCascadeView — Regression zu den Prod-UX-Bugs 2026-06-07
2
+ // (publicstatus Bugs 7+8): (7) Source-Badges zeigten ROHE i18n-Keys
3
+ // ("config.source.default"), weil kein Bundle die Keys kannte; (8) ein
4
+ // Tenant-Admin sah ALLE Cascade-Ebenen (System/App-Override/Computed)
5
+ // obwohl er nur die Tenant-Ebene beeinflussen kann. Jetzt: Keys leben
6
+ // als kumiko.config.* in kumikoDefaultTranslations, und Nicht-System-
7
+ // Screens kollabieren alles oberhalb des Screen-Scopes zu EINER
8
+ // neutralen "Preset"-Zeile.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import type { ConfigCascade, ConfigCascadeLevel } from "@cosmicdrift/kumiko-framework/engine";
12
+ import userEvent from "@testing-library/user-event";
13
+ import { ConfigCascadeView } from "../components/config-cascade";
14
+ import { render, screen } from "./test-utils";
15
+
16
+ function level(overrides: Partial<ConfigCascadeLevel> & { source: ConfigCascadeLevel["source"] }) {
17
+ return {
18
+ label: overrides.source,
19
+ value: undefined,
20
+ isActive: false,
21
+ hasValue: false,
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ // Tenant-Scope-Key wie ihn buildCascade liefert: tenant-row + alle
27
+ // Operator-Ebenen + default.
28
+ function tenantCascade(overrides?: {
29
+ tenantValue?: string;
30
+ systemActive?: boolean;
31
+ }): ConfigCascade {
32
+ const tenantHasValue = overrides?.tenantValue !== undefined;
33
+ const systemActive = overrides?.systemActive === true;
34
+ return {
35
+ value: overrides?.tenantValue ?? (systemActive ? "system-smtp" : "fallback"),
36
+ source: tenantHasValue ? "tenant-row" : systemActive ? "system-row" : "default",
37
+ levels: [
38
+ level({
39
+ source: "tenant-row",
40
+ value: overrides?.tenantValue,
41
+ hasValue: tenantHasValue,
42
+ isActive: tenantHasValue,
43
+ }),
44
+ level({
45
+ source: "system-row",
46
+ value: systemActive ? "system-smtp" : undefined,
47
+ hasValue: systemActive,
48
+ isActive: !tenantHasValue && systemActive,
49
+ }),
50
+ level({ source: "app-override" }),
51
+ level({ source: "computed" }),
52
+ level({
53
+ source: "default",
54
+ value: "fallback",
55
+ hasValue: true,
56
+ isActive: !tenantHasValue && !systemActive,
57
+ }),
58
+ ],
59
+ };
60
+ }
61
+
62
+ describe("ConfigCascadeView — i18n (Bug 7)", () => {
63
+ test("Source-Badges zeigen übersetzte Labels, keine rohen Keys", async () => {
64
+ const user = userEvent.setup();
65
+ const view = render(
66
+ <ConfigCascadeView cascade={tenantCascade({ tenantValue: "acme" })} screenScope="tenant" />,
67
+ );
68
+ // Collapsed-Header: aktive Ebene = Tenant.
69
+ expect(view.container.textContent).toContain("Tenant");
70
+ expect(view.container.textContent).not.toContain("config.source");
71
+
72
+ await user.click(screen.getByRole("button"));
73
+ expect(view.container.textContent).not.toContain("config.source");
74
+ expect(view.container.textContent).not.toContain("config.cascade");
75
+ // activeMarker übersetzt.
76
+ expect(view.container.textContent).toContain("active");
77
+ });
78
+ });
79
+
80
+ describe("ConfigCascadeView — Scope-Filter (Bug 8)", () => {
81
+ test("screenScope=tenant: Operator-Ebenen sind unsichtbar, EIN Preset-Fallback bleibt", async () => {
82
+ const user = userEvent.setup();
83
+ const view = render(<ConfigCascadeView cascade={tenantCascade()} screenScope="tenant" />);
84
+ await user.click(screen.getByRole("button"));
85
+
86
+ // Sichtbar: Tenant-Zeile + genau eine neutrale Preset-Zeile.
87
+ expect(view.container.textContent).toContain("Tenant");
88
+ expect(view.container.textContent).toContain("Preset");
89
+ // Unsichtbar: alles was nur der Operator steuert.
90
+ expect(view.container.textContent).not.toContain("System");
91
+ expect(view.container.textContent).not.toContain("App override");
92
+ expect(view.container.textContent).not.toContain("Computed");
93
+ // Der deklarierte Default erscheint als Preset-Wert, nicht als
94
+ // eigene "Default"-Ebene.
95
+ expect(view.container.textContent).toContain("fallback");
96
+ expect(view.container.textContent).not.toContain("Default");
97
+ });
98
+
99
+ test("screenScope=tenant mit aktivem System-Wert: Preset zeigt den effektiven Wert, nicht die Quelle", async () => {
100
+ const user = userEvent.setup();
101
+ const view = render(
102
+ <ConfigCascadeView cascade={tenantCascade({ systemActive: true })} screenScope="tenant" />,
103
+ );
104
+ // Collapsed-Header leakt die Operator-Quelle nicht …
105
+ expect(view.container.textContent).toContain("Preset");
106
+ expect(view.container.textContent).toContain("system-smtp");
107
+ expect(view.container.textContent).not.toContain("System");
108
+
109
+ // … und expanded genauso: effektiver Wert sichtbar, Quelle neutral.
110
+ await user.click(screen.getAllByRole("button")[0] as HTMLElement);
111
+ expect(view.container.textContent).toContain("system-smtp");
112
+ expect(view.container.textContent).not.toContain("System");
113
+ });
114
+
115
+ test("screenScope=system: Operator sieht weiterhin die volle Cascade", async () => {
116
+ const user = userEvent.setup();
117
+ const view = render(
118
+ <ConfigCascadeView cascade={tenantCascade({ systemActive: true })} screenScope="system" />,
119
+ );
120
+ await user.click(screen.getByRole("button"));
121
+ expect(view.container.textContent).toContain("System");
122
+ expect(view.container.textContent).toContain("App override");
123
+ expect(view.container.textContent).toContain("Computed");
124
+ expect(view.container.textContent).toContain("Default");
125
+ });
126
+
127
+ test("Reset-Button erscheint nur bei eigener Überschreibung und nennt den Scope übersetzt", async () => {
128
+ const user = userEvent.setup();
129
+ const resets: { key: string; scope: string }[] = [];
130
+ render(
131
+ <ConfigCascadeView
132
+ cascade={tenantCascade({ tenantValue: "acme" })}
133
+ screenScope="tenant"
134
+ qualifiedKey="branding.title"
135
+ onReset={(key, scope) => resets.push({ key, scope })}
136
+ />,
137
+ );
138
+ await user.click(screen.getByRole("button"));
139
+ const reset = screen.getByText("Reset override (Tenant)");
140
+ await user.click(reset);
141
+ expect(resets).toEqual([{ key: "branding.title", scope: "tenant" }]);
142
+ });
143
+
144
+ test("ohne eigene Überschreibung: kein Reset-Button", async () => {
145
+ const user = userEvent.setup();
146
+ const view = render(
147
+ <ConfigCascadeView
148
+ cascade={tenantCascade()}
149
+ screenScope="tenant"
150
+ qualifiedKey="branding.title"
151
+ onReset={() => undefined}
152
+ />,
153
+ );
154
+ await user.click(screen.getByRole("button"));
155
+ expect(view.queryByText("Reset override (Tenant)")).toBeNull();
156
+ });
157
+ });
@@ -348,18 +348,95 @@ describe("KumikoScreen", () => {
348
348
  });
349
349
  });
350
350
 
351
+ // Regression zum Prod-Bug 2026-06-07 (Bug 4): dispatcher.write wirft
352
+ // nicht — ein Failure-Result wurde verworfen, der Confirm-Dialog
353
+ // schloss kommentarlos und der User sah "nichts passiert". Jetzt:
354
+ // Dialog schließt UND ein destructive-Toast zeigt den Fehlertext.
355
+ test("entityList rowActions writeHandler: fehlgeschlagener Write → Fehler-Toast statt stillem Dialog-Close", async () => {
356
+ const dispatcher = makeDispatcher({
357
+ query: (async () => ({
358
+ isSuccess: true,
359
+ data: {
360
+ rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
361
+ nextCursor: null,
362
+ },
363
+ })) as unknown as Dispatcher["query"],
364
+ write: (async () => ({
365
+ isSuccess: false,
366
+ error: {
367
+ code: "conflict",
368
+ httpStatus: 409,
369
+ // Key absichtlich in keinem Bundle — dispatcherErrorText muss
370
+ // auf error.message zurückfallen.
371
+ i18nKey: "tasks.errors.delete-conflict",
372
+ message: "Version conflict for entity r1",
373
+ },
374
+ })) as unknown as Dispatcher["write"],
375
+ });
376
+
377
+ const screenWithDelete: EntityListScreenDefinition = {
378
+ id: "task-list",
379
+ type: "entityList",
380
+ entity: "task",
381
+ columns: ["title"],
382
+ rowActions: [
383
+ {
384
+ id: "delete",
385
+ label: "actions.delete",
386
+ handler: "tasks:write:task:delete",
387
+ confirm: "actions.delete-confirm",
388
+ style: "danger",
389
+ },
390
+ ],
391
+ };
392
+
393
+ const { ToastProvider } = await import("../primitives/toast");
394
+ const user = userEvent.setup();
395
+ render(
396
+ <ToastProvider>
397
+ <DispatcherProvider dispatcher={dispatcher}>
398
+ <KumikoScreen
399
+ schema={{ ...schema, screens: [screenWithDelete] }}
400
+ qn="tasks:screen:task-list"
401
+ />
402
+ </DispatcherProvider>
403
+ </ToastProvider>,
404
+ );
405
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
406
+
407
+ // danger erzwingt den Confirm-Dialog.
408
+ await user.click(screen.getByTestId("row-r1-action-delete"));
409
+ expect(await screen.findByTestId("row-r1-action-delete-dialog")).toBeTruthy();
410
+
411
+ await user.click(screen.getByTestId("row-r1-action-delete-dialog-confirm"));
412
+
413
+ // Dialog schließt — aber NICHT kommentarlos: der Fehlertext ist da.
414
+ await waitFor(() => expect(screen.queryByTestId("row-r1-action-delete-dialog")).toBeNull());
415
+ expect(await screen.findByText("Version conflict for entity r1")).toBeTruthy();
416
+ });
417
+
351
418
  // Tier 2.7e-1: rowAction kind="navigate" — Click ruft nav.navigate
352
419
  // mit screen-id, ggf. mit URL-Search-Params aus params(row).
353
- test("entityList rowActions kind=navigate: Click nav.navigate + setSearchParams", async () => {
420
+ // Reihenfolge ist Teil des Contracts: navigate ZUERST, dann
421
+ // setSearchParams — pushState trägt keine Query, vorher gesetzte
422
+ // Params kleben sonst an der alten URL (actionForm-Prefill leer).
423
+ test("entityList rowActions kind=navigate: Click → nav.navigate, DANN setSearchParams", async () => {
424
+ const calls: { kind: "navigate" | "setSearchParams"; value: unknown }[] = [];
354
425
  const navigateCalls: { screenId: string }[] = [];
355
426
  const searchParamUpdates: Record<string, string | null>[] = [];
356
427
  const memoryNav = {
357
428
  route: { screenId: "task-list" },
358
- navigate: (target: { screenId: string }) => navigateCalls.push(target),
429
+ navigate: (target: { screenId: string }) => {
430
+ calls.push({ kind: "navigate", value: target });
431
+ navigateCalls.push(target);
432
+ },
359
433
  replace: () => undefined,
360
434
  hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
361
435
  searchParams: {},
362
- setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
436
+ setSearchParams: (u: Record<string, string | null>) => {
437
+ calls.push({ kind: "setSearchParams", value: u });
438
+ searchParamUpdates.push(u);
439
+ },
363
440
  };
364
441
  const dispatcher = makeDispatcher({
365
442
  query: (async () => ({
@@ -406,6 +483,68 @@ describe("KumikoScreen", () => {
406
483
  expect(navigateCalls[0]).toEqual({ screenId: "task-edit" });
407
484
  // params werden zu Strings serialisiert (URL-Layer kennt nur Strings).
408
485
  expect(searchParamUpdates).toEqual([{ taskId: "r1", priority: "5" }]);
486
+ // Reihenfolge-Pin: erst navigate, dann setSearchParams.
487
+ expect(calls.map((c) => c.kind)).toEqual(["navigate", "setSearchParams"]);
488
+ });
489
+
490
+ // entityId-Variante: entityEdit-Targets brauchen die Id als PFAD-
491
+ // Segment (route.entityId), nicht als Search-Param — sonst öffnet
492
+ // der Edit-Screen im Create-Mode (Prod-Bug 2026-06-07, Bug 3).
493
+ test("entityList rowActions kind=navigate mit entityId: Id landet im NavTarget, nicht in den Search-Params", async () => {
494
+ const navigateCalls: { screenId: string; entityId?: string }[] = [];
495
+ const searchParamUpdates: Record<string, string | null>[] = [];
496
+ const memoryNav = {
497
+ route: { screenId: "task-list" },
498
+ navigate: (target: { screenId: string; entityId?: string }) => navigateCalls.push(target),
499
+ replace: () => undefined,
500
+ hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
501
+ searchParams: {},
502
+ setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
503
+ };
504
+ const dispatcher = makeDispatcher({
505
+ query: (async () => ({
506
+ isSuccess: true,
507
+ data: {
508
+ rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
509
+ nextCursor: null,
510
+ },
511
+ })) as unknown as Dispatcher["query"],
512
+ });
513
+
514
+ const screenWithEdit: EntityListScreenDefinition = {
515
+ id: "task-list",
516
+ type: "entityList",
517
+ entity: "task",
518
+ columns: ["title"],
519
+ rowActions: [
520
+ {
521
+ kind: "navigate",
522
+ id: "edit",
523
+ label: "actions.edit",
524
+ screen: "task-edit",
525
+ entityId: (row) => String(row["id"] ?? ""),
526
+ },
527
+ ],
528
+ };
529
+
530
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
531
+ const user = userEvent.setup();
532
+ render(
533
+ <NavProvider value={memoryNav}>
534
+ <DispatcherProvider dispatcher={dispatcher}>
535
+ <KumikoScreen
536
+ schema={{ ...schema, screens: [screenWithEdit] }}
537
+ qn="tasks:screen:task-list"
538
+ />
539
+ </DispatcherProvider>
540
+ </NavProvider>,
541
+ );
542
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
543
+
544
+ await user.click(screen.getByTestId("row-r1-action-edit"));
545
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
546
+ expect(navigateCalls[0]).toEqual({ screenId: "task-edit", entityId: "r1" });
547
+ expect(searchParamUpdates).toEqual([]);
409
548
  });
410
549
 
411
550
  test("entityList rowActions kind=navigate ohne params: setSearchParams wird NICHT gerufen", async () => {
@@ -746,6 +885,85 @@ describe("KumikoScreen", () => {
746
885
  expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
747
886
  });
748
887
 
888
+ // cancelTarget (Bug-Bash 2026-06-07, Bug 9): redirect erzeugte
889
+ // automatisch einen Abbrechen-Button mit demselben Ziel wie der
890
+ // Submit-Redirect — auf Single-Action-Screens ("Test-Mail senden")
891
+ // semantisch leer. `cancelTarget: false` schaltet ihn ab,
892
+ // `cancelTarget: "<screen>"` entkoppelt ihn vom redirect.
893
+ test("actionForm mit redirect: Abbrechen-Button existiert (historisches Default-Verhalten)", () => {
894
+ const actionScreen: ActionFormScreenDefinition = {
895
+ id: "quick-add",
896
+ type: "actionForm",
897
+ handler: "tasks:write:task:quick-add",
898
+ fields: { title: { type: "text", required: true } },
899
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
900
+ redirect: "task-list",
901
+ };
902
+ render(
903
+ <DispatcherProvider dispatcher={makeDispatcher()}>
904
+ <KumikoScreen
905
+ schema={{ ...schema, screens: [actionScreen, listScreen] }}
906
+ qn="tasks:screen:quick-add"
907
+ />
908
+ </DispatcherProvider>,
909
+ );
910
+ expect(screen.getByTestId("render-edit-cancel")).toBeTruthy();
911
+ });
912
+
913
+ test("actionForm mit cancelTarget=false: KEIN Abbrechen-Button trotz redirect", () => {
914
+ const actionScreen: ActionFormScreenDefinition = {
915
+ id: "quick-add",
916
+ type: "actionForm",
917
+ handler: "tasks:write:task:quick-add",
918
+ fields: { title: { type: "text", required: true } },
919
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
920
+ redirect: "task-list",
921
+ cancelTarget: false,
922
+ };
923
+ render(
924
+ <DispatcherProvider dispatcher={makeDispatcher()}>
925
+ <KumikoScreen
926
+ schema={{ ...schema, screens: [actionScreen, listScreen] }}
927
+ qn="tasks:screen:quick-add"
928
+ />
929
+ </DispatcherProvider>,
930
+ );
931
+ expect(screen.queryByTestId("render-edit-cancel")).toBeNull();
932
+ });
933
+
934
+ test("actionForm mit cancelTarget-Screen: Abbrechen navigiert dorthin, auch ohne redirect", async () => {
935
+ const navigateCalls: { screenId: string }[] = [];
936
+ const memoryNav = {
937
+ route: { screenId: "quick-add" },
938
+ navigate: (target: { screenId: string }) => navigateCalls.push(target),
939
+ replace: () => undefined,
940
+ hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
941
+ searchParams: {},
942
+ setSearchParams: () => undefined,
943
+ };
944
+ const actionScreen: ActionFormScreenDefinition = {
945
+ id: "quick-add",
946
+ type: "actionForm",
947
+ handler: "tasks:write:task:quick-add",
948
+ fields: { title: { type: "text", required: true } },
949
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
950
+ cancelTarget: "task-list",
951
+ };
952
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
953
+ render(
954
+ <NavProvider value={memoryNav}>
955
+ <DispatcherProvider dispatcher={makeDispatcher()}>
956
+ <KumikoScreen
957
+ schema={{ ...schema, screens: [actionScreen, listScreen] }}
958
+ qn="tasks:screen:quick-add"
959
+ />
960
+ </DispatcherProvider>
961
+ </NavProvider>,
962
+ );
963
+ fireEvent.click(screen.getByTestId("render-edit-cancel"));
964
+ expect(navigateCalls).toEqual([{ screenId: "task-list" }]);
965
+ });
966
+
749
967
  // Tier 2.7e-2: URL-Search-Params füllen die actionForm initial values.
750
968
  // Use-case: rowAction kind=navigate setzt `?taskId=r1`, das actionForm
751
969
  // sieht es beim Mount und pre-fillt das title-Feld.
@@ -0,0 +1,167 @@
1
+ //
2
+ // useBrowserNavApi({ hasWorkspaces }) — NavTarget-Contract: workspaceId
3
+ // weglassen = aktueller Workspace bleibt. Regression zum Prod-Bug
4
+ // 2026-06-07 (publicstatus Bugs 3/5): navigate({ screenId }) aus einem
5
+ // Workspace heraus erzeugte `/<screenId>`, parsePath las das Screen-
6
+ // Segment als Workspace-Id, WorkspaceShell revertete sofort auf den
7
+ // Default-Screen — Edit-/Toolbar-Aktionen wirkten tot.
8
+ //
9
+
10
+ import { beforeEach, describe, expect, test } from "bun:test";
11
+ import { NavProvider, useNav } from "@cosmicdrift/kumiko-renderer";
12
+ import { act, fireEvent, render, screen } from "@testing-library/react";
13
+ import type { ReactNode } from "react";
14
+ import { useBrowserNavApi } from "../app/nav";
15
+
16
+ function WorkspaceNav({ children }: { readonly children: ReactNode }): ReactNode {
17
+ const api = useBrowserNavApi({ hasWorkspaces: true });
18
+ return <NavProvider value={api}>{children}</NavProvider>;
19
+ }
20
+
21
+ function Probe(): React.ReactElement {
22
+ const nav = useNav();
23
+ return (
24
+ <div>
25
+ <span data-testid="workspace-id">{nav.route?.workspaceId ?? "(none)"}</span>
26
+ <span data-testid="screen-id">{nav.route?.screenId ?? "(none)"}</span>
27
+ <span data-testid="entity-id">{nav.route?.entityId ?? "(none)"}</span>
28
+ <span data-testid="href-edit">
29
+ {nav.hrefFor({ screenId: "component-edit", entityId: "c1" })}
30
+ </span>
31
+ <button
32
+ type="button"
33
+ data-testid="go-edit"
34
+ onClick={() => nav.navigate({ screenId: "component-edit", entityId: "c1" })}
35
+ >
36
+ edit
37
+ </button>
38
+ <button
39
+ type="button"
40
+ data-testid="go-form"
41
+ onClick={() => nav.navigate({ screenId: "maintenance-schedule-form" })}
42
+ >
43
+ form
44
+ </button>
45
+ <button
46
+ type="button"
47
+ data-testid="go-other-workspace"
48
+ onClick={() => nav.navigate({ workspaceId: "visual", screenId: "tree" })}
49
+ >
50
+ switch
51
+ </button>
52
+ <button
53
+ type="button"
54
+ data-testid="replace-form"
55
+ onClick={() => nav.replace({ screenId: "maintenance-schedule-form" })}
56
+ >
57
+ replace
58
+ </button>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ describe("useBrowserNavApi({ hasWorkspaces: true }) — Workspace-Erhalt ohne explizite workspaceId", () => {
64
+ beforeEach(() => {
65
+ window.history.replaceState(null, "", "/admin/component-list");
66
+ });
67
+
68
+ test("navigate({ screenId, entityId }) erbt den aktuellen Workspace → '/admin/component-edit/c1'", () => {
69
+ render(
70
+ <WorkspaceNav>
71
+ <Probe />
72
+ </WorkspaceNav>,
73
+ );
74
+ act(() => {
75
+ fireEvent.click(screen.getByTestId("go-edit"));
76
+ });
77
+ expect(window.location.pathname).toBe("/admin/component-edit/c1");
78
+ expect(screen.getByTestId("workspace-id").textContent).toBe("admin");
79
+ expect(screen.getByTestId("screen-id").textContent).toBe("component-edit");
80
+ expect(screen.getByTestId("entity-id").textContent).toBe("c1");
81
+ });
82
+
83
+ test("navigate({ screenId }) (Toolbar-Fall 'Wartung planen') bleibt im Workspace", () => {
84
+ render(
85
+ <WorkspaceNav>
86
+ <Probe />
87
+ </WorkspaceNav>,
88
+ );
89
+ act(() => {
90
+ fireEvent.click(screen.getByTestId("go-form"));
91
+ });
92
+ expect(window.location.pathname).toBe("/admin/maintenance-schedule-form");
93
+ expect(screen.getByTestId("screen-id").textContent).toBe("maintenance-schedule-form");
94
+ });
95
+
96
+ test("explizite workspaceId gewinnt weiterhin (WorkspaceSwitcher-Fall)", () => {
97
+ render(
98
+ <WorkspaceNav>
99
+ <Probe />
100
+ </WorkspaceNav>,
101
+ );
102
+ act(() => {
103
+ fireEvent.click(screen.getByTestId("go-other-workspace"));
104
+ });
105
+ expect(window.location.pathname).toBe("/visual/tree");
106
+ expect(screen.getByTestId("workspace-id").textContent).toBe("visual");
107
+ });
108
+
109
+ test("replace erbt den Workspace symmetrisch zu navigate", () => {
110
+ render(
111
+ <WorkspaceNav>
112
+ <Probe />
113
+ </WorkspaceNav>,
114
+ );
115
+ act(() => {
116
+ fireEvent.click(screen.getByTestId("replace-form"));
117
+ });
118
+ expect(window.location.pathname).toBe("/admin/maintenance-schedule-form");
119
+ });
120
+
121
+ test("hrefFor erbt den Workspace — Anchor-Links zeigen nicht aus dem Workspace raus", () => {
122
+ render(
123
+ <WorkspaceNav>
124
+ <Probe />
125
+ </WorkspaceNav>,
126
+ );
127
+ expect(screen.getByTestId("href-edit").textContent).toBe("/admin/component-edit/c1");
128
+ });
129
+
130
+ test("ohne aktuelle Route (URL an der Root) bleibt das Target unverändert", () => {
131
+ window.history.replaceState(null, "", "/");
132
+ render(
133
+ <WorkspaceNav>
134
+ <Probe />
135
+ </WorkspaceNav>,
136
+ );
137
+ act(() => {
138
+ fireEvent.click(screen.getByTestId("go-form"));
139
+ });
140
+ // Kein Workspace zum Erben — formatPath bleibt flach; WorkspaceShell
141
+ // löst die Default-Workspace-Auflösung wie bisher selbst.
142
+ expect(window.location.pathname).toBe("/maintenance-schedule-form");
143
+ });
144
+ });
145
+
146
+ describe("useBrowserNavApi ohne Workspaces — Injection bleibt aus", () => {
147
+ beforeEach(() => {
148
+ window.history.replaceState(null, "", "/task-list");
149
+ });
150
+
151
+ function FlatNav({ children }: { readonly children: ReactNode }): ReactNode {
152
+ const api = useBrowserNavApi({ hasWorkspaces: false });
153
+ return <NavProvider value={api}>{children}</NavProvider>;
154
+ }
155
+
156
+ test("navigate({ screenId }) bleibt flach: '/maintenance-schedule-form'", () => {
157
+ render(
158
+ <FlatNav>
159
+ <Probe />
160
+ </FlatNav>,
161
+ );
162
+ act(() => {
163
+ fireEvent.click(screen.getByTestId("go-form"));
164
+ });
165
+ expect(window.location.pathname).toBe("/maintenance-schedule-form");
166
+ });
167
+ });
@@ -0,0 +1,79 @@
1
+ // Theme-Persistenz (Bug-Bash 2026-06-07, Bug 13): Der Toggle setzte
2
+ // nur die .dark-Class — ohne localStorage-Persist + Restore war die
3
+ // Wahl nach jedem Reload weg, was sich für User als "dark/light geht
4
+ // nicht" anfühlte. Der FOUC-Schutz (Inline-Script in der Host-HTML)
5
+ // ist App-Sache; hier wird die JS-Seite gepinnt.
6
+
7
+ import { beforeEach, describe, expect, test } from "bun:test";
8
+ import { act, render, screen } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { applyStoredThemeMode, THEME_STORAGE_KEY, useBrowserTokensApi } from "../tokens";
11
+
12
+ function Probe(): ReactNode {
13
+ const api = useBrowserTokensApi();
14
+ return (
15
+ <div>
16
+ <span data-testid="mode">{api.mode}</span>
17
+ <button type="button" data-testid="toggle" onClick={() => api.toggleMode()}>
18
+ toggle
19
+ </button>
20
+ <button type="button" data-testid="set-light" onClick={() => api.setMode("light")}>
21
+ light
22
+ </button>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ describe("useBrowserTokensApi — Theme-Persistenz", () => {
28
+ beforeEach(() => {
29
+ window.localStorage.removeItem(THEME_STORAGE_KEY);
30
+ document.documentElement.classList.remove("dark");
31
+ });
32
+
33
+ test("toggleMode persistiert die Wahl in localStorage", () => {
34
+ render(<Probe />);
35
+ expect(screen.getByTestId("mode").textContent).toBe("light");
36
+
37
+ act(() => {
38
+ screen.getByTestId("toggle").click();
39
+ });
40
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
41
+ expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark");
42
+
43
+ act(() => {
44
+ screen.getByTestId("toggle").click();
45
+ });
46
+ expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("light");
47
+ });
48
+
49
+ test("setMode persistiert ebenfalls", () => {
50
+ render(<Probe />);
51
+ act(() => {
52
+ screen.getByTestId("set-light").click();
53
+ });
54
+ expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("light");
55
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
56
+ });
57
+
58
+ test("applyStoredThemeMode restored die gespeicherte Wahl (Reload-Simulation)", () => {
59
+ // "Reload": Class weg (frisches HTML), aber localStorage hat dark.
60
+ window.localStorage.setItem(THEME_STORAGE_KEY, "dark");
61
+ document.documentElement.classList.remove("dark");
62
+
63
+ applyStoredThemeMode();
64
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
65
+ });
66
+
67
+ test("applyStoredThemeMode ohne gespeicherte Wahl lässt den HTML-Default stehen", () => {
68
+ document.documentElement.classList.add("dark");
69
+ applyStoredThemeMode();
70
+ // Kein gespeicherter Wert → nichts anfassen.
71
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
72
+ });
73
+
74
+ test("applyStoredThemeMode ignoriert kaputte Werte", () => {
75
+ window.localStorage.setItem(THEME_STORAGE_KEY, "neon");
76
+ applyStoredThemeMode();
77
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
78
+ });
79
+ });
package/src/app/nav.tsx CHANGED
@@ -185,17 +185,28 @@ export function useBrowserNavApi(options?: {
185
185
  const basePath = useMemo(() => normalizeBasePath(options?.basePath), [options?.basePath]);
186
186
  const searchParams = useMemo(() => parseSearchParams(search), [search]);
187
187
  const inAppPath = useMemo(() => stripBasePath(path, basePath), [path, basePath]);
188
- return useMemo<NavApi>(
189
- () => ({
190
- route: inAppPath === undefined ? undefined : parsePath(inAppPath, hasWorkspaces),
191
- navigate: (target) => pushPath(prependBasePath(formatPath(target), basePath)),
192
- replace: (target) => replacePath(prependBasePath(formatPath(target), basePath)),
193
- hrefFor: (target) => prependBasePath(formatPath(target), basePath),
188
+ return useMemo<NavApi>(() => {
189
+ const route = inAppPath === undefined ? undefined : parsePath(inAppPath, hasWorkspaces);
190
+ // NavTarget-Contract: workspaceId weglassen = aktueller Workspace
191
+ // bleibt. formatPath kennt die aktuelle Route nicht — ohne Injection
192
+ // landet `/screen-id` in parsePath(hasWorkspaces) als workspaceId,
193
+ // WorkspaceShell sieht einen unbekannten Workspace und revertet
194
+ // sofort auf den Default-Screen ("Klick tut nichts"-Prod-Bug).
195
+ const inCurrentWorkspace = (target: NavTarget): NavTarget =>
196
+ hasWorkspaces && target.workspaceId === undefined && route?.workspaceId !== undefined
197
+ ? { ...target, workspaceId: route.workspaceId }
198
+ : target;
199
+ return {
200
+ route,
201
+ navigate: (target) =>
202
+ pushPath(prependBasePath(formatPath(inCurrentWorkspace(target)), basePath)),
203
+ replace: (target) =>
204
+ replacePath(prependBasePath(formatPath(inCurrentWorkspace(target)), basePath)),
205
+ hrefFor: (target) => prependBasePath(formatPath(inCurrentWorkspace(target)), basePath),
194
206
  searchParams,
195
207
  setSearchParams: applySearchParamUpdates,
196
- }),
197
- [inAppPath, hasWorkspaces, basePath, searchParams],
198
- );
208
+ };
209
+ }, [inAppPath, hasWorkspaces, basePath, searchParams]);
199
210
  }
200
211
 
201
212
  // ---- KumikoLink (Anchor-basiert, nur Web) ----
@@ -9,13 +9,13 @@ import type { ReactNode } from "react";
9
9
  import { useState } from "react";
10
10
 
11
11
  const SOURCE_I18N_KEY: Record<ConfigValueSource, string> = {
12
- "user-row": "config.source.user",
13
- "tenant-row": "config.source.tenant",
14
- "system-row": "config.source.system",
15
- "app-override": "config.source.appOverride",
16
- computed: "config.source.computed",
17
- default: "config.source.default",
18
- missing: "config.source.missing",
12
+ "user-row": "kumiko.config.source.user",
13
+ "tenant-row": "kumiko.config.source.tenant",
14
+ "system-row": "kumiko.config.source.system",
15
+ "app-override": "kumiko.config.source.appOverride",
16
+ computed: "kumiko.config.source.computed",
17
+ default: "kumiko.config.source.default",
18
+ missing: "kumiko.config.source.missing",
19
19
  };
20
20
 
21
21
  const SOURCE_COLORS: Record<ConfigValueSource, string> = {
@@ -28,13 +28,32 @@ const SOURCE_COLORS: Record<ConfigValueSource, string> = {
28
28
  missing: "text-red-500 bg-red-50 border-red-200",
29
29
  };
30
30
 
31
- function SourceBadge({ source }: { source: ConfigValueSource }): ReactNode {
31
+ // Fallback-Reihenfolge der Cascade, spezifischste Quelle zuerst.
32
+ // Index-Vergleich gegen die Screen-Scope-Quelle entscheidet, welche
33
+ // Ebenen ein Nicht-Operator sehen darf.
34
+ const SOURCE_ORDER: readonly ConfigValueSource[] = [
35
+ "user-row",
36
+ "tenant-row",
37
+ "system-row",
38
+ "app-override",
39
+ "computed",
40
+ "default",
41
+ "missing",
42
+ ];
43
+
44
+ function SourceBadge({
45
+ source,
46
+ labelKey,
47
+ }: {
48
+ source: ConfigValueSource;
49
+ labelKey?: string;
50
+ }): ReactNode {
32
51
  const t = useTranslation();
33
52
  return (
34
53
  <span
35
54
  className={`inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${SOURCE_COLORS[source]}`}
36
55
  >
37
- {t(SOURCE_I18N_KEY[source])}
56
+ {t(labelKey ?? SOURCE_I18N_KEY[source])}
38
57
  </span>
39
58
  );
40
59
  }
@@ -50,6 +69,47 @@ function scopeToSource(scope: ConfigScope): ConfigValueSource {
50
69
  return "system-row";
51
70
  }
52
71
 
72
+ // Eine Cascade-Zeile in Anzeige-Form: Ebenen oberhalb des Screen-Scopes
73
+ // werden für Nicht-Operator-Screens zu EINER neutralen "Vorgabe"-Zeile
74
+ // kollabiert — der Wert bleibt sichtbar, die Operator-Quelle nicht.
75
+ type DisplayLevel = {
76
+ readonly level: ConfigCascadeLevel;
77
+ readonly badgeSource: ConfigValueSource;
78
+ readonly badgeLabelKey?: string;
79
+ };
80
+
81
+ function toDisplayLevels(
82
+ levels: readonly ConfigCascadeLevel[],
83
+ screenScopeSource: ConfigValueSource,
84
+ ): readonly DisplayLevel[] {
85
+ // System-Screens sind Operator-Sicht — volle Cascade inkl.
86
+ // app-override/computed/default bleibt sichtbar.
87
+ if (screenScopeSource === "system-row") {
88
+ return levels.map((level) => ({ level, badgeSource: level.source }));
89
+ }
90
+ const scopeIdx = SOURCE_ORDER.indexOf(screenScopeSource);
91
+ const own = levels.filter((l) => SOURCE_ORDER.indexOf(l.source) <= scopeIdx);
92
+ const higher = levels.filter((l) => SOURCE_ORDER.indexOf(l.source) > scopeIdx);
93
+ // Genau eine Fallback-Zeile: die aktive höhere Ebene (deren Wert der
94
+ // User effektiv bekommt), sonst der deklarierte Default/Missing.
95
+ const fallback =
96
+ higher.find((l) => l.isActive) ??
97
+ higher.find((l) => l.source === "default" || l.source === "missing");
98
+ const ownRows: DisplayLevel[] = own.map((level) => ({ level, badgeSource: level.source }));
99
+ if (fallback === undefined) return ownRows;
100
+ return [
101
+ ...ownRows,
102
+ {
103
+ level: fallback,
104
+ badgeSource: "default",
105
+ // Neutral "Vorgabe" statt System/Override/Computed — der Screen-
106
+ // Scope kann diese Ebenen weder setzen noch zurücksetzen, die
107
+ // Quelle ist für ihn Operator-Interna.
108
+ badgeLabelKey: "kumiko.config.cascade.preset",
109
+ },
110
+ ];
111
+ }
112
+
53
113
  type ConfigCascadeViewProps = {
54
114
  readonly cascade: ConfigCascade;
55
115
  readonly screenScope: ConfigScope;
@@ -71,9 +131,10 @@ export function ConfigCascadeView({
71
131
  // the screen.
72
132
  if (!Array.isArray(cascade?.levels)) return null;
73
133
 
74
- const activeLevel = cascade.levels.find((l) => l.isActive);
75
134
  const screenScopeSource = scopeToSource(screenScope);
76
- const hasOverride = activeLevel?.source === screenScopeSource;
135
+ const displayLevels = toDisplayLevels(cascade.levels, screenScopeSource);
136
+ const activeDisplay = displayLevels.find((d) => d.level.isActive);
137
+ const hasOverride = activeDisplay?.level.source === screenScopeSource;
77
138
 
78
139
  return (
79
140
  <div className="mt-1 text-xs">
@@ -83,22 +144,27 @@ export function ConfigCascadeView({
83
144
  className="flex items-center gap-1 text-gray-500 hover:text-gray-700 cursor-pointer"
84
145
  >
85
146
  <span className="text-[10px]">{expanded ? "▼" : "▶"}</span>
86
- {activeLevel ? (
147
+ {activeDisplay ? (
87
148
  <>
88
- <SourceBadge source={activeLevel.source} />
149
+ <SourceBadge
150
+ source={activeDisplay.badgeSource}
151
+ {...(activeDisplay.badgeLabelKey !== undefined && {
152
+ labelKey: activeDisplay.badgeLabelKey,
153
+ })}
154
+ />
89
155
  <span className="text-gray-400">
90
- {formatValue(activeLevel.value, activeLevel.hasValue)}
156
+ {formatValue(activeDisplay.level.value, activeDisplay.level.hasValue)}
91
157
  </span>
92
158
  </>
93
159
  ) : (
94
- <span className="text-gray-400">{t("config.cascade.noValue")}</span>
160
+ <span className="text-gray-400">{t("kumiko.config.cascade.noValue")}</span>
95
161
  )}
96
162
  </button>
97
163
 
98
164
  {expanded ? (
99
165
  <div className="mt-1 flex flex-col gap-0.5 pl-3 border-l-2 border-gray-100">
100
- {cascade.levels.map((level) => (
101
- <CascadeLevelRow key={level.source} level={level} />
166
+ {displayLevels.map((display) => (
167
+ <CascadeLevelRow key={display.level.source} display={display} />
102
168
  ))}
103
169
 
104
170
  {hasOverride && onReset && qualifiedKey ? (
@@ -107,7 +173,9 @@ export function ConfigCascadeView({
107
173
  onClick={() => onReset(qualifiedKey, screenScope)}
108
174
  className="mt-1 self-start text-[10px] text-orange-500 hover:text-orange-700 cursor-pointer underline"
109
175
  >
110
- {t("config.cascade.resetTo", { scope: t(SOURCE_I18N_KEY[screenScopeSource]) })}
176
+ {t("kumiko.config.cascade.resetTo", {
177
+ scope: t(SOURCE_I18N_KEY[screenScopeSource]),
178
+ })}
111
179
  </button>
112
180
  ) : null}
113
181
  </div>
@@ -116,16 +184,20 @@ export function ConfigCascadeView({
116
184
  );
117
185
  }
118
186
 
119
- function CascadeLevelRow({ level }: { level: ConfigCascadeLevel }): ReactNode {
187
+ function CascadeLevelRow({ display }: { display: DisplayLevel }): ReactNode {
120
188
  const t = useTranslation();
189
+ const { level } = display;
121
190
  return (
122
191
  <div
123
192
  className={`flex items-center gap-1.5 ${level.isActive ? "font-medium" : "text-gray-400"}`}
124
193
  >
125
- <SourceBadge source={level.source} />
194
+ <SourceBadge
195
+ source={display.badgeSource}
196
+ {...(display.badgeLabelKey !== undefined && { labelKey: display.badgeLabelKey })}
197
+ />
126
198
  <span>{formatValue(level.value, level.hasValue)}</span>
127
199
  {level.isActive ? (
128
- <span className="text-[10px] text-gray-400">{t("config.cascade.activeMarker")}</span>
200
+ <span className="text-[10px] text-gray-400">{t("kumiko.config.cascade.activeMarker")}</span>
129
201
  ) : null}
130
202
  </div>
131
203
  );
@@ -1,17 +1,19 @@
1
1
  import type { ConfigValueSource } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
2
3
  import type { ReactNode } from "react";
3
4
 
4
- const SOURCE_CONFIG: Record<ConfigValueSource, { label: string; bg: string; text: string }> = {
5
- "user-row": { label: "User", bg: "#dbeafe", text: "#1e40af" },
6
- "tenant-row": { label: "Tenant", bg: "#dcfce7", text: "#166534" },
7
- "system-row": { label: "System", bg: "#f3e8ff", text: "#6b21a8" },
8
- "app-override": { label: "Override", bg: "#ffedd5", text: "#9a3412" },
9
- computed: { label: "Computed", bg: "#ccfbf1", text: "#115e59" },
10
- default: { label: "Default", bg: "#f3f4f6", text: "#4b5563" },
11
- missing: { label: "Missing", bg: "#fee2e2", text: "#991b1b" },
5
+ const SOURCE_CONFIG: Record<ConfigValueSource, { labelKey: string; bg: string; text: string }> = {
6
+ "user-row": { labelKey: "kumiko.config.source.user", bg: "#dbeafe", text: "#1e40af" },
7
+ "tenant-row": { labelKey: "kumiko.config.source.tenant", bg: "#dcfce7", text: "#166534" },
8
+ "system-row": { labelKey: "kumiko.config.source.system", bg: "#f3e8ff", text: "#6b21a8" },
9
+ "app-override": { labelKey: "kumiko.config.source.appOverride", bg: "#ffedd5", text: "#9a3412" },
10
+ computed: { labelKey: "kumiko.config.source.computed", bg: "#ccfbf1", text: "#115e59" },
11
+ default: { labelKey: "kumiko.config.source.default", bg: "#f3f4f6", text: "#4b5563" },
12
+ missing: { labelKey: "kumiko.config.source.missing", bg: "#fee2e2", text: "#991b1b" },
12
13
  };
13
14
 
14
15
  export function ConfigSourceBadge({ source }: { readonly source: ConfigValueSource }): ReactNode {
16
+ const t = useTranslation();
15
17
  const cfg = SOURCE_CONFIG[source];
16
18
 
17
19
  return (
@@ -30,7 +32,7 @@ export function ConfigSourceBadge({ source }: { readonly source: ConfigValueSour
30
32
  whiteSpace: "nowrap",
31
33
  }}
32
34
  >
33
- {cfg.label}
35
+ {t(cfg.labelKey)}
34
36
  </span>
35
37
  );
36
38
  }
@@ -36,9 +36,12 @@ export function DefaultDialog({
36
36
  setLoading(true);
37
37
  try {
38
38
  await onConfirm();
39
- onOpenChange(false);
40
39
  } finally {
41
40
  setLoading(false);
41
+ // Auch bei rejected onConfirm schließen — ein offen hängendes Modal
42
+ // ohne Botschaft wirkt eingefroren; der Fehler selbst wird vom
43
+ // onConfirm-Pfad surfaced (Row-Actions: Toast).
44
+ onOpenChange(false);
42
45
  }
43
46
  }
44
47
 
@@ -29,6 +29,7 @@ import {
29
29
  type TextProps,
30
30
  useColumnRenderer,
31
31
  useTranslation,
32
+ WriteFailedError,
32
33
  } from "@cosmicdrift/kumiko-renderer";
33
34
  import * as LabelPrimitive from "@radix-ui/react-label";
34
35
  import { cva } from "class-variance-authority";
@@ -53,6 +54,7 @@ import {
53
54
  DropdownMenuTrigger,
54
55
  } from "./dropdown-menu";
55
56
  import { MoneyInput } from "./money-input";
57
+ import { useToast } from "./toast";
56
58
 
57
59
  // ---- Button ----
58
60
 
@@ -599,10 +601,22 @@ function needsConfirm(action: DataTableRowAction): boolean {
599
601
 
600
602
  function useRowActionTrigger(row: ListRowViewModel) {
601
603
  const [busy, setBusy] = useState(false);
604
+ const { toast } = useToast();
605
+ const t = useTranslation();
602
606
  const triggerNow = async (action: DataTableRowAction): Promise<void> => {
603
607
  setBusy(true);
604
608
  try {
605
609
  await action.onTrigger(row);
610
+ } catch (e) {
611
+ // Surfacing statt schlucken: ein verschluckter Write-Fehler sah für
612
+ // den User wie "nichts passiert" aus (Prod-Bug 2026-06-07).
613
+ const docsUrl = e instanceof WriteFailedError ? e.dispatcherError.docsUrl : undefined;
614
+ toast({
615
+ title: t("kumiko.rowAction.failed"),
616
+ description: e instanceof Error ? e.message : String(e),
617
+ variant: "destructive",
618
+ ...(docsUrl !== undefined && { docsUrl }),
619
+ });
606
620
  } finally {
607
621
  setBusy(false);
608
622
  }
package/src/tokens.ts CHANGED
@@ -16,9 +16,20 @@ import { useSyncExternalStore } from "react";
16
16
  // reiner Notification-Bus (Tick-Counter), den setMode/toggleMode bei
17
17
  // jedem Class-Wechsel hochzählen. So bleibt die DOM-Klasse die einzige
18
18
  // Wahrheit — readCurrentMode liest sie frisch bei jedem getSnapshot.
19
+ //
20
+ // Persistenz: die Wahl landet in localStorage (THEME_STORAGE_KEY) und
21
+ // wird beim ersten Hook-Mount restored — ohne das war der Toggle nach
22
+ // jedem Reload weg ("dark/light geht nicht", Prod-Bug 2026-06-07).
23
+ // Gegen FOUC gehört zusätzlich ein synchrones Inline-Script in die
24
+ // Host-HTML, VOR dem Stylesheet-Link:
25
+ //
26
+ // <script>try{if(localStorage.getItem("kumiko:theme")==="dark")
27
+ // document.documentElement.classList.add("dark")}catch(e){}</script>
19
28
 
20
29
  const themeTick = createStore(0);
21
30
 
31
+ export const THEME_STORAGE_KEY = "kumiko:theme";
32
+
22
33
  function readCurrentMode(): ThemeMode {
23
34
  if (typeof document === "undefined") return "dark";
24
35
  return document.documentElement.classList.contains("dark") ? "dark" : "light";
@@ -28,11 +39,46 @@ function notifyThemeChange(): void {
28
39
  themeTick.setState((t) => t + 1);
29
40
  }
30
41
 
42
+ function persistMode(mode: ThemeMode): void {
43
+ try {
44
+ window.localStorage.setItem(THEME_STORAGE_KEY, mode);
45
+ } catch {
46
+ // skip: localStorage kann werfen (Private-Mode/Quota) — Theme bleibt
47
+ // dann sessionbasiert, der Class-Toggle hat trotzdem funktioniert.
48
+ }
49
+ }
50
+
51
+ /** Liest die persistierte Theme-Wahl und setzt die `.dark`-Class. Wird
52
+ * beim ersten useBrowserTokensApi-Mount aufgerufen; das Inline-Script
53
+ * in der Host-HTML (siehe Header-Kommentar) macht dasselbe synchron
54
+ * vor dem ersten Paint. */
55
+ export function applyStoredThemeMode(): void {
56
+ if (typeof document === "undefined") return;
57
+ let stored: string | null = null;
58
+ try {
59
+ stored = window.localStorage.getItem(THEME_STORAGE_KEY);
60
+ } catch {
61
+ // skip: localStorage kann werfen (Private-Mode) — ohne gespeicherte
62
+ // Wahl bleibt der Server-/HTML-Default stehen.
63
+ }
64
+ if (stored !== "dark" && stored !== "light") return;
65
+ document.documentElement.classList.toggle("dark", stored === "dark");
66
+ notifyThemeChange();
67
+ }
68
+
69
+ let storedModeApplied = false;
70
+
31
71
  /** Hook der eine TokensApi für den Browser baut. Wird von
32
72
  * createKumikoApp genutzt; App-Code der einen eigenen Token-State
33
73
  * braucht (z.B. User-Präferenz aus localStorage) kann selber
34
74
  * `<TokensProvider value={...}>` mounten. */
35
75
  export function useBrowserTokensApi(): TokensApi {
76
+ // Einmal pro Page-Load: gespeicherte Wahl anwenden. Lazy statt
77
+ // Modul-Side-Effect, damit Import ohne DOM (SSR/Tests) safe bleibt.
78
+ if (!storedModeApplied && typeof document !== "undefined") {
79
+ storedModeApplied = true;
80
+ applyStoredThemeMode();
81
+ }
36
82
  const mode = useSyncExternalStore(themeTick.subscribe, readCurrentMode, () => "dark" as const);
37
83
  return {
38
84
  tokens: cssVarTokens,
@@ -40,11 +86,13 @@ export function useBrowserTokensApi(): TokensApi {
40
86
  setMode: (next) => {
41
87
  if (typeof document === "undefined") return;
42
88
  document.documentElement.classList.toggle("dark", next === "dark");
89
+ persistMode(next);
43
90
  notifyThemeChange();
44
91
  },
45
92
  toggleMode: () => {
46
93
  if (typeof document === "undefined") return;
47
- document.documentElement.classList.toggle("dark");
94
+ const nowDark = document.documentElement.classList.toggle("dark");
95
+ persistMode(nowDark ? "dark" : "light");
48
96
  notifyThemeChange();
49
97
  },
50
98
  };