@aiaiai-pt/design-system 0.44.1 → 0.46.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.
@@ -6,9 +6,15 @@
6
6
  import Alert from "./Alert.svelte";
7
7
  import CodeBlock from "./CodeBlock.svelte";
8
8
  import Combobox from "./Combobox.svelte";
9
+ import DatePicker from "./DatePicker.svelte";
9
10
  import DateTimePicker from "./DateTimePicker.svelte";
10
11
  import Input from "./Input.svelte";
12
+ import JsonEditor from "./JsonEditor.svelte";
13
+ import Textarea from "./Textarea.svelte";
14
+ import Toggle from "./Toggle.svelte";
11
15
  import MapPicker from "./MapPicker.svelte";
16
+ import MoneyInput from "./MoneyInput.svelte";
17
+ import MultiSelectCombobox from "./MultiSelectCombobox.svelte";
12
18
  import FileUpload from "./FileUpload.svelte";
13
19
  import FileUploadItem from "./FileUploadItem.svelte";
14
20
  import Select from "./Select.svelte";
@@ -16,6 +22,8 @@
16
22
  import type { Snippet } from "svelte";
17
23
  import {
18
24
  buildActionPayload,
25
+ isPayloadIncluded,
26
+ isReadonlyParam,
19
27
  placementConsequenceRows as buildPlacementConsequenceRows,
20
28
  type RendererMode,
21
29
  } from "./action-form-renderer-payload";
@@ -24,7 +32,31 @@
24
32
  // `target_config.layout_key` and validated through `resolveLayout`,
25
33
  // so an operator-supplied string never reaches the DOM verbatim
26
34
  // (RH#11 / R-SEC-07 / TH-08).
27
- import { resolveLayout } from "./action-form-renderer-layouts";
35
+ import { resolveLayout, type LayoutSection } from "./action-form-renderer-layouts";
36
+ // #634 S4 — live section show/hide from the contract's `visible_when`
37
+ // predicate (pure client evaluation over the value bag; the server owns
38
+ // enforcement).
39
+ import { sectionVisible } from "./action-form-visibility";
40
+ // #34 (atelier#669 V1, D6) — cardinality-aware relationship picking over the
41
+ // two rel param shapes (form-surface.v1 `relationship` summaries and
42
+ // derived-action `object_reference` + `multiple` params).
43
+ import {
44
+ isMultiRelationship,
45
+ isRelationshipParam,
46
+ normalizeRelIds,
47
+ relationshipTypeCode,
48
+ } from "./action-form-renderer-relationships";
49
+ // #36/#37 — widget-first dispatch (field summaries carry render intent in
50
+ // `widget`; derived-action params in `ui_schema.widget`; legacy params fall
51
+ // back to their `type`) + the date-only wire serialization.
52
+ import {
53
+ dateOnlyToDate,
54
+ dateToDateOnly,
55
+ geoJsonPointToLonLat,
56
+ lonLatToGeoJsonPoint,
57
+ storedFileDescriptor,
58
+ widgetKind,
59
+ } from "./action-form-renderer-widgets";
28
60
 
29
61
  /**
30
62
  * The renderer treats every parameter/action/placement as a loose record
@@ -42,7 +74,15 @@
42
74
  placement?: Entity | null;
43
75
  target?: Record<string, unknown> | null;
44
76
  source?: Record<string, unknown> | null;
45
- sections?: Array<{ key?: string; label?: string; parameters?: Entity[] }>;
77
+ sections?: Array<{
78
+ key?: string;
79
+ label?: string;
80
+ order?: number | null;
81
+ columns?: number | null;
82
+ /** Read defensively (hosts carry concrete precondition types). */
83
+ visible_when?: unknown;
84
+ parameters?: Entity[];
85
+ }>;
46
86
  parameters?: Entity[];
47
87
  criteria?: Entity[];
48
88
  };
@@ -82,6 +122,20 @@
82
122
  query: string,
83
123
  filter?: Record<string, string>,
84
124
  ) => Promise<SelectOption[]>;
