@cosmicdrift/kumiko-renderer 0.192.0 → 0.193.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",
3
- "version": "0.192.0",
3
+ "version": "0.193.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.192.0",
19
- "@cosmicdrift/kumiko-headless": "0.192.0",
18
+ "@cosmicdrift/kumiko-framework": "0.193.0",
19
+ "@cosmicdrift/kumiko-headless": "0.193.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -0,0 +1,239 @@
1
+ // Finding: render-field-unsupported-types.test.tsx only proves jsonb/embedded
2
+ // (no embeddedListCells) fields render read-only (Banner, no Input wired up).
3
+ // It never proves the actual claim that matters: saving the form after
4
+ // editing an UNRELATED field does not corrupt or overwrite those fields'
5
+ // real data. Because RenderField never wires an onChange for them, their
6
+ // value in the form controller stays reference-identical to the record it
7
+ // was seeded from — the update payload's `changes` diff (reference-equality
8
+ // based, see form-controller.ts valuesDiff) must therefore omit them
9
+ // entirely, never re-include them as e.g. a stringified copy.
10
+ //
11
+ // This renders the real update path (KumikoScreen → EntityEditScreen →
12
+ // EntityEditUpdateBody → RenderEdit → RenderField) under a stub dispatcher
13
+ // and inspects the actual write() payload, not just the render tree.
14
+
15
+ import { describe, expect, test } from "bun:test";
16
+ import type {
17
+ EntityDefinition,
18
+ EntityEditScreenDefinition,
19
+ } from "@cosmicdrift/kumiko-framework/ui-types";
20
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
21
+ import { act, render, waitFor } from "@testing-library/react";
22
+ import type { ComponentType, FormEvent, ReactNode } from "react";
23
+ import { DispatcherProvider } from "../../context/dispatcher-context";
24
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
25
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
26
+ import {
27
+ type CorePrimitives,
28
+ type FormProps,
29
+ type InputProps,
30
+ PrimitivesProvider,
31
+ } from "../../primitives";
32
+ import type { FeatureSchema } from "../feature-schema";
33
+ import { KumikoScreen } from "../kumiko-screen";
34
+ import { NavProvider } from "../nav";
35
+
36
+ const capturedInputs: Record<string, InputProps | undefined> = {};
37
+ const captureInput: ComponentType<InputProps> = (props) => {
38
+ capturedInputs[props.name] = props;
39
+ return null;
40
+ };
41
+ let capturedFormSubmit: ((e?: FormEvent) => void) | undefined;
42
+ const captureForm: ComponentType<FormProps> = (props) => {
43
+ capturedFormSubmit = props.onSubmit;
44
+ return <>{props.children}</>;
45
+ };
46
+ const noop = (): ReactNode => null;
47
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
48
+
49
+ const testPrimitives: CorePrimitives = {
50
+ Button: noop,
51
+ Banner: passChildren,
52
+ Field: passChildren,
53
+ Input: captureInput,
54
+ DataTable: noop,
55
+ Form: captureForm,
56
+ Section: passChildren,
57
+ Card: passChildren,
58
+ Grid: passChildren,
59
+ GridCell: passChildren,
60
+ Text: passChildren,
61
+ Heading: noop,
62
+ Dialog: noop,
63
+ Modal: noop,
64
+ Lightbox: noop,
65
+ ConfigSourceBadge: noop,
66
+ ConfigCascadeView: noop,
67
+ Link: noop,
68
+ };
69
+
70
+ let lastWrite: { type: string; payload: unknown } | undefined;
71
+
72
+ // The annotation resets TS's flow-narrowing, which only tracks the
73
+ // straight-line `lastWrite = undefined` reset, not the reassignment inside the dispatcher closure.
74
+ function requireLastWrite(): { type: string; payload: unknown } {
75
+ const write: { type: string; payload: unknown } | undefined = lastWrite;
76
+ if (!write) throw new Error("expected a write() call");
77
+ return write;
78
+ }
79
+
80
+ function stubDispatcher(record: Readonly<Record<string, unknown>>): Dispatcher {
81
+ return {
82
+ write: (async (type: string, payload: unknown) => {
83
+ lastWrite = { type, payload };
84
+ return { isSuccess: true, data: {} };
85
+ }) as unknown as Dispatcher["write"],
86
+ query: (async () => ({ isSuccess: true, data: record })) as unknown as Dispatcher["query"],
87
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
88
+ statusStore: {
89
+ getState: () => "online",
90
+ subscribe: () => () => {},
91
+ } as unknown as Dispatcher["statusStore"],
92
+ async *stream() {},
93
+ pendingWrites: () => [],
94
+ pendingFiles: () => [],
95
+ };
96
+ }
97
+
98
+ function buildSchema(): FeatureSchema {
99
+ const entity: EntityDefinition = {
100
+ fields: {
101
+ name: { type: "text", maxLength: 200, required: false, searchable: false, sortable: false },
102
+ extra: { type: "jsonb" },
103
+ meta: {
104
+ type: "embedded",
105
+ schema: { note: { type: "text" } },
106
+ },
107
+ },
108
+ };
109
+ const screen: EntityEditScreenDefinition = {
110
+ id: "widget-edit",
111
+ type: "entityEdit",
112
+ entity: "widget",
113
+ layout: { sections: [{ columns: 1, fields: ["name", "extra", "meta"] }] },
114
+ };
115
+ return {
116
+ featureName: "widgets",
117
+ entities: { widget: entity },
118
+ screens: [screen],
119
+ } as FeatureSchema;
120
+ }
121
+
122
+ describe("EntityEditUpdateForm — untouched jsonb/embedded fields survive submit", () => {
123
+ test("changing an unrelated field and submitting keeps jsonb/embedded fields out of the diff (not stringified, not overwritten)", async () => {
124
+ lastWrite = undefined;
125
+ capturedFormSubmit = undefined;
126
+ for (const key of Object.keys(capturedInputs)) delete capturedInputs[key];
127
+
128
+ const record = {
129
+ id: "w1",
130
+ version: 3,
131
+ name: "Old name",
132
+ extra: { a: 1 },
133
+ meta: { note: "keep me" },
134
+ };
135
+
136
+ render(
137
+ <LocaleProvider
138
+ resolver={createStaticLocaleResolver({ locale: "de-DE" })}
139
+ fallbackBundles={[kumikoDefaultTranslations]}
140
+ >
141
+ <DispatcherProvider dispatcher={stubDispatcher(record)}>
142
+ <NavProvider
143
+ value={{
144
+ route: { screenId: "widgets:widget-edit" },
145
+ navigate: () => {},
146
+ replace: () => {},
147
+ hrefFor: () => "",
148
+ searchParams: {},
149
+ setSearchParams: () => {},
150
+ }}
151
+ >
152
+ <PrimitivesProvider value={testPrimitives}>
153
+ <KumikoScreen schema={buildSchema()} qn="widgets:screen:widget-edit" entityId="w1" />
154
+ </PrimitivesProvider>
155
+ </NavProvider>
156
+ </DispatcherProvider>
157
+ </LocaleProvider>,
158
+ );
159
+
160
+ await waitFor(() => {
161
+ expect(capturedInputs["name"]).toBeDefined();
162
+ });
163
+
164
+ const nameInput = capturedInputs["name"];
165
+ if (nameInput?.kind !== "text") throw new Error("expected a text input for the name field");
166
+ act(() => nameInput.onChange("New name"));
167
+
168
+ await waitFor(() => {
169
+ expect(capturedFormSubmit).toBeDefined();
170
+ });
171
+ act(() => capturedFormSubmit?.());
172
+
173
+ await waitFor(() => {
174
+ expect(lastWrite).toBeDefined();
175
+ });
176
+
177
+ const payload = requireLastWrite().payload as { changes: Record<string, unknown> };
178
+ expect(payload.changes["name"]).toBe("New name");
179
+ // The untouched jsonb/embedded fields must never resurface in the diff —
180
+ // if they did (the pre-fix bug this guards against), it would mean the
181
+ // update overwrites them, and the old code path could send a corrupted
182
+ // (e.g. stringified) copy instead of the real object.
183
+ expect(Object.hasOwn(payload.changes, "extra")).toBe(false);
184
+ expect(Object.hasOwn(payload.changes, "meta")).toBe(false);
185
+ });
186
+
187
+ test("touching the jsonb field itself keeps it a real object in the diff, never a string", async () => {
188
+ lastWrite = undefined;
189
+ capturedFormSubmit = undefined;
190
+ for (const key of Object.keys(capturedInputs)) delete capturedInputs[key];
191
+
192
+ const record = {
193
+ id: "w1",
194
+ version: 3,
195
+ name: "Old name",
196
+ extra: { a: 1 },
197
+ meta: { note: "keep me" },
198
+ };
199
+
200
+ render(
201
+ <LocaleProvider
202
+ resolver={createStaticLocaleResolver({ locale: "de-DE" })}
203
+ fallbackBundles={[kumikoDefaultTranslations]}
204
+ >
205
+ <DispatcherProvider dispatcher={stubDispatcher(record)}>
206
+ <NavProvider
207
+ value={{
208
+ route: { screenId: "widgets:widget-edit" },
209
+ navigate: () => {},
210
+ replace: () => {},
211
+ hrefFor: () => "",
212
+ searchParams: {},
213
+ setSearchParams: () => {},
214
+ }}
215
+ >
216
+ <PrimitivesProvider value={testPrimitives}>
217
+ <KumikoScreen schema={buildSchema()} qn="widgets:screen:widget-edit" entityId="w1" />
218
+ </PrimitivesProvider>
219
+ </NavProvider>
220
+ </DispatcherProvider>
221
+ </LocaleProvider>,
222
+ );
223
+
224
+ await waitFor(() => {
225
+ expect(capturedFormSubmit).toBeDefined();
226
+ });
227
+ act(() => capturedFormSubmit?.());
228
+
229
+ await waitFor(() => {
230
+ expect(lastWrite).toBeDefined();
231
+ });
232
+
233
+ const payload = requireLastWrite().payload as { changes: Record<string, unknown> };
234
+ // Nothing was touched at all — the diff must be empty, and specifically
235
+ // must not contain a stringified copy of extra/meta.
236
+ expect(Object.hasOwn(payload.changes, "extra")).toBe(false);
237
+ expect(Object.hasOwn(payload.changes, "meta")).toBe(false);
238
+ });
239
+ });
@@ -0,0 +1,128 @@
1
+ // Guard-evasion-adjacent bug: useNavigateToCreateFor picked the first
2
+ // entityEdit screen with allowCreate !== false as the "+ Neu" target,
3
+ // without excluding singleton:true screens. A singleton screen opened
4
+ // without an entityId resolves the existing record (EntityEditSingletonBody)
5
+ // instead of a blank create form — so "+ Neu" silently opened a prefilled
6
+ // update form. This test renders the real list path (KumikoScreen →
7
+ // EntityListScreen → EntityListBody → RenderList) for an entity whose only
8
+ // registered entityEdit screen is a singleton, and asserts no create button
9
+ // renders — not just the hook in isolation.
10
+ import { describe, expect, test } from "bun:test";
11
+ import type {
12
+ EntityDefinition,
13
+ EntityEditScreenDefinition,
14
+ EntityListScreenDefinition,
15
+ } from "@cosmicdrift/kumiko-framework/ui-types";
16
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
17
+ import { render } from "@testing-library/react";
18
+ import type { ComponentType, ReactNode } from "react";
19
+ import { DispatcherProvider } from "../../context/dispatcher-context";
20
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
21
+ import { type ButtonProps, type CorePrimitives, PrimitivesProvider } from "../../primitives";
22
+ import type { FeatureSchema } from "../feature-schema";
23
+ import { KumikoScreen } from "../kumiko-screen";
24
+ import { NavProvider } from "../nav";
25
+
26
+ const capturedButtonTestIds: string[] = [];
27
+ const captureButton: ComponentType<ButtonProps> = (props) => {
28
+ if (props.testId !== undefined) capturedButtonTestIds.push(props.testId);
29
+ return null;
30
+ };
31
+ const noop = (): ReactNode => null;
32
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
33
+
34
+ const testPrimitives: CorePrimitives = {
35
+ Button: captureButton,
36
+ Banner: passChildren,
37
+ Field: passChildren,
38
+ Input: noop,
39
+ DataTable: noop,
40
+ Form: passChildren,
41
+ Section: passChildren,
42
+ Card: passChildren,
43
+ Grid: passChildren,
44
+ GridCell: passChildren,
45
+ Text: passChildren,
46
+ Heading: noop,
47
+ Dialog: noop,
48
+ Modal: noop,
49
+ Lightbox: noop,
50
+ ConfigSourceBadge: noop,
51
+ ConfigCascadeView: noop,
52
+ Link: noop,
53
+ };
54
+
55
+ function stubDispatcher(): Dispatcher {
56
+ return {
57
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
58
+ query: (async () => ({
59
+ isSuccess: true,
60
+ data: { rows: [], total: 0 },
61
+ })) as unknown as Dispatcher["query"],
62
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
63
+ statusStore: {
64
+ getState: () => "online",
65
+ subscribe: () => () => {},
66
+ } as unknown as Dispatcher["statusStore"],
67
+ async *stream() {},
68
+ pendingWrites: () => [],
69
+ pendingFiles: () => [],
70
+ };
71
+ }
72
+
73
+ function buildSchema(): FeatureSchema {
74
+ const entity: EntityDefinition = {
75
+ fields: {
76
+ name: { type: "text", maxLength: 200, required: false, searchable: false, sortable: false },
77
+ },
78
+ };
79
+ const listScreen: EntityListScreenDefinition = {
80
+ id: "org-list",
81
+ type: "entityList",
82
+ entity: "org",
83
+ columns: ["name"],
84
+ };
85
+ // The only entityEdit screen for "org" is a singleton — there is
86
+ // deliberately no separate non-singleton entityEdit screen.
87
+ const editScreen: EntityEditScreenDefinition = {
88
+ id: "org-edit",
89
+ type: "entityEdit",
90
+ entity: "org",
91
+ singleton: true,
92
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
93
+ };
94
+ return {
95
+ featureName: "orgs",
96
+ entities: { org: entity },
97
+ screens: [listScreen, editScreen],
98
+ } as FeatureSchema;
99
+ }
100
+
101
+ describe("entityList '+ Neu' target excludes singleton entityEdit screens", () => {
102
+ test("no create button renders when the only entityEdit screen is singleton:true", () => {
103
+ capturedButtonTestIds.length = 0;
104
+ render(
105
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
106
+ <DispatcherProvider dispatcher={stubDispatcher()}>
107
+ <NavProvider
108
+ value={{
109
+ route: { screenId: "orgs:org-list" },
110
+ navigate: () => {},
111
+ replace: () => {},
112
+ hrefFor: () => "",
113
+ searchParams: {},
114
+ setSearchParams: () => {},
115
+ }}
116
+ >
117
+ <PrimitivesProvider value={testPrimitives}>
118
+ <KumikoScreen schema={buildSchema()} qn="orgs:screen:org-list" />
119
+ </PrimitivesProvider>
120
+ </NavProvider>
121
+ </DispatcherProvider>
122
+ </LocaleProvider>,
123
+ );
124
+
125
+ expect(capturedButtonTestIds).not.toContain("render-list-create");
126
+ expect(capturedButtonTestIds).not.toContain("render-list-empty-create");
127
+ });
128
+ });
@@ -289,10 +289,15 @@ function useNavigateToCreateFor(
289
289
  const nav = useNav();
290
290
  const editScreenId = useMemo(() => {
291
291
  // allowCreate:false = update-only Edit-Screen (Create läuft über einen
292
- // Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel.
292
+ // Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel. singleton:true
293
+ // öffnet ohne entityId den vorhandenen Record (EntityEditSingletonBody)
294
+ // statt eines leeren Create-Forms — zählt aus demselben Grund nicht.
293
295
  const edit = schema.screens.find(
294
296
  (s: ScreenDefinition) =>
295
- s.type === "entityEdit" && s.entity === entityName && s.allowCreate !== false,
297
+ s.type === "entityEdit" &&
298
+ s.entity === entityName &&
299
+ s.allowCreate !== false &&
300
+ s.singleton !== true,
296
301
  );
297
302
  return edit !== undefined ? lastSegment(edit.id) : undefined;
298
303
  }, [schema.screens, entityName]);
@@ -166,6 +166,68 @@ describe("EmbeddedListField — cell change recomputes derived", () => {
166
166
  { product: "p1", unit: "pcs", quantity: 2.5, unitPrice: 923, amount: 2308 },
167
167
  ]);
168
168
  });
