@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 +4 -4
- package/src/__tests__/config-edit.test.tsx +59 -0
- package/src/__tests__/field-validation-i18n.test.tsx +69 -0
- package/src/__tests__/form-action-bar.test.tsx +94 -0
- package/src/__tests__/kumiko-screen.test.tsx +129 -0
- package/src/__tests__/timestamp-input.test.tsx +95 -0
- package/src/__tests__/tokens-theme-persist.test.tsx +20 -1
- package/src/components/config-cascade.tsx +1 -1
- package/src/components/config-source-badge.tsx +37 -3
- package/src/primitives/index.tsx +27 -17
- package/src/primitives/timestamp-input.tsx +88 -0
- package/src/tokens.ts +18 -5
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>",
|
|
@@ -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.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 {
|
|
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
|
-
|
|
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
|
|
54
|
+
data-testid="config-source-badge"
|
|
21
55
|
style={{
|
|
22
56
|
display: "inline-flex",
|
|
23
57
|
alignItems: "center",
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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
|
-
<
|
|
344
|
-
|
|
345
|
-
{
|
|
344
|
+
<TimestampInput
|
|
345
|
+
id={props.id}
|
|
346
|
+
name={props.name}
|
|
346
347
|
value={props.value}
|
|
347
|
-
onChange={
|
|
348
|
-
|
|
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
|
|
1182
|
+
className="sticky top-0 z-10 h-12 bg-muted/30 border-b"
|
|
1180
1183
|
>
|
|
1181
|
-
{
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
-
|
|
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,
|