@cosmicdrift/kumiko-renderer-web 0.2.2 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # @cosmicdrift/kumiko-renderer-web
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0.3.0 bringt zwei neue Subsysteme (Step-Engine Tier-3 + Visual-Tree) plus
8
+ eine AST-Codemod-Pipeline als Vorarbeit für den L2-AI-Layer.
9
+
10
+ ### Breaking Changes
11
+
12
+ - `skipTransitionGuard` → `unsafeSkipTransitionGuard` (Rename in
13
+ feature-ast + engine). Der `unsafe`-Prefix macht die Tragweite des
14
+ Casts sichtbar und ist konsistent zur `unsafeProjectionUpsert`- und
15
+ `r.rawTable`-Konvention. Migration: 1:1-Ersetzung, keine Verhaltens-Änderung.
16
+
17
+ ### Features
18
+
19
+ - **Step-Engine M.4 — Tier-3 Workflow-Engine.** Neue Step-Vocabulary
20
+ `wait`, `waitForEvent`, `retry` ermöglicht persistierte Long-Running-Flows
21
+ über Job-Boundaries hinweg. Q7 Snapshot-at-Start hängt jedem Step-Run
22
+ einen SHA-256-Fingerprint des Aggregat-Zustands an, sodass Replays
23
+ deterministisch gegen den ursprünglichen Eingangszustand laufen.
24
+ - **Visual-Tree V.1.x — Tree-API + Editor-Panel.** Neue `VisualTree`-
25
+ Component plus TreeProvider-Pattern; erste TreeProviders für
26
+ `text-content` und `legal-pages` (CMS-light + Impressum/Privacy).
27
+ Fundament für den späteren No-Code-Designer (~3000 LOC, 98 Tests).
28
+ - **Codemod-Pipeline.** AST-basierte Patcher-Module für strukturelle
29
+ Feature-Edits — wird vom kommenden L2-AI-Layer als Tool-Surface
30
+ verwendet, ist aber eigenständig nutzbar für ts-morph-style Migrationen.
31
+ - **user-data-rights Sample-Recipe.** DSGVO Art. 15/17/18/20 vollständig
32
+ als Sample-Recipe (`samples/recipes/`) inklusive README — zeigt die
33
+ Export- und Forget-Pipeline gegen den `compliance-profiles`-Default
34
+ (`eu-dsgvo`).
35
+
36
+ ### Fixes
37
+
38
+ - `tier-engine`: auto-default-tier-Hook benutzt jetzt `ctx.db.raw` für
39
+ Event-Store-Operationen (#37, vorher: stiller Bug, 22 Tage live).
40
+ - `engine`: unsafe-projection-upsert nutzt `as never` statt `as any` —
41
+ schmaler Cast-Surface, weniger Compiler-Knebel.
42
+ - `visual-tree`: runtime-isolation marker für client-konsumierte Files,
43
+ damit der Multi-Entry-Build den richtigen Bundle-Split bekommt.
44
+ - `feature-ast`: vollständiger `unsafeSkipTransitionGuard`-Rename (war
45
+ in zwei Modulen noch der alte Name).
46
+ - `framework`: Error-Reasons + `noConsole`-Lint + No-Date-API-Guard
47
+ wieder push-ready.
48
+
49
+ ### Library-Updates
50
+
51
+ hono 4.12, jose 6.2, stripe 22.1, meilisearch 0.58, marked 18,
52
+ bun-types 1.3.13, lucide-react 1.14, bullmq 5.76, ioredis 5.10,
53
+ i18next 26.0, react + radix-ui-primitives auf aktuelle Minors.
54
+
55
+ ### Patch Changes
56
+
57
+ - Updated dependencies
58
+ - @cosmicdrift/kumiko-dispatcher-live@0.3.0
59
+ - @cosmicdrift/kumiko-headless@0.3.0
60
+ - @cosmicdrift/kumiko-renderer@0.3.0
61
+
62
+ ## 0.2.3
63
+
64
+ ### Patch Changes
65
+
66
+ - @cosmicdrift/kumiko-dispatcher-live@0.2.3
67
+ - @cosmicdrift/kumiko-headless@0.2.3
68
+ - @cosmicdrift/kumiko-renderer@0.2.3
69
+
3
70
  ## 0.2.2
4
71
 
5
72
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.2.2",
3
+ "version": "0.3.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>",
@@ -9,38 +9,41 @@
9
9
  "runtime": "client"
10
10
  },
