@cosmicdrift/kumiko-renderer-web 0.3.0 → 0.4.1

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,84 @@
1
1
  # @cosmicdrift/kumiko-renderer-web
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 010b410: feat(auth-email-password): "Bestätigungs-Mail erneut senden" im LoginScreen
8
+
9
+ LoginScreen bietet bei reason=email_not_verified jetzt einen Resend-Link
10
+ im Fehler-Banner — der existierende `requestEmailVerification`-Endpoint
11
+ wird direkt aufgerufen, der Banner wechselt nach Erfolg zum Info-Variant
12
+ ("Wir haben dir eine neue Bestätigungs-Mail geschickt.").
13
+
14
+ UX-Details:
15
+
16
+ - Bei 429 → inline-Hint "Bitte warte kurz und versuche es erneut."
17
+ - Bei Netzwerk/sonstigen Fehlern → inline-Hint "Konnte nicht senden."
18
+ - Anti-Typo-Gate: ändert der User die Email-Eingabe nach dem Login-Fail,
19
+ verschwindet der Resend-Link — sonst würde Resend silent-success an die
20
+ geänderte (potentiell typoed) Adresse gehen ohne User-Feedback.
21
+ - Andere Failure-Codes (invalid_credentials etc.) zeigen weiterhin keinen
22
+ Resend-Link.
23
+
24
+ i18n: 4 neue Keys (DE+EN) im `auth.login.resend*`-Namespace, additive.
25
+ Apps die ihre Translations override-en müssen nichts ändern.
26
+
27
+ Additive UI-Feature — keine API-Breaks, keine Schema-Migration.
28
+
29
+ - Updated dependencies [010b410]
30
+ - @cosmicdrift/kumiko-dispatcher-live@0.4.1
31
+ - @cosmicdrift/kumiko-headless@0.4.1
32
+ - @cosmicdrift/kumiko-renderer@0.4.1
33
+
34
+ ## 0.4.0
35
+
36
+ ### Minor Changes
37
+
38
+ - 825e7d2: Visual-Tree V.1.4 → V.1.6 — Feature-complete Editor + Folder-Hierarchy + Roving-tabindex.
39
+
40
+ **V.1.4** — explicit `folder?: string` Schema-Field auf text-block-entity. Slug bleibt
41
+ kebab-only validiert, Folder explizit gesetzt. Tree gruppiert via `groupBlocksByFolder`
42
+ (ersetzt `groupBlocksBySlugPrefix`). `Subscribe<T>` Signature um optional `emitError`
43
+ erweitert für explicit async-error-Pfade. ProviderBranch zeigt Error-Banner mit
44
+ Retry-Button. Drift-Test pinnt seedTextBlock-vs-set.write Slug-Validation.
45
+
46
+ **V.1.4b** — URL-State-Routing für Editor-Target via `nav.searchParams`. F5 + Back-Button
47
+ stellen den Editor-State wieder her. Format: `?t=text-content:edit&a_slug=...&a_lang=...`.
48
+ Plus `useDispatchTarget` hook ersetzt globalen `dispatchTarget` als empfohlenen Production-
49
+ Pfad (legacy bleibt für Test-Hooks).
50
+
51
+ **V.1.5** — Arrow-Key-Navigation (`<aside role="tree">`, ARIA-tree-Pattern) + SSE-driven
52
+ Tree-Refresh. `ClientFeatureDefinition.treeEntities?: string[]` listet Entity-Namen pro
53
+ Provider; live-events triggern provider-re-mount → Stale-Tree-state="stub"→"filled"
54
+ flippt nach save automatisch.
55
+
56
+ **V.1.5c+d** — Active-Node-Highlight (explicit blue + 2px border-l + scrollIntoView),
57
+ VS-Code-Polish (compact spacing, focus-visible, folder-icon-color text-amber, indent-
58
+ guides per ancestor-depth), Folder-Wrapper für legal-pages ("📁 Legal" + slug-first
59
+ Verschachtelung) und text-content ("📁 Content").
60
+
61
+ **V.1.6** — Multi-level Folder-Splitting (`folder="page/marketing"` → nested folders,
62
+ walk-or-create-pattern, folder/leaf-collision-tolerant). Roving-tabindex (nur focused-
63
+ treeitem hat tabIndex=0, Tab cyclt aus dem Tree raus).
64
+
65
+ 35/35 kumiko check PASS, 13/13 group-blocks + 22/22 text-content integration tests grün.
66
+ Browser + Keyboard lokal validated.
67
+
68
+ **Breaking**: `TreeContext` Type entfernt (V.1.2 SR2-Rip — war nie genutzt). Provider sind
69
+ session-bound: `TreeChildrenSubscribe = () => Subscribe<T>` statt `(ctx) => Subscribe<T>`.
70
+
71
+ **V.1.7-Followups**: useEffect-deps in VisualTree-focus-init (Performance), Cancellation-
72
+ Token in TreeProvider's fetch (emit-after-unmount-warning), inline-rename, drag-drop,
73
+ file-icons per slug-extension, parent-jump bei ArrowLeft auf collapsed-item.
74
+
75
+ ### Patch Changes
76
+
77
+ - Updated dependencies [825e7d2]
78
+ - @cosmicdrift/kumiko-dispatcher-live@0.4.0
79
+ - @cosmicdrift/kumiko-headless@0.4.0
80
+ - @cosmicdrift/kumiko-renderer@0.4.0
81
+
3
82
  ## 0.3.0
4
83
 
5
84
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.3.0",
20
- "@cosmicdrift/kumiko-headless": "0.3.0",
21
- "@cosmicdrift/kumiko-renderer": "0.3.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.4.1",
20
+ "@cosmicdrift/kumiko-headless": "0.4.1",
21
+ "@cosmicdrift/kumiko-renderer": "0.4.1",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -36,6 +36,18 @@
36
36
  "react-dom": "^19.2.6",
37
37
  "tailwind-merge": "^3.6.0"
38
38
  },
