@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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 (79) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/action-menu.test.tsx +75 -0
  3. package/src/__tests__/breadcrumb.test.tsx +50 -0
  4. package/src/__tests__/create-app.test.tsx +171 -1
  5. package/src/__tests__/dashboard-screen.test.tsx +393 -0
  6. package/src/__tests__/default-app-shell.test.tsx +47 -1
  7. package/src/__tests__/form-action-bar.test.tsx +9 -0
  8. package/src/__tests__/kumiko-screen.test.tsx +155 -1
  9. package/src/__tests__/lightbox.test.tsx +46 -0
  10. package/src/__tests__/nav-tree.test.tsx +122 -0
  11. package/src/__tests__/primitives.test.tsx +32 -1
  12. package/src/__tests__/profile-menu.test.tsx +29 -0
  13. package/src/__tests__/projection-detail.test.tsx +101 -0
  14. package/src/__tests__/projection-list-actions.test.tsx +131 -0
  15. package/src/__tests__/render-list.test.tsx +11 -3
  16. package/src/__tests__/test-utils.tsx +1 -0
  17. package/src/__tests__/toast.test.tsx +9 -9
  18. package/src/__tests__/use-mutation.test.tsx +86 -0
  19. package/src/__tests__/workspace-shell.test.tsx +102 -47
  20. package/src/__tests__/xss-safety.test.tsx +62 -0
  21. package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
  22. package/src/app/browser-locale.ts +1 -1
  23. package/src/app/create-app.tsx +96 -29
  24. package/src/app/dashboard-body.tsx +454 -0
  25. package/src/index.ts +75 -1
  26. package/src/layout/__tests__/nav-registry.test.ts +105 -0
  27. package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
  28. package/src/layout/default-app-shell.tsx +9 -71
  29. package/src/layout/nav-tree.tsx +167 -37
  30. package/src/layout/shell-breadcrumb.ts +46 -0
  31. package/src/layout/shell-header.tsx +114 -0
  32. package/src/layout/workspace-shell.tsx +45 -18
  33. package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
  34. package/src/primitives/__tests__/input-testid.test.tsx +45 -0
  35. package/src/primitives/__tests__/money-input.test.tsx +13 -1
  36. package/src/primitives/dialog.tsx +47 -76
  37. package/src/primitives/index.tsx +215 -37
  38. package/src/primitives/layout.tsx +39 -0
  39. package/src/primitives/lightbox.tsx +39 -0
  40. package/src/primitives/modal-shell.tsx +62 -0
  41. package/src/primitives/money-input.tsx +20 -10
  42. package/src/primitives/toast.tsx +14 -9
  43. package/src/sse/live-events.ts +6 -1
  44. package/src/styles.css +25 -0
  45. package/src/tokens.ts +4 -0
  46. package/src/ui/__tests__/avatar.test.tsx +66 -0
  47. package/src/ui/__tests__/sheet.test.tsx +102 -0
  48. package/src/version/__tests__/update-checker.test.tsx +115 -0
  49. package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
  50. package/src/widgets/__tests__/drawer.test.tsx +35 -0
  51. package/src/widgets/__tests__/form-kit.test.tsx +368 -0
  52. package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
  53. package/src/widgets/__tests__/query-table.test.tsx +118 -0
  54. package/src/widgets/__tests__/result-panel.test.tsx +117 -0
  55. package/src/widgets/__tests__/widgets.test.tsx +243 -0
  56. package/src/widgets/ai-text-field.tsx +370 -0
  57. package/src/widgets/charts.tsx +228 -0
  58. package/src/widgets/collapsible-section.tsx +42 -0
  59. package/src/widgets/detail-list.tsx +43 -0
  60. package/src/widgets/drawer.tsx +49 -0
  61. package/src/widgets/feed-list.tsx +35 -0
  62. package/src/widgets/form-fields.tsx +326 -0
  63. package/src/widgets/index.ts +59 -0
  64. package/src/widgets/infinity-list.tsx +112 -0
  65. package/src/widgets/mode-switch.tsx +41 -0
  66. package/src/widgets/progress-bar.tsx +27 -0
  67. package/src/widgets/progress-list.tsx +38 -0
  68. package/src/widgets/query-table.tsx +103 -0
  69. package/src/widgets/result-panel.tsx +251 -0
  70. package/src/widgets/section-card.tsx +30 -0
  71. package/src/widgets/stat.tsx +181 -0
  72. package/src/widgets/states.tsx +89 -0
  73. package/src/widgets/status-badge.tsx +51 -0
  74. package/src/widgets/use-draft.ts +40 -0
  75. package/src/ui/card.tsx +0 -93
  76. package/src/ui/collapsible.tsx +0 -34
  77. package/src/ui/dropdown-menu.tsx +0 -258
  78. package/src/ui/select.tsx +0 -191
  79. /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