11
11
  "exports": {
12
- ".": "./src/index.ts",
12
+ ".": {
13
+ "types": "./src/index.ts",
14
+ "default": "./src/index.ts"
15
+ },
13
16
  "./styles.css": "./src/styles.css"
14
17
  },
15
18
  "dependencies": {
16
- "@cosmicdrift/kumiko-dispatcher-live": "0.2.2",
17
- "@cosmicdrift/kumiko-headless": "0.2.2",
18
- "@cosmicdrift/kumiko-renderer": "0.2.2",
19
- "@radix-ui/react-dialog": "^1.1.6",
20
- "@radix-ui/react-dropdown-menu": "^2.1.6",
21
- "@radix-ui/react-label": "^2.1.2",
22
- "@radix-ui/react-popover": "^1.1.6",
23
- "@radix-ui/react-select": "^2.1.6",
24
- "@radix-ui/react-slot": "^1.1.2",
25
- "@radix-ui/react-toast": "^1.2.6",
26
- "@radix-ui/react-tooltip": "^1.1.8",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.3.0",
20
+ "@cosmicdrift/kumiko-headless": "0.3.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.3.0",
22
+ "@radix-ui/react-dialog": "^1.1.15",
23
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
24
+ "@radix-ui/react-label": "^2.1.8",
25
+ "@radix-ui/react-popover": "^1.1.15",
26
+ "@radix-ui/react-select": "^2.2.6",
27
+ "@radix-ui/react-slot": "^1.2.4",
28
+ "@radix-ui/react-toast": "^1.2.15",
29
+ "@radix-ui/react-tooltip": "^1.2.8",
27
30
  "class-variance-authority": "^0.7.1",
28
31
  "clsx": "^2.1.1",
29
32
  "cmdk": "^1.1.1",
30
- "lucide-react": "^1.11.0",
31
- "react": "^19.2.0",
33
+ "lucide-react": "^1.14.0",
34
+ "react": "^19.2.6",
32
35
  "react-day-picker": "^9.14.0",
33
- "react-dom": "^19.2.0",
34
- "tailwind-merge": "^3.0.2"
36
+ "react-dom": "^19.2.6",
37
+ "tailwind-merge": "^3.6.0"
35
38
  },
36
39
  "devDependencies": {
37
- "@tailwindcss/cli": "^4.0.0",
38
- "@testing-library/react": "^16.3.0",
40
+ "@tailwindcss/cli": "^4.3.0",
41
+ "@testing-library/react": "^16.3.2",
39
42
  "@testing-library/user-event": "^14.6.1",
40
- "@types/react": "^19.2.0",
41
- "@types/react-dom": "^19.2.0",
42
- "jsdom": "^29.1.0",
43
- "tailwindcss": "^4.0.0"
43
+ "@types/react": "^19.2.14",
44
+ "@types/react-dom": "^19.2.3",
45
+ "jsdom": "^29.1.1",
46
+ "tailwindcss": "^4.3.0"
44
47
  },