39
+ "peerDependencies": {
40
+ "@tailwindcss/cli": "^4.3.0",
41
+ "tailwindcss": "^4.3.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@tailwindcss/cli": {
45
+ "optional": false
46
+ },
47
+ "tailwindcss": {
48
+ "optional": false
49
+ }
50
+ },
39
51
  "devDependencies": {
40
52
  "@tailwindcss/cli": "^4.3.0",
41
53
  "@testing-library/react": "^16.3.2",
@@ -18,9 +18,9 @@
18
18
  // zeigt VisualTree mit beiden Provider-Beiträgen
19
19
  //
20
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.
21
+ // (Tree-Provider sind reine Client-Functions ohne Args). Echtes
22
+ // setupTestStack kommt mit V.1.2 wenn text-content's Slug-Liste durch
23
+ // die Server-Pipeline geht.
24
24
 
25
25
  import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
26
26
  import type { FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
@@ -66,6 +66,13 @@ export type ClientFeatureDefinition = {
66
66
  * features liefern beide Seiten konsistent. */
67
67
  readonly treeActions?: Readonly<Record<string, TreeActionDef>>;
68
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
+
69
76
  /** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
70
77
  * TreeNode mit target angeklickt wird, schlägt der EditorPanel das
71
78
  * Component hier nach und rendert es. Komponenten erhalten target
@@ -201,6 +201,7 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
201
201
  // den Provider eines Features durch den eines anderen überschreiben);
202
202
  // wir warnen einmal pro Kollision. Visual-Tree.md V.1.1-Distribution.
203
203
  const treeProviders = new Map<string, TreeChildrenSubscribe>();
204
+ const treeEntities = new Map<string, readonly string[]>();
204
205
  for (const f of clientFeatures) {
205
206
  if (f.treeProvider === undefined) continue;
206
207
  if (treeProviders.has(f.name)) {
@@ -210,6 +211,9 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
210
211
  );
211
212
  }
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
+ }
213
217
  }
214
218
 
215
219
  // Editor-Resolver aggregieren — keyed by "featureId:action". Gleiche
@@ -252,7 +256,7 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
252
256
  <LiveEventsProvider value={liveEvents}>
253
257
  <CustomScreensProvider value={customScreens}>
254
258
  <ColumnRenderersProvider value={columnRenderers}>
255
- <TreeProvidersProvider value={treeProviders}>
259
+ <TreeProvidersProvider value={treeProviders} entities={treeEntities}>
256
260
  <ResolversProvider resolvers={resolvers}>
257
261
  <ToastProvider>
258
262
  {stackWrappers(providers, stackWrappers(gates, screenNode))}
@@ -22,17 +22,36 @@ import type { TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine
22
22
  import { createContext, type ReactNode, useContext } from "react";
23
23
 
24
24
  const EMPTY_PROVIDERS: ReadonlyMap<string, TreeChildrenSubscribe> = new Map();
25
+ const EMPTY_ENTITIES: ReadonlyMap<string, readonly string[]> = new Map();
25
26
 
26
27
  const TreeProvidersContext =
27
28
  createContext<ReadonlyMap<string, TreeChildrenSubscribe>>(EMPTY_PROVIDERS);
28
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
+
29
35
  export type TreeProvidersProviderProps = {
30
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[]>;
31
40
  readonly children: ReactNode;
32
41
  };
33
42
 
34
- export function TreeProvidersProvider({ value, children }: TreeProvidersProviderProps): ReactNode {
35
- return <TreeProvidersContext.Provider value={value}>{children}</TreeProvidersContext.Provider>;
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
+ );
36
55
  }
37
56
 
38
57
  /** Hook für TreeProvider-Map-Konsumenten (VisualTree im WorkspaceShell).
@@ -42,3 +61,8 @@ export function TreeProvidersProvider({ value, children }: TreeProvidersProvider
42
61
  export function useTreeProviders(): ReadonlyMap<string, TreeChildrenSubscribe> {
43
62
  return useContext(TreeProvidersContext);
44
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,132 @@
1
+ import type {
2
+ ConfigCascade,
3
+ ConfigCascadeLevel,
4
+ ConfigScope,
5
+ ConfigValueSource,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
8
+ import type { ReactNode } from "react";
9
+ import { useState } from "react";
10
+
11
+ const SOURCE_I18N_KEY: Record<ConfigValueSource, string> = {
12
+ "user-row": "config.source.user",
13
+ "tenant-row": "config.source.tenant",
14
+ "system-row": "config.source.system",
15
+ "app-override": "config.source.appOverride",
16
+ computed: "config.source.computed",
17
+ default: "config.source.default",
18
+ missing: "config.source.missing",
19
+ };
20
+
21
+ const SOURCE_COLORS: Record<ConfigValueSource, string> = {
22
+ "user-row": "text-blue-600 bg-blue-50 border-blue-200",
23
+ "tenant-row": "text-green-600 bg-green-50 border-green-200",
24
+ "system-row": "text-purple-600 bg-purple-50 border-purple-200",
25
+ "app-override": "text-orange-600 bg-orange-50 border-orange-200",
26
+ computed: "text-teal-600 bg-teal-50 border-teal-200",
27
+ default: "text-gray-500 bg-gray-50 border-gray-200",
28
+ missing: "text-red-500 bg-red-50 border-red-200",
29
+ };
30
+
31
+ function SourceBadge({ source }: { source: ConfigValueSource }): ReactNode {
32
+ const t = useTranslation();
33
+ return (
34
+ <span
35
+ className={`inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${SOURCE_COLORS[source]}`}
36
+ >
37
+ {t(SOURCE_I18N_KEY[source])}
38
+ </span>
39
+ );
40
+ }
41
+
42
+ function formatValue(value: string | number | boolean | undefined, hasValue: boolean): string {
43
+ if (!hasValue || value === undefined) return "—";
44
+ return String(value);
45
+ }
46
+
47
+ function scopeToSource(scope: ConfigScope): ConfigValueSource {
48
+ if (scope === "user") return "user-row";
49
+ if (scope === "tenant") return "tenant-row";
50
+ return "system-row";
51
+ }
52
+
53
+ type ConfigCascadeViewProps = {
54
+ readonly cascade: ConfigCascade;
55
+ readonly screenScope: ConfigScope;
56
+ readonly onReset?: (key: string, scope: ConfigScope) => void;
57
+ readonly qualifiedKey?: string;
58
+ };
59
+
60
+ export function ConfigCascadeView({
61
+ cascade,
62
+ screenScope,
63
+ onReset,
64
+ qualifiedKey,
65
+ }: ConfigCascadeViewProps): ReactNode {
66
+ const t = useTranslation();
67
+ const [expanded, setExpanded] = useState(false);
68
+
69
+ // Safety net: callers should already filter malformed cascades, but
70
+ // a missing levels-array (e.g. from a partial mock) shouldn't crash
71
+ // the screen.
72
+ if (!Array.isArray(cascade?.levels)) return null;
73
+
74
+ const activeLevel = cascade.levels.find((l) => l.isActive);
75
+ const screenScopeSource = scopeToSource(screenScope);
76
+ const hasOverride = activeLevel?.source === screenScopeSource;
77
+
78
+ return (
79
+ <div className="mt-1 text-xs">
80
+ <button
81
+ type="button"
82
+ onClick={() => setExpanded(!expanded)}
83
+ className="flex items-center gap-1 text-gray-500 hover:text-gray-700 cursor-pointer"
84
+ >
85
+ <span className="text-[10px]">{expanded ? "▼" : "▶"}</span>
86
+ {activeLevel ? (
87
+ <>
88
+ <SourceBadge source={activeLevel.source} />
89
+ <span className="text-gray-400">
90
+ {formatValue(activeLevel.value, activeLevel.hasValue)}
91
+ </span>
92
+ </>
93
+ ) : (
94
+ <span className="text-gray-400">{t("config.cascade.noValue")}</span>
95
+ )}
96
+ </button>
97
+
98
+ {expanded ? (
99
+ <div className="mt-1 flex flex-col gap-0.5 pl-3 border-l-2 border-gray-100">
100
+ {cascade.levels.map((level) => (
101
+ <CascadeLevelRow key={level.source} level={level} />
102
+ ))}
103
+
104
+ {hasOverride && onReset && qualifiedKey ? (
105
+ <button
106
+ type="button"
107
+ onClick={() => onReset(qualifiedKey, screenScope)}
108
+ className="mt-1 self-start text-[10px] text-orange-500 hover:text-orange-700 cursor-pointer underline"
109
+ >
110
+ {t("config.cascade.resetTo", { scope: t(SOURCE_I18N_KEY[screenScopeSource]) })}
111
+ </button>
112
+ ) : null}
113
+ </div>
114
+ ) : null}
115
+ </div>
116
+ );
117
+ }
118
+
119
+ function CascadeLevelRow({ level }: { level: ConfigCascadeLevel }): ReactNode {
120
+ const t = useTranslation();
121
+ return (
122
+ <div
123
+ className={`flex items-center gap-1.5 ${level.isActive ? "font-medium" : "text-gray-400"}`}
124
+ >
125
+ <SourceBadge source={level.source} />
126
+ <span>{formatValue(level.value, level.hasValue)}</span>
127
+ {level.isActive ? (
128
+ <span className="text-[10px] text-gray-400">{t("config.cascade.activeMarker")}</span>
129
+ ) : null}
130
+ </div>
131
+ );
132
+ }
@@ -0,0 +1,36 @@
1
+ import type { ConfigValueSource } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { ReactNode } from "react";
3
+
4
+ const SOURCE_CONFIG: Record<ConfigValueSource, { label: string; bg: string; text: string }> = {
5
+ "user-row": { label: "User", bg: "#dbeafe", text: "#1e40af" },
6
+ "tenant-row": { label: "Tenant", bg: "#dcfce7", text: "#166534" },
7
+ "system-row": { label: "System", bg: "#f3e8ff", text: "#6b21a8" },
8
+ "app-override": { label: "Override", bg: "#ffedd5", text: "#9a3412" },
9
+ computed: { label: "Computed", bg: "#ccfbf1", text: "#115e59" },
10
+ default: { label: "Default", bg: "#f3f4f6", text: "#4b5563" },
11
+ missing: { label: "Missing", bg: "#fee2e2", text: "#991b1b" },
12
+ };
13
+
14
+ export function ConfigSourceBadge({ source }: { readonly source: ConfigValueSource }): ReactNode {
15
+ const cfg = SOURCE_CONFIG[source];
16
+
17
+ return (
18
+ <span
19
+ style={{
20
+ display: "inline-flex",
21
+ alignItems: "center",
22
+ padding: "0 6px",
23
+ fontSize: "11px",
24
+ fontWeight: 500,
25
+ lineHeight: "18px",
26
+ borderRadius: "4px",
27
+ backgroundColor: cfg.bg,
28
+ color: cfg.text,
29
+ marginLeft: "6px",
30
+ whiteSpace: "nowrap",
31
+ }}
32
+ >
33
+ {cfg.label}
34
+ </span>
35
+ );
36
+ }
@@ -1,9 +1,11 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { NavProvider } from "@cosmicdrift/kumiko-renderer";
4
5
  import { act, fireEvent, render, screen } from "@testing-library/react";
5
6
  import type { ReactNode } from "react";
6
7
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
8
+ import { useBrowserNavApi } from "../../app/nav";
7
9
  import { TreeProvidersProvider } from "../../app/tree-providers-context";
8
10
  import { setDispatchListener } from "../target-resolver-stub";
9
11
  import { VisualTree } from "../visual-tree";
@@ -45,7 +47,15 @@ function renderTree(
45
47
  providers: ReadonlyMap<string, TreeChildrenSubscribe>,
46
48
  ): ReturnType<typeof render> {
47
49
  function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
48
- return <TreeProvidersProvider value={providers}>{children}</TreeProvidersProvider>;
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
+ );
49
59
  }
50
60
  return render(<VisualTree workspaceId="test-ws" />, { wrapper: Wrapper });
51
61
  }
@@ -56,6 +66,9 @@ function renderTree(
56
66
  // Test-Isolation sauber ist. Production-Code nutzt nur die Standard-
57
67
  // Schnittstelle, daher transparent.
58
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, "", "/");
59
72
  const store = new Map<string, string>();
60
73
  Object.defineProperty(window, "localStorage", {
61
74
  configurable: true,
@@ -1,14 +1,23 @@
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.
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.
6
14
 
7
15
  import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { useNav } from "@cosmicdrift/kumiko-renderer";
8
17
  import { X } from "lucide-react";
9
18
  import type { ComponentType, ReactNode } from "react";
10
- import { useCallback, useEffect, useState } from "react";
11
- import { subscribeTargetDispatches } from "./target-resolver-stub";
19
+ import { useCallback, useMemo } from "react";
20
+ import { clearTargetSearchParams, parseTargetFromSearchParams } from "./target-url";
12
21
 
13
22
  export type ResolverComponent = ComponentType<{
14
23
  readonly target: TargetRef;
@@ -65,27 +74,31 @@ function EditorPanelInner({
65
74
  }
66
75
 
67
76
  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
- }, []);
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]);
76
82
 
77
83
  const handleClose = useCallback(() => {
78
- setTarget(undefined);
79
- }, []);
84
+ nav.setSearchParams(clearTargetSearchParams(nav.searchParams));
85
+ }, [nav]);
80
86
 
81
- if (target === undefined) return null;
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
+ }
82
97
 
98
+ function EditorEmptyState(): ReactNode {
83
99
  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} />
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>
89
102
  </div>
90
103
  );
91
104
  }
@@ -1,9 +1,9 @@
1
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().
2
+ // V.1.4b: URL-State-Bridge via useDispatchTarget-Hook. Production schreibt
3
+ // target in nav.searchParams (F5-recovery); Subscribe-Stream bleibt für
4
+ // Test-Hooks (setDispatchListener) und Apps die kein NavProvider haben.
5
5
  //
6
- // Dispatch-Priority:
6
+ // Dispatch-Priority (V.1.2):
7
7
  // 1. Test-Listener (setDispatchListener) — exklusiv, kein Production-
8
8
  // Subscriber läuft während Tests (Test-Isolation).
9
9
  // 2. Production-Subscriber (subscribeTargetDispatches) — alle
@@ -11,9 +11,17 @@
11
11
  // 3. Kein Test-Listener + keine Subscriber → console.debug fallback
12
12
  // (damit unhandled Klicks sichtbar bleiben).
13
13
  //
14
- // Siehe visual-tree.md V.1.2.
14
+ // **useDispatchTarget (V.1.4b)** ist der empfohlene Production-Pfad.
15
+ // TreeNodeRenderer ruft den Hook in seinem Click-Handler — er schreibt
16
+ // URL via nav.setSearchParams UND ruft den globalen dispatchTarget für
17
+ // Test-Listener-Kompatibilität.
18
+ //
19
+ // Siehe visual-tree.md V.1.2 + V.1.4b.
15
20
 
16
21
  import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { useNav } from "@cosmicdrift/kumiko-renderer";
23
+ import { useCallback } from "react";
24
+ import { serializeTarget } from "./target-url";
17
25
 
18
26
  type DispatchListener = (target: TargetRef) => void;
19
27
 
@@ -51,3 +59,17 @@ export function subscribeTargetDispatches(fn: DispatchListener): () => void {
51
59
  subscribers.delete(fn);
52
60
  };
53
61
  }
62
+
63
+ /** V.1.4b: empfohlener Production-Dispatch. Schreibt target in
64
+ * nav.searchParams (URL-State, F5-fähig) UND ruft dispatchTarget für
65
+ * Test-Listener + legacy-Subscribers. */
66
+ export function useDispatchTarget(): (target: TargetRef) => void {
67
+ const nav = useNav();
68
+ return useCallback(
69
+ (target: TargetRef) => {
70
+ nav.setSearchParams(serializeTarget(target, nav.searchParams));
71
+ dispatchTarget(target);
72
+ },
73
+ [nav],
74
+ );
75
+ }
@@ -0,0 +1,86 @@
1
+ // @runtime client
2
+ // URL-Bridge für Visual-Tree-Targets. Serialisiert TargetRef in
3
+ // Search-Params + parsed zurück.
4
+ //
5
+ // **URL-Shape**:
6
+ // ?t=<featureId>:<action>&a_<argKey1>=<value1>&a_<argKey2>=<value2>...
7
+ //
8
+ // Beispiel: text-content edit "imprint/de"
9
+ // ?t=text-content:edit&a_slug=imprint&a_lang=de
10
+ //
11
+ // **Warum prefix `a_`**: vermeidet Naming-Konflikt mit anderen Query-
12
+ // Params (Pagination, Sort, Filter). Plus klare Trennung target-meta
13
+ // (`t`) vs. action-args (`a_*`).
14
+ //
15
+ // **Warum nicht JSON-encoded**: URL bleibt lesbar + bookmark-fähig.
16
+ // JSON-base64 wäre robust für arbitrary Shapes aber unleserlich. arg-
17
+ // values sind heute nur primitive strings (text-content: slug, lang) —
18
+ // V.1.5 kann auf JSON wechseln wenn nested-args echten Bedarf zeigen.
19
+
20
+ import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
21
+
22
+ const TARGET_PARAM = "t";
23
+ const ARG_PREFIX = "a_";
24
+
25
+ /** Build a search-params update for `setSearchParams`. Clears all
26
+ * existing `a_*` keys plus `t` (so wechsel target nicht alte args
27
+ * liegen lässt). Returns null-value entries to clear, plus the new
28
+ * target entries. */
29
+ export function serializeTarget(
30
+ target: TargetRef,
31
+ currentParams: Readonly<Record<string, string>>,
32
+ ): Readonly<Record<string, string | null>> {
33
+ const updates: Record<string, string | null> = {};
34
+ // Clear all previous arg-keys (vermeidet stale args bei target-switch).
35
+ for (const key of Object.keys(currentParams)) {
36
+ if (key.startsWith(ARG_PREFIX)) updates[key] = null;
37
+ }
38
+ updates[TARGET_PARAM] = `${target.featureId}:${target.action}`;
39
+ if (target.args !== undefined) {
40
+ for (const [k, v] of Object.entries(target.args)) {
41
+ // V.1.4b: nur string-args. Numbers/booleans werden via String()
42
+ // koerziert; nested objects/arrays werden NICHT supported (würde
43
+ // JSON-encoded URL brauchen, siehe Header-Comment).
44
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
45
+ updates[`${ARG_PREFIX}${k}`] = String(v);
46
+ }
47
+ }
48
+ }
49
+ return updates;
50
+ }
51
+
52
+ /** Inverse — parse the current search-params back into a TargetRef.
53
+ * Returns undefined wenn der `t`-Key fehlt (kein active target). */
54
+ export function parseTargetFromSearchParams(
55
+ params: Readonly<Record<string, string>>,
56
+ ): TargetRef | undefined {
57
+ const t = params[TARGET_PARAM];
58
+ if (t === undefined || t === "") return undefined;
59
+ const sepIdx = t.indexOf(":");
60
+ if (sepIdx < 0) return undefined;
61
+ const featureId = t.slice(0, sepIdx);
62
+ const action = t.slice(sepIdx + 1);
63
+ if (featureId === "" || action === "") return undefined;
64
+
65
+ const args: Record<string, string> = {};
66
+ let hasArgs = false;
67
+ for (const [key, value] of Object.entries(params)) {
68
+ if (key.startsWith(ARG_PREFIX)) {
69
+ args[key.slice(ARG_PREFIX.length)] = value;
70
+ hasArgs = true;
71
+ }
72
+ }
73
+ return hasArgs ? { featureId, action, args } : { featureId, action };
74
+ }
75
+
76
+ /** Returns null-valued updates that clear target + all args. Used vom
77
+ * Close-Button + onUnmount. */
78
+ export function clearTargetSearchParams(
79
+ currentParams: Readonly<Record<string, string>>,
80
+ ): Readonly<Record<string, string | null>> {
81
+ const updates: Record<string, string | null> = { [TARGET_PARAM]: null };
82
+ for (const key of Object.keys(currentParams)) {
83
+ if (key.startsWith(ARG_PREFIX)) updates[key] = null;
84
+ }
85
+ return updates;
86
+ }
@@ -19,16 +19,30 @@
19
19
  // Siehe visual-tree.md V.1.1-C + A4 (TreeNode-Type-Definition).
20
20
 
21
21
  import type {
22
+ TargetRef,
22
23
  TreeAction,
23
24
  TreeChildrenSubscribe,
24
- TreeContext,
25
25
  TreeNode,
26
26
  TreeNodeState,
27
27
  } from "@cosmicdrift/kumiko-framework/engine";
28
- import { ChevronDown, ChevronRight, Plus } from "lucide-react";
29
- import { type ReactNode, useEffect, useState } from "react";
28
+ import { useNav } from "@cosmicdrift/kumiko-renderer";
29
+ import { ChevronDown, ChevronRight, File, Folder, Plus } from "lucide-react";
30
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
30
31
  import { cn } from "../lib/cn";
31
- import { dispatchTarget } from "./target-resolver-stub";
32
+ import { useDispatchTarget } from "./target-resolver-stub";
33
+ import { parseTargetFromSearchParams } from "./target-url";
34
+
35
+ // Icon-Registry (V.1.2-Stub): Provider liefern symbolische String-Keys
36
+ // (`node.icon = "folder"`), Renderer mappt auf das lucide-Component.
37
+ // Unknown Keys → kein Render (sauber leerer Slot, kein plain-string-
38
+ // Overlap im 14px-Container). V.1.3+ erweitert Registry um App-
39
+ // erweiterbare Custom-Icons; aktuelles Set deckt Tree-Folder/File-Bedarf
40
+ // vom V.1.2-Consumer (text-content groupBlocksBySlugPrefix → "folder")
41
+ // und legal-pages-Slugs (no icon set).
42
+ const NODE_ICONS: Readonly<Record<string, typeof Folder>> = {
43
+ folder: Folder,
44
+ file: File,
45
+ };
32
46
 
33
47
  // State → Tailwind-Klassen-Mapping. „filled" ist no-op (default-text).
34
48
  // Restliche Werte signalisieren visuell: stub = leise, empty = stark
@@ -49,22 +63,42 @@ function isSubscribeFn(c: readonly TreeNode[] | TreeChildrenSubscribe): c is Tre
49
63
  return typeof c === "function";
50
64
  }
51
65
 
66
+ // V.1.5c Target-Vergleich: TreeNode ist „active" wenn sein target dem
67
+ // aktiven Target aus der URL entspricht. Vergleich ist deep-shallow:
68
+ // featureId + action exakt, args als flat-Record mit shallow-equal
69
+ // (args sind heute nur primitives). null/undefined-tolerant.
70
+ function targetsEqual(a: TargetRef, b: TargetRef | undefined): boolean {
71
+ if (b === undefined) return false;
72
+ if (a.featureId !== b.featureId) return false;
73
+ if (a.action !== b.action) return false;
74
+ const aKeys = a.args ? Object.keys(a.args) : [];
75
+ const bKeys = b.args ? Object.keys(b.args) : [];
76
+ if (aKeys.length !== bKeys.length) return false;
77
+ for (const k of aKeys) {
78
+ if (a.args?.[k] !== b.args?.[k]) return false;
79
+ }
80
+ return true;
81
+ }
82
+
52
83
  export type TreeNodeRendererProps = {
53
84
  readonly node: TreeNode;
54
- readonly ctx: TreeContext;
55
85
  readonly path: string;
56
86
  readonly expanded: ReadonlySet<string>;
57
87
  readonly onToggle: (path: string) => void;
58
88
  readonly depth?: number;
89
+ /** V.1.6c Roving-tabindex: nur das focused-Treeitem hat tabIndex=0,
90
+ * alle anderen tabIndex=-1. Wenn undefined → kein item focused
91
+ * (transient bei mount, VisualTree useEffect setzt's). */
92
+ readonly focusedPath?: string;
59
93
  };
60
94
 
61
95
  export function TreeNodeRenderer({
62
96
  node,
63
- ctx,
64
97
  path,
65
98
  expanded,
66
99
  onToggle,
67
100
  depth = 0,
101
+ focusedPath,
68
102
  }: TreeNodeRendererProps): ReactNode {
69
103
  const isExpanded = expanded.has(path);
70
104
  const hasChildren = node.children !== undefined;
@@ -84,13 +118,32 @@ export function TreeNodeRenderer({
84
118
  if (!isExpanded) return;
85
119
  if (node.children === undefined) return;
86
120
  if (!isSubscribeFn(node.children)) return; // static-Array-Pfad in ChildrenView
87
- const subscribe = node.children(ctx);
121
+ const subscribe = node.children();
88
122
  const unsubscribe = subscribe(setDynamicChildren);
89
123
  return unsubscribe;
90
- }, [isExpanded, node.children, ctx]);
124
+ }, [isExpanded, node.children]);
91
125
 
