@cosmicdrift/kumiko-renderer-web 0.233.0 → 0.235.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.
@@ -129,6 +129,69 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
129
129
  expect(screen.queryByTestId("kumiko-screen-projection-detail-title")).toBeNull();
130
130
  expect(screen.queryByTestId("kumiko-screen-projection-detail-metrics")).toBeNull();
131
131
  });
132
+
133
+ test("colors the status badge via the StatusTone heuristic for a recognized value", async () => {
134
+ const headerScreen: ProjectionDetailScreenDefinition = {
135
+ ...baseScreen,
136
+ header: { title: "tenantName", status: "state" },
137
+ };
138
+ const dispatcher = dispatcherReturning({ ...rowData, state: "overdue" });
139
+
140
+ render(
141
+ <DispatcherProvider dispatcher={dispatcher}>
142
+ <KumikoScreen
143
+ schema={schemaFor(headerScreen)}
144
+ qn="rentals:screen:rent-detail"
145
+ entityId="rent-1"
146
+ />
147
+ </DispatcherProvider>,
148
+ );
149
+
150
+ await waitFor(() => screen.getByTestId("render-edit-form"));
151
+ expect(screen.getByTestId("kumiko-screen-projection-detail-status").className).toContain(
152
+ "text-status-bad",
153
+ );
154
+ });
155
+
156
+ test("an unmapped status value falls back to the muted tone", async () => {
157
+ const headerScreen: ProjectionDetailScreenDefinition = {
158
+ ...baseScreen,
159
+ header: { title: "tenantName", status: "state" },
160
+ };
161
+ const dispatcher = dispatcherReturning({ ...rowData, state: "archived" });
162
+
163
+ render(
164
+ <DispatcherProvider dispatcher={dispatcher}>
165
+ <KumikoScreen
166
+ schema={schemaFor(headerScreen)}
167
+ qn="rentals:screen:rent-detail"
168
+ entityId="rent-1"
169
+ />
170
+ </DispatcherProvider>,
171
+ );
172
+
173
+ await waitFor(() => screen.getByTestId("render-edit-form"));
174
+ expect(screen.getByTestId("kumiko-screen-projection-detail-status").className).toContain(
175
+ "text-muted-foreground",
176
+ );
177
+ });
178
+
179
+ test("read-only detail screens render no footer action bar", async () => {
180
+ const dispatcher = dispatcherReturning(rowData);
181
+
182
+ render(
183
+ <DispatcherProvider dispatcher={dispatcher}>
184
+ <KumikoScreen
185
+ schema={schemaFor(baseScreen)}
186
+ qn="rentals:screen:rent-detail"
187
+ entityId="rent-1"
188
+ />
189
+ </DispatcherProvider>,
190
+ );
191
+
192
+ await waitFor(() => screen.getByTestId("render-edit-form"));
193
+ expect(screen.queryByTestId("render-edit-form-actions")).toBeNull();
194
+ });
132
195
  });
133
196
 
134
197
  describe("KumikoScreen / projectionDetail — metric tiles render through the Metric primitive", () => {
@@ -399,6 +462,58 @@ describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
399
462
 
400
463
  expect(setSearchParamsCalls).toContainEqual({ tab: "payments" });
401
464
  });
465
+
466
+ test("relatedList tab content renders without its own Section card wrapper", async () => {
467
+ const dispatcher = dispatcherReturning(rowData);
468
+ const { navApi } = navWithTab("payments");
469
+
470
+ render(
471
+ <NavProvider value={navApi}>
472
+ <DispatcherProvider dispatcher={dispatcher}>
473
+ <KumikoScreen
474
+ schema={schemaFor(tabsScreen)}
475
+ qn="rentals:screen:rent-detail"
476
+ entityId="rent-1"
477
+ />
478
+ </DispatcherProvider>
479
+ </NavProvider>,
480
+ );
481
+
482
+ await waitFor(() =>
483
+ expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
484
+ );
485
+ // hideTitle (tabs mode) drops the Section wrapper — DataTable already
486
+ // draws its own card frame, so a second nested Card would double it up.
487
+ expect(screen.queryByTestId("related-list-Payments")).toBeNull();
488
+ });
489
+
490
+ test("without a Tabs primitive, falls back to the stacked all-sections layout instead of truncating to the first section (fw#2501)", async () => {
491
+ const dispatcher = dispatcherReturning(rowData);
492
+ const { navApi } = navWithTab(undefined);
493
+
494
+ renderWithPrimitivesOverride(
495
+ <NavProvider value={navApi}>
496
+ <DispatcherProvider dispatcher={dispatcher}>
497
+ <KumikoScreen
498
+ schema={schemaFor(tabsScreen)}
499
+ qn="rentals:screen:rent-detail"
500
+ entityId="rent-1"
501
+ />
502
+ </DispatcherProvider>
503
+ </NavProvider>,
504
+ { Tabs: undefined },
505
+ );
506
+
507
+ await waitFor(() => screen.getByTestId("field-description"));
508
+ // Pre-fix: activeSection stayed truncated to sections[0] even without a
509
+ // Tabs primitive to switch away from it — payments/invoices content was
510
+ // permanently unreachable, with no tab strip to reach it either.
511
+ await waitFor(() =>
512
+ expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
513
+ );
514
+ expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:invoices")).toBe(true);
515
+ expect(screen.queryByTestId("kumiko-screen-projection-detail-tabs")).toBeNull();
516
+ });
402
517
  });
