@aiaiai-pt/design-system 0.44.0 → 0.45.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.
@@ -24,7 +24,11 @@
24
24
  // `target_config.layout_key` and validated through `resolveLayout`,
25
25
  // so an operator-supplied string never reaches the DOM verbatim
26
26
  // (RH#11 / R-SEC-07 / TH-08).
27
- import { resolveLayout } from "./action-form-renderer-layouts";
27
+ import { resolveLayout, type LayoutSection } from "./action-form-renderer-layouts";
28
+ // #634 S4 — live section show/hide from the contract's `visible_when`
29
+ // predicate (pure client evaluation over the value bag; the server owns
30
+ // enforcement).
31
+ import { sectionVisible } from "./action-form-visibility";
28
32
 
29
33
  /**
30
34
  * The renderer treats every parameter/action/placement as a loose record
@@ -42,7 +46,15 @@
42
46
  placement?: Entity | null;
43
47
  target?: Record<string, unknown> | null;
44
48
  source?: Record<string, unknown> | null;
45
- sections?: Array<{ key?: string; label?: string; parameters?: Entity[] }>;
49
+ sections?: Array<{
50
+ key?: string;
51
+ label?: string;
52
+ order?: number | null;
53
+ columns?: number | null;
54
+ /** Read defensively (hosts carry concrete precondition types). */
55
+ visible_when?: unknown;
56
+ parameters?: Entity[];
57
+ }>;
46
58
  parameters?: Entity[];
47
59
  criteria?: Entity[];
48
60
  };
@@ -193,12 +205,21 @@
193
205
  );
194
206
  const schemaSections = $derived(schemaSectionsFromContract());
195
207
  const sections = $derived(schemaSections ?? groupIntoSections(visibleParameters));
208
+ // #634 S4 — LIVE conditional sections: a section whose `visible_when`
209
+ // predicate fails against the current value bag is hidden (and re-appears
210
+ // the moment the driving value changes). Sections without a predicate —
211
+ // including every legacy fold section — always pass, so this is inert
212
+ // until a sheet declares `visible_when`. The value bag still spans ALL
213
+ // parameters (hidden sections keep their answers).
214
+ const liveSections = $derived(
215
+ sections.filter((section) => sectionVisible(section, values)),
216
+ );
196
217
  // Wizard pagination (#105 P6): when a section is active, render only it. The
197
218
  // value bag is unaffected — every parameter is still seeded and submitted.
198
219
  const displaySections = $derived(
199
220
  activeSectionKey == null
200
- ? sections
201
- : sections.filter((section) => section.name === activeSectionKey),
221
+ ? liveSections
222
+ : liveSections.filter((section) => section.name === activeSectionKey),
202
223
  );
203
224
  const payload = $derived(buildPayload());
204
225
  const payloadJson = $derived(JSON.stringify(payload, null, 2));
@@ -216,8 +237,13 @@
216
237
  function isEmpty(value: unknown): boolean {
217
238
  return value === undefined || value === null || value === "";
218
239
  }
