@cosmicdrift/kumiko-renderer-web 0.65.0 → 0.67.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.
Files changed (47) hide show
  1. package/package.json +6 -4
  2. package/src/__tests__/form-action-bar.test.tsx +50 -15
  3. package/src/__tests__/nav-tree.test.tsx +250 -16
  4. package/src/__tests__/primitives.test.tsx +9 -6
  5. package/src/__tests__/render-edit.test.tsx +6 -6
  6. package/src/__tests__/test-utils.tsx +21 -0
  7. package/src/__tests__/workspace-shell.test.tsx +21 -66
  8. package/src/app/__tests__/qualify-nav-provider-key.test.ts +27 -0
  9. package/src/app/client-plugin.tsx +12 -27
  10. package/src/app/create-app.tsx +31 -19
  11. package/src/app/nav-providers-context.tsx +56 -0
  12. package/src/index.ts +8 -0
  13. package/src/layout/app-layout.tsx +9 -2
  14. package/src/layout/default-app-shell.tsx +116 -33
  15. package/src/layout/nav-tree.tsx +491 -125
  16. package/src/layout/sidebar-brand.tsx +40 -0
  17. package/src/layout/sidebar-user.tsx +46 -0
  18. package/src/layout/sidebar.tsx +1 -1
  19. package/src/layout/target-resolver-stub.tsx +3 -5
  20. package/src/layout/workspace-shell.tsx +32 -34
  21. package/src/primitives/file-upload.tsx +118 -0
  22. package/src/primitives/index.tsx +314 -175
  23. package/src/styles.css +76 -50
  24. package/src/ui/avatar.tsx +110 -0
  25. package/src/ui/badge.tsx +49 -0
  26. package/src/ui/breadcrumb.tsx +110 -0
  27. package/src/ui/button.tsx +65 -0
  28. package/src/ui/card.tsx +93 -0
  29. package/src/ui/checkbox.tsx +33 -0
  30. package/src/ui/collapsible.tsx +34 -0
  31. package/src/ui/dropdown-menu.tsx +258 -0
  32. package/src/ui/input.tsx +22 -0
  33. package/src/ui/label.tsx +25 -0
  34. package/src/ui/select.tsx +191 -0
  35. package/src/ui/separator.tsx +29 -0
  36. package/src/ui/sheet.tsx +144 -0
  37. package/src/ui/sidebar.tsx +727 -0
  38. package/src/ui/skeleton.tsx +14 -0
  39. package/src/ui/table.tsx +117 -0
  40. package/src/ui/textarea.tsx +19 -0
  41. package/src/ui/tooltip.tsx +58 -0
  42. package/src/ui/use-mobile.ts +20 -0
  43. package/src/__tests__/visual-tree-integration.test.tsx +0 -314
  44. package/src/app/tree-providers-context.tsx +0 -68
  45. package/src/layout/__tests__/visual-tree.test.tsx +0 -303
  46. package/src/layout/tree-node-renderer.tsx +0 -386
  47. package/src/layout/visual-tree.tsx +0 -398
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.65.0",
3
+ "version": "0.67.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,14 +16,15 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.65.0",
20
- "@cosmicdrift/kumiko-headless": "0.65.0",
21
- "@cosmicdrift/kumiko-renderer": "0.65.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.67.0",
20
+ "@cosmicdrift/kumiko-headless": "0.67.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.67.0",
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",
25
25
  "@radix-ui/react-popover": "^1.1.15",
26
26
  "@radix-ui/react-select": "^2.2.6",
27
+ "@radix-ui/react-separator": "^1.1.7",
27
28
  "@radix-ui/react-slot": "^1.2.4",
28
29
  "@radix-ui/react-toast": "^1.2.15",
29
30
  "@radix-ui/react-tooltip": "^1.2.8",
@@ -31,6 +32,7 @@
31
32
  "clsx": "^2.1.1",
32
33
  "cmdk": "^1.1.1",
33
34
  "lucide-react": "^1.14.0",
35
+ "radix-ui": "^1.4.3",
34
36
  "react": "^19.2.6",
35
37
  "react-day-picker": "^10.0.0",
36
38
  "react-dom": "^19.2.6",