169
+
170
+ // Regression: withRecomputedDerived used to read source values from
171
+ // `result` (mutated in-place during the loop), so a chained derived cell
172
+ // (gross depends on net, net depends on raw qty/price) got the FRESH net
173
+ // when "net" happened to iterate before "gross" in embeddedListDerived's
174
+ // key order, but the STALE pre-edit net otherwise — the client preview
175
+ // drifted from the server (withDerivedCells always reads the untouched
176
+ // source row) purely based on object-key order. Fixed by reading from a
177
+ // frozen `source` snapshot, same as the server. Two field configs with
178
+ // "net"/"gross" declared in opposite key order must therefore produce the
179
+ // identical, order-independent result.
180
+ test("chained derived cells (gross depends on net) recompute the same value regardless of the derived-map's key order", () => {
181
+ const cells: EditFieldViewModel["embeddedListCells"] = [
182
+ { field: "qty", label: "Qty", type: "number", required: true },
183
+ { field: "price", label: "Price", type: "number", required: true },
184
+ { field: "tax", label: "Tax", type: "number", required: true },
185
+ { field: "net", label: "Net", type: "number", required: false },
186
+ { field: "gross", label: "Gross", type: "number", required: false },
187
+ ];
188
+ // Pre-edit, already-settled row: net = 2*3 = 6, gross = 6+1 = 7.
189
+ const rows = [{ qty: 2, price: 3, tax: 1, net: 6, gross: 7 }];
190
+ // A fixed reference calculation for "qty changes from 2 to 4": net
191
+ // recomputes from the fresh raw inputs (qty=4, price=3) to 12; gross
192
+ // reads net from the row as it was BEFORE this edit (still 6, same as
193
+ // the server would), giving 6+1=7 — not 12+1=13.
194
+ const expected = { qty: 4, price: 3, tax: 1, net: 12, gross: 7 };
195
+
196
+ let netFirstResult: unknown;
197
+ renderEmbeddedListField(
198
+ {
199
+ ...invoiceLinesField({ value: rows }),
200
+ embeddedListCells: cells,
201
+ embeddedListDerived: {
202
+ net: { op: "multiply", from: ["qty", "price"] },
203
+ gross: { op: "sum", from: ["net", "tax"] },
204
+ },
205
+ },
206
+ (v) => {
207
+ netFirstResult = v;
208
+ },
209
+ );
210
+ captured?.onCellChange(0, "qty", 4);
211
+
212
+ let grossFirstResult: unknown;
213
+ renderEmbeddedListField(
214
+ {
215
+ ...invoiceLinesField({ value: rows }),
216
+ embeddedListCells: cells,
217
+ embeddedListDerived: {
218
+ gross: { op: "sum", from: ["net", "tax"] },
219
+ net: { op: "multiply", from: ["qty", "price"] },
220
+ },
221
+ },
222
+ (v) => {
223
+ grossFirstResult = v;
224
+ },
225
+ );
226
+ captured?.onCellChange(0, "qty", 4);
227
+
228
+ expect(netFirstResult).toEqual([expected]);
229
+ expect(grossFirstResult).toEqual([expected]);
230
+ });
169
231
  });
