@cosmicdrift/kumiko-renderer-web 0.2.3 → 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.
@@ -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,40 @@ 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
+ for (const f of clientFeatures) {
205
+ if (f.treeProvider === undefined) continue;
206
+ if (treeProviders.has(f.name)) {
207
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
208
+ console.warn(
209
+ `[kumiko] treeProvider for "${f.name}" defined by multiple clientFeatures — last definition wins.`,
210
+ );
211
+ }
212
+ treeProviders.set(f.name, f.treeProvider);
213
+ }
214
+
215
+ // Editor-Resolver aggregieren — keyed by "featureId:action". Gleiche
216
+ // Last-Wins-Semantik wie columnRenderers. Warnung bei Kollision.
217
+ const resolvers = new Map<string, ResolverComponent>();
218
+ for (const f of clientFeatures) {
219
+ if (f.resolvers === undefined) continue;
220
+ for (const [key, component] of Object.entries(f.resolvers)) {
221
+ if (resolvers.has(key)) {
222
+ // biome-ignore lint/suspicious/noConsole: client-bundle has no logger; collision is a dev-time warning that must surface in the browser DevTools.
223
+ console.warn(
224
+ `[kumiko] resolver "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
225
+ );
226
+ }
227
+ resolvers.set(key, component);
228
+ }
229
+ }
230
+
231
+ const localeResolver = options.locale ?? createBrowserLocaleResolver();
196
232
 
197
233
  const navAdapter = options.navAdapter ?? useBrowserNavApi;
198
234
  const hasWorkspaces = (app.workspaces?.length ?? 0) > 0;
@@ -210,15 +246,19 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
210
246
 
211
247
  const tree = (
212
248
  <TokensBoot>
213
- <LocaleProvider resolver={resolver} fallbackBundles={fallbackBundles}>
249
+ <LocaleProvider resolver={localeResolver} fallbackBundles={fallbackBundles}>
214
250
  <PrimitivesProvider value={primitives}>
215
251
  <DispatcherProvider dispatcher={dispatcher}>
216
252
  <LiveEventsProvider value={liveEvents}>
217
253
  <CustomScreensProvider value={customScreens}>
218
254
  <ColumnRenderersProvider value={columnRenderers}>
219
- <ToastProvider>
220
- {stackWrappers(providers, stackWrappers(gates, screenNode))}
221
- </ToastProvider>
255
+ <TreeProvidersProvider value={treeProviders}>
256
+ <ResolversProvider resolvers={resolvers}>
257
+ <ToastProvider>
258
+ {stackWrappers(providers, stackWrappers(gates, screenNode))}
259
+ </ToastProvider>
260
+ </ResolversProvider>
261
+ </TreeProvidersProvider>
222
262
  </ColumnRenderersProvider>
223
263
  </CustomScreensProvider>
224
264
  </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,44 @@
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
+
26
+ const TreeProvidersContext =
27
+ createContext<ReadonlyMap<string, TreeChildrenSubscribe>>(EMPTY_PROVIDERS);
28
+
29
+ export type TreeProvidersProviderProps = {
30
+ readonly value: ReadonlyMap<string, TreeChildrenSubscribe>;
31
+ readonly children: ReactNode;
32
+ };
33
+
34
+ export function TreeProvidersProvider({ value, children }: TreeProvidersProviderProps): ReactNode {
35
+ return <TreeProvidersContext.Provider value={value}>{children}</TreeProvidersContext.Provider>;
36
+ }
37
+
38
+ /** Hook für TreeProvider-Map-Konsumenten (VisualTree im WorkspaceShell).
39
+ * Returnt eine empty Map wenn die App keine clientFeatures mit
40
+ * treeProvider registriert hat — sicheres Default-Verhalten ohne
41
+ * Crash. */
42
+ export function useTreeProviders(): ReadonlyMap<string, TreeChildrenSubscribe> {
43
+ return useContext(TreeProvidersContext);
44
+ }
@@ -0,0 +1,287 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { act, fireEvent, render, screen } from "@testing-library/react";
5
+ import type { ReactNode } from "react";
6
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
7
+ import { TreeProvidersProvider } from "../../app/tree-providers-context";
8
+ import { setDispatchListener } from "../target-resolver-stub";
9
+ import { VisualTree } from "../visual-tree";
10
+
11
+ // Mock-Provider-Helper. emit wird einmal initial gerufen mit den
12
+ // gegebenen Nodes; cleanup ist no-op. Subscriptions können später
13
+ // auch dynamisch sein (siehe `makeMutableProvider`).
14
+ function makeStaticProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
15
+ return () => (emit) => {
16
+ emit(nodes);
17
+ return () => {};
18
+ };
19
+ }
20
+
21
+ // Mutable-Provider — Test kann via `emitFn` einen weiteren Emit
22
+ // auslösen um Subscribe-Update zu beweisen. Returnt ein Tupel
23
+ // [provider, controls].
24
+ function makeMutableProvider(initial: readonly TreeNode[]): {
25
+ readonly provider: TreeChildrenSubscribe;
26
+ emit(nodes: readonly TreeNode[]): void;
27
+ } {
28
+ let listener: ((nodes: readonly TreeNode[]) => void) | undefined;
29
+ const provider: TreeChildrenSubscribe = () => (emit) => {
30
+ listener = emit;
31
+ emit(initial);
32
+ return () => {
33
+ listener = undefined;
34
+ };
35
+ };
36
+ return {
37
+ provider,
38
+ emit(nodes) {
39
+ if (listener !== undefined) listener(nodes);
40
+ },
41
+ };
42
+ }
43
+
44
+ function renderTree(
45
+ providers: ReadonlyMap<string, TreeChildrenSubscribe>,
46
+ ): ReturnType<typeof render> {
47
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
48
+ return <TreeProvidersProvider value={providers}>{children}</TreeProvidersProvider>;
49
+ }
50
+ return render(<VisualTree workspaceId="test-ws" />, { wrapper: Wrapper });
51
+ }
52
+
53
+ // vitest+Bun-Runtime liefert nur ein partielles localStorage (kein
54
+ // `clear`/`removeItem`). Wir installieren pro Test einen frischen
55
+ // Map-basierten Mock, damit Standard-API-Methoden funktionieren und
56
+ // Test-Isolation sauber ist. Production-Code nutzt nur die Standard-
57
+ // Schnittstelle, daher transparent.
58
+ beforeEach(() => {
59
+ const store = new Map<string, string>();
60
+ Object.defineProperty(window, "localStorage", {
61
+ configurable: true,
62
+ value: {
63
+ getItem: (key: string): string | null => store.get(key) ?? null,
64
+ setItem: (key: string, value: string): void => {
65
+ store.set(key, value);
66
+ },
67
+ removeItem: (key: string): void => {
68
+ store.delete(key);
69
+ },
70
+ clear: (): void => store.clear(),
71
+ get length(): number {
72
+ return store.size;
73
+ },
74
+ key: (i: number): string | null => Array.from(store.keys())[i] ?? null,
75
+ },
76
+ });
77
+ });
78
+
79
+ describe("VisualTree — Empty-State", () => {
80
+ test("ohne registrierte Provider rendert sichtbaren Empty-Hint", () => {
81
+ renderTree(new Map());
82
+ expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
83
+ expect(screen.getByText(/Keine Tree-Provider aktiv/)).toBeTruthy();
84
+ });
85
+
86
+ test("Provider-Map vorhanden + emittet leere TreeNode[]: kein Empty-State, kein NavTree-Fallback", () => {
87
+ const providers = new Map([["text-content", makeStaticProvider([])]]);
88
+ renderTree(providers);
89
+ // Nicht im Empty-State (es gibt einen registrierten Provider, der
90
+ // hat nur kein Knoten emittet). Stattdessen rendert die ProviderBranch
91
+ // mit dem featureName als Label im aria-tree.
92
+ expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
93
+ expect(screen.getByLabelText("Visual Tree")).toBeTruthy();
94
+ });
95
+ });
96
+
97
+ describe("VisualTree — Provider-Iteration", () => {
98
+ test("Single-Provider mit static-children rendert Top-Level-Knoten", () => {
99
+ const providers = new Map([
100
+ ["text-content", makeStaticProvider([{ label: "Marketing" }, { label: "Legal" }])],
101
+ ]);
102
+ renderTree(providers);
103
+ expect(screen.getByText("Marketing")).toBeTruthy();
104
+ expect(screen.getByText("Legal")).toBeTruthy();
105
+ });
106
+
107
+ test("Multi-Provider alphabetisch sortiert nach featureName", () => {
108
+ // legal-pages kommt alphabetisch vor text-content
109
+ const providers = new Map<string, TreeChildrenSubscribe>([
110
+ ["text-content", makeStaticProvider([{ label: "Marketing" }])],
111
+ ["legal-pages", makeStaticProvider([{ label: "Imprint" }])],
112
+ ]);
113
+ renderTree(providers);
114
+ const branches = document.querySelectorAll("[data-kumiko-tree-branch]");
115
+ expect(branches[0]?.getAttribute("data-kumiko-tree-branch")).toBe("legal-pages");
116
+ expect(branches[1]?.getAttribute("data-kumiko-tree-branch")).toBe("text-content");
117
+ });
118
+
119
+ test("Provider der nicht emittet bleibt im loading-State sichtbar", () => {
120
+ // Provider ruft emit nie auf (z.B. async-fetch noch im Flug)
121
+ const noopProvider: TreeChildrenSubscribe = () => () => () => {};
122
+ const providers = new Map([["slow-feature", noopProvider]]);
123
+ renderTree(providers);
124
+ expect(screen.getByText("slow-feature: lädt …")).toBeTruthy();
125
+ });
126
+
127
+ test("Subscribe-Update: zweiter Emit re-rendert die Liste", () => {
128
+ const { provider, emit } = makeMutableProvider([{ label: "Hero" }]);
129
+ const providers = new Map([["text-content", provider]]);
130
+ renderTree(providers);
131
+
132
+ expect(screen.getByText("Hero")).toBeTruthy();
133
+
134
+ // Provider emittet aktualisierte Liste — Tree muss re-rendern.
135
+ // act() wrapped den state-Update damit React synchronisiert flushed.
136
+ act(() => {
137
+ emit([{ label: "Hero" }, { label: "Pricing" }]);
138
+ });
139
+ expect(screen.getByText("Pricing")).toBeTruthy();
140
+ expect(screen.getByText("Hero")).toBeTruthy();
141
+ });
142
+
143
+ test("Provider-Unsubscribe wird beim Unmount gecallt", () => {
144
+ let unsubscribed = false;
145
+ const provider: TreeChildrenSubscribe = () => (emit) => {
146
+ emit([{ label: "Foo" }]);
147
+ return () => {
148
+ unsubscribed = true;
149
+ };
150
+ };
151
+ const providers = new Map([["test", provider]]);
152
+ const result = renderTree(providers);
153
+
154
+ expect(unsubscribed).toBe(false);
155
+ result.unmount();
156
+ expect(unsubscribed).toBe(true);
157
+ });
158
+ });
159
+
160
+ describe("VisualTree — Click-Dispatch", () => {
161
+ let cleanup: (() => void) | undefined;
162
+ afterEach(() => {
163
+ cleanup?.();
164
+ cleanup = undefined;
165
+ });
166
+
167
+ test("Click auf Knoten mit target ruft dispatchTarget mit dem TargetRef", () => {
168
+ const dispatched: unknown[] = [];
169
+ cleanup = setDispatchListener((target) => {
170
+ dispatched.push(target);
171
+ });
172
+
173
+ const providers = new Map([
174
+ [
175
+ "text-content",
176
+ makeStaticProvider([
177
+ {
178
+ label: "Hero",
179
+ target: { featureId: "text-content", action: "edit", args: { slug: "hero" } },
180
+ },
181
+ ]),
182
+ ],
183
+ ]);
184
+ renderTree(providers);
185
+
186
+ fireEvent.click(screen.getByText("Hero"));
187
+
188
+ expect(dispatched).toEqual([
189
+ { featureId: "text-content", action: "edit", args: { slug: "hero" } },
190
+ ]);
191
+ });
192
+
193
+ test('Skeleton-Affordance: state="empty" + createAction rendert + Button und dispatcht createAction.target', () => {
194
+ // D3-Validation aus visual-tree.md V.1.1-Decisions: Provider-explizit
195
+ // createAction-Field auf TreeNode mit state="empty" → Tree-Component
196
+ // zeigt automatisch ein "+"-Icon und dispatcht createAction.target
197
+ // beim Klick (NICHT node.target — das wäre die Row-onClick-Action).
198
+ const dispatched: unknown[] = [];
199
+ cleanup = setDispatchListener((target) => {
200
+ dispatched.push(target);
201
+ });
202
+
203
+ const providers = new Map([
204
+ [
205
+ "sections",
206
+ makeStaticProvider([
207
+ {
208
+ label: "Sections",
209
+ state: "empty",
210
+ createAction: {
211
+ icon: "plus",
212
+ label: "Add section",
213
+ target: { featureId: "sections", action: "create" },
214
+ },
215
+ },
216
+ ]),
217
+ ],
218
+ ]);
219
+ renderTree(providers);
220
+
221
+ // + Button greifbar via aria-label aus createAction.label
222
+ const addButton = screen.getByLabelText("Add section");
223
+ fireEvent.click(addButton);
224
+
225
+ expect(dispatched).toEqual([{ featureId: "sections", action: "create" }]);
226
+ });
227
+
228
+ test("Click auf Container-Knoten (mit children) toggled expand statt Dispatch", () => {
229
+ const dispatched: unknown[] = [];
230
+ cleanup = setDispatchListener((target) => {
231
+ dispatched.push(target);
232
+ });
233
+
234
+ const providers = new Map([
235
+ [
236
+ "text-content",
237
+ makeStaticProvider([
238
+ {
239
+ label: "Marketing",
240
+ children: [{ label: "Hero" }],
241
+ },
242
+ ]),
243
+ ],
244
+ ]);
245
+ renderTree(providers);
246
+
247
+ // Initial collapsed: Hero nicht sichtbar
248
+ expect(screen.queryByText("Hero")).toBeNull();
249
+ fireEvent.click(screen.getByText("Marketing"));
250
+ // Nach Click expanded: Hero sichtbar, kein Dispatch passiert
251
+ expect(screen.getByText("Hero")).toBeTruthy();
252
+ expect(dispatched).toEqual([]);
253
+ });
254
+ });
255
+
256
+ describe("VisualTree — localStorage-Persistenz", () => {
257
+ test("Toggle persistiert expanded-Set ins localStorage pro Workspace", () => {
258
+ const providers = new Map([
259
+ ["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
260
+ ]);
261
+ renderTree(providers);
262
+
263
+ fireEvent.click(screen.getByText("Marketing"));
264
+
265
+ const stored = window.localStorage.getItem("kumiko:visual-tree:expanded:test-ws");
266
+ expect(stored).not.toBeNull();
267
+ const parsed = JSON.parse(stored ?? "[]") as string[];
268
+ expect(parsed.length).toBe(1);
269
+ expect(parsed[0]).toContain("Marketing");
270
+ });
271
+
272
+ test("Re-mount restored expanded-Set aus localStorage", () => {
273
+ // Setup: persistierter expanded-Set für test-ws-Workspace
274
+ window.localStorage.setItem(
275
+ "kumiko:visual-tree:expanded:test-ws",
276
+ JSON.stringify(["text-content/0-Marketing"]),
277
+ );
278
+
279
+ const providers = new Map([
280
+ ["text-content", makeStaticProvider([{ label: "Marketing", children: [{ label: "Hero" }] }])],
281
+ ]);
282
+ renderTree(providers);
283
+
284
+ // Marketing ist expandiert → Hero sichtbar ohne Click
285
+ expect(screen.getByText("Hero")).toBeTruthy();
286
+ });
287
+ });
@@ -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,91 @@
1
+ // EditorPanel — V.1.2: Right-side editor panel für Target-Dispatch.
2
+ // Wird sichtbar wenn ein TreeNode mit target angeklickt wird. Zeigt
3
+ // das passende Editor-Component aus dem Resolver-Registry, oder eine
4
+ // Fallback-Info wenn kein Resolver registriert ist.
5
+ // Siehe visual-tree.md V.1.2 + V.1.1-B.
6
+
7
+ import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { X } from "lucide-react";
9
+ import type { ComponentType, ReactNode } from "react";
10
+ import { useCallback, useEffect, useState } from "react";
11
+ import { subscribeTargetDispatches } from "./target-resolver-stub";
12
+
13
+ export type ResolverComponent = ComponentType<{
14
+ readonly target: TargetRef;
15
+ readonly onClose: () => void;
16
+ }>;
17
+
18
+ export type EditorPanelProps = {
19
+ readonly resolvers: ReadonlyMap<string, ResolverComponent>;
20
+ };
21
+
22
+ function EditorPanelInner({
23
+ target,
24
+ resolvers,
25
+ onClose,
26
+ }: {
27
+ readonly target: TargetRef;
28
+ readonly resolvers: ReadonlyMap<string, ResolverComponent>;
29
+ readonly onClose: () => void;
30
+ }): ReactNode {
31
+ const resolverKey = `${target.featureId}:${target.action}`;
32
+ const Resolver = resolvers.get(resolverKey);
33
+
34
+ if (Resolver !== undefined) {
35
+ return <Resolver target={target} onClose={onClose} />;
36
+ }
37
+
38
+ return (
39
+ <div className="flex flex-col gap-4 p-4">
40
+ <div className="flex items-center justify-between">
41
+ <h2 className="text-lg font-semibold">Editor</h2>
42
+ <button
43
+ type="button"
44
+ aria-label="close editor"
45
+ className="p-1 hover:bg-accent rounded"
46
+ onClick={onClose}
47
+ >
48
+ <X className="size-4" />
49
+ </button>
50
+ </div>
51
+ <div className="text-sm text-muted-foreground space-y-2">
52
+ <p>
53
+ Kein Editor f&uuml;r{" "}
54
+ <code>
55
+ {target.featureId}:{target.action}
56
+ </code>{" "}
57
+ registriert.
58
+ </p>
59
+ <pre className="bg-muted p-2 rounded text-xs overflow-auto">
60
+ {JSON.stringify(target.args, null, 2)}
61
+ </pre>
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ export function EditorPanel({ resolvers }: EditorPanelProps): ReactNode {
68
+ const [target, setTarget] = useState<TargetRef | undefined>();
69
+
70
+ useEffect(() => {
71
+ const unsubscribe = subscribeTargetDispatches((t: TargetRef) => {
72
+ setTarget(t);
73
+ });
74
+ return unsubscribe;
75
+ }, []);
76
+
77
+ const handleClose = useCallback(() => {
78
+ setTarget(undefined);
79
+ }, []);
80
+
81
+ if (target === undefined) return null;
82
+
83
+ return (
84
+ <div
85
+ data-kumiko-layout="editor-panel"
86
+ className="fixed inset-y-0 right-0 z-50 w-[480px] max-w-[90vw] border-l bg-background shadow-xl overflow-y-auto"
87
+ >
88
+ <EditorPanelInner target={target} resolvers={resolvers} onClose={handleClose} />
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,53 @@
1
+ // TargetResolver — V.1.2: Multi-Listener-TargetDispatch mit Test-Hook.
2
+ // Der V.1.1-Stub (console.debug) wird durch Production-Subscriber
3
+ // ersetzt — EditorPanel, URL-State-Bridge, etc. registrieren sich
4
+ // via subscribeTargetDispatches().
5
+ //
6
+ // Dispatch-Priority:
7
+ // 1. Test-Listener (setDispatchListener) — exklusiv, kein Production-
8
+ // Subscriber läuft während Tests (Test-Isolation).
9
+ // 2. Production-Subscriber (subscribeTargetDispatches) — alle
10
+ // registrierten Production-Subscriber.
11
+ // 3. Kein Test-Listener + keine Subscriber → console.debug fallback
12
+ // (damit unhandled Klicks sichtbar bleiben).
13
+ //
14
+ // Siehe visual-tree.md V.1.2.
15
+
16
+ import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
17
+
18
+ type DispatchListener = (target: TargetRef) => void;
19
+
20
+ let testListener: DispatchListener | undefined;
21
+ const subscribers = new Set<DispatchListener>();
22
+
23
+ export function dispatchTarget(target: TargetRef): void {
24
+ if (testListener !== undefined) {
25
+ testListener(target);
26
+ return;
27
+ }
28
+ if (subscribers.size > 0) {
29
+ for (const fn of subscribers) {
30
+ fn(target);
31
+ }
32
+ return;
33
+ }
34
+ // biome-ignore lint/suspicious/noConsole: fallback wenn kein Subscriber registered
35
+ console.debug("[VisualTree] target dispatched (unhandled)", target);
36
+ }
37
+
38
+ /** Test-Hook: Exklusiver Spy. Returnt cleanup. */
39
+ export function setDispatchListener(fn: DispatchListener): () => void {
40
+ testListener = fn;
41
+ return () => {
42
+ testListener = undefined;
43
+ };
44
+ }
45
+
46
+ /** Production-Subscriber registrieren. Returnt unsubscribe. Läuft nur
47
+ * wenn kein Test-Listener aktiv (Test-Isolation). */
48
+ export function subscribeTargetDispatches(fn: DispatchListener): () => void {
49
+ subscribers.add(fn);
50
+ return () => {
51
+ subscribers.delete(fn);
52
+ };
53
+ }