@cosmicdrift/kumiko-renderer-web 0.197.0 → 0.198.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.197.0",
3
+ "version": "0.198.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.197.0",
20
- "@cosmicdrift/kumiko-headless": "0.197.0",
21
- "@cosmicdrift/kumiko-renderer": "0.197.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.198.0",
20
+ "@cosmicdrift/kumiko-headless": "0.198.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.198.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",
@@ -206,6 +206,99 @@ describe("createKumikoApp", () => {
206
206
  );
207
207
  });
208
208
 
209
+ test("custom screen without a registered clientFeatures component → console.error names feature + screen at boot (kumiko-framework#2025)", async () => {
210
+ // A server feature can declare a `type: "custom"` screen without the
211
+ // app mounting the corresponding client plugin — the screen is still
212
+ // reachable via URL. createKumikoApp should surface that visibly ONCE
213
+ // at boot instead of only showing it as a banner when the URL is
214
+ // opened (which nobody would see if they never visit the route).
215
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
216
+
217
+ const customScreenSchema: FeatureSchema = {
218
+ featureName: "delivery",
219
+ entities: { task: taskEntity },
220
+ screens: [
221
+ {
222
+ id: "delivery-log",
223
+ type: "custom",
224
+ renderer: { react: { __component: "DeliveryLog" } },
225
+ },
226
+ ],
227
+ };
228
+
229
+ mountRoot();
230
+ await mountApp({
231
+ schema: customScreenSchema,
232
+ dispatcher: makeDispatcher(),
233
+ screenQn: "delivery:screen:delivery-log",
234
+ });
235
+
236
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("delivery:delivery-log"));
237
+
238
+ errorSpy.mockRestore();
239
+ });
240
+
241
+ test("custom screen WITH a registered clientFeatures component → no boot diagnostic", async () => {
242
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
243
+
244
+ function DeliveryLog(): ReactNode {
245
+ return <span data-testid="delivery-log-mounted" />;
246
+ }
247
+ const customScreenSchema: FeatureSchema = {
248
+ featureName: "delivery",
249
+ entities: { task: taskEntity },
250
+ screens: [
251
+ {
252
+ id: "delivery-log",
253
+ type: "custom",
254
+ renderer: { react: { __component: "DeliveryLog" } },
255
+ },
256
+ ],
257
+ };
258
+
259
+ mountRoot();
260
+ await mountApp({
261
+ schema: customScreenSchema,
262
+ dispatcher: makeDispatcher(),
263
+ screenQn: "delivery:screen:delivery-log",
264
+ clientFeatures: [{ name: "delivery", components: { "delivery-log": DeliveryLog } }],
265
+ });
266
+
267
+ expect(await screen.findByTestId("delivery-log-mounted")).toBeTruthy();
268
+ expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("delivery:delivery-log"));
269
+
270
+ errorSpy.mockRestore();
271
+ });
272
+
273
+ test("dormant custom screen without a registered clientFeatures component → no boot diagnostic (kumiko-framework#2034)", async () => {
274
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
275
+
276
+ const dormantScreenSchema: FeatureSchema = {
277
+ featureName: "user-data-rights",
278
+ entities: { task: taskEntity },
279
+ screens: [
280
+ {
281
+ id: "privacy-center",
282
+ type: "custom",
283
+ renderer: { react: { __component: "PrivacyCenterScreen" } },
284
+ access: { openToAll: true },
285
+ dormant: true,
286
+ },
287
+ ],
288
+ };
289
+
290
+ mountRoot();
291
+ await mountApp({
292
+ schema: dormantScreenSchema,
293
+ dispatcher: makeDispatcher(),
294
+ screenQn: "user-data-rights:screen:privacy-center",
295
+ });
296
+
297
+ expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("privacy-center"));
298
+
299
+ errorSpy.mockRestore();
300
+ });
301
+
209
302
  test("clientFeatures.columnRenderers → bei Key-Kollision warnt + last-wins gewinnt", async () => {
210
303
  // Zwei Features liefern denselben Renderer-Key — der Merge in
211
304
  // create-app warnt und behält den späteren Eintrag (Last-Wins).
@@ -85,6 +85,44 @@ describe("Form = eine Card, Sections als innere Abschnitte", () => {
85
85
  expect(form.className).toContain("gap-4");
86
86
  });
87
87
 
88
+ test("BareFormProvider: sections get the divider rule between them", () => {
89
+ render(
90
+ <BareFormProvider>
91
+ <Form onSubmit={() => {}} testId="bare-sections">
92
+ <Section testId="s1">
93
+ <div>a</div>
94
+ </Section>
95
+ <Section testId="s2">
96
+ <div>b</div>
97
+ </Section>
98
+ </Form>
99
+ </BareFormProvider>,
100
+ );
101
+ const form = screen.getByTestId("bare-sections");
102
+ // jsdom doesn't run Tailwind's JIT, so `:not(:first-child)` can't be
103
+ // asserted via computed style — check the structural precondition the
104
+ // selector relies on (both sections are direct <form> children, in
105
+ // order) plus the divider utility class carrying it.
106
+ const sectionChildren = Array.from(form.children).filter((el) => el.tagName === "SECTION");
107
+ expect(sectionChildren.length).toBe(2);
108
+ expect(sectionChildren[0]).toBe(screen.getByTestId("s1"));
109
+ expect(sectionChildren[1]).toBe(screen.getByTestId("s2"));
110
+ expect(form.className).toContain("[&>section:not(:first-child)]:border-t");
111
+ });
112
+
113
+ test("BareFormProvider: flat AuthCard-shaped fields render no <section> — the divider rule stays inert", () => {
114
+ render(
115
+ <BareFormProvider>
116
+ <Form onSubmit={() => {}} testId="bare-flat">
117
+ <div>field one</div>
118
+ <div>field two</div>
119
+ </Form>
120
+ </BareFormProvider>,
121
+ );
122
+ const form = screen.getByTestId("bare-flat");
123
+ expect(form.querySelectorAll("section").length).toBe(0);
124
+ });
125
+
88
126
  test("Section standalone (außerhalb Form) bleibt eine eigene Card", () => {
89
127
  render(
90
128
  <Section testId="solo" title="Solo">
@@ -1707,7 +1707,7 @@ describe("KumikoScreen", () => {
1707
1707
  expect(navigateCalls).toEqual([]);
1708
1708
  });
1709
1709
 
1710
- test("custom screen type → placeholder (M4 wires r.uiComponent)", () => {
1710
+ test("custom screen type without a registered component error placeholder naming feature + screen (kumiko-framework#2025)", () => {
1711
1711
  const customSchema: FeatureSchema = {
1712
1712
  featureName: "tasks",
1713
1713
  entities: { task: taskEntity },
@@ -1724,7 +1724,11 @@ describe("KumikoScreen", () => {
1724
1724
  <KumikoScreen schema={customSchema} qn="tasks:screen:dashboard" />
1725
1725
  </DispatcherProvider>,
1726
1726
  );
1727
- expect(screen.getByTestId("kumiko-screen-custom-placeholder")).toBeTruthy();
1727
+ const placeholder = screen.getByTestId("kumiko-screen-custom-placeholder");
1728
+ expect(placeholder.getAttribute("data-variant")).toBe("error");
1729
+ expect(placeholder.textContent).toContain("dashboard");
1730
+ expect(placeholder.textContent).toContain("tasks");
1731
+ expect(placeholder.textContent).toContain("clientFeatures");
1728
1732
  });
1729
1733
 
1730
1734
  // ------------------------------------------------------------------
@@ -2015,6 +2019,79 @@ describe("KumikoScreen: singleton entityEdit", () => {
2015
2019
  expect(bannerText).toBe(kumikoDefaultTranslations["en"]?.["errors.access.denied"] ?? "");
2016
2020
  expect(screen.queryByTestId("render-edit-form")).toBeNull();
2017
2021
  });
2022
+
2023
+ // kumiko-screen#1944: EntityEditSingletonBody runs without a wrapping
2024
+ // entityList screen, so the create-body's default "navigate back to the
2025
+ // list" success handler is a silent no-op — a successful create used to
2026
+ // leave the form stuck on the just-submitted create values, and a second
2027
+ // submit created a duplicate record ("exactly one record per tenant"
2028
+ // broken). Create on an empty table must now switch straight to the
2029
+ // update form of the newly created record.
2030
+ test("erfolgreicher Create bei leerer Tabelle wechselt in Update-Form des neuen Records (kein zweiter Create möglich)", async () => {
2031
+ let created = false;
2032
+ const write = mock(async (type: string) => {
2033
+ if (type === "tasks:write:task:create") {
2034
+ created = true;
2035
+ return { isSuccess: true, data: { id: "task-1" } };
2036
+ }
2037
+ return { isSuccess: true, data: {} };
2038
+ });
2039
+ const query = mock(async (type: string) => {
2040
+ if (type === "tasks:query:task:list") {
2041
+ return created
2042
+ ? { isSuccess: true, data: { rows: [{ id: "task-1", title: "Created title" }] } }
2043
+ : { isSuccess: true, data: { rows: [], nextCursor: null } };
2044
+ }
2045
+ if (type === "tasks:query:task:detail") {
2046
+ return {
2047
+ isSuccess: true,
2048
+ data: { id: "task-1", version: 1, title: "Created title", count: 0, done: false },
2049
+ };
2050
+ }
2051
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
2052
+ });
2053
+ const dispatcher = makeDispatcher({
2054
+ write: write as unknown as Dispatcher["write"],
2055
+ query: query as unknown as Dispatcher["query"],
2056
+ });
2057
+
2058
+ render(
2059
+ <DispatcherProvider dispatcher={dispatcher}>
2060
+ <KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
2061
+ </DispatcherProvider>,
2062
+ );
2063
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
2064
+
2065
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
2066
+ fireEvent.change(titleInput, { target: { value: "Created title" } });
2067
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
2068
+
2069
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
2070
+ // The singleton wrapper refetches its list(limit:1) query after the
2071
+ // create succeeds, sees the new row, and switches from the create body
2072
+ // to the update body — which loads the record via its own detail
2073
+ // query, landing on the same title through a different data path.
2074
+ await waitFor(() => {
2075
+ const input = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
2076
+ expect(input.value).toBe("Created title");
2077
+ });
2078
+ expect(query).toHaveBeenCalledWith(
2079
+ "tasks:query:task:detail",
2080
+ { id: "task-1" },
2081
+ expect.anything(),
2082
+ );
2083
+
2084
+ // A second submit on the (now update-mode) form must dispatch an
2085
+ // update, never a second create.
2086
+ fireEvent.change(screen.getByTestId("field-title").querySelector("input") as HTMLInputElement, {
2087
+ target: { value: "Edited again" },
2088
+ });
2089
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
2090
+ // Exactly one create (the first submit) followed by an update, never a
2091
+ // second create — the singleton wrapper must have flipped branches.
2092
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(2));
2093
+ expect(write).toHaveBeenLastCalledWith("tasks:write:task:update", expect.anything());
2094
+ });
2018
2095
  });
2019
2096
 
2020
2097
  // --- actionForm extension-section (Wave J: Incident-Update-Timeline) ---
@@ -5,8 +5,18 @@
5
5
  // 2. Active-State greift auf node mit screen wenn nav.route's
6
6
  // screenId matcht (Standard-Sidebar-Verhalten).
7
7
 
8
- import { afterEach, describe, expect, test } from "bun:test";
8
+ import {
9
+ afterAll,
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ type Mock,
15
+ spyOn,
16
+ test,
17
+ } from "bun:test";
9
18
  import type {
19
+ NavIconKey,
10
20
  TargetRef,
11
21
  TreeChildrenSubscribe,
12
22
  TreeNode,
@@ -169,6 +179,18 @@ function expectNavIcons(container: HTMLElement, iconKeys: readonly string[]): vo
169
179
  }
170
180
 
171
181
  describe("NavTree", () => {
182
+ let warnSpy: Mock<typeof console.warn>;
183
+ beforeEach(() => {
184
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
185
+ // spyOn reuses the same underlying mock across calls on the same target
186
+ // within a process — without an explicit clear, call history from a
187
+ // PRIOR test in this describe block leaks into the next test's spy.
188
+ warnSpy.mockClear();
189
+ });
190
+ afterAll(() => {
191
+ warnSpy.mockRestore();
192
+ });
193
+
172
194
  test("Section-Header (parent ohne screen) ist statisches Label, children sichtbar", () => {
173
195
  render(<NavTree schema={makeSchema()} testId="tree" />);
174
196
 
@@ -318,15 +340,50 @@ describe("NavTree", () => {
318
340
  expectNavIcons(container, ["palette", "link", "share"]);
319
341
  });
320
342
 
321
- test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg)", () => {
343
+ test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg), aber warnt sichtbar", () => {
322
344
  const schema = {
323
345
  featureName: "showcase",
324
346
  entities: {},
325
347
  screens: [{ id: "x", type: "entityList", entity: "x", columns: [] }],
326
- navs: [{ id: "x", label: "X", screen: "x", order: 10, icon: "does-not-exist" }],
348
+ navs: [
349
+ {
350
+ id: "x",
351
+ label: "X",
352
+ screen: "x",
353
+ order: 10,
354
+ // Deliberately unregistered — asserts the runtime fallback, not the
355
+ // (now compile-time) NavIconKey vocabulary.
356
+ icon: "does-not-exist" as NavIconKey,
357
+ },
358
+ ],
327
359
  } as FeatureSchema;
328
360
  const { container } = render(<NavTree schema={schema} />);
329
361
  expect(container.querySelectorAll("svg").length).toBe(0);
362
+ expect(warnSpy).toHaveBeenCalledWith(
363
+ expect.stringContaining('Nav entry "showcase:nav:x" references icon "does-not-exist"'),
364
+ );
365
+ });
366
+
367
+ test("bekannter icon-Key warnt NICHT", () => {
368
+ const schema = {
369
+ featureName: "showcase",
370
+ entities: {},
371
+ screens: [{ id: "dash", type: "entityList", entity: "x", columns: [] }],
372
+ navs: [{ id: "dash", label: "Dash", screen: "dash", order: 10, icon: "dashboard" }],
373
+ } as FeatureSchema;
374
+ render(<NavTree schema={schema} />);
375
+ expect(warnSpy).not.toHaveBeenCalled();
376
+ });
377
+
378
+ test("kein icon gesetzt warnt NICHT (Bundled Features ohne Icon sind kein Fehler)", () => {
379
+ const schema = {
380
+ featureName: "showcase",
381
+ entities: {},
382
+ screens: [{ id: "plain", type: "entityList", entity: "x", columns: [] }],
383
+ navs: [{ id: "plain", label: "Plain", screen: "plain", order: 10 }],
384
+ } as FeatureSchema;
385
+ render(<NavTree schema={schema} />);
386
+ expect(warnSpy).not.toHaveBeenCalled();
330
387
  });
331
388
  });
332
389
 
@@ -98,6 +98,33 @@ describe("RenderEdit", () => {
98
98
  expect(screen.queryByTestId("field-notes")).toBeNull();
99
99
  });
100
100
 
101
+ // A hidden field must not leave an empty grid cell behind — the cell count
102
+ // has to track the visible field count exactly, in both directions.
103
+ test("a hidden field claims no grid cell; toggling visibility adds/removes exactly one cell", () => {
104
+ render(
105
+ <DispatcherProvider dispatcher={makeDispatcher()}>
106
+ <RenderEdit<TestValues>
107
+ screen={makeScreen()}
108
+ entity={orderEntity}
109
+ featureName="orders"
110
+ initial={{ title: "", count: 0, isUrgent: false }}
111
+ writeCommand="order:create"
112
+ />
113
+ </DispatcherProvider>,
114
+ );
115
+
116
+ const grid = screen.getByTestId("section-Basics").querySelector(".grid");
117
+ expect(grid).toBeTruthy();
118
+ // notes is hidden (isUrgent=false): title, count, isUrgent → 3 cells.
119
+ expect(grid?.children.length).toBe(3);
120
+
121
+ const urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
122
+ fireEvent.click(urgentCheckbox as HTMLElement);
123
+
124
+ // notes becomes visible → 4 cells, no leftover empty cell from before.
125
+ expect(grid?.children.length).toBe(4);
126
+ });
127
+
101
128
  // Issue #1677: a section's optional `description` renders as the
102
129
  // Section's subtitle slot underneath the block heading, and a field's
103
130
  // `icon` reaches the DOM as a prefix icon on its input.
@@ -2485,6 +2512,13 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
2485
2512
  // No silent adopt: the form stays pristine until the user picks.
2486
2513
  const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
2487
2514
  expect(titleInput.value).toBe("");
2515
+ // #1976: single-candidate is now the normal case (auto-adopt removed) —
2516
+ // the banner text and the "start new" button must both resolve to real
2517
+ // copy, not the raw i18n keys.
2518
+ const picker = screen.getByTestId("render-edit-draft-picker");
2519
+ expect(picker.textContent).toContain("Found an open draft for this form");
2520
+ expect(picker.textContent).not.toContain("kumiko.form.draft.resume-single");
2521
+ expect(screen.getByTestId("render-edit-draft-start-new").textContent).toBe("Start new");
2488
2522
  });
2489
2523
 
2490
2524
  test("start-new on a one-candidate picker clears it without adopting, leaving the candidate untouched", async () => {
@@ -2560,6 +2594,10 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
2560
2594
  );
2561
2595
 
2562
2596
  await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
2597
+ // Multiple candidates keep the plural banner copy.
2598
+ expect(screen.getByTestId("render-edit-draft-picker").textContent).toContain(
2599
+ "Found multiple open drafts",
2600
+ );
2563
2601
  const pickSecond = screen.getByTestId(`render-edit-draft-pick-${screenDef.id}:new:draft-2`);
2564
2602
  fireEvent.click(pickSecond);
2565
2603
 
@@ -3170,4 +3208,37 @@ describe("RenderEdit fields filter", () => {
3170
3208
  expect(write).not.toHaveBeenCalled();
3171
3209
  expect(screen.getByTestId("field-notes-errors")).toBeTruthy();
3172
3210
  });
3211
+
3212
+ // #1907: `fields` naming only unknown field names (typo, renamed field)
3213
+ // must not collapse the validation scope to an empty array — an empty
3214
+ // scope filters out ALL issues and lets submit through unvalidated.
3215
+ test("fields naming only unknown field names falls back to unscoped and still blocks submit", async () => {
3216
+ const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
3217
+ const schema = z.object({
3218
+ title: z.string().min(1),
3219
+ notes: z.string().min(1),
3220
+ });
3221
+
3222
+ render(
3223
+ <DispatcherProvider dispatcher={makeDispatcher(write)}>
3224
+ <RenderEdit<FilterValues>
3225
+ screen={makeTwoSectionScreen()}
3226
+ entity={filterEntity}
3227
+ featureName="orders"
3228
+ initial={{ title: "Acme", count: 0, notes: "" }}
3229
+ writeCommand="order:create"
3230
+ schema={schema}
3231
+ fields={["doesNotExist"]}
3232
+ />
3233
+ </DispatcherProvider>,
3234
+ );
3235
+
3236
+ const form = screen.getByTestId("render-edit-form");
3237
+ await act(async () => {
3238
+ fireEvent.submit(form);
3239
+ await Promise.resolve();
3240
+ });
3241
+
3242
+ expect(write).not.toHaveBeenCalled();
3243
+ });
3173
3244
  });
@@ -332,6 +332,35 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
332
332
  for (const f of clientFeatures) {
333
333
  if (f.components !== undefined) Object.assign(customScreens, f.components);
334
334
  }
335
+ // Fail loud at boot instead of per-URL: a mounted server feature can
336
+ // declare a `type: "custom"` screen that's reachable directly by URL
337
+ // even without nav placement — without this check, a missing client
338
+ // plugin only surfaces as an error placeholder to whoever happens to
339
+ // open that URL (kumiko-framework#2025). Screens flagged `dormant`
340
+ // (e.g. user-data-rights privacy-center, auth-mfa's enable screen) are
341
+ // registered without a self-owned r.nav on purpose — an app opts in by
342
+ // navving them explicitly, so a consumer that hasn't done that yet isn't
343
+ // missing anything (kumiko-framework#2034). Still dev-only: several
344
+ // bundled screens that ARE self-navved (e.g. compliance-profiles'
345
+ // profile-picker) have real unmounted-client-plugin bugs in existing
346
+ // consumer apps today (kumiko-framework#2025) — running this in
347
+ // production before those are fixed (tracked via infra#503) would just
348
+ // spam every affected app's console.
349
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
350
+ const missingCustomScreens = app.features.flatMap((f) =>
351
+ f.screens
352
+ .filter(
353
+ (s) => s.type === "custom" && s.dormant !== true && customScreens[s.id] === undefined,
354
+ )
355
+ .map((s) => `${f.featureName}:${s.id}`),
356
+ );
357
+ if (missingCustomScreens.length > 0) {
358
+ // biome-ignore lint/suspicious/noConsole: dev-only diagnostic for missing client plugins
359
+ console.error(
360
+ `[kumiko] ${missingCustomScreens.length} custom screen(s) have no registered component in clientFeatures.components — they render an error placeholder instead of the intended UI: ${missingCustomScreens.join(", ")}. Add the feature's web client plugin to clientFeatures.`,
361
+ );
362
+ }
363
+ }
335
364
  // Column-renderer map, same last-wins semantics as customScreens —