92
126
  const stateClass = STATE_CLASSES[node.state ?? "filled"];
93
127
  const indentStyle = { paddingLeft: `${depth * 12 + 8}px` };
128
+ const dispatch = useDispatchTarget();
129
+ const nav = useNav();
130
+
131
+ // V.1.5c Active-Node-Highlight: TreeItem ist „selected" wenn sein
132
+ // target dem aktiven Target aus der URL entspricht. Vergleich via
133
+ // featureId+action+args (deep-shallow, args ist flat-Record). Plus
134
+ // scrollIntoView wenn active-Knoten nicht im Viewport ist (F5 mit
135
+ // tief im Tree liegendem Target → User sieht ihn nicht ohne Scroll).
136
+ const activeTarget = useMemo(
137
+ () => parseTargetFromSearchParams(nav.searchParams),
138
+ [nav.searchParams],
139
+ );
140
+ const isActive = node.target !== undefined && targetsEqual(node.target, activeTarget);
141
+ const rowRef = useRef<HTMLDivElement | null>(null);
142
+ useEffect(() => {
143
+ if (isActive && rowRef.current) {
144
+ rowRef.current.scrollIntoView({ block: "nearest", inline: "nearest" });
145
+ }
146
+ }, [isActive]);
94
147
 
95
148
  const handleRowClick = (): void => {
96
149
  if (hasChildren) {
@@ -98,22 +151,50 @@ export function TreeNodeRenderer({
98
151
  return;
99
152
  }
100
153
  if (node.target !== undefined) {
101
- dispatchTarget(node.target);
154
+ dispatch(node.target);
102
155
  }
103
156
  };
104
157
 
158
+ // VS-Code-style indent-guides: ein 1px vertikaler border per
159
+ // ancestor-depth. Outer wrapper ist relative + die Lines sind absolute
160
+ // mit position pro depth-step (12px); top=0 bottom=0 streckt sie über
161
+ // Row + alle children (rekursiv wrapper wrappt children mit drin).
162
+ const indentGuides =
163
+ depth > 0
164
+ ? Array.from({ length: depth }, (_, i) => (
165
+ <div
166
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable index = depth-level
167
+ key={i}
168
+ aria-hidden
169
+ className="absolute top-0 bottom-0 w-px bg-border/60 pointer-events-none"
170
+ style={{ left: `${i * 12 + 13}px` }}
171
+ />
172
+ ))
173
+ : null;
174
+
105
175
  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 */}
176
+ <div data-kumiko-tree-node={path} className="relative">
177
+ {indentGuides}
178
+ {/* Outer Row als <div role="treeitem">: V.1.5a ARIA-tree-Pattern.
179
+ role + tabIndex=0 + aria-expanded gibt Screenreader Tree-
180
+ Semantik. Arrow-Key-Navigation läuft auf dem aside-Container
181
+ via querySelectorAll('[role=treeitem]') siehe visual-tree.tsx.
182
+ div+role statt native button weil nested <button> in
183
+ HoverActions invalid HTML wäre. */}
113
184
  <div
185
+ ref={rowRef}
114
186
  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,
187
+ // VS-Code-ähnlich: compact spacing, full-row click-area,
188
+ // hover subtle, active filled. Active sticky über hover via
189
+ // class-order. focus-ring nur bei Keyboard (focus-visible).
190
+ "group flex w-full items-center gap-1.5 py-0.5 pr-2 cursor-pointer rounded-sm relative",
191
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
192
+ // Explicit VS-Code-Blau für active (statt theme-`bg-accent`,
193
+ // weil publicstatus's accent near-white ist → kaum sichtbar).
194
+ // border-l-2 als VS-Code-Marker-Bar links.
195
+ isActive
196
+ ? "bg-blue-100 dark:bg-blue-900/40 border-l-2 border-l-blue-500"
197
+ : cn("hover:bg-muted/60", stateClass),
117
198
  )}
