@cosmicdrift/kumiko-renderer-web 0.38.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.38.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>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.37.0",
20
- "@cosmicdrift/kumiko-headless": "0.37.0",
21
- "@cosmicdrift/kumiko-renderer": "0.37.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.38.0",
20
+ "@cosmicdrift/kumiko-headless": "0.38.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.38.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",
@@ -166,4 +166,63 @@ describe("KumikoScreen / configEdit", () => {
166
166
  // dieser State stale (regression-guard).
167
167
  await waitFor(() => expect(submit.disabled).toBe(true));
168
168
  });
169
+
170
+ // Regression Bug-Bash-2 (2026-06-08): RenderEdit reichte denselben
171
+ // Appendix-Callback als labelAppendix UND fieldAppendix durch —
172
+ // Badge + Vorgabe-Disclosure erschienen doppelt (vor und nach dem
173
+ // Input) auf jedem Settings-Screen mit Default-Werten.
174
+ test("Source-Badge und Vorgabe-Disclosure erscheinen genau einmal pro Feld", async () => {
175
+ const dispatcher: Dispatcher = createMockDispatcher({
176
+ query: (async (qn: string) => {
177
+ if (qn === "config:query:cascade") {
178
+ return {
179
+ isSuccess: true,
180
+ data: {
181
+ "demo:config:site-name": {
182
+ value: "Acme",
183
+ source: "tenant-row",
184
+ levels: [
185
+ {
186
+ source: "tenant-row",
187
+ label: "tenant-row",
188
+ value: "Acme",
189
+ isActive: true,
190
+ hasValue: true,
191
+ },
192
+ {
193
+ source: "default",
194
+ label: "default",
195
+ value: "fallback",
196
+ isActive: false,
197
+ hasValue: true,
198
+ },
199
+ ],
200
+ },
201
+ },
202
+ };
203
+ }
204
+ return {
205
+ isSuccess: true,
206
+ data: {
207
+ "demo:config:site-name": { value: "Acme", scope: "tenant", source: "tenant-row" },
208
+ },
209
+ };
210
+ }) as unknown as Dispatcher["query"],
211
+ });
212
+ render(
213
+ <DispatcherProvider dispatcher={dispatcher}>
214
+ <KumikoScreen schema={schema} qn="demo:screen:settings" />
215
+ </DispatcherProvider>,
216
+ );
217
+ await waitFor(() => screen.getByTestId("render-edit-form"));
218
+ const field = screen.getByTestId("field-siteName");
219
+ await waitFor(() =>
220
+ expect(field.querySelectorAll('[data-testid="config-cascade"]')).toHaveLength(1),
221
+ );
222
+ expect(field.querySelectorAll('[data-testid="config-source-badge"]')).toHaveLength(1);
223
+ // Badge inline am Label, Disclosure unter dem Input — nicht umgekehrt.
224
+ const label = field.querySelector("label");
225
+ expect(label?.querySelector('[data-testid="config-source-badge"]')).toBeTruthy();
226
+ expect(label?.querySelector('[data-testid="config-cascade"]')).toBeNull();
227
+ });
169
228
  });
