@cosmicdrift/kumiko-framework 0.152.0 → 0.153.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.152.0",
3
+ "version": "0.153.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>",
@@ -193,7 +193,7 @@
193
193
  "zod": "^4.4.3"
194
194
  },
195
195
  "devDependencies": {
196
- "@cosmicdrift/kumiko-dispatcher-live": "0.152.0",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.153.0",
197
197
  "bun-types": "^1.3.13",
198
198
  "pino-pretty": "^13.1.3"
199
199
  },
@@ -80,6 +80,86 @@ describe("r.nav() — registration", () => {
80
80
  });
81
81
  });
82
82
 
83
+ describe("r.screen({ nav }) — inline nav sugar", () => {
84
+ test("synthesizes a nav entry from the screen's id", () => {
85
+ const feature = defineFeature("shop", (r) => {
86
+ r.entity("product", productEntity());
87
+ r.screen({
88
+ id: "products",
89
+ type: "entityList",
90
+ entity: "product",
91
+ columns: ["name"],
92
+ nav: { label: "shop:nav.products", icon: "box", order: 5 },
93
+ });
94
+ });
95
+ expect(feature.navs["products"]).toMatchObject({
96
+ id: "products",
97
+ label: "shop:nav.products",
98
+ icon: "box",
99
+ order: 5,
100
+ screen: "shop:screen:products",
101
+ });
102
+ });
103
+
104
+ test("supports parent like a standalone r.nav()", () => {
105
+ const feature = defineFeature("shop", (r) => {
106
+ r.nav({ id: "catalog", label: "x" });
107
+ r.entity("product", productEntity());
108
+ r.screen({
109
+ id: "products",
110
+ type: "entityList",
111
+ entity: "product",
112
+ columns: ["name"],
113
+ nav: { label: "y", parent: "shop:nav:catalog" },
114
+ });
115
+ });
116
+ expect(feature.navs["products"]?.parent).toBe("shop:nav:catalog");
117
+ });
118
+
119
+ test("passes the same boot-validation as a standalone r.nav()", () => {
120
+ const feature = defineFeature("shop", (r) => {
121
+ r.entity("product", productEntity());
122
+ r.screen({
123
+ id: "products",
124
+ type: "entityList",
125
+ entity: "product",
126
+ columns: ["name"],
127
+ nav: { label: "y" },
128
+ });
129
+ });
130
+ expect(() => validateBoot([feature])).not.toThrow();
131
+ });
132
+
133
+ test("screen without nav registers no nav entry", () => {
134
+ const feature = defineFeature("shop", (r) => {
135
+ r.entity("product", productEntity());
136
+ r.screen({
137
+ id: "products",
138
+ type: "entityList",
139
+ entity: "product",
140
+ columns: ["name"],
141
+ });
142
+ });
143
+ expect(feature.navs["products"]).toBeUndefined();
144
+ });
145
+
146
+ test("rejects when a standalone r.nav() already used the screen's id", () => {
147
+ expect(() =>
148
+ defineFeature("shop", (r) => {
149
+ r.nav({ id: "products", label: "standalone" });
150
+ r.entity("product", productEntity());
151
+ r.screen({
152
+ id: "products",
153
+ type: "entityList",
154
+ entity: "product",
155
+ columns: ["name"],
156
+ nav: { label: "inline" },
157
+ });
158
+ }),
159
+ ).toThrow(/already registered/);
160
+ });
161
+ });
162
+
83
163
  describe("createRegistry — nav indexing", () => {
84
164
  test("indexes nav entries by qualified name", () => {
85
165
  const feature = defineFeature("shop", (r) => {
@@ -358,3 +358,45 @@ describe("renderPattern — single-pattern shape", () => {
358
358
  expect(out).toBe('r.metric({ name: "requests", type: "counter" });');
359
359
  });
360
360
  });
361
+
362
+ // Regression guard for the class of bug the r.exposesApi/r.usesApi fold
363
+ // hit: a registrar-shape change (there, removing a method; here, adding a
364
+ // nested optional field) must survive the Designer's parse→render→parse
365
+ // cycle, not just a TS compile of the framework itself. r.screen()'s `nav`
366
+ // sugar is a generic nested object on an already-generic pattern (no
367
+ // per-field extractor/renderer), so this is the cheap case — but proving
368
+ // it beats assuming it.
369
+ const SCREEN_WITH_NAV_FEATURE = `
370
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
371
+
372
+ defineFeature("shop", (r) => {
373
+ r.entity("product", { fields: { name: { type: "text" } } });
374
+ r.screen({
375
+ id: "products",
376
+ type: "entityList",
377
+ entity: "product",
378
+ columns: ["name"],
379
+ nav: { label: "shop:nav.products", icon: "box", order: 5 },
380
+ });
381
+ });
382
+ `;
383
+
384
+ describe("render → parse roundtrip — r.screen({ nav }) inline sugar", () => {
385
+ test("nested nav object survives parse → render → parse unchanged", () => {
386
+ const initial = parse(SCREEN_WITH_NAV_FEATURE);
387
+ const rendered = renderFeatureFile({
388
+ featureName: initial.featureName ?? "",
389
+ patterns: initial.patterns,
390
+ });
391
+ const reparsed = parse(rendered);
392
+ expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
393
+
394
+ const screenPattern = reparsed.patterns.find((p) => p.kind === "screen");
395
+ expect(screenPattern?.kind).toBe("screen");
396
+ if (screenPattern?.kind === "screen") {
397
+ expect(screenPattern.definition).toMatchObject({
398
+ nav: { label: "shop:nav.products", icon: "box", order: 5 },
399
+ });
400
+ }
401
+ });
402
+ });
@@ -260,6 +260,27 @@ export function buildUiExtensionsMethods<TName extends string>(
260
260
  );
261
261
  }
262
262
  state.screens[definition.id] = definition;
263
+ if (definition.nav) {
264
+ // Sugar for the common "one nav entry pointing at this screen"
265
+ // case — synthesizes id/screen from the screen's own id. Beyond
266
+ // label/icon/parent/order, declare a standalone r.nav() instead.
267
+ if (state.navs[definition.id]) {
268
+ throw new Error(
269
+ `[Feature ${name}] Nav entry "${definition.id}" already registered. ` +
270
+ `Nav ids must be unique per feature — remove the standalone ` +
271
+ `r.nav("${definition.id}", ...) call or the screen's inline nav.`,
272
+ );
273
+ }
274
+ const navDefinition: NavDefinition = {
275
+ id: definition.id,
276
+ label: definition.nav.label,
277
+ icon: definition.nav.icon,
278
+ parent: definition.nav.parent,
279
+ order: definition.nav.order,
280
+ screen: `${name}:screen:${definition.id}`,
281
+ };
282
+ state.navs[definition.id] = navDefinition;
283
+ }
263
284
  },
264
285
  nav(definition: NavDefinition): void {
265
286
  // Reject kebab-drift at registration-time so the stack trace points at
@@ -684,7 +684,9 @@ export type FeatureRegistrar<TFeature extends string = string> = {
684
684
  // the registry qualifies to "<feature>:screen:<id>". Boot-validation checks
685
685
  // that entity-bound screens reference a registered entity and that the
686
686
  // columns / form-field refs name real fields — cross-feature component-QN
687
- // validation (r.uiComponent) comes in M4/M5.
687
+ // validation (r.uiComponent) comes in M4/M5. Optional `nav` field is
688
+ // sugar for a single nav entry pointing at this screen — equivalent to
689
+ // a standalone r.nav({ id: <same id>, screen: "<feature>:screen:<id>", ... }).
688
690
  screen(definition: ScreenDefinition): void;
689
691
 
690
692
  // Register a nav entry. The id is the feature-local short name (kebab-case);
@@ -727,7 +727,19 @@ export type ScreenSlots = {
727
727
 
728
728
  // --- discriminated union ---
729
729
 
730
- export type ScreenDefinition =
730
+ // Inline nav-entry sugar for `r.screen({ ..., nav: {...} })` — covers the
731
+ // common case of "one nav entry pointing at this screen". The nav entry's
732
+ // `id`/`screen` are synthesized from the screen's own id; for anything
733
+ // beyond label/icon/parent/order (access-gating, workspaces, actions),
734
+ // declare a standalone `r.nav()` entry instead.
735
+ export type ScreenNavSugar = {
736
+ readonly label: string;
737
+ readonly icon?: string;
738
+ readonly parent?: string;
739
+ readonly order?: number;
740
+ };
741
+
742
+ export type ScreenDefinition = (
731
743
  | EntityListScreenDefinition
732
744
  | ProjectionListScreenDefinition
733
745
  | ProjectionDetailScreenDefinition
@@ -735,7 +747,8 @@ export type ScreenDefinition =
735
747
  | EntityEditScreenDefinition
736
748
  | ActionFormScreenDefinition
737
749
  | ConfigEditScreenDefinition
738
- | CustomScreenDefinition;
750
+ | CustomScreenDefinition
751
+ ) & { readonly nav?: ScreenNavSugar };
739
752
 
740
753
  // Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
741
754
  // authors who branch on the three FieldRenderer variants without manual