@cosmicdrift/kumiko-renderer 0.193.1 → 0.195.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.
@@ -25,7 +25,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
25
25
  import type { z } from "zod";
26
26
  import { ExtensionFormRegistryProvider, useExtensionFormHost } from "../app/extension-form-submit";
27
27
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
28
- import { useDispatcher } from "../context/dispatcher-context";
28
+ import { useOptionalDispatcher } from "../context/dispatcher-context";
29
29
  import { useDraftStorage } from "../context/draft-storage-context";
30
30
  import { formatWhen } from "../format-when";
31
31
  import { useForm } from "../hooks/use-form";
@@ -47,6 +47,9 @@ const FORM_DRAFT_GET = "form-draft:query:get";
47
47
  const FORM_DRAFT_SAVE = "form-draft:write:save";
48
48
  const FORM_DRAFT_DISCARD = "form-draft:write:discard";
49
49
  const FORM_DRAFT_LIST = "form-draft:query:list";
50
+ // Mirrors bundled-features/src/form-draft/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
51
+ // — same hardcoding rationale as the QNs above (no dependency on that package).
52
+ const FORM_DRAFT_KEY_MAX_LENGTH = 256;
50
53
 
51
54
  // Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
52
55
  // patch() call (VIN-decode, an extension section) should not save immediately
@@ -77,6 +80,14 @@ function newDraftPrefix(screenId: string): string {
77
80
  return `${screenId}:new:`;
78
81
  }
79
82
 
83
+ // `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
84
+ // without a polyfill — guard like dispatcher-live.ts's generateRequestId.
85
+ function mintDraftId(): string {
86
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
87
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
88
+ return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
89
+ }
90
+
80
91
  // End-to-end renderer für einen entityEdit screen. Rendert aus-
81
92
  // schließlich über Primitives — kein raw HTML. Ein Native-Renderer
82
93
  // der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
@@ -149,7 +160,10 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
149
160
  * reference on every call must not do so unconditionally: `setValues` is
150
161
  * a no-op when the merged value is reference-equal to the current one,
151
162
  * so only a converging patch settles instead of looping. Without this
152
- * prop, existing behavior is unchanged. */
163
+ * prop, existing behavior is unchanged. `onControlsReady` is guaranteed
164
+ * to have already fired by the time the mount-time `onChange` call
165
+ * happens, so a caller patching dependent fields from inside `onChange`
166
+ * never has to guard against `controls` being undefined. */
153
167
  readonly onChange?: (state: RenderEditChangeState<TValues>) => void;
154
168
  /** Controlled mode (issue #1887): called once after mount, hands the
155
169
  * caller `patch`/`validate`/`getValues` bound to this RenderEdit
@@ -175,7 +189,13 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
175
189
  * moot — e.g. Solon's editor pointing at an existing record instead of
176
190
  * creating a new one. Extension sections are out of scope: RenderEdit has
177
191
  * no way to force-disable an arbitrary registered component. Omitting
178
- * this prop keeps unchanged behavior. */
192
+ * this prop keeps unchanged behavior.
193
+ *
194
+ * ponytail: direct-consumer only — kumiko-screen.tsx's RenderEdit call
195
+ * sites pass explicit prop lists without a spread and never forward
196
+ * `disabled`, so a screen-driven app can't set the locked state today.
197
+ * Upgrade path if that's needed: thread a screen-spec flag through to
198
+ * `EntityEditCreateBody`/`EntityEditEditBody`. */
179
199
  readonly disabled?: boolean;
