@aiaiai-pt/design-system 0.9.0 → 0.10.1

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.
@@ -0,0 +1,470 @@
1
+ <script lang="ts">
2
+ // DS-internal sibling imports (this component lives in the DS now, #73 73f —
3
+ // converged so admin preview AND portal runtime mount the SAME renderer).
4
+ import Badge from "./Badge.svelte";
5
+ import CodeBlock from "./CodeBlock.svelte";
6
+ import Input from "./Input.svelte";
7
+ import Select from "./Select.svelte";
8
+ import Tag from "./Tag.svelte";
9
+ import {
10
+ buildActionPayload,
11
+ placementConsequenceRows as buildPlacementConsequenceRows,
12
+ type RendererMode,
13
+ } from "./action-form-renderer-payload";
14
+ // S7 (#27): the renderer dispatches arrangement to one of three
15
+ // registered layout components. The key is read from
16
+ // `target_config.layout_key` and validated through `resolveLayout`,
17
+ // so an operator-supplied string never reaches the DOM verbatim
18
+ // (RH#11 / R-SEC-07 / TH-08).
19
+ import { resolveLayout } from "./action-form-renderer-layouts";
20
+
21
+ /**
22
+ * The renderer treats every parameter/action/placement as a loose record
23
+ * (it reads optional fields defensively). It used to import the admin app's
24
+ * `Entity`; in the DS it carries its own structural alias so it has no app
25
+ * dependency. The consuming app may pass its richer `Entity` — structurally
26
+ * compatible.
27
+ */
28
+ type Entity = Record<string, unknown>;
29
+ type SelectOption = { value: string; label: string };
30
+ type ActionSchema = {
31
+ found?: boolean;
32
+ schema_version?: string;
33
+ action?: Entity | null;
34
+ placement?: Entity | null;
35
+ target?: Record<string, unknown> | null;
36
+ source?: Record<string, unknown> | null;
37
+ sections?: Array<{ key?: string; label?: string; parameters?: Entity[] }>;
38
+ parameters?: Entity[];
39
+ criteria?: Entity[];
40
+ };
41
+
42
+ interface Props {
43
+ action: Entity | null;
44
+ placement?: Entity | null;
45
+ parameters?: Entity[];
46
+ criteria?: Entity[];
47
+ mode?: RendererMode;
48
+ schema?: ActionSchema | null;
49
+ }
50
+
51
+ let {
52
+ action,
53
+ placement = null,
54
+ parameters = [],
55
+ criteria = [],
56
+ mode = "admin-preview",
57
+ schema = null,
58
+ }: Props = $props();
59
+
60
+ let values = $state<Record<string, unknown>>({});
61
+
62
+ const renderedAction = $derived((schema?.action as Entity | null | undefined) ?? action);
63
+ const renderedPlacement = $derived((schema?.placement as Entity | null | undefined) ?? placement);
64
+ const renderedParameters = $derived((schema?.parameters as Entity[] | undefined) ?? parameters);
65
+ const renderedCriteria = $derived((schema?.criteria as Entity[] | undefined) ?? criteria);
66
+ const orderedParameters = $derived(
67
+ [...renderedParameters]
68
+ .filter((parameter) => parameter.is_active !== false)
69
+ .sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0)),
70
+ );
71
+ const visibleParameters = $derived(orderedParameters.filter((parameter) => isVisible(parameter)));
72
+ const hiddenOrDefaultedParameters = $derived(
73
+ orderedParameters.filter(
74
+ (parameter) =>
75
+ !isVisible(parameter) ||
76
+ (parameter.default_value !== null && parameter.default_value !== undefined),
77
+ ),
78
+ );
79
+ const schemaSections = $derived(schemaSectionsFromContract());
80
+ const sections = $derived(schemaSections ?? groupIntoSections(visibleParameters));
81
+ const payload = $derived(buildPayload());
82
+ const payloadJson = $derived(JSON.stringify(payload, null, 2));
83
+ const criteriaSummary = $derived(
84
+ renderedCriteria
85
+ .filter((criterion) => criterion.is_active !== false)
86
+ .sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0)),
87
+ );
88
+
89
+ $effect(() => {
90
+ const next = { ...values };
91
+ let touched = false;
92
+ for (const parameter of orderedParameters) {
93
+ const key = parameterKey(parameter);
94
+ if (!key || key in next) continue;
95
+ next[key] = initialValue(parameter);
96
+ touched = true;
97
+ }
98
+ if (touched) values = next;
99
+ });
100
+
101
+ function parameterKey(parameter: Entity): string {
102
+ return String(parameter.source_field_path ?? parameter.key ?? "");
103
+ }
104
+
105
+ function parameterType(parameter: Entity): string {
106
+ return String(parameter.type ?? "string");
107
+ }
108
+
109
+ function isVisible(parameter: Entity): boolean {
110
+ const visibility = parameter.visibility;
111
+ if (visibility && typeof visibility === "object" && !Array.isArray(visibility)) {
112
+ return (visibility as Record<string, unknown>).visible !== false;
113
+ }
114
+ return true;
115
+ }
116
+
117
+ function sectionName(parameter: Entity): string {
118
+ const visibility = parameter.visibility;
119
+ if (visibility && typeof visibility === "object" && !Array.isArray(visibility)) {
120
+ const section = (visibility as Record<string, unknown>).section;
121
+ if (section) return String(section);
122
+ }
123
+ const uiSchema = parameter.ui_schema;
124
+ if (uiSchema && typeof uiSchema === "object" && !Array.isArray(uiSchema)) {
125
+ const section = (uiSchema as Record<string, unknown>).section;
126
+ if (section) return String(section);
127
+ }
128
+ return "Details";
129
+ }
130
+
131
+ function groupIntoSections(items: Entity[]): Array<{ name: string; items: Entity[] }> {
132
+ const byName = new Map<string, Entity[]>();
133
+ for (const parameter of items) {
134
+ const name = sectionName(parameter);
135
+ byName.set(name, [...(byName.get(name) ?? []), parameter]);
136
+ }
137
+ return [...byName.entries()].map(([name, sectionItems]) => ({ name, items: sectionItems }));
138
+ }
139
+
140
+ function schemaSectionsFromContract(): Array<{ name: string; items: Entity[] }> | null {
141
+ if (!Array.isArray(schema?.sections)) return null;
142
+ const next = schema.sections
143
+ .map((section) => {
144
+ const items = Array.isArray(section.parameters)
145
+ ? section.parameters
146
+ .filter((parameter) => parameter.is_active !== false && isVisible(parameter))
147
+ .sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
148
+ : [];
149
+ return { name: String(section.label ?? section.key ?? "Details"), items };
150
+ })
151
+ .filter((section) => section.items.length);
152
+ return next.length ? next : null;
153
+ }
154
+
155
+ function enumOptions(parameter: Entity): SelectOption[] {
156
+ let options: unknown[] = Array.isArray(parameter.options) ? parameter.options : [];
157
+ if (!options.length) {
158
+ const constraints = Array.isArray(parameter.constraints) ? parameter.constraints : [];
159
+ const oneOf = constraints.find((constraint) => {
160
+ if (!constraint || typeof constraint !== "object") return false;
161
+ const type = (constraint as Record<string, unknown>).type;
162
+ return type === "oneOf" || type === "one_of";
163
+ }) as Record<string, unknown> | undefined;
164
+ options = Array.isArray(oneOf?.values) ? oneOf.values : [];
165
+ }
166
+ return options
167
+ .map((option: unknown) => {
168
+ if (option && typeof option === "object") {
169
+ const row = option as Record<string, unknown>;
170
+ const value = String(row.value ?? row.key ?? "");
171
+ return value ? { value, label: String(row.label ?? row.name ?? value) } : null;
172
+ }
173
+ const value = String(option ?? "");
174
+ return value ? { value, label: value } : null;
175
+ })
176
+ .filter((option): option is SelectOption => !!option);
177
+ }
178
+
179
+ function initialValue(parameter: Entity): unknown {
180
+ if (parameter.default_value !== null && parameter.default_value !== undefined) {
181
+ return parameter.default_value;
182
+ }
183
+ const type = parameterType(parameter);
184
+ if (type === "bool" || type === "boolean") return false;
185
+ if (type === "number" || type === "integer") return "";
186
+ if (type === "enum" || type === "select") return enumOptions(parameter)[0]?.value ?? "";
187
+ return "";
188
+ }
189
+
190
+ function setValue(key: string, value: unknown) {
191
+ values = { ...values, [key]: value };
192
+ }
193
+
194
+ function visibleValueBag(): Record<string, unknown> {
195
+ const bag: Record<string, unknown> = {};
196
+ for (const parameter of orderedParameters) {
197
+ const key = parameterKey(parameter);
198
+ if (!key) continue;
199
+ bag[key] = values[key] ?? initialValue(parameter);
200
+ }
201
+ return bag;
202
+ }
203
+
204
+ function targetConfig(): Record<string, unknown> {
205
+ const schemaTarget = schema?.target;
206
+ if (schemaTarget && typeof schemaTarget === "object" && !Array.isArray(schemaTarget)) {
207
+ return schemaTarget as Record<string, unknown>;
208
+ }
209
+ const value = renderedPlacement?.target_config;
210
+ return value && typeof value === "object" && !Array.isArray(value)
211
+ ? (value as Record<string, unknown>)
212
+ : {};
213
+ }
214
+
215
+ function sourceSchema(): Record<string, unknown> {
216
+ const schemaSource = schema?.source;
217
+ if (schemaSource && typeof schemaSource === "object" && !Array.isArray(schemaSource)) {
218
+ return schemaSource as Record<string, unknown>;
219
+ }
220
+ const value = renderedPlacement?.source_schema;
221
+ return value && typeof value === "object" && !Array.isArray(value)
222
+ ? (value as Record<string, unknown>)
223
+ : {};
224
+ }
225
+
226
+ function buildPayload(): Record<string, unknown> {
227
+ return buildActionPayload({
228
+ action: renderedAction ?? null,
229
+ placement: renderedPlacement ?? null,
230
+ targetConfig: targetConfig(),
231
+ sourceSchema: sourceSchema(),
232
+ rawValues: visibleValueBag(),
233
+ schemaVersion: schema?.schema_version ?? null,
234
+ mode,
235
+ }) as unknown as Record<string, unknown>;
236
+ }
237
+
238
+ function placementConsequenceRows(): Array<{ label: string; value: string }> {
239
+ return buildPlacementConsequenceRows({
240
+ placement: renderedPlacement ?? null,
241
+ targetConfig: targetConfig(),
242
+ sourceSchema: sourceSchema(),
243
+ mode,
244
+ });
245
+ }
246
+
247
+ // S7 (#27): resolve the layout component from target_config.layout_key.
248
+ // Unknown / null / typo'd keys collapse to stacked-default; the
249
+ // returned `key` is one of three known literals safe to render as
250
+ // `data-layout={resolvedLayout.key}` (TH-08 mitigation).
251
+ const resolvedLayout = $derived(resolveLayout(targetConfig().layout_key));
252
+ </script>
253
+
254
+ {#snippet fieldRow(rawParameter: Record<string, unknown>)}
255
+ {@const parameter = rawParameter as Entity}
256
+ {@const key = parameterKey(parameter)}
257
+ {@const type = parameterType(parameter)}
258
+ {#if type === "enum" || type === "select" || enumOptions(parameter).length}
259
+ <Select
260
+ label={String(parameter.label ?? key)}
261
+ name={key}
262
+ value={String(values[key] ?? initialValue(parameter) ?? "")}
263
+ options={enumOptions(parameter)}
264
+ placeholder="Select value"
265
+ onchange={(value: string) => setValue(key, value)}
266
+ />
267
+ {:else if type === "number" || type === "integer"}
268
+ <Input
269
+ label={String(parameter.label ?? key)}
270
+ name={key}
271
+ type="number"
272
+ value={String(values[key] ?? "")}
273
+ oninput={(event: Event) => {
274
+ const value = (event.target as HTMLInputElement).value;
275
+ setValue(key, value === "" ? "" : Number(value));
276
+ }}
277
+ />
278
+ {:else if type === "bool" || type === "boolean"}
279
+ <Select
280
+ label={String(parameter.label ?? key)}
281
+ name={key}
282
+ value={values[key] ? "true" : "false"}
283
+ options={[
284
+ { value: "true", label: "Yes" },
285
+ { value: "false", label: "No" },
286
+ ]}
287
+ onchange={(value: string) => setValue(key, value === "true")}
288
+ />
289
+ {:else}
290
+ <Input
291
+ label={String(parameter.label ?? key)}
292
+ name={key}
293
+ value={String(values[key] ?? "")}
294
+ oninput={(event: Event) => setValue(key, (event.target as HTMLInputElement).value)}
295
+ />
296
+ {/if}
297
+ {#if mode === "public-submit"}
298
+ <!-- Citizen-facing: just a quiet required hint, no operator type debug. -->
299
+ {#if parameter.required}<p class="field-meta">Required</p>{/if}
300
+ {:else}
301
+ <p class="field-meta">
302
+ {String(parameter.required ? "Required" : "Optional")} / {type}
303
+ </p>
304
+ {/if}
305
+ {/snippet}
306
+
307
+ <div class="renderer" data-testid={`action-form-renderer-${mode}`} data-layout={resolvedLayout.key}>
308
+ <div class="renderer-header">
309
+ <div>
310
+ <!-- The placement-preview eyebrow + surface badge are operator chrome —
311
+ hidden in public-submit so a citizen sees a clean form title. -->
312
+ {#if mode !== "public-submit"}
313
+ <p class="eyebrow">Placement preview</p>
314
+ {/if}
315
+ <h3>{String(renderedAction?.label ?? renderedAction?.key ?? "Select an action")}</h3>
316
+ </div>
317
+ {#if mode !== "public-submit"}
318
+ <Badge variant={renderedPlacement ? "info" : "neutral"}>
319
+ {String(renderedPlacement?.surface ?? "No placement")}
320
+ </Badge>
321
+ {/if}
322
+ </div>
323
+
324
+ {#if !renderedAction}
325
+ <p class="muted">Select an action to preview its placement-aware form contract.</p>
326
+ {:else if orderedParameters.length === 0}
327
+ <p class="muted">This action has no fields yet.</p>
328
+ {:else}
329
+ {@const Layout = resolvedLayout.component}
330
+ <form class="rendered-form">
331
+ <Layout {sections} field={fieldRow} />
332
+ </form>
333
+ {/if}
334
+
335
+ {#if mode === "admin-preview"}
336
+ <div class="admin-preview">
337
+ <section class="preview-block">
338
+ <h4>Hidden and defaulted fields</h4>
339
+ {#if hiddenOrDefaultedParameters.length}
340
+ <div class="tag-grid">
341
+ {#each hiddenOrDefaultedParameters as parameter (parameterKey(parameter))}
342
+ <Tag>
343
+ {String(parameter.key)}
344
+ {#if !isVisible(parameter)}
345
+ / hidden{/if}
346
+ {#if parameter.default_value !== null && parameter.default_value !== undefined}
347
+ / defaulted
348
+ {/if}
349
+ </Tag>
350
+ {/each}
351
+ </div>
352
+ {:else}
353
+ <p class="muted">No hidden or defaulted fields declared.</p>
354
+ {/if}
355
+ </section>
356
+
357
+ <section class="preview-block">
358
+ <h4>Placement consequences</h4>
359
+ <dl class="consequence-list">
360
+ {#each placementConsequenceRows() as row}
361
+ <div>
362
+ <dt>{row.label}</dt>
363
+ <dd>{row.value}</dd>
364
+ </div>
365
+ {/each}
366
+ </dl>
367
+ </section>
368
+
369
+ <section class="preview-block">
370
+ <h4>Validation used by server</h4>
371
+ {#if criteriaSummary.length}
372
+ <div class="tag-grid">
373
+ {#each criteriaSummary as criterion (String(criterion.id))}
374
+ <Tag>{String(criterion.criteria_type)} / {String(criterion.key)}</Tag>
375
+ {/each}
376
+ </div>
377
+ {:else}
378
+ <p class="muted">No submission criteria configured yet.</p>
379
+ {/if}
380
+ </section>
381
+
382
+ <section class="preview-block">
383
+ <h4>Normalized payload</h4>
384
+ <CodeBlock code={payloadJson} language="json" lineNumbers={false} copyable={false} />
385
+ </section>
386
+ </div>
387
+ {/if}
388
+ </div>
389
+
390
+ <style>
391
+ .renderer {
392
+ display: flex;
393
+ flex-direction: column;
394
+ gap: var(--space-lg);
395
+ }
396
+
397
+ .renderer-header {
398
+ display: flex;
399
+ align-items: flex-start;
400
+ justify-content: space-between;
401
+ flex-wrap: wrap;
402
+ gap: var(--space-md);
403
+ }
404
+
405
+ .renderer-header h3 {
406
+ margin: 0;
407
+ }
408
+
409
+ .eyebrow {
410
+ margin: 0 0 var(--space-xs);
411
+ color: var(--color-text-muted);
412
+ font-size: var(--type-caption-size);
413
+ /* #63 typography — uppercase reserved for code tokens. */
414
+ }
415
+
416
+ .muted,
417
+ .field-meta {
418
+ color: var(--color-text-muted);
419
+ }
420
+
421
+ .rendered-form,
422
+ .admin-preview,
423
+ .preview-block {
424
+ display: flex;
425
+ flex-direction: column;
426
+ gap: var(--space-md);
427
+ }
428
+
429
+ .preview-block h4 {
430
+ margin: 0;
431
+ font-size: var(--type-body-size);
432
+ }
433
+
434
+ .field-meta {
435
+ margin: 0;
436
+ font-size: var(--type-caption-size);
437
+ }
438
+
439
+ .preview-block {
440
+ padding-top: var(--space-md);
441
+ border-top: var(--elevation-border);
442
+ }
443
+
444
+ .tag-grid {
445
+ display: flex;
446
+ flex-wrap: wrap;
447
+ gap: var(--space-xs);
448
+ }
449
+
450
+ .consequence-list {
451
+ display: grid;
452
+ gap: var(--space-sm);
453
+ margin: 0;
454
+ }
455
+
456
+ .consequence-list div {
457
+ display: grid;
458
+ grid-template-columns: minmax(calc(var(--space-4xl) * 2), 0.4fr) minmax(0, 1fr);
459
+ gap: var(--space-md);
460
+ }
461
+
462
+ .consequence-list dt {
463
+ color: var(--color-text-muted);
464
+ }
465
+
466
+ .consequence-list dd {
467
+ margin: 0;
468
+ word-break: break-word;
469
+ }
470
+ </style>
@@ -0,0 +1,35 @@
1
+ import { type RendererMode } from "./action-form-renderer-payload";
2
+ /**
3
+ * The renderer treats every parameter/action/placement as a loose record
4
+ * (it reads optional fields defensively). It used to import the admin app's
5
+ * `Entity`; in the DS it carries its own structural alias so it has no app
6
+ * dependency. The consuming app may pass its richer `Entity` — structurally
7
+ * compatible.
8
+ */
9
+ type Entity = Record<string, unknown>;
10
+ type ActionSchema = {
11
+ found?: boolean;
12
+ schema_version?: string;
13
+ action?: Entity | null;
14
+ placement?: Entity | null;
15
+ target?: Record<string, unknown> | null;
16
+ source?: Record<string, unknown> | null;
17
+ sections?: Array<{
18
+ key?: string;
19
+ label?: string;
20
+ parameters?: Entity[];
21
+ }>;
22
+ parameters?: Entity[];
23
+ criteria?: Entity[];
24
+ };
25
+ interface Props {
26
+ action: Entity | null;
27
+ placement?: Entity | null;
28
+ parameters?: Entity[];
29
+ criteria?: Entity[];
30
+ mode?: RendererMode;
31
+ schema?: ActionSchema | null;
32
+ }
33
+ declare const ActionFormRenderer: import("svelte").Component<Props, {}, "">;
34
+ type ActionFormRenderer = ReturnType<typeof ActionFormRenderer>;
35
+ export default ActionFormRenderer;
@@ -0,0 +1,72 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Compact-mobile layout (#27 / S7).
4
+ *
5
+ * Sections collapse into a single tight column with reduced padding
6
+ * and smaller meta. Used by `public_submit` placements that target
7
+ * narrow viewports (citizen mobile portal). Structurally a single
8
+ * column like stacked-default, but distinguishable by reduced gap +
9
+ * absent section card chrome — section labels become inline headings
10
+ * so the form reads denser on mobile.
11
+ */
12
+ import type { LayoutSection } from "./action-form-renderer-layouts";
13
+ import type { Snippet } from "svelte";
14
+
15
+ let {
16
+ sections,
17
+ field,
18
+ }: {
19
+ sections: LayoutSection[];
20
+ field: Snippet<[Record<string, unknown>]>;
21
+ } = $props();
22
+ </script>
23
+
24
+ <div class="layout-compact" data-layout-shape="compact">
25
+ {#each sections as section (section.name)}
26
+ <section class="compact-section" aria-label={section.name}>
27
+ <h5>{section.name}</h5>
28
+ {#each section.items as parameter}
29
+ <div class="field-row">
30
+ {@render field(parameter)}
31
+ </div>
32
+ {/each}
33
+ </section>
34
+ {/each}
35
+ </div>
36
+
37
+ <style>
38
+ /* Single tight column — distinguishable from stacked-default by:
39
+ 1. No Card chrome around sections (HTML <section>, not DS Card),
40
+ 2. Compact gap and padding,
41
+ 3. <h5> heading not <h4>. */
42
+ .layout-compact {
43
+ display: flex;
44
+ flex-direction: column;
45
+ gap: var(--space-sm);
46
+ }
47
+
48
+ .compact-section {
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: var(--space-xs);
52
+ padding: var(--space-xs) 0;
53
+ border-bottom: var(--elevation-border);
54
+ }
55
+
56
+ .compact-section:last-child {
57
+ border-bottom: 0;
58
+ }
59
+
60
+ .compact-section h5 {
61
+ margin: 0;
62
+ font-size: var(--type-caption-size);
63
+ /* #63 typography — uppercase reserved for code tokens. */
64
+ color: var(--color-text-muted);
65
+ }
66
+
67
+ .field-row {
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: 2px;
71
+ }
72
+ </style>
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Compact-mobile layout (#27 / S7).
3
+ *
4
+ * Sections collapse into a single tight column with reduced padding
5
+ * and smaller meta. Used by `public_submit` placements that target
6
+ * narrow viewports (citizen mobile portal). Structurally a single
7
+ * column like stacked-default, but distinguishable by reduced gap +
8
+ * absent section card chrome — section labels become inline headings
9
+ * so the form reads denser on mobile.
10
+ */
11
+ import type { LayoutSection } from "./action-form-renderer-layouts";
12
+ import type { Snippet } from "svelte";
13
+ type $$ComponentProps = {
14
+ sections: LayoutSection[];
15
+ field: Snippet<[Record<string, unknown>]>;
16
+ };
17
+ declare const LayoutCompactMobile: import("svelte").Component<$$ComponentProps, {}, "">;
18
+ type LayoutCompactMobile = ReturnType<typeof LayoutCompactMobile>;
19
+ export default LayoutCompactMobile;
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Inline-row layout (#27 / S7).
4
+ *
5
+ * Parameters in each section are arranged in a single horizontal row
6
+ * using auto-fit grid, suitable for compact admin runtime forms
7
+ * (one-line filters, narrow inline panels). This is structurally
8
+ * distinct from stacked-default — same parameter set produces a grid
9
+ * with multiple columns instead of a vertical column. The S7
10
+ * acceptance asserts the structural difference.
11
+ */
12
+ import Card from "./Card.svelte";
13
+ import type { LayoutSection } from "./action-form-renderer-layouts";
14
+ import type { Snippet } from "svelte";
15
+
16
+ let {
17
+ sections,
18
+ field,
19
+ }: {
20
+ sections: LayoutSection[];
21
+ field: Snippet<[Record<string, unknown>]>;
22
+ } = $props();
23
+ </script>
24
+
25
+ <div class="layout-inline" data-layout-shape="inline">
26
+ {#each sections as section (section.name)}
27
+ <Card variant="flat" class="section" role="group" aria-label={section.name}>
28
+ <h4>{section.name}</h4>
29
+ <div class="row">
30
+ {#each section.items as parameter}
31
+ <div class="field-cell">
32
+ {@render field(parameter)}
33
+ </div>
34
+ {/each}
35
+ </div>
36
+ </Card>
37
+ {/each}
38
+ </div>
39
+
40
+ <style>
41
+ .layout-inline {
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: var(--space-md);
45
+ }
46
+
47
+ .layout-inline :global(.section) {
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: var(--space-sm);
51
+ }
52
+
53
+ /* The defining shape — auto-fit columns, NOT a vertical stack. The
54
+ same parameter set renders as a multi-column grid here vs. one
55
+ column in stacked-default. */
56
+ .row {
57
+ display: grid;
58
+ grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
59
+ gap: var(--space-md);
60
+ align-items: end;
61
+ }
62
+
63
+ .field-cell {
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: var(--space-xs);
67
+ min-width: 0;
68
+ }
69
+
70
+ h4 {
71
+ margin: 0;
72
+ font-size: var(--type-body-size);
73
+ }
74
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { LayoutSection } from "./action-form-renderer-layouts";
2
+ import type { Snippet } from "svelte";
3
+ type $$ComponentProps = {
4
+ sections: LayoutSection[];
5
+ field: Snippet<[Record<string, unknown>]>;
6
+ };
7
+ declare const LayoutInlineRow: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type LayoutInlineRow = ReturnType<typeof LayoutInlineRow>;
9
+ export default LayoutInlineRow;
@@ -0,0 +1,66 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Default vertical-stack layout for the action renderer (#27 / S7).
4
+ *
5
+ * Each section is a labelled <fieldset>, parameters laid out as a
6
+ * single-column flex column. This is the safe fallback for unknown
7
+ * layout keys — see `resolve.ts`.
8
+ */
9
+ import Card from "./Card.svelte";
10
+ import type { LayoutSection } from "./action-form-renderer-layouts";
11
+ import type { Snippet } from "svelte";
12
+
13
+ let {
14
+ sections,
15
+ field,
16
+ }: {
17
+ sections: LayoutSection[];
18
+ field: Snippet<[Record<string, unknown>]>;
19
+ } = $props();
20
+ </script>
21
+
22
+ <div class="layout-stacked" data-layout-shape="stacked">
23
+ {#each sections as section (section.name)}
24
+ <Card variant="flat" class="section" role="group" aria-label={section.name}>
25
+ <h4>{section.name}</h4>
26
+ <div class="rows">
27
+ {#each section.items as parameter}
28
+ <div class="field-row">
29
+ {@render field(parameter)}
30
+ </div>
31
+ {/each}
32
+ </div>
33
+ </Card>
34
+ {/each}
35
+ </div>
36
+
37
+ <style>
38
+ .layout-stacked {
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: var(--space-md);
42
+ }
43
+
44
+ .layout-stacked :global(.section) {
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: var(--space-sm);
48
+ }
49
+
50
+ .rows {
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: var(--space-md);
54
+ }
55
+
56
+ .field-row {
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: var(--space-xs);
60
+ }
61
+
62
+ h4 {
63
+ margin: 0;
64
+ font-size: var(--type-body-size);
65
+ }
66
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { LayoutSection } from "./action-form-renderer-layouts";
2
+ import type { Snippet } from "svelte";
3
+ type $$ComponentProps = {
4
+ sections: LayoutSection[];
5
+ field: Snippet<[Record<string, unknown>]>;
6
+ };
7
+ declare const LayoutStackedDefault: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type LayoutStackedDefault = ReturnType<typeof LayoutStackedDefault>;
9
+ export default LayoutStackedDefault;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Layout dispatcher for the action renderer (#27 / S7).
3
+ *
4
+ * `target_config.layout_key` selects which arrangement an
5
+ * `ActionFormRenderer` uses to lay out its parameter rows. This module
6
+ * is the single source of truth for the registry. Anything outside the
7
+ * registry — null, undefined, empty string, "wizard", a typo, an
8
+ * operator's free-text — falls back to `stacked-default`.
9
+ *
10
+ * Locked by spec rabbit hole #11:
11
+ * - Exactly three entries: stacked-default, inline-row, compact-mobile.
12
+ * Adding wizard / two-column is a separate epic.
13
+ * - Frontend-only, no ontology entity. Storage is `target_config.layout_key`.
14
+ *
15
+ * Locked by threat model TH-08 (R-SEC-07):
16
+ * - The operator can write any string into `target_config.layout_key`.
17
+ * The renderer MUST NOT interpolate that string into a `class=`,
18
+ * `data-*`, or `style` attribute. Instead, it consults this
19
+ * dispatcher and uses the registry's known-good `key` (one of three
20
+ * literals) when emitting the `data-layout` attribute. Unknown
21
+ * keys collapse to `stacked-default`, so a malicious or misspelled
22
+ * value never reaches the DOM.
23
+ */
24
+ import type { Component } from "svelte";
25
+ /** Public string set. The output of `resolveLayout(...).key` is always
26
+ * one of these three values, regardless of input. Tests assert this. */
27
+ export type LayoutKey = "stacked-default" | "inline-row" | "compact-mobile";
28
+ export interface LayoutEntry {
29
+ /** The known-good key. Safe to render as `data-layout={key}`. */
30
+ key: LayoutKey;
31
+ /** Svelte component the renderer mounts for this layout. */
32
+ component: Component<LayoutComponentProps>;
33
+ }
34
+ export interface LayoutSection {
35
+ name: string;
36
+ items: Array<Record<string, unknown>>;
37
+ }
38
+ import type { Snippet } from "svelte";
39
+ export interface LayoutComponentProps {
40
+ sections: LayoutSection[];
41
+ /** Snippet that renders one parameter's input control. The renderer
42
+ * passes this in so per-field rendering stays in one place — layouts
43
+ * decide arrangement, not control type. */
44
+ field: Snippet<[Record<string, unknown>]>;
45
+ }
46
+ /** Type guard for known keys. Exported so consumers can branch on
47
+ * membership without re-implementing the literal set. */
48
+ export declare function isKnownLayoutKey(value: unknown): value is LayoutKey;
49
+ /** Resolve any input to a registry entry. Unknown / null / undefined /
50
+ * non-string / typo'd values fall through to `stacked-default`. */
51
+ export declare function resolveLayout(key: unknown): LayoutEntry;
52
+ /** All known keys, in registry order. Useful for picker UIs. */
53
+ export declare const LAYOUT_KEYS: readonly LayoutKey[];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Layout dispatcher for the action renderer (#27 / S7).
3
+ *
4
+ * `target_config.layout_key` selects which arrangement an
5
+ * `ActionFormRenderer` uses to lay out its parameter rows. This module
6
+ * is the single source of truth for the registry. Anything outside the
7
+ * registry — null, undefined, empty string, "wizard", a typo, an
8
+ * operator's free-text — falls back to `stacked-default`.
9
+ *
10
+ * Locked by spec rabbit hole #11:
11
+ * - Exactly three entries: stacked-default, inline-row, compact-mobile.
12
+ * Adding wizard / two-column is a separate epic.
13
+ * - Frontend-only, no ontology entity. Storage is `target_config.layout_key`.
14
+ *
15
+ * Locked by threat model TH-08 (R-SEC-07):
16
+ * - The operator can write any string into `target_config.layout_key`.
17
+ * The renderer MUST NOT interpolate that string into a `class=`,
18
+ * `data-*`, or `style` attribute. Instead, it consults this
19
+ * dispatcher and uses the registry's known-good `key` (one of three
20
+ * literals) when emitting the `data-layout` attribute. Unknown
21
+ * keys collapse to `stacked-default`, so a malicious or misspelled
22
+ * value never reaches the DOM.
23
+ */
24
+ import type { Component } from "svelte";
25
+
26
+ import LayoutCompactMobile from "./LayoutCompactMobile.svelte";
27
+ import LayoutInlineRow from "./LayoutInlineRow.svelte";
28
+ import LayoutStackedDefault from "./LayoutStackedDefault.svelte";
29
+
30
+ /** Public string set. The output of `resolveLayout(...).key` is always
31
+ * one of these three values, regardless of input. Tests assert this. */
32
+ export type LayoutKey = "stacked-default" | "inline-row" | "compact-mobile";
33
+
34
+ export interface LayoutEntry {
35
+ /** The known-good key. Safe to render as `data-layout={key}`. */
36
+ key: LayoutKey;
37
+ /** Svelte component the renderer mounts for this layout. */
38
+ component: Component<LayoutComponentProps>;
39
+ }
40
+
41
+ export interface LayoutSection {
42
+ name: string;
43
+ items: Array<Record<string, unknown>>;
44
+ }
45
+
46
+ import type { Snippet } from "svelte";
47
+
48
+ export interface LayoutComponentProps {
49
+ sections: LayoutSection[];
50
+ /** Snippet that renders one parameter's input control. The renderer
51
+ * passes this in so per-field rendering stays in one place — layouts
52
+ * decide arrangement, not control type. */
53
+ field: Snippet<[Record<string, unknown>]>;
54
+ }
55
+
56
+ /** The registry. Order is the order shown to operators in the
57
+ * AddPlacementPanel select; the first entry is the default. */
58
+ const REGISTRY: Record<LayoutKey, Component<LayoutComponentProps>> = {
59
+ "stacked-default": LayoutStackedDefault as unknown as Component<LayoutComponentProps>,
60
+ "inline-row": LayoutInlineRow as unknown as Component<LayoutComponentProps>,
61
+ "compact-mobile": LayoutCompactMobile as unknown as Component<LayoutComponentProps>,
62
+ };
63
+
64
+ const DEFAULT_KEY: LayoutKey = "stacked-default";
65
+
66
+ /** Type guard for known keys. Exported so consumers can branch on
67
+ * membership without re-implementing the literal set. */
68
+ export function isKnownLayoutKey(value: unknown): value is LayoutKey {
69
+ return typeof value === "string" && value in REGISTRY;
70
+ }
71
+
72
+ /** Resolve any input to a registry entry. Unknown / null / undefined /
73
+ * non-string / typo'd values fall through to `stacked-default`. */
74
+ export function resolveLayout(key: unknown): LayoutEntry {
75
+ if (isKnownLayoutKey(key)) {
76
+ return { key, component: REGISTRY[key] };
77
+ }
78
+ return { key: DEFAULT_KEY, component: REGISTRY[DEFAULT_KEY] };
79
+ }
80
+
81
+ /** All known keys, in registry order. Useful for picker UIs. */
82
+ export const LAYOUT_KEYS: readonly LayoutKey[] = Object.keys(REGISTRY) as LayoutKey[];
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Builds the normalized action payload that ActionFormRenderer renders in
3
+ * its `admin-preview` mode and that any consumer (admin runtime, public
4
+ * portal, Open311 adapter, future apps) sees as the canonical shape.
5
+ *
6
+ * S1 (#27) — generic target shape; no domain-specific keys. The previous
7
+ * `occurrence: { occurrence_type, organization_id }` block has been
8
+ * replaced with `target: { model, scope, id }`. `target.scope` carries
9
+ * everything from the placement's `target_config` minus the `model` and
10
+ * `id` keys (which become first-class). The BFF
11
+ * `_normalized_submission_preview` mirrors this contract.
12
+ *
13
+ * Pure function: takes resolved inputs (action, placement, target_config,
14
+ * source_schema, raw_values, mode), returns the canonical payload. The
15
+ * Svelte component owns the reactive plumbing; this module owns the shape.
16
+ */
17
+ export type RendererMode = "admin-preview" | "admin-execute" | "public-submit" | "adapter-preview";
18
+ /**
19
+ * Minimal action/placement references the payload builder reads. Callers may
20
+ * pass a full ontology Entity; this narrower shape is what we actually rely
21
+ * on, so test stubs don't need to populate Entity's `created_at`/`updated_at`.
22
+ */
23
+ export interface ActionRef {
24
+ id?: string | null;
25
+ key?: string | null;
26
+ status?: string | null;
27
+ target_model?: string | null;
28
+ [extra: string]: unknown;
29
+ }
30
+ export interface PlacementRef extends ActionRef {
31
+ surface?: string | null;
32
+ }
33
+ export interface BuildArgs {
34
+ action: ActionRef | null;
35
+ placement: PlacementRef | null;
36
+ /** Resolved from schema?.target ?? placement?.target_config. */
37
+ targetConfig: Record<string, unknown>;
38
+ /** Resolved from schema?.source ?? placement?.source_schema. */
39
+ sourceSchema: Record<string, unknown>;
40
+ /** Submitted values keyed by parameter source_field_path or key. */
41
+ rawValues: Record<string, unknown>;
42
+ schemaVersion: string | null;
43
+ mode: RendererMode;
44
+ }
45
+ export interface ActionPayload {
46
+ source: string;
47
+ action: {
48
+ id: string | null;
49
+ key: string | null;
50
+ status: string | null;
51
+ };
52
+ placement: {
53
+ id: string | null;
54
+ key: string | null;
55
+ surface: string;
56
+ status: string | null;
57
+ } | null;
58
+ target: {
59
+ model: string | null;
60
+ scope: Record<string, unknown>;
61
+ id: string | null;
62
+ };
63
+ form: {
64
+ action_type_key: string | null;
65
+ action_placement_key: string | null;
66
+ raw_values: Record<string, unknown>;
67
+ };
68
+ metadata: {
69
+ renderer_mode: RendererMode;
70
+ schema_version: string | null;
71
+ target_model: string | null;
72
+ form_definition_id: string | null;
73
+ };
74
+ }
75
+ export declare function buildActionPayload(args: BuildArgs): ActionPayload;
76
+ /**
77
+ * Generic placement-consequence rows for the admin preview "Placement
78
+ * consequences" section. Replaces the old "Occurrence type" hardcoded
79
+ * row with a per-scope-key row, derived from target_config.
80
+ */
81
+ export interface ConsequenceRow {
82
+ label: string;
83
+ value: string;
84
+ }
85
+ export declare function placementConsequenceRows(args: {
86
+ placement: PlacementRef | null;
87
+ targetConfig: Record<string, unknown>;
88
+ sourceSchema: Record<string, unknown>;
89
+ mode: RendererMode;
90
+ }): ConsequenceRow[];
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Builds the normalized action payload that ActionFormRenderer renders in
3
+ * its `admin-preview` mode and that any consumer (admin runtime, public
4
+ * portal, Open311 adapter, future apps) sees as the canonical shape.
5
+ *
6
+ * S1 (#27) — generic target shape; no domain-specific keys. The previous
7
+ * `occurrence: { occurrence_type, organization_id }` block has been
8
+ * replaced with `target: { model, scope, id }`. `target.scope` carries
9
+ * everything from the placement's `target_config` minus the `model` and
10
+ * `id` keys (which become first-class). The BFF
11
+ * `_normalized_submission_preview` mirrors this contract.
12
+ *
13
+ * Pure function: takes resolved inputs (action, placement, target_config,
14
+ * source_schema, raw_values, mode), returns the canonical payload. The
15
+ * Svelte component owns the reactive plumbing; this module owns the shape.
16
+ */
17
+ export type RendererMode = "admin-preview" | "admin-execute" | "public-submit" | "adapter-preview";
18
+
19
+ /**
20
+ * Minimal action/placement references the payload builder reads. Callers may
21
+ * pass a full ontology Entity; this narrower shape is what we actually rely
22
+ * on, so test stubs don't need to populate Entity's `created_at`/`updated_at`.
23
+ */
24
+ export interface ActionRef {
25
+ id?: string | null;
26
+ key?: string | null;
27
+ status?: string | null;
28
+ target_model?: string | null;
29
+ [extra: string]: unknown;
30
+ }
31
+
32
+ export interface PlacementRef extends ActionRef {
33
+ surface?: string | null;
34
+ }
35
+
36
+ export interface BuildArgs {
37
+ action: ActionRef | null;
38
+ placement: PlacementRef | null;
39
+ /** Resolved from schema?.target ?? placement?.target_config. */
40
+ targetConfig: Record<string, unknown>;
41
+ /** Resolved from schema?.source ?? placement?.source_schema. */
42
+ sourceSchema: Record<string, unknown>;
43
+ /** Submitted values keyed by parameter source_field_path or key. */
44
+ rawValues: Record<string, unknown>;
45
+ schemaVersion: string | null;
46
+ mode: RendererMode;
47
+ }
48
+
49
+ export interface ActionPayload {
50
+ source: string;
51
+ action: {
52
+ id: string | null;
53
+ key: string | null;
54
+ status: string | null;
55
+ };
56
+ placement: {
57
+ id: string | null;
58
+ key: string | null;
59
+ surface: string;
60
+ status: string | null;
61
+ } | null;
62
+ target: {
63
+ model: string | null;
64
+ scope: Record<string, unknown>;
65
+ id: string | null;
66
+ };
67
+ form: {
68
+ action_type_key: string | null;
69
+ action_placement_key: string | null;
70
+ raw_values: Record<string, unknown>;
71
+ };
72
+ metadata: {
73
+ renderer_mode: RendererMode;
74
+ schema_version: string | null;
75
+ target_model: string | null;
76
+ form_definition_id: string | null;
77
+ };
78
+ }
79
+
80
+ const RESERVED_TARGET_KEYS = new Set(["model", "id"]);
81
+
82
+ function nullableString(value: unknown): string | null {
83
+ if (value === null || value === undefined || value === "") return null;
84
+ return String(value);
85
+ }
86
+
87
+ function defaultSourceFor(mode: RendererMode): string {
88
+ return mode === "public-submit" ? "citizen" : "admin";
89
+ }
90
+
91
+ function defaultSurfaceFor(mode: RendererMode): string {
92
+ return mode === "public-submit" ? "public_submit" : "admin_preview";
93
+ }
94
+
95
+ function pickScope(targetConfig: Record<string, unknown>): Record<string, unknown> {
96
+ const scope: Record<string, unknown> = {};
97
+ for (const [key, value] of Object.entries(targetConfig)) {
98
+ if (RESERVED_TARGET_KEYS.has(key)) continue;
99
+ scope[key] = value;
100
+ }
101
+ return scope;
102
+ }
103
+
104
+ export function buildActionPayload(args: BuildArgs): ActionPayload {
105
+ const { action, placement, targetConfig, sourceSchema, rawValues, schemaVersion, mode } = args;
106
+
107
+ const sourceFromSchema = nullableString(sourceSchema.source);
108
+ const targetModel =
109
+ nullableString(targetConfig.model) ??
110
+ nullableString(placement?.target_model) ??
111
+ nullableString(action?.target_model);
112
+
113
+ const placementBlock = placement
114
+ ? {
115
+ id: nullableString(placement.id),
116
+ key: nullableString(placement.key),
117
+ surface: String(placement.surface ?? defaultSurfaceFor(mode)),
118
+ status: nullableString(placement.status),
119
+ }
120
+ : null;
121
+
122
+ return {
123
+ source: sourceFromSchema ?? defaultSourceFor(mode),
124
+ action: {
125
+ id: nullableString(action?.id),
126
+ key: nullableString(action?.key),
127
+ status: nullableString(action?.status),
128
+ },
129
+ placement: placementBlock,
130
+ target: {
131
+ model: targetModel,
132
+ scope: pickScope(targetConfig),
133
+ id: nullableString(targetConfig.id),
134
+ },
135
+ form: {
136
+ action_type_key: nullableString(action?.key),
137
+ action_placement_key: nullableString(placement?.key),
138
+ raw_values: rawValues,
139
+ },
140
+ metadata: {
141
+ renderer_mode: mode,
142
+ schema_version: schemaVersion,
143
+ target_model: targetModel,
144
+ form_definition_id: nullableString(targetConfig.form_definition_id),
145
+ },
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Generic placement-consequence rows for the admin preview "Placement
151
+ * consequences" section. Replaces the old "Occurrence type" hardcoded
152
+ * row with a per-scope-key row, derived from target_config.
153
+ */
154
+ export interface ConsequenceRow {
155
+ label: string;
156
+ value: string;
157
+ }
158
+
159
+ export function placementConsequenceRows(args: {
160
+ placement: PlacementRef | null;
161
+ targetConfig: Record<string, unknown>;
162
+ sourceSchema: Record<string, unknown>;
163
+ mode: RendererMode;
164
+ }): ConsequenceRow[] {
165
+ const { placement, targetConfig, sourceSchema, mode } = args;
166
+ const rows: ConsequenceRow[] = [
167
+ {
168
+ label: "Surface",
169
+ value: String(placement?.surface ?? "No placement selected"),
170
+ },
171
+ {
172
+ label: "Accepted source",
173
+ value: String(sourceSchema.source ?? defaultSourceFor(mode)),
174
+ },
175
+ {
176
+ label: "Target model",
177
+ value: String(targetConfig.model ?? placement?.target_model ?? "-"),
178
+ },
179
+ ];
180
+
181
+ const scope = pickScope(targetConfig);
182
+ const scopeEntries = Object.entries(scope).filter(
183
+ ([key]) => key !== "form_definition_id" && key !== "form_definition_key",
184
+ );
185
+ if (scopeEntries.length === 0) {
186
+ rows.push({ label: "Scope", value: "Any row of model" });
187
+ } else {
188
+ for (const [key, value] of scopeEntries) {
189
+ rows.push({ label: `Scope · ${key}`, value: String(value ?? "-") });
190
+ }
191
+ }
192
+
193
+ rows.push({
194
+ label: "Form definition",
195
+ value: String(
196
+ targetConfig.form_definition_key ??
197
+ targetConfig.form_definition_id ??
198
+ "Inherit (auto-derived)",
199
+ ),
200
+ });
201
+
202
+ return rows;
203
+ }
@@ -122,3 +122,6 @@ export { default as GeoSearch } from "./GeoSearch.svelte";
122
122
 
123
123
  // Scheduling
124
124
  export { default as Calendar } from "./Calendar.svelte";
125
+
126
+ // Action form renderer (placement-aware; admin preview + portal runtime, #73)
127
+ export { default as ActionFormRenderer } from "./ActionFormRenderer.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,6 +36,7 @@
36
36
  "default": "./components/*.svelte"
37
37
  },
38
38
  "./components/*": {
39
+ "types": "./components/*.d.ts",
39
40
  "svelte": "./components/*",
40
41
  "default": "./components/*"
41
42
  },