45
48
  "repository": {
46
49
  "type": "git",
@@ -0,0 +1,315 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // V.1.1-D Integration-Test — End-to-End-Beweis für die Schleife
4
+ // `clientFeatures.treeProvider → useTreeProviders → VisualTree →
5
+ // TreeNodeRenderer → Click → dispatchTarget`.
6
+ //
7
+ // Im Gegensatz zum visual-tree.test.tsx (isolierte VisualTree-Component
8
+ // mit Mock-Providers) mountet dieser Test den vollen WorkspaceShell mit
9
+ // zwei Workspaces (nav + tree) und zwei Tree-Provider-clientFeatures.
10
+ // Bewert wird:
11
+ // 1. Provider-Iteration: beide Top-Level-Knoten landen im DOM
12
+ // (alphabetisch nach featureName)
13
+ // 2. Subscribe-Update: zweiter Emit eines Providers re-rendert die
14
+ // Sidebar ohne Workspace-Switch
15
+ // 3. Stub-Dispatch: Click auf Knoten mit target ruft dispatch mit
16
+ // richtigem TargetRef
17
+ // 4. Workspace-Switch: nav-Workspace zeigt NavTree, tree-Workspace
18
+ // zeigt VisualTree mit beiden Provider-Beiträgen
19
+ //
20
+ // **Memory `[Kein Fake-Dispatcher]`-Note**: V.1.1 hat keine HTTP-Calls
21
+ // (Tree-Provider sind reine Client-Functions die nur ctx.tenantId
22
+ // lesen). Echtes setupTestStack kommt mit V.1.2 wenn text-content's
23
+ // Slug-Liste durch die Server-Pipeline geht.
24
+
25
+ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
26
+ import type { FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
27
+ import {
28
+ createStaticLocaleResolver,
29
+ LocaleProvider,
30
+ NavProvider,
31
+ PrimitivesProvider,
32
+ } from "@cosmicdrift/kumiko-renderer";
33
+ import { act, fireEvent, render, screen } from "@testing-library/react";
34
+ import type { ReactNode } from "react";
35
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
36
+ import { useBrowserNavApi } from "../app/nav";
37
+ import { TreeProvidersProvider } from "../app/tree-providers-context";
38
+ import { setDispatchListener } from "../layout/target-resolver-stub";
39
+ import { WorkspaceShell } from "../layout/workspace-shell";
40
+ import { defaultPrimitives } from "../primitives";
41
+
42
+ // localStorage-Mock (vitest+Bun-Runtime liefert nur partielles
43
+ // localStorage). Pro Test frische Map damit Tests sauber isoliert sind.
44
+ beforeEach(() => {
45
+ const store = new Map<string, string>();
46
+ Object.defineProperty(window, "localStorage", {
47
+ configurable: true,
48
+ value: {
49
+ getItem: (key: string): string | null => store.get(key) ?? null,
50
+ setItem: (key: string, value: string): void => {
51
+ store.set(key, value);
52
+ },
53
+ removeItem: (key: string): void => {
54
+ store.delete(key);
55
+ },
56
+ clear: (): void => store.clear(),
57
+ get length(): number {
58
+ return store.size;
59
+ },
60
+ key: (i: number): string | null => Array.from(store.keys())[i] ?? null,
61
+ },
62
+ });
63
+ window.history.replaceState(null, "", "/");
64
+ });
65
+
66
+ function ws(
67
+ id: string,
68
+ options: {
69
+ label: string;
70
+ isDefault?: boolean;
71
+ navigation?: "nav" | "tree";
72
+ navMembers?: readonly string[];
73
+ },
74
+ ): WorkspaceSchema {
75
+ return {
76
+ definition: {
77
+ id,
78
+ label: options.label,
79
+ access: { openToAll: true },
80
+ ...(options.isDefault === true && { default: true }),
81
+ ...(options.navigation !== undefined && { navigation: options.navigation }),
82
+ },
83
+ navMembers: options.navMembers ?? [],
84
+ };
85
+ }
86
+
87
+ function makeStaticProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
88
+ return () => (emit) => {
89
+ emit(nodes);
90
+ return () => {};
91
+ };
92
+ }
93
+
94
+ function makeMutableProvider(initial: readonly TreeNode[]): {
95
+ readonly provider: TreeChildrenSubscribe;
96
+ emit(nodes: readonly TreeNode[]): void;
97
+ } {
98
+ let listener: ((nodes: readonly TreeNode[]) => void) | undefined;
99
+ const provider: TreeChildrenSubscribe = () => (emit) => {
100
+ listener = emit;
101
+ emit(initial);
102
+ return () => {
103
+ listener = undefined;
104
+ };
105
+ };
106
+ return {
107
+ provider,
108
+ emit(nodes) {
109
+ if (listener !== undefined) listener(nodes);
110
+ },
111
+ };
112
+ }
113
+
114
+ function renderShellWithTreeProviders(
115
+ schema: FeatureSchema,
116
+ treeProviders: ReadonlyMap<string, TreeChildrenSubscribe>,
117
+ ): ReturnType<typeof render> {
118
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
119
+ const nav = useBrowserNavApi({ hasWorkspaces: true });
120
+ return (
121
+ <LocaleProvider resolver={createStaticLocaleResolver()}>
122
+ <PrimitivesProvider value={defaultPrimitives}>
123
+ <NavProvider value={nav}>
124
+ <TreeProvidersProvider value={treeProviders}>{children}</TreeProvidersProvider>
125
+ </NavProvider>
126
+ </PrimitivesProvider>
127
+ </LocaleProvider>
128
+ );
129
+ }
130
+ return render(
131
+ <WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
132
+ <div>content</div>
133
+ </WorkspaceShell>,
134
+ { wrapper: Wrapper },
135
+ );
136
+ }
137
+
138
+ describe("V.1.1-D Integration — Provider-Iteration + Subscribe + Dispatch", () => {
139
+ let cleanup: (() => void) | undefined;
140
+ afterEach(() => {
141
+ cleanup?.();
142
+ cleanup = undefined;
143
+ });
144
+
145
+ test("Provider-Iteration: beide Features landen im DOM, alphabetisch sortiert", () => {
146
+ const schema = {
147
+ featureName: "demo",
148
+ entities: {},
149
+ screens: [],
150
+ navs: [],
151
+ workspaces: [
152
+ ws("visual", {
153
+ label: "Visual",
154
+ isDefault: true,
155
+ navigation: "tree",
156
+ }),
157
+ ],
158
+ } as const;
159
+
160
+ const treeProviders = new Map<string, TreeChildrenSubscribe>([
161
+ ["text-content", makeStaticProvider([{ label: "Marketing" }])],
162
+ ["legal-pages", makeStaticProvider([{ label: "Imprint" }])],
163
+ ]);
164
+
165
+ renderShellWithTreeProviders(schema, treeProviders);
166
+
167
+ expect(screen.getByText("Marketing")).toBeTruthy();
168
+ expect(screen.getByText("Imprint")).toBeTruthy();
169
+
170
+ // Reihenfolge: legal-pages (l < t) kommt vor text-content
171
+ const branches = document.querySelectorAll("[data-kumiko-tree-branch]");
172
+ expect(branches[0]?.getAttribute("data-kumiko-tree-branch")).toBe("legal-pages");
173
+ expect(branches[1]?.getAttribute("data-kumiko-tree-branch")).toBe("text-content");
174
+ });
175
+
176
+ test("Subscribe-Update ohne Workspace-Switch: zweiter Emit re-rendert", () => {
177
+ const { provider, emit } = makeMutableProvider([{ label: "Hero" }]);
178
+
179
+ const schema = {
180
+ featureName: "demo",
181
+ entities: {},
182
+ screens: [],
183
+ navs: [],
184
+ workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
185
+ } as const;
186
+
187
+ const treeProviders = new Map([["text-content", provider]]);
188
+
189
+ renderShellWithTreeProviders(schema, treeProviders);
190
+ expect(screen.getByText("Hero")).toBeTruthy();
191
+
192
+ act(() => {
193
+ emit([{ label: "Hero" }, { label: "Pricing" }, { label: "Footer" }]);
194
+ });
195
+
196
+ expect(screen.getByText("Pricing")).toBeTruthy();
197
+ expect(screen.getByText("Footer")).toBeTruthy();
198
+ });
199
+
200
+ test("Stub-Dispatch: Click → setDispatchListener-Spy bekommt richtige TargetRef", () => {
201
+ const dispatched: unknown[] = [];
202
+ cleanup = setDispatchListener((target) => {
203
+ dispatched.push(target);
204
+ });
205
+
206
+ const schema = {
207
+ featureName: "demo",
208
+ entities: {},
209
+ screens: [],
210
+ navs: [],
211
+ workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
212
+ } as const;
213
+
214
+ const treeProviders = new Map<string, TreeChildrenSubscribe>([
215
+ [
216
+ "text-content",
217
+ makeStaticProvider([
218
+ {
219
+ label: "Imprint",
220
+ target: {
221
+ featureId: "text-content",
222
+ action: "edit",
223
+ args: { slug: "imprint" },
224
+ },
225
+ },
226
+ ]),
227
+ ],
228
+ ]);
229
+
230
+ renderShellWithTreeProviders(schema, treeProviders);
231
+ fireEvent.click(screen.getByText("Imprint"));
232
+
233
+ expect(dispatched).toEqual([
234
+ {
235
+ featureId: "text-content",
236
+ action: "edit",
237
+ args: { slug: "imprint" },
238
+ },
239
+ ]);
240
+ });
241
+
242
+ test("Workspace-Switch: nav-Workspace zeigt NavTree, tree-Workspace zeigt VisualTree-Provider", () => {
243
+ const schema = {
244
+ featureName: "demo",
245
+ entities: {},
246
+ screens: [],
247
+ navs: [{ id: "list", label: "Builder-List" }],
248
+ workspaces: [
249
+ ws("admin", {
250
+ label: "Admin",
251
+ isDefault: true,
252
+ navigation: "nav",
253
+ navMembers: ["demo:nav:list"],
254
+ }),
255
+ ws("visual", {
256
+ label: "Visual",
257
+ navigation: "tree",
258
+ }),
259
+ ],
260
+ } as const;
261
+
262
+ const treeProviders = new Map<string, TreeChildrenSubscribe>([
263
+ ["text-content", makeStaticProvider([{ label: "Marketing" }])],
264
+ ]);
265
+
266
+ renderShellWithTreeProviders(schema, treeProviders);
267
+
268
+ // Initial: admin-Workspace → NavTree mit "Builder-List"
269
+ expect(screen.getByText("Builder-List")).toBeTruthy();
270
+ expect(screen.queryByText("Marketing")).toBeNull();
271
+
272
+ // Switch zu visual → VisualTree mit Provider-Beitrag
273
+ fireEvent.click(screen.getByTestId("workspace-tab-visual"));
274
+ expect(screen.getByText("Marketing")).toBeTruthy();
275
+ expect(screen.queryByText("Builder-List")).toBeNull();
276
+
277
+ // Zurück zu admin → NavTree wieder
278
+ fireEvent.click(screen.getByTestId("workspace-tab-admin"));
279
+ expect(screen.getByText("Builder-List")).toBeTruthy();
280
+ expect(screen.queryByText("Marketing")).toBeNull();
281
+ });
282
+
283
+ test("localStorage-Persistenz: Toggle persistiert pro Workspace, Re-Mount restored", () => {
284
+ const schema = {
285
+ featureName: "demo",
286
+ entities: {},
287
+ screens: [],
288
+ navs: [],
289
+ workspaces: [ws("visual", { label: "Visual", isDefault: true, navigation: "tree" })],
290
+ } as const;
291
+
292
+ const treeProviders = new Map<string, TreeChildrenSubscribe>([
293
+ ["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
294
+ ]);
295
+
296
+ const result = renderShellWithTreeProviders(schema, treeProviders);
297
+
298
+ // Initial collapsed: Hero nicht sichtbar
299
+ expect(screen.queryByText("Hero")).toBeNull();
300
+
301
+ // Toggle: Marketing ausklappen
302
+ fireEvent.click(screen.getByText("Marketing"));
303
+ expect(screen.getByText("Hero")).toBeTruthy();
304
+
305
+ // localStorage hat den Persisted-State
306
+ const stored = window.localStorage.getItem("kumiko:visual-tree:expanded:visual");
307
+ expect(stored).not.toBeNull();
308
+ expect(JSON.parse(stored ?? "[]")).toContain("text-content/0-Marketing");
309
+
310
+ // Re-Mount mit gleichem Workspace → expanded restored
311
+ result.unmount();
312
+ renderShellWithTreeProviders(schema, treeProviders);
313
+ expect(screen.getByText("Hero")).toBeTruthy();
314
+ });
315
+ });
@@ -59,6 +59,7 @@ function ws(
59
59
  openToAll?: boolean;
60
60
  isDefault?: boolean;
61
61
  navMembers?: readonly string[];
62
+ navigation?: "nav" | "tree";
62
63
  } = {},
63
64
  ): WorkspaceSchema {
64
65
  const access = options.openToAll
@@ -73,6 +74,7 @@ function ws(
73
74
  ...(options.order !== undefined && { order: options.order }),
74
75
  ...(access !== undefined && { access }),
75
76
  ...(options.isDefault === true && { default: true }),
77
+ ...(options.navigation !== undefined && { navigation: options.navigation }),
76
78
  },
77
79
  navMembers: options.navMembers ?? [],
78
80
  };
