@cosmicdrift/kumiko-renderer-web 0.50.0 → 0.51.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.50.0",
3
+ "version": "0.51.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,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.45.0",
20
- "@cosmicdrift/kumiko-headless": "0.45.0",
21
- "@cosmicdrift/kumiko-renderer": "0.45.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.50.0",
20
+ "@cosmicdrift/kumiko-headless": "0.50.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.50.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",
@@ -0,0 +1,43 @@
1
+ // AppLayout: pinnt den fill-Vertrag — Default ist der klassische
2
+ // min-h-screen-Seitenflow, fill schaltet auf h-screen + innen-scrollendes
3
+ // main (min-h-0). Plus die className/mainClassName-Erweiterungspunkte.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { AppLayout } from "../layout/app-layout";
7
+ import { render } from "./test-utils";
8
+
9
+ function root(container: HTMLElement): HTMLElement {
10
+ const el = container.querySelector('[data-kumiko-layout="app"]');
11
+ if (el === null) throw new Error("AppLayout root not found");
12
+ return el as HTMLElement;
13
+ }
14
+
15
+ describe("AppLayout fill", () => {
16
+ test("Default → min-h-screen (Seiten-Scroll), kein fill-Marker", () => {
17
+ const { container } = render(<AppLayout>x</AppLayout>);
18
+ const el = root(container);
19
+ expect(el.className).toContain("min-h-screen");
20
+ expect(el.getAttribute("data-kumiko-fill")).toBeNull();
21
+ expect((container.querySelector("main") as HTMLElement).className).not.toContain("min-h-0");
22
+ });
23
+
24
+ test("fill → h-screen + main min-h-0 (Innen-Scroll), fill-Marker gesetzt", () => {
25
+ const { container } = render(<AppLayout fill>x</AppLayout>);
26
+ const el = root(container);
27
+ expect(el.className).not.toContain("min-h-screen");
28
+ expect(el.className).toContain("h-screen");
29
+ expect(el.getAttribute("data-kumiko-fill")).toBe("true");
30
+ expect((container.querySelector("main") as HTMLElement).className).toContain("min-h-0");
31
+ });
32
+
33
+ test("className/mainClassName werden an die Defaults angehängt", () => {
34
+ const { container } = render(
35
+ <AppLayout className="bg-gradient-test" mainClassName="main-test">
36
+ x
37
+ </AppLayout>,
38
+ );
39
+ expect(root(container).className).toContain("bg-gradient-test");
40
+ expect(root(container).className).toContain("flex");
41
+ expect((container.querySelector("main") as HTMLElement).className).toContain("main-test");
42
+ });
43
+ });
@@ -179,4 +179,34 @@ describe("NavTree", () => {
179
179
  // Items selbst bleibt sichtbar
180
180
  expect(screen.getByText("Items")).toBeTruthy();
181
181
  });
182
+
183
+ test("Nav-Eintrag mit bekanntem icon rendert ein Lucide-Icon, ohne icon den Dot", () => {
184
+ const schema = {
185
+ featureName: "showcase",
186
+ entities: {},
187
+ screens: [
188
+ { id: "dash", type: "entityList", entity: "x", columns: [] },
189
+ { id: "plain", type: "entityList", entity: "x", columns: [] },
190
+ ],
191
+ navs: [
192
+ { id: "dash", label: "Dash", screen: "dash", order: 10, icon: "dashboard" },
193
+ { id: "plain", label: "Plain", screen: "plain", order: 20 },
194
+ ],
195
+ } as FeatureSchema;
196
+ const { container } = render(<NavTree schema={schema} />);
197
+ // Flache Navigation ohne Sections → keine Chevrons. Genau EIN svg:
198
+ // das dashboard-Icon. Das icon-lose Item rendert den Dot (span, kein svg).
199
+ expect(container.querySelectorAll("svg").length).toBe(1);
200
+ });
201
+
202
+ test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg)", () => {
203
+ const schema = {
204
+ featureName: "showcase",
205
+ entities: {},
206
+ screens: [{ id: "x", type: "entityList", entity: "x", columns: [] }],
207
+ navs: [{ id: "x", label: "X", screen: "x", order: 10, icon: "does-not-exist" }],
208
+ } as FeatureSchema;
209
+ const { container } = render(<NavTree schema={schema} />);
210
+ expect(container.querySelectorAll("svg").length).toBe(0);
211
+ });
182
212
  });
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
2
3
 