240
+ // #634 S4 — the gate counts only parameters whose section is LIVE-visible:
241
+ // a required field inside a `visible_when`-hidden section must not block
242
+ // submit (it is unreachable). In the legacy fold path this flattens back
243
+ // to exactly `visibleParameters`.
244
+ const gateParameters = $derived(liveSections.flatMap((section) => section.items));
219
245
  const canSubmit = $derived(
220
- visibleParameters
246
+ gateParameters
221
247
  .filter((parameter) => parameter.required)
222
248
  .every((parameter) =>
223
249
  String(parameter.type ?? "") === "file"
@@ -306,18 +332,34 @@
306
332
  return [...byName.entries()].map(([name, sectionItems]) => ({ name, items: sectionItems }));
307
333
  }
308
334
 
309
- function schemaSectionsFromContract(): Array<{ name: string; items: Entity[] }> | null {
335
+ function schemaSectionsFromContract(): LayoutSection[] | null {
310
336
  if (!Array.isArray(schema?.sections)) return null;
311
337
  const next = schema.sections
312
- .map((section) => {
338
+ .map((section, index) => {
313
339
  const items = Array.isArray(section.parameters)
314
340
  ? section.parameters
315
341
  .filter((parameter) => parameter.is_active !== false && isVisible(parameter))
316
342
  .sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
317
343
  : [];
318
- return { name: String(section.label ?? section.key ?? "Details"), items };
344
+ // #634 S3/S4 carry the contract's presentation keys through to the
345
+ // layouts: stable `key`, declared `columns`, the `visible_when`
346
+ // predicate, and `order` (the server emits sections pre-sorted; the
347
+ // sort below is a defensive mirror, stable via declaration index).
348
+ return {
349
+ name: String(section.label ?? section.key ?? "Details"),
350
+ key: section.key ? String(section.key) : undefined,
351
+ columns: Number(section.columns ?? 1),
352
+ visibleWhen:
353
+ section.visible_when && typeof section.visible_when === "object"
354
+ ? (section.visible_when as Record<string, unknown>)
355
+ : null,
356
+ order: Number(section.order ?? 0),
357
+ index,
358
+ items,
359
+ };
319
360
  })
320
- .filter((section) => section.items.length);
361
+ .filter((section) => section.items.length)
362
+ .sort((a, b) => a.order - b.order || a.index - b.index);
321
363
  return next.length ? next : null;
322
364
  }
323
365
 
@@ -479,6 +521,16 @@
479
521
  return Object.values(fileUploads).flatMap((list) => list.map((f) => f.key));
480
522
  }
481
523
 
524
+ /** #629 follow-up — the same keys ATTRIBUTED to the param each was
525
+ * uploaded against, so consumers never have to guess positionally. */
526
+ function attachmentsByParam(): Record<string, string[]> {
527
+ const out: Record<string, string[]> = {};
528
+ for (const [paramKey, list] of Object.entries(fileUploads)) {
529
+ if (list.length) out[paramKey] = list.map((f) => f.key);
530
+ }
531
+ return out;
532
+ }
533
+
482
534
  function buildPayload(): Record<string, unknown> {
483
535
  return buildActionPayload({
484
536
  action: renderedAction ?? null,
@@ -487,6 +539,7 @@
487
539
  sourceSchema: sourceSchema(),
488
540
  rawValues: visibleValueBag(),
489
541
  attachmentKeys: allAttachmentKeys(),
542
+ attachmentsByParam: attachmentsByParam(),
490
543
  schemaVersion: schema?.schema_version ?? null,
491
544
  mode,
492
545
  }) as unknown as Record<string, unknown>;
@@ -662,8 +715,10 @@
662
715
  aria-required={ariaRequired}
663
716
  />
664
717
  {/if}
665
- {#if mode === "public-submit"}
666
- <!-- Citizen-facing: just a quiet required hint, no operator type debug. -->
718
+ {#if isSubmitMode}
719
+ <!-- EXECUTE surfaces (citizen public-submit AND staff admin-execute,
720
+ #629 follow-up): just a quiet required hint — the "Required / type"
721
+ line is operator DEBUG chrome, preview modes only. -->
667
722
  {#if parameter.required}<p class="field-meta">Required</p>{/if}
668
723
  {:else}
669
724
  <p class="field-meta">
@@ -675,8 +730,12 @@
675
730
  <div class="renderer" data-testid={`action-form-renderer-${mode}`} data-layout={resolvedLayout.key}>
676
731
  <!-- Wizard pagination (#105 P6): the ServiceFlow shell owns the step
677
732
  heading, so the renderer's own action-title header is suppressed when a
678
- section is active to avoid a duplicate heading per step. -->
679
- {#if activeSectionKey == null}
733
+ section is active to avoid a duplicate heading per step.
734
+ admin-execute (#629 follow-up): the HOST container (drawer/modal) owns
735
+ the action heading — the renderer's header would duplicate it, and the
736
+ "Placement preview" eyebrow + placement Badge are authoring chrome that
737
+ has no meaning on an execute surface. -->
738
+ {#if activeSectionKey == null && mode !== "admin-execute"}
680
739
  <div class="renderer-header">
681
740
  <div>
682
741
  <!-- The placement-preview eyebrow + surface badge are operator chrome —
@@ -22,6 +22,10 @@ type ActionSchema = {
22
22
  sections?: Array<{
23
23
  key?: string;
24
24
  label?: string;
25
+ order?: number | null;
26
+ columns?: number | null;
27
+ /** Read defensively (hosts carry concrete precondition types). */
28
+ visible_when?: unknown;
25
29
  parameters?: Entity[];
26
30
  }>;
27
31
  parameters?: Entity[];
@@ -5,9 +5,15 @@
5
5
  * Each section is a labelled <fieldset>, parameters laid out as a
6
6
  * single-column flex column. This is the safe fallback for unknown
7
7
  * layout keys — see `resolve.ts`.
8
+ *
9
+ * #634 S3 — a section declaring `columns: 2` lays its cells on a
10
+ * two-column grid; `span: full` params (and wide types — geo/file)
11
+ * break out to the full row (the EntityFormSurface two-col precedent).
12
+ * Default (columns 1 / absent) keeps the single-column stack
13
+ * byte-identical.
8
14
  */
9
15
  import Card from "./Card.svelte";
10
- import type { LayoutSection } from "./action-form-renderer-layouts";
16
+ import { fieldSpansFull, type LayoutSection } from "./action-form-renderer-layouts";
11
17
  import type { Snippet } from "svelte";
12
18
 
13
19
  let {
@@ -23,9 +29,16 @@
23
29
  {#each sections as section (section.name)}
24
30
  <Card variant="flat" class="section" role="group" aria-label={section.name}>
25
31
  <h4>{section.name}</h4>
26
- <div class="rows">
32
+ <div
33
+ class="rows"
34
+ class:two-col={(section.columns ?? 1) === 2}
35
+ data-columns={section.columns ?? 1}
36
+ >
27
37
  {#each section.items as parameter}
28
- <div class="field-row">
38
+ <div
39
+ class="field-row"
40
+ class:span-full={fieldSpansFull(parameter, section.columns)}
41
+ >
29
42
  {@render field(parameter)}
30
43
  </div>
31
44
  {/each}
@@ -53,6 +66,26 @@
53
66
  gap: var(--space-md);
54
67
  }
55
68
 
69
+ /* #634 S3 — two-column density: half-width cells flow the grid,
70
+ span-full cells break out to the whole row. Collapses back to one
71
+ column on narrow viewports (the EntityFormSurface breakpoint). */
72
+ .rows.two-col {
73
+ display: grid;
74
+ grid-template-columns: repeat(2, minmax(0, 1fr));
75
+ column-gap: var(--space-md);
76
+ row-gap: var(--space-md);
77
+ }
78
+
79
+ .rows.two-col .field-row.span-full {
80
+ grid-column: 1 / -1;
81
+ }
82
+
83
+ @media (max-width: 48rem) {
84
+ .rows.two-col {
85
+ grid-template-columns: 1fr;
86
+ }
87
+ }
88
+
56
89
  .field-row {
57
90
  display: flex;
58
91
  flex-direction: column;
@@ -1,4 +1,4 @@
1
- import type { LayoutSection } from "./action-form-renderer-layouts";
1
+ import { type LayoutSection } from "./action-form-renderer-layouts";
2
2
  import type { Snippet } from "svelte";
3
3
  type $$ComponentProps = {
4
4
  sections: LayoutSection[];
@@ -34,7 +34,24 @@ export interface LayoutEntry {
34
34
  export interface LayoutSection {
35
35
  name: string;
36
36
  items: Array<Record<string, unknown>>;
37
+ /** #634 S3 — stable contract key (schema.sections[].key), when present. */
38
+ key?: string;
39
+ /** #634 S3 — declared section density (1|2). Layouts that arrange rows
40
+ * vertically render a two-column grid when 2; other layouts may ignore
41
+ * it (their arrangement is their own contract). Default 1. */
42
+ columns?: number;
43
+ /** #634 S4 — the section's optional `visible_when` predicate, carried
44
+ * from the contract for live show/hide (see action-form-visibility). */
45
+ visibleWhen?: Record<string, unknown> | null;
37
46
  }
47
+ /**
48
+ * #634 S3 — whether a parameter's cell spans the full row inside a
49
+ * `columns: 2` section. An explicit `span: "full"` on the parameter wins;
50
+ * wide types clamp to full regardless; everything else flows half-width
51
+ * (so a bare `columns: 2` declaration visibly two-columns its fields).
52
+ * In a single-column section every cell is trivially full.
53
+ */
54
+ export declare function fieldSpansFull(parameter: Record<string, unknown>, columns: number | undefined): boolean;
38
55
  import type { Snippet } from "svelte";
39
56
  export interface LayoutComponentProps {
40
57
  sections: LayoutSection[];
@@ -41,6 +41,36 @@ export interface LayoutEntry {
41
41
  export interface LayoutSection {
42
42
  name: string;
43
43
  items: Array<Record<string, unknown>>;
44
+ /** #634 S3 — stable contract key (schema.sections[].key), when present. */
45
+ key?: string;
46
+ /** #634 S3 — declared section density (1|2). Layouts that arrange rows
47
+ * vertically render a two-column grid when 2; other layouts may ignore
48
+ * it (their arrangement is their own contract). Default 1. */
49
+ columns?: number;
50
+ /** #634 S4 — the section's optional `visible_when` predicate, carried
51
+ * from the contract for live show/hide (see action-form-visibility). */
52
+ visibleWhen?: Record<string, unknown> | null;
53
+ }
54
+
55
+ /** Parameter types that can never sit half-width in a two-column section —
56
+ * they carry their own wide UI (map canvas, upload list). Mirrors the CRUD
57
+ * form-surface compiler's full-width clamp (form-surface.ts). */
58
+ const FULL_WIDTH_PARAM_TYPES = new Set(["geo", "file", "json"]);
59
+
60
+ /**
61
+ * #634 S3 — whether a parameter's cell spans the full row inside a
62
+ * `columns: 2` section. An explicit `span: "full"` on the parameter wins;
63
+ * wide types clamp to full regardless; everything else flows half-width
64
+ * (so a bare `columns: 2` declaration visibly two-columns its fields).
65
+ * In a single-column section every cell is trivially full.
66
+ */
67
+ export function fieldSpansFull(
68
+ parameter: Record<string, unknown>,
69
+ columns: number | undefined,
70
+ ): boolean {
71
+ if ((columns ?? 1) < 2) return true;
72
+ if (parameter.span === "full") return true;
73
+ return FULL_WIDTH_PARAM_TYPES.has(String(parameter.type ?? ""));
44
74
  }
45
75
 
46
76
  import type { Snippet } from "svelte";
@@ -56,9 +86,11 @@ export interface LayoutComponentProps {
56
86
  /** The registry. Order is the order shown to operators in the
57
87
  * AddPlacementPanel select; the first entry is the default. */
58
88
  const REGISTRY: Record<LayoutKey, Component<LayoutComponentProps>> = {
59
- "stacked-default": LayoutStackedDefault as unknown as Component<LayoutComponentProps>,
89
+ "stacked-default":
90
+ LayoutStackedDefault as unknown as Component<LayoutComponentProps>,
60
91
  "inline-row": LayoutInlineRow as unknown as Component<LayoutComponentProps>,
61
- "compact-mobile": LayoutCompactMobile as unknown as Component<LayoutComponentProps>,
92
+ "compact-mobile":
93
+ LayoutCompactMobile as unknown as Component<LayoutComponentProps>,
62
94
  };
63
95
 
64
96
  const DEFAULT_KEY: LayoutKey = "stacked-default";
@@ -79,4 +111,6 @@ export function resolveLayout(key: unknown): LayoutEntry {
79
111
  }
80
112
 
81
113
  /** All known keys, in registry order. Useful for picker UIs. */
82
- export const LAYOUT_KEYS: readonly LayoutKey[] = Object.keys(REGISTRY) as LayoutKey[];
114
+ export const LAYOUT_KEYS: readonly LayoutKey[] = Object.keys(
115
+ REGISTRY,
116
+ ) as LayoutKey[];
@@ -43,12 +43,20 @@ export interface BuildArgs {
43
43
  * attachment contract: a TOP-LEVEL `attachment_keys` sibling, never part
44
44
  * of raw_values (the form schema doesn't validate file params). */
45
45
  attachmentKeys?: string[];
46
+ /** #629 follow-up — the SAME storage keys, attributed to the file param
47
+ * each was uploaded against (`{param_key: [keys]}`). Additive: consumers
48
+ * that only read the flat `attachment_keys` are unaffected; consumers
49
+ * that must map keys back to parameters (the staff execute adapter) read
50
+ * this instead of guessing positionally. */
51
+ attachmentsByParam?: Record<string, string[]>;
46
52
  schemaVersion: string | null;
47
53
  mode: RendererMode;
48
54
  }
49
55
  export interface ActionPayload {
50
56
  /** Uploaded file storage keys (file-typed params) — absent when none. */
51
57
  attachment_keys?: string[];
58
+ /** Per-param attribution of the same keys — absent when none. */
59
+ attachments_by_param?: Record<string, string[]>;
52
60
  source: string;
53
61
  action: {
54
62
  id: string | null;
@@ -46,6 +46,12 @@ export interface BuildArgs {
46
46
  * attachment contract: a TOP-LEVEL `attachment_keys` sibling, never part
47
47
  * of raw_values (the form schema doesn't validate file params). */
48
48
  attachmentKeys?: string[];
49
+ /** #629 follow-up — the SAME storage keys, attributed to the file param
50
+ * each was uploaded against (`{param_key: [keys]}`). Additive: consumers
51
+ * that only read the flat `attachment_keys` are unaffected; consumers
52
+ * that must map keys back to parameters (the staff execute adapter) read
53
+ * this instead of guessing positionally. */
54
+ attachmentsByParam?: Record<string, string[]>;
49
55
  schemaVersion: string | null;
50
56
  mode: RendererMode;
51
57
  }
@@ -53,6 +59,8 @@ export interface BuildArgs {
53
59
  export interface ActionPayload {
54
60
  /** Uploaded file storage keys (file-typed params) — absent when none. */
55
61
  attachment_keys?: string[];
62
+ /** Per-param attribution of the same keys — absent when none. */
63
+ attachments_by_param?: Record<string, string[]>;
56
64
  source: string;
57
65
  action: {
58
66
  id: string | null;
@@ -108,7 +116,7 @@ function pickScope(targetConfig: Record<string, unknown>): Record<string, unknow
108
116
  }
109
117
 
110
118
  export function buildActionPayload(args: BuildArgs): ActionPayload {
111
- const { action, placement, targetConfig, sourceSchema, rawValues, schemaVersion, mode, attachmentKeys } = args;
119
+ const { action, placement, targetConfig, sourceSchema, rawValues, schemaVersion, mode, attachmentKeys, attachmentsByParam } = args;
112
120
 
113
121
  const sourceFromSchema = nullableString(sourceSchema.source);
114
122
  const targetModel =
@@ -152,6 +160,9 @@ export function buildActionPayload(args: BuildArgs): ActionPayload {
152
160
  ...(attachmentKeys && attachmentKeys.length > 0
153
161
  ? { attachment_keys: attachmentKeys }
154
162
  : {}),
163
+ ...(attachmentsByParam && Object.keys(attachmentsByParam).length > 0
164
+ ? { attachments_by_param: attachmentsByParam }
165
+ : {}),
155
166
  };
156
167
  }
157
168
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Client-side `visible_when` evaluation for action-form sections (#634 S4).
3
+ *
4
+ * The action's server-compiled contract round-trips an OPTIONAL
5
+ * `visible_when` predicate per section (the platform's Foundry-framed
6
+ * ActionPrecondition shape — conditions are data, one vocabulary). The
7
+ * server owns ENFORCEMENT (submission gates); this module owns the pure
8
+ * PRESENTATION concern: live show/hide of a section as the operator types.
9
+ *
10
+ * The operator set is a deliberate SUBSET of the server evaluator
11
+ * (`validation.py::_evaluate_operator`) — the comparisons a form value bag
12
+ * can answer locally. Two deliberate divergences, both UI-safe:
13
+ *
14
+ * - Blank form values (`""`, null, undefined) are treated as ABSENT for
15
+ * `is_null` / `not_null` — an untouched text input means "no value yet",
16
+ * so `{field: X, operator: not_null}` reads "shown once X has a value"
17
+ * (the spec's canonical example).
18
+ * - An operator this subset doesn't know evaluates to VISIBLE (fail-open).
19
+ * Hiding inputs on an unknown operator would make fields unreachable;
20
+ * the server's gates still enforce whatever the condition meant.
21
+ */
22
+ export interface VisibleWhenCondition {
23
+ field?: string;
24
+ operator?: string;
25
+ value?: unknown;
26
+ [key: string]: unknown;
27
+ }
28
+ /** Blank = the form has no value for the field yet. `false` and `0` are
29
+ * real values; only null / undefined / empty-string count as blank. */
30
+ export declare function isBlankFormValue(value: unknown): boolean;
31
+ /**
32
+ * Evaluate one `visible_when` condition against the live form value bag.
33
+ * Returns whether the section should be VISIBLE.
34
+ */
35
+ export declare function evaluateVisibleWhen(condition: VisibleWhenCondition | null | undefined, values: Record<string, unknown>): boolean;
36
+ /** A section shape carrying an optional predicate. Reads BOTH spellings so
37
+ * hosts can pass either the renderer's mapped sections (`visibleWhen`) or
38
+ * raw contract sections (`visible_when`) without an adapter. `unknown` on
39
+ * purpose: hosts carry their own concrete precondition types; the evaluator
40
+ * reads the predicate defensively. */
41
+ export interface ConditionalSection {
42
+ visibleWhen?: unknown;
43
+ visible_when?: unknown;
44
+ }
45
+ /** Whether a section is visible given the live value bag. Sections with no
46
+ * predicate are always visible. Accepts any object shape (fold sections
47
+ * carry no predicate at all) — the predicate is read defensively. */
48
+ export declare function sectionVisible(section: object | null | undefined, values: Record<string, unknown>): boolean;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Client-side `visible_when` evaluation for action-form sections (#634 S4).
3
+ *
4
+ * The action's server-compiled contract round-trips an OPTIONAL
5
+ * `visible_when` predicate per section (the platform's Foundry-framed
6
+ * ActionPrecondition shape — conditions are data, one vocabulary). The
7
+ * server owns ENFORCEMENT (submission gates); this module owns the pure
8
+ * PRESENTATION concern: live show/hide of a section as the operator types.
9
+ *
10
+ * The operator set is a deliberate SUBSET of the server evaluator
11
+ * (`validation.py::_evaluate_operator`) — the comparisons a form value bag
12
+ * can answer locally. Two deliberate divergences, both UI-safe:
13
+ *
14
+ * - Blank form values (`""`, null, undefined) are treated as ABSENT for
15
+ * `is_null` / `not_null` — an untouched text input means "no value yet",
16
+ * so `{field: X, operator: not_null}` reads "shown once X has a value"
17
+ * (the spec's canonical example).
18
+ * - An operator this subset doesn't know evaluates to VISIBLE (fail-open).
19
+ * Hiding inputs on an unknown operator would make fields unreachable;
20
+ * the server's gates still enforce whatever the condition meant.
21
+ */
22
+
23
+ export interface VisibleWhenCondition {
24
+ field?: string;
25
+ operator?: string;
26
+ value?: unknown;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ /** Blank = the form has no value for the field yet. `false` and `0` are
31
+ * real values; only null / undefined / empty-string count as blank. */
32
+ export function isBlankFormValue(value: unknown): boolean {
33
+ return value === undefined || value === null || value === "";
34
+ }
35
+
36
+ function asComparableNumber(value: unknown): number | null {
37
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
38
+ if (typeof value === "string" && value.trim() !== "") {
39
+ const parsed = Number(value);
40
+ return Number.isFinite(parsed) ? parsed : null;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function numericCompare(
46
+ operator: "gt" | "lt" | "gte" | "lte",
47
+ actual: unknown,
48
+ expected: unknown,
49
+ ): boolean {
50
+ // Mirrors the server: a missing actual is VACUOUSLY satisfied for the
51
+ // ordering operators (the gate that owns refusal runs server-side).
52
+ if (isBlankFormValue(actual)) return true;
53
+ const left = asComparableNumber(actual);
54
+ const right = asComparableNumber(expected);
55
+ if (left === null || right === null) {
56
+ // Fall back to string comparison only when both sides are strings
57
+ // (e.g. ISO dates order lexicographically); otherwise fail open.
58
+ if (typeof actual === "string" && typeof expected === "string") {
59
+ if (operator === "gt") return actual > expected;
60
+ if (operator === "lt") return actual < expected;
61
+ if (operator === "gte") return actual >= expected;
62
+ return actual <= expected;
63
+ }
64
+ return true;
65
+ }
66
+ if (operator === "gt") return left > right;
67
+ if (operator === "lt") return left < right;
68
+ if (operator === "gte") return left >= right;
69
+ return left <= right;
70
+ }
71
+
72
+ function looseEquals(actual: unknown, expected: unknown): boolean {
73
+ if (actual === expected) return true;
74
+ // Form controls carry strings; sheets author typed literals. "3" == 3 and
75
+ // "true" == true are the same answer from the operator's point of view.
76
+ if (typeof expected === "boolean") return String(actual) === String(expected);
77
+ const leftNumber = asComparableNumber(actual);
78
+ const rightNumber = asComparableNumber(expected);
79
+ if (leftNumber !== null && rightNumber !== null)
80
+ return leftNumber === rightNumber;
81
+ return String(actual ?? "") === String(expected ?? "");
82
+ }
83
+
84
+ function stringMatch(
85
+ mode: "contains" | "starts" | "ends",
86
+ actual: unknown,
87
+ expected: unknown,
88
+ ): boolean {
89
+ if (isBlankFormValue(actual) || isBlankFormValue(expected)) return false;
90
+ const haystack = String(actual).toLowerCase();
91
+ const needle = String(expected).toLowerCase();
92
+ if (mode === "contains") return haystack.includes(needle);
93
+ if (mode === "starts") return haystack.startsWith(needle);
94
+ return haystack.endsWith(needle);
95
+ }
96
+
97
+ /**
98
+ * Evaluate one `visible_when` condition against the live form value bag.
99
+ * Returns whether the section should be VISIBLE.
100
+ */
101
+ export function evaluateVisibleWhen(
102
+ condition: VisibleWhenCondition | null | undefined,
103
+ values: Record<string, unknown>,
104
+ ): boolean {
105
+ if (!condition || typeof condition !== "object") return true;
106
+ const field = typeof condition.field === "string" ? condition.field : "";
107
+ const operator =
108
+ typeof condition.operator === "string" ? condition.operator : "";
109
+ if (!field || !operator) return true;
110
+ const actual = values[field];
111
+ const expected = condition.value;
112
+
113
+ switch (operator) {
114
+ case "eq":
115
+ return looseEquals(actual, expected);
116
+ case "ne":
117
+ return !looseEquals(actual, expected);
118
+ case "gt":
119
+ case "lt":
120
+ case "gte":
121
+ case "lte":
122
+ return numericCompare(operator, actual, expected);
123
+ case "in":
124
+ return Array.isArray(expected)
125
+ ? expected.some((entry) => looseEquals(actual, entry))
126
+ : false;
127
+ case "not_in":
128
+ return Array.isArray(expected)
129
+ ? !expected.some((entry) => looseEquals(actual, entry))
130
+ : true;
131
+ case "is_null":
132
+ return isBlankFormValue(actual);
133
+ case "not_null":
134
+ return !isBlankFormValue(actual);
135
+ case "contains":
136
+ return stringMatch("contains", actual, expected);
137
+ case "starts_with":
138
+ return stringMatch("starts", actual, expected);
139
+ case "ends_with":
140
+ return stringMatch("ends", actual, expected);
141
+ default:
142
+ // Unknown operator → fail-open VISIBLE (see module doc).
143
+ return true;
144
+ }
145
+ }
146
+
147
+ /** A section shape carrying an optional predicate. Reads BOTH spellings so
148
+ * hosts can pass either the renderer's mapped sections (`visibleWhen`) or
149
+ * raw contract sections (`visible_when`) without an adapter. `unknown` on
150
+ * purpose: hosts carry their own concrete precondition types; the evaluator
151
+ * reads the predicate defensively. */
152
+ export interface ConditionalSection {
153
+ visibleWhen?: unknown;
154
+ visible_when?: unknown;
155
+ }
156
+
157
+ /** Whether a section is visible given the live value bag. Sections with no
158
+ * predicate are always visible. Accepts any object shape (fold sections
159
+ * carry no predicate at all) — the predicate is read defensively. */
160
+ export function sectionVisible(
161
+ section: object | null | undefined,
162
+ values: Record<string, unknown>,
163
+ ): boolean {
164
+ if (!section) return true;
165
+ const bag = section as ConditionalSection;
166
+ const predicate =
167
+ (bag.visibleWhen as VisibleWhenCondition | null | undefined) ??
168
+ (bag.visible_when as VisibleWhenCondition | null | undefined) ??
169
+ null;
170
+ return evaluateVisibleWhen(predicate, values);
171
+ }
@@ -106,3 +106,4 @@ export { default as GeoSearch } from "./GeoSearch.svelte";
106
106
  export { default as Calendar } from "./Calendar.svelte";
107
107
  export { default as ActionFormRenderer } from "./ActionFormRenderer.svelte";
108
108
  export { serializeValueSource, parseValueSource } from "./ValueSourcePicker.helpers.js";
109
+ export { evaluateVisibleWhen, isBlankFormValue, sectionVisible } from "./action-form-visibility";
@@ -173,3 +173,11 @@ export { default as Calendar } from "./Calendar.svelte";
173
173
 
174
174
  // Action form renderer (placement-aware; admin preview + portal runtime, #73)
175
175
  export { default as ActionFormRenderer } from "./ActionFormRenderer.svelte";
176
+ // #634 S4 — pure client-side `visible_when` evaluation (section show/hide).
177
+ // Exported at the root so host step-models (wizard rails) share the exact
178
+ // evaluator the renderer applies internally.
179
+ export {
180
+ evaluateVisibleWhen,
181
+ isBlankFormValue,
182
+ sectionVisible,
183
+ } from "./action-form-visibility";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,10 @@
31
31
  "svelte": "./components/index.js",
32
32
  "default": "./components/index.js"
33
33
  },
34
+ "./components/action-form-visibility": {
35
+ "types": "./components/action-form-visibility.d.ts",
36
+ "default": "./components/action-form-visibility.ts"
37
+ },
34
38
  "./components/*.svelte": {
35
39
  "types": "./components/*.svelte.d.ts",
36
40
  "svelte": "./components/*.svelte",