@cosmicdrift/kumiko-renderer-web 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -495,6 +495,75 @@ describe("KumikoScreen", () => {
495
495
  expect(await screen.findByText("Version conflict for entity r1")).toBeTruthy();
496
496
  });
497
497
 
498
+ // 284/2: der HIT-Zweig von dispatcherErrorText — bekannter i18nKey
499
+ // mit Params → der ÜBERSETZTE, interpolierte Text landet im Toast
500
+ // (nicht error.message). Genau die Logik, die die Funktion rechtfertigt.
501
+ test("entityList rowActions writeHandler: bekannter i18nKey → übersetzter Toast mit interpolierten Params", async () => {
502
+ const dispatcher = makeDispatcher({
503
+ query: (async () => ({
504
+ isSuccess: true,
505
+ data: {
506
+ rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
507
+ nextCursor: null,
508
+ },
509
+ })) as unknown as Dispatcher["query"],
510
+ write: (async () => ({
511
+ isSuccess: false,
512
+ error: {
513
+ code: "validation_error",
514
+ httpStatus: 422,
515
+ i18nKey: "kumiko.validation.too-short",
516
+ i18nParams: { min: 5 },
517
+ message: "raw fallback — must NOT appear",
518
+ },
519
+ })) as unknown as Dispatcher["write"],
520
+ });
521
+
522
+ const screenWithDelete: EntityListScreenDefinition = {
523
+ id: "task-list",
524
+ type: "entityList",
525
+ entity: "task",
526
+ columns: ["title"],
527
+ rowActions: [
528
+ {
529
+ id: "delete",
530
+ label: "actions.delete",
531
+ handler: "tasks:write:task:delete",
532
+ confirm: "actions.delete-confirm",
533
+ style: "danger",
534
+ },
535
+ ],
536
+ };
537
+
538
+ const { ToastProvider } = await import("../primitives/toast");
539
+ const { LocaleProvider, createStaticLocaleResolver, kumikoDefaultTranslations } = await import(
540
+ "@cosmicdrift/kumiko-renderer"
541
+ );
542
+ const user = userEvent.setup();
543
+ render(
544
+ <LocaleProvider
545
+ resolver={createStaticLocaleResolver()}
546
+ fallbackBundles={[kumikoDefaultTranslations]}
547
+ >
548
+ <ToastProvider>
549
+ <DispatcherProvider dispatcher={dispatcher}>
550
+ <KumikoScreen
551
+ schema={{ ...schema, screens: [screenWithDelete] }}
552
+ qn="tasks:screen:task-list"
553
+ />
554
+ </DispatcherProvider>
555
+ </ToastProvider>
556
+ </LocaleProvider>,
557
+ );
558
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
559
+
560
+ await user.click(screen.getByTestId("row-r1-action-delete"));
561
+ await user.click(screen.getByTestId("row-r1-action-delete-dialog-confirm"));
562
+
563
+ expect(await screen.findByText("Too short (at least 5 characters).")).toBeTruthy();
564
+ expect(screen.queryByText("raw fallback — must NOT appear")).toBeNull();
565
+ });
566
+
498
567
  // Tier 2.7e-1: rowAction kind="navigate" — Click ruft nav.navigate
499
568
  // mit screen-id, ggf. mit URL-Search-Params aus params(row).
500
569
  // Reihenfolge ist Teil des Contracts: navigate ZUERST, dann
@@ -567,6 +636,66 @@ describe("KumikoScreen", () => {
567
636
  expect(calls.map((c) => c.kind)).toEqual(["navigate", "setSearchParams"]);
568
637
  });
569
638
 
639
+ // 284/3: derselbe Contract gegen die ECHTE useBrowserNavApi statt
640
+ // memoryNav — der eigentliche Bug-Mechanismus (pushState verwirft die
641
+ // Query, setSearchParams muss auf der NEUEN URL landen) ist nur hier
642
+ // real verifiziert. Kombination navigate + entityId + params.
643
+ test("entityList rowActions kind=navigate: echte useBrowserNavApi → Pfad-Segmente + ?param auf der neuen URL", async () => {
644
+ window.history.replaceState(null, "", "/task-list");
645
+
646
+ const dispatcher = makeDispatcher({
647
+ query: (async () => ({
648
+ isSuccess: true,
649
+ data: {
650
+ rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
651
+ nextCursor: null,
652
+ },
653
+ })) as unknown as Dispatcher["query"],
654
+ });
655
+
656
+ const screenWithNav: EntityListScreenDefinition = {
657
+ id: "task-list",
658
+ type: "entityList",
659
+ entity: "task",
660
+ columns: ["title"],
661
+ rowActions: [
662
+ {
663
+ kind: "navigate",
664
+ id: "edit",
665
+ label: "actions.edit",
666
+ screen: "task-edit",
667
+ entityId: "id",
668
+ params: { map: { from: "title" } },
669
+ },
670
+ ],
671
+ };
672
+
673
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
674
+ const { useBrowserNavApi } = await import("../app/nav");
675
+ function BrowserNav({ children }: { readonly children: React.ReactNode }): React.ReactNode {
676
+ const api = useBrowserNavApi({ hasWorkspaces: false });
677
+ return <NavProvider value={api}>{children}</NavProvider>;
678
+ }
679
+
680
+ const user = userEvent.setup();
681
+ render(
682
+ <BrowserNav>
683
+ <DispatcherProvider dispatcher={dispatcher}>
684
+ <KumikoScreen
685
+ schema={{ ...schema, screens: [screenWithNav] }}
686
+ qn="tasks:screen:task-list"
687
+ />
688
+ </DispatcherProvider>
689
+ </BrowserNav>,
690
+ );
691
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
692
+
693
+ await user.click(screen.getByTestId("row-r1-action-edit"));
694
+
695
+ await waitFor(() => expect(window.location.pathname).toBe("/task-edit/r1"));
696
+ expect(new URLSearchParams(window.location.search).get("from")).toBe("Alpha");
697
+ });
698
+
570
699
  // entityId-Variante: entityEdit-Targets brauchen die Id als PFAD-
571
700
  // Segment (route.entityId), nicht als Search-Param — sonst öffnet
572
701
  // der Edit-Screen im Create-Mode (Prod-Bug 2026-06-07, Bug 3).
@@ -7,7 +7,12 @@
7
7
  import { beforeEach, describe, expect, test } from "bun:test";
8
8
  import { act, render, screen } from "@testing-library/react";
9
9
  import type { ReactNode } from "react";
10
- import { 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");
@@ -1,4 +1,4 @@
1
- import type { ConfigValueSource } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { ConfigScope, ConfigValueSource } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
3
3
  import type { ReactNode } from "react";
4
4
 
@@ -12,9 +12,42 @@ const SOURCE_CONFIG: Record<ConfigValueSource, { labelKey: string; bg: string; t
12
12
  missing: { labelKey: "kumiko.config.source.missing", bg: "#fee2e2", text: "#991b1b" },
13
13
  };
14
14
 
15
- 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
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,