@@ -1,7 +1,10 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import type { EntityListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
3
  import type { WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
3
4
  import {
4
5
  createStaticLocaleResolver,
6
+ DispatcherProvider,
7
+ KumikoScreen,
5
8
  LocaleProvider,
6
9
  NavProvider,
7
10
  PrimitivesProvider,
@@ -17,7 +20,7 @@ import {
17
20
  } from "../layout/workspace-shell";
18
21
  import { WorkspaceSwitcher } from "../layout/workspace-switcher";
19
22
  import { defaultPrimitives } from "../primitives";
20
- import { fireEvent, render, screen } from "./test-utils";
23
+ import { createMockDispatcher, fireEvent, render, screen } from "./test-utils";
21
24
 
22
25
  // jsdom shares window.history across tests in the same file. Reset to /
23
26
  // before each render so URL-driven workspace state from one test doesn't
@@ -57,7 +60,6 @@ function ws(
57
60
  openToAll?: boolean;
58
61
  isDefault?: boolean;
59
62
  navMembers?: readonly string[];
60
- navigation?: "nav" | "tree";
61
63
  } = {},
62
64
  ): WorkspaceSchema {
63
65
  const access = options.openToAll
@@ -72,7 +74,6 @@ function ws(
72
74
  ...(options.order !== undefined && { order: options.order }),
73
75
  ...(access !== undefined && { access }),
74
76
  ...(options.isDefault === true && { default: true }),
75
- ...(options.navigation !== undefined && { navigation: options.navigation }),
76
77
  },
77
78
  navMembers: options.navMembers ?? [],
78
79
  };
@@ -329,10 +330,11 @@ describe("WorkspaceShell", () => {
329
330
  <div>content</div>
330
331
  </WorkspaceShell>,
331
332
  );
332
- // admin → system + orders, NOT tours
333
- expect(screen.getByText("System")).toBeTruthy();
334
- expect(screen.getByText("Orders")).toBeTruthy();
335
- expect(screen.queryByText("Tours")).toBeNull();
333
+ // admin → system + orders, NOT tours. getAllByText: die shadcn-Sidebar
334
+ // rendert Desktop + Mobile-Sheet, jeder Nav-Eintrag kommt also 2× vor.
335
+ expect(screen.getAllByText("System").length).toBeGreaterThan(0);
336
+ expect(screen.getAllByText("Orders").length).toBeGreaterThan(0);
337
+ expect(screen.queryAllByText("Tours").length).toBe(0);
336
338
  });
337
339
 
338
340
  test("clicking the dispatch tab swaps the visible nav set", () => {
@@ -346,10 +348,10 @@ describe("WorkspaceShell", () => {
346
348
  </WorkspaceShell>,
347
349
  );
348
350
  fireEvent.click(screen.getByTestId("workspace-tab-dispatch"));
349
- // dispatch → orders + tours, NOT system
350
- expect(screen.getByText("Orders")).toBeTruthy();
351
- expect(screen.getByText("Tours")).toBeTruthy();
352
- expect(screen.queryByText("System")).toBeNull();
351
+ // dispatch → orders + tours, NOT system (getAllByText: Desktop + Mobile-Sheet)
352
+ expect(screen.getAllByText("Orders").length).toBeGreaterThan(0);
353
+ expect(screen.getAllByText("Tours").length).toBeGreaterThan(0);
354
+ expect(screen.queryAllByText("System").length).toBe(0);
353
355
  });
354
356
 
355
357
  test("a driver lands on the driver workspace (their only one)", () => {
@@ -365,9 +367,9 @@ describe("WorkspaceShell", () => {
365
367
  // Only one workspace → switcher renders nothing, but the nav still
366
368
  // shows that workspace's members.
367
369
  expect(screen.queryByTestId("workspace-tab-driver")).toBeNull();
368
- expect(screen.getByText("Tours")).toBeTruthy();
369
- expect(screen.queryByText("System")).toBeNull();
370
- expect(screen.queryByText("Orders")).toBeNull();
370
+ expect(screen.getAllByText("Tours").length).toBeGreaterThan(0);
371
+ expect(screen.queryAllByText("System").length).toBe(0);
372
+ expect(screen.queryAllByText("Orders").length).toBe(0);
371
373
  });
372
374
 
373
375
  test("schema without workspaces falls back to plain rendering (all navs visible)", () => {
@@ -710,8 +712,9 @@ describe("WorkspaceShell — AppSchema (multi-feature)", () => {
710
712
  <div>content</div>
711
713
  </WorkspaceShell>,
712
714
  );
713
- expect(screen.getByTestId("sidebar-footer")).toBeTruthy();
714
- expect(screen.getByTestId("sidebar-footer").textContent).toBe("v1.2.3");
715
+ const footers = screen.getAllByTestId("sidebar-footer");
716
+ expect(footers.length).toBeGreaterThan(0);
717
+ expect(footers[0]?.textContent).toBe("v1.2.3");
715
718
  });
716
719
 
717
720
  test("ohne sidebarFooter-Prop rendert die Sidebar ohne Footer-Slot (default)", () => {
@@ -772,26 +775,23 @@ describe("WorkspaceShell — AppSchema (multi-feature)", () => {
772
775
  });
773
776
 
774
777
  // ---------------------------------------------------------------------------
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.
778
+ // EINE Nav. WorkspaceShell rendert IMMER NavTree. Im Content-Bereich
779
+ // ersetzt ein aktives target (aus den nav.searchParams) den RoutedScreen
780
+ // durch den EditorPanel.
780
781
  // ---------------------------------------------------------------------------
781
782
 
782
- describe("WorkspaceShell — EINE Nav (Visual-Tree gefaltet)", () => {
783
- test('navigation: "tree" rendert NavTree (Tree-Stub ist gefaltet, kein Switch mehr)', () => {
783
+ describe("WorkspaceShell — EINE Nav", () => {
784
+ test("workspace rendert NavTree", () => {
784
785
  const schema = {
785
786
  featureName: "demo",
786
787
  entities: {},
787
788
  screens: [],
788
789
  navs: [{ id: "list", label: "List" }],
789
790
  workspaces: [
790
- ws("visual", {
791
- label: "Visual",
791
+ ws("admin", {
792
+ label: "Admin",
792
793
  openToAll: true,
793
794
  isDefault: true,
794
- navigation: "tree",
795
795
  navMembers: ["demo:nav:list"],
796
796
  }),
797
797
  ],
@@ -802,13 +802,16 @@ describe("WorkspaceShell — EINE Nav (Visual-Tree gefaltet)", () => {
802
802
  <div>content</div>
803
803
  </WorkspaceShell>,
804
804
  );
805
- // NavTree rendert das nav-Item — auch bei navigation:"tree" (No-op).
806
805
  expect(screen.getByText("List")).toBeTruthy();
807
- // Der alte VisualTree-Stub existiert nicht mehr.
808
- expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
809
806
  });
810
807
 
811
- test("workspace ohne navigation-Property rendert NavTree (Backwards-Compat)", () => {
808
+ test("aktives target in der URL rendert EditorPanel statt der screen-children", () => {
809
+ // Klick auf einen target-Knoten persistiert das target in den
810
+ // searchParams (?t=feat:action&a_*=...). WorkspaceShell rendert dann
811
+ // den EditorPanel statt des RoutedScreen. Voller workspace+screen-Pfad,
812
+ // damit der default-fill-Effect nicht replaced (sonst Verlust der
813
+ // searchParams). Kein Resolver registriert → EditorPanel-Fallback.
814
+ window.history.replaceState(null, "", "/admin/x?t=demo:edit&a_id=1");
812
815
  const schema = {
813
816
  featureName: "demo",
814
817
  entities: {},
@@ -829,39 +832,91 @@ describe("WorkspaceShell — EINE Nav (Visual-Tree gefaltet)", () => {
829
832
  <div>content</div>
830
833
  </WorkspaceShell>,
831
834
  );
832
- expect(screen.getByText("List")).toBeTruthy();
835
+ // EditorPanel-Fallback (kein Resolver für demo:edit) — beweist target→Editor.
836
+ expect(screen.getByRole("heading", { name: "Editor" })).toBeTruthy();
837
+ // Die screen-children sind ersetzt, nicht zusätzlich gerendert.
838
+ expect(screen.queryByText("content")).toBeNull();
833
839
  });
834
840
 
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");
841
+ test("navBadges pass through to NavTree leaf items", () => {
842
842
  const schema = {
843
- featureName: "demo",
843
+ featureName: "bmc",
844
844
  entities: {},
845
845
  screens: [],
846
- navs: [{ id: "list", label: "List" }],
846
+ navs: [
847
+ { id: "system", label: "System", screen: "bmc:screen:system" },
848
+ { id: "orders", label: "Orders", screen: "bmc:screen:orders" },
849
+ ],
847
850
  workspaces: [
848
851
  ws("admin", {
849
852
  label: "Admin",
850
- openToAll: true,
853
+ roles: ["admin"],
854
+ order: 1,
851
855
  isDefault: true,
852
- navMembers: ["demo:nav:list"],
856
+ navMembers: ["bmc:nav:system", "bmc:nav:orders"],
853
857
  }),
854
858
  ],
855
859
  } as const;
856
860
 
857
861
  renderShell(
858
- <WorkspaceShell brand={<div>Brand</div>} schema={schema} user={{ id: "u1", roles: [] }}>
862
+ <WorkspaceShell
863
+ brand={<div>Brand</div>}
864
+ schema={schema}
865
+ user={{ id: "u1", roles: ["admin"] }}
866
+ navBadges={new Map([["orders", <span>3</span>]])}
867
+ >
859
868
  <div>content</div>
860
869
  </WorkspaceShell>,
861
870
  );
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();
871
+ const ordersItems = screen.getAllByText("Orders");
872
+ const systemItems = screen.getAllByText("System");
873
+ expect(ordersItems.some((el) => el.closest("li")?.textContent?.includes("3"))).toBe(true);
874
+ expect(systemItems.every((el) => !el.closest("li")?.textContent?.includes("3"))).toBe(true);
875
+ });
876
+ });
877
+
878
+ // WorkspaceShell threads `user.roles` into a UserRolesProvider around
879
+ // children (#1203) — KumikoScreen reads that to gate role-restricted
880
+ // screens at render time, not just via nav/workspace-switcher filtering.
881
+ describe("WorkspaceShell wires user.roles into children for screen-level access (#1203)", () => {
882
+ const restrictedScreen: EntityListScreenDefinition = {
883
+ id: "restricted",
884
+ type: "entityList",
885
+ entity: "x",
886
+ columns: [],
887
+ access: { roles: ["admin"] },
888
+ };
889
+ const schema = {
890
+ featureName: "bmc",
891
+ entities: {},
892
+ screens: [restrictedScreen],
893
+ navs: [],
894
+ workspaces: [],
895
+ } as const;
896
+
897
+ test("no user prop → role-gated screen child shows access-denied", () => {
898
+ renderShell(
899
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
900
+ <WorkspaceShell brand={<div>Brand</div>} schema={schema}>
901
+ <KumikoScreen schema={schema} qn="bmc:screen:restricted" />
902
+ </WorkspaceShell>
903
+ </DispatcherProvider>,
904
+ );
905
+ expect(screen.getByTestId("kumiko-screen-access-denied")).toBeTruthy();
906
+ });
907
+
908
+ test("user prop with matching role → role-gated screen child renders", () => {
909
+ renderShell(
910
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
911
+ <WorkspaceShell
912
+ brand={<div>Brand</div>}
913
+ schema={schema}
914
+ user={{ id: "u1", roles: ["admin"] }}
915
+ >
916
+ <KumikoScreen schema={schema} qn="bmc:screen:restricted" />
917
+ </WorkspaceShell>
918
+ </DispatcherProvider>,
919
+ );
920
+ expect(screen.queryByTestId("kumiko-screen-access-denied")).toBeNull();
866
921
  });
867
922
  });
@@ -0,0 +1,62 @@
1
+ //
2
+ // Pins the XSS-escaping behavior for tenant-/pattern-authored content
3
+ // (kumiko-framework#1321). renderer-web has no dangerouslySetInnerHTML
4
+ // and no field-renderer path that wires tenant data into href/src — this
5
+ // test guards JSX auto-escaping against future regressions (e.g. if
6
+ // someone adds rich-text/markdown support via dangerouslySetInnerHTML),
7
+ // and pins the Link primitive's scheme guard (kumiko-framework#1365)
8
+ // against javascript:/data: hrefs as defense-in-depth.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { defaultPrimitives } from "../primitives";
12
+ import { StatCard } from "../widgets/stat";
13
+ import { render, screen } from "./test-utils";
14
+
15
+ const PAYLOAD = '<script>window.__xss = true;</script><img src=x onerror="window.__xss = true">';
16
+ const UNSAFE_HREFS = [
17
+ "javascript:window.__xss = true",
18
+ "data:text/html,<script>window.__xss=true</script>",
19
+ // Browsers strip embedded control/whitespace chars before scheme
20
+ // detection — a tab/newline mid-scheme must not bypass the check.
21
+ "java\tscript:window.__xss = true",
22
+ "java\nscript:window.__xss = true",
23
+ ];
24
+
25
+ describe("XSS-safety (tenant-authored content)", () => {
26
+ test("Text-Primitive rendert Payload als Text, nicht als HTML", () => {
27
+ render(<defaultPrimitives.Text testId="txt">{PAYLOAD}</defaultPrimitives.Text>);
28
+ const node = screen.getByTestId("txt");
29
+ expect(node.textContent).toBe(PAYLOAD);
30
+ expect(node.querySelector("script")).toBeNull();
31
+ expect(node.querySelector("img")).toBeNull();
32
+ });
33
+
34
+ test("StatCard-Widget rendert label/value/sub als Text, nicht als HTML", () => {
35
+ render(<StatCard testId="stat" label={PAYLOAD} value={PAYLOAD} sub={PAYLOAD} />);
36
+ const node = screen.getByTestId("stat");
37
+ expect(node.querySelector("script")).toBeNull();
38
+ expect(node.querySelector("img")).toBeNull();
39
+ expect(node.textContent).toContain(PAYLOAD);
40
+ });
41
+
42
+ test.each(UNSAFE_HREFS)("Link-Primitive neutralisiert unsicheren href-Scheme: %s", (href) => {
43
+ render(
44
+ <defaultPrimitives.Link testId="link" href={href}>
45
+ text
46
+ </defaultPrimitives.Link>,
47
+ );
48
+ const node = screen.getByTestId("link");
49
+ expect(node.getAttribute("href")).toBe("#");
50
+ });
51
+
52
+ test("Link-Primitive lässt sichere hrefs (https/mailto/relativ) unverändert", () => {
53
+ for (const href of ["https://example.com", "mailto:a@example.com", "/relative/path"]) {
54
+ render(
55
+ <defaultPrimitives.Link testId={`link-${href}`} href={href}>
56
+ text
57
+ </defaultPrimitives.Link>,
58
+ );
59
+ expect(screen.getByTestId(`link-${href}`).getAttribute("href")).toBe(href);
60
+ }
61
+ });
62
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { CustomScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
4
+ import { firstOpenScreenQn } from "../create-app";
5
+
6
+ function feature(
7
+ overrides: Partial<FeatureSchema> & { readonly featureName: string },
8
+ ): FeatureSchema {
9
+ return { entities: {}, screens: [], ...overrides };
10
+ }
11
+
12
+ function customScreen(
13
+ overrides: Partial<CustomScreenDefinition> & { readonly id: string },
14
+ ): CustomScreenDefinition {
15
+ return { type: "custom", renderer: {}, ...overrides };
16
+ }
17
+
18
+ describe("firstOpenScreenQn", () => {
19
+ test("picks an open screen that is placed in nav", () => {
20
+ const features: readonly FeatureSchema[] = [
21
+ feature({
22
+ featureName: "shop",
23
+ screens: [customScreen({ id: "catalog" })],
24
+ navs: [{ id: "catalog", label: "shop:nav.catalog", screen: "shop:screen:catalog" }],
25
+ }),
26
+ ];
27
+ expect(firstOpenScreenQn(features)).toBe("shop:screen:catalog");
28
+ });
29
+
30
+ test("skips a dormant open screen that has no nav entry (#1258)", () => {
31
+ const features: readonly FeatureSchema[] = [
32
+ feature({
33
+ featureName: "auth-mfa",
34
+ screens: [customScreen({ id: "auth-mfa-enable", access: { openToAll: true } })],
35
+ // No nav entry — this is auth-mfa's dormant custom-screen convention.
36
+ }),
37
+ feature({
38
+ featureName: "shop",
39
+ screens: [customScreen({ id: "catalog" })],
40
+ navs: [{ id: "catalog", label: "shop:nav.catalog", screen: "shop:screen:catalog" }],
41
+ }),
42
+ ];
43
+ expect(firstOpenScreenQn(features)).toBe("shop:screen:catalog");
44
+ });
45
+
46
+ test("skips role-restricted screens even when placed in nav", () => {
47
+ const features: readonly FeatureSchema[] = [
48
+ feature({
49
+ featureName: "admin",
50
+ screens: [customScreen({ id: "dashboard", access: { roles: ["Admin"] } })],
51
+ navs: [{ id: "dashboard", label: "admin:nav.dashboard", screen: "admin:screen:dashboard" }],
52
+ }),
53
+ ];
54
+ expect(firstOpenScreenQn(features)).toBeUndefined();
55
+ });
56
+
57
+ test("returns undefined when no screen is both open and nav-placed", () => {
58
+ const features: readonly FeatureSchema[] = [
59
+ feature({
60
+ featureName: "auth-mfa",
61
+ screens: [customScreen({ id: "auth-mfa-enable", access: { openToAll: true } })],
62
+ }),
63
+ ];
64
+ expect(firstOpenScreenQn(features)).toBeUndefined();
65
+ });
66
+
67
+ test("resolves a bare screen id in nav to the qualified screen name", () => {
68
+ const features: readonly FeatureSchema[] = [
69
+ feature({
70
+ featureName: "shop",
71
+ screens: [customScreen({ id: "catalog" })],
72
+ navs: [{ id: "catalog", label: "shop:nav.catalog", screen: "catalog" }],
73
+ }),
74
+ ];
75
+ expect(firstOpenScreenQn(features)).toBe("shop:screen:catalog");
76
+ });
77
+ });
@@ -67,7 +67,7 @@ export function createBrowserLocaleResolver(
67
67
  timeZone: () => timeZone,
68
68
  subscribe: store.subscribe,
69
69
  setLocale: (next) => {
70
- // Early-Return so we don't write localStorage on a no-op. Store's
70
+ // skip: locale unchanged, avoid redundant localStorage write
71
71
  // own Object.is-Gate would already block listener notification,
72
72
  // but the persistence side-effect lives outside the store.
73
73
  if (next === store.getSnapshot()) return;
@@ -11,6 +11,7 @@ import {
11
11
  type ColumnRendererComponent,
12
12
  ColumnRenderersProvider,
13
13
  CustomScreensProvider,
14
+ DashboardBodyProvider,
14
15
  DispatcherProvider,
15
16
  type ExtensionSectionComponent,
16
17
  ExtensionSectionsProvider,
@@ -19,13 +20,16 @@ import {
19
20
  kumikoDefaultTranslations,
20
21
  LiveEventsProvider,
21
22
  LocaleProvider,
23
+ mergeTranslations,
22
24
  type NavApi,
23
25
  NavProvider,
24
26
  PrimitivesProvider,
25
27
  type PrimitivesRegistry,
26
28
  qualifyScreenId,
27
29
  TokensProvider,
30
+ type TranslationsByLocale,
28
31
  toAppSchema,
32
+ translationsByLocaleFromKeys,
29
33
  useNav,
30
34
  } from "@cosmicdrift/kumiko-renderer";
31
35
  import { type ComponentType, type ReactNode, useMemo } from "react";
@@ -38,6 +42,7 @@ import { useBrowserTokensApi } from "../tokens";
38
42
  import { UpdateChecker } from "../version/update-checker";
39
43
  import { createBrowserLocaleResolver } from "./browser-locale";
40
44
  import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
45
+ import { WebDashboardBody } from "./dashboard-body";
41
46
  import { useBrowserNavApi } from "./nav";
42
47
  import { NavProvidersProvider } from "./nav-providers-context";
43
48
  import { type ResolverComponent, ResolversProvider } from "./resolvers-context";
@@ -124,6 +129,42 @@ function readInjectedSchema(): AppSchema | FeatureSchema | undefined {
124
129
  return w.__KUMIKO_SCHEMA__;
125
130
  }
126
131
 
132
+ // Erstes Screen über alle Features in deklarierter Reihenfolge, dessen
133
+ // `access` niemanden ausschließt (undefined oder openToAll). Die Landing-
134
+ // Route wird VOR Auth-Resolution gewählt, kennt also keine User-Rollen —
135
+ // ein role-restricted Screen (z.B. bundled user/tenant, SystemAdmin-only)
136
+ // darf hier nie gewinnen, sonst landet jeder Nicht-Admin auf einem
137
+ // Access-Denied-Screen (#1176).
138
+ // Also requires the screen be reachable via r.nav, otherwise a dormant
139
+ // `type: "custom"` screen a feature only registers for manual app-side
140
+ // placement (e.g. auth-mfa's enable screen) can win by declaration order
141
+ // alone, landing every app without an explicit `screenQn` on a screen
142
+ // nobody wired a component for (#1258).
143
+ export function firstOpenScreenQn(features: readonly FeatureSchema[]): string | undefined {
144
+ // NavDefinition.screen carries two shapes in practice: most bundled
145
+ // features author it pre-qualified ("tenant:screen:members"), but the
146
+ // config settings-hub generator emits the bare short id. Index both
147
+ // forms so nav-reachability doesn't depend on which convention a given
148
+ // feature happens to use.
149
+ const navScreenQns = new Set<string>();
150
+ for (const f of features) {
151
+ for (const n of f.navs ?? []) {
152
+ if (n.screen === undefined) continue;
153
+ navScreenQns.add(n.screen);
154
+ navScreenQns.add(qualifyScreenId(f.featureName, n.screen));
155
+ }
156
+ }
157
+ for (const feature of features) {
158
+ const openScreen = feature.screens.find(
159
+ (s) =>
160
+ (s.access === undefined || "openToAll" in s.access) &&
161
+ navScreenQns.has(qualifyScreenId(feature.featureName, s.id)),
162
+ );
163
+ if (openScreen !== undefined) return qualifyScreenId(feature.featureName, openScreen.id);
164
+ }
165
+ return undefined;
166
+ }
167
+
127
168
  export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonly root: Root } {
128
169
  const rootId = options.rootId ?? "root";
129
170
  const container = document.getElementById(rootId);
@@ -149,19 +190,11 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
149
190
  }
150
191
  const app = toAppSchema(rawSchema);
151
192
 
152
- // Fallback-Screen: erstes Screen über alle Features in deklarierter
153
- // Reihenfolge. War vorher schema.screens[0], jetzt search the first
154
- // feature with screens.
155
- const firstFeatureWithScreens = app.features.find((f) => f.screens.length > 0);
156
- const firstScreen = firstFeatureWithScreens?.screens[0];
157
- const fallbackQn =
158
- options.screenQn ??
159
- (firstScreen !== undefined && firstFeatureWithScreens !== undefined
160
- ? qualifyScreenId(firstFeatureWithScreens.featureName, firstScreen.id)
161
- : undefined);
193
+ // Fallback-Screen falls kein explizites screenQn übergeben wurde.
194
+ const fallbackQn = options.screenQn ?? firstOpenScreenQn(app.features);
162
195
  if (!fallbackQn) {
163
196
  throw new Error(
164
- "createKumikoApp: schema contains no screens. Add at least one entry to `schema.screens` or pass `screenQn` explicitly.",
197
+ "createKumikoApp: schema contains no screens accessible without a role restriction. Add at least one entry to `schema.screens` without `access.roles`, or pass `screenQn` explicitly.",
165
198
  );
166
199
  }
167
200
 
@@ -175,12 +208,24 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
175
208
  const clientFeatures = options.clientFeatures ?? [];
176
209
  const providers = clientFeatures.flatMap((f) => f.providers ?? []);
177
210
  const gates = clientFeatures.flatMap((f) => f.gates ?? []);
178
- // Framework-Default-Bundle als ALLERLETZTER Fallback App-Resolver +
179
- // clientFeatures.translations haben Vorrang. Apps können einzelne
180
- // kumiko.*-Keys via clientFeatures.translations überschreiben (z.B.
181
- // "kumiko.actions.save" "Sichern" für ein bestimmtes Feature).
211
+ // Precedence in fallbackBundles (Array-Order = Priorität, höchste zuerst):
212
+ // 1. clientFeatures.translations App-Overrides gewinnen immer, auch
213
+ // gegen framework-eigene Labels.
214
+ // 2. schemaTranslations server-authored r.translations, von
215
+ // buildAppSchema verbatim projiziert (#1059). Ohne dieses Bundle
216
+ // resolven Nav-/Screen-Labels nur, wenn eine App sie ZUSÄTZLICH in
217
+ // web/i18n.ts dupliziert — die meisten bundled-features taten das
218
+ // nie, Labels rendern dann als rohe i18n-Keys.
219
+ // 3. kumikoDefaultTranslations — Framework-Defaults, ALLERLETZTER
220
+ // Fallback.
221
+ const schemaTranslations = app.features.reduce<TranslationsByLocale>(
222
+ (acc, f) =>
223
+ f.translations ? mergeTranslations(acc, translationsByLocaleFromKeys(f.translations)) : acc,
224
+ {},
225
+ );
182
226
  const fallbackBundles = [
183
227
  ...clientFeatures.flatMap((f) => (f.translations !== undefined ? [f.translations] : [])),
228
+ schemaTranslations,
184
229
  kumikoDefaultTranslations,
185
230
  ];
186
231
  // Custom-Screen-Components-Map mergen: spätere Features überschreiben
@@ -285,20 +330,22 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
285
330
  <PrimitivesProvider value={primitives}>
286
331
  <DispatcherProvider dispatcher={dispatcher}>
287
332
  <LiveEventsProvider value={liveEvents}>
288
- <CustomScreensProvider value={customScreens}>
289
- <ColumnRenderersProvider value={columnRenderers}>
290
- <ExtensionSectionsProvider value={extensionSectionComponents}>
291
- <NavProvidersProvider value={navProviders} entities={navEntities}>
292
- <ResolversProvider resolvers={resolvers}>
293
- <ToastProvider>
294
- <UpdateChecker />
295
- {stackWrappers(providers, stackWrappers(gates, screenNode))}
296
- </ToastProvider>
297
- </ResolversProvider>
298
- </NavProvidersProvider>
299
- </ExtensionSectionsProvider>
300
- </ColumnRenderersProvider>
301
- </CustomScreensProvider>
333
+ <DashboardBodyProvider value={WebDashboardBody}>
334
+ <CustomScreensProvider value={customScreens}>
335
+ <ColumnRenderersProvider value={columnRenderers}>
336
+ <ExtensionSectionsProvider value={extensionSectionComponents}>
337
+ <NavProvidersProvider value={navProviders} entities={navEntities}>
338
+ <ResolversProvider resolvers={resolvers}>
339
+ <ToastProvider>
340
+ <UpdateChecker />
341
+ {stackWrappers(providers, stackWrappers(gates, screenNode))}
342
+ </ToastProvider>
343
+ </ResolversProvider>
344
+ </NavProvidersProvider>
345
+ </ExtensionSectionsProvider>
346
+ </ColumnRenderersProvider>
347
+ </CustomScreensProvider>
348
+ </DashboardBodyProvider>
302
349
  </LiveEventsProvider>
303
350
  </DispatcherProvider>
304
351
  </PrimitivesProvider>
@@ -436,6 +483,25 @@ function RoutedScreen({
436
483
  };
437
484
  }, [onRowClick, app.features, nav]);
438
485
 
486
+ // Copy-Link-Action (Issue #912) für entityEdit-Update-Screens. Baut die
487
+ // absolute Permalink-URL aus der aktuellen Route + kopiert sie —
488
+ // `navigator`/`window` sind hier erlaubt (renderer-web, kein
489
+ // platform-neutrales Package). Kein Button ohne entityId (create-mode).
490
+ // Silent-catch bei Clipboard-Fehler (non-secure context) mirrort das
491
+ // bestehende Muster in pat-tokens-screen.tsx.
492
+ const effectiveOnCopyLink = useMemo<(() => Promise<void> | void) | undefined>(() => {
493
+ const route = nav.route;
494
+ if (route?.entityId === undefined) return undefined;
495
+ return async () => {
496
+ const url = `${window.location.origin}${nav.hrefFor(route)}`;
497
+ try {
498
+ await navigator.clipboard.writeText(url);
499
+ } catch {
500
+ // clipboard blocked (non-secure context) — no fallback UI needed here
501
+ }
502
+ };
503
+ }, [nav]);
504
+
439
505
  // KumikoScreen will nach wie vor ein single-feature schema. Wir
440
506
  // füttern es mit dem owning Feature — es enthält Entity-Defs +
441
507
  // Screen-Defs für den aktiven Render-Pfad. Kein Owner gefunden → wir
@@ -455,6 +521,7 @@ function RoutedScreen({
455
521
  {...(translate !== undefined && { translate })}
456
522
  {...(entityId !== undefined && { entityId })}
457
523
  onRowClick={effectiveOnRowClick}
524
+ {...(effectiveOnCopyLink !== undefined && { onCopyLink: effectiveOnCopyLink })}
458
525
  />
459
526
  );
460
527
  }