403
518
 
404
519
  // Only guard against a solon-shaped screen (relatedList-heavy) silently regressing.
@@ -448,6 +563,10 @@ describe("KumikoScreen / projectionDetail — unchanged for a solon-shaped scree
448
563
  // Contrast for the tabs-mode "form title suppressed" test above — outside
449
564
  // tabs mode (hideSectionTitles unset) the form's own title still renders.
450
565
  expect(screen.getByTestId("render-edit-form-title")).toBeTruthy();
566
+ // Contrast for the tabs-mode "no Section wrapper" test above — a stacked
567
+ // (non-tabs) relatedList section has a visible title, so it keeps its
568
+ // own Section card.
569
+ expect(screen.getByTestId("related-list-Payments")).toBeTruthy();
451
570
  });
452
571
  });
453
572
 
@@ -119,8 +119,8 @@ describe("RenderEdit", () => {
119
119
  // notes is hidden (isUrgent=false): title, count, isUrgent → 3 cells.
120
120
  expect(grid?.children.length).toBe(3);
121
121
 
122
- const urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
123
- fireEvent.click(urgentCheckbox as HTMLElement);
122
+ const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
123
+ fireEvent.click(urgentSwitch as HTMLElement);
124
124
 
125
125
  // notes becomes visible → 4 cells, no leftover empty cell from before.
126
126
  expect(grid?.children.length).toBe(4);
@@ -240,10 +240,9 @@ describe("RenderEdit", () => {
240
240
  );
241
241
 
242
242
  expect(screen.queryByTestId("field-notes")).toBeNull();
243
- // boolean-Feld = vendored Radix-Checkbox → button[role=checkbox], kein
244
- // native input[type=checkbox] mehr.
245
- const urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
246
- fireEvent.click(urgentCheckbox as HTMLElement);
243
+ // boolean field in a standard form = vendored Radix Switch → button[role=switch].
244
+ const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
245
+ fireEvent.click(urgentSwitch as HTMLElement);
247
246
  expect(screen.queryByTestId("field-notes")).toBeTruthy();
248
247
  });
249
248
 
@@ -284,7 +283,7 @@ describe("RenderEdit", () => {
284
283
  expect(seenResults[0]?.isSuccess).toBe(true);
285
284
  });
286
285
 
287
- test("layout.width defaults the form shell to max-w-full when unset", () => {
286
+ test("layout.width defaults the form shell to max-w-4xl when unset", () => {
288
287
  render(
289
288
  <DispatcherProvider dispatcher={makeDispatcher()}>
290
289
  <RenderEdit<TestValues>
@@ -298,7 +297,7 @@ describe("RenderEdit", () => {
298
297
  );
299
298
 
300
299
  const shell = screen.getByTestId("render-edit-form").firstElementChild;
301
- expect(shell?.className).toContain("max-w-full");
300
+ expect(shell?.className).toContain("max-w-4xl");
302
301
  expect(shell?.className).not.toContain("max-w-3xl");
303
302
  });
304
303
 
@@ -2858,8 +2857,8 @@ describe("RenderEdit locked state (#1896)", () => {
2858
2857
  expect((titleInput as HTMLInputElement).disabled).toBe(true);
2859
2858
  const countInput = screen.getByTestId("field-count").querySelector("input");
2860
2859
  expect((countInput as HTMLInputElement).disabled).toBe(true);
2861
- const urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
2862
- expect((urgentCheckbox as HTMLButtonElement).disabled).toBe(true);
2860
+ const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
2861
+ expect((urgentSwitch as HTMLButtonElement).disabled).toBe(true);
2863
2862
  expect((screen.getByTestId("render-edit-submit") as HTMLButtonElement).disabled).toBe(true);
2864
2863
  });
2865
2864
 
@@ -0,0 +1,287 @@
1
+ //
2
+ // Unit tests for the secretsEdit screen type. Secrets are write-only — the
3
+ // security invariant this file exists to pin: an input NEVER starts pre-filled
4
+ // with the redacted preview, and no dispatched payload ever carries that
5
+ // preview string next to the plaintext the user typed.
6
+ //
7
+ // `as unknown as Dispatcher["query"/"batch"/"write"]` throughout: each inline
8
+ // mock lambda only implements the one overload a given test exercises, never
9
+ // the full overloaded Dispatcher signature — the missing overloads are never
10
+ // called at runtime.
11
+
12
+ import { describe, expect, mock, test } from "bun:test";
13
+ import type { SecretsEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
14
+ import type { Dispatcher, DispatcherError } from "@cosmicdrift/kumiko-headless";
15
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
16
+ import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
17
+ import userEvent from "@testing-library/user-event";
18
+ import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
19
+
20
+ const secretsScreen: SecretsEditScreenDefinition = {
21
+ id: "secrets",
22
+ type: "secretsEdit",
23
+ secretKeys: { "stripe-api-key": "stripe:secret:api-key" },
24
+ fieldLabels: { "stripe-api-key": "config.secret.stripe.api-key.label" },
25
+ sections: [{ fields: ["stripe-api-key"] }],
26
+ };
27
+
28
+ const schema: FeatureSchema = {
29
+ featureName: "config",
30
+ entities: {},
31
+ screens: [secretsScreen],
32
+ };
33
+
34
+ describe("KumikoScreen / secretsEdit", () => {
35
+ test("a set secret shows its redacted preview while the input stays empty", async () => {
36
+ const dispatcher: Dispatcher = createMockDispatcher({
37
+ query: (async () => ({
38
+ isSuccess: true,
39
+ data: [{ key: "stripe:secret:api-key", redactedPreview: "sk_***abc", hint: null }],
40
+ })) as unknown as Dispatcher["query"],
41
+ });
42
+
43
+ render(
44
+ <DispatcherProvider dispatcher={dispatcher}>
45
+ <KumikoScreen schema={schema} qn="config:screen:secrets" />
46
+ </DispatcherProvider>,
47
+ );
48
+
49
+ await waitFor(() => screen.getByTestId("secrets-edit-form"));
50
+ await waitFor(() => screen.getByText("sk_***abc"));
51
+ const input = screen.getByTestId("secret-input-stripe-api-key") as HTMLInputElement;
52
+ expect(input.value).toBe("");
53
+ });
54
+
55
+ test("submitting with no input dispatches nothing", async () => {
56
+ const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
57
+ isSuccess: true as const,
58
+ results: [],
59
+ }));
60
+ const dispatcher: Dispatcher = createMockDispatcher({
61
+ query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
62
+ batch: batchSpy as unknown as Dispatcher["batch"],
63
+ });
64
+
65
+ const user = userEvent.setup();
66
+ render(
67
+ <DispatcherProvider dispatcher={dispatcher}>
68
+ <KumikoScreen schema={schema} qn="config:screen:secrets" />
69
+ </DispatcherProvider>,
70
+ );
71
+
72
+ await waitFor(() => screen.getByTestId("secrets-edit-form"));
73
+ await user.click(screen.getByTestId("secrets-edit-submit"));
74
+ expect(batchSpy).not.toHaveBeenCalled();
75
+ });
76
+
77
+ test("typing a value and saving dispatches exactly one secrets:write:set with the plaintext, never the redacted preview", async () => {
78
+ const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
79
+ isSuccess: true as const,
80
+ results: [],
81
+ }));
82
+ const dispatcher: Dispatcher = createMockDispatcher({
83
+ query: (async () => ({
84
+ isSuccess: true,
85
+ data: [{ key: "stripe:secret:api-key", redactedPreview: "sk_***abc", hint: null }],
86
+ })) as unknown as Dispatcher["query"],
87
+ batch: batchSpy as unknown as Dispatcher["batch"],
88
+ });
89
+
90
+ const user = userEvent.setup();
91
+ render(
92
+ <DispatcherProvider dispatcher={dispatcher}>
93
+ <KumikoScreen schema={schema} qn="config:screen:secrets" />
94
+ </DispatcherProvider>,
95
+ );
96
+
97
+ await waitFor(() => screen.getByTestId("secrets-edit-form"));
98
+ const input = screen.getByTestId("secret-input-stripe-api-key");
99
+ await user.type(input, "sk_live_newvalue");
100
+ await user.click(screen.getByTestId("secrets-edit-submit"));
101
+
102
+ await waitFor(() => expect(batchSpy).toHaveBeenCalledTimes(1));
103
+ const commands = batchSpy.mock.calls[0]?.[0];
104
+ if (!commands) throw new Error("batchSpy not called");
105
+ expect(commands).toEqual([
106
+ {
107
+ type: "secrets:write:set",
108
+ payload: { key: "stripe:secret:api-key", value: "sk_live_newvalue" },
109
+ },
110
+ ]);
111
+ // The security invariant this test exists for: the preview never rides
112
+ // along in a write payload next to the plaintext.
113
+ expect(JSON.stringify(commands)).not.toContain("sk_***abc");
114
+ });
115
+
116
+ test("an unset required secret shows the required marker; a set one does not", async () => {
117
+ const requiredScreen: SecretsEditScreenDefinition = {
118
+ ...secretsScreen,
119
+ requiredFields: ["stripe-api-key"],
120
+ };
121
+ const requiredSchema: FeatureSchema = {
122
+ featureName: "config",
123
+ entities: {},
124
+ screens: [requiredScreen],
125
+ };
126
+
127
+ const dispatcher: Dispatcher = createMockDispatcher({
128
+ query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
129
+ });
130
+
131
+ render(
132
+ <DispatcherProvider dispatcher={dispatcher}>
133
+ <KumikoScreen schema={requiredSchema} qn="config:screen:secrets" />
134
+ </DispatcherProvider>,
135
+ );
136
+
137
+ await waitFor(() => screen.getByTestId("secrets-edit-form"));
138
+ await waitFor(() => screen.getByTestId("required-marker-stripe-api-key"));
139
+ });
140
+
141
+ test("a set required secret does not show the required marker", async () => {
142
+ const requiredScreen: SecretsEditScreenDefinition = {
143
+ ...secretsScreen,
144
+ requiredFields: ["stripe-api-key"],
145
+ };
146
+ const requiredSchema: FeatureSchema = {
147
+ featureName: "config",
148
+ entities: {},
149
+ screens: [requiredScreen],
150
+ };
151
+ const qualifiedKey = requiredScreen.secretKeys["stripe-api-key"];
152
+
153
+ const dispatcher: Dispatcher = createMockDispatcher({
154
+ query: (async () => ({
155
+ isSuccess: true,
156
+ data: [{ key: qualifiedKey, redactedPreview: "sk_***abc", hint: null }],
157
+ })) as unknown as Dispatcher["query"],
158
+ });
159
+
160
+ render(
161
+ <DispatcherProvider dispatcher={dispatcher}>
162
+ <KumikoScreen schema={requiredSchema} qn="config:screen:secrets" />
163
+ </DispatcherProvider>,
164
+ );
165
+
166
+ await waitFor(() => screen.getByTestId("secrets-edit-form"));
167
+ await waitFor(() => screen.getByText("sk_***abc"));
168
+ expect(screen.queryByTestId("required-marker-stripe-api-key")).toBeNull();
169
+ });
170
+
171
+ test("an unset required secret does not block saving a different secret", async () => {
172
+ const twoFieldScreen: SecretsEditScreenDefinition = {
173
+ id: "secrets",
174
+ type: "secretsEdit",
175
+ secretKeys: {
176
+ "stripe-api-key": "stripe:secret:[REDACTED:API key param]",
177
+ "webhook-secret": "stripe:secret:[REDACTED:webhook param]",
178
+ },
179
+ fieldLabels: {
180
+ "stripe-api-key": "config.secret.stripe.api-key.label",
181
+ "webhook-secret": "config.secret.stripe.webhook.label",
182
+ },
183
+ sections: [{ fields: ["stripe-api-key", "webhook-secret"] }],
184
+ requiredFields: ["stripe-api-key"],
185
+ };
186
+ const twoFieldSchema: FeatureSchema = {
187
+ featureName: "config",
188
+ entities: {},
189
+ screens: [twoFieldScreen],
190
+ };
191
+ const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
192
+ isSuccess: true as const,
193
+ results: [],
194
+ }));
195
+ const dispatcher: Dispatcher = createMockDispatcher({
196
+ query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
197
+ batch: batchSpy as unknown as Dispatcher["batch"],
198
+ });
199
+
200
+ const user = userEvent.setup();
201
+ render(
202
+ <DispatcherProvider dispatcher={dispatcher}>
203
+ <KumikoScreen schema={twoFieldSchema} qn="config:screen:secrets" />
204
+ </DispatcherProvider>,
205
+ );
206
+
207
+ await waitFor(() => screen.getByTestId("secrets-edit-form"));
208
+ const requiredInput = screen.getByTestId("secret-input-stripe-api-key") as HTMLInputElement;
209
+ expect(requiredInput.required).toBe(false);
210
+
211
+ await user.type(screen.getByTestId("secret-input-webhook-secret"), "whsec_newvalue");
212
+ await user.click(screen.getByTestId("secrets-edit-submit"));
213
+
214
+ await waitFor(() => expect(batchSpy).toHaveBeenCalledTimes(1));
215
+ const commands = batchSpy.mock.calls[0]?.[0];
216
+ if (!commands) throw new Error("batchSpy not called");
217
+ expect(commands).toEqual([
218
+ {
219
+ type: "secrets:write:set",
220
+ payload: { key: "stripe:secret:[REDACTED:webhook param]", value: "whsec_newvalue" },
221
+ },
222
+ ]);
223
+ });
224
+
225
+ test("delete dispatches secrets:write:delete with the qualified key", async () => {
226
+ const writeSpy = mock(async (_type: string, _payload: unknown) => ({
227
+ isSuccess: true as const,
228
+ data: {},
229
+ }));
230
+ const dispatcher: Dispatcher = createMockDispatcher({
231
+ query: (async () => ({
232
+ isSuccess: true,
233
+ data: [{ key: "stripe:secret:api-key", redactedPreview: "sk_***abc", hint: null }],
234
+ })) as unknown as Dispatcher["query"],
235
+ write: writeSpy as unknown as Dispatcher["write"],
236
+ });
237
+
238
+ const user = userEvent.setup();
239
+ render(
240
+ <DispatcherProvider dispatcher={dispatcher}>
241
+ <KumikoScreen schema={schema} qn="config:screen:secrets" />
242
+ </DispatcherProvider>,
243
+ );
244
+
245
+ await waitFor(() => screen.getByTestId("secrets-edit-form"));
246
+ await user.click(screen.getByTestId("secret-delete-stripe-api-key"));
247
+
248
+ await waitFor(() => expect(writeSpy).toHaveBeenCalledTimes(1));
249
+ expect(writeSpy).toHaveBeenCalledWith("secrets:write:delete", { key: "stripe:secret:api-key" });
250
+ });
251
+
252
+ test("a failed save shows the error and never renders the typed plaintext", async () => {
253
+ const plaintext = "sk_live_wouldbeleakedifechoed";
254
+ const serverError: DispatcherError = {
255
+ code: "TENANT_SECRET_WRITE_CONFLICT",
256
+ httpStatus: 409,
257
+ i18nKey: "secrets:errors.writeConflict",
258
+ message: "Could not save secret: a concurrent update conflicted, please retry.",
259
+ };
260
+ const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
261
+ isSuccess: false as const,
262
+ error: serverError,
263
+ }));
264
+ const dispatcher: Dispatcher = createMockDispatcher({
265
+ query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
266
+ batch: batchSpy as unknown as Dispatcher["batch"],
267
+ });
268
+
269
+ const user = userEvent.setup();
270
+ render(
271
+ <DispatcherProvider dispatcher={dispatcher}>
272
+ <KumikoScreen schema={schema} qn="config:screen:secrets" />
273
+ </DispatcherProvider>,
274
+ );
275
+
276
+ await waitFor(() => screen.getByTestId("secrets-edit-form"));
277
+ const input = screen.getByTestId("secret-input-stripe-api-key");
278
+ await user.type(input, plaintext);
279
+ await user.click(screen.getByTestId("secrets-edit-submit"));
280
+
281
+ await waitFor(() => screen.getByTestId("secrets-edit-error"));
282
+ expect(screen.getByTestId("secrets-edit-error").textContent).toContain(serverError.message);
283
+ // The drafted plaintext must never leak into the error surface (or
284
+ // anywhere else in the DOM besides the input's own `value`).
285
+ expect(document.body.textContent).not.toContain(plaintext);
286
+ });
287
+ });
@@ -440,13 +440,12 @@ describe("WorkspaceShell", () => {
440
440
  expect(screen.queryByText("Tours")).toBeNull();
441
441
  });