180
200
  /** Renders the fields without RenderEdit's own action bar (save, cancel,
181
201
  * delete, copy-link). For hosts that put those controls into their own
@@ -250,7 +270,6 @@ function ExtensionSectionMount({
250
270
  values,
251
271
  patch,
252
272
  validate,
253
- hidden,
254
273
  }: {
255
274
  readonly section: EditExtensionSectionViewModel;
256
275
  readonly entityName: string;
@@ -259,7 +278,6 @@ function ExtensionSectionMount({
259
278
  readonly values?: Readonly<Record<string, unknown>>;
260
279
  readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
261
280
  readonly validate?: () => boolean;
262
- readonly hidden?: boolean;
263
281
  }): ReactNode {
264
282
  const { Banner, Section, Text } = usePrimitives();
265
283
  const name = extensionSectionName(section.component);
@@ -270,7 +288,6 @@ function ExtensionSectionMount({
270
288
  key={section.title}
271
289
  title={section.title}
272
290
  testId={`section-extension-${section.title}`}
273
- hidden={hidden}
274
291
  >
275
292
  <Banner variant="info" testId={`section-extension-placeholder-${section.title}`}>
276
293
  <Text>
@@ -283,7 +300,7 @@ function ExtensionSectionMount({
283
300
  );
284
301
  }
285
302
  return (
286
- <Section title={section.title} testId={`section-extension-${section.title}`} hidden={hidden}>
303
+ <Section title={section.title} testId={`section-extension-${section.title}`}>
287
304
  <Component
288
305
  entityName={entityName}
289
306
  entityId={entityId}
@@ -334,10 +351,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
334
351
  // ohnehin nur in einem mounted Kumiko-App-Tree läuft.
335
352
  const t = useTranslation();
336
353
  const translate = translateProp ?? t;
337
- const dispatcher = useDispatcher();
354
+ const dispatcher = useOptionalDispatcher();
338
355
 
339
356
  const isWizard = screen.layout.mode === "wizard";
340
- const draftEnabled = isWizard && screen.layout.draft === true;
357
+ const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
341
358
  const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
342
359
  const draftStorage = useDraftStorage();
343
360
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
@@ -378,23 +395,26 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
378
395
  const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
379
396
  const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
380
397
  useExtensionFormHost(setExtensionDirty);
381
- const { Button, Banner, Dialog, Form, Section, Grid, GridCell, Text, Progress, StepBar } =
382
- usePrimitives();
398
+ const {
399
+ Button,
400
+ Banner,
401
+ Dialog,
402
+ Form,
403
+ Section,
404
+ Grid,
405
+ GridCell,
406
+ Text,
407
+ Progress,
408
+ StepBar,
409
+ WizardStepGroup,
410
+ } = usePrimitives();
383
411
 
384
412
  const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
385
413
 
386
- // Scope für validate()/submit() — nur die tatsächlich gerenderten Feldnamen
387
- // aus `fields` (bereits non-extension-only, siehe deriveFormFields), auf den
388
- // `fields`-Filter-Prop eingeschränkt. Muss VOR submitConfig/useForm stehen
389
- // (der Controller bakt submitConfig beim ersten Render dauerhaft ein) und
390
- // kann daher nicht von `vm`/`filteredSections` abgeleitet werden, die erst
391
- // nach dem Controller (aus snapshot.values) existieren — die Feldnamen-Menge
392
- // pro Section ist aber wertunabhängig (nur visible/readOnly/value hängen von
393
- // `values` ab), also liefert diese Ableitung dieselbe Menge wie
394
- // filterEditSections(vm.sections, fieldsFilter) es täte. undefined (= kein
395
- // Filter aktiv) heißt unscoped validate/submit — auf "alle gerenderten
396
- // Felder" scopen würde sonst root-level .refine()-Issues aus der
397
- // unscoped-Validierung stillschweigend wegfiltern.
414
+ // Must be computed before submitConfig/useForm bakes it in (the controller
415
+ // freezes it on first render) — safe because a section's field-name set is
416
+ // value-independent, so it matches filterEditSections(vm.sections, fieldsFilter).
417
+ // undefined = unscoped, since scoping would silently drop root-level .refine() issues.
398
418
  const scopeFieldNames = useMemo(
399
419
  () =>
400
420
  fieldsFilter === undefined ? undefined : fieldsFilter.filter((f) => Object.hasOwn(fields, f)),
@@ -453,23 +473,6 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
453
473
  // on every parent render, not just on a snapshot change, risking a loop if
454
474
  // the caller's onChange triggers a parent re-render. Held in a ref like
455
475
  // onChangeRef so only a real snapshot mutation retriggers this effect.
456
- const schemaRef = useRef(schema);
457
- schemaRef.current = schema;
458
- useEffect(() => {
459
- const cb = onChangeRef.current;
460
- if (cb === undefined) return;
461
- // Dry-run parse against `schema` — NOT controller.validate(). Calling
462
- // validate() here would write field-level errors into snapshot.errors
463
- // on every keystroke, painting error messages while the user is still
464
- // typing. `valid` can therefore legitimately diverge from what's
465
- // currently rendered under the fields (the last *mutating* validate()
466
- // call, e.g. from controls.validate() or submit()).
467
- const currentSchema = schemaRef.current;
468
- const valid =
469
- currentSchema === undefined ? true : currentSchema.safeParse(snapshot.values).success;
470
- cb({ values: snapshot.values, changes: snapshot.changes, dirty: snapshot.isDirty, valid });
471
- }, [snapshot]);
472
-
473
476
  const onControlsReadyRef = useRef(onControlsReady);
474
477
  onControlsReadyRef.current = onControlsReady;
475
478
  const scopeFieldNamesRef = useRef(scopeFieldNames);
@@ -507,16 +510,24 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
507
510
  const patchAndScheduleDraftSave = useCallback(
508
511
  (partial: Partial<TValues>) => {
509
512
  controller.setValues(partial);
510
- if (!draftEnabled) return;
513
+ if (!draftEnabled || dispatcher === undefined) return;
511
514
  if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
512
515
  draftSaveTimerRef.current = setTimeout(() => {
513
516
  draftSaveTimerRef.current = null;
514
517
  saveDraftRef.current(currentStepRef.current);
515
518
  }, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
516
519
  },
517
- [controller, draftEnabled],
520
+ [controller, draftEnabled, dispatcher],
518
521
  );
519
522
 
523
+ // Runs before the onChange effect below (declaration order = React
524
+ // execution order) so that an onChange fired on mount always sees
525
+ // `controls !== undefined` — both effects depend only on mount-stable
526
+ // values (controller/scopedValidate/patchAndScheduleDraftSave), so the
527
+ // swap is otherwise a no-op. See the #1888 VIN-decode test: an initial
528
+ // value that should derive dependent fields on mount needs controls.patch
529
+ // available on the very first onChange call, not just from the second
530
+ // keystroke onward.
520
531
  useEffect(() => {
521
532
  const cb = onControlsReadyRef.current;
522
533
  if (cb === undefined) return;
@@ -532,9 +543,28 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
532
543
  // RenderEdit mount in practice.
533
544
  }, [controller, scopedValidate, patchAndScheduleDraftSave]);
534
545
 
546
+ const schemaRef = useRef(schema);
547
+ schemaRef.current = schema;
548
+ // Controls are guaranteed ready by the time this fires (see the
549
+ // onControlsReady effect above, which is declared first on purpose).
550
+ useEffect(() => {
551
+ const cb = onChangeRef.current;
552
+ if (cb === undefined) return;
553
+ // Dry-run parse against `schema` — NOT controller.validate(). Calling
554
+ // validate() here would write field-level errors into snapshot.errors
555
+ // on every keystroke, painting error messages while the user is still
556
+ // typing. `valid` can therefore legitimately diverge from what's
557
+ // currently rendered under the fields (the last *mutating* validate()
558
+ // call, e.g. from controls.validate() or submit()).
559
+ const currentSchema = schemaRef.current;
560
+ const valid =
561
+ currentSchema === undefined ? true : currentSchema.safeParse(snapshot.values).success;
562
+ cb({ values: snapshot.values, changes: snapshot.changes, dirty: snapshot.isDirty, valid });
563
+ }, [snapshot]);
564
+
535
565
  useEffect(() => {
536
566
  // skip: this screen does not persist a draft.
537
- if (!draftEnabled) return;
567
+ if (!draftEnabled || dispatcher === undefined) return;
538
568
  // skip: create-mode with no draftId yet — nothing to restore, either
539
569
  // the list-fallback effect below finds one or the first step change
540
570
  // mints a fresh one.
@@ -581,7 +611,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
581
611
  // skip: this screen does not persist a draft, this is edit-mode, a
582
612
  // draftId is already known (from storage or an earlier adoption), or
583
613
  // this mount already ran the list lookup once (didListRef).
584
- if (!draftEnabled || !isCreateMode || draftId !== null || didListRef.current) return;
614
+ if (
615
+ !draftEnabled ||
616
+ !isCreateMode ||
617
+ draftId !== null ||
618
+ didListRef.current ||
619
+ dispatcher === undefined
620
+ )
621
+ return;
585
622
  didListRef.current = true;
586
623
  let cancelled = false;
587
624
  void (async () => {
@@ -665,17 +702,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
665
702
  // Step transitions only — never per keystroke. Deliberately not awaited: a
666
703
  // failed draft save must not block the step change.
667
704
  //
668
- // Create-mode, first step change: mints the draftId here (issue #1913)
669
- // rather than at mount, so a form abandoned on step 0 never claims a
670
- // draftId or writes a row at all. `draftKey`/`draftId` state won't
671
- // reflect the mint until the next render, so the just-minted key is
705
+ // Create-mode, first draft save (step change OR a debounced patch() from
706
+ // controlled mode / extension sections, see patchAndScheduleDraftSave
707
+ // above — both call this on step 0 too): mints the draftId here (issue
708
+ // #1913) rather than at mount, so a form nobody ever interacted with
709
+ // never claims a draftId or writes a row. `draftKey`/`draftId` state
710
+ // won't reflect the mint until the next render, so the just-minted key is
672
711
  // computed inline instead of read from the memoized `draftKey`.
673
712
  function saveDraft(stepIndex: number): void {
674
713
  // skip: this screen does not persist a draft.
675
- if (!draftEnabled) return;
714
+ if (!draftEnabled || dispatcher === undefined) return;
676
715
  let key = draftKey;
677
716
  if (isCreateMode && draftId === null) {
678
- const mintedId = crypto.randomUUID();
717
+ const mintedId = mintDraftId();
679
718
  mintedDraftIdRef.current = mintedId;
680
719
  draftStorage.setDraftId(screen.id, mintedId);
681
720
  setDraftId(mintedId);
@@ -689,6 +728,16 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
689
728
  // in practice (mint above always produces one), kept as a type-level
690
729
  // guard against a stale `undefined` key ever reaching the write.
691
730
  if (key === undefined) return;
731
+ // draftKeySchema.max(FORM_DRAFT_KEY_MAX_LENGTH) rejects this server-side —
732
+ // catch it here instead of losing the save silently to the fire-and-forget
733
+ // write below (the user would only notice on resume, with nothing to resume).
734
+ if (key.length > FORM_DRAFT_KEY_MAX_LENGTH) {
735
+ // biome-ignore lint/suspicious/noConsole: no error-surfacing path exists for a fire-and-forget draft save.
736
+ console.warn(
737
+ `RenderEdit: draftKey "${key}" is ${key.length} chars, over the server's ${FORM_DRAFT_KEY_MAX_LENGTH}-char limit — skipping this draft save. Shorten screen.id or entity id.`,
738
+ );
739
+ return;
740
+ }
692
741
  void dispatcher.write(FORM_DRAFT_SAVE, {
693
742
  draftKey: key,
694
743
  values: controller.getSnapshot().values,
@@ -727,7 +776,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
727
776
 
728
777
  async function discardDraft(): Promise<void> {
729
778
  // skip: this screen does not persist a draft.
730
- if (!draftEnabled) return;
779
+ if (!draftEnabled || dispatcher === undefined) return;
731
780
  // A pending debounced patch-save must not fire after discard — it would
732
781
  // resurrect the draft it just deleted.
733
782
  if (draftSaveTimerRef.current !== null) {
@@ -791,8 +840,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
791
840
  setIsSubmitting(true);
792
841
  setExtensionErrorKey(null);
793
842
  try {
794
- // Extension-only: nur eine Section ist dirty, das Haupt-Form unverändert.
795
- // Kein Entity-Write (würde einen leeren changes-Payload schreiben) — nur
843
+ // Extension-only: only a section is dirty, the main form is unchanged.
844
+ // No entity write (would send an empty changes payload) — only
796
845
  // die Section-Handler laufen lassen.
797
846
  if (snapshot.isUnchanged && extensionDirty) {
798
847
  // Same discard as the main path: an extension-only save is still a
@@ -802,27 +851,27 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
802
851
  }
803
852
  let result: SubmitResult<unknown>;
804
853
  if (customSubmit !== undefined) {
805
- // customSubmit-Pfad (z.B. configEdit, das pro Field einen
806
- // separaten Write feuert). Erst client-side Validation, dann
854
+ // customSubmit path (e.g. configEdit, which fires a separate write
855
+ // per field). Client-side validation first, then
807
856
  // an den Caller; on-success rebased der Form-State explizit
808
- // weil controller.submit() das normalerweise selbst macht und
809
- // ohne customSubmit's Hilfe weiß der Controller nichts vom
857
+ // because controller.submit() normally does this itself and
858
+ // without customSubmit's help the controller knows nothing about
810
859
  // erfolgreichen Submit (isUnchanged blieb sonst false).
811
860
  //
812
861
  // WICHTIG: snapshot direkt vom Controller holen statt aus
813
- // React-State. Bei rapid fill→click kann React-Batching die
814
- // Input-State-Updates noch nicht commited haben, wenn der
815
- // submit-Click fire'd. handleSubmit's Closure würde dann mit
862
+ // React state. On rapid fill→click, React batching may not have
863
+ // committed input state updates yet when the submit click fires.
864
+ // handleSubmit's closure would then run with
816
865
  // stale snapshot.changes={} laufen, customSubmit fired keine
817
866
  // Writes, returnt success, Form rebase → User glaubt "saved"
818
867
  // aber gar nichts ist passiert. controller.getSnapshot() ist
819
868
  // immer aktuell — der Controller ist die Source-of-Truth, die
820
- // React-State ist nur ein Mirror für's Rendering.
869
+ // React state is only a mirror for rendering.
821
870
  const valid = controller.validate(scopeFieldNames);
822
871
  if (!valid) {
823
- // Field-Order matters: validationBlocked-true ist eine eigene
824
- // Variante in der SubmitResult-Union (NICHT mit data/error
825
- // gemixt), TS narrowt das nur ohne den Discriminator-Fight.
872
+ // Field order matters: validationBlocked:true is its own
873
+ // variant in the SubmitResult union (NOT mixed with data/error);
874
+ // TS only narrows cleanly without a discriminator fight.
826
875
  const blocked: SubmitResult<unknown> = {
827
876
  validationBlocked: true,
828
877
  isSuccess: false,
@@ -835,8 +884,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
835
884
  } else {
836
885
  result = await controller.submit();
837
886
  }
838
- // Form-level Errors (ohne field-level details) landen im Banner.
839
- // Field-Errors fließen über snapshot.errors in die einzelnen Fields.
887
+ // Form-level errors (without field-level details) go to the banner.
888
+ // Field errors flow via snapshot.errors into the individual fields.
840
889
  let extensionsPersisted = true;
841
890
  if (result.isSuccess) {
842
891
  setFormError(null);
@@ -888,7 +937,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
888
937
 
889
938
  // Sticky-top Action-Bar: Delete (links, destructive) + Cancel +
890
939
  // Save. Delete sitzt links abgesetzt damit die Click-Distanz zu
891
- // Save groß ist; rot + Confirm-Dialog sind ausreichend Schutz
940
+ // Save is large; red styling + confirm dialog are enough protection
892
941
  // gegen Fehlklicks. Save bleibt rechts (primary affordance).
893
942
  const formActions = (
894
943
  <>
@@ -957,7 +1006,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
957
1006
  // Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
958
1007
  // screen:<id>.<mode>.title / .<mode>.subtitle
959
1008
  // Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
960
- // title fällt zuletzt auf screenId, subtitle auf undefined (kein Untertitel).
1009
+ // title falls back to screenId; subtitle to undefined (no subtitle).
961
1010
  const isCreate = (() => {
962
1011
  const id = resolveExtensionEntityId(entityIdProp, vm.id);
963
1012
  return id == null || id === "";
@@ -1049,9 +1098,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1049
1098
  }
1050
1099
  return (
1051
1100
  <StepBar
1052
- steps={filteredSections.map(
1053
- (section, sectionIndex) => section.title ?? String(sectionIndex + 1),
1054
- )}
1101
+ steps={filteredSections.map((section) => section.title ?? "")}
1055
1102
  currentIndex={currentStep}
1056
1103
  compactLabel={compactLabel}
1057
1104
  testId="render-edit-wizard-steps"
@@ -1068,8 +1115,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1068
1115
  // navigating past its step — otherwise Finish only ran the last
1069
1116
  // mounted step's handler and silently dropped earlier steps' writes.
1070
1117
  const stepHidden = isWizard && sectionIndex !== currentStep;
1118
+ // Off-screen wizard steps stay mounted (see comment above) but must
1119
+ // not participate in native constraint validation, or the Next
1120
+ // button's `type="submit"` triggers the browser's full-form check
1121
+ // — including required fields on unvisited steps, which are not
1122
+ // focusable while hidden, so the wizard silently stops navigating
1123
+ // (only a console warning, no visible error). A plain `hidden`
1124
+ // attribute does NOT bar descendants from constraint validation on
1125
+ // web — see WizardStepGroupProps for what implementations must
1126
+ // guarantee.
1071
1127
  if (section.kind === "extension") {
1072
- return (
1128
+ const mount = (
1073
1129
  <ExtensionSectionMount
1074
1130
  key={section.title}
1075
1131
  section={section}
@@ -1083,9 +1139,20 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1083
1139
  patchAndScheduleDraftSave as (partial: Readonly<Record<string, unknown>>) => void
1084
1140
  }
1085
1141
  validate={scopedValidate}
1086
- hidden={stepHidden}
1087
1142
  />
1088
1143
  );
1144
+ if (!isWizard) return mount;
1145
+ if (WizardStepGroup === undefined) {
1146
+ // Both silent fallbacks are unsafe here — render-visible-all-steps or unmount-drops-registry.
1147
+ throw new Error(
1148
+ "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1149
+ );
1150
+ }
1151
+ return (
1152
+ <WizardStepGroup key={section.title} hidden={stepHidden}>
1153
+ {mount}
1154
+ </WizardStepGroup>
1155
+ );
1089
1156
  }
1090
1157
  if (!section.visible) return null;
1091
1158
  // Section-Header unterdrücken wenn er den Form-Titel der
@@ -1094,13 +1161,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1094
1161
  const sectionTitle = section.title === formTitle ? undefined : section.title;
1095
1162
  // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
1096
1163
  const sectionKey = section.title ?? `section-${sectionIndex}`;
1097
- return (
1164
+ const sectionEl = (
1098
1165
  <Section
1099
1166
  key={sectionKey}
1100
1167
  {...(sectionTitle !== undefined && { title: sectionTitle })}
1101
1168
  {...(section.description !== undefined && { subtitle: section.description })}
1102
1169
  testId={`section-${sectionKey}`}
1103
- hidden={stepHidden}
1104
1170
  >
1105
1171
  <Grid columns={section.columns}>
1106
1172
  {section.fields.map((field: EditFieldViewModel) => (
@@ -1126,6 +1192,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1126
1192
  </Grid>
1127
1193
  </Section>
1128
1194
  );
1195
+ if (!isWizard) return sectionEl;
1196
+ if (WizardStepGroup === undefined) {
1197
+ throw new Error(
1198
+ "RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
1199
+ );
1200
+ }
1201
+ return (
1202
+ <WizardStepGroup key={sectionKey} hidden={stepHidden}>
1203
+ {sectionEl}
1204
+ </WizardStepGroup>
1205
+ );
1129
1206
  })}
1130
1207
  {formError !== null && (
1131
1208
  <Banner
@@ -23,6 +23,12 @@ import { ReferenceCreateDialog } from "./reference-create-dialog";
23
23
  // Der field.type → Input-kind Mapping bleibt hier, weil es
24
24
  // Domain-Logik ist (EntityDefinition-Feldtyp) und nicht Darstellung.
25
25
 
26
+ // No `hideLabel` prop here (fw#1870/#1871#3, deliberately out of scope):
27
+ // RenderField is driven entirely by EditFieldViewModel/EntityEditScreenDefinition,
28
+ // which have no per-field hideLabel slot — wiring it through would need a
29
+ // schema change, not just a prop. Declarative grid/table screens stay on
30
+ // visible labels until that schema work happens; imperative *Field widgets
31
+ // (form-fields.tsx, AiTextField) already support it.
26
32
  export type RenderFieldProps = {
27
33
  readonly field: EditFieldViewModel;
28
34
  readonly issues?: readonly FieldIssue[];
@@ -53,7 +59,7 @@ export function RenderField({
53
59
  fieldAppendix,
54
60
  allIssues,
55
61
  }: RenderFieldProps): ReactNode {
56
- const { Field, Input, Banner } = usePrimitives();
62
+ const { Field, Input, Banner, Text } = usePrimitives();
57
63
  // App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
58
64
  // navigator.language (Browser-Sprache) zurück statt der gewählten
59
65
  // App-Sprache. BEWUSSTE API-Verschärfung (seit 0.38): RenderField ist
@@ -88,7 +94,7 @@ export function RenderField({
88
94
  featureName={featureName ?? ""}
89
95
  />
90
96
  ) : (
91
- renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, t })
97
+ renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, t })
92
98
  );
93
99
 
94
100
  return (
@@ -303,6 +309,7 @@ function renderInput({
303
309
  Input,
304
310
  appLocale,
305
311
  Banner,
312
+ Text,
306
313
  t,
307
314
  }: {
308
315
  readonly field: EditFieldViewModel;
@@ -312,6 +319,7 @@ function renderInput({
312
319
  readonly Input: ReturnType<typeof usePrimitives>["Input"];
313
320
  readonly appLocale: string;
314
321
  readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
322
+ readonly Text: ReturnType<typeof usePrimitives>["Text"];
315
323
  readonly t: ReturnType<typeof useTranslation>;
316
324
  }): ReactNode {
317
325
  const common = {
@@ -476,6 +484,7 @@ function renderInput({
476
484
  {...(field.entityType !== undefined && { entityType: field.entityType })}
477
485
  {...(field.fieldName !== undefined && { fieldName: field.fieldName })}
478
486
  {...(field.imageVariant !== undefined && { imageVariant: field.imageVariant })}
487
+ {...(field.capture !== undefined && { capture: field.capture })}
479
488
  />
480
489
  );
481
490
  }
@@ -492,12 +501,15 @@ function renderInput({
492
501
  case "embedded":
493
502
  case "jsonb":
494
503
  case "files":
495
- case "images":
504
+ case "images": {
505
+ const hasValue = field.value !== undefined && field.value !== null && field.value !== "";
496
506
  return (
497
507
  <Banner id={id} variant="info">
498
508
  {t("kumiko.field.unsupported")}
509
+ {hasValue && <Text variant="code">{JSON.stringify(field.value)}</Text>}
499
510
  </Banner>
500
511
  );
512
+ }
501
513
  default: {
502
514
  // text + unknown scalar type → text input. If TextFieldDef.multiline
503
515
  // is set (the view-model carries it), the renderer switches to
@@ -30,9 +30,6 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
30
30
  "kumiko.wizard.step": "Schritt {current} von {total}",
31
31
  "kumiko.wizard.step-with-title": "Schritt {current} von {total} · {title}",
32
32
 
33
- // StepBar widget (renderer-web) — screen-reader text for completed steps whose number is visually replaced by a checkmark.
34
- "kumiko.widget.stepBar.done": "Erledigt",
35
-
36
33
  // Version — Update-Awareness-Banner (UpdateChecker).
37
34
  "kumiko.version.update-available": "Eine neue Version ist verfügbar.",
38
35
 
@@ -57,6 +54,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
57
54
  "kumiko.field.embedded-list.move-down": "Nach unten verschieben",
58
55
  "kumiko.field.embedded-list.empty": "Noch keine Zeilen.",
59
56
  "kumiko.field.embedded-list.empty-cta": "Erste Zeile hinzufügen",
57
+ "kumiko.field.embedded-list.paste-rows-truncated":
58
+ "{count} eingefügte Zeile(n) wurden verworfen (maximale Zeilenzahl erreicht).",
59
+ "kumiko.field.embedded-list.paste-cells-unmatched":
60
+ "{count} Zelle(n) hatten keine passende Option und wurden nicht geändert.",
60
61
 
61
62
  // List — DataTable Toolbar, Empty-State, Search.
62
63
  "kumiko.list.search-placeholder": "Suchen…",
@@ -83,12 +84,20 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
83
84
  "kumiko.widget.upload.uploading": "Wird hochgeladen…",
84
85
  "kumiko.widget.upload.done": "Hochgeladen",
85
86
  "kumiko.widget.upload.error": "Fehlgeschlagen",
87
+ "kumiko.widget.upload.rejected-type": "Dateityp nicht erlaubt",
88
+
89
+ // Widgets — StepBar screen-reader text for completed steps whose number is visually replaced by a checkmark.
90
+ "kumiko.widget.step-bar.done": "Erledigt",
86
91
 
87
92
  // Nav — Sidebar Tree (Toggle-aria-Labels).
88
93
  "kumiko.nav.expand": "Aufklappen",
89
94
  "kumiko.nav.collapse": "Zuklappen",
90
95
  "kumiko.nav.search": "Navigation durchsuchen…",
91
96
 
97
+ // Workspace — Switcher-Trigger aria-Label (renderer-web).
98
+ "kumiko.workspace.switch": "Workspace wechseln",
99
+ "kumiko.workspace.select": "Workspace wählen",
100
+
92
101
  // Dialog — Confirm-Buttons + Close-aria-Label.
93
102
  "kumiko.dialog.confirm": "Bestätigen",
94
103
  "kumiko.dialog.cancel": "Abbrechen",
@@ -211,8 +220,6 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
211
220
  "kumiko.wizard.step": "Step {current} of {total}",
212
221
  "kumiko.wizard.step-with-title": "Step {current} of {total} · {title}",
213
222
 
214
- "kumiko.widget.stepBar.done": "Done",
215
-
216
223
  "kumiko.version.update-available": "A new version is available.",
217
224
 
218
225
  "kumiko.toast.learn-more": "Learn more",
@@ -234,6 +241,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
234
241
  "kumiko.field.embedded-list.move-down": "Move down",
235
242
  "kumiko.field.embedded-list.empty": "No rows yet.",
236
243
  "kumiko.field.embedded-list.empty-cta": "Add first row",
244
+ "kumiko.field.embedded-list.paste-rows-truncated":
245
+ "{count} pasted row(s) were dropped (max row count reached).",
246
+ "kumiko.field.embedded-list.paste-cells-unmatched":
247
+ "{count} cell(s) had no matching option and were left unchanged.",
237
248
 
238
249
  "kumiko.list.search-placeholder": "Search…",
239
250
  "kumiko.list.empty.title": "No entries yet.",
@@ -254,11 +265,18 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
254
265
  "kumiko.widget.upload.uploading": "Uploading…",
255
266
  "kumiko.widget.upload.done": "Uploaded",
256
267
  "kumiko.widget.upload.error": "Failed",
268
+ "kumiko.widget.upload.rejected-type": "File type not allowed",
269
+
270
+ "kumiko.widget.step-bar.done": "Done",
257
271
 
258
272
  "kumiko.nav.expand": "Expand",
259
273
  "kumiko.nav.collapse": "Collapse",
260
274
  "kumiko.nav.search": "Search navigation…",
261
275
 
276
+ "kumiko.workspace.switch": "Switch workspace",
277
+ // Workspace-Switcher fallback label when activeId points at no visible workspace.
278
+ "kumiko.workspace.select": "Select workspace",
279
+
262
280
  "kumiko.dialog.confirm": "Confirm",
263
281
  "kumiko.dialog.cancel": "Cancel",
264
282
  "kumiko.dialog.close": "Close",
package/src/index.ts CHANGED
@@ -180,6 +180,7 @@ export type {
180
180
  SectionProps,
181
181
  StepBarProps,
182
182
  TextProps,
183
+ WizardStepGroupProps,
183
184
  } from "./primitives";
184
185
  export { PrimitivesProvider, usePrimitives } from "./primitives";
185
186
  export { sortByAccessor } from "./sort-by-accessor";