@cosmicdrift/kumiko-framework 0.224.1 → 0.225.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-framework",
3
- "version": "0.224.1",
3
+ "version": "0.225.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.224.1",
201
+ "@cosmicdrift/kumiko-types": "0.225.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.224.1",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.225.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -4099,7 +4099,7 @@ describe("boot-validator — config key backing × scope", () => {
4099
4099
  expect(() => validateBoot([feature])).not.toThrow();
4100
4100
  });
4101
4101
 
4102
- test("navigate with params targeting a dashboard screen → Throw", () => {
4102
+ test("navigate with params targeting a dashboard screen without a filter → Throw (params would be a no-op)", () => {
4103
4103
  const feature = defineFeature("shop", (r) => {
4104
4104
  r.entity("product", createEntity({ fields: { name: createTextField() } }));
4105
4105
  r.screen({
@@ -4135,7 +4135,97 @@ describe("boot-validator — config key backing × scope", () => {
4135
4135
  });
4136
4136
  });
4137
4137
  expect(() => validateBoot([feature])).toThrow(
4138
- /sets params on navigate-target "product-dashboard".*screen type "dashboard".*only actionForm and entityEdit-create/,
4138
+ /sets params on navigate-target "product-dashboard" \(dashboard\).*declares no filter.*no-op/,
4139
+ );
4140
+ });
4141
+
4142
+ test("navigate with params targeting a dashboard screen with a matching filter → no throw (useFilterParams reads it, fw#1708 follow-up)", () => {
4143
+ const feature = defineFeature("shop", (r) => {
4144
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
4145
+ r.screen({
4146
+ id: "product-list",
4147
+ type: "entityList",
4148
+ entity: "product",
4149
+ columns: ["name"],
4150
+ rowActions: [
4151
+ {
4152
+ kind: "navigate",
4153
+ id: "view",
4154
+ label: "actions.view",
4155
+ screen: "product-dashboard",
4156
+ params: { map: { category: "name" } },
4157
+ },
4158
+ ],
4159
+ });
4160
+ r.queryHandler("count", z.object({}), async () => ({ total: 0 }), {
4161
+ access: { openToAll: true },
4162
+ });
4163
+ r.screen({
4164
+ id: "product-dashboard",
4165
+ type: "dashboard",
4166
+ filter: {
4167
+ id: "category",
4168
+ label: "Category",
4169
+ kind: "select",
4170
+ options: [{ value: "a", label: "A" }],
4171
+ },
4172
+ panels: [
4173
+ {
4174
+ kind: "stat",
4175
+ id: "count",
4176
+ label: "Products",
4177
+ query: "shop:query:count",
4178
+ valueField: "total",
4179
+ },
4180
+ ],
4181
+ });
4182
+ });
4183
+ expect(() => validateBoot([feature])).not.toThrow();
4184
+ });
4185
+
4186
+ test("navigate with params targeting a dashboard screen whose filter id doesn't match any extracted key → Throw", () => {
4187
+ const feature = defineFeature("shop", (r) => {
4188
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
4189
+ r.screen({
4190
+ id: "product-list",
4191
+ type: "entityList",
4192
+ entity: "product",
4193
+ columns: ["name"],
4194
+ rowActions: [
4195
+ {
4196
+ kind: "navigate",
4197
+ id: "view",
4198
+ label: "actions.view",
4199
+ screen: "product-dashboard",
4200
+ params: { map: { productName: "name" } },
4201
+ },
4202
+ ],
4203
+ });
4204
+ r.queryHandler("count", z.object({}), async () => ({ total: 0 }), {
4205
+ access: { openToAll: true },
4206
+ });
4207
+ r.screen({
4208
+ id: "product-dashboard",
4209
+ type: "dashboard",
4210
+ filter: {
4211
+ id: "category",
4212
+ label: "Category",
4213
+ kind: "select",
4214
+ options: [{ value: "a", label: "A" }],
4215
+ },
4216
+ panels: [
4217
+ {
4218
+ kind: "stat",
4219
+ id: "count",
4220
+ label: "Products",
4221
+ query: "shop:query:count",
4222
+ valueField: "total",
4223
+ },
4224
+ ],
4225
+ });
4226
+ });
4227
+ expect(() => validateBoot([feature])).toThrow(
4228
+ /sets params \[productName\] on navigate-target "product-dashboard" \(dashboard\) whose filter id is "category".*none of the extracted keys match/,
4139
4229
  );
4140
4230
  });
4141
4231
 
@@ -12,7 +12,7 @@ import { z } from "zod";
12
12
  import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
13
13
  import { defineFeature } from "../define-feature";
14
14
  import { createRegistry } from "../registry";
15
- import type { EntityDefinition } from "../types/fields";
15
+ import type { EntityDefinition, MultiSelectFieldDef } from "../types/fields";
16
16
  import type { ProjectionListScreenDefinition } from "../types/screen";
17
17
 
18
18
  describe("buildAppSchema", () => {
@@ -314,6 +314,141 @@ describe("buildAppSchema", () => {
314
314
  expect(fields["name"]?.["searchable"]).toBeUndefined();
315
315
  });
316
316
 
317
+ test("MultiSelect: display/columns/maxRows überleben die Projection (fw#2494)", () => {
318
+ // Regression: without display in the client schema, render-field.tsx's
319
+ // `field.display === "checkboxes"` check always falls through → the
320
+ // renderer always shows the combobox, MultiSelectCheckboxes is unreachable.
321
+ const entity = {
322
+ fields: {
323
+ tags: {
324
+ type: "multiSelect",
325
+ options: ["a", "b", "c"],
326
+ display: "checkboxes",
327
+ columns: 3,
328
+ maxRows: 5,
329
+ },
330
+ categories: { type: "multiSelect", options: ["x", "y"] },
331
+ },
332
+ } as unknown as EntityDefinition;
333
+
334
+ const f = defineFeature("ent", (r) => {
335
+ r.entity("thing", entity);
336
+ });
337
+ const app = buildAppSchema(createRegistry([f]));
338
+ const fields = (
339
+ app.features[0]!.entities["thing"] as unknown as {
340
+ fields: Record<string, Record<string, unknown>>;
341
+ }
342
+ ).fields;
343
+
344
+ expect(fields["tags"]?.["display"]).toBe("checkboxes");
345
+ expect(fields["tags"]?.["columns"]).toBe(3);
346
+ expect(fields["tags"]?.["maxRows"]).toBe(5);
347
+ // Fields without display/columns/maxRows don't carry the keys (no false-litter).
348
+ expect(fields["categories"]?.["display"]).toBeUndefined();
349
+ expect(fields["categories"]?.["columns"]).toBeUndefined();
350
+ expect(fields["categories"]?.["maxRows"]).toBeUndefined();
351
+ });
352
+
353
+ test("MultiSelectFieldDef: alle Keys sind in projectField bewusst eingeordnet", () => {
354
+ // Bounded completeness check, scoped to MultiSelectFieldDef's own key set
355
+ // (packages/types/src/fields.ts) — NOT a general FieldDefinition lockstep.
356
+ // A full-union version would have to classify every property of all 20
357
+ // FieldDefinition variants as forwarded or server-only, and several are
358
+ // known-forwarded-but-missing today (e.g. image's `capture`, embedded's
359
+ // `schema`/`derived`/`totals`/`minItems`/`maxItems`, date's
360
+ // `min`/`max`/`locale`, longText's `multiline`) — declaring those
361
+ // "server-only" here would be a false claim, and fixing them is out of
362
+ // scope for fw#2494 (see report for the full gap list). This check only
363
+ // covers the type this fix actually touches.
364
+ //
365
+ // The `_allKeysClassified` assignment below is the actual guard: if
366
+ // MultiSelectFieldDef ever gains a key that isn't in one of the three
367
+ // lists, `Exclude<keyof MultiSelectFieldDef, Classified>` stops being
368
+ // `never` and the assignment fails to typecheck — `bun run typecheck`
369
+ // (part of `bun run kumiko check`) catches it even without touching this
370
+ // test's runtime assertions.
371
+ const FORWARDED_KEYS = [
372
+ "type",
373
+ "required",
374
+ "filterable",
375
+ "options",
376
+ "display",
377
+ "columns",
378
+ "maxRows",
379
+ ] as const;
380
+
381
+ const SERVER_ONLY_KEYS = [
382
+ "sensitive", // controls write-response redaction, never rendered
383
+ "access", // server-side authz check, not a renderer concern
384
+ "pii", // PII classification, drives crypto/storage — not client-relevant
385
+ "userOwned", // same: subject-key annotation, server/crypto-only
386
+ "tenantOwned", // same: subject-key annotation, server/crypto-only
387
+ "anonymize", // retention-cleanup callback, never serializable to JSON
388
+ "allowPlaintext", // PII-audit reason string, not a renderer concern
389
+ "lookupable", // blind-index equality lookup, server-side query concern
390
+ "subjectRef", // GDPR-hook-coverage marker, not a renderer concern
391
+ ] as const;
392
+
393
+ // Keys that ARE client-relevant in principle but aren't forwarded today —
394
+ // pre-existing gaps, each with its own reason, none introduced by fw#2494.
395
+ const KNOWN_GAP_KEYS = [
396
+ "default", // array-valued for multiSelect, but projectField's isLiteral()
397
+ // only accepts string/number/boolean/null — so array defaults are
398
+ // silently dropped, never a real server-only property. Verified
399
+ // empirically: a multiSelect field with `default: ["a"]` projects to
400
+ // no `default` key at all. Separate pre-existing bug, out of scope here.
401
+ ] as const;
402
+
403
+ type Classified =
404
+ | (typeof FORWARDED_KEYS)[number]
405
+ | (typeof SERVER_ONLY_KEYS)[number]
406
+ | (typeof KNOWN_GAP_KEYS)[number];
407
+ const _allKeysClassified: Exclude<keyof MultiSelectFieldDef, Classified> extends never
408
+ ? true
409
+ : never = true;
410
+
411
+ const entity = {
412
+ fields: {
413
+ tags: {
414
+ type: "multiSelect",
415
+ required: true,
416
+ filterable: true,
417
+ options: ["a", "b"],
418
+ display: "checkboxes",
419
+ columns: 2,
420
+ maxRows: 4,
421
+ sensitive: true,
422
+ access: { read: ["admin"] },
423
+ pii: true,
424
+ userOwned: { ownerField: "authorId" },
425
+ tenantOwned: true,
426
+ anonymize: () => "[ANONYMIZED]",
427
+ allowPlaintext: "is_business_data",
428
+ lookupable: true,
429
+ subjectRef: true,
430
+ },
431
+ },
432
+ } as unknown as EntityDefinition;
433
+
434
+ const f = defineFeature("ent", (r) => {
435
+ r.entity("thing", entity);
436
+ });
437
+ const app = buildAppSchema(createRegistry([f]));
438
+ const projected = (
439
+ app.features[0]!.entities["thing"] as unknown as {
440
+ fields: Record<string, Record<string, unknown>>;
441
+ }
442
+ ).fields["tags"] as Record<string, unknown>;
443
+
444
+ for (const key of FORWARDED_KEYS) {
445
+ expect(projected[key]).toBeDefined();
446
+ }
447
+ for (const key of SERVER_ONLY_KEYS) {
448
+ expect(projected[key]).toBeUndefined();
449
+ }
450
+ });
451
+
317
452
  test("AppSchema ist via JSON.stringify roundtrip-sicher", () => {
318
453
  // Echter Smoke-Test des Vertrags — wenn jemand in den project-
319
454
  // Helper eine Function reinschmuggelt, würde das hier brennen.
@@ -0,0 +1,158 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
+ import { validateBoot as validateBootRaw } from "../boot-validator";
5
+ import { defineFeature } from "../define-feature";
6
+ import { createEntity, createTextField } from "../factories";
7
+
8
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
9
+ validateBootRaw(withBootValidatorFixture(features));
10
+ }
11
+
12
+ // Akten-Layout: projectionDetail `layout.mode: "tabs"` + `header`/`metrics`.
13
+ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
14
+ test("mode: tabs with only one section throws", () => {
15
+ const feature = defineFeature("app", (r) => {
16
+ r.screen({
17
+ id: "rent-detail",
18
+ type: "projectionDetail",
19
+ query: "app:query:rent:detail",
20
+ layout: {
21
+ mode: "tabs",
22
+ sections: [{ id: "overview", title: "Overview", fields: ["description"] }],
23
+ },
24
+ });
25
+ });
26
+ expect(() => validateBoot([feature])).toThrow(/tabs need at least 2 sections/);
27
+ });
28
+
29
+ test("tabs section without a title throws", () => {
30
+ const feature = defineFeature("app", (r) => {
31
+ r.screen({
32
+ id: "rent-detail",
33
+ type: "projectionDetail",
34
+ query: "app:query:rent:detail",
35
+ layout: {
36
+ mode: "tabs",
37
+ sections: [
38
+ { id: "overview", fields: ["description"] },
39
+ { id: "history", title: "History", fields: ["notes"] },
40
+ ],
41
+ },
42
+ });
43
+ });
44
+ expect(() => validateBoot([feature])).toThrow(/sections\[0\] has no title/);
45
+ });
46
+
47
+ test("tabs section without an id throws", () => {
48
+ const feature = defineFeature("app", (r) => {
49
+ r.screen({
50
+ id: "rent-detail",
51
+ type: "projectionDetail",
52
+ query: "app:query:rent:detail",
53
+ layout: {
54
+ mode: "tabs",
55
+ sections: [
56
+ { title: "Overview", fields: ["description"] },
57
+ { id: "history", title: "History", fields: ["notes"] },
58
+ ],
59
+ },
60
+ });
61
+ });
62
+ expect(() => validateBoot([feature])).toThrow(/sections\[0\] \("Overview"\) has no id/);
63
+ });
64
+
65
+ test("tabs section id that is not kebab-case throws", () => {
66
+ const feature = defineFeature("app", (r) => {
67
+ r.screen({
68
+ id: "rent-detail",
69
+ type: "projectionDetail",
70
+ query: "app:query:rent:detail",
71
+ layout: {
72
+ mode: "tabs",
73
+ sections: [
74
+ { id: "Overview", title: "Overview", fields: ["description"] },
75
+ { id: "history", title: "History", fields: ["notes"] },
76
+ ],
77
+ },
78
+ });
79
+ });
80
+ expect(() => validateBoot([feature])).toThrow(/must be kebab-case/);
81
+ });
82
+
83
+ test("duplicate tab ids throw", () => {
84
+ const feature = defineFeature("app", (r) => {
85
+ r.screen({
86
+ id: "rent-detail",
87
+ type: "projectionDetail",
88
+ query: "app:query:rent:detail",
89
+ layout: {
90
+ mode: "tabs",
91
+ sections: [
92
+ { id: "overview", title: "Overview", fields: ["description"] },
93
+ { id: "overview", title: "History", fields: ["notes"] },
94
+ ],
95
+ },
96
+ });
97
+ });
98
+ expect(() => validateBoot([feature])).toThrow(/duplicate tab id "overview"/);
99
+ });
100
+
101
+ test("mode: tabs on entityEdit throws — tabs are projectionDetail-only", () => {
102
+ const feature = defineFeature("app", (r) => {
103
+ r.entity("rent", createEntity({ fields: { name: createTextField() } }));
104
+ r.screen({
105
+ id: "rent-edit",
106
+ type: "entityEdit",
107
+ entity: "rent",
108
+ layout: {
109
+ mode: "tabs",
110
+ sections: [
111
+ { id: "overview", title: "Overview", columns: 1, fields: ["name"] },
112
+ { id: "history", title: "History", columns: 1, fields: ["name"] },
113
+ ],
114
+ },
115
+ });
116
+ });
117
+ expect(() => validateBoot([feature])).toThrow(
118
+ /Screen "rent-edit" \(entityEdit\) sets mode: "tabs" — tabs are only supported on projectionDetail/,
119
+ );
120
+ });
121
+
122
+ test("metric without a fieldLabels entry throws — no fallback to the raw column name", () => {
123
+ const feature = defineFeature("app", (r) => {
124
+ r.screen({
125
+ id: "rent-detail",
126
+ type: "projectionDetail",
127
+ query: "app:query:rent:detail",
128
+ layout: { sections: [{ title: "s", fields: ["description"] }] },
129
+ metrics: ["balance"],
130
+ });
131
+ });
132
+ expect(() => validateBoot([feature])).toThrow(/metric "balance" has no entry in fieldLabels/);
133
+ });
134
+
135
+ test("valid tabs + header + metrics declaration boots cleanly", () => {
136
+ const feature = defineFeature("app", (r) => {
137
+ r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
138
+ access: { openToAll: true },
139
+ });
140
+ r.screen({
141
+ id: "rent-detail",
142
+ type: "projectionDetail",
143
+ query: "app:query:rent:detail",
144
+ header: { title: "name", subtitle: "address", status: "state" },
145
+ metrics: ["balance", "overdueDays"],
146
+ fieldLabels: { balance: "rent.balance", overdueDays: "rent.overdueDays" },
147
+ layout: {
148
+ mode: "tabs",
149
+ sections: [
150
+ { id: "overview", title: "Overview", fields: ["description"] },
151
+ { id: "history", title: "History", fields: ["notes"] },
152
+ ],
153
+ },
154
+ });
155
+ });
156
+ expect(() => validateBoot([feature])).not.toThrow();
157
+ });
158
+ });
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
8
8
  import { rowMetaFieldNames } from "../../db/table-builder";
9
- import { isValidQn, qualifyEntityName } from "../qualified-name";
9
+ import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name";
10
10
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
11
11
  import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
12
12
  import type { EntityDefinition, FeatureDefinition } from "../types";
@@ -83,6 +83,14 @@ function validateNoWidgetRequiredField(
83
83
  // entityList and projectionList (framework#1708) — projectionList has no
84
84
  // `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
85
85
  // entityEdit target without an explicit entityId reaches create there.
86
+ //
87
+ // dashboard targets (framework#1708 follow-up, commit 4e0d6cb26) also read
88
+ // URL search params — but only for the single `filter.id` a dashboard
89
+ // declares (useFilterParams in dashboard-body.tsx seeds its value from
90
+ // `nav.searchParams[filter.id]`). A dashboard with no `filter` has nowhere
91
+ // for the value to land, and a params extractor whose keys don't include
92
+ // the filter's id would silently miss it — both are boot errors instead of
93
+ // a silently-empty dashboard.
86
94
  function validateRowActionNavigateParams(
87
95
  featureName: string,
88
96
  screenId: string,
@@ -93,16 +101,38 @@ function validateRowActionNavigateParams(
93
101
  ): void {
94
102
  // skip: not a navigate-with-params action — nothing to validate here.
95
103
  if (action.kind !== "navigate" || action.params === undefined) return;
96
- // entityList/projectionList targets also read URL search params (Tier
104
+ // skip: unresolvable/custom target already reported (or exempt) elsewhere.
105
+ if (target === undefined || target.screen.type === "custom") return;
106
+ // skip: entityList/projectionList targets also read URL search params (Tier
97
107
  // 2.7c filter-prefill, see use-list-url-state.ts: `<screenId>.q/.sort/
98
108
  // .dir/.page/.f.<field>`), not just actionForm/entityEdit-create.
99
- const exemptTargetType =
100
- target === undefined ||
101
- target.screen.type === "custom" ||
102
- target.screen.type === "entityList" ||
103
- target.screen.type === "projectionList";
104
- // skip: unresolvable/custom/list target already reported (or exempt) elsewhere.
105
- if (exemptTargetType) return;
109
+ if (target.screen.type === "entityList" || target.screen.type === "projectionList") return;
110
+
111
+ if (target.screen.type === "dashboard") {
112
+ const targetDescriptor =
113
+ action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
114
+ const filter = target.screen.filter;
115
+ if (filter === undefined) {
116
+ throw new Error(
117
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
118
+ `params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
119
+ `filter — params would be a no-op. Remove the params extractor, or add a \`filter\` to the ` +
120
+ `target dashboard so it has somewhere to read the value from.`,
121
+ );
122
+ }
123
+ const extractedKeys =
124
+ "pick" in action.params ? action.params.pick : Object.keys(action.params.map);
125
+ if (!extractedKeys.includes(filter.id)) {
126
+ throw new Error(
127
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
128
+ `params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
129
+ `filter id is "${filter.id}" — none of the extracted keys match, so the filter would stay ` +
130
+ `unset. Fix the params extractor to produce a "${filter.id}" key, or remove it.`,
131
+ );
132
+ }
133
+ // skip: filter present and the extractor's keys cover it — valid dashboard deep-link.
134
+ return;
135
+ }
106
136
 
107
137
  const isEntityEditUpdate =
108
138
  target.screen.type === "entityEdit" &&
@@ -140,6 +170,17 @@ function validateWizardLayout(
140
170
  layout: EditLayout,
141
171
  featureMap: ReadonlyMap<string, FeatureDefinition>,
142
172
  ): void {
173
+ // Tabs truncate the layout to one section, but scopeFieldNames derives
174
+ // required-field validation from the `fields` prop, not from layout — a
175
+ // hidden tab could hide a required field and silently block submit.
176
+ // projectionDetail has no submit, so tabs are safe there (see its own
177
+ // branch in validateScreens) but not here.
178
+ if (layout.mode === "tabs") {
179
+ throw new Error(
180
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets mode: "tabs" — tabs are only ` +
181
+ `supported on projectionDetail. Use mode: "wizard" or "single" instead.`,
182
+ );
183
+ }
143
184
  // "form-draft" is hardcoded because the framework layer must not depend on
144
185
  // @cosmicdrift/kumiko-bundled-features — same precedence as the
145
186
  // "user-data-rights" check in gdpr-storage.ts.
@@ -572,6 +613,54 @@ export function validateScreens(
572
613
  `declare at least one section.`,
573
614
  );
574
615
  }
616
+ if (screen.layout.mode === "tabs") {
617
+ if (screen.layout.sections.length < 2) {
618
+ throw new Error(
619
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but only ` +
620
+ `${screen.layout.sections.length} section(s) — tabs need at least 2 sections.`,
621
+ );
622
+ }
623
+ const tabIds = new Set<string>();
624
+ screen.layout.sections.forEach((section, index) => {
625
+ if (section.title === undefined || section.title.trim().length === 0) {
626
+ throw new Error(
627
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
628
+ `sections[${index}] has no title — every tab needs a title.`,
629
+ );
630
+ }
631
+ if (section.id === undefined || section.id.trim().length === 0) {
632
+ throw new Error(
633
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
634
+ `sections[${index}] ("${section.title}") has no id — every tab needs a stable id for ` +
635
+ `the ?tab= param.`,
636
+ );
637
+ }
638
+ if (!isKebabSegment(section.id)) {
639
+ throw new Error(
640
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sections[${index}] ` +
641
+ `("${section.title}") has id "${section.id}" — must be kebab-case.`,
642
+ );
643
+ }
644
+ if (tabIds.has(section.id)) {
645
+ throw new Error(
646
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has duplicate tab id ` +
647
+ `"${section.id}" (sections[${index}]).`,
648
+ );
649
+ }
650
+ tabIds.add(section.id);
651
+ });
652
+ }
653
+ if (screen.metrics !== undefined) {
654
+ for (const metric of screen.metrics) {
655
+ if (screen.fieldLabels?.[metric] === undefined) {
656
+ throw new Error(
657
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${metric}" has ` +
658
+ `no entry in fieldLabels — every metrics field needs a label, there is no fallback to ` +
659
+ `the raw column name.`,
660
+ );
661
+ }
662
+ }
663
+ }
575
664
  for (const section of screen.layout.sections) {
576
665
  if (isExtensionEditSection(section)) {
577
666
  throw new Error(
@@ -428,6 +428,12 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
428
428
  if (typeof def["entity"] === "string") out["entity"] = def["entity"];
429
429
  if (typeof def["labelField"] === "string") out["labelField"] = def["labelField"];
430
430
  if (typeof def["multiple"] === "boolean") out["multiple"] = def["multiple"];
431
+ // MultiSelect: display picks checkboxes vs. combobox in the renderer,
432
+ // columns/maxRows size the checkbox grid — without these the renderer
433
+ // always falls back to the combobox (fw#2494).
434
+ if (typeof def["display"] === "string") out["display"] = def["display"];
435
+ if (typeof def["columns"] === "number") out["columns"] = def["columns"];
436
+ if (typeof def["maxRows"] === "number") out["maxRows"] = def["maxRows"];
431
437
  return out as FieldDefinition; // @cast-boundary schema-walk
432
438
  }
433
439