@@ -770,3 +772,143 @@ describe("WorkspaceShell — AppSchema (multi-feature)", () => {
770
772
  expect(screen.getByText("List")).toBeTruthy();
771
773
  });
772
774
  });
775
+
776
+ // ---------------------------------------------------------------------------
777
+ // navigation-Mode-Switch — Phase 0 Schicht 3 / V.1.
778
+ // Workspace mit `navigation: "tree"` mountet den Visual-Tree-Stub statt
779
+ // NavTree. Default (kein navigation oder navigation="nav") hält das
780
+ // existing NavTree-Verhalten — kein Breaking-Change für Apps die Visual-
781
+ // Tree nicht aktivieren. Echte Tree-Component kommt in V.1.1.
782
+ // Siehe docs/plans/architecture/visual-tree.md A1.
783
+ // ---------------------------------------------------------------------------
784
+
785
+ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
786
+ test('navigation: "tree" mountet den Visual-Tree-Stub statt NavTree', () => {
787
+ const schema = {
788
+ featureName: "demo",
789
+ entities: {},
790
+ screens: [],
791
+ navs: [{ id: "list", label: "List" }],
792
+ workspaces: [
793
+ ws("visual", {
794
+ label: "Visual",
795
+ openToAll: true,
796
+ isDefault: true,
797
+ navigation: "tree",
798
+ navMembers: ["demo:nav:list"],
799
+ }),
800
+ ],
801
+ } as const;
802
+
803
+ renderShell(
804
+ <WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
805
+ <div>content</div>
806
+ </WorkspaceShell>,
807
+ );
808
+ // Stub-Marker rendert
809
+ expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
810
+ // NavTree wäre durch das nav-Item "List" sichtbar — darf hier NICHT
811
+ // rendern (Tree-Mode ersetzt NavTree komplett)
812
+ expect(screen.queryByText("List")).toBeNull();
813
+ });
814
+
815
+ test('navigation: "nav" mountet NavTree (default-äquivalent)', () => {
816
+ const schema = {
817
+ featureName: "demo",
818
+ entities: {},
819
+ screens: [],
820
+ navs: [{ id: "list", label: "List" }],
821
+ workspaces: [
822
+ ws("admin", {
823
+ label: "Admin",
824
+ openToAll: true,
825
+ isDefault: true,
826
+ navigation: "nav",
827
+ navMembers: ["demo:nav:list"],
828
+ }),
829
+ ],
830
+ } as const;
831
+
832
+ renderShell(
833
+ <WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
834
+ <div>content</div>
835
+ </WorkspaceShell>,
836
+ );
837
+ expect(screen.getByText("List")).toBeTruthy();
838
+ expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
839
+ });
840
+
841
+ test("workspace ohne navigation-Property defaultet zu NavTree (Backwards-Compat)", () => {
842
+ const schema = {
843
+ featureName: "demo",
844
+ entities: {},
845
+ screens: [],
846
+ navs: [{ id: "list", label: "List" }],
847
+ workspaces: [
848
+ // Keine `navigation`-Property → existing Apps müssen exakt so
849
+ // weiter laufen wie vor V.1
850
+ ws("admin", {
851
+ label: "Admin",
852
+ openToAll: true,
853
+ isDefault: true,
854
+ navMembers: ["demo:nav:list"],
855
+ }),
856
+ ],
857
+ } as const;
858
+
859
+ renderShell(
860
+ <WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
861
+ <div>content</div>
862
+ </WorkspaceShell>,
863
+ );
864
+ expect(screen.getByText("List")).toBeTruthy();
865
+ expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
866
+ });
867
+
868
+ test("Switcher-Toggle re-rendert Sidebar zwischen NavTree und Stub", () => {
869
+ // Multi-Workspace-Setup: beide accessible, einer nav, einer tree.
870
+ // Single-mount-Tests können nicht beweisen dass der conditional
871
+ // render bei prop-change sauber re-rendert — das ist die echte UX
872
+ // (User klickt im Switcher zwischen Modi). Dieser Test pinnt das.
873
+ const schema = {
874
+ featureName: "demo",
875
+ entities: {},
876
+ screens: [],
877
+ navs: [{ id: "list", label: "List" }],
878
+ workspaces: [
879
+ ws("admin", {
880
+ label: "Admin",
881
+ openToAll: true,
882
+ isDefault: true,
883
+ navigation: "nav",
884
+ navMembers: ["demo:nav:list"],
885
+ }),
886
+ ws("visual", {
887
+ label: "Visual",
888
+ openToAll: true,
889
+ navigation: "tree",
890
+ }),
891
+ ],
892
+ } as const;
893
+
894
+ renderShell(
895
+ <WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
896
+ <div>content</div>
897
+ </WorkspaceShell>,
898
+ );
899
+
900
+ // Initial: admin-Workspace (default) aktiv → NavTree sichtbar, kein Stub
901
+ expect(screen.getByText("List")).toBeTruthy();
902
+ expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
903
+
904
+ // Switch zu visual-Workspace via Switcher-Click
905
+ fireEvent.click(screen.getByTestId("workspace-tab-visual"));
906
+ expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
907
+ expect(screen.queryByText("List")).toBeNull();
908
+
909
+ // Zurück zu admin → NavTree wieder, Stub weg
910
+ fireEvent.click(screen.getByTestId("workspace-tab-admin"));
911
+ expect(screen.getByText("List")).toBeTruthy();
912
+ expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
913
+ });
914
+ });
@@ -9,6 +9,11 @@
9
9
  // ganz außen gestackt, dann alle Gates nach innen. So hat jeder Gate