@@ -1,9 +1,7 @@
1
- // F2.4 (Bug-Bash-2): Die Sticky-Action-Bar spannte die volle Breite,
2
- // während der Form-Body auf max-w-2xl begrenzt ist — die Buttons
3
- // klebten am Fensterrand, optisch abgekoppelt vom Formular. Außerdem
4
- // wiederholte der Section-Header bei Single-Section-ActionForms den
5
- // Screen-Titel 1:1. Strukturelle Assertions (Klassen/DOM) — der
6
- // visuelle Beweis läuft über die publicstatus-Screens nach dem Bump.
1
+ // shadcn-Form-Muster: die Action-Buttons sitzen als Footer am ENDE des
2
+ // Formulars (border-t-getrennt, rechtsbündig, im max-w-Body), NICHT mehr
3
+ // als Sticky-Bar im Header. Der Titel ist ein Heading oben. Strukturelle
4
+ // Assertions (Klassen/DOM) — der visuelle Beweis läuft über den Runner.
7
5
 
8
6
  import { describe, expect, test } from "bun:test";
9
7
  import type {
@@ -16,20 +14,21 @@ import { createMockDispatcher, render, screen } from "./test-utils";
16
14
 
17
15
  const { Form, Section, Button } = defaultPrimitives;
18
16
 
19
- describe("DefaultForm Action-Bar", () => {
20
- test("Bar-Inhalt aligned mit dem Form-Body (max-w-Container in der Bar)", () => {
17
+ describe("DefaultForm Action-Footer", () => {
18
+ test("Actions sitzen rechtsbündig am Form-Ende (border-t), Titel ist Heading", () => {
21
19
  render(
22
20
  <Form onSubmit={() => {}} title="Titel" actions={<Button>Save</Button>} testId="f">
23
21
  <div>body</div>
24
22
  </Form>,
25
23
  );
26
- const bar = screen.getByTestId("f-actions");
27
- const inner = bar.firstElementChild;
28
- expect(inner).toBeTruthy();
29
- // Gleiche Breiten-Constraint wie der Body (max-w-2xl + px-6) — die
30
- // Buttons enden damit an derselben Linie wie die Formularfelder.
31
- expect(inner?.className).toContain("max-w-2xl");
32
- expect(inner?.className).toContain("px-6");
24
+ const actions = screen.getByTestId("f-actions");
25
+ expect(actions.className).toContain("justify-end");
26
+ expect(actions.className).toContain("border-t");
27
+ // Footer liegt im max-w-3xl-Form-Body (Buttons enden an der Feld-Linie).
28
+ expect(actions.closest(".max-w-3xl")).toBeTruthy();
29
+ // Titel ist ein eigenes Heading oben, NICHT mehr in der Action-Bar.
30
+ expect(actions.textContent).not.toContain("Titel");
31
+ expect(screen.getByTestId("f-title").textContent).toBe("Titel");
33
32
  });
34
33
  });
35
34
 
@@ -44,6 +43,42 @@ describe("DefaultSection ohne Titel", () => {
44
43
  });
45
44
  });
46
45
 
46
+ describe("Form = eine Card, Sections als innere Abschnitte", () => {
47
+ test("Section im Form trägt keine eigene Card-Fläche, Titel+Footer leben in derselben Card", () => {
48
+ render(
49
+ <Form onSubmit={() => {}} title="Titel" actions={<Button>Save</Button>} testId="f">
50
+ <Section testId="s1">
51
+ <div>a</div>
52
+ </Section>
53
+ <Section testId="s2">
54
+ <div>b</div>
55
+ </Section>
56
+ </Form>,
57
+ );
58
+ // Inner-Region: keine eigene bg-card; die Divider zwischen Sections
59
+ // macht der divide-y-Wrapper, nicht die Section selbst.
60
+ const s1 = screen.getByTestId("s1");
61
+ expect(s1.className).not.toContain("bg-card");
62
+ expect(s1.closest(".divide-y")).toBeTruthy();
63
+ // Das Form wrappt alles in GENAU eine Card-Fläche.
64
+ const card = screen.getByTestId("f").querySelector(".bg-card");
65
+ expect(card).toBeTruthy();
66
+ expect(card?.querySelectorAll(".bg-card").length).toBe(0);
67
+ // Titel + Action-Footer sitzen in dieser Card.
68
+ expect(card?.querySelector("[data-testid='f-title']")).toBeTruthy();
69
+ expect(card?.querySelector("[data-testid='f-actions']")).toBeTruthy();
70
+ });
71
+
72
+ test("Section standalone (außerhalb Form) bleibt eine eigene Card", () => {
73
+ render(
74
+ <Section testId="solo" title="Solo">
75
+ <div>x</div>
76
+ </Section>,
77
+ );
78
+ expect(screen.getByTestId("solo").className).toContain("bg-card");
79
+ });
80
+ });
81
+
47
82
  const orderEntity = {
48
83
  fields: { title: { type: "text", required: true } },
49
84
  } as unknown as EntityDefinition; // @cast-boundary test-fixture
@@ -5,10 +5,20 @@
5
5
  // 2. Active-State greift auf node mit screen wenn nav.route's
6
6
  // screenId matcht (Standard-Sidebar-Verhalten).
7
7
 
8
- import { describe, expect, test } from "bun:test";
9
- import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import type {
10
+ TargetRef,
11
+ TreeChildrenSubscribe,
12
+ TreeNode,
13
+ } from "@cosmicdrift/kumiko-framework/engine";
14
+ import type { FeatureSchema, LiveEventSubscriber } from "@cosmicdrift/kumiko-renderer";
15
+ import { LiveEventsProvider } from "@cosmicdrift/kumiko-renderer";
16
+ import { act } from "@testing-library/react";
17
+ import type { ReactNode } from "react";
18
+ import { NavProvidersProvider } from "../app/nav-providers-context";
10
19
  import { NavTree } from "../layout/nav-tree";
11
- import { fireEvent, render, screen } from "./test-utils";
20
+ import { setDispatchListener } from "../layout/target-resolver-stub";
21
+ import { fireEvent, renderWithSidebar as render, screen } from "./test-utils";
12
22
 
13
23
  function makeSchema(): FeatureSchema {
14
24
  return {
@@ -129,13 +139,13 @@ describe("NavTree role-gating", () => {
129
139
  });
130
140
 
131
141
  describe("NavTree", () => {
132
- test("Section-Header (parent ohne screen) rendert children eingerückt default expanded", () => {
142
+ test("Section-Header (parent ohne screen) ist statisches Label, children sichtbar", () => {
133
143
  render(<NavTree schema={makeSchema()} testId="tree" />);
134
144
 
135
- // Section "Data" ist als Toggle-Button gerendert (uppercase).
136
- const dataHeader = screen.getByText("Data").closest("button") as HTMLButtonElement;
137
- expect(dataHeader).not.toBeNull();
138
- expect(dataHeader.getAttribute("aria-expanded")).toBe("true");
145
+ // Section "Data" ist eine STATISCHE Überschrift (sidebar-07-Muster), kein
146
+ // Toggle-Button Collapse gehört auf Items mit children, nicht die Section.
147
+ expect(screen.getByText("Data")).toBeTruthy();
148
+ expect(screen.getByText("Data").closest("button")).toBeNull();
139
149
 
140
150
  // Children sind sichtbar im DOM.
141
151
  expect(screen.getByText("Items")).toBeTruthy();
@@ -143,16 +153,14 @@ describe("NavTree", () => {
143
153
  expect(screen.getByText("Backlog")).toBeTruthy();
144
154
  });
145
155
 
146
- test("Click auf Section-Header toggled aria-expanded — children verschwinden", () => {
156
+ test("Section-Header collapst NICHT — children bleiben sichtbar", () => {
147
157
  render(<NavTree schema={makeSchema()} testId="tree" />);
148
158
 
149
- const dataHeader = screen.getByText("Data").closest("button") as HTMLButtonElement;
150
- fireEvent.click(dataHeader);
151
-
152
- expect(dataHeader.getAttribute("aria-expanded")).toBe("false");
153
- // Items ist child von Data → nach Collapse nicht mehr im DOM
154
- expect(screen.queryByText("Items")).toBeNull();
155
- expect(screen.queryByText("Active")).toBeNull();
159
+ // Kein Section-Toggle: "Data" sitzt nicht in einem Button, es gibt nichts
160
+ // zu klicken. Die Items bleiben dauerhaft sichtbar.
161
+ expect(screen.getByText("Data").closest("button")).toBeNull();
162
+ expect(screen.getByText("Items")).toBeTruthy();
163
+ expect(screen.getByText("Active")).toBeTruthy();
156
164
  });
157
165
 
158
166
  test("Parent mit Screen + children — Chevron-Click toggled, ohne Navigation", () => {
@@ -210,3 +218,229 @@ describe("NavTree", () => {
210
218
  expect(container.querySelectorAll("svg").length).toBe(0);
211
219
  });
212
220
  });
221
+
222
+ // ── Visual-Tree-Merge: dynamische Knoten in der EINEN Nav ──────────────
223
+ //
224
+ // Beweist die vier Caps die NavTree aus dem alten VisualTree übernimmt:
225
+ // target-Dispatch, lazy Provider-Children, createAction (+), und — der
226
+ // kritische Pfad — der SSE-treeEntities-Refresh, der neue Knoten LIVE in
227
+ // die Nav bringt (sonst lädt der Tree einmal und „+pricing" erscheint nie).
228
+
229
+ type LiveCb = Parameters<LiveEventSubscriber>[1];
230
+
231
+ function controllableLiveEvents(): {
232
+ readonly subscribe: LiveEventSubscriber;
233
+ fire(entity: string): void;
234
+ } {
235
+ const listeners = new Map<string, Set<LiveCb>>();
236
+ const subscribe: LiveEventSubscriber = (entity, cb) => {
237
+ const set = listeners.get(entity) ?? new Set<LiveCb>();
238
+ set.add(cb);
239
+ listeners.set(entity, set);
240
+ return () => {
241
+ set.delete(cb);
242
+ };
243
+ };
244
+ return {
245
+ subscribe,
246
+ fire(entity) {
247
+ // Die Consumer-cbs (NavTree, prod) ignorieren das Event-Arg → zero-arg
248
+ // call genügt; Cast überbrückt nur die Param-Signatur.
249
+ for (const cb of listeners.get(entity) ?? []) (cb as () => void)();
250
+ },
251
+ };
252
+ }
253
+
254
+ function pageLeaf(slug: string): TreeNode {
255
+ return { label: slug, target: { featureId: "cms", action: "edit", args: { slug } } };
256
+ }
257
+
258
+ // Provider mit `provider: true`-Knoten "Content" + „+"-createAction. QN =
259
+ // "cms:nav:content" (featureName:nav:id). Der Provider liefert die Children.
260
+ function dynamicSchema(): FeatureSchema {
261
+ return {
262
+ featureName: "cms",
263
+ entities: {},
264
+ screens: [],
265
+ navs: [
266
+ {
267
+ id: "content",
268
+ label: "Content",
269
+ order: 10,
270
+ provider: true,
271
+ createAction: {
272
+ icon: "plus",
273
+ label: "New page",
274
+ target: { featureId: "cms", action: "create", args: { folder: "" } },
275
+ },
276
+ },
277
+ ],
278
+ } as FeatureSchema;
279
+ }
280
+
281
+ function renderDynamic(args: {
282
+ readonly schema: FeatureSchema;
283
+ readonly providers: ReadonlyMap<string, TreeChildrenSubscribe>;
284
+ readonly entities?: ReadonlyMap<string, readonly string[]>;
285
+ readonly live?: LiveEventSubscriber;
286
+ }): ReturnType<typeof render> {
287
+ const inner: ReactNode = (
288
+ <NavProvidersProvider
289
+ value={args.providers}
290
+ {...(args.entities && { entities: args.entities })}
291
+ >
292
+ <NavTree schema={args.schema} />
293
+ </NavProvidersProvider>
294
+ );
295
+ // Eigene (kontrollierbare) LiveEventsProvider überschreibt den No-op aus
296
+ // den DefaultProviders (nächster Provider gewinnt).
297
+ return render(
298
+ args.live !== undefined ? (
299
+ <LiveEventsProvider value={args.live}>{inner}</LiveEventsProvider>
300
+ ) : (
301
+ inner
302
+ ),
303
+ );
304
+ }
305
+
306
+ describe("NavTree dynamic provider nodes", () => {
307
+ let restoreDispatch: (() => void) | undefined;
308
+ afterEach(() => {
309
+ restoreDispatch?.();
310
+ restoreDispatch = undefined;
311
+ });
312
+
313
+ test("provider:true-Knoten lädt seine Children lazy + rendert sie (default-expanded)", async () => {
314
+ const provider: TreeChildrenSubscribe = () => (emit) => {
315
+ emit([pageLeaf("apex"), pageLeaf("hero")]);
316
+ return () => {};
317
+ };
318
+ const providers = new Map([["cms:nav:content", provider]]);
319
+ await act(async () => {
320
+ renderDynamic({ schema: dynamicSchema(), providers });
321
+ });
322
+
323
+ expect(screen.getByText("Content")).toBeTruthy();
324
+ expect(screen.getByText("apex")).toBeTruthy();
325
+ expect(screen.getByText("hero")).toBeTruthy();
326
+ });
327
+
328
+ test("createAction rendert ein Plus-Button der sein target dispatcht", async () => {
329
+ let dispatched: TargetRef | undefined;
330
+ restoreDispatch = setDispatchListener((t) => {
331
+ dispatched = t;
332
+ });
333
+ const provider: TreeChildrenSubscribe = () => (emit) => {
334
+ emit([pageLeaf("apex")]);
335
+ return () => {};
336
+ };
337
+ await act(async () => {
338
+ renderDynamic({
339
+ schema: dynamicSchema(),
340
+ providers: new Map([["cms:nav:content", provider]]),
341
+ });
342
+ });
343
+
344
+ fireEvent.click(screen.getByRole("button", { name: "New page" }));
345
+ expect(dispatched).toEqual({ featureId: "cms", action: "create", args: { folder: "" } });
346
+ });
347
+
348
+ test("target-Knoten dispatcht beim Klick (statt Route-Link)", async () => {
349
+ let dispatched: TargetRef | undefined;
350
+ restoreDispatch = setDispatchListener((t) => {
351
+ dispatched = t;
352
+ });
353
+ const schema = {
354
+ featureName: "cms",
355
+ entities: {},
356
+ screens: [],
357
+ navs: [
358
+ {
359
+ id: "hero",
360
+ label: "Hero",
361
+ order: 10,
362
+ target: { featureId: "cms", action: "edit", args: { slug: "hero" } },
363
+ },
364
+ ],
365
+ } as FeatureSchema;
366
+ await act(async () => {
367
+ renderDynamic({ schema, providers: new Map() });
368
+ });
369
+
370
+ fireEvent.click(screen.getByText("Hero"));
371
+ expect(dispatched).toEqual({ featureId: "cms", action: "edit", args: { slug: "hero" } });
372
+ });
373
+
374
+ test("SSE-treeEntities-Refresh bringt neu erstellte Knoten LIVE in die Nav", async () => {
375
+ // Der eigentliche Kern: ein text-block-Event re-fired den Provider →
376
+ // die frisch erstellte Seite („pricing") erscheint, ohne Re-Mount.
377
+ let pages = ["apex", "hero"];
378
+ const provider: TreeChildrenSubscribe = () => (emit) => {
379
+ emit(pages.map(pageLeaf));
380
+ return () => {};
381
+ };
382
+ const live = controllableLiveEvents();
383
+ await act(async () => {
384
+ renderDynamic({
385
+ schema: dynamicSchema(),
386
+ providers: new Map([["cms:nav:content", provider]]),
387
+ entities: new Map([["cms:nav:content", ["text-block"]]]),
388
+ live: live.subscribe,
389
+ });
390
+ });
391
+
392
+ expect(screen.getByText("apex")).toBeTruthy();
393
+ expect(screen.queryByText("pricing")).toBeNull();
394
+
395
+ // Neue Seite angelegt → text-block-Event feuert → Provider re-fired.
396
+ pages = ["apex", "hero", "pricing"];
397
+ await act(async () => {
398
+ live.fire("text-block");
399
+ });
400
+
401
+ expect(screen.getByText("pricing")).toBeTruthy();
402
+ });
403
+
404
+ test("Provider-Subscription wird beim Re-Fire + Unmount sauber abgebaut (kein Leak)", async () => {
405
+ // Jeder subscribe() liefert eine cleanup-Funktion. Invariante: zu jedem
406
+ // Zeitpunkt darf höchstens EINE Subscription aktiv sein. Beim SSE-Re-Fire
407
+ // muss die vorherige abgebaut werden BEVOR neu subscribed wird (sonst
408
+ // akkumulieren sie = Leak); beim Unmount läuft die letzte cleanup. Heute
409
+ // liefern alle Provider no-op-cleanup — dieser Test schützt den ersten
410
+ // mit echtem Teardown. (active() statt fixer Counts → robust gegen
411
+ // StrictMode-Doppel-Mount: net bleibt eine aktive Subscription.)
412
+ let subscribes = 0;
413
+ let unsubscribes = 0;
414
+ const active = (): number => subscribes - unsubscribes;
415
+ const provider: TreeChildrenSubscribe = () => (emit) => {
416
+ subscribes += 1;
417
+ emit([pageLeaf("apex")]);
418
+ return () => {
419
+ unsubscribes += 1;
420
+ };
421
+ };
422
+ const live = controllableLiveEvents();
423
+ let r: ReturnType<typeof render> | undefined;
424
+ await act(async () => {
425
+ r = renderDynamic({
426
+ schema: dynamicSchema(),
427
+ providers: new Map([["cms:nav:content", provider]]),
428
+ entities: new Map([["cms:nav:content", ["text-block"]]]),
429
+ live: live.subscribe,
430
+ });
431
+ });
432
+ expect(active()).toBe(1); // genau eine aktive Subscription nach Mount
433
+
434
+ const before = subscribes;
435
+ await act(async () => {
436
+ live.fire("text-block");
437
+ });
438
+ expect(subscribes).toBeGreaterThan(before); // Re-Fire hat neu subscribed
439
+ expect(active()).toBe(1); // …aber die alte vorher abgebaut → weiterhin eine
440
+
441
+ await act(async () => {
442
+ r?.unmount();
443
+ });
444
+ expect(active()).toBe(0); // Unmount baut alles ab → kein Leak
445
+ });
446
+ });
@@ -783,7 +783,7 @@ describe("Form", () => {
783
783
  expect(submitEvent.defaultPrevented).toBe(true);
784
784
  });
785
785
 
786
- test("title slot rendert in der Action-Bar links neben den Actions", () => {
786
+ test("title rendert als Heading oben, Actions als Footer am Ende", () => {
787
787
  render(
788
788
  <Form
789
789
  onSubmit={() => undefined}
@@ -794,9 +794,11 @@ describe("Form", () => {
794
794
  <div>content</div>
795
795
  </Form>,
796
796
  );
797
- const actionsBar = screen.getByTestId("form-actions");
798
- expect(actionsBar.textContent).toContain("Eintrag bearbeiten");
799
- expect(actionsBar.textContent).toContain("Save");
797
+ expect(screen.getByTestId("form-title").textContent).toContain("Eintrag bearbeiten");
798
+ const actionsFooter = screen.getByTestId("form-actions");
799
+ expect(actionsFooter.textContent).toContain("Save");
800
+ // Titel ist NICHT mehr in der Action-Leiste (raus aus dem Header).
801
+ expect(actionsFooter.textContent).not.toContain("Eintrag bearbeiten");
800
802
  });
801
803
 
802
804
  test("ohne title und actions: keine Action-Bar gerendert", () => {
@@ -856,7 +858,7 @@ describe("Heading variants", () => {
856
858
  });
857
859
 
858
860
  describe("DataTable toolbar slots", () => {
859
- test("toolbarTitle + toolbarStart + toolbarEnd rendern in einer Zeile", () => {
861
+ test("toolbarStart + toolbarEnd rendern in einer Zeile; Titel wird NICHT gerendert (steht im Breadcrumb)", () => {
860
862
  render(
861
863
  <DataTable
862
864
  columns={[]}
@@ -872,7 +874,8 @@ describe("DataTable toolbar slots", () => {
872
874
  />,
873
875
  );
874
876
  const toolbar = screen.getByTestId("dt-toolbar");
875
- expect(toolbar.textContent).toContain("Items");
877
+ // Der Screen-Titel lebt im Shell-Breadcrumb, nicht mehr in der Toolbar.
878
+ expect(toolbar.textContent).not.toContain("Items");
876
879
  expect(toolbar.querySelector('[data-testid="search"]')).not.toBeNull();
877
880
  expect(toolbar.querySelector('[data-testid="create"]')).not.toBeNull();
878
881
  });
@@ -118,10 +118,10 @@ describe("RenderEdit", () => {
118
118
  );
119
119
 
120
120
  expect(screen.queryByTestId("field-notes")).toBeNull();
121
- const urgentCheckbox = screen
122
- .getByTestId("field-isUrgent")
123
- .querySelector("input[type=checkbox]");
124
- fireEvent.click(urgentCheckbox as HTMLInputElement);
121
+ // boolean-Feld = vendored Radix-Checkbox → button[role=checkbox], kein
122
+ // native input[type=checkbox] mehr.
123
+ const urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
124
+ fireEvent.click(urgentCheckbox as HTMLElement);
125
125
  expect(screen.queryByTestId("field-notes")).toBeTruthy();
126
126
  });
127
127
 
@@ -178,8 +178,8 @@ describe("RenderEdit", () => {
178
178
  // Default-Translate (Test-Setup hat keinen Bundle für screen:*.title)
179
179
  // → i18n returnt den Key selber, RenderEdit detected das + zeigt
180
180
  // den screenId. Beweist die Convention: kein Hardcoded "Untitled".
181
- const actionsBar = screen.getByTestId("render-edit-form-actions");
182
- expect(actionsBar.textContent).toContain("orders:screen:order-edit");
181
+ const formTitle = screen.getByTestId("render-edit-form-title");
182
+ expect(formTitle.textContent).toContain("orders:screen:order-edit");
183
183
  });
184
184
 
185
185
  test("extension section renders the registered Component with entityName + entityId", () => {
@@ -34,6 +34,7 @@ import { render as _render, type RenderOptions, type RenderResult } from "@testi
34
34
  import type { ReactElement, ReactNode } from "react";
35
35
  import { defaultPrimitives } from "../primitives";
36
36
  import { defaultTokens } from "../tokens";
37
+ import { SidebarProvider } from "../ui/sidebar";
37
38
 
38
39
  // jsdom hat keinen ResizeObserver — cmdk (Combobox-Library) braucht
39
40
  // das im Setup. Stub reicht für unsere Tests; wir messen keine
@@ -86,10 +87,30 @@ function DefaultProviders({ children }: { readonly children: ReactNode }): React
86
87
  );
87
88
  }
88
89
 
90
+ // DefaultProviders + SidebarProvider. Nur für Tests die vendored shadcn-
91
+ // Sidebar-Teile (NavTree → SidebarMenuButton ruft useSidebar()) ISOLIERT
92
+ // rendern. Nicht der Default: SidebarProvider zieht TooltipProvider +
93
+ // keydown/matchMedia-Listener ein — global gewrappt verlangsamt/leakt das
94
+ // auf single-CPU-CI (Banner-Timeouts). Shells stellen ihren Provider selbst.
95
+ function SidebarProviders({ children }: { readonly children: ReactNode }): ReactNode {
96
+ return (
97
+ <DefaultProviders>
98
+ <SidebarProvider>{children}</SidebarProvider>
99
+ </DefaultProviders>
100
+ );
101
+ }
102
+
89
103
  export function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">): RenderResult {
90
104
  return _render(ui, { wrapper: DefaultProviders, ...options });
91
105
  }
92
106
 
107
+ export function renderWithSidebar(
108
+ ui: ReactElement,
109
+ options?: Omit<RenderOptions, "wrapper">,
110
+ ): RenderResult {
111
+ return _render(ui, { wrapper: SidebarProviders, ...options });
112
+ }
113
+
93
114
  // ---------------------------------------------------------------------------
94
115
  // Mock-Dispatcher-Helper
95
116
  // ---------------------------------------------------------------------------
@@ -772,16 +772,15 @@ describe("WorkspaceShell — AppSchema (multi-feature)", () => {
772
772
  });
773
773
 
774
774
  // ---------------------------------------------------------------------------
775
- // navigation-Mode-Switch Phase 0 Schicht 3 / V.1.
776
- // Workspace mit `navigation: "tree"` mountet den Visual-Tree-Stub statt
777
- // NavTree. Default (kein navigation oder navigation="nav") hält das
778
- // existing NavTree-Verhalten — kein Breaking-Change für Apps die Visual-
779
- // Tree nicht aktivieren. Echte Tree-Component kommt in V.1.1.
780
- // Siehe docs/plans/architecture/visual-tree.md A1.
775
+ // EINE Nav (Visual-Tree-Merge). Der frühere navigation:"tree"/VisualTree-
776
+ // Zweig ist gefaltet: WorkspaceShell rendert IMMER NavTree. Die
777
+ // navigation-Property ist seitdem ein No-op (deferred removal aus
778
+ // WorkspaceDefinition). Im Content-Bereich ersetzt ein aktives target
779
+ // (aus den nav.searchParams) den RoutedScreen durch den EditorPanel.
781
780
  // ---------------------------------------------------------------------------
782
781
 
783
- describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
784
- test('navigation: "tree" mountet den Visual-Tree-Stub statt NavTree', () => {
782
+ describe("WorkspaceShell — EINE Nav (Visual-Tree gefaltet)", () => {
783
+ test('navigation: "tree" rendert NavTree (Tree-Stub ist gefaltet, kein Switch mehr)', () => {
785
784
  const schema = {
786
785
  featureName: "demo",
787
786
  entities: {},
@@ -803,48 +802,19 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
803
802
  <div>content</div>
804
803
  </WorkspaceShell>,
805
804
  );
806
- // Stub-Marker rendert
807
- expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
808
- // NavTree wäre durch das nav-Item "List" sichtbar — darf hier NICHT
809
- // rendern (Tree-Mode ersetzt NavTree komplett)
810
- expect(screen.queryByText("List")).toBeNull();
811
- });
812
-
813
- test('navigation: "nav" mountet NavTree (default-äquivalent)', () => {
814
- const schema = {
815
- featureName: "demo",
816
- entities: {},
817
- screens: [],
818
- navs: [{ id: "list", label: "List" }],
819
- workspaces: [
820
- ws("admin", {
821
- label: "Admin",
822
- openToAll: true,
823
- isDefault: true,
824
- navigation: "nav",
825
- navMembers: ["demo:nav:list"],
826
- }),
827
- ],
828
- } as const;
829
-
830
- renderShell(
831
- <WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
832
- <div>content</div>
833
- </WorkspaceShell>,
834
- );
805
+ // NavTree rendert das nav-Item — auch bei navigation:"tree" (No-op).
835
806
  expect(screen.getByText("List")).toBeTruthy();
807
+ // Der alte VisualTree-Stub existiert nicht mehr.
836
808
  expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
837
809
  });
838
810
 
839
- test("workspace ohne navigation-Property defaultet zu NavTree (Backwards-Compat)", () => {
811
+ test("workspace ohne navigation-Property rendert NavTree (Backwards-Compat)", () => {
840
812
  const schema = {
841
813
  featureName: "demo",
842
814
  entities: {},
843
815
  screens: [],
844
816
  navs: [{ id: "list", label: "List" }],
845
817
  workspaces: [
846
- // Keine `navigation`-Property → existing Apps müssen exakt so
847
- // weiter laufen wie vor V.1
848
818
  ws("admin", {
849
819
  label: "Admin",
850
820
  openToAll: true,
@@ -860,14 +830,15 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
860
830
  </WorkspaceShell>,
861
831
  );
862
832
  expect(screen.getByText("List")).toBeTruthy();
863
- expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
864
833
  });
865
834
 
866
- test("Switcher-Toggle re-rendert Sidebar zwischen NavTree und Stub", () => {
867
- // Multi-Workspace-Setup: beide accessible, einer nav, einer tree.
868
- // Single-mount-Tests können nicht beweisen dass der conditional
869
- // render bei prop-change sauber re-rendert das ist die echte UX
870
- // (User klickt im Switcher zwischen Modi). Dieser Test pinnt das.
835
+ test("aktives target in der URL rendert EditorPanel statt der screen-children", () => {
836
+ // Klick auf einen target-Knoten persistiert das target in den
837
+ // searchParams (?t=feat:action&a_*=...). WorkspaceShell rendert dann
838
+ // den EditorPanel statt des RoutedScreen. Voller workspace+screen-Pfad,
839
+ // damit der default-fill-Effect nicht replaced (sonst Verlust der
840
+ // searchParams). Kein Resolver registriert → EditorPanel-Fallback.
841
+ window.history.replaceState(null, "", "/admin/x?t=demo:edit&a_id=1");
871
842
  const schema = {
872
843
  featureName: "demo",
873
844
  entities: {},
@@ -878,14 +849,8 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
878
849
  label: "Admin",
879
850
  openToAll: true,
880
851
  isDefault: true,
881
- navigation: "nav",
882
852
  navMembers: ["demo:nav:list"],
883
853
  }),
884
- ws("visual", {
885
- label: "Visual",
886
- openToAll: true,
887
- navigation: "tree",
888
- }),
889
854
  ],
890
855
  } as const;
891
856
 
@@ -894,19 +859,9 @@ describe("WorkspaceShell — navigation-Mode (Visual-Tree opt-in)", () => {
894
859
  <div>content</div>
895
860
  </WorkspaceShell>,
896
861
  );
897
-
898
- // Initial: admin-Workspace (default) aktiv → NavTree sichtbar, kein Stub
899
- expect(screen.getByText("List")).toBeTruthy();
900
- expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
901
-
902
- // Switch zu visual-Workspace via Switcher-Click
903
- fireEvent.click(screen.getByTestId("workspace-tab-visual"));
904
- expect(screen.getByLabelText("Visual Tree (no providers)")).toBeTruthy();
905
- expect(screen.queryByText("List")).toBeNull();
906
-
907
- // Zurück zu admin → NavTree wieder, Stub weg
908
- fireEvent.click(screen.getByTestId("workspace-tab-admin"));
909
- expect(screen.getByText("List")).toBeTruthy();
910
- expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
862
+ // EditorPanel-Fallback (kein Resolver für demo:edit) — beweist target→Editor.
863
+ expect(screen.getByRole("heading", { name: "Editor" })).toBeTruthy();
864
+ // Die screen-children sind ersetzt, nicht zusätzlich gerendert.
865
+ expect(screen.queryByText("content")).toBeNull();
911
866
  });
912
867
  });