118
199
  style={indentStyle}
119
200
  onClick={handleRowClick}
@@ -123,16 +204,29 @@ export function TreeNodeRenderer({
123
204
  handleRowClick();
124
205
  }
125
206
  }}
126
- role="button"
127
- tabIndex={0}
207
+ role="treeitem"
208
+ // V.1.6c Roving-tabindex: nur das focused-treeitem hat
209
+ // tabIndex=0, alle anderen tabIndex=-1. focusedPath=undefined
210
+ // ist transient (VisualTree useEffect setzt's post-mount auf
211
+ // erstes treeitem); während dieser Phase haben alle tabIndex=-1
212
+ // und Tab-Reach geht nicht durch — minimal-Window, akzeptabel.
213
+ tabIndex={focusedPath === path ? 0 : -1}
128
214
  aria-expanded={hasChildren ? isExpanded : undefined}
215
+ aria-selected={isActive ? true : undefined}
216
+ data-kumiko-tree-path={path}
217
+ data-kumiko-tree-has-children={hasChildren ? "true" : "false"}
218
+ data-kumiko-tree-active={isActive ? "true" : undefined}
129
219
  >
130
220
  <ChevronGlyph hasChildren={hasChildren} expanded={isExpanded} />
131
- {node.icon !== undefined && (
132
- <span aria-hidden className="size-3.5">
133
- {node.icon}
134
- </span>
135
- )}
221
+ {node.icon !== undefined &&
222
+ (() => {
223
+ const IconComponent = NODE_ICONS[node.icon];
224
+ if (IconComponent === undefined) return null;
225
+ // VS-Code-typische Icon-Color: folder yellow/amber, file
226
+ // muted. Plus fill für folder gibt geschlossenem-Folder-Look.
227
+ const iconColor = node.icon === "folder" ? "text-amber-500" : "text-muted-foreground";
228
+ return <IconComponent aria-hidden className={cn("size-3.5 shrink-0", iconColor)} />;
229
+ })()}
136
230
  <span className="flex-1 truncate text-sm">{node.label}</span>