170
232
 
171
233
  describe("EmbeddedListField — row operations", () => {
@@ -33,10 +33,15 @@ function withRecomputedDerived(
33
33
  cells: readonly EmbeddedListCellViewModel[],
34
34
  ): EmbeddedRow {
35
35
  if (derived === undefined) return row;
36
+ // Read sources from an unchanged snapshot, never from `result` (which this
37
+ // loop mutates) — mirrors withDerivedCells server-side so chained derived
38
+ // cells (e.g. net=qty*price, then gross=sum(net,tax)) can't drift between
39
+ // client preview and server depending on Object.entries(derived) order.
40
+ const source = { ...row };
36
41
  const result: Record<string, unknown> = { ...row };
37
42
  for (const [derivedField, def] of Object.entries(derived)) {
38
43
  const values = def.from.map((src) => {
39
- const v = result[src];
44
+ const v = source[src];
40
45
  return typeof v === "number" ? v : undefined;
41
46
  });
42
47
  const computed = computeDerivedCellValue(def.op, values);
@@ -177,6 +177,12 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
177
177
  * no way to force-disable an arbitrary registered component. Omitting
178
178
  * this prop keeps unchanged behavior. */
179
179
  readonly disabled?: boolean;
180
+ /** Renders the fields without RenderEdit's own action bar (save, cancel,
181
+ * delete, copy-link). For hosts that put those controls into their own
182
+ * chrome — a drawer footer, a wizard shell — and drive the write through
183
+ * `onControlsReady`'s `submit`. Omitting this prop keeps unchanged
184
+ * behavior. */
185
+ readonly hideActions?: boolean;
180
186
  };
181
187
 
182
188
  export type RenderEditChangeState<TValues extends FormValues> = {
@@ -190,6 +196,12 @@ export type RenderEditControls<TValues extends FormValues> = {
190
196
  readonly patch: (partial: Partial<TValues>) => void;
191
197
  readonly validate: () => boolean;
192
198
  readonly getValues: () => TValues;
199
+ /** Runs the same pipeline the built-in save button runs: validation,
200
+ * `customSubmit`/`writeCommand`, extension-section persistence, draft
201
+ * discard, state rebase. Unlike the button it carries no unchanged-form
202
+ * guard — a host showing a pre-filled proposal must be able to accept it
203
+ * untouched. */
204
+ readonly submit: () => Promise<void>;
193
205
  };
194
206
 
195
207
  function toConditionValue<TValues extends FormValues, TCtx>(
@@ -238,6 +250,7 @@ function ExtensionSectionMount({
238
250
  values,
239
251
  patch,
240
252
  validate,
253
+ hidden,
241
254
  }: {
242
255
  readonly section: EditExtensionSectionViewModel;
243
256
  readonly entityName: string;
@@ -246,6 +259,7 @@ function ExtensionSectionMount({
246
259
  readonly values?: Readonly<Record<string, unknown>>;
247
260
  readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
248
261
  readonly validate?: () => boolean;
262
+ readonly hidden?: boolean;
249
263
  }): ReactNode {
250
264
  const { Banner, Section, Text } = usePrimitives();
251
265
  const name = extensionSectionName(section.component);
@@ -256,6 +270,7 @@ function ExtensionSectionMount({
256
270
  key={section.title}
257
271
  title={section.title}
258
272
  testId={`section-extension-${section.title}`}
273
+ hidden={hidden}
259
274
  >
260
275
  <Banner variant="info" testId={`section-extension-placeholder-${section.title}`}>
261
276
  <Text>
@@ -268,7 +283,7 @@ function ExtensionSectionMount({
268
283
  );
269
284
  }
270
285
  return (
271
- <Section title={section.title} testId={`section-extension-${section.title}`}>
286
+ <Section title={section.title} testId={`section-extension-${section.title}`} hidden={hidden}>
272
287
  <Component
273
288
  entityName={entityName}
274
289
  entityId={entityId}
@@ -309,6 +324,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
309
324
  onControlsReady,
310
325
  fields: fieldsFilter,
311
326
  disabled = false,
327
+ hideActions,
312
328
  } = props;
313
329
  const { customSubmit } = props;
314
330
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
@@ -432,6 +448,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
432
448
  // re-subscribes with a "new" onChange → refires.
433
449
  const onChangeRef = useRef(onChange);
434
450
  onChangeRef.current = onChange;
451
+ // `schema` is typically an inline caller prop (`schema={z.object({...})}`)
452
+ // with unstable identity across renders — as an effect dep it would refire
453
+ // on every parent render, not just on a snapshot change, risking a loop if
454
+ // the caller's onChange triggers a parent re-render. Held in a ref like
455
+ // onChangeRef so only a real snapshot mutation retriggers this effect.
456
+ const schemaRef = useRef(schema);
457
+ schemaRef.current = schema;
435
458
  useEffect(() => {
436
459
  const cb = onChangeRef.current;
437
460
  if (cb === undefined) return;
@@ -441,9 +464,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
441
464
  // typing. `valid` can therefore legitimately diverge from what's
442
465
  // currently rendered under the fields (the last *mutating* validate()
443
466
  // call, e.g. from controls.validate() or submit()).
444
- const valid = schema === undefined ? true : schema.safeParse(snapshot.values).success;
467
+ const currentSchema = schemaRef.current;
468
+ const valid =
469
+ currentSchema === undefined ? true : currentSchema.safeParse(snapshot.values).success;
445
470
  cb({ values: snapshot.values, changes: snapshot.changes, dirty: snapshot.isDirty, valid });
446
- }, [snapshot, schema]);
471
+ }, [snapshot]);
447
472
 
448
473
  const onControlsReadyRef = useRef(onControlsReady);
449
474
  onControlsReadyRef.current = onControlsReady;
@@ -463,6 +488,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
463
488
  // is defined — kept fresh where `currentStep` is computed below.
464
489
  const saveDraftRef = useRef(saveDraft);
465
490
  saveDraftRef.current = saveDraft;
491
+ // `handleSubmit` (defined below) is a fresh closure over this render's
492
+ // snapshot/extensionDirty, while the onControlsReady effect fires once per
493
+ // mount — the ref lets controls.submit() always reach the current one.
494
+ const handleSubmitRef = useRef<() => Promise<void>>(async () => {});
466
495
  const currentStepRef = useRef(0);
467
496
  const draftSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
468
497
  useEffect(() => {
@@ -495,6 +524,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
495
524
  patch: patchAndScheduleDraftSave,
496
525
  validate: scopedValidate,
497
526
  getValues: () => controller.getSnapshot().values,
527
+ submit: () => handleSubmitRef.current(),
498
528
  });
499
529
  // controller is mount-lifetime-stable (see useForm's comment on its own
500
530
  // useMemo), same for patchAndScheduleDraftSave/scopedValidate (both
@@ -539,11 +569,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
539
569
  }, [draftEnabled, draftKey, draftId, dispatcher, controller]);
540
570
 
541
571
  // Mount-time fallback (issue #1913) for create-mode when no draftId
542
- // survived in storage (new tab, cleared storage): ask the server for
543
- // this screen's open drafts. Exactly one adopt it silently (same
544
- // effect as if storage had it). Multiple render a simple picker
545
- // (below) and let the user choose. Zero stay null, saveDraft mints a
546
- // fresh draftId on the first step change same as any other fresh create.
572
+ // survived in storage (new tab, cleared storage): ask the server for this
573
+ // screen's open drafts and let the user choose via a picker (below) —
574
+ // even for exactly one candidate. Auto-adopting a lone candidate would
575
+ // silently hand this tab someone else's in-progress draft from a parallel
576
+ // session on the same screen (cross-tab hijack); the picker's "start new"
577
+ // action covers the common case (it really was this tab's own draft) with
578
+ // one extra click. Zero candidates → stay null, saveDraft mints a fresh
579
+ // draftId on the first step change same as any other fresh create.
547
580
  useEffect(() => {
548
581
  // skip: this screen does not persist a draft, this is edit-mode, a
549
582
  // draftId is already known (from storage or an earlier adoption), or
@@ -566,19 +599,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
566
599
  // skip: no open drafts for this screen — stay null, saveDraft mints a
567
600
  // fresh draftId on the first step change same as any other create.
568
601
  if (candidates.length === 0) return;
569
- const [only] = candidates;
570
- if (candidates.length === 1 && only !== undefined) {
571
- const adoptedId = only.draftKey.slice(prefix.length);
572
- draftStorage.setDraftId(screen.id, adoptedId);
573
- setDraftId(adoptedId);
574
- return;
575
- }
576
602
  setDraftCandidates(candidates);
577
603
  })();
578
604
  return () => {
579
605
  cancelled = true;
580
606
  };
581
- }, [draftEnabled, isCreateMode, draftId, dispatcher, screen.id, draftStorage]);
607
+ }, [draftEnabled, isCreateMode, draftId, dispatcher, screen.id]);
582
608
 
583
609
  // User picked one of several open drafts from the mount-time picker
584
610
  // (see draftCandidates below) — adopt it the same way a single
@@ -675,11 +701,20 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
675
701
  // have no field scope here (they validate via the controlled-mode
676
702
  // controls.validate() API), so no call, no clearing of unrelated errors.
677
703
  function handleWizardNext(): void {
704
+ const next = Math.min(currentStep + 1, lastStepIndex);
705
+ // Locked state (#1896): `disabled` means "no input/no write", not "no
706
+ // navigation" — a disabled wizard must still be steppable so Back/Next
707
+ // reach every step. But neither validate() (would block on an empty
708
+ // required field nobody can fill in) nor saveDraft() (would mint a
709
+ // draftId / dispatch a write — exactly what `disabled` forbids) may run.
710
+ if (disabled) {
711
+ setRawStep(next);
712
+ return;
713
+ }
678
714
  const section = filteredSections[currentStep];
679
715
  const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
680
716
  // skip: the current step has field errors — no transition, no draft save.
681
717
  if (fieldNames.length > 0 && !controller.validate(fieldNames)) return;
682
- const next = Math.min(currentStep + 1, lastStepIndex);
683
718
  setRawStep(next);
684
719
  saveDraft(next);
685
720
  }
@@ -702,7 +737,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
702
737
  // skip: create-mode, no step change happened yet — no draftId was ever
703
738
  // minted, so no row exists to discard.
704
739
  if (draftKey === undefined) return;
705
- await dispatcher.write(FORM_DRAFT_DISCARD, { draftKey });
740
+ // Best-effort: a rejected write (e.g. network error) must not propagate
741
+ // out of handleSubmit AFTER the entity write already succeeded — that
742
+ // would break onSubmit/navigation/toast and risk a duplicate submit on
743
+ // retry. An orphaned draft row is swept later by cleanup.job.ts. Local
744
+ // cleanup below still runs on failure so this instance doesn't try to
745
+ // resume a draft it already considers submitted.
746
+ try {
747
+ await dispatcher.write(FORM_DRAFT_DISCARD, { draftKey });
748
+ } catch {
749
+ // best-effort, see comment above.
750
+ }
706
751
  // A successful submit ends this draftId's life — a subsequent create on
707
752
  // the same screen (new mount) must mint its own, not resume this one.
708
753
  if (isCreateMode) {
@@ -712,18 +757,37 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
712
757
  }
713
758
  }
714
759
 
760
+ // Wizard mode: locates the first step whose "fields" section contains one
761
+ // of the given field paths — matched on the path's first segment, since
762
+ // `FieldIssue.path`/`snapshot.errors` keys can be dotted (embedded-list
763
+ // rows, e.g. `tasks.2.title`) while a section's field name is always the
764
+ // plain top-level name. Returns undefined for a root-level issue (path
765
+ // `"(root)"`, see zod-bridge.ts) or one that matches no rendered field —
766
+ // callers must NOT suppress the error banner in that case, there is
767
+ // nowhere to jump the user to.
768
+ function findFirstErroringStep(fieldPaths: readonly string[]): number | undefined {
769
+ const erroredFields = new Set(fieldPaths.map((p) => p.split(".")[0]));
770
+ const idx = filteredSections.findIndex(
771
+ (s) => s.kind === "fields" && s.fields.some((f) => erroredFields.has(f.field)),
772
+ );
773
+ return idx === -1 ? undefined : idx;
774
+ }
775
+
715
776
  async function handleSubmit(): Promise<void> {
716
- // Locked state (#1896): the submit button is visibly disabled, but a
717
- // native form submit (Enter key) reaches this handler regardless of the
718
- // button's disabled attribute — block it here too, not just in the UI.
719
- if (disabled) return;
720
777
  // Enter in the active step triggers the native form submit (Next is
721
778
  // type="submit" for Enter support) — on intermediate steps that means
722
- // "Next", not "Save".
779
+ // "Next", not "Save". Checked BEFORE the `disabled` guard below:
780
+ // `disabled` means "no input/no write", not "no navigation" — a
781
+ // disabled wizard must still be steppable (handleWizardNext itself
782
+ // blocks validate()/saveDraft() while disabled).
723
783
  if (isWizard && !isLastWizardStep) {
724
784
  handleWizardNext();
725
785
  return;
726
786
  }
787
+ // Locked state (#1896): the submit button is visibly disabled, but a
788
+ // native form submit (Enter key) reaches this handler regardless of the
789
+ // button's disabled attribute — block it here too, not just in the UI.
790
+ if (disabled) return;
727
791
  setIsSubmitting(true);
728
792
  setExtensionErrorKey(null);
729
793
  try {
@@ -781,9 +845,36 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
781
845
  // the draft behind after a successful submit.
782
846
  await discardDraft();
783
847
  extensionsPersisted = await persistExtensions();
784
- } else if (!result.validationBlocked) {
848
+ } else if (result.validationBlocked) {
849
+ // Root-level `.refine()`/cross-field issues from controller.validate()
850
+ // — same "jump to the erroring step" treatment as a server field
851
+ // error below, sourced from the freshly-validated snapshot.
852
+ if (isWizard) {
853
+ const step = findFirstErroringStep(Object.keys(controller.getSnapshot().errors));
854
+ if (step !== undefined) setRawStep(step);
855
+ }
856
+ } else {
785
857
  const fieldIssues = result.error.details?.fields ?? [];
786
- setFormError(fieldIssues.length === 0 ? result.error : null);
858
+ // A server field error on a step the wizard isn't currently showing
859
+ // is otherwise invisible — jump to the first step that contains one
860
+ // of the errored fields. Non-wizard forms render every field at
861
+ // once, so the original suppress-when-field-issues-exist rule still
862
+ // applies there (the field itself already shows the error inline).
863
+ if (isWizard) {
864
+ const step = findFirstErroringStep(fieldIssues.map((i) => i.path));
865
+ if (step !== undefined) {
866
+ setRawStep(step);
867
+ setFormError(null);
868
+ } else {
869
+ // No rendered step contains any of the errored fields (a
870
+ // root-level issue, or a field outside every step) — nothing
871
+ // will show this error inline, so the banner must not be
872
+ // suppressed.
873
+ setFormError(result.error);
874
+ }
875
+ } else {
876
+ setFormError(fieldIssues.length === 0 ? result.error : null);
877
+ }
787
878
  }
788
879
  // skip: on entity-success-but-extension-failure the extension-error
789
880
  // banner is showing — don't notify (the caller navigates away on success
@@ -793,6 +884,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
793
884
  setIsSubmitting(false);
794
885
  }
795
886
  }