3
4
  // Linear-Pattern: Sidebar ist Geschwister von <Topbar+Main>-Spalte,
4
5
  // nimmt die volle Höhe. Wenn ein Topbar gesetzt ist, lebt er NUR über
@@ -12,23 +13,50 @@ export type AppLayoutProps = {
12
13
  readonly sidebar?: ReactNode;
13
14
  readonly children: ReactNode;
14
15
  readonly testId?: string;
16
+ /** Viewport-fit Shell. true → Wurzel = `h-screen` (fixe Viewport-Höhe),
17
+ * Sidebar/Topbar bleiben stehen, der Main-Bereich scrollt INNEN
18
+ * (`min-h-0` + `overflow-auto`). false (Default) → klassischer
19
+ * `min-h-screen`-Flow, die ganze Seite scrollt. Dashboard-artige Apps
20
+ * wollen `true`; eine öffentliche, lange Content-Seite eher `false`.
21
+ * Clippt nie — der Content scrollt in `main` statt im Body. */
22
+ readonly fill?: boolean;
23
+ /** Optionaler Klassen-Append an die Wurzel (eigener Hintergrund etc.).
24
+ * Erweitert die Defaults, ersetzt sie nicht (cn-merge). */
25
+ readonly className?: string;
26
+ /** Optionaler Klassen-Append an `<main>`. */
27
+ readonly mainClassName?: string;
15
28
  };
16
29
 
