@cosmicdrift/kumiko-renderer-web 0.2.3 → 0.4.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.
@@ -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,38 @@ 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
+ /** V.1.5b SSE-Tree-Refresh: Liste der Entity-Namen die der Provider
70
+ * abdeckt. Bei Live-Events für eine dieser Entities (created/updated/
71
+ * deleted/restored) wird der Provider neu aufgerufen → Tree refresht.
72
+ * Optional + leer/undefined → kein SSE-Refresh (static Provider, z.B.
73
+ * legal-pages). Beispiel text-content: `["text-block"]`. */
74
+ readonly treeEntities?: readonly string[];
75
+
76
+ /** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
77
+ * TreeNode mit target angeklickt wird, schlägt der EditorPanel das
78
+ * Component hier nach und rendert es. Komponenten erhalten target
79
+ * (mit args) und eine onClose-Callback. Ohne registrierten Resolver
80
+ * zeigt der EditorPanel einen Info-Fallback.
81
+ * Siehe visual-tree.md V.1.2. */
82
+ readonly resolvers?: Readonly<
83
+ Record<string, ComponentType<{ readonly target: TargetRef; readonly onClose: () => void }>>
84
+ >;
48
85
  };
49
86
 
50
87
  /** Wickelt einen ReactNode durch eine Liste von Providern/Gates von
@@ -1,4 +1,5 @@
1
1
  import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
2
+ import type { TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import type {
3
4
  Dispatcher,
4
5
  ListRowViewModel,
@@ -35,6 +36,8 @@ import { useBrowserTokensApi } from "../tokens";
35
36
  import { createBrowserLocaleResolver } from "./browser-locale";
36
37
  import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
37
38
  import { useBrowserNavApi } from "./nav";
39
+ import { type ResolverComponent, ResolversProvider } from "./resolvers-context";
40
+ import { TreeProvidersProvider } from "./tree-providers-context";
38
41
 
39
42
  // Web-Bootstrap. Mounted den ganzen Kumiko-Render-Stack im Browser:
40
43
  // Tokens (class-based light/dark via <html>), Primitives (HTML),
@@ -192,7 +195,44 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
192
195
  }
193
196
  }
194
197
 
195
- const resolver = options.locale ?? createBrowserLocaleResolver();
198
+ // Tree-Provider-Map aggregieren — keyed by clientFeature.name (matches
199
+ // server-side FeatureDefinition.name). Mehrere clientFeatures mit
200
+ // gleichem name + treeProvider sind ein Author-Bug (würde stillschweigend
201
+ // den Provider eines Features durch den eines anderen überschreiben);
202
+ // wir warnen einmal pro Kollision. Visual-Tree.md V.1.1-Distribution.
203
+ const treeProviders = new Map<string, TreeChildrenSubscribe>();
204
+ const treeEntities = new Map<string, readonly string[]>();
205
+ for (const f of clientFeatures) {
206
+ if (f.treeProvider === undefined) continue;
207
+ if (treeProviders.has(f.name)) {
208
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
209
+ console.warn(
210
+ `[kumiko] treeProvider for "${f.name}" defined by multiple clientFeatures — last definition wins.`,
211
+ );
212
+ }
213
+ treeProviders.set(f.name, f.treeProvider);
214
+ if (f.treeEntities !== undefined && f.treeEntities.length > 0) {
215
+ treeEntities.set(f.name, f.treeEntities);
216
+ }
217
+ }
218
+
219
+ // Editor-Resolver aggregieren — keyed by "featureId:action". Gleiche
220
+ // Last-Wins-Semantik wie columnRenderers. Warnung bei Kollision.
221
+ const resolvers = new Map<string, ResolverComponent>();
222
+ for (const f of clientFeatures) {
223
+ if (f.resolvers === undefined) continue;
224
+ for (const [key, component] of Object.entries(f.resolvers)) {
225
+ if (resolvers.has(key)) {
226
+ // biome-ignore lint/suspicious/noConsole: client-bundle has no logger; collision is a dev-time warning that must surface in the browser DevTools.
227
+ console.warn(
228
+ `[kumiko] resolver "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
229
+ );
230
+ }
231
+ resolvers.set(key, component);
232
+ }
233
+ }
234
+
235
+ const localeResolver = options.locale ?? createBrowserLocaleResolver();
196
236
 
197
237
  const navAdapter = options.navAdapter ?? useBrowserNavApi;
198
238
  const hasWorkspaces = (app.workspaces?.length ?? 0) > 0;
@@ -210,15 +250,19 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
210
250
 
211
251
  const tree = (
212
252
  <TokensBoot>
213
- <LocaleProvider resolver={resolver} fallbackBundles={fallbackBundles}>
253
+ <LocaleProvider resolver={localeResolver} fallbackBundles={fallbackBundles}>
214
254
  <PrimitivesProvider value={primitives}>
215
255
  <DispatcherProvider dispatcher={dispatcher}>
216
256
  <LiveEventsProvider value={liveEvents}>
217
257
  <CustomScreensProvider value={customScreens}>
218
258
  <ColumnRenderersProvider value={columnRenderers}>
219
- <ToastProvider>
220
- {stackWrappers(providers, stackWrappers(gates, screenNode))}
221
- </ToastProvider>
259
+ <TreeProvidersProvider value={treeProviders} entities={treeEntities}>
260
+ <ResolversProvider resolvers={resolvers}>
261
+ <ToastProvider>
262
+ {stackWrappers(providers, stackWrappers(gates, screenNode))}
263
+ </ToastProvider>
264
+ </ResolversProvider>
265
+ </TreeProvidersProvider>
222
266
  </ColumnRenderersProvider>
223
267
  </CustomScreensProvider>
224
268
  </LiveEventsProvider>
@@ -0,0 +1,29 @@
1
+ // ResolversContext — stellt Editor-Resolver-Komponenten für das
2
+ // EditorPanel bereit. Aggregiert in createKumikoApp aus allen
3
+ // clientFeatures.resolvers, analog zu treeProviders-context.
4
+ // Siehe visual-tree.md V.1.2.
5
+
6
+ import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
7
+ import type { ComponentType, ReactNode } from "react";
8
+ import { createContext, useContext } from "react";
9
+
10
+ export type ResolverComponent = ComponentType<{
11
+ readonly target: TargetRef;
12
+ readonly onClose: () => void;
13
+ }>;
14
+
15
+ const ResolversContext = createContext<ReadonlyMap<string, ResolverComponent> | null>(null);
16
+
17
+ export type ResolversProviderProps = {
18
+ readonly resolvers: ReadonlyMap<string, ResolverComponent>;
19
+ readonly children: ReactNode;
20
+ };
21
+
22
+ export function ResolversProvider({ resolvers, children }: ResolversProviderProps): ReactNode {
23
+ return <ResolversContext.Provider value={resolvers}>{children}</ResolversContext.Provider>;
24
+ }
25
+
26
+ export function useResolvers(): ReadonlyMap<string, ResolverComponent> {
27
+ const ctx = useContext(ResolversContext);
28
+ return ctx ?? new Map();
29
+ }
@@ -0,0 +1,68 @@
1
+ // TreeProvidersContext — React-Context für die client-side TreeProvider-
2
+ // Map. Wird von createKumikoApp aus den `clientFeatures[].treeProvider`-
3
+ // Feldern aggregiert und steht damit allen Layout-Komponenten zur
4
+ // Verfügung (insbesondere VisualTree im WorkspaceShell).
5
+ //
6
+ // **Warum Context, nicht Prop**: WorkspaceShell ist bereits multi-Prop
7
+ // (brand, schema, user, sidebarFooter, ...). Provider-Map wäre der
8
+ // nächste Required-when-tree-mode-Prop und würde das Interface aufweichen
9
+ // (welche Props sind Pflicht wann?). Context-Pattern ist konsistent zu
10
+ // existing kumiko-renderer-Mechanik (NavProvider, LocaleProvider,
11
+ // PrimitivesProvider, LiveEventsProvider) — alle für „cross-component
12
+ // state aus app-level".
13
+ //
14
+ // **Default-Wert** ist eine leere Map. Apps ohne Tree-Workspace mounten
15
+ // die App ohne `clientFeatures` mit `treeProvider`, und VisualTree
16
+ // rendert eine empty-state-Sicht. Memory `[Sicherheit > Convenience]`:
17
+ // kein silent-fallback, sondern explicit empty.
18
+ //
19
+ // Siehe visual-tree.md V.1.1-Distribution-Mechanismus.
20
+
21
+ import type { TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { createContext, type ReactNode, useContext } from "react";
23
+
24
+ const EMPTY_PROVIDERS: ReadonlyMap<string, TreeChildrenSubscribe> = new Map();
25
+ const EMPTY_ENTITIES: ReadonlyMap<string, readonly string[]> = new Map();
26
+
27
+ const TreeProvidersContext =
28
+ createContext<ReadonlyMap<string, TreeChildrenSubscribe>>(EMPTY_PROVIDERS);
29
+
30
+ // V.1.5b separater Context für SSE-Entity-Lists pro Provider. Parallel-
31
+ // Context statt Entry-Tuple weil bestehende TreeProvidersProvider-Konsumenten
32
+ // (tests, integration) sonst alle Migrations-Effort hätten.
33
+ const TreeEntitiesContext = createContext<ReadonlyMap<string, readonly string[]>>(EMPTY_ENTITIES);
34
+
35
+ export type TreeProvidersProviderProps = {
36
+ readonly value: ReadonlyMap<string, TreeChildrenSubscribe>;
37
+ /** Optional: pro Provider die Entity-Liste für SSE-Live-Refresh.
38
+ * Default: leere Map → kein Provider refresht via SSE. */
39
+ readonly entities?: ReadonlyMap<string, readonly string[]>;
40
+ readonly children: ReactNode;
41
+ };
42
+
43
+ export function TreeProvidersProvider({
44
+ value,
45
+ entities,
46
+ children,
47
+ }: TreeProvidersProviderProps): ReactNode {
48
+ return (
49
+ <TreeProvidersContext.Provider value={value}>
50
+ <TreeEntitiesContext.Provider value={entities ?? EMPTY_ENTITIES}>
51
+ {children}
52
+ </TreeEntitiesContext.Provider>
53
+ </TreeProvidersContext.Provider>
54
+ );
55
+ }
56
+
57
+ /** Hook für TreeProvider-Map-Konsumenten (VisualTree im WorkspaceShell).
58
+ * Returnt eine empty Map wenn die App keine clientFeatures mit
59
+ * treeProvider registriert hat — sicheres Default-Verhalten ohne
60
+ * Crash. */
61
+ export function useTreeProviders(): ReadonlyMap<string, TreeChildrenSubscribe> {
62
+ return useContext(TreeProvidersContext);
63
+ }
64
+
65
+ /** V.1.5b SSE-Entity-Map pro Provider. Empty Map default. */
66
+ export function useTreeEntities(): ReadonlyMap<string, readonly string[]> {
67
+ return useContext(TreeEntitiesContext);
68
+ }
@@ -0,0 +1,300 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { NavProvider } from "@cosmicdrift/kumiko-renderer";
5
+ import { act, fireEvent, render, screen } from "@testing-library/react";
6
+ import type { ReactNode } from "react";
7
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
8
+ import { useBrowserNavApi } from "../../app/nav";
9
+ import { TreeProvidersProvider } from "../../app/tree-providers-context";
10
+ import { setDispatchListener } from "../target-resolver-stub";
11
+ import { VisualTree } from "../visual-tree";
12
+
13
+ // Mock-Provider-Helper. emit wird einmal initial gerufen mit den
14
+ // gegebenen Nodes; cleanup ist no-op. Subscriptions können später
15
+ // auch dynamisch sein (siehe `makeMutableProvider`).
16
+ function makeStaticProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
17
+ return () => (emit) => {
18
+ emit(nodes);
19
+ return () => {};
20
+ };
21
+ }
22
+
23
+ // Mutable-Provider — Test kann via `emitFn` einen weiteren Emit
24
+ // auslösen um Subscribe-Update zu beweisen. Returnt ein Tupel
25
+ // [provider, controls].
26
+ function makeMutableProvider(initial: readonly TreeNode[]): {
27
+ readonly provider: TreeChildrenSubscribe;
28
+ emit(nodes: readonly TreeNode[]): void;
29
+ } {
30
+ let listener: ((nodes: readonly TreeNode[]) => void) | undefined;
31
+ const provider: TreeChildrenSubscribe = () => (emit) => {
32
+ listener = emit;
33
+ emit(initial);
34
+ return () => {
35
+ listener = undefined;
36
+ };
37
+ };
38
+ return {
39
+ provider,
40
+ emit(nodes) {
41
+ if (listener !== undefined) listener(nodes);
42
+ },
43
+ };
44
+ }
45
+
46
+ function renderTree(
47
+ providers: ReadonlyMap<string, TreeChildrenSubscribe>,
48
+ ): ReturnType<typeof render> {
49
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
50
+ // V.1.4b: TreeNodeRenderer + ActionButton nutzen useDispatchTarget,
51
+ // das useNav greift — Tests brauchen NavProvider. Browser-nav reset
52
+ // erfolgt im beforeEach (window.history.replaceState).
53
+ const nav = useBrowserNavApi();
54
+ return (
55
+ <NavProvider value={nav}>
56
+ <TreeProvidersProvider value={providers}>{children}</TreeProvidersProvider>
57
+ </NavProvider>
58
+ );
59
+ }
60
+ return render(<VisualTree workspaceId="test-ws" />, { wrapper: Wrapper });
61
+ }
62
+
63
+ // vitest+Bun-Runtime liefert nur ein partielles localStorage (kein
64
+ // `clear`/`removeItem`). Wir installieren pro Test einen frischen
65
+ // Map-basierten Mock, damit Standard-API-Methoden funktionieren und
66
+ // Test-Isolation sauber ist. Production-Code nutzt nur die Standard-
67
+ // Schnittstelle, daher transparent.
68
+ beforeEach(() => {
69
+ // V.1.4b: URL-State leakt sonst zwischen Tests (useBrowserNavApi
70
+ // liest window.location). Plus localStorage-Mock unten.
71
+ window.history.replaceState(null, "", "/");
72
+ const store = new Map<string, string>();
73
+ Object.defineProperty(window, "localStorage", {
74
+ configurable: true,
75
+ value: {
76
+ getItem: (key: string): string | null => store.get(key) ?? null,
77
+ setItem: (key: string, value: string): void => {
78
+ store.set(key, value);
79
+ },
80
+ removeItem: (key: string): void => {
81
+ store.delete(key);
82
+ },
83
+ clear: (): void => store.clear(),
84
+ get length(): number {
85
+ return store.size;
86
+ },
87
+ key: (i: number): string | null => Array.from(store.keys())[i] ?? null,
88
+ },
89
+ });
90
+ });
91
+
92
+ describe("VisualTree — Empty-State", () => {
93
+ test("ohne registrierte Provider rendert sichtbaren Empty-Hint", () => {
94
+ renderTree(new Map());
95
+ expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
96
+ expect(screen.getByText(/Keine Tree-Provider aktiv/)).toBeTruthy();
97
+ });
98
+
99
+ test("Provider-Map vorhanden + emittet leere TreeNode[]: kein Empty-State, kein NavTree-Fallback", () => {
100
+ const providers = new Map([["text-content", makeStaticProvider([])]]);
101
+ renderTree(providers);
102
+ // Nicht im Empty-State (es gibt einen registrierten Provider, der
103
+ // hat nur kein Knoten emittet). Stattdessen rendert die ProviderBranch
104
+ // mit dem featureName als Label im aria-tree.
105
+ expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
106
+ expect(screen.getByLabelText("Visual Tree")).toBeTruthy();
107
+ });
108
+ });
109
+
110
+ describe("VisualTree — Provider-Iteration", () => {
111
+ test("Single-Provider mit static-children rendert Top-Level-Knoten", () => {
112
+ const providers = new Map([
113
+ ["text-content", makeStaticProvider([{ label: "Marketing" }, { label: "Legal" }])],
114
+ ]);
115
+ renderTree(providers);
116
+ expect(screen.getByText("Marketing")).toBeTruthy();
117
+ expect(screen.getByText("Legal")).toBeTruthy();
118
+ });
119
+
120
+ test("Multi-Provider alphabetisch sortiert nach featureName", () => {
121
+ // legal-pages kommt alphabetisch vor text-content
122
+ const providers = new Map<string, TreeChildrenSubscribe>([
123
+ ["text-content", makeStaticProvider([{ label: "Marketing" }])],
124
+ ["legal-pages", makeStaticProvider([{ label: "Imprint" }])],
125
+ ]);
126
+ renderTree(providers);
127
+ const branches = document.querySelectorAll("[data-kumiko-tree-branch]");
128
+ expect(branches[0]?.getAttribute("data-kumiko-tree-branch")).toBe("legal-pages");
129
+ expect(branches[1]?.getAttribute("data-kumiko-tree-branch")).toBe("text-content");
130
+ });
131
+
132
+ test("Provider der nicht emittet bleibt im loading-State sichtbar", () => {
133
+ // Provider ruft emit nie auf (z.B. async-fetch noch im Flug)
134
+ const noopProvider: TreeChildrenSubscribe = () => () => () => {};
135
+ const providers = new Map([["slow-feature", noopProvider]]);
136
+ renderTree(providers);
137
+ expect(screen.getByText("slow-feature: lädt …")).toBeTruthy();
138
+ });
139
+
140
+ test("Subscribe-Update: zweiter Emit re-rendert die Liste", () => {
141
+ const { provider, emit } = makeMutableProvider([{ label: "Hero" }]);
142
+ const providers = new Map([["text-content", provider]]);
143
+ renderTree(providers);
144
+
145
+ expect(screen.getByText("Hero")).toBeTruthy();
146
+
147
+ // Provider emittet aktualisierte Liste — Tree muss re-rendern.
148
+ // act() wrapped den state-Update damit React synchronisiert flushed.
149
+ act(() => {
150
+ emit([{ label: "Hero" }, { label: "Pricing" }]);
151
+ });
152
+ expect(screen.getByText("Pricing")).toBeTruthy();
153
+ expect(screen.getByText("Hero")).toBeTruthy();
154
+ });
155
+
156
+ test("Provider-Unsubscribe wird beim Unmount gecallt", () => {
157
+ let unsubscribed = false;
158
+ const provider: TreeChildrenSubscribe = () => (emit) => {
159
+ emit([{ label: "Foo" }]);
160
+ return () => {
161
+ unsubscribed = true;
162
+ };
163
+ };
164
+ const providers = new Map([["test", provider]]);
165
+ const result = renderTree(providers);
166
+
167
+ expect(unsubscribed).toBe(false);
168
+ result.unmount();
169
+ expect(unsubscribed).toBe(true);
170
+ });
171
+ });
172
+
173
+ describe("VisualTree — Click-Dispatch", () => {
174
+ let cleanup: (() => void) | undefined;
175
+ afterEach(() => {
176
+ cleanup?.();
177
+ cleanup = undefined;
178
+ });
179
+
180
+ test("Click auf Knoten mit target ruft dispatchTarget mit dem TargetRef", () => {
181
+ const dispatched: unknown[] = [];
182
+ cleanup = setDispatchListener((target) => {
183
+ dispatched.push(target);
184
+ });
185
+
186
+ const providers = new Map([
187
+ [
188
+ "text-content",
189
+ makeStaticProvider([
190
+ {
191
+ label: "Hero",
192
+ target: { featureId: "text-content", action: "edit", args: { slug: "hero" } },
193
+ },
194
+ ]),
195
+ ],
196
+ ]);
197
+ renderTree(providers);
198
+
199
+ fireEvent.click(screen.getByText("Hero"));
200
+
201
+ expect(dispatched).toEqual([
202
+ { featureId: "text-content", action: "edit", args: { slug: "hero" } },
203
+ ]);
204
+ });
205
+
206
+ test('Skeleton-Affordance: state="empty" + createAction rendert + Button und dispatcht createAction.target', () => {
207
+ // D3-Validation aus visual-tree.md V.1.1-Decisions: Provider-explizit
208
+ // createAction-Field auf TreeNode mit state="empty" → Tree-Component
209
+ // zeigt automatisch ein "+"-Icon und dispatcht createAction.target
210
+ // beim Klick (NICHT node.target — das wäre die Row-onClick-Action).
211
+ const dispatched: unknown[] = [];
212
+ cleanup = setDispatchListener((target) => {
213
+ dispatched.push(target);
214
+ });
215
+
216
+ const providers = new Map([
217
+ [
218
+ "sections",
219
+ makeStaticProvider([
220
+ {
221
+ label: "Sections",
222
+ state: "empty",
223
+ createAction: {
224
+ icon: "plus",
225
+ label: "Add section",
226
+ target: { featureId: "sections", action: "create" },
227
+ },
228
+ },
229
+ ]),
230
+ ],
231
+ ]);
232
+ renderTree(providers);
233
+
234
+ // + Button greifbar via aria-label aus createAction.label
235
+ const addButton = screen.getByLabelText("Add section");
236
+ fireEvent.click(addButton);
237
+
238
+ expect(dispatched).toEqual([{ featureId: "sections", action: "create" }]);
239
+ });
240
+
241
+ test("Click auf Container-Knoten (mit children) toggled expand statt Dispatch", () => {
242
+ const dispatched: unknown[] = [];
243
+ cleanup = setDispatchListener((target) => {
244
+ dispatched.push(target);
245
+ });
246
+
247
+ const providers = new Map([
248
+ [
249
+ "text-content",
250
+ makeStaticProvider([
251
+ {
252
+ label: "Marketing",
253
+ children: [{ label: "Hero" }],
254
+ },
255
+ ]),
256
+ ],
257
+ ]);
258
+ renderTree(providers);
259
+
260
+ // Initial collapsed: Hero nicht sichtbar
261
+ expect(screen.queryByText("Hero")).toBeNull();
262
+ fireEvent.click(screen.getByText("Marketing"));
263
+ // Nach Click expanded: Hero sichtbar, kein Dispatch passiert
264
+ expect(screen.getByText("Hero")).toBeTruthy();
265
+ expect(dispatched).toEqual([]);
266
+ });
267
+ });
268
+
269
+ describe("VisualTree — localStorage-Persistenz", () => {
270
+ test("Toggle persistiert expanded-Set ins localStorage pro Workspace", () => {
271
+ const providers = new Map([
272
+ ["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
273
+ ]);
274
+ renderTree(providers);
275
+
276
+ fireEvent.click(screen.getByText("Marketing"));
277
+
278
+ const stored = window.localStorage.getItem("kumiko:visual-tree:expanded:test-ws");
279
+ expect(stored).not.toBeNull();
280
+ const parsed = JSON.parse(stored ?? "[]") as string[];
281
+ expect(parsed.length).toBe(1);
282
+ expect(parsed[0]).toContain("Marketing");
283
+ });
284
+
285
+ test("Re-mount restored expanded-Set aus localStorage", () => {
286
+ // Setup: persistierter expanded-Set für test-ws-Workspace
287
+ window.localStorage.setItem(
288
+ "kumiko:visual-tree:expanded:test-ws",
289
+ JSON.stringify(["text-content/0-Marketing"]),
290
+ );
291
+
292
+ const providers = new Map([
293
+ ["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
294
+ ]);
295
+ renderTree(providers);
296
+
297
+ // Marketing ist expandiert → Hero sichtbar ohne Click
298
+ expect(screen.getByText("Hero")).toBeTruthy();
299
+ });
300
+ });
@@ -43,7 +43,7 @@ const SIZE_CLASSES: Record<AvatarSize, string> = {
43
43
  sm: "size-5 text-[10px]",
44
44
  md: "size-6 text-[11px]",
45
45
  lg: "size-8 text-xs",
46
- };
46
+ } satisfies Record<AvatarSize, string>;
47
47
 
48
48
  function hashCode(str: string): number {
49
49
  // djb2-Variante — schnell, deterministic, gut genug verteilt für
@@ -0,0 +1,104 @@
1
+ // EditorPanel — V.1.2: Main-area editor für Target-Dispatch im
2
+ // Visual-Tree-Workspace. VS-Code-Style-Layout: Tree links, Editor füllt
3
+ // den Main-Bereich; kein floating-Right-Panel. Resolver liefert die
4
+ // Editor-Component für `${featureId}:${action}`, Fallback-Info zeigt
5
+ // die Args wenn nichts registriert ist, Empty-State wenn nichts gewählt.
6
+ //
7
+ // **V.1.4b URL-State**: target wird in `nav.searchParams` persistiert
8
+ // (Format: `?t=text-content:edit&a_slug=imprint&a_lang=de`). F5 +
9
+ // Back-Button stellen den Editor-State wieder her. Single source of
10
+ // truth = URL; useState fällt weg. Close clears params via setSearchParams.
11
+ // Subscribe-Stream bleibt für Test-Hooks (setDispatchListener), wird
12
+ // in Prod nicht mehr für EditorPanel benutzt.
13
+ // Siehe visual-tree.md V.1.2 + V.1.1-B + V.1.4b.
14
+
15
+ import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { useNav } from "@cosmicdrift/kumiko-renderer";
17
+ import { X } from "lucide-react";
18
+ import type { ComponentType, ReactNode } from "react";
19
+ import { useCallback, useMemo } from "react";
20
+ import { clearTargetSearchParams, parseTargetFromSearchParams } from "./target-url";
21
+
22
+ export type ResolverComponent = ComponentType<{
23
+ readonly target: TargetRef;
24
+ readonly onClose: () => void;
25
+ }>;
26
+
27
+ export type EditorPanelProps = {
28
+ readonly resolvers: ReadonlyMap<string, ResolverComponent>;
29
+ };
30
+
31
+ function EditorPanelInner({
32
+ target,
33
+ resolvers,
34
+ onClose,
35
+ }: {
36
+ readonly target: TargetRef;
37
+ readonly resolvers: ReadonlyMap<string, ResolverComponent>;
38
+ readonly onClose: () => void;
39
+ }): ReactNode {
40
+ const resolverKey = `${target.featureId}:${target.action}`;
41
+ const Resolver = resolvers.get(resolverKey);
42
+
43
+ if (Resolver !== undefined) {
44
+ return <Resolver target={target} onClose={onClose} />;
45
+ }
46
+
47
+ return (
48
+ <div className="flex flex-col gap-4 p-4">
49
+ <div className="flex items-center justify-between">
50
+ <h2 className="text-lg font-semibold">Editor</h2>
51
+ <button
52
+ type="button"
53
+ aria-label="close editor"
54
+ className="p-1 hover:bg-accent rounded"
55
+ onClick={onClose}
56
+ >
57
+ <X className="size-4" />
58
+ </button>
59
+ </div>
60
+ <div className="text-sm text-muted-foreground space-y-2">
61
+ <p>
62
+ Kein Editor f&uuml;r{" "}
63
+ <code>
64
+ {target.featureId}:{target.action}
65
+ </code>{" "}
66
+ registriert.
67
+ </p>
68
+ <pre className="bg-muted p-2 rounded text-xs overflow-auto">
69
+ {JSON.stringify(target.args, null, 2)}
70
+ </pre>
71
+ </div>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ export function EditorPanel({ resolvers }: EditorPanelProps): ReactNode {
77
+ const nav = useNav();
78
+ // target derived from URL → F5/Back stellen state wieder her.
79
+ // useMemo stabilisiert reference solange searchParams shallow-gleich
80
+ // sind (nav-Impl liefert plain-record für genau diesen check).
81
+ const target = useMemo(() => parseTargetFromSearchParams(nav.searchParams), [nav.searchParams]);
82
+
83
+ const handleClose = useCallback(() => {
84
+ nav.setSearchParams(clearTargetSearchParams(nav.searchParams));
85
+ }, [nav]);
86
+
87
+ return (
88
+ <div data-kumiko-layout="editor-main" className="flex-1 overflow-y-auto">
89
+ {target === undefined ? (
90
+ <EditorEmptyState />
91
+ ) : (
92
+ <EditorPanelInner target={target} resolvers={resolvers} onClose={handleClose} />
93
+ )}
94
+ </div>
95
+ );
96
+ }
97
+
98
+ function EditorEmptyState(): ReactNode {
99
+ return (
100
+ <div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
101
+ <p>W&auml;hle einen Knoten links zum Bearbeiten.</p>
102
+ </div>
103
+ );
104
+ }