336
365
  // duplicate keys across features are rarely intentional, so a
337
366
  // collision logs once instead of silently overriding a library's
@@ -353,8 +382,8 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
353
382
  app.features.flatMap((f) => f.contentCollections ?? []),
354
383
  );
355
384
 
356
- // Editor-Resolver aggregieren — keyed by "featureId:action". Gleiche
357
- // Last-Wins-Semantik wie columnRenderers. Warnung bei Kollision.
385
+ // Aggregate editor resolvers — keyed by "featureId:action". Same
386
+ // last-wins semantics as columnRenderers. Warns on collision.
358
387
  const resolvers = new Map<string, ResolverComponent>();
359
388
  for (const f of clientFeatures) {
360
389
  if (f.resolvers === undefined) continue;
@@ -425,10 +454,10 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
425
454
  return { root };
426
455
  }
427
456
 
428
- // TokensBoot nutzt den browser-backed TokensApi-Hook (class-based
429
- // dark-toggle) und reicht den Wert an den shared TokensProvider
430
- // durch. Keine eigene State-Haltungdie class auf <html> ist die
431
- // SSoT, useSyncExternalStore im Hook synced das in React.
457
+ // TokensBoot uses the browser-backed TokensApi hook (class-based
458
+ // dark-toggle) and passes the value through to the shared
459
+ // TokensProvider. No own statethe class on <html> is the
460
+ // SSoT, useSyncExternalStore in the hook syncs that into React.
432
461
  function TokensBoot({ children }: { readonly children: ReactNode }): ReactNode {
433
462
  const api = useBrowserTokensApi();
434
463
  return <TokensProvider value={api}>{children}</TokensProvider>;
@@ -471,9 +500,9 @@ function BrowserNavBoot({
471
500
  );
472
501
  }
473
502
 
474
- // Sucht das Feature, dem ein vollständig qualifizierter ScreenQn gehört.
475
- // Returns undefined wenn der Screen in keinem Feature-Schema deklariert
476
- // ist — KumikoScreen rendert dann den "Screen not found"-Banner.
503
+ // Finds the feature that owns a fully qualified ScreenQn.
504
+ // Returns undefined if the screen isn't declared in any feature schema —
505
+ // KumikoScreen then renders the "Screen not found" banner.
477
506
  function findOwnerFeature(app: AppSchema, qn: string): FeatureSchema | undefined {
478
507
  for (const feature of app.features) {
479
508
  for (const s of feature.screens) {
@@ -496,11 +525,11 @@ function RoutedScreen({
496
525
  }): ReactNode {
497
526
  const nav = useNav();
498
527
 
499
- // ScreenId aus dem Route ist NICHT qualified — nav.route.screenId
500
- // kommt aus dem URL-Path und ist die kurze Form ("order-list"). Wir
501
- // müssen das ans richtige Feature heften. Strategie: durch alle
502
- // Features iterieren bis das passende Screen-Decl auftaucht. Ohne
503
- // Match Fallback-Feature (das vom fallbackQn).
528
+ // ScreenId from the route is NOT qualified — nav.route.screenId
529
+ // comes from the URL path and is the short form ("order-list"). We
530
+ // need to pin it to the right feature. Strategy: iterate through all
531
+ // features until the matching screen decl turns up. No match →
532
+ // fallback feature (the one from fallbackQn).
504
533
  const { feature, qn, entityId } = useMemo(() => {
505
534
  if (nav.route === undefined) {
506
535
  return {
@@ -532,17 +561,17 @@ function RoutedScreen({
532
561
  >(() => {
533
562
  if (onRowClick !== undefined) return onRowClick;
534
563
  return (row, entityName) => {
535
- // Edit-Screen für die Entity über alle Features suchen im
536
- // Single-Feature-Setup ist das das gleiche Feature wie das aktive,
537
- // im Multi-Feature kann der Edit theoretisch in einem anderen
538
- // Feature liegen (eines, das die Entity teilt).
564
+ // Search for the edit screen for the entity across all features
565
+ // in a single-feature setup that's the same feature as the active
566
+ // one, in multi-feature the edit could theoretically live in a
567
+ // different feature (one that shares the entity).
539
568
  for (const f of app.features) {
540
569
  const editScreen = f.screens.find(
541
570
  (s) => s.type === "entityEdit" && s.entity === entityName,
542
571
  );
543
572
  if (editScreen) {
544
- // editScreen.id ist QN-Form (registry-stamped); nav.navigate
545
- // erwartet Short-Form. Sonst wird die URL doppelt-qualifiziert.
573
+ // editScreen.id is QN form (registry-stamped); nav.navigate
574
+ // expects the short form. Otherwise the URL gets double-qualified.
546
575
  nav.navigate({ screenId: lastSegment(editScreen.id), entityId: row.id });
547
576
  return;
548
577
  }
@@ -550,12 +579,12 @@ function RoutedScreen({
550
579
  };
551
580
  }, [onRowClick, app.features, nav]);
552
581
 
553
- // Copy-Link-Action (Issue #912) für entityEdit-Update-Screens. Baut die
554
- // absolute Permalink-URL aus der aktuellen Route + kopiert sie
555
- // `navigator`/`window` sind hier erlaubt (renderer-web, kein
556
- // platform-neutrales Package). Kein Button ohne entityId (create-mode).
557
- // Silent-catch bei Clipboard-Fehler (non-secure context) mirrort das
558
- // bestehende Muster in pat-tokens-screen.tsx.
582
+ // Copy-link action (Issue #912) for entityEdit update screens. Builds
583
+ // the absolute permalink URL from the current route + copies it
584
+ // `navigator`/`window` are allowed here (renderer-web, not a
585
+ // platform-neutral package). No button without entityId (create-mode).
586
+ // Silent-catch on clipboard error (non-secure context) mirrors the
587
+ // existing pattern in pat-tokens-screen.tsx.
559
588
  const effectiveOnCopyLink = useMemo<(() => Promise<void> | void) | undefined>(() => {
560
589
  const route = nav.route;
561
590
  if (route?.entityId === undefined) return undefined;
@@ -17,7 +17,7 @@ import type {
17
17
  TreeAction,
18
18
  TreeNode,
19
19
  } from "@cosmicdrift/kumiko-framework/engine";
20
- import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
20
+ import type { NavDefinition, NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
21
21
  import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
22
22
  import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
23
23
  import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
@@ -48,6 +48,7 @@ import {
48
48
  Hash,
49
49
  Home,
50
50
  KeyRound,
51
+ Languages,
51
52
  Layers,
52
53
  LayoutDashboard,
53
54
  LayoutGrid,
@@ -106,12 +107,18 @@ import {
106
107
  import { useDispatchTarget } from "./target-resolver-stub";
107
108
  import { parseTargetFromSearchParams } from "./target-url";
108
109
 
109
- // Nav-Icon-Registry: ein Nav-Eintrag setzt `icon: "<key>"` (im r.nav-Decl),
110
- // der Renderer mappt den symbolischen Key auf ein lucide-Component. Unknown
111
- // Keys kein Icon (sauberer Fallback auf den Dot, kein Boot-Fail). Spiegelt
112
- // das NODE_ICONS-Muster vom Visual-Tree. App-Authors referenzieren nur diese
113
- // Keys; Erweiterung = neuer Eintrag hier (eine Quelle, alle Apps).
114
- const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
110
+ // Nav-icon registry: a nav entry sets `icon: "<key>"` (in the r.nav decl),
111
+ // the renderer maps the symbolic key to a lucide component. `NavIconKey` is
112
+ // the closed vocabulary a feature author can write (packages/types/src/
113
+ // nav-icon.ts); `satisfies` below makes this map a compile-time drift
114
+ // guard a key added to one without the other fails the build.
115
+ //
116
+ // `node.icon`/`TreeAction.icon` stay plain `string` at the resolved-tree
117
+ // layer (dynamic/provider-supplied data isn't statically known), so the
118
+ // runtime `Object.hasOwn` lookups below still see an unknown key on
119
+ // occasion — that's the defense-in-depth fallback to the dot, not the
120
+ // primary guard anymore.
121
+ const NAV_ICONS = {
115
122
  dashboard: LayoutDashboard,
116
123
  "layout-grid": LayoutGrid,
117
124
  "book-open": BookOpen,
@@ -158,7 +165,13 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
158
165
  rocket: Rocket,
159
166
  // Was imported but never registered — `icon: "plus"` silently fell back.
160
167
  plus: Plus,
161
- };
168
+ languages: Languages,
169
+ } as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
170
+
171
+ // Widened alias for the two lookup sites below, which index by the plain
172
+ // `string` icon key of the resolved NavNode/TreeAction tree — not the
173
+ // closed NavIconKey union NAV_ICONS itself is typed against.
174
+ const NAV_ICON_LOOKUP: Readonly<Record<string, typeof Folder | undefined>> = NAV_ICONS;
162
175
 
163
176
  export type NavTreeProps = {
164
177
  // Akzeptiert beide Shapes — AppSchema (multi-feature) oder
@@ -336,8 +349,17 @@ function NavLeadingIcon({
336
349
  label?: string;
337
350
  }): ReactNode {
338
351
  const iconKey = expanded && node.icon === "folder" ? "folder-open" : node.icon;
339
- const NavIcon =
340
- iconKey !== undefined && Object.hasOwn(NAV_ICONS, iconKey) ? NAV_ICONS[iconKey] : undefined;
352
+ const isKnownIcon = iconKey !== undefined && Object.hasOwn(NAV_ICONS, iconKey);
353
+ const NavIcon = iconKey !== undefined && isKnownIcon ? NAV_ICON_LOOKUP[iconKey] : undefined;
354
+ useEffect(() => {
355
+ if (iconKey !== undefined && !isKnownIcon) {
356
+ // biome-ignore lint/suspicious/noConsole: diagnostic for unregistered nav icon keys
357
+ console.warn(
358
+ `[kumiko] Nav entry "${node.qualifiedName}" references icon "${iconKey}", which is not ` +
359
+ `registered in NAV_ICONS — falling back to the dot indicator. Check the icon key spelling.`,
360
+ );
361
+ }
362
+ }, [iconKey, isKnownIcon, node.qualifiedName]);
341
363
  if (NavIcon !== undefined) return <NavIcon aria-hidden="true" className="shrink-0" />;
342
364
  const initial = label?.trim().charAt(0).toUpperCase();
343
365
  return (
@@ -526,10 +548,10 @@ function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNode
526
548
  };
527
549
  }
528
550
 
529
- // Action-Icon-Lookup: bekannter NAV_ICONS-Key → Lucide, sonst der rohe
530
- // String als Text (Provider-Konvention, kein Boot-Fail bei unknown).
551
+ // Action icon lookup: registered NAV_ICONS key → Lucide icon, otherwise the
552
+ // raw string as text (provider convention, no boot-fail on an unknown key).
531
553
  function ActionGlyph({ icon }: { readonly icon: string }): ReactNode {
532
- const Icon = Object.hasOwn(NAV_ICONS, icon) ? NAV_ICONS[icon] : undefined;
554
+ const Icon = Object.hasOwn(NAV_ICONS, icon) ? NAV_ICON_LOOKUP[icon] : undefined;
533
555
  if (Icon !== undefined) return <Icon aria-hidden className="size-3.5" />;
534
556
  return (
535
557
  <span aria-hidden className="text-xs">
@@ -171,4 +171,16 @@ describe("formatDatePlaceholder", () => {
171
171
  test("en-GB → day/month/year pattern with slash separator", () => {
172
172
  expect(formatDatePlaceholder("en-GB", enPlaceholderLetters)).toBe("DD/MM/YYYY");
173
173
  });
174
+
175
+ // #1879: a missing i18n key falls back to the raw, multi-character key
176
+ // string (see i18n.tsx) — the placeholder must clamp to one char per slot
177
+ // instead of repeating the whole fallback key.
178
+ test("multi-character letters (e.g. raw i18n fallback key) clamp to first code point", () => {
179
+ const rawKeyLetters = {
180
+ year: "kumiko.field.dateField.placeholderYear",
181
+ month: "kumiko.field.dateField.placeholderMonth",
182
+ day: "kumiko.field.dateField.placeholderDay",
183
+ };
184
+ expect(formatDatePlaceholder("de-DE", rawKeyLetters)).toBe("kk.kk.kkkk");
185
+ });
174
186
  });
@@ -281,6 +281,32 @@ describe("EmbeddedListInput — issue rendering", () => {
281
281
  ).toBeTruthy();
282
282
  });
283
283
 
284
+ // #1876: a long validation message in a narrow cell must wrap instead of
285
+ // widening the whole `min-w-max` desktop table into horizontal scroll.
286
+ test("cellIssues in the desktop table are width-constrained and wrap", () => {
287
+ const rows = [{ description: "A", quantity: 1, amount: 100 }];
288
+ renderWithLocale(
289
+ <EmbeddedListInput
290
+ {...baseProps({
291
+ rows,
292
+ cellIssues: {
293
+ "0.description": [
294
+ issue(
295
+ "lines.0.description",
296
+ "This is a deliberately long validation message that must wrap instead of widening the table",
297
+ ),
298
+ ],
299
+ },
300
+ })}
301
+ />,
302
+ );
303
+ const desktop = within(screen.getByTestId("lines-desktop"));
304
+ const container = desktop.getByTestId("lines-cell-0-description-errors");
305
+ expect(container.className).toContain("max-w-[16rem]");
306
+ expect(container.className).toContain("whitespace-normal");
307
+ expect(container.className).toContain("break-words");
308
+ });
309
+
284
310
  test("rowIssues render under the matching row", () => {
285
311
  const rows = [{ description: "A", quantity: 1, amount: 100 }];
286
312
  renderWithLocale(
@@ -105,15 +105,22 @@ function localeDateOrder(locale: string): readonly DateSlot[] {
105
105
  // from formatToParts — nothing hardcoded per locale. `letters` is one
106
106
  // character per slot (from i18n); repeated to the slot's digit count
107
107
  // (day/month 2, year 4).
108
+ // Clamps each slot letter to its first code point — `letters` normally comes
109
+ // from i18n and can fall back to the raw, multi-character translation key
110
+ // when a lookup misses, which would otherwise blow up the placeholder length.
111
+ function firstCodePoint(s: string): string {
112
+ return [...s][0] ?? "?";
113
+ }
114
+
108
115
  export function formatDatePlaceholder(
109
116
  locale: string,
110
117
  letters: { readonly year: string; readonly month: string; readonly day: string },
111
118
  ): string {
112
119
  return localeDateParts(locale)
113
120
  .map((part) => {
114
- if (part.type === "year") return letters.year.repeat(4);
115
- if (part.type === "month") return letters.month.repeat(2);
116
- if (part.type === "day") return letters.day.repeat(2);
121
+ if (part.type === "year") return firstCodePoint(letters.year).repeat(4);
122
+ if (part.type === "month") return firstCodePoint(letters.month).repeat(2);
123
+ if (part.type === "day") return firstCodePoint(letters.day).repeat(2);
117
124
  return part.value;
118
125
  })
119
126
  .join("");
@@ -121,14 +121,27 @@ function parsePasteGrid(text: string): readonly (readonly string[])[] {
121
121
  function IssueMessages({
122
122
  issues,
123
123
  testId,
124
+ constrainWidth,
124
125
  }: {
125
126
  readonly issues: readonly FieldIssue[] | undefined;
126
127
  readonly testId?: string;
128
+ // Table cells sit inside a `min-w-max` table (see EmbeddedListInput below):
129
+ // a long, unbounded message there widens the whole table instead of
130
+ // wrapping, forcing horizontal scroll. Only the desktop table-cell path
131
+ // needs this — mobile cards and full-width row/list issues wrap naturally.
132
+ readonly constrainWidth?: boolean;
127
133
  }): ReactNode {
128
134
  const t = useTranslation();
129
135
  if (issues === undefined || issues.length === 0) return null;
130
136
  return (
131
- <div role="alert" data-testid={testId} className="text-xs text-destructive">
137
+ <div
138
+ role="alert"
139
+ data-testid={testId}
140
+ className={cn(
141
+ "text-xs text-destructive",
142
+ constrainWidth === true && "max-w-[16rem] whitespace-normal break-words",
143
+ )}
144
+ >
132
145
  {issues.map((issue) => (
133
146
  <div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey, issue.params)}</div>
134
147
  ))}
@@ -554,6 +567,7 @@ export function EmbeddedListInput({
554
567
  <IssueMessages
555
568
  issues={issues}
556
569
  testId={testIdFor(`cell-${rowIndex}-${column.field}-errors`)}
570
+ constrainWidth
557
571
  />
558
572
  </TableCell>
559
573
  );
@@ -1596,7 +1596,12 @@ function DefaultForm({
1596
1596
  onSubmit(e);
1597
1597
  }}
1598
1598
  data-testid={testId}
1599
- className="flex flex-col gap-4"
1599
+ className={cn(
1600
+ "flex flex-col gap-4",
1601
+ // Bare forms stack sections without a card; without a divider a
1602
+ // section boundary reads as a layout gap, not structure.
1603
+ "[&>section:not(:first-child)]:border-t",
1604
+ )}
1600
1605
  >
1601
1606
  <InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
1602
1607
  {actions !== undefined && (
@@ -0,0 +1,99 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import type { LiveEvent } from "@cosmicdrift/kumiko-renderer";
3
+ import { createEventSourceLiveEvents } from "../live-events";
4
+
5
+ // happy-dom doesn't provide EventSource, and this module only needs
6
+ // `typeof window !== "undefined"` to unlock — no real DOM required. Stub
7
+ // both globals directly instead of pulling in the project's DOM test config.
8
+ class FakeEventSource {
9
+ static instances: FakeEventSource[] = [];
10
+ private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
11
+
12
+ constructor(readonly url: string) {
13
+ FakeEventSource.instances.push(this);
14
+ }
15
+
16
+ addEventListener(type: string, listener: (e: MessageEvent) => void): void {
17
+ let set = this.listeners.get(type);
18
+ if (!set) {
19
+ set = new Set();
20
+ this.listeners.set(type, set);
21
+ }
22
+ set.add(listener);
23
+ }
24
+
25
+ close(): void {}
26
+
27
+ dispatch(entityName: string, data: unknown): void {
28
+ const event = { data: JSON.stringify(data) } as MessageEvent;
29
+ for (const listener of this.listeners.get(entityName) ?? []) listener(event);
30
+ }
31
+ }
32
+
33
+ const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
34
+ const originalEventSource = Object.getOwnPropertyDescriptor(globalThis, "EventSource");
35
+
36
+ beforeEach(() => {
37
+ FakeEventSource.instances.length = 0;
38
+ // biome-ignore lint/suspicious/noExplicitAny: test-only global stub
39
+ (globalThis as any).window = globalThis;
40
+ // biome-ignore lint/suspicious/noExplicitAny: test-only global stub
41
+ (globalThis as any).EventSource = FakeEventSource;
42
+ });
43
+
44
+ afterEach(() => {
45
+ if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow);
46
+ else delete (globalThis as { window?: unknown }).window;
47
+ if (originalEventSource) Object.defineProperty(globalThis, "EventSource", originalEventSource);
48
+ else delete (globalThis as { EventSource?: unknown }).EventSource;
49
+ });
50
+
51
+ function entityEvent(overrides: Partial<LiveEvent["data"]> = {}): LiveEvent["data"] {
52
+ return {
53
+ id: "e1",
54
+ aggregateType: "invoice",
55
+ version: 1,
56
+ payload: {},
57
+ createdAt: "2026-01-01T00:00:00.000Z",
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ describe("createEventSourceLiveEvents", () => {
63
+ test("any verb — including business verbs and the auto-verb 'forgotten' — triggers the entity listener", () => {
64
+ const liveEvents = createEventSourceLiveEvents();
65
+ const received: LiveEvent[] = [];
66
+ const unsubscribe = liveEvents("invoice", (event) => received.push(event));
67
+
68
+ const source = FakeEventSource.instances.at(-1);
69
+ expect(source).toBeDefined();
70
+ // The server now names every frame after the entity, not the verb — a
71
+ // business verb like "archived" or the auto-verb "forgotten" never
72
+ // needed its own listener because no verb-specific listener exists.
73
+ source?.dispatch("invoice", entityEvent({ id: "archived-1" }));
74
+ source?.dispatch("invoice", entityEvent({ id: "forgotten-1" }));
75
+
76
+ expect(received).toHaveLength(2);
77
+ expect(received[0]?.type).toBe("invoice");
78
+ expect(received[1]?.data.id).toBe("forgotten-1");
79
+
80
+ unsubscribe();
81
+ });
82
+
83
+ test("a subscriber for one entity does not receive another entity's frame", () => {
84
+ const liveEvents = createEventSourceLiveEvents();
85
+ const invoiceEvents: LiveEvent[] = [];
86
+ const userEvents: LiveEvent[] = [];
87
+ const unsubInvoice = liveEvents("invoice", (event) => invoiceEvents.push(event));
88
+ const unsubUser = liveEvents("user", (event) => userEvents.push(event));
89
+
90
+ const source = FakeEventSource.instances.at(-1);
91
+ source?.dispatch("invoice", entityEvent({ aggregateType: "invoice" }));
92
+
93
+ expect(invoiceEvents).toHaveLength(1);
94
+ expect(userEvents).toHaveLength(0);
95
+
96
+ unsubInvoice();
97
+ unsubUser();
98
+ });
99
+ });
@@ -3,15 +3,13 @@ import type { LiveEvent, LiveEventSubscriber } from "@cosmicdrift/kumiko-rendere
3
3
  // EventSource-backed Live-Events für den Web-Renderer. Der shared
4
4
  // Layer konsumiert nur das `LiveEventSubscriber`-Interface; diese Datei
5
5
  // liefert eine Factory die intern eine EventSource auf /api/sse aufbaut,
6
- // pro (entity, verb)-Kombi einen addEventListener verdrahtet und
7
- // subscriptions routet.
6
+ // pro Entity EINEN addEventListener verdrahtet (Server benennt den Frame
7
+ // nach dem aggregateType, siehe sse-route.ts) und subscriptions routet.
8
8
  //
9
9
  // Verbindungs-Lifecycle: lazy beim ersten subscribe, close wenn der
10
10
  // letzte unsubscribe feuert. Mehrere Consumer teilen sich dieselbe
11
11
  // EventSource, sparen CPU + Server-Load.
12
12
 
13
- const VERBS = ["created", "updated", "deleted", "restored"] as const;
14
-
15
13
  type EntitySubscriber = {
16
14
  readonly entityName: string;
17
15
  readonly listener: (event: LiveEvent) => void;
@@ -34,18 +32,21 @@ export function createEventSourceLiveEvents(
34
32
 
35
33
  const subscribers = new Set<EntitySubscriber>();
36
34
  let source: EventSource | undefined;
37
- const wiredTypes = new Set<string>();
35
+ const wiredEntities = new Set<string>();
38
36
 
39
- const handleEvent = (type: string, raw: string): void => {
37
+ // `subscribers` is a flat Set across all entities — the browser-side
38
+ // addEventListener(entityName, ...) gate below only filters which frames
39
+ // arrive at all, not which subscriber a given frame is for. Without this
40
+ // filter, an `invoice` subscriber would also fire on every `user` frame.
41
+ const handleEvent = (raw: string): void => {
40
42
  let parsed: LiveEvent["data"];
41
43
  try {
42
44
  parsed = JSON.parse(raw) as LiveEvent["data"];
43
45
  } catch {
44
46
  // skip: malformed SSE payload, drop it rather than crash all subscribers
45
- // zu crashen und alle anderen subscribers mitzureißen.
46
47
  return;
47
48
  }
48
- const event: LiveEvent = { type, data: parsed };
49
+ const event: LiveEvent = { type: parsed.aggregateType, data: parsed };
49
50
  for (const sub of subscribers) {
50
51
  if (sub.entityName === parsed.aggregateType) sub.listener(event);
51
52
  }
@@ -62,14 +63,12 @@ export function createEventSourceLiveEvents(
62
63
  const ensureListenersForEntity = (entityName: string): void => {
63
64
  // skip: not connected yet, listeners get wired once ensureConnected runs
64
65
  if (source === undefined) return;
65
- for (const verb of VERBS) {
66
- const type = `${entityName}.${verb}`;
67
- if (wiredTypes.has(type)) continue;
68
- source.addEventListener(type, (e) => {
69
- handleEvent(type, (e as MessageEvent).data);
70
- });
71
- wiredTypes.add(type);
72
- }
66
+ // skip: already wired for this entity
67
+ if (wiredEntities.has(entityName)) return;
68
+ source.addEventListener(entityName, (e) => {
69
+ handleEvent((e as MessageEvent).data);
70
+ });
71
+ wiredEntities.add(entityName);
73
72
  };
74
73
 
75
74
  const closeIfEmpty = (): void => {
@@ -79,7 +78,7 @@ export function createEventSourceLiveEvents(
79
78
  if (source === undefined) return;
80
79
  source.close();
81
80
  source = undefined;
82
- wiredTypes.clear();
81
+ wiredEntities.clear();
83
82
  };
84
83
 
85
84
  return (entityName, listener) => {