887
+ handleSubmitRef.current = handleSubmit;
796
888
 
797
889
  // Sticky-top Action-Bar: Delete (links, destructive) + Cancel +
798
890
  // Save. Delete sitzt links abgesetzt damit die Click-Distanz zu
@@ -844,12 +936,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
844
936
  </Button>
845
937
  )}
846
938
  {isWizard && !isLastWizardStep && (
847
- <Button
848
- type="submit"
849
- disabled={disabled}
850
- variant="primary"
851
- testId="render-edit-wizard-next"
852
- >
939
+ <Button type="submit" variant="primary" testId="render-edit-wizard-next">
853
940
  {translate("kumiko.actions.next")}
854
941
  </Button>
855
942
  )}
@@ -895,7 +982,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
895
982
  onSubmit={() => void handleSubmit()}
896
983
  title={formTitle}
897
984
  {...(formSubtitle !== undefined && { subtitle: formSubtitle })}
898
- actions={formActions}
985
+ {...(hideActions !== true && { actions: formActions })}
899
986
  testId="render-edit-form"
900
987
  stickyActions={isWizard}
901
988
  {...(screen.layout.width !== undefined && { width: screen.layout.width })}
@@ -904,17 +991,28 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
904
991
  <Banner
905
992
  variant="info"
906
993
  testId="render-edit-draft-picker"