125
+ /** Edit-time label hydration for relationship params (#34): receives the
126
+ * target entity type code and the currently-linked ids, returns
127
+ * `{value, label}` items so the picker renders labels, never raw ids.
128
+ * Same philosophy as searchEntities — the CONSUMER owns transport/auth;
129
+ * omitted → the picker falls back to showing the ids. Best-effort: a
130
+ * failed hydration never blocks the form. */
131
+ hydrateEntities?: (
132
+ typeCode: string,
133
+ ids: string[],
134
+ ) => Promise<SelectOption[]>;
135
+ /** #41 — per-field server-error binding: `{param_key: message}` rendered
136
+ * as each field's inline error (the V2a adapter re-binds proxy 4xx field
137
+ * errors here). Display-only — the HOST owns when errors set and clear. */
138
+ fieldErrors?: Record<string, string>;
85
139
  /** Environment-specific captcha (e.g. Turnstile), rendered above the button
86
140
  * in submit modes. */
87
141
  captcha?: Snippet;
@@ -126,6 +180,8 @@
126
180
  onApply = undefined,
127
181
  uploadFile = undefined,
128
182
  searchEntities = undefined,
183
+ hydrateEntities = undefined,
184
+ fieldErrors = undefined,
129
185
  captcha = undefined,
130
186
  submitLabel = "Submit",
131
187
  boundary = undefined,
@@ -193,12 +249,21 @@
193
249
  );
194
250
  const schemaSections = $derived(schemaSectionsFromContract());
195
251
  const sections = $derived(schemaSections ?? groupIntoSections(visibleParameters));
252
+ // #634 S4 — LIVE conditional sections: a section whose `visible_when`
253
+ // predicate fails against the current value bag is hidden (and re-appears
254
+ // the moment the driving value changes). Sections without a predicate —
255
+ // including every legacy fold section — always pass, so this is inert
256
+ // until a sheet declares `visible_when`. The value bag still spans ALL
257
+ // parameters (hidden sections keep their answers).
258
+ const liveSections = $derived(
259
+ sections.filter((section) => sectionVisible(section, values)),
260
+ );
196
261
  // Wizard pagination (#105 P6): when a section is active, render only it. The
197
262
  // value bag is unaffected — every parameter is still seeded and submitted.
198
263
  const displaySections = $derived(
199
264
  activeSectionKey == null
200
- ? sections
201
- : sections.filter((section) => section.name === activeSectionKey),
265
+ ? liveSections
266
+ : liveSections.filter((section) => section.name === activeSectionKey),
202
267
  );
203
268
  const payload = $derived(buildPayload());
204
269
  const payloadJson = $derived(JSON.stringify(payload, null, 2));
@@ -214,16 +279,25 @@
214
279
  // parameter must have a non-empty value. Server-side submission criteria are
215
280
  // enforced by the BFF on submit and surfaced via `submitError`.
216
281
  function isEmpty(value: unknown): boolean {
282
+ // #34 — an empty ID list (required m2m with zero picks) must block submit.
283
+ if (Array.isArray(value)) return value.length === 0;
217
284
  return value === undefined || value === null || value === "";
218
285
  }
286
+ // #634 S4 — the gate counts only parameters whose section is LIVE-visible:
287
+ // a required field inside a `visible_when`-hidden section must not block
288
+ // submit (it is unreachable). In the legacy fold path this flattens back
289
+ // to exactly `visibleParameters`.
290
+ const gateParameters = $derived(liveSections.flatMap((section) => section.items));
219
291
  const canSubmit = $derived(
220
- visibleParameters
221
- .filter((parameter) => parameter.required)
222
- .every((parameter) =>
223
- String(parameter.type ?? "") === "file"
224
- ? (fileUploads[parameterKey(parameter)] ?? []).length > 0
225
- : !isEmpty(values[parameterKey(parameter)]),
226
- ),
292
+ // #38 — any invalid json draft blocks submit, required or not.
293
+ !Object.values(jsonInvalid).some((message) => message !== null) &&
294
+ gateParameters
295
+ .filter((parameter) => parameter.required)
296
+ .every((parameter) =>
297
+ String(parameter.type ?? "") === "file"
298
+ ? (fileUploads[parameterKey(parameter)] ?? []).length > 0
299
+ : !isEmpty(values[parameterKey(parameter)]),
300
+ ),
227
301
  );
228
302
 
229
303
  async function handleSubmit(): Promise<void> {
@@ -306,18 +380,34 @@
306
380
  return [...byName.entries()].map(([name, sectionItems]) => ({ name, items: sectionItems }));
307
381
  }
308
382
 