10
10
  // Zugriff auf jeden Provider, egal welches Feature ihn gebracht hat.
11
11
 
12
+ import type {
13
+ TargetRef,
14
+ TreeActionDef,
15
+ TreeChildrenSubscribe,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
12
17
  import type { ColumnRendererComponent, TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
13
18
  import type { ComponentType, ReactNode } from "react";
14
19
 
@@ -45,6 +50,31 @@ export type ClientFeatureDefinition = {
45
50
  * echte JSX-Renderer leben im Client-Bundle. Last-Wins bei Key-
46
51
  * Kollision über mehrere Features. */
47
52
  readonly columnRenderers?: Readonly<Record<string, ColumnRendererComponent>>;
53
+ /** Tree-Provider für `r.workspace({ navigation: "tree" })`-Workspaces
54
+ * (Visual-Tree). Wird beim Mount des Tree-Workspaces mit ctx aufgerufen,
55
+ * emittiert TreeNode[] die in der Sidebar gerendert werden. Closure-
56
+ * Distribution gleicher Mechanismus wie `columnRenderers` — Server-
57
+ * Registry kennt nur „dass es das gibt", echte Function lebt
58
+ * client-side. Spiegelt das server-side `r.tree(provider)` aus dem
59
+ * Feature; bundled-features liefern beide Seiten konsistent.
60
+ * Siehe visual-tree.md V.1.1-Distribution. */
61
+ readonly treeProvider?: TreeChildrenSubscribe;
62
+ /** Tree-Actions-Schema — die Action-Map die `buildTarget` compile-time
63
+ * validiert. Erased-Runtime-Surface; typed Handle wandert separat
64
+ * via Server-Feature setup-export (FeatureDefinition.exports.handle).
65
+ * Identisch zur server-side `r.treeActions(...)`-Map; bundled-
66
+ * features liefern beide Seiten konsistent. */
67
+ readonly treeActions?: Readonly<Record<string, TreeActionDef>>;
68
+
69
+ /** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
70
+ * TreeNode mit target angeklickt wird, schlägt der EditorPanel das
71
+ * Component hier nach und rendert es. Komponenten erhalten target
72
+ * (mit args) und eine onClose-Callback. Ohne registrierten Resolver
73
+ * zeigt der EditorPanel einen Info-Fallback.
74
+ * Siehe visual-tree.md V.1.2. */
75
+ readonly resolvers?: Readonly<
76
+ Record<string, ComponentType<{ readonly target: TargetRef; readonly onClose: () => void }>>
77
+ >;
48
78
  };
49
79
 
50
80
  /** Wickelt einen ReactNode durch eine Liste von Providern/Gates von