907
- actions={draftCandidates.map((candidate) => (
994
+ actions={[
995
+ ...draftCandidates.map((candidate) => (
996
+ <Button
997
+ key={candidate.id}
998
+ type="button"
999
+ variant="link"
1000
+ onClick={() => adoptDraft(candidate)}
1001
+ testId={`render-edit-draft-pick-${candidate.id}`}
1002
+ >
1003
+ {formatWhen(candidate.savedAt)}
1004
+ </Button>
1005
+ )),
908
1006
  <Button
909
- key={candidate.id}
1007
+ key="start-new"
910
1008
  type="button"
911
1009
  variant="link"
912
- onClick={() => adoptDraft(candidate)}
913
- testId={`render-edit-draft-pick-${candidate.id}`}
1010
+ onClick={() => setDraftCandidates(null)}
1011
+ testId="render-edit-draft-start-new"
914
1012
  >
915
- {formatWhen(candidate.savedAt)}
916
- </Button>
917
- ))}
1013
+ {translate("kumiko.form.draft.start-new")}
1014
+ </Button>,
1015
+ ]}
918
1016
  >
919
1017
  <Text>{translate("kumiko.form.draft.resume-multiple")}</Text>
920
1018
  </Banner>
@@ -963,68 +1061,72 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
963
1061
  })()}