137
231
  <HoverActions
138
232
  actions={node.actions}
@@ -142,12 +236,12 @@ export function TreeNodeRenderer({
142
236
  {isExpanded && (
143
237
  <ChildrenView
144
238
  node={node}
145
- ctx={ctx}
146
239
  path={path}
147
240
  expanded={expanded}
148
241
  onToggle={onToggle}
149
242
  depth={depth}
150
243
  dynamicChildren={dynamicChildren}
244
+ focusedPath={focusedPath}
151
245
  />
152
246
  )}
153
247
  </div>
@@ -197,6 +291,7 @@ function ActionButton({
197
291
  readonly action: TreeAction;
198
292
  readonly icon: ReactNode;
199
293
  }): ReactNode {
294
+ const dispatch = useDispatchTarget();
200
295
  return (
201
296
  <button
202
297
  type="button"
@@ -206,7 +301,7 @@ function ActionButton({
206
301
  // Stop the event so the parent-row's onClick (which would
207
302
  // toggle / dispatch the row's own target) doesn't fire.
208
303
  e.stopPropagation();
209
- dispatchTarget(action.target);
304
+ dispatch(action.target);
210
305
  }}
211
306
  >
212
307
  {icon}
@@ -216,20 +311,20 @@ function ActionButton({
216
311
 
217
312
  function ChildrenView({
218
313
  node,
219
- ctx,
220
314
  path,
221
315
  expanded,
222
316
  onToggle,
223
317
  depth,
224
318
  dynamicChildren,
319
+ focusedPath,
225
320
  }: {
226
321
  readonly node: TreeNode;
227
- readonly ctx: TreeContext;
228
322
  readonly path: string;
229
323
  readonly expanded: ReadonlySet<string>;
230
324
  readonly onToggle: (path: string) => void;
231
325
  readonly depth: number;
232
326
  readonly dynamicChildren: readonly TreeNode[] | null;
327
+ readonly focusedPath: string | undefined;
233
328
  }): ReactNode {
234
329
  // Array.isArray narrow't TS automatisch auf readonly TreeNode[] — kein
235
330
  // as-Cast nötig (Memory `[Type Assertions]`).
@@ -248,11 +343,11 @@ function ChildrenView({
248
343
  <TreeNodeRenderer
249
344
  key={childPath}
250
345
  node={child}
251
- ctx={ctx}
252
346
  path={childPath}
253
347
  expanded={expanded}
254
348
  onToggle={onToggle}
255
349
  depth={depth + 1}
350
+ focusedPath={focusedPath}
256
351
  />
257
352
  );
258
353
  })}
@@ -279,7 +374,6 @@ function ChildrenView({
279
374
  <TreeNodeRenderer
280
375
  key={childPath}
281
376
  node={child}
282
- ctx={ctx}
283
377
  path={childPath}
284
378
  expanded={expanded}
285
379
  onToggle={onToggle}
@@ -14,36 +14,26 @@
14
14
  // silent fallback). Apps die navigation:"tree" deklarieren aber keinen
15
15
  // clientFeatures.treeProvider liefern, kriegen eine sichtbare Diagnose.
16
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.
17
+ // **Tenant-Source**: Provider sind session-bound; Backend liest tenantId
18
+ // aus session bei jedem fetch/dispatch. V.1.1 hatte ein TreeContext-Arg
19
+ // mit hardcoded pinned tenantId, das vom einzigen Consumer (text-content)
20
+ // ignoriert wurde. SR2-Rip 2026-05-18: Dead-API entfernt. Tenant-aware
21
+ // Provider re-introduce wenn realer Bedarf (siehe tree-node.ts comment).
21
22
  //
22
23
  // Siehe visual-tree.md V.1.1-A.
23
24
 
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";
25
+ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
26
+ import { useLiveEvents } from "@cosmicdrift/kumiko-renderer";
27
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
28
+ import { useTreeEntities, useTreeProviders } from "../app/tree-providers-context";
32
29
  import { TreeNodeRenderer } from "./tree-node-renderer";
33
30
 
34
31
  const EXPANDED_STORAGE_PREFIX = "kumiko:visual-tree:expanded:";
35
32
 
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 });
33
+ // Stable-reference empty-list vermeidet useEffect-deps-Trigger durch
34
+ // jedes Render (sonst würde `[].length === 0` short-circuit doch der
35
+ // Identity-Vergleich subscribeLive-effect destabilisieren).
36
+ const EMPTY_ENTITY_LIST: readonly string[] = [];
47
37
 
48
38
  export type VisualTreeProps = {
49
39
  /** Workspace-ID des aktiven `navigation:"tree"`-Workspaces. Wird als
@@ -55,6 +45,7 @@ export type VisualTreeProps = {
55
45
 
56
46
  export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
57
47
  const providers = useTreeProviders();
48
+ const treeEntities = useTreeEntities();
58
49
  const sortedProviders = useMemo(
59
50
  () => [...providers.entries()].sort(([a], [b]) => a.localeCompare(b)),
60
51
  [providers],
@@ -62,6 +53,36 @@ export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
62
53
 
63
54
  const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => loadExpanded(workspaceId));
64
55
 
56
+ // V.1.6c Roving-tabindex: nur ein treeitem hat tabIndex=0, Tab cyclt
57
+ // damit aus dem Tree heraus statt durch alle Items. focusedPath
58
+ // tracked welches item current-focused ist; Arrow-Keys updaten es via
59
+ // native onFocus-event (siehe TreeNodeRenderer).
60
+ const [focusedPath, setFocusedPath] = useState<string | undefined>(undefined);
61
+ const asideRef = useRef<HTMLElement | null>(null);
62
+
63
+ // Beim ersten Render (oder content-Change) ist focusedPath
64
+ // undefined → ALLE Rows hätten tabIndex=-1 und Tab könnte Tree nicht
65
+ // betreten. Post-mount setzen wir das erste sichtbare treeitem als
66
+ // initial focus. Plus: wenn focusedPath einen Pfad zeigt der nicht
67
+ // mehr existiert (Tree-Refresh), fall back to first.
68
+ useEffect(() => {
69
+ if (asideRef.current === null) return;
70
+ const items = asideRef.current.querySelectorAll<HTMLElement>('[role="treeitem"]');
71
+ if (items.length === 0) return;
72
+ if (focusedPath === undefined) {
73
+ const firstPath = items[0]?.dataset["kumikoTreePath"];
74
+ if (firstPath !== undefined) setFocusedPath(firstPath);
75
+ return;
76
+ }
77
+ const stillExists = Array.from(items).some(
78
+ (el) => el.dataset["kumikoTreePath"] === focusedPath,
79
+ );
80
+ if (!stillExists) {
81
+ const firstPath = items[0]?.dataset["kumikoTreePath"];
82
+ if (firstPath !== undefined) setFocusedPath(firstPath);
83
+ }
84
+ });
85
+
65
86
  // localStorage-Persistenz: jeder Toggle persistiert sofort. Workspace-
66
87
  // Switch lädt anderen Set neu (siehe 2nd useEffect).
67
88
  useEffect(() => {
@@ -90,20 +111,99 @@ export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
90
111
  return <EmptyState />;
91
112
  }
92
113
 
114
+ // V.1.5a ARIA-Tree-Keyboard-Nav: arrow-keys navigieren zwischen
115
+ // sichtbaren treeitems via DOM-query (Source of Truth = was im DOM
116
+ // sichtbar ist, inkl. expand/collapse-State). Tab kommt aus dem Tree
117
+ // heraus (kein Trap); innerhalb wird Arrow-Key erwartet.
118
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>): void => {
119
+ const target = e.target as HTMLElement;
120
+ if (target.getAttribute("role") !== "treeitem") return;
121
+
122
+ const items = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[role="treeitem"]'));
123
+ const idx = items.indexOf(target);
124
+ if (idx < 0) return;
125
+ const path = target.dataset["kumikoTreePath"];
126
+ const hasChildren = target.dataset["kumikoTreeHasChildren"] === "true";
127
+ const isExpanded = target.getAttribute("aria-expanded") === "true";
128
+
129
+ switch (e.key) {
130
+ case "ArrowDown": {
131
+ if (idx < items.length - 1) {
132
+ e.preventDefault();
133
+ items[idx + 1]?.focus();
134
+ }
135
+ break;
136
+ }
137
+ case "ArrowUp": {
138
+ if (idx > 0) {
139
+ e.preventDefault();
140
+ items[idx - 1]?.focus();
141
+ }
142
+ break;
143
+ }
144
+ case "ArrowRight": {
145
+ if (hasChildren && !isExpanded && path !== undefined) {
146
+ e.preventDefault();
147
+ handleToggle(path);
148
+ } else if (hasChildren && isExpanded && idx < items.length - 1) {
149
+ // Already expanded → move to first child (next visible item
150
+ // ist by DOM-order der erste child).
151
+ e.preventDefault();
152
+ items[idx + 1]?.focus();
153
+ }
154
+ break;
155
+ }
156
+ case "ArrowLeft": {
157
+ if (hasChildren && isExpanded && path !== undefined) {
158
+ e.preventDefault();
159
+ handleToggle(path);
160
+ }
161
+ // V.1.5a: kein parent-jump (würde flat-list-traversal brauchen,
162
+ // ARIA-Tree-Pattern would expect that — geht V.1.5b mit roving-
163
+ // tabindex). Aktuell ArrowLeft auf collapsed-item: no-op.
164
+ break;
165
+ }
166
+ case "Home": {
167
+ e.preventDefault();
168
+ items[0]?.focus();
169
+ break;
170
+ }
171
+ case "End": {
172
+ e.preventDefault();
173
+ items[items.length - 1]?.focus();
174
+ break;
175
+ }
176
+ }
177
+ };
178
+
93
179
  return (
94
180
  <aside
181
+ ref={asideRef}
95
182
  aria-label="Visual Tree"
96
183
  data-kumiko-layout="visual-tree"
97
184
  className="flex flex-col text-sm overflow-y-auto"
185
+ // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: ARIA-tree pattern requires role=tree on container; <aside> is the right semantic outer element for a sidebar
186
+ role="tree"
187
+ onKeyDown={handleKeyDown}
188
+ onFocus={(e) => {
189
+ // Native focus auf einem treeitem (via Mouse-Click oder Arrow-
190
+ // Key's focus()) → setze focusedPath. Bubble-up vom child-row
191
+ // landet hier, e.target ist das treeitem.
192
+ const target = e.target as HTMLElement;
193
+ if (target.getAttribute("role") !== "treeitem") return;
194
+ const path = target.dataset["kumikoTreePath"];
195
+ if (path !== undefined && path !== focusedPath) setFocusedPath(path);
196
+ }}
98
197
  >
99
198
  {sortedProviders.map(([featureName, provider]) => (
100
199
  <ProviderBranch
101
200
  key={featureName}
102
201
  featureName={featureName}
103
202
  provider={provider}
104
- ctx={DEFAULT_TREE_CTX}
203
+ entities={treeEntities.get(featureName) ?? EMPTY_ENTITY_LIST}
105
204
  expanded={expanded}
106
205
  onToggle={handleToggle}
206
+ focusedPath={focusedPath}
107
207
  />
108
208
  ))}
109
209
  </aside>
@@ -113,37 +213,97 @@ export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
113
213
  // ProviderBranch — eine Sub-Section pro registrertem TreeProvider.
114
214
  // Jeder Provider liefert eine readonly TreeNode[] über Subscribe; jeder
115
215
  // Top-Level-Knoten wird via TreeNodeRenderer gerendert.
216
+ //
217
+ // **V.1.4 Subscribe-Error-Handling**: drei Error-Surfaces sind abgedeckt:
218
+ // 1. provider() throws synchron → try/catch im useEffect setzt error
219
+ // 2. subscribe(emit) throws synchron → same try/catch
220
+ // 3. Provider-interner SSE/fetch fail't async → Provider muss
221
+ // `emit({ error: ... })` rufen statt empty-emit. Heutige Convention:
222
+ // Provider können einen Marker-TreeNode mit state="error" emitten
223
+ // (siehe text-content client-plugin: catch + emit([])). Recovery-
224
+ // Pfad ist Retry-Button im error-banner, der `attempt` increments
225
+ // → useEffect re-fires (deps haben attempt drin) → provider neu
226
+ // aufgerufen.
116
227
  function ProviderBranch({
117
228
  featureName,
118
229
  provider,
119
- ctx,
230
+ entities,
120
231
  expanded,
121
232
  onToggle,
233
+ focusedPath,
122
234
  }: {
123
235
  readonly featureName: string;
124
236
  readonly provider: TreeChildrenSubscribe;
125
- readonly ctx: TreeContext;
237
+ readonly entities: readonly string[];
126
238
  readonly expanded: ReadonlySet<string>;
127
239
  readonly onToggle: (path: string) => void;
240
+ readonly focusedPath: string | undefined;
128
241
  }): ReactNode {
129
242
  // null = noch nicht emitted (initial-Loading). Ein Provider der
130
243
  // niemals emittet bleibt damit sichtbar als „lädt …" und nicht
131
244
  // unsichtbar — Memory `[Sicherheit > Convenience]`.
132
245
  const [nodes, setNodes] = useState<readonly TreeNode[] | null>(null);
246
+ const [error, setError] = useState<string | null>(null);
247
+ const [attempt, setAttempt] = useState(0);
248
+
249
+ // V.1.5b SSE-Tree-Refresh: subscribe live-events für die gelisteten
250
+ // entities, increment attempt → useEffect re-fires → provider re-mountet.
251
+ // Gleiche Mechanik wie der retry-button, daher single trigger.
252
+ const subscribeLive = useLiveEvents();
253
+ useEffect(() => {
254
+ if (entities.length === 0) return;
255
+ const unsubs = entities.map((entityName) =>
256
+ subscribeLive(entityName, () => setAttempt((n) => n + 1)),
257
+ );
258
+ return () => {
259
+ for (const u of unsubs) u();
260
+ };
261
+ }, [entities, subscribeLive]);
133
262
 
263
+ // `attempt` ist absichtlich in den deps: Retry-Button increments
264
+ // attempt → useEffect re-fires → provider neu aufgerufen. Biome's
265
+ // static-analysis sieht attempt nicht im body und meldet es als
266
+ // unnecessary — semantisch ist es der Trigger.
267
+ // biome-ignore lint/correctness/useExhaustiveDependencies: attempt triggers retry
134
268
  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]);
269
+ setError(null);
270
+ setNodes(null);
271
+ try {
272
+ const subscribe = provider();
273
+ try {
274
+ const unsubscribe = subscribe(setNodes, (e) =>
275
+ setError(e instanceof Error ? e.message : String(e)),
276
+ );
277
+ return unsubscribe;
278
+ } catch (e) {
279
+ setError(e instanceof Error ? e.message : "Subscribe fehlgeschlagen.");
280
+ }
281
+ } catch (e) {
282
+ setError(e instanceof Error ? e.message : "Provider-Init fehlgeschlagen.");
283
+ }
284
+ return undefined;
285
+ }, [provider, attempt]);
286
+
287
+ if (error !== null) {
288
+ return (
289
+ <div
290
+ data-kumiko-tree-branch={featureName}
291
+ data-kumiko-tree-state="error"
292
+ className="flex items-center gap-2 px-2 py-1 text-xs text-destructive"
293
+ >
294
+ <span className="flex-1">
295
+ {featureName}: {error}
296
+ </span>
297
+ <button
298
+ type="button"
299
+ onClick={() => setAttempt((n) => n + 1)}
300
+ className="rounded border border-destructive/40 px-2 py-0.5 hover:bg-destructive/10"
301
+ >
302
+ Neu laden
303
+ </button>
304
+ </div>
305
+ );
306
+ }
147
307
 
148
308
  if (nodes === null) {
149
309
  return (
@@ -166,11 +326,11 @@ function ProviderBranch({
166
326
  <TreeNodeRenderer
167
327
  key={nodePath}
168
328
  node={node}
169
- ctx={ctx}
170
329
  path={nodePath}
171
330
  expanded={expanded}
172
331
  onToggle={onToggle}
173
332
  depth={0}
333
+ focusedPath={focusedPath}
174
334
  />
175
335
  );
176
336
  })}
@@ -220,8 +220,7 @@ export function WorkspaceShell({
220
220
  </Sidebar>
221
221
  }
222
222
  >
223
- {isTreeMode && <EditorPanel resolvers={resolvers} />}
224
- {children}
223
+ {isTreeMode ? <EditorPanel resolvers={resolvers} /> : children}
225
224
  </AppLayout>
226
225
  );
227
226
  }
@@ -130,7 +130,16 @@ function DefaultBanner({
130
130
 
131
131
  // ---- Field (Label + Error) ----
132
132
 
133
- function DefaultField({ id, label, required, issues, children, testId }: FieldProps): ReactNode {
133
+ function DefaultField({
134
+ id,
135
+ label,
136
+ required,
137
+ issues,
138
+ labelAppendix,
139
+ fieldAppendix,
140
+ children,
141
+ testId,
142
+ }: FieldProps): ReactNode {
134
143
  const t = useTranslation();
135
144
  const hasError = issues !== undefined && issues.length > 0;
136
145
  return (
@@ -148,9 +157,11 @@ function DefaultField({ id, label, required, issues, children, testId }: FieldPr
148
157
  )}
149
158
  >
150
159
  {label}
160
+ {labelAppendix !== undefined && <>{labelAppendix}</>}
151
161
  {required === true && <span className="ml-0.5 text-destructive">*</span>}
152
162
  </LabelPrimitive.Root>
153
163
  {children}
164
+ {fieldAppendix !== undefined && <div className="mt-1">{fieldAppendix}</div>}
154
165
  {hasError && (
155
166
  <div
156
167
  role="alert"
@@ -1255,6 +1266,9 @@ function DefaultHeading({ variant = "page", children, testId }: HeadingProps): R
1255
1266
  );
1256
1267
  }
1257
1268
 
1269
+ import { ConfigCascadeView as DefaultConfigCascadeView } from "../components/config-cascade";
1270
+ import { ConfigSourceBadge as DefaultConfigSourceBadge } from "../components/config-source-badge";
1271
+
1258
1272
  export const defaultPrimitives: CorePrimitives = {
1259
1273
  Button: DefaultButton,
1260
1274
  Banner: DefaultBanner,
@@ -1268,4 +1282,6 @@ export const defaultPrimitives: CorePrimitives = {
1268
1282
  Text: DefaultText,
1269
1283
  Heading: DefaultHeading,
1270
1284
  Dialog: DefaultDialog,
1285
+ ConfigSourceBadge: DefaultConfigSourceBadge,
1286
+ ConfigCascadeView: DefaultConfigCascadeView,
1271
1287
  };