17
- export function AppLayout({ topbar, sidebar, children, testId }: AppLayoutProps): ReactNode {
30
+ export function AppLayout({
31
+ topbar,
32
+ sidebar,
33
+ children,
34
+ testId,
35
+ fill,
36
+ className,
37
+ mainClassName,
38
+ }: AppLayoutProps): ReactNode {
18
39
  return (
19
40
  <div
20
41
  data-testid={testId}
21
42
  data-kumiko-layout="app"
22
- className="flex min-h-screen flex-row bg-background text-foreground"
43
+ {...(fill === true && { "data-kumiko-fill": "true" })}
44
+ className={cn(
45
+ "flex flex-row bg-background text-foreground",
46
+ fill === true ? "h-screen" : "min-h-screen",
47
+ className,
48
+ )}
23
49
  >
24
50
  {sidebar}
25
- <div className="flex flex-1 min-w-0 flex-col">
51
+ <div className={cn("flex flex-1 min-w-0 flex-col", fill === true && "min-h-0")}>
26
52
  {topbar}
27
53
  {/* main hat KEIN Padding — Screens (Form, Liste, Demo-Pages)
28
54
  managen ihr Padding selber, damit Action-Bars edge-to-edge
29
55
  spannen können ohne Negative-Margin-Tricks die mit
30
56
  `position: sticky` kollidieren. */}
31
- <main className="flex-1 overflow-auto">{children}</main>
57
+ <main className={cn("flex-1 overflow-auto", fill === true && "min-h-0", mainClassName)}>
58
+ {children}
59
+ </main>
32
60
  </div>
33
61
  </div>
34
62
  );
@@ -44,6 +44,10 @@ export type DefaultAppShellProps = {
44
44
  /** Footer-Slot unten in der Sidebar — Profile-Row, Help-Link,
45
45
  * Plan-Banner. Klebt am unteren Rand via `mt-auto`. */
46
46
  readonly sidebarFooter?: ReactNode;
47
+ /** Viewport-fit Shell — durchgereicht an AppLayout. true → fixe
48
+ * Viewport-Höhe, Content scrollt innen statt der ganzen Seite.
49
+ * Default false (klassischer Seiten-Scroll). Siehe AppLayout.fill. */
50
+ readonly fill?: boolean;
47
51
  /** Screen-Content der in `main` gerendert wird. */
48
52
  readonly children: ReactNode;
49
53
  };
@@ -54,10 +58,12 @@ export function DefaultAppShell({
54
58
  user,
55
59
  sidebarActions,
56
60
  sidebarFooter,
61
+ fill,
57
62
  children,
58
63
  }: DefaultAppShellProps): ReactNode {
59
64
  return (
60
65
  <AppLayout
66
+ {...(fill !== undefined && { fill })}
61
67
  sidebar={
62
68
  <Sidebar
63
69
  header={brand}
@@ -16,11 +16,72 @@ import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
16
16
  import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
17
17
  import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
18
18
  import { lastSegment, toAppSchema, useNav, useTranslation } from "@cosmicdrift/kumiko-renderer";
19
- import { ChevronDown, ChevronRight } from "lucide-react";
19
+ import {
20
+ BarChart3,
21
+ Bell,
22
+ Calculator,
23
+ CalendarDays,
24
+ ChevronDown,
25
+ ChevronRight,
26
+ Coins,
27
+ CreditCard,
28
+ FileText,
29
+ Folder,
30
+ Gauge,
31
+ Home,
32
+ LayoutDashboard,
33
+ LineChart,
34
+ List,
35
+ PiggyBank,
36
+ Receipt,
37
+ Search,
38
+ Settings,
39
+ Shield,
40
+ Sparkles,
41
+ Table,
42
+ TrendingUp,
43
+ User,
44
+ Users,
45
+ Wallet,
46
+ Wand2,
47
+ } from "lucide-react";
20
48
  import { type ReactNode, useCallback, useMemo, useState } from "react";
21
49
  import { KumikoLink } from "../app/nav";
22
50
  import { cn } from "../lib/cn";
23
51
 
52
+ // Nav-Icon-Registry: ein Nav-Eintrag setzt `icon: "<key>"` (im r.nav-Decl),
53
+ // der Renderer mappt den symbolischen Key auf ein lucide-Component. Unknown
54
+ // Keys → kein Icon (sauberer Fallback auf den Dot, kein Boot-Fail). Spiegelt
55
+ // das NODE_ICONS-Muster vom Visual-Tree. App-Authors referenzieren nur diese
56
+ // Keys; Erweiterung = neuer Eintrag hier (eine Quelle, alle Apps).
57
+ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
58
+ dashboard: LayoutDashboard,
59
+ gauge: Gauge,
60
+ list: List,
61
+ table: Table,
62
+ calculator: Calculator,
63
+ wallet: Wallet,
64
+ coins: Coins,
65
+ "credit-card": CreditCard,
66
+ "piggy-bank": PiggyBank,
67
+ receipt: Receipt,
68
+ chart: LineChart,
69
+ "bar-chart": BarChart3,
70
+ trending: TrendingUp,
71
+ sparkles: Sparkles,
72
+ wand: Wand2,
73
+ calendar: CalendarDays,
74
+ file: FileText,
75
+ folder: Folder,
76
+ home: Home,
77
+ bell: Bell,
78
+ shield: Shield,
79
+ settings: Settings,
80
+ users: Users,
81
+ user: User,
82
+ search: Search,
83
+ };
84
+
24
85
  export type NavTreeProps = {
25
86
  // Akzeptiert beide Shapes — AppSchema (multi-feature) oder
26
87
  // FeatureSchema (legacy single-feature). toAppSchema normalisiert
@@ -82,6 +143,7 @@ function NavNodeItem({ node, depth, collapsed, onToggle }: NavNodeItemProps): Re
82
143
  const t = useTranslation();
83
144
  const active = node.screen !== undefined && nav.route?.screenId === lastSegment(node.screen);
84
145
 
146
+ const NavIcon = node.icon !== undefined ? NAV_ICONS[node.icon] : undefined;
85
147
  const hasChildren = node.children.length > 0;
86
148
  const isCollapsed = collapsed.has(node.qualifiedName);
87
149
  const indent = { paddingLeft: `${0.5 + depth * 1}rem` };
@@ -156,13 +218,23 @@ function NavNodeItem({ node, depth, collapsed, onToggle }: NavNodeItemProps): Re
156
218
  )}
157
219
  {...(active && { "aria-current": "page" })}
158
220
  >
159
- <span
160
- aria-hidden="true"
161
- className={cn(
162
- "inline-block size-1.5 rounded-full",
163
- active ? "bg-accent-foreground" : "bg-muted-foreground/40",
164
- )}
165
- />
221
+ {NavIcon !== undefined ? (
222
+ <NavIcon
223
+ aria-hidden="true"
224
+ className={cn(
225
+ "size-4 shrink-0",
226
+ active ? "text-foreground" : "text-muted-foreground",
227
+ )}
228
+ />
229
+ ) : (
230
+ <span
231
+ aria-hidden="true"
232
+ className={cn(
233
+ "inline-block size-1.5 rounded-full",
234
+ active ? "bg-accent-foreground" : "bg-muted-foreground/40",
235
+ )}
236
+ />
237
+ )}
166
238
  <span className="truncate">{displayLabel}</span>
167
239
  </KumikoLink>
168
240
  {chevron !== null && <div className="pr-2">{chevron}</div>}