@@ -0,0 +1,69 @@
1
+ // Regression Bug-Bash-2 (2026-06-08): Validierungsfehler zeigten ROHE
2
+ // Keys ("errors.validation.invalid_format") — der Namespace den Server
3
+ // (ValidationError) und Client (zod-bridge) erzeugen war in keinem
4
+ // Default-Bundle definiert, und DefaultField reichte issue.params nicht
5
+ // an t() durch (Platzhalter wie {minimum} blieben uninterpoliert).
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { FieldIssue } from "@cosmicdrift/kumiko-headless";
8
+ import { defaultPrimitives } from "../primitives";
9
+ import { render } from "./test-utils";
10
+
11
+ function renderFieldWithIssues(issues: readonly FieldIssue[]) {
12
+ const { Field } = defaultPrimitives;
13
+ return render(
14
+ <Field id="f" label="Feld" issues={issues} testId="field-under-test">
15
+ <input id="f" />
16
+ </Field>,
17
+ );
18
+ }
19
+
20
+ describe("DefaultField / errors.validation.* i18n", () => {
21
+ test("invalid_format zeigt übersetzten Text statt rohem Key", () => {
22
+ const view = renderFieldWithIssues([
23
+ { path: "startsAt", code: "invalid_format", i18nKey: "errors.validation.invalid_format" },
24
+ ]);
25
+ expect(view.container.textContent).not.toContain("errors.validation");
26
+ expect(view.container.textContent).toContain("Invalid format.");
27
+ });
28
+
29
+ test("too_small interpoliert {minimum} aus issue.params", () => {
30
+ const view = renderFieldWithIssues([
31
+ {
32
+ path: "name",
33
+ code: "too_small",
34
+ i18nKey: "errors.validation.too_small",
35
+ params: { minimum: 3 },
36
+ },
37
+ ]);
38
+ expect(view.container.textContent).not.toContain("errors.validation");
39
+ expect(view.container.textContent).not.toContain("{minimum}");
40
+ expect(view.container.textContent).toContain("minimum: 3");
41
+ });
42
+
43
+ test("alle Server-/Zod-Codes sind in beiden Default-Bundles übersetzt", async () => {
44
+ const { kumikoDefaultTranslations } = await import("@cosmicdrift/kumiko-renderer");
45
+ const codes = [
46
+ "invalid_type",
47
+ "too_small",
48
+ "too_big",
49
+ "invalid_format",
50
+ "not_multiple_of",
51
+ "unrecognized_keys",
52
+ "invalid_union",
53
+ "invalid_key",
54
+ "invalid_element",
55
+ "invalid_value",
56
+ "custom",
57
+ "unexpected_field",
58
+ "out_of_bounds",
59
+ "invalid_option",
60
+ "failed",
61
+ ];
62
+ for (const locale of ["de", "en"] as const) {
63
+ const bundle = kumikoDefaultTranslations[locale];
64
+ for (const code of codes) {
65
+ expect(bundle?.[`errors.validation.${code}`]).toBeString();
66
+ }
67
+ }
68
+ });
69
+ });
@@ -0,0 +1,94 @@
1
+ // F2.4 (Bug-Bash-2): Die Sticky-Action-Bar spannte die volle Breite,
2
+ // während der Form-Body auf max-w-2xl begrenzt ist — die Buttons
3
+ // klebten am Fensterrand, optisch abgekoppelt vom Formular. Außerdem
4
+ // wiederholte der Section-Header bei Single-Section-ActionForms den
5
+ // Screen-Titel 1:1. Strukturelle Assertions (Klassen/DOM) — der
6
+ // visuelle Beweis läuft über die publicstatus-Screens nach dem Bump.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type {
10
+ EntityDefinition,
11
+ EntityEditScreenDefinition,
12
+ } from "@cosmicdrift/kumiko-framework/ui-types";
13
+ import { DispatcherProvider, RenderEdit } from "@cosmicdrift/kumiko-renderer";
14
+ import { defaultPrimitives } from "../primitives";
15
+ import { createMockDispatcher, render, screen } from "./test-utils";
16
+
17
+ const { Form, Section, Button } = defaultPrimitives;
18
+
19
+ describe("DefaultForm Action-Bar", () => {
20
+ test("Bar-Inhalt aligned mit dem Form-Body (max-w-Container in der Bar)", () => {
21
+ render(
22
+ <Form onSubmit={() => {}} title="Titel" actions={<Button>Save</Button>} testId="f">
23
+ <div>body</div>
24
+ </Form>,
25
+ );
26
+ const bar = screen.getByTestId("f-actions");
27
+ const inner = bar.firstElementChild;
28
+ expect(inner).toBeTruthy();
29
+ // Gleiche Breiten-Constraint wie der Body (max-w-2xl + px-6) — die
30
+ // Buttons enden damit an derselben Linie wie die Formularfelder.
31
+ expect(inner?.className).toContain("max-w-2xl");
32
+ expect(inner?.className).toContain("px-6");
33
+ });
34
+ });
35
+
36
+ describe("DefaultSection ohne Titel", () => {
37
+ test("rendert keinen leeren Header", () => {
38
+ render(
39
+ <Section testId="s">
40
+ <div>content</div>
41
+ </Section>,
42
+ );
43
+ expect(screen.getByTestId("s").querySelector("h3")).toBeNull();
44
+ });
45
+ });
46
+
47
+ const orderEntity = {
48
+ fields: { title: { type: "text", required: true } },
49
+ } as unknown as EntityDefinition; // @cast-boundary test-fixture
50
+
51
+ function makeScreen(sectionTitle: string): EntityEditScreenDefinition {
52
+ return {
53
+ id: "orders:screen:order-edit",
54
+ type: "entityEdit",
55
+ entity: "order",
56
+ layout: { sections: [{ title: sectionTitle, columns: 1, fields: ["title"] }] },
57
+ };
58
+ }
59
+
60
+ describe("RenderEdit Section-Titel-Dopplung", () => {
61
+ test("Section-Titel == Form-Titel → Header unterdrückt", () => {
62
+ // Ohne translate-Bundle fällt der Form-Titel auf screen.id zurück —
63
+ // ein gleichlautender Section-Titel reproduziert die Dopplung.
64
+ render(
65
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
66
+ <RenderEdit
67
+ screen={makeScreen("orders:screen:order-edit")}
68
+ entity={orderEntity}
69
+ featureName="orders"
70
+ initial={{ title: "" }}
71
+ writeCommand="order:create"
72
+ />
73
+ </DispatcherProvider>,
74
+ );
75
+ const section = screen.getByTestId("section-orders:screen:order-edit");
76
+ expect(section.querySelector("h3")).toBeNull();
77
+ });
78
+
79
+ test("abweichender Section-Titel bleibt sichtbar", () => {
80
+ render(
81
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
82
+ <RenderEdit
83
+ screen={makeScreen("Basics")}
84
+ entity={orderEntity}
85
+ featureName="orders"
86
+ initial={{ title: "" }}
87
+ writeCommand="order:create"
88
+ />
89
+ </DispatcherProvider>,
90
+ );
91
+ const section = screen.getByTestId("section-Basics");
92
+ expect(section.querySelector("h3")?.textContent).toBe("Basics");
93
+ });
94
+ });
@@ -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).
@@ -0,0 +1,95 @@
1
+ // Regression Bug-Bash-2 (2026-06-08): timestamp-Felder ohne locatedBy
2
+ // werden server-seitig als z.iso.datetime() (UTC mit `Z`) validiert,
3
+ // das native datetime-local-Input emittierte aber offset-lose lokale
4
+ // Zeit ("2026-06-08T21:09") → jeder Save endete in 422 invalid_format.
5
+ // Die Assertions laufen gegen die ECHTEN Zod-Schemas aus
6
+ // schema-builder.ts (z.iso.datetime / z.iso.datetime({local:true})).
7
+ import { describe, expect, test } from "bun:test";
8
+ import { fireEvent } from "@testing-library/react";
9
+ import { z } from "zod";
10
+ import { defaultPrimitives } from "../primitives";
11
+ import { inputValueToTimestamp, timestampToInputValue } from "../primitives/timestamp-input";
12
+ import { render } from "./test-utils";
13
+
14
+ const utcSchema = z.iso.datetime();
15
+ const wallClockSchema = z.iso.datetime({ local: true });
16
+ const DATETIME_LOCAL = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
17
+
18
+ describe("timestamp Konvertierung (Helpers)", () => {
19
+ test("UTC: lokale Eingabe wird als Z-Instant emittiert, Instant bleibt erhalten", () => {
20
+ const emitted = inputValueToTimestamp("2026-06-08T21:09", false);
21
+ if (emitted === undefined) throw new Error("expected emitted value");
22
+ expect(utcSchema.safeParse(emitted).success).toBe(true);
23
+ expect(emitted.endsWith("Z")).toBe(true);
24
+ expect(new Date(emitted).getTime()).toBe(new Date("2026-06-08T21:09").getTime());
25
+ });
26
+
27
+ test("wallClock: Eingabe geht offset-los durch und passt das local-Schema", () => {
28
+ const emitted = inputValueToTimestamp("2026-06-08T21:09", true);
29
+ expect(emitted).toBe("2026-06-08T21:09");
30
+ expect(wallClockSchema.safeParse(emitted).success).toBe(true);
31
+ });
32
+
33
+ test("leere Eingabe → undefined (Feld geleert)", () => {
34
+ expect(inputValueToTimestamp("", false)).toBeUndefined();
35
+ expect(inputValueToTimestamp("", true)).toBeUndefined();
36
+ });
37
+
38
+ test("UTC-Instant aus dem Server wird als lokale Wall-Clock angezeigt", () => {
39
+ const display = timestampToInputValue("2026-06-08T19:09:00.000Z");
40
+ expect(display).toMatch(DATETIME_LOCAL);
41
+ expect(new Date(`${display}`).getTime()).toBe(new Date("2026-06-08T19:09:00.000Z").getTime());
42
+ });
43
+
44
+ test("offset-loser Wert wird nur auf Minuten gekürzt", () => {
45
+ expect(timestampToInputValue("2026-06-08T21:09:33")).toBe("2026-06-08T21:09");
46
+ expect(timestampToInputValue("")).toBe("");
47
+ });
48
+ });
49
+
50
+ describe("Input kind=timestamp (Primitive)", () => {
51
+ test("rendert UTC-Wert als valides datetime-local und emittiert Z-Instant", () => {
52
+ const { Input } = defaultPrimitives;
53
+ const emitted: (string | undefined)[] = [];
54
+ const view = render(
55
+ <Input
56
+ kind="timestamp"
57
+ id="ts"
58
+ name="ts"
59
+ value="2026-06-08T19:09:00Z"
60
+ onChange={(v) => emitted.push(v)}
61
+ />,
62
+ );
63
+ const input = view.container.querySelector("input");
64
+ if (!input) throw new Error("expected input");
65
+ // datetime-local akzeptiert keine Z-Suffixe — der angezeigte Wert
66
+ // muss konvertiert sein, sonst zeigt der Browser ein leeres Feld.
67
+ expect(input.value).toMatch(DATETIME_LOCAL);
68
+
69
+ fireEvent.change(input, { target: { value: "2026-06-08T21:09" } });
70
+ expect(emitted).toHaveLength(1);
71
+ const value = emitted[0];
72
+ if (value === undefined) throw new Error("expected emitted value");
73
+ expect(utcSchema.safeParse(value).success).toBe(true);
74
+ });
75
+
76
+ test("wallClock-Variante emittiert offset-lose Wall-Clock", () => {
77
+ const { Input } = defaultPrimitives;
78
+ const emitted: (string | undefined)[] = [];
79
+ const view = render(
80
+ <Input
81
+ kind="timestamp"
82
+ id="ts"
83
+ name="ts"
84
+ value=""
85
+ wallClock
86
+ onChange={(v) => emitted.push(v)}
87
+ />,
88
+ );
89
+ const input = view.container.querySelector("input");
90
+ if (!input) throw new Error("expected input");
91
+ fireEvent.change(input, { target: { value: "2026-06-08T10:00" } });
92
+ expect(emitted).toEqual(["2026-06-08T10:00"]);
93
+ expect(wallClockSchema.safeParse(emitted[0]).success).toBe(true);
94
+ });
95
+ });
@@ -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 { applyStoredThemeMode, THEME_STORAGE_KEY, useBrowserTokensApi } from "../tokens";
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");
@@ -137,7 +137,7 @@ export function ConfigCascadeView({
137
137
  const hasOverride = activeDisplay?.level.source === screenScopeSource;
138
138
 
139
139
  return (
140
- <div className="mt-1 text-xs">
140
+ <div className="mt-1 text-xs" data-testid="config-cascade">
141
141
  <button
142
142
  type="button"
143
143
  onClick={() => setExpanded(!expanded)}
@@ -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,12 +12,46 @@ 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
- export function ConfigSourceBadge({ source }: { readonly source: ConfigValueSource }): ReactNode {
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
- const cfg = SOURCE_CONFIG[source];
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
54
+ data-testid="config-source-badge"
21
55
  style={{
22
56
  display: "inline-flex",
23
57
  alignItems: "center",
@@ -55,6 +55,7 @@ import {
55
55
  DropdownMenuTrigger,
56
56
  } from "./dropdown-menu";
57
57
  import { MoneyInput } from "./money-input";
58
+ import { TimestampInput } from "./timestamp-input";
58
59
  import { useToast } from "./toast";
59
60
 
60
61
  // ---- Button ----
@@ -172,7 +173,7 @@ function DefaultField({
172
173
  className="text-xs text-destructive"
173
174
  >
174
175
  {issues.map((issue) => (
175
- <div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey)}</div>
176
+ <div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey, issue.params)}</div>
176
177
  ))}
177
178
  </div>
178
179
  )}
@@ -340,13 +341,15 @@ function DefaultInput(props: InputProps): ReactNode {
340
341
  );
341
342
  case "timestamp":
342
343
  return (
343
- <input
344
- type="datetime-local"
345
- {...common}
344
+ <TimestampInput
345
+ id={props.id}
346
+ name={props.name}
346
347
  value={props.value}
347
- onChange={(e: ChangeEvent<HTMLInputElement>) =>
348
- props.onChange(e.target.value !== "" ? e.target.value : undefined)
349
- }
348
+ onChange={props.onChange}
349
+ {...(props.wallClock !== undefined && { wallClock: props.wallClock })}
350
+ {...(props.disabled !== undefined && { disabled: props.disabled })}
351
+ {...(props.required !== undefined && { required: props.required })}
352
+ {...(props.hasError !== undefined && { hasError: props.hasError })}
350
353
  className={cn(inputClassBase, errorClass)}
351
354
  />
352
355
  );
@@ -1176,14 +1179,19 @@ function DefaultForm({ onSubmit, children, title, actions, testId }: FormProps):
1176
1179
  {(title !== undefined || actions !== undefined) && (
1177
1180
  <div
1178
1181
  data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1179
- className="sticky top-0 z-10 h-12 px-6 bg-muted/30 border-b flex items-center gap-3"
1182
+ className="sticky top-0 z-10 h-12 bg-muted/30 border-b"
1180
1183
  >
1181
- {title !== undefined && (
1182
- <div className="text-lg font-semibold tracking-tight truncate">{title}</div>
1183
- )}
1184
- {actions !== undefined && (
1185
- <div className="flex items-center gap-2 ml-auto">{actions}</div>
1186
- )}
1184
+ {/* Bar-Hintergrund spannt die volle Breite, der Inhalt aligned
1185
+ aber mit dem max-w-2xl-Form-Body — sonst kleben die Buttons
1186
+ am Fensterrand, optisch abgekoppelt vom Formular. */}
1187
+ <div className="h-full px-6 max-w-2xl w-full flex items-center gap-3">
1188
+ {title !== undefined && (
1189
+ <div className="text-lg font-semibold tracking-tight truncate">{title}</div>
1190
+ )}
1191
+ {actions !== undefined && (
1192
+ <div className="flex items-center gap-2 ml-auto">{actions}</div>
1193
+ )}
1194
+ </div>
1187
1195
  </div>
1188
1196
  )}
1189
1197
  <div className="px-6 pt-6 pb-12 max-w-2xl w-full flex flex-col gap-8">{children}</div>
@@ -1198,9 +1206,11 @@ function DefaultSection({ title, children, testId }: SectionProps): ReactNode {
1198
1206
  // aus Border + Shadow. Spart Chrome und sieht weniger "boxy" aus.
1199
1207
  return (
1200
1208
  <section data-testid={testId} className="flex flex-col gap-4">
1201
- <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
1202
- {title}
1203
- </h3>
1209
+ {title !== undefined && (
1210
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
1211
+ {title}
1212
+ </h3>
1213
+ )}
1204
1214
  <div className="flex flex-col gap-4">{children}</div>
1205
1215
  </section>
1206
1216
  );
@@ -0,0 +1,88 @@
1
+ // TimestampInput — natives <input type="datetime-local"> mit
2
+ // Wert-Konvertierung. Der Server validiert timestamp-Felder als
3
+ // ISO-UTC mit `Z` (z.iso.datetime()) bzw. als Wall-Clock ohne Offset
4
+ // bei locatedTimestamps (z.iso.datetime({ local: true })). Das native
5
+ // datetime-local-Input spricht aber IMMER lokale Wall-Clock ohne
6
+ // Offset — ohne Konvertierung ging jeder UTC-Timestamp als
7
+ // offset-loser String raus und der Server lehnte mit invalid_format
8
+ // ab (Bug-Bash-2, 2026-06-08).
9
+
10
+ import type { ChangeEvent, ReactNode } from "react";
11
+ import { cn } from "../lib/cn";
12
+
13
+ const LOCAL_MINUTES = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
14
+ const HAS_OFFSET = /(?:Z|[+-]\d{2}:\d{2})$/;
15
+
16
+ function pad(n: number): string {
17
+ return String(n).padStart(2, "0");
18
+ }
19
+
20
+ /** Form-State → datetime-local-Format (`yyyy-MM-ddTHH:mm`).
21
+ * UTC-Instants (mit `Z`/Offset) werden in lokale Wall-Clock
22
+ * umgerechnet; offset-lose Werte nur auf Minuten gekürzt. */
23
+ export function timestampToInputValue(value: string): string {
24
+ if (value === "") return "";
25
+ if (HAS_OFFSET.test(value)) {
26
+ const d = new Date(value);
27
+ if (Number.isNaN(d.getTime())) return "";
28
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
29
+ }
30
+ const m = LOCAL_MINUTES.exec(value);
31
+ return m !== null ? m[0] : "";
32
+ }
33
+
34
+ /** datetime-local-Wert → Wire-Format. wallClock=true reicht die
35
+ * Wall-Clock unverändert durch; sonst wird die lokale Zeit als
36
+ * UTC-Instant mit `Z`-Suffix emittiert. */
37
+ export function inputValueToTimestamp(raw: string, wallClock: boolean): string | undefined {
38
+ if (raw === "") return undefined;
39
+ if (wallClock) return raw;
40
+ // Offset-loser Datetime-String wird von Date als LOKALE Zeit geparst
41
+ // (ES2020+) — genau die Semantik die datetime-local liefert.
42
+ const d = new Date(raw);
43
+ if (Number.isNaN(d.getTime())) return undefined;
44
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}Z`;
45
+ }
46
+
47
+ export type TimestampInputProps = {
48
+ readonly id: string;
49
+ readonly name: string;
50
+ readonly value: string;
51
+ readonly onChange: (v: string | undefined) => void;
52
+ readonly wallClock?: boolean;
53
+ readonly disabled?: boolean;
54
+ readonly required?: boolean;
55
+ readonly hasError?: boolean;
56
+ readonly className?: string;
57
+ };
58
+
59
+ export function TimestampInput({
60
+ id,
61
+ name,
62
+ value,
63
+ onChange,
64
+ wallClock,
65
+ disabled,
66
+ required,
67
+ hasError,
68
+ className,
69
+ }: TimestampInputProps): ReactNode {
70
+ return (
71
+ <input
72
+ type="datetime-local"
73
+ id={id}
74
+ name={name}
75
+ disabled={disabled}
76
+ required={required}
77
+ aria-invalid={hasError === true ? true : undefined}
78
+ value={timestampToInputValue(value)}
79
+ onChange={(e: ChangeEvent<HTMLInputElement>) =>
80
+ onChange(inputValueToTimestamp(e.target.value, wallClock === true))
81
+ }
82
+ // Das `flex` der Input-Basisklasse macht die Shadow-DOM-Teile des
83
+ // datetime-local zu Flex-Items — der Picker-Indicator klebt dann
84
+ // direkt am Text statt am rechten Rand. ml-auto schiebt ihn zurück.
85
+ className={cn("[&::-webkit-calendar-picker-indicator]:ml-auto", className)}
86
+ />
87
+ );
88
+ }
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
- if (!storedModeApplied && typeof document !== "undefined") {
79
- storedModeApplied = true;
80
- applyStoredThemeMode();
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,