@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.
@@ -0,0 +1,292 @@
1
+ // TreeNodeRenderer — recursive Pro-Knoten-Component für den Visual-Tree.
2
+ //
3
+ // **Pflichten** (visual-tree.md V.1.1-C):
4
+ // 1. Render `[icon] [label] [actions]` row mit State-abhängigen Klassen
5
+ // 2. Click-Dispatch: onClick mit gesetztem `node.target` → dispatchTarget
6
+ // 3. Hover-Actions rechts (CSS-only, hover-visible)
7
+ // 4. Children rekursiv: static-Array direkt, TreeChildrenSubscribe lazy
8
+ // mit subscribe/unsubscribe an expand/collapse
9
+ // 5. Skeleton-Affordance: state="empty" + createAction → automatic
10
+ // "+"-Icon, dispatcht createAction.target
11
+ //
12
+ // **Expand-State** lebt nicht hier sondern im VisualTree (Top-Level)
13
+ // damit localStorage-Persistenz pro Workspace eine Stelle hat. Renderer
14
+ // kriegt `expanded: Set<path>` + `onToggle(path)` als Props.
15
+ //
16
+ // **Path** = Workspace-eindeutiger String (parent-path + child-index oder
17
+ // node.label-segment). Stable über Re-Renders, eindeutig pro Knoten.
18
+ //
19
+ // Siehe visual-tree.md V.1.1-C + A4 (TreeNode-Type-Definition).
20
+
21
+ import type {
22
+ TreeAction,
23
+ TreeChildrenSubscribe,
24
+ TreeContext,
25
+ TreeNode,
26
+ TreeNodeState,
27
+ } from "@cosmicdrift/kumiko-framework/engine";
28
+ import { ChevronDown, ChevronRight, Plus } from "lucide-react";
29
+ import { type ReactNode, useEffect, useState } from "react";
30
+ import { cn } from "../lib/cn";
31
+ import { dispatchTarget } from "./target-resolver-stub";
32
+
33
+ // State → Tailwind-Klassen-Mapping. „filled" ist no-op (default-text).
34
+ // Restliche Werte signalisieren visuell: stub = leise, empty = stark
35
+ // gedimmt + italic, loading = pulse-animation, error = destruktiv-Farbe.
36
+ const STATE_CLASSES: Readonly<Record<TreeNodeState, string>> = {
37
+ filled: "",
38
+ stub: "opacity-55",
39
+ empty: "opacity-50 italic",
40
+ loading: "animate-pulse",
41
+ error: "text-destructive",
42
+ };
43
+
44
+ // TypeGuard für TreeChildrenSubscribe-Form. Nach `Array.isArray()`-check
45
+ // kann TS den Function-Branch nicht automatisch narrowen, daher dieser
46
+ // explizite Guard statt `as`-Cast (siehe Memory `[Type Assertions]` und
47
+ // build-target.ts:isArgsObject als Vorbild).
48
+ function isSubscribeFn(c: readonly TreeNode[] | TreeChildrenSubscribe): c is TreeChildrenSubscribe {
49
+ return typeof c === "function";
50
+ }
51
+
52
+ export type TreeNodeRendererProps = {
53
+ readonly node: TreeNode;
54
+ readonly ctx: TreeContext;
55
+ readonly path: string;
56
+ readonly expanded: ReadonlySet<string>;
57
+ readonly onToggle: (path: string) => void;
58
+ readonly depth?: number;
59
+ };
60
+
61
+ export function TreeNodeRenderer({
62
+ node,
63
+ ctx,
64
+ path,
65
+ expanded,
66
+ onToggle,
67
+ depth = 0,
68
+ }: TreeNodeRendererProps): ReactNode {
69
+ const isExpanded = expanded.has(path);
70
+ const hasChildren = node.children !== undefined;
71
+
72
+ // Dynamic-Children-Subscribe: nur wenn ausgeklappt UND Function-Form.
73
+ // null = noch nicht emitted (zeige loading), Array = letzter Emit.
74
+ //
75
+ // **Identity-Assumption** (TODO V.1.2 verifizieren mit echtem Provider):
76
+ // node.children muss als Function-Reference stabil über Re-Renders sein,
77
+ // sonst trigger der useEffect ständig unsubscribe+resubscribe ("re-
78
+ // subscribe-Storm"). Recommended-Pattern für Provider-Authors: Function
79
+ // als top-level-const oder useMemo'd, NICHT inline-Closure pro Emit.
80
+ // Bei first violation in V.1.2 entweder useMemo hier oder path-Cache
81
+ // im VisualTree-Top-Level — Entscheidung wenn realer Trigger sichtbar.
82
+ const [dynamicChildren, setDynamicChildren] = useState<readonly TreeNode[] | null>(null);
83
+ useEffect(() => {
84
+ if (!isExpanded) return;
85
+ if (node.children === undefined) return;
86
+ if (!isSubscribeFn(node.children)) return; // static-Array-Pfad in ChildrenView
87
+ const subscribe = node.children(ctx);
88
+ const unsubscribe = subscribe(setDynamicChildren);
89
+ return unsubscribe;
90
+ }, [isExpanded, node.children, ctx]);
91
+
92
+ const stateClass = STATE_CLASSES[node.state ?? "filled"];
93
+ const indentStyle = { paddingLeft: `${depth * 12 + 8}px` };
94
+
95
+ const handleRowClick = (): void => {
96
+ if (hasChildren) {
97
+ onToggle(path);
98
+ return;
99
+ }
100
+ if (node.target !== undefined) {
101
+ dispatchTarget(node.target);
102
+ }
103
+ };
104
+
105
+ return (
106
+ <div data-kumiko-tree-node={path}>
107
+ {/* Outer Row als <div role="button"> statt <button>: native <button>
108
+ darf laut HTML-Spec keine geschachtelten <button>-Children
109
+ enthalten — die HoverActions würden sonst ungültiges HTML
110
+ erzeugen. role+tabIndex+keyDown gibt äquivalente a11y. TODO V.1.2:
111
+ Arrow-key-navigation zwischen Tree-Siblings (ARIA-tree-Pattern). */}
112
+ {/* biome-ignore lint/a11y/useSemanticElements: nested <button> wäre invalid HTML — siehe HoverActions */}
113
+ <div
114
+ className={cn(
115
+ "group flex w-full items-center gap-1.5 py-1 pr-2 cursor-pointer hover:bg-accent/30 rounded-sm",
116
+ stateClass,
117
+ )}
118
+ style={indentStyle}
119
+ onClick={handleRowClick}
120
+ onKeyDown={(e) => {
121
+ if (e.key === "Enter" || e.key === " ") {
122
+ e.preventDefault();
123
+ handleRowClick();
124
+ }
125
+ }}
126
+ role="button"
127
+ tabIndex={0}
128
+ aria-expanded={hasChildren ? isExpanded : undefined}
129
+ >
130
+ <ChevronGlyph hasChildren={hasChildren} expanded={isExpanded} />
131
+ {node.icon !== undefined && (
132
+ <span aria-hidden className="size-3.5">
133
+ {node.icon}
134
+ </span>
135
+ )}
136
+ <span className="flex-1 truncate text-sm">{node.label}</span>
137
+ <HoverActions
138
+ actions={node.actions}
139
+ createAction={node.state === "empty" ? node.createAction : undefined}
140
+ />
141
+ </div>
142
+ {isExpanded && (
143
+ <ChildrenView
144
+ node={node}
145
+ ctx={ctx}
146
+ path={path}
147
+ expanded={expanded}
148
+ onToggle={onToggle}
149
+ depth={depth}
150
+ dynamicChildren={dynamicChildren}
151
+ />
152
+ )}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ function ChevronGlyph({
158
+ hasChildren,
159
+ expanded,
160
+ }: {
161
+ readonly hasChildren: boolean;
162
+ readonly expanded: boolean;
163
+ }): ReactNode {
164
+ if (!hasChildren) return <span aria-hidden className="size-3.5" />;
165
+ return expanded ? (
166
+ <ChevronDown aria-hidden className="size-3.5 shrink-0" />
167
+ ) : (
168
+ <ChevronRight aria-hidden className="size-3.5 shrink-0" />
169
+ );
170
+ }
171
+
172
+ function HoverActions({
173
+ actions,
174
+ createAction,
175
+ }: {
176
+ readonly actions?: readonly TreeAction[];
177
+ readonly createAction?: TreeAction;
178
+ }): ReactNode {
179
+ const has = (actions !== undefined && actions.length > 0) || createAction !== undefined;
180
+ if (!has) return null;
181
+ return (
182
+ <span className="invisible group-hover:visible flex items-center gap-1 shrink-0">
183
+ {createAction !== undefined && (
184
+ <ActionButton action={createAction} icon={<Plus className="size-3.5" />} />
185
+ )}
186
+ {actions?.map((a) => (
187
+ <ActionButton key={a.label} action={a} icon={<span aria-hidden>{a.icon}</span>} />
188
+ ))}
189
+ </span>
190
+ );
191
+ }
192
+
193
+ function ActionButton({
194
+ action,
195
+ icon,
196
+ }: {
197
+ readonly action: TreeAction;
198
+ readonly icon: ReactNode;
199
+ }): ReactNode {
200
+ return (
201
+ <button
202
+ type="button"
203
+ aria-label={action.label}
204
+ className="p-0.5 hover:bg-accent rounded"
205
+ onClick={(e) => {
206
+ // Stop the event so the parent-row's onClick (which would
207
+ // toggle / dispatch the row's own target) doesn't fire.
208
+ e.stopPropagation();
209
+ dispatchTarget(action.target);
210
+ }}
211
+ >
212
+ {icon}
213
+ </button>
214
+ );
215
+ }
216
+
217
+ function ChildrenView({
218
+ node,
219
+ ctx,
220
+ path,
221
+ expanded,
222
+ onToggle,
223
+ depth,
224
+ dynamicChildren,
225
+ }: {
226
+ readonly node: TreeNode;
227
+ readonly ctx: TreeContext;
228
+ readonly path: string;
229
+ readonly expanded: ReadonlySet<string>;
230
+ readonly onToggle: (path: string) => void;
231
+ readonly depth: number;
232
+ readonly dynamicChildren: readonly TreeNode[] | null;
233
+ }): ReactNode {
234
+ // Array.isArray narrow't TS automatisch auf readonly TreeNode[] — kein
235
+ // as-Cast nötig (Memory `[Type Assertions]`).
236
+ if (Array.isArray(node.children)) {
237
+ const children = node.children;
238
+ return (
239
+ <>
240
+ {children.map((child, idx) => {
241
+ // Path: idx als stabiler Disambiguator falls Provider doppelte
242
+ // Labels liefert (Provider-Bug, aber React-Keys müssen unique
243
+ // sein sonst silent state-corruption). Provider-Liefer-Order
244
+ // ist stabil — idx ist hier kein „array-shift"-Risk wie bei
245
+ // user-rearrangeable Lists.
246
+ const childPath = `${path}/${idx}-${child.label}`;
247
+ return (
248
+ <TreeNodeRenderer
249
+ key={childPath}
250
+ node={child}
251
+ ctx={ctx}
252
+ path={childPath}
253
+ expanded={expanded}
254
+ onToggle={onToggle}
255
+ depth={depth + 1}
256
+ />
257
+ );
258
+ })}
259
+ </>
260
+ );
261
+ }
262
+ // Dynamic-children-Pfad: noch nicht emitted → Lade-Zeile, dann Liste.
263
+ if (dynamicChildren === null) {
264
+ return (
265
+ <div
266
+ className="text-xs text-muted-foreground italic py-1"
267
+ style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
268
+ >
269
+ Lädt …
270
+ </div>
271
+ );
272
+ }
273
+ return (
274
+ <>
275
+ {dynamicChildren.map((child, idx) => {
276
+ // Selbe idx-Disambiguator-Logik wie ChildrenView static-Branch.
277
+ const childPath = `${path}/${idx}-${child.label}`;
278
+ return (
279
+ <TreeNodeRenderer
280
+ key={childPath}
281
+ node={child}
282
+ ctx={ctx}
283
+ path={childPath}
284
+ expanded={expanded}
285
+ onToggle={onToggle}
286
+ depth={depth + 1}
287
+ />
288
+ );
289
+ })}
290
+ </>
291
+ );
292
+ }
@@ -0,0 +1,238 @@
1
+ // @runtime client
2
+ // VisualTree — Top-Level-Component für `r.workspace({ navigation: "tree" })`-
3
+ // Workspaces. Ersetzt VisualTreeStub aus Phase 0 Schicht 3.
4
+ //
5
+ // **Pflichten** (visual-tree.md V.1.1-A):
6
+ // 1. Provider-Iteration via `useTreeProviders()`-Hook
7
+ // 2. Subscribe-Wiring pro Provider mit unsubscribe-Cleanup beim Unmount
8
+ // 3. Top-Level-Reihenfolge alphabetisch nach featureName
9
+ // 4. Children-Lazy-Load (TreeNodeRenderer subscribed wenn Knoten ausgeklappt)
10
+ // 5. localStorage-Persistenz für expanded-Set pro Workspace
11
+ //
12
+ // **Empty-State**: keine Provider registriert → Hint statt leerem
13
+ // `<aside>` (Memory `[Sicherheit > Convenience]`: explicit empty statt
14
+ // silent fallback). Apps die navigation:"tree" deklarieren aber keinen
15
+ // clientFeatures.treeProvider liefern, kriegen eine sichtbare Diagnose.
16
+ //
17
+ // **Tenant-Source V.1.1**: TreeContext.tenantId ist auf SYSTEM_TENANT_ID
18
+ // gepinnt. Provider die echten Tenant brauchen kommen V.1.2 (text-content)
19
+ // — dann wird der Tenant-Source via Auth-Layer / TenantContext / Prop
20
+ // verdrahtet. V.1.1 zeigt das Wiring durch, kein konkreter Tenant-Bedarf.
21
+ //
22
+ // Siehe visual-tree.md V.1.1-A.
23
+
24
+ import type {
25
+ TenantId,
26
+ TreeChildrenSubscribe,
27
+ TreeContext,
28
+ TreeNode,
29
+ } from "@cosmicdrift/kumiko-framework/engine";
30
+ import { type ReactNode, useEffect, useMemo, useState } from "react";
31
+ import { useTreeProviders } from "../app/tree-providers-context";
32
+ import { TreeNodeRenderer } from "./tree-node-renderer";
33
+
34
+ const EXPANDED_STORAGE_PREFIX = "kumiko:visual-tree:expanded:";
35
+
36
+ // V.1.1-Pin: TenantId für die Default-Tree-Context. Lokal als Branded-
37
+ // Type-Construction (Memory `[Type Assertions]`) statt Value-Import aus
38
+ // /engine — der engine-Barrel ist `runtime`-klassifiziert und ein client-
39
+ // Modul darf ihn nicht als Wert konsumieren. **Sync-Pflicht**: muss mit
40
+ // SYSTEM_TENANT_ID aus engine/types/identifiers.ts identisch bleiben;
41
+ // ein Mismatch zeigt sich als „falscher Tenant" in Provider-Requests.
42
+ // V.1.2 ersetzt den Pin durch echten Tenant-Source (TenantContext oder
43
+ // Auth-Layer) sobald der erste Provider tenant-spezifische Daten braucht.
44
+ const SYSTEM_TENANT_ID_PIN = "00000000-0000-4000-8000-000000000000" as TenantId;
45
+
46
+ const DEFAULT_TREE_CTX: TreeContext = Object.freeze({ tenantId: SYSTEM_TENANT_ID_PIN });
47
+
48
+ export type VisualTreeProps = {
49
+ /** Workspace-ID des aktiven `navigation:"tree"`-Workspaces. Wird als
50
+ * Schlüssel für localStorage-Persistenz verwendet (separates
51
+ * Expand-State pro Workspace, weil ein User mehrere Visual-Workspaces
52
+ * haben kann). */
53
+ readonly workspaceId: string;
54
+ };
55
+
56
+ export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
57
+ const providers = useTreeProviders();
58
+ const sortedProviders = useMemo(
59
+ () => [...providers.entries()].sort(([a], [b]) => a.localeCompare(b)),
60
+ [providers],
61
+ );
62
+
63
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => loadExpanded(workspaceId));
64
+
65
+ // localStorage-Persistenz: jeder Toggle persistiert sofort. Workspace-
66
+ // Switch lädt anderen Set neu (siehe 2nd useEffect).
67
+ useEffect(() => {
68
+ saveExpanded(workspaceId, expanded);
69
+ }, [workspaceId, expanded]);
70
+
71
+ // Workspace-Switch: expanded-Set neu laden (User hat anderen Tree-
72
+ // Workspace ausgewählt, dort gilt anderer Set).
73
+ useEffect(() => {
74
+ setExpanded(loadExpanded(workspaceId));
75
+ }, [workspaceId]);
76
+
77
+ const handleToggle = (path: string): void => {
78
+ setExpanded((prev) => {
79
+ const next = new Set(prev);
80
+ if (next.has(path)) {
81
+ next.delete(path);
82
+ } else {
83
+ next.add(path);
84
+ }
85
+ return next;
86
+ });
87
+ };
88
+
89
+ if (sortedProviders.length === 0) {
90
+ return <EmptyState />;
91
+ }
92
+
93
+ return (
94
+ <aside
95
+ aria-label="Visual Tree"
96
+ data-kumiko-layout="visual-tree"
97
+ className="flex flex-col text-sm overflow-y-auto"
98
+ >
99
+ {sortedProviders.map(([featureName, provider]) => (
100
+ <ProviderBranch
101
+ key={featureName}
102
+ featureName={featureName}
103
+ provider={provider}
104
+ ctx={DEFAULT_TREE_CTX}
105
+ expanded={expanded}
106
+ onToggle={handleToggle}
107
+ />
108
+ ))}
109
+ </aside>
110
+ );
111
+ }
112
+
113
+ // ProviderBranch — eine Sub-Section pro registrertem TreeProvider.
114
+ // Jeder Provider liefert eine readonly TreeNode[] über Subscribe; jeder
115
+ // Top-Level-Knoten wird via TreeNodeRenderer gerendert.
116
+ function ProviderBranch({
117
+ featureName,
118
+ provider,
119
+ ctx,
120
+ expanded,
121
+ onToggle,
122
+ }: {
123
+ readonly featureName: string;
124
+ readonly provider: TreeChildrenSubscribe;
125
+ readonly ctx: TreeContext;
126
+ readonly expanded: ReadonlySet<string>;
127
+ readonly onToggle: (path: string) => void;
128
+ }): ReactNode {
129
+ // null = noch nicht emitted (initial-Loading). Ein Provider der
130
+ // niemals emittet bleibt damit sichtbar als „lädt …" und nicht
131
+ // unsichtbar — Memory `[Sicherheit > Convenience]`.
132
+ const [nodes, setNodes] = useState<readonly TreeNode[] | null>(null);
133
+
134
+ useEffect(() => {
135
+ // TODO V.1.2: Subscribe-Error-Handling. Drei Error-Surfaces sind heute
136
+ // nicht abgedeckt: (1) provider(ctx) wirft synchron, (2) subscribe(emit)
137
+ // wirft synchron, (3) Provider-internes SSE/fetch wirft → emit nie
138
+ // gefeuert, „lädt …" bleibt unendlich. Fix-Shape hängt vom
139
+ // V.1.2-Consumer (text-content) ab — transient-network-blip vs.
140
+ // tenant-misconfig haben unterschiedliche Recovery-Pfade. Reload-
141
+ // Action im Knoten (state="error" + retry-Button) als minimaler
142
+ // Plan, aber ohne konkreten Failure-Mode-Anker keine richtige Spec.
143
+ const subscribe = provider(ctx);
144
+ const unsubscribe = subscribe(setNodes);
145
+ return unsubscribe;
146
+ }, [provider, ctx]);
147
+
148
+ if (nodes === null) {
149
+ return (
150
+ <div
151
+ data-kumiko-tree-branch={featureName}
152
+ data-kumiko-tree-state="loading"
153
+ className="text-xs text-muted-foreground italic px-2 py-1"
154
+ >
155
+ {featureName}: lädt …
156
+ </div>
157
+ );
158
+ }
159
+
160
+ return (
161
+ <div data-kumiko-tree-branch={featureName}>
162
+ {nodes.map((node, idx) => {
163
+ // Selbe idx-Disambiguator-Logik wie TreeNodeRenderer.ChildrenView.
164
+ const nodePath = `${featureName}/${idx}-${node.label}`;
165
+ return (
166
+ <TreeNodeRenderer
167
+ key={nodePath}
168
+ node={node}
169
+ ctx={ctx}
170
+ path={nodePath}
171
+ expanded={expanded}
172
+ onToggle={onToggle}
173
+ depth={0}
174
+ />
175
+ );
176
+ })}
177
+ </div>
178
+ );
179
+ }
180
+
181
+ function EmptyState(): ReactNode {
182
+ // <section> + aria-label + tabIndex=0 macht den Empty-State per Tab
183
+ // erreichbar — sonst wäre die Diagnose-Message für Keyboard-Nutzer
184
+ // unsichtbar/nicht-fokussierbar. <section> ist semantisch korrekt für
185
+ // „informational region", Biome akzeptiert tabIndex hier (im Gegensatz
186
+ // zu <aside> oder bare <div role="region">).
187
+ // TODO V.1.2: Volle Arrow-Key-Navigation zwischen Tree-Siblings (siehe
188
+ // ARIA-tree-Pattern). Heute nur Tab-Reach + native button-Tastatur.
189
+ return (
190
+ <section
191
+ aria-label="Visual Tree (no providers)"
192
+ data-kumiko-layout="visual-tree-empty"
193
+ className="p-4 text-sm text-muted-foreground"
194
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: Empty-State ist eine Diagnose-Region die Keyboard-Nutzer per Tab erreichen können müssen
195
+ tabIndex={0}
196
+ >
197
+ <p className="m-0 font-semibold">Keine Tree-Provider aktiv</p>
198
+ <p className="mt-2">
199
+ Dieser Workspace deklariert <code>navigation: "tree"</code>, aber kein registriertes
200
+ Client-Feature liefert einen <code>treeProvider</code>. Siehe{" "}
201
+ <code>
202
+ createKumikoApp({"{"}clientFeatures: [...]{"}"})
203
+ </code>
204
+ .
205
+ </p>
206
+ </section>
207
+ );
208
+ }
209
+
210
+ // localStorage-Helpers. Stille Failure bei Storage-Errors (Quota,
211
+ // Privacy-Mode) — Tree funktioniert dann ohne Persistenz, was ok ist.
212
+
213
+ function storageKey(workspaceId: string): string {
214
+ return `${EXPANDED_STORAGE_PREFIX}${workspaceId}`;
215
+ }
216
+
217
+ function loadExpanded(workspaceId: string): ReadonlySet<string> {
218
+ if (typeof window === "undefined") return new Set();
219
+ try {
220
+ const raw = window.localStorage.getItem(storageKey(workspaceId));
221
+ if (raw === null) return new Set();
222
+ const parsed = JSON.parse(raw) as unknown;
223
+ if (!Array.isArray(parsed)) return new Set();
224
+ return new Set(parsed.filter((p): p is string => typeof p === "string"));
225
+ } catch {
226
+ return new Set();
227
+ }
228
+ }
229
+
230
+ function saveExpanded(workspaceId: string, expanded: ReadonlySet<string>): void {
231
+ if (typeof window === "undefined") return;
232
+ try {
233
+ window.localStorage.setItem(storageKey(workspaceId), JSON.stringify([...expanded]));
234
+ } catch {
235
+ // Privacy-Mode / Quota-Errors → ignorieren, Tree läuft ohne
236
+ // Persistenz weiter.
237
+ }
238
+ }
@@ -33,10 +33,13 @@ import type { AccessRule } from "@cosmicdrift/kumiko-framework/ui-types";
33
33
  import type { AppSchema, FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
34
34
  import { qualifyNavId, toAppSchema, useNav } from "@cosmicdrift/kumiko-renderer";
35
35
  import { type ReactNode, useCallback, useLayoutEffect, useMemo } from "react";
36
+ import { useResolvers } from "../app/resolvers-context";
36
37
  import { AppLayout } from "./app-layout";
38
+ import { EditorPanel } from "./editor-panel";
37
39
  import { lastSegment, NavTree } from "./nav-tree";
38
40
  import { Sidebar } from "./sidebar";
39
41
  import { Topbar } from "./topbar";
42
+ import { VisualTree } from "./visual-tree";
40
43
  import { WorkspaceSwitcher } from "./workspace-switcher";
41
44
 
42
45
  export type WorkspaceShellUser = {
@@ -182,19 +185,42 @@ export function WorkspaceShell({
182
185
  <WorkspaceSwitcher workspaces={visible} activeId={activeId} onSelect={handleSelect} />
183
186
  );
184
187
 
188
+ // Sidebar-Content-Switch: workspace mit `navigation: "tree"` (opt-in)
189
+ // mountet die VisualTree-Component statt NavTree. Default (kein
190
+ // navigation gesetzt oder navigation="nav") behält das existing
191
+ // NavTree-Verhalten — kein Breaking-Change für Apps die Visual-Tree
192
+ // nicht aktivieren.
193
+ //
194
+ // VisualTree konsumiert TreeProviders via Context (siehe
195
+ // app/tree-providers-context.tsx) — App-Author registriert seine
196
+ // clientFeatures.treeProvider via createKumikoApp, kein zusätzlicher
197
+ // Prop hier nötig. Workspace-ID wird durchgereicht für die
198
+ // localStorage-Persistenz des expanded-Set.
199
+ // Siehe docs/plans/architecture/visual-tree.md A1 + V.1.1-A.
200
+ const resolvers = useResolvers();
201
+ const isTreeMode = activeWorkspace?.definition.navigation === "tree";
202
+
203
+ const sidebarContent =
204
+ activeWorkspace?.definition.navigation === "tree" ? (
205
+ <VisualTree workspaceId={activeWorkspace.definition.id} />
206
+ ) : (
207
+ <NavTree
208
+ schema={app}
209
+ {...(user !== undefined && { user })}
210
+ {...(allowedNavQns !== undefined && { allowedNavQns })}
211
+ />
212
+ );
213
+
185
214
  return (
186
215
  <AppLayout
187
216
  topbar={<Topbar start={brand} center={switcher || undefined} end={topbarActions} />}
188
217
  sidebar={
189
218
  <Sidebar {...(sidebarFooter !== undefined && { footer: sidebarFooter })}>
190
- <NavTree
191
- schema={app}
192
- {...(user !== undefined && { user })}
193
- {...(allowedNavQns !== undefined && { allowedNavQns })}
194
- />
219
+ {sidebarContent}
195
220
  </Sidebar>
196
221
  }
197
222
  >
223
+ {isTreeMode && <EditorPanel resolvers={resolvers} />}
198
224
  {children}
199
225
  </AppLayout>
200
226
  );