309
- function schemaSectionsFromContract(): Array<{ name: string; items: Entity[] }> | null {
383
+ function schemaSectionsFromContract(): LayoutSection[] | null {
310
384
  if (!Array.isArray(schema?.sections)) return null;
311
385
  const next = schema.sections
312
- .map((section) => {
386
+ .map((section, index) => {
313
387
  const items = Array.isArray(section.parameters)
314
388
  ? section.parameters
315
389
  .filter((parameter) => parameter.is_active !== false && isVisible(parameter))
316
390
  .sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
317
391
  : [];
318
- return { name: String(section.label ?? section.key ?? "Details"), items };
392
+ // #634 S3/S4 carry the contract's presentation keys through to the
393
+ // layouts: stable `key`, declared `columns`, the `visible_when`
394
+ // predicate, and `order` (the server emits sections pre-sorted; the
395
+ // sort below is a defensive mirror, stable via declaration index).
396
+ return {
397
+ name: String(section.label ?? section.key ?? "Details"),
398
+ key: section.key ? String(section.key) : undefined,
399
+ columns: Number(section.columns ?? 1),
400
+ visibleWhen:
401
+ section.visible_when && typeof section.visible_when === "object"
402
+ ? (section.visible_when as Record<string, unknown>)
403
+ : null,
404
+ order: Number(section.order ?? 0),
405
+ index,
406
+ items,
407
+ };
319
408
  })
320
- .filter((section) => section.items.length);
409
+ .filter((section) => section.items.length)
410
+ .sort((a, b) => a.order - b.order || a.index - b.index);
321
411
  return next.length ? next : null;
322
412
  }
323
413
 
@@ -346,6 +436,11 @@
346
436
  }
347
437
 
348
438
  function initialValue(parameter: Entity): unknown {
439
+ // #34 — a multi-value rel param holds an ID LIST in the bag (the m2m wire
440
+ // shape), normalized even when the default arrives as a scalar.
441
+ if (isRelationshipParam(parameter) && isMultiRelationship(parameter)) {
442
+ return normalizeRelIds(parameter.default_value);
443
+ }
349
444
  if (parameter.default_value !== null && parameter.default_value !== undefined) {
350
445
  return parameter.default_value;
351
446
  }
@@ -353,6 +448,12 @@
353
448
  if (type === "bool" || type === "boolean") return false;
354
449
  if (type === "number" || type === "integer") return "";
355
450
  if (type === "enum" || type === "select") return enumOptions(parameter)[0]?.value ?? "";
451
+ // #34 — an unset single `relationship` summary is null on the wire
452
+ // (string|null contract). Legacy `object_reference` keeps its "" seed.
453
+ if (type === "relationship") return null;
454
+ // #38 — an unset json field is null (empty editor), never the ""
455
+ // string (which would render as a literal '""' draft).
456
+ if (type === "json" || widgetKind(parameter) === "json") return null;
356
457
  return "";
357
458
  }
358
459
 
@@ -365,6 +466,10 @@
365
466
  for (const parameter of orderedParameters) {
366
467
  const key = parameterKey(parameter);
367
468
  if (!key) continue;
469
+ // #41 / D3 — payload include|exclude: excluded params render but never
470
+ // reach the wire (readonly defaults OUT on the form-surface.v1 lane;
471
+ // `payload: "include"` declares prefill-and-send).
472
+ if (!isPayloadIncluded(parameter)) continue;
368
473
  bag[key] = values[key] ?? initialValue(parameter);
369
474
  }
370
475
  return bag;
@@ -392,13 +497,17 @@
392
497
  : {};
393
498
  }
394
499
 
395
- async function handleFiles(key: string, files: File[]) {
500
+ // #40 — `replace` (a stored current exists on the edit form): SINGLE slot,
501
+ // a new upload supersedes the pending one; the stored file itself is only
502
+ // superseded host-side when the new key rides the payload. Default
503
+ // (append) keeps the legacy up-to-3 behavior byte-identical.
504
+ async function handleFiles(key: string, files: File[], replace = false) {
396
505
  if (!uploadFile) return;
397
506
  fileError = { ...fileError, [key]: "" };
398
- const existing = fileUploads[key] ?? [];
399
- const room = FILE_MAX_COUNT - existing.length;
507
+ const existing = replace ? [] : (fileUploads[key] ?? []);
508
+ const room = replace ? 1 : FILE_MAX_COUNT - existing.length;
400
509
  const batch = files.slice(0, room);
401
- if (files.length > room) {
510
+ if (!replace && files.length > room) {
402
511
  fileError = { ...fileError, [key]: `Up to ${FILE_MAX_COUNT} files` };
403
512
  }
404
513
  if (batch.length === 0) return;
@@ -409,7 +518,9 @@
409
518
  if ("key" in result) {
410
519
  fileUploads = {
411
520
  ...fileUploads,
412
- [key]: [...(fileUploads[key] ?? []), { key: result.key, name: file.name }],
521
+ [key]: replace
522
+ ? [{ key: result.key, name: file.name }]
523
+ : [...(fileUploads[key] ?? []), { key: result.key, name: file.name }],
413
524
  };
414
525
  } else {
415
526
  fileError = { ...fileError, [key]: result.error };
@@ -441,8 +552,10 @@
441
552
  const gen = (referenceSearchGen[key] = (referenceSearchGen[key] ?? 0) + 1);
442
553
  referenceLoading = { ...referenceLoading, [key]: true };
443
554
  try {
555
+ // #34 — the type code comes from the relationship meta when present
556
+ // (form-surface lane), else the param's own object_type (action lane).
444
557
  const items = await searchEntities(
445
- String(parameter.object_type ?? ""),
558
+ relationshipTypeCode(parameter),
446
559
  query,
447
560
  objectFilter(parameter),
448
561
  );
@@ -456,6 +569,96 @@
456
569
  }
457
570
  }
458
571
 
572
+ // #34 — relationship label bookkeeping. `relLabels` maps, per param key, a
573
+ // linked entity id → its display label, fed by (a) edit-time hydration
574
+ // through the `hydrateEntities` seam and (b) pick-time capture from the
575
+ // search results — so a chip / selected value never degrades to a raw id
576
+ // when a later search replaces the Combobox items. (The m2m search-to-add
577
+ // draft is MultiSelectCombobox-internal — the renderer never sees it.)
578
+ let relLabels = $state<Record<string, Record<string, string>>>({});
579
+ const relHydrateRequested: Record<string, Set<string>> = {};
580
+
581
+ // #38 — per-param json parse invalidity (JsonEditor oninvalid seam). A
582
+ // non-null message anywhere blocks submit: invalid text must never reach
583
+ // the wire OR be silently dropped by submitting around it.
584
+ let jsonInvalid = $state<Record<string, string | null>>({});
585
+
586
+ $effect(() => {
587
+ if (!hydrateEntities) return;
588
+ for (const parameter of orderedParameters) {
589
+ if (!isRelationshipParam(parameter)) continue;
590
+ const key = parameterKey(parameter);
591
+ if (!key) continue;
592
+ const ids = normalizeRelIds(values[key] ?? initialValue(parameter));
593
+ const known = relLabels[key] ?? {};
594
+ const requested = (relHydrateRequested[key] ??= new Set());
595
+ const missing = ids.filter((id) => !(id in known) && !requested.has(id));
596
+ if (!missing.length) continue;
597
+ for (const id of missing) requested.add(id);
598
+ hydrateEntities(relationshipTypeCode(parameter), missing)
599
+ .then((items) => {
600
+ if (!Array.isArray(items) || !items.length) return;
601
+ const next = { ...(relLabels[key] ?? {}) };
602
+ for (const item of items) {
603
+ const value = String(item.value ?? "");
604
+ if (value) next[value] = String(item.label ?? value);
605
+ }
606
+ relLabels = { ...relLabels, [key]: next };
607
+ })
608
+ .catch(() => {
609
+ // Best-effort: the picker falls back to raw ids.
610
+ });
611
+ }
612
+ });
613
+
614
+ function relLabel(key: string, id: string): string {
615
+ return relLabels[key]?.[id] ?? id;
616
+ }
617
+
618
+ function captureRelLabel(key: string, id: string) {
619
+ const item = (referenceItems[key] ?? []).find((entry) => entry.value === id);
620
+ if (!item) return;
621
+ relLabels = {
622
+ ...relLabels,
623
+ [key]: { ...(relLabels[key] ?? {}), [id]: item.label },
624
+ };
625
+ }
626
+
627
+ /** Combobox items for a rel param. Multi: the raw search results —
628
+ * MultiSelectCombobox itself excludes already-picked ids. Single: prepends
629
+ * the current selection when a later search dropped it (label
630
+ * persistence). */
631
+ function relComboItems(parameter: Entity): SelectOption[] {
632
+ const key = parameterKey(parameter);
633
+ const searched = referenceItems[key] ?? [];
634
+ if (isMultiRelationship(parameter)) return searched;
635
+ const current = typeof values[key] === "string" ? String(values[key]) : "";
636
+ if (!current || searched.some((item) => item.value === current)) {
637
+ return searched;
638
+ }
639
+ return [{ value: current, label: relLabel(key, current) }, ...searched];
640
+ }
641
+
642
+ function pickSingleRel(key: string, id: string) {
643
+ captureRelLabel(key, id);
644
+ setValue(key, id);
645
+ }
646
+
647
+ function addRelId(key: string, id: string) {
648
+ if (!id) return;
649
+ const current = normalizeRelIds(values[key]);
650
+ if (current.includes(id)) return;
651
+ captureRelLabel(key, id);
652
+ setValue(key, [...current, id]);
653
+ }
654
+
655
+ function removeRelId(key: string, id: string) {
656
+ setValue(
657
+ key,
658
+ normalizeRelIds(values[key]).filter((value) => value !== id),
659
+ );
660
+ }
661
+
459
662
  // `datetime` params keep an ISO 8601 STRING in the value bag (the platform's
460
663
  // datetime wire vocabulary — `$now` resolves to one) so the payload stays
461
664
  // plain JSON; the DateTimePicker speaks Date on its prop edge.
@@ -523,6 +726,7 @@
523
726
  {@const parameter = rawParameter as Entity}
524
727
  {@const key = parameterKey(parameter)}
525
728
  {@const type = parameterType(parameter)}
729
+ {@const kind = widgetKind(parameter)}
526
730
  <!-- A11y (#244 C7 / Selo item 4): required fields are marked
527
731
  `aria-required` on the control itself, so a screen reader announces
528
732
  "required" on the field — not just the disconnected visual hint below.
@@ -530,17 +734,113 @@
530
734
  {@const ariaRequired = parameter.required ? "true" : undefined}
531
735
  <!-- #252 — a parameter with `editable: false` (visibility.editable === false)
532
736
  renders READ-ONLY: its value (e.g. a logged-in citizen's prefilled
533
- identity — name/email) is shown but not editable. The value still rides
534
- the payload; the field just can't be changed. Default (undefined/true) is
535
- editable, so this is purely additive. -->
536
- {@const editable = parameter.editable !== false}
537
- {#if type === "enum" || type === "select" || enumOptions(parameter).length}
737
+ identity — name/email) is shown but not editable. On the ACTION lane the
738
+ value still rides the payload; on the form-surface.v1 lane
739
+ (`visibility: "readonly"` string) it defaults OFF the wire per D3 —
740
+ see isPayloadIncluded. -->
741
+ {@const editable = !isReadonlyParam(parameter)}
742
+ <!-- #41 — first-class field states: `disabled` and `loading` are host/
743
+ contract-declared inertness (loading = options/context not resolved
744
+ yet); `fieldError` is the per-field server-error binding. -->
745
+ {@const fieldDisabled = parameter.disabled === true || parameter.loading === true}
746
+ {@const fieldError = fieldErrors?.[key]}
747
+ {#if kind === "currency"}
748
+ <!-- #35 — money: the bag carries the RAW number ("" when empty — the
749
+ number-field empty sentinel); formatting is display-only. -->
750
+ <MoneyInput
751
+ label={String(parameter.label ?? key)}
752
+ name={key}
753
+ value={typeof values[key] === "number" ? (values[key] as number) : null}
754
+ readonly={!editable}
755
+ disabled={fieldDisabled}
756
+ error={fieldError}
757
+ onchange={(amount: number | null) => setValue(key, amount === null ? "" : amount)}
758
+ aria-required={ariaRequired}
759
+ />
760
+ {:else if kind === "textarea"}
761
+ <!-- #36 — long text. Full-width-only (FULL_WIDTH_WIDGET_KINDS clamp). -->
762
+ <Textarea
763
+ label={String(parameter.label ?? key)}
764
+ name={key}
765
+ rows={4}
766
+ value={String(values[key] ?? initialValue(parameter) ?? "")}
767
+ readonly={!editable}
768
+ disabled={fieldDisabled}
769
+ error={fieldError}
770
+ oninput={(event: Event) =>
771
+ setValue(key, (event.target as HTMLTextAreaElement).value)}
772
+ aria-required={ariaRequired}
773
+ />
774
+ {:else if kind === "toggle"}
775
+ <!-- #36 — boolean as the DS switch; the bag carries a BOOLEAN, never a
776
+ "yes"/"no" string. A bare bool param WITHOUT the widget keeps the
777
+ legacy Yes/No select (byte-identical action lane). -->
778
+ <Toggle
779
+ label={String(parameter.label ?? key)}
780
+ checked={values[key] === true}
781
+ disabled={fieldDisabled || !editable}
782
+ onchange={(checked: boolean) => setValue(key, checked)}
783
+ aria-required={ariaRequired}
784
+ />
785
+ {:else if kind === "slug"}
786
+ <!-- #36 — slug input-type refinement: machine-name typing affordances;
787
+ the value stays whatever the operator types (validation is the
788
+ server's). -->
789
+ <Input
790
+ label={String(parameter.label ?? key)}
791
+ name={key}
792
+ value={String(values[key] ?? initialValue(parameter) ?? "")}
793
+ readonly={!editable}
794
+ disabled={fieldDisabled}
795
+ error={fieldError}
796
+ spellcheck="false"
797
+ autocapitalize="none"
798
+ autocorrect="off"
799
+ oninput={(event: Event) => setValue(key, (event.target as HTMLInputElement).value)}
800
+ aria-required={ariaRequired}
801
+ />
802
+ {:else if kind === "json"}
803
+ <!-- #38 — json editor with the invalid-state contract: invalid text
804
+ surfaces its parse error, emits nothing, and blocks submit via the
805
+ jsonInvalid gate. -->
806
+ <JsonEditor
807
+ label={String(parameter.label ?? key)}
808
+ value={values[key] ?? initialValue(parameter)}
809
+ readonly={!editable}
810
+ disabled={fieldDisabled}
811
+ error={fieldError}
812
+ onchange={(value: unknown) => setValue(key, value)}
813
+ oninvalid={(message: string | null) =>
814
+ (jsonInvalid = { ...jsonInvalid, [key]: message })}
815
+ />
816
+ {:else if kind === "date"}
817
+ <!-- #37 — date-only picker; the bag carries the YYYY-MM-DD wire string
818
+ (LOCAL calendar parts — no toISOString, no DST off-by-one). -->
819
+ <DatePicker
820
+ label={String(parameter.label ?? key)}
821
+ value={dateOnlyToDate(values[key])}
822
+ readonly={!editable}
823
+ disabled={fieldDisabled}
824
+ error={fieldError}
825
+ onchange={(date: Date | null) => setValue(key, date ? dateToDateOnly(date) : "")}
826
+ />
827
+ {:else if type === "enum" || type === "select" || enumOptions(parameter).length}
828
+ {@const options = enumOptions(parameter)}
829
+ <!-- #41 — empty state: an option-driven field with ZERO resolved options
830
+ is inert with an explicit hint, never a dead control. -->
538
831
  <Select
539
832
  label={String(parameter.label ?? key)}
540
833
  name={key}
541
834
  value={String(values[key] ?? initialValue(parameter) ?? "")}
542
- options={enumOptions(parameter)}
835
+ {options}
543
836
  placeholder="Select value"
837
+ disabled={fieldDisabled || !editable || options.length === 0}
838
+ error={fieldError}
839
+ help={parameter.loading === true
840
+ ? "Loading options..."
841
+ : options.length === 0
842
+ ? "No options available"
843
+ : undefined}
544
844
  onchange={(value: string) => setValue(key, value)}
545
845
  aria-required={ariaRequired}
546
846
  />
@@ -551,6 +851,8 @@
551
851
  type="number"
552
852
  value={String(values[key] ?? "")}
553
853
  readonly={!editable}
854
+ disabled={fieldDisabled}
855
+ error={fieldError}
554
856
  oninput={(event: Event) => {
555
857
  const value = (event.target as HTMLInputElement).value;
556
858
  setValue(key, value === "" ? "" : Number(value));
@@ -566,6 +868,8 @@
566
868
  { value: "true", label: "Yes" },
567
869
  { value: "false", label: "No" },
568
870
  ]}
871
+ disabled={fieldDisabled || !editable}
872
+ error={fieldError}
569
873
  onchange={(value: string) => setValue(key, value === "true")}
570
874
  aria-required={ariaRequired}
571
875
  />
@@ -577,50 +881,102 @@
577
881
  label={String(parameter.label ?? key)}
578
882
  value={datetimeValue(values[key])}
579
883
  readonly={!editable}
884
+ disabled={fieldDisabled}
885
+ error={fieldError}
580
886
  onchange={(date: Date) => setValue(key, date.toISOString())}
581
887
  />
582
- {:else if type === "object_reference"}
583
- <!-- `object_reference` parameter (#629 PR1b): lazy-search Combobox over
584
- the entity type named by `object_type`; `object_filter` rides the
585
- injected searchEntities seam. No seam disabled (the file-param
586
- convention for a missing consumer transport). -->
587
- <Combobox
588
- label={String(parameter.label ?? key)}
589
- items={referenceItems[key] ?? []}
590
- value={String(values[key] ?? "")}
591
- placeholder={typeof parameter.object_type === "string" && parameter.object_type
592
- ? `Search ${parameter.object_type}...`
593
- : "Search..."}
594
- disabled={!searchEntities || !editable}
595
- loading={referenceLoading[key] ?? false}
596
- onsearch={(query: string) => handleReferenceSearch(parameter, query)}
597
- onchange={(value: string) => setValue(key, value)}
598
- />
888
+ {:else if isRelationshipParam(parameter)}
889
+ <!-- Relationship / `object_reference` picking, cardinality-aware (#34,
890
+ atelier#669 D6). Covers the form-surface.v1 `relationship` summary
891
+ AND the derived-action `object_reference` (+ `multiple`) param.
892
+ `object_filter` rides the injected searchEntities seam; no seam →
893
+ disabled (the file-param convention for a missing transport). -->
894
+ {#if isMultiRelationship(parameter)}
895
+ <!-- many_to_many: the DS MultiSelectCombobox widget (chips +
896
+ search-to-add). The renderer contributes only SEMANTICS: the value
897
+ bag carries the ID LIST, labels come from hydration/pick capture —
898
+ a single-pick replace of an m2m value is impossible by
899
+ construction (the widget never holds the selection). -->
900
+ <MultiSelectCombobox
901
+ data-testid="afr-rel-multi"
902
+ label={String(parameter.label ?? key)}
903
+ required={parameter.required === true}
904
+ items={relComboItems(parameter)}
905
+ selected={normalizeRelIds(values[key]).map((id) => ({
906
+ value: id,
907
+ label: relLabel(key, id),
908
+ }))}
909
+ placeholder={relationshipTypeCode(parameter)
910
+ ? `Search ${relationshipTypeCode(parameter)}...`
911
+ : "Search..."}
912
+ disabled={!searchEntities || !editable || fieldDisabled}
913
+ loading={(referenceLoading[key] ?? false) || parameter.loading === true}
914
+ error={fieldError}
915
+ onsearch={(query: string) => handleReferenceSearch(parameter, query)}
916
+ onadd={(value: string) => addRelId(key, value)}
917
+ onremove={(value: string) => removeRelId(key, value)}
918
+ />
919
+ {:else}
920
+ <Combobox
921
+ label={String(parameter.label ?? key)}
922
+ items={relComboItems(parameter)}
923
+ value={String(values[key] ?? "")}
924
+ placeholder={relationshipTypeCode(parameter)
925
+ ? `Search ${relationshipTypeCode(parameter)}...`
926
+ : "Search..."}
927
+ disabled={!searchEntities || !editable || fieldDisabled}
928
+ loading={(referenceLoading[key] ?? false) || parameter.loading === true}
929
+ error={fieldError}
930
+ onsearch={(query: string) => handleReferenceSearch(parameter, query)}
931
+ onchange={(value: string) => pickSingleRel(key, value)}
932
+ />
933
+ {/if}
599
934
  {:else if type === "file"}
600
935
  <!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
601
936
  ride payload.attachment_keys; raw_values never sees this param. -->
602
937
  <!-- A11y: the file param is a labelled group (the FileUpload's own input
603
938
  can't take a `for`/label from here), so SR users get the field name +
604
939
  required state when they enter it. -->
940
+ {@const storedFile = storedFileDescriptor(parameter.default_value)}
941
+ {@const replaceMode = storedFile !== null}
605
942
  <div class="afr-file-param" role="group" aria-labelledby={`${key}-file-label`}>
606
943
  <span id={`${key}-file-label`} class="afr-file-label"
607
944
  >{String(parameter.label ?? key)}{parameter.required
608
945
  ? " (required)"
609
946
  : ""}</span
610
947
  >
948
+ <!-- #40 — REPLACE semantics: the stored current shows until a pending
949
+ replacement exists; untouched emits NO attachment key (the host
950
+ keeps the stored file); removing the pending replacement restores
951
+ untouched. -->
952
+ {#if replaceMode && storedFile && !(fileUploads[key] ?? []).length}
953
+ <div class="afr-file-current" data-testid="afr-file-current">
954
+ {#if storedFile.url}
955
+ <a
956
+ class="afr-file-current-name"
957
+ href={storedFile.url}
958
+ target="_blank"
959
+ rel="noopener noreferrer">{storedFile.name}</a
960
+ >
961
+ {:else}
962
+ <span class="afr-file-current-name">{storedFile.name}</span>
963
+ {/if}
964
+ <span class="afr-file-current-hint">Current file — uploading replaces it</span>
965
+ </div>
966
+ {/if}
611
967
  {#each fileUploads[key] ?? [] as f (f.key)}
612
968
  <FileUploadItem
613
969
  name={f.name}
614
970
  onremove={() => removeFile(key, f.key)}
615
971
  />
616
972
  {/each}
617
- {#if (fileUploads[key] ?? []).length < FILE_MAX_COUNT}
973
+ {#if replaceMode || (fileUploads[key] ?? []).length < FILE_MAX_COUNT}
618
974
  <FileUpload
619
975
  accept={fileAccept(parameter)}
620
976
  maxSize={fileMaxBytes(parameter)}
621
- multiple
977
+ multiple={!replaceMode}
622
978
  disabled={!uploadFile || fileBusy[key]}
623
- onfiles={(files: File[]) => handleFiles(key, files)}
979
+ onfiles={(files: File[]) => handleFiles(key, files, replaceMode)}
624
980
  onreject={() => {
625
981
  fileError = {
626
982
  ...fileError,
@@ -631,8 +987,29 @@
631
987
  {/if}
632
988
  {#if fileError[key]}
633
989
  <p class="afr-file-error" role="alert">{fileError[key]}</p>
990
+ {:else if fieldError}
991
+ <p class="afr-file-error" role="alert">{fieldError}</p>
634
992
  {/if}
635
993
  </div>
994
+ {:else if kind === "geometry"}
995
+ <!-- #39 — the form-surface.v1 geometry widget: the value bag carries a
996
+ GeoJSON Point (the CRUD wire shape); the MapPicker keeps speaking
997
+ [lon, lat] at its prop edge; the pure adapters translate. A
998
+ non-Point value fails LOUD on the field instead of rendering a
999
+ silently-empty map over real data. The legacy `geo` param (bare
1000
+ tuple) is the branch below, untouched. -->
1001
+ {@const hydrated = geoJsonPointToLonLat(values[key])}
1002
+ <MapPicker
1003
+ mode="point"
1004
+ height="20rem"
1005
+ {boundary}
1006
+ {layers}
1007
+ {onoutofbounds}
1008
+ error={fieldError ?? hydrated.error ?? geoError}
1009
+ label={String(parameter.label ?? key)}
1010
+ value={hydrated.coords ?? undefined}
1011
+ onchange={(coords: [number, number]) => setValue(key, lonLatToGeoJsonPoint(coords))}
1012
+ />
636
1013
  {:else if type === "geo"}
637
1014
  <!-- A `geo` parameter renders the DS map-picker; its value is the
638
1015
  [lon, lat] the BFF's GEO_PARSE binding transform turns into a Point.
@@ -648,7 +1025,7 @@
648
1025
  {boundary}
649
1026
  {layers}
650
1027
  {onoutofbounds}
651
- error={geoError}
1028
+ error={fieldError ?? geoError}
652
1029
  label={String(parameter.label ?? key)}
653
1030
  center={Array.isArray(parameter.default_value)
654
1031
  ? (parameter.default_value as [number, number])
@@ -669,6 +1046,8 @@
669
1046
  name={key}
670
1047
  value={String(values[key] ?? initialValue(parameter) ?? "")}
671
1048
  readonly={!editable}
1049
+ disabled={fieldDisabled}
1050
+ error={fieldError}
672
1051
  oninput={(event: Event) => setValue(key, (event.target as HTMLInputElement).value)}
673
1052
  aria-required={ariaRequired}
674
1053
  />
@@ -886,6 +1265,25 @@
886
1265
  color: var(--input-error-text);
887
1266
  }
888
1267
 
1268
+ .afr-file-current {
1269
+ display: flex;
1270
+ align-items: baseline;
1271
+ gap: var(--space-sm);
1272
+ }
1273
+
1274
+ .afr-file-current-name {
1275
+ font-family: var(--input-font);
1276
+ font-size: var(--input-font-size);
1277
+ color: var(--color-text);
1278
+ }
1279
+
1280
+ .afr-file-current-hint {
1281
+ font-family: var(--input-help-font);
1282
+ font-size: var(--input-help-size);
1283
+ color: var(--input-help-color);
1284
+ }
1285
+
1286
+
889
1287
  .submit-captcha {
890
1288
  min-height: var(--space-4xl);
891
1289
  }