964
1062
  </>
965
1063
  )}
966
- {(isWizard ? filteredSections.filter((_, i) => i === currentStep) : filteredSections).map(
967
- (section: EditSectionViewModel, sectionIndex: number) => {
968
- if (section.kind === "extension") {
969
- return (
970
- <ExtensionSectionMount
971
- key={section.title}
972
- section={section}
973
- entityName={vm.entityName}
974
- entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
975
- initialValues={extensionInitialValues}
976
- values={snapshot.values}
977
- // @cast-boundary form-values: ExtensionSectionProps is not generic
978
- // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
979
- patch={
980
- patchAndScheduleDraftSave as (
981
- partial: Readonly<Record<string, unknown>>,
982
- ) => void
983
- }
984
- validate={scopedValidate}
985
- />
986
- );
987
- }
988
- if (!section.visible) return null;
989
- // Section-Header unterdrücken wenn er den Form-Titel der
990
- // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
991
- // ActionForms, deren Section-Label = Screen-Titel ist).
992
- const sectionTitle = section.title === formTitle ? undefined : section.title;
993
- // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
994
- const sectionKey = section.title ?? `section-${sectionIndex}`;
1064
+ {filteredSections.map((section: EditSectionViewModel, sectionIndex: number) => {
1065
+ // Wizard steps stay mounted while off-screen (native `hidden`, not
1066
+ // unmounted) so an extension section's submit-registry entry
1067
+ // (useExtensionFormSubmit → registry.remove on unmount) survives
1068
+ // navigating past its step — otherwise Finish only ran the last
1069
+ // mounted step's handler and silently dropped earlier steps' writes.
1070
+ const stepHidden = isWizard && sectionIndex !== currentStep;
1071
+ if (section.kind === "extension") {
995
1072
  return (
996
- <Section
997
- key={sectionKey}
998
- {...(sectionTitle !== undefined && { title: sectionTitle })}
999
- {...(section.description !== undefined && { subtitle: section.description })}
1000
- testId={`section-${sectionKey}`}
1001
- >
1002
- <Grid columns={section.columns}>
1003
- {section.fields.map((field: EditFieldViewModel) => (
1004
- <GridCellForField
1005
- key={field.field}
1006
- field={disabled ? { ...field, readOnly: true } : field}
1007
- columns={section.columns}
1008
- issues={snapshot.errors[field.field]}
1009
- onChange={(v) => {
1010
- (controller.setField as (k: string, v: unknown) => void)(field.field, v);
1011
- }}
1012
- GridCell={GridCell}
1013
- featureName={featureName}
1014
- {...(labelAppendix !== undefined && {
1015
- labelAppendix: labelAppendix(field.field),
1016
- })}
1017
- {...(fieldAppendix !== undefined && {
1018
- fieldAppendix: fieldAppendix(field.field),
1019
- })}
1020
- allIssues={snapshot.errors}
1021
- />
1022
- ))}
1023
- </Grid>
1024
- </Section>
1073
+ <ExtensionSectionMount
1074
+ key={section.title}
1075
+ section={section}
1076
+ entityName={vm.entityName}
1077
+ entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
1078
+ initialValues={extensionInitialValues}
1079
+ values={snapshot.values}
1080
+ // @cast-boundary form-values: ExtensionSectionProps is not generic
1081
+ // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
1082
+ patch={
1083
+ patchAndScheduleDraftSave as (partial: Readonly<Record<string, unknown>>) => void
1084
+ }
1085
+ validate={scopedValidate}
1086
+ hidden={stepHidden}
1087
+ />
1025
1088
  );
1026
- },
1027
- )}
1089
+ }
1090
+ if (!section.visible) return null;
1091
+ // Section-Header unterdrücken wenn er den Form-Titel der
1092
+ // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
1093
+ // ActionForms, deren Section-Label = Screen-Titel ist).
1094
+ const sectionTitle = section.title === formTitle ? undefined : section.title;
1095
+ // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
1096
+ const sectionKey = section.title ?? `section-${sectionIndex}`;
1097
+ return (
1098
+ <Section
1099
+ key={sectionKey}
1100
+ {...(sectionTitle !== undefined && { title: sectionTitle })}
1101
+ {...(section.description !== undefined && { subtitle: section.description })}
1102
+ testId={`section-${sectionKey}`}
1103
+ hidden={stepHidden}
1104
+ >
1105
+ <Grid columns={section.columns}>
1106
+ {section.fields.map((field: EditFieldViewModel) => (
1107
+ <GridCellForField
1108
+ key={field.field}
1109
+ field={disabled ? { ...field, readOnly: true } : field}
1110
+ columns={section.columns}
1111
+ issues={snapshot.errors[field.field]}
1112
+ onChange={(v) => {
1113
+ (controller.setField as (k: string, v: unknown) => void)(field.field, v);
1114
+ }}
1115
+ GridCell={GridCell}
1116
+ featureName={featureName}
1117
+ {...(labelAppendix !== undefined && {
1118
+ labelAppendix: labelAppendix(field.field),
1119
+ })}
1120
+ {...(fieldAppendix !== undefined && {
1121
+ fieldAppendix: fieldAppendix(field.field),
1122
+ })}
1123
+ allIssues={snapshot.errors}
1124
+ />
1125
+ ))}
1126
+ </Grid>
1127
+ </Section>
1128
+ );
1129
+ })}
1028
1130
  {formError !== null && (
1029
1131
  <Banner
1030
1132
  variant="error"
@@ -328,7 +328,6 @@ function renderInput({
328
328
  // already emits `number | undefined`, so no extra coercion is needed
329
329
  // beyond what "number" already does (#1925).
330
330
  case "number":
331
- case "decimal":
332
331
  case "bigInt":
333
332
  return (
334
333
  <Input
@@ -339,6 +338,20 @@ function renderInput({
339
338
  {...(field.icon !== undefined && { icon: field.icon })}
340
339
  />
341
340
  );
341
+ case "decimal":
342
+ // step="any" disables the native stepMismatch constraint — without it
343
+ // <input type="number"> defaults to step=1 and blocks form submit on
344
+ // any fractional value via silent browser-native validation.
345
+ return (
346
+ <Input
347
+ kind="number"
348
+ {...common}
349
+ value={numberValue(field.value)}
350
+ onChange={(v) => onChange(v)}
351
+ step="any"
352
+ {...(field.icon !== undefined && { icon: field.icon })}
353
+ />
354
+ );
342
355
  case "tz":
343
356
  return (
344
357
  <Input
@@ -532,11 +545,13 @@ function numberValue(v: unknown): number | "" {
532
545
  // MINOR_UNIT_SCALE=100 (money.ts), which disagrees with currencyDecimals for
533
546
  // zero-decimal currencies like JPY, so it would double-scale JPY amounts.
534
547
  // Deriving from `amount * 10**currencyDecimals(currency)` keeps this the
535
- // exact inverse of moneyPayload below. A bare number is passed through
536
- // unchangedlegacy/malformed data, already in minor units.
548
+ // exact inverse of moneyPayload below. A bare number (e.g. ConfigEditBody's
549
+ // stored-config coercion) is MAJOR units too every producer in this repo
550
+ // hands rehydrateMoney's `{amount,…}` shape or a raw major-unit number, never
551
+ // pre-scaled minor units.
537
552
  function moneyMinorValue(v: unknown, currency: string): number | "" {
538
553
  if (v === undefined || v === null || v === "") return "";
539
- if (typeof v === "number") return v;
554
+ if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
540
555
  if (typeof v === "object") {
541
556
  const amount = (v as { amount?: unknown }).amount;
542
557
  if (typeof amount === "number") return Math.round(amount * 10 ** currencyDecimals(currency));
@@ -223,6 +223,9 @@ export type InputProps =
223
223
  /** Symbolic icon key (FIELD_ICONS registry, renderer-web) — renders
224
224
  * as a prefix on the input. Unknown key → no icon (no boot-fail). */
225
225
  readonly icon?: string;
226
+ /** `<input step>`. "any" disables the native stepMismatch constraint
227
+ * (needed for decimal fields — integer fields leave this unset). */
228
+ readonly step?: number | "any";
226
229
  }
227
230
  | {
228
231
  readonly kind: "range";
@@ -720,6 +723,11 @@ export type SectionProps = {
720
723
  * Default "default" (normal card border). */
721
724
  readonly variant?: "default" | "destructive";
722
725
  readonly testId?: string;
726
+ /** Keeps the section mounted while visually hiding it (native `hidden`
727
+ * attribute where supported) — used by wizard mode to keep off-screen
728
+ * steps mounted so their state (e.g. an extension section's submit
729
+ * registration) survives step navigation instead of unmounting. */
730
+ readonly hidden?: boolean;
723
731
  };
724
732
 
725
733
  /** Columns-basiertes Layout. Web: CSS grid, Native: Flex-Wrap mit