442
442
 
443
- test("fill=true applies h-svh on root and min-h-0 on inset/main", () => {
443
+ test("fill defaults to true: applies h-svh on root and min-h-0 on inset/main", () => {
444
444
  renderShell(
445
445
  <WorkspaceShell
446
446
  brand={<div>Brand</div>}
447
447
  schema={schema}
448
448
  user={{ id: "u1", roles: ["admin"] }}
449
- fill
450
449
  >
451
450
  <div>content</div>
452
451
  </WorkspaceShell>,
@@ -462,13 +461,15 @@ describe("WorkspaceShell", () => {
462
461
  expect(innerMain?.classList.contains("overflow-auto")).toBe(true);
463
462
  });
464
463
 
465
- // #1692: default (no fill) must not force viewport-lock classes
466
- test("without fill does not apply h-svh or min-h-0", () => {
464
+ // fill={false} is the escape hatch back to page-scroll: must not apply
465
+ // viewport-lock classes.
466
+ test("fill={false} does not apply h-svh or min-h-0", () => {
467
467
  renderShell(
468
468
  <WorkspaceShell
469
469
  brand={<div>Brand</div>}
470
470
  schema={schema}
471
471
  user={{ id: "u1", roles: ["admin"] }}
472
+ fill={false}
472
473
  >
473
474
  <div>content</div>
474
475
  </WorkspaceShell>,
package/src/icons.tsx CHANGED
@@ -1,5 +1,20 @@
1
+ // Icon registry: symbolic `NavIconKey` → lucide-react component. `NavIconKey`
2
+ // is the closed vocabulary a feature author can write (packages/types/src/
3
+ // nav-icon.ts); `satisfies` below makes this map a compile-time drift
4
+ // guard — a key added to one without the other fails the build.
5
+ //
6
+ // Runtime data (provider-supplied nav/action icons) is only typed as plain
7
+ // `string` at the resolved-tree layer, so an unknown key can still show up
8
+ // at runtime on occasion — `Icon` falls back to rendering the raw key as
9
+ // text rather than crashing, mirroring the old `ActionGlyph` behavior in
10
+ // nav-tree.tsx.
11
+ //
12
+ // Buttons resolve icons through the same map — importing it from the nav
13
+ // layout module would couple them to the sidebar.
1
14
  import type { NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
2
15
  import {
16
+ AlertTriangle,
17
+ Archive,
3
18
  ArrowLeft,
4
19
  ArrowRight,
5
20
  BarChart3,
@@ -9,17 +24,27 @@ import {
9
24
  Calculator,
10
25
  CalendarDays,
11
26
  Check,
27
+ CheckCircle2,
28
+ ChevronDown,
29
+ ChevronRight,
12
30
  ClipboardList,
31
+ Clock,
13
32
  Coins,
14
33
  Copy,
15
34
  CreditCard,
16
35
  Download,
36
+ ExternalLink,
37
+ Eye,
38
+ EyeOff,
17
39
  FileText,
40
+ Filter,
41
+ Flag,
18
42
  Folder,
19
43
  FolderOpen,
20
44
  Gauge,
21
45
  Hash,
22
46
  Home,
47
+ Info,
23
48
  KeyRound,
24
49
  Languages,
25
50
  Layers,
@@ -28,14 +53,23 @@ import {
28
53
  LineChart,
29
54
  Link,
30
55
  List,
56
+ Loader2,
31
57
  Lock,
32
58
  Mail,
59
+ MapPin,
60
+ MoreHorizontal,
61
+ MoreVertical,
33
62
  Package,
34
63
  Palette,
64
+ Pencil,
65
+ Phone,
35
66
  PiggyBank,
36
67
  Plus,
68
+ Printer,
37
69
  Receipt,
70
+ RefreshCw,
38
71
  Rocket,
72
+ Save,
39
73
  Search,
40
74
  Send,
41
75
  Server,
@@ -44,19 +78,23 @@ import {
44
78
  Shield,
45
79
  ShieldCheck,
46
80
  Sparkles,
81
+ Star,
47
82
  Table,
48
83
  Tag,
49
84
  Trash2,
50
85
  TrendingUp,
86
+ Undo2,
51
87
  Upload,
52
88
  User,
53
89
  Users,
54
90
  Wallet,
55
91
  Wand2,
56
92
  X,
93
+ XCircle,
57
94
  } from "lucide-react";
95
+ import type { ReactNode } from "react";
96
+ import { cn } from "./lib/cn";
58
97
 
59
- // Buttons resolve icons through the same map, importing it from the nav layout module would couple them to the sidebar.
60
98
  export const NAV_ICONS = {
61
99
  dashboard: LayoutDashboard,
62
100
  "layout-grid": LayoutGrid,
@@ -113,4 +151,49 @@ export const NAV_ICONS = {
113
151
  "arrow-left": ArrowLeft,
114
152
  "arrow-right": ArrowRight,
115
153
  copy: Copy,
154
+ pencil: Pencil,
155
+ eye: Eye,
156
+ "eye-off": EyeOff,
157
+ filter: Filter,
158
+ refresh: RefreshCw,
159
+ "more-horizontal": MoreHorizontal,
160
+ "more-vertical": MoreVertical,
161
+ "external-link": ExternalLink,
162
+ "chevron-down": ChevronDown,
163
+ "chevron-right": ChevronRight,
164
+ save: Save,
165
+ undo: Undo2,
166
+ archive: Archive,
167
+ star: Star,
168
+ flag: Flag,
169
+ clock: Clock,
170
+ "map-pin": MapPin,
171
+ phone: Phone,
172
+ printer: Printer,
173
+ "alert-triangle": AlertTriangle,
174
+ info: Info,
175
+ "check-circle": CheckCircle2,
176
+ "x-circle": XCircle,
177
+ loader: Loader2,
116
178
  } as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
179
+
180
+ // Widened alias for runtime lookups against the plain `string` icon keys
181
+ // resolved-tree nodes carry — not the closed NavIconKey union NAV_ICONS
182
+ // itself is typed against.
183
+ const ICON_LOOKUP: Readonly<Record<string, typeof Folder | undefined>> = NAV_ICONS;
184
+
185
+ export function Icon({
186
+ name,
187
+ className,
188
+ }: {
189
+ readonly name: NavIconKey;
190
+ readonly className?: string;
191
+ }): ReactNode {
192
+ const LucideIcon = Object.hasOwn(NAV_ICONS, name) ? ICON_LOOKUP[name] : undefined;
193
+ if (LucideIcon !== undefined) return <LucideIcon aria-hidden="true" className={className} />;
194
+ return (
195
+ <span aria-hidden="true" className={cn("text-xs", className)}>
196
+ {name}
197
+ </span>
198
+ );
199
+ }
@@ -13,12 +13,12 @@ export type AppLayoutProps = {
13
13
  readonly sidebar?: ReactNode;
14
14
  readonly children: ReactNode;
15
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. */
16
+ /** Viewport-fit Shell. Default `true` → Wurzel = `h-screen` (fixe
17
+ * Viewport-Höhe), Sidebar/Topbar bleiben stehen, der Main-Bereich
18
+ * scrollt INNEN (`min-h-0` + `overflow-auto`). `false` → klassischer
19
+ * `min-h-screen`-Flow, die ganze Seite scrollt — Escape-Hatch für
20
+ * eine öffentliche, lange Content-Seite die echten Body-Scroll
21
+ * braucht. Clippt nie — der Content scrollt in `main` statt im Body. */
22
22
  readonly fill?: boolean;
23
23
  /** Optionaler Klassen-Append an die Wurzel (eigener Hintergrund etc.).
24
24
  * Erweitert die Defaults, ersetzt sie nicht (cn-merge). */
@@ -32,7 +32,7 @@ export function AppLayout({
32
32
  sidebar,
33
33
  children,
34
34
  testId,
35
- fill,
35
+ fill = true,
36
36
  className,
37
37
  mainClassName,
38
38
  }: AppLayoutProps): ReactNode {