@aiaiai-pt/design-system 0.6.0 → 0.8.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.
@@ -27,6 +27,21 @@
27
27
 
28
28
  @example Loading
29
29
  <DataTable {columns} rows={[]} loading />
30
+
31
+ @example Custom cell rendering (badges, chips, components)
32
+ Pass a `cell` snippet to override the default per-cell text rendering.
33
+ The default behavior (using `column.render` to produce a string) is
34
+ preserved when no `cell` snippet is provided.
35
+
36
+ <DataTable {columns} {rows}>
37
+ {#snippet cell({ row, column, value })}
38
+ {#if column.key === 'status'}
39
+ <Badge variant={statusVariant(value)}>{value}</Badge>
40
+ {:else}
41
+ {value ?? '-'}
42
+ {/if}
43
+ {/snippet}
44
+ </DataTable>
30
45
  -->
31
46
  <script>
32
47
  /**
@@ -65,6 +80,23 @@
65
80
  on_select = undefined,
66
81
  /** @type {((row: Record<string, unknown>) => void) | undefined} */
67
82
  on_row_click = undefined,
83
+ /**
84
+ * Optional per-cell render override. When provided, called for every td
85
+ * with `{ row, column, value }`; the snippet's output replaces the
86
+ * default text rendering. When omitted, the default text path runs
87
+ * (`column.render` then `String(value)`) so existing consumers keep
88
+ * working unchanged.
89
+ *
90
+ * Why a snippet rather than passing a Svelte component class via
91
+ * `column.render`: snippets accept the full row context (not just the
92
+ * value), let consumers compose any DS primitive (Badge, Tag, Status,
93
+ * Button) without bringing those imports into the DS surface, and
94
+ * Svelte 5's snippet API is the canonical way to parameterize child
95
+ * rendering. `column.render` stays for the common string case.
96
+ *
97
+ * @type {import('svelte').Snippet<[{ row: Record<string, unknown>, column: ColumnDef, value: unknown }]> | undefined}
98
+ */
99
+ cell = undefined,
68
100
  /** @type {import('svelte').Snippet | undefined} */
69
101
  children = undefined,
70
102
  /** @type {string} */
@@ -258,7 +290,11 @@
258
290
  {/if}
259
291
  {#each columns as col}
260
292
  <td class="table-td">
261
- {render_cell(col, row[col.key], row)}
293
+ {#if cell}
294
+ {@render cell({ row, column: col, value: row[col.key] })}
295
+ {:else}
296
+ {render_cell(col, row[col.key], row)}
297
+ {/if}
262
298
  </td>
263
299
  {/each}
264
300
  </tr>
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Convert a ValueSourceValue into wire form ready to persist.
3
+ *
4
+ * @param {ValueSourceValue} v
5
+ * @returns {SerializedValueSource}
6
+ */
7
+ export function serializeValueSource(v: ValueSourceValue): SerializedValueSource;
8
+ /**
9
+ * Convert wire form back into a ValueSourceValue.
10
+ * Returns null when value is null/undefined and no functionArgs were supplied.
11
+ *
12
+ * @param {unknown} wire
13
+ * @param {Record<string, unknown> | null | undefined} [functionArgs]
14
+ * @returns {ValueSourceValue | null}
15
+ */
16
+ export function parseValueSource(wire: unknown, functionArgs?: Record<string, unknown> | null | undefined): ValueSourceValue | null;
17
+ export namespace _internal {
18
+ export { CREATED_REF_RE };
19
+ }
20
+ export type ValueSourceMode = "literal" | "parameter" | "entity-field" | "user-field" | "now" | "source-id" | "created-field" | "config-list" | "function" | "expression";
21
+ export type ValueSourceValue = ({
22
+ mode: "literal";
23
+ value: string | number | boolean | null;
24
+ } | {
25
+ mode: "parameter";
26
+ key: string;
27
+ } | {
28
+ mode: "entity-field";
29
+ field: string;
30
+ } | {
31
+ mode: "user-field";
32
+ key: string;
33
+ } | {
34
+ mode: "now";
35
+ } | {
36
+ mode: "source-id";
37
+ } | {
38
+ mode: "created-field";
39
+ index: number;
40
+ field: string;
41
+ } | {
42
+ mode: "config-list";
43
+ configType: string;
44
+ } | {
45
+ mode: "function";
46
+ name: string;
47
+ args: Record<string, ValueSourceValue>;
48
+ } | {
49
+ mode: "expression";
50
+ expr: string;
51
+ });
52
+ export type SerializedValueSource = {
53
+ /**
54
+ * The wire scalar/string going into the slot
55
+ * (e.g. ActionEdit.value, ParamValuesSource.config_query.filter[key]).
56
+ */
57
+ value: unknown;
58
+ /**
59
+ * Resolved function args dict — only set when v.mode === 'function'.
60
+ * Values are wire scalars/strings (recursively serialized).
61
+ */
62
+ functionArgs?: Record<string, unknown> | undefined;
63
+ };
64
+ /**
65
+ * Helpers for ValueSourcePicker — translate between the picker's
66
+ * structured ValueSourceValue object and the wire format the action
67
+ * engine reads (admin-api/app/actions/refs.py and edits.py).
68
+ *
69
+ * Mode catalog and wire formats — see
70
+ * dev_docs/specs/action-editor-parity-value-source-picker.md §3.
71
+ */
72
+ /**
73
+ * @typedef {'literal' | 'parameter' | 'entity-field' | 'user-field'
74
+ * | 'now' | 'source-id' | 'created-field'
75
+ * | 'config-list' | 'function' | 'expression'} ValueSourceMode
76
+ */
77
+ /**
78
+ * @typedef {(
79
+ * { mode: 'literal', value: string | number | boolean | null }
80
+ * | { mode: 'parameter', key: string }
81
+ * | { mode: 'entity-field', field: string }
82
+ * | { mode: 'user-field', key: string }
83
+ * | { mode: 'now' }
84
+ * | { mode: 'source-id' }
85
+ * | { mode: 'created-field', index: number, field: string }
86
+ * | { mode: 'config-list', configType: string }
87
+ * | { mode: 'function', name: string, args: Record<string, ValueSourceValue> }
88
+ * | { mode: 'expression', expr: string }
89
+ * )} ValueSourceValue
90
+ */
91
+ /**
92
+ * @typedef {Object} SerializedValueSource
93
+ * @property {unknown} value
94
+ * The wire scalar/string going into the slot
95
+ * (e.g. ActionEdit.value, ParamValuesSource.config_query.filter[key]).
96
+ * @property {Record<string, unknown> | undefined} [functionArgs]
97
+ * Resolved function args dict — only set when v.mode === 'function'.
98
+ * Values are wire scalars/strings (recursively serialized).
99
+ */
100
+ declare const CREATED_REF_RE: RegExp;
101
+ export {};
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Helpers for ValueSourcePicker — translate between the picker's
3
+ * structured ValueSourceValue object and the wire format the action
4
+ * engine reads (admin-api/app/actions/refs.py and edits.py).
5
+ *
6
+ * Mode catalog and wire formats — see
7
+ * dev_docs/specs/action-editor-parity-value-source-picker.md §3.
8
+ */
9
+
10
+ /**
11
+ * @typedef {'literal' | 'parameter' | 'entity-field' | 'user-field'
12
+ * | 'now' | 'source-id' | 'created-field'
13
+ * | 'config-list' | 'function' | 'expression'} ValueSourceMode
14
+ */
15
+
16
+ /**
17
+ * @typedef {(
18
+ * { mode: 'literal', value: string | number | boolean | null }
19
+ * | { mode: 'parameter', key: string }
20
+ * | { mode: 'entity-field', field: string }
21
+ * | { mode: 'user-field', key: string }
22
+ * | { mode: 'now' }
23
+ * | { mode: 'source-id' }
24
+ * | { mode: 'created-field', index: number, field: string }
25
+ * | { mode: 'config-list', configType: string }
26
+ * | { mode: 'function', name: string, args: Record<string, ValueSourceValue> }
27
+ * | { mode: 'expression', expr: string }
28
+ * )} ValueSourceValue
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} SerializedValueSource
33
+ * @property {unknown} value
34
+ * The wire scalar/string going into the slot
35
+ * (e.g. ActionEdit.value, ParamValuesSource.config_query.filter[key]).
36
+ * @property {Record<string, unknown> | undefined} [functionArgs]
37
+ * Resolved function args dict — only set when v.mode === 'function'.
38
+ * Values are wire scalars/strings (recursively serialized).
39
+ */
40
+
41
+ const CREATED_REF_RE = /^\$created\.(\d+)\.(\w+)$/;
42
+
43
+ /**
44
+ * Convert a ValueSourceValue into wire form ready to persist.
45
+ *
46
+ * @param {ValueSourceValue} v
47
+ * @returns {SerializedValueSource}
48
+ */
49
+ export function serializeValueSource(v) {
50
+ switch (v.mode) {
51
+ case "literal":
52
+ return { value: v.value };
53
+ case "parameter":
54
+ return { value: `$parameters.${v.key}` };
55
+ case "entity-field":
56
+ return { value: `$entity.${v.field}` };
57
+ case "user-field":
58
+ return { value: `$user.${v.key}` };
59
+ case "now":
60
+ return { value: "$now" };
61
+ case "source-id":
62
+ return { value: "$source.id" };
63
+ case "created-field":
64
+ return { value: `$created.${v.index}.${v.field}` };
65
+ case "config-list":
66
+ return { value: `$config.${v.configType}` };
67
+ case "expression":
68
+ return { value: `$expr(${v.expr})` };
69
+ case "function": {
70
+ /** @type {Record<string, unknown>} */
71
+ const functionArgs = {};
72
+ for (const [argKey, argValue] of Object.entries(v.args)) {
73
+ functionArgs[argKey] = serializeValueSource(argValue).value;
74
+ }
75
+ return { value: `$function.${v.name}`, functionArgs };
76
+ }
77
+ default: {
78
+ const exhaustive = /** @type {never} */ (v);
79
+ throw new Error(
80
+ `serializeValueSource: unknown mode ${JSON.stringify(exhaustive)}`,
81
+ );
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Convert wire form back into a ValueSourceValue.
88
+ * Returns null when value is null/undefined and no functionArgs were supplied.
89
+ *
90
+ * @param {unknown} wire
91
+ * @param {Record<string, unknown> | null | undefined} [functionArgs]
92
+ * @returns {ValueSourceValue | null}
93
+ */
94
+ export function parseValueSource(wire, functionArgs) {
95
+ if (wire === undefined) {
96
+ // Slot was absent from the stored JSON — picker shows empty.
97
+ return null;
98
+ }
99
+ if (wire === null) {
100
+ // Engine treats null wire as literal null (refs.py:42 falls through).
101
+ return { mode: "literal", value: null };
102
+ }
103
+
104
+ if (typeof wire !== "string") {
105
+ // Non-string scalar (number / bool) → literal as-is.
106
+ return { mode: "literal", value: /** @type {any} */ (wire) };
107
+ }
108
+
109
+ // Bare $now / $source.id (exact match).
110
+ if (wire === "$now") return { mode: "now" };
111
+ if (wire === "$source.id") return { mode: "source-id" };
112
+
113
+ // Prefixed refs.
114
+ if (wire.startsWith("$parameters.")) {
115
+ return { mode: "parameter", key: wire.slice("$parameters.".length) };
116
+ }
117
+ if (wire.startsWith("$entity.")) {
118
+ return { mode: "entity-field", field: wire.slice("$entity.".length) };
119
+ }
120
+ if (wire.startsWith("$user.")) {
121
+ return { mode: "user-field", key: wire.slice("$user.".length) };
122
+ }
123
+ if (wire.startsWith("$config.")) {
124
+ return { mode: "config-list", configType: wire.slice("$config.".length) };
125
+ }
126
+ if (wire.startsWith("$function.")) {
127
+ const name = wire.slice("$function.".length);
128
+ /** @type {Record<string, ValueSourceValue>} */
129
+ const args = {};
130
+ if (functionArgs) {
131
+ for (const [argKey, argWire] of Object.entries(functionArgs)) {
132
+ const parsed = parseValueSource(argWire);
133
+ if (parsed !== null) {
134
+ args[argKey] = parsed;
135
+ }
136
+ }
137
+ }
138
+ return { mode: "function", name, args };
139
+ }
140
+ if (wire.startsWith("$expr(") && wire.endsWith(")")) {
141
+ return { mode: "expression", expr: wire.slice("$expr(".length, -1) };
142
+ }
143
+ const createdMatch = CREATED_REF_RE.exec(wire);
144
+ if (createdMatch) {
145
+ return {
146
+ mode: "created-field",
147
+ index: Number.parseInt(createdMatch[1], 10),
148
+ field: createdMatch[2],
149
+ };
150
+ }
151
+
152
+ // No recognized prefix → literal string. Matches engine's fall-through
153
+ // in refs.py:75 (`return value`).
154
+ return { mode: "literal", value: wire };
155
+ }
156
+
157
+ export const _internal = { CREATED_REF_RE };
@@ -0,0 +1,592 @@
1
+ <!--
2
+ @component ValueSourcePicker
3
+
4
+ One picker for every action-engine slot that accepts "literal-or-reference".
5
+ Authoring affordance for action editors (Foundry-style declarative actions):
6
+ every Edit value, Create field, function arg, and config-query filter shares
7
+ this control. Wire format matches admin-api/app/actions/refs.py byte-for-byte.
8
+
9
+ Pair with the helpers shipped alongside (ValueSourcePicker.helpers.js):
10
+ serializeValueSource(v) → { value, functionArgs? }
11
+ parseValueSource(wire, functionArgs?) → ValueSourceValue | null
12
+
13
+ Allowed modes are narrowed per consumer — see the per-consumer matrix in
14
+ the consumer's spec (see issue #28 of the action-editor parity work).
15
+
16
+ @example Edits' value slot
17
+ <ValueSourcePicker
18
+ label="VALUE"
19
+ bind:value={editValue}
20
+ allowed={['literal', 'parameter', 'function', 'now', 'expression']}
21
+ context={{ parameters, functions }}
22
+ expectedType={field.type}
23
+ />
24
+
25
+ @example Function arg (recursive — depth 2)
26
+ <ValueSourcePicker
27
+ label={argName}
28
+ bind:value={argValue}
29
+ allowed={['literal', 'parameter', 'entity-field', 'user-field', 'config-list']}
30
+ context={{ parameters, entitySchema, userFields, configTypes }}
31
+ />
32
+ -->
33
+ <script module>
34
+ let _vspUid = 0;
35
+ </script>
36
+
37
+ <script>
38
+ /**
39
+ * @typedef {import('./ValueSourcePicker.helpers.js').ValueSourceMode} ValueSourceMode
40
+ * @typedef {import('./ValueSourcePicker.helpers.js').ValueSourceValue} ValueSourceValue
41
+ *
42
+ * @typedef {{ key: string, label: string, type: string }} ParameterField
43
+ * @typedef {{ name: string, label?: string, argSchema: Record<string, SchemaField> }} FunctionDefinition
44
+ * @typedef {{ key: string, label: string, description?: string }} UserField
45
+ * @typedef {{ index: number, entityType: string, fields: string[] }} PriorCreate
46
+ * @typedef {{ code: string, label: string }} ConfigType
47
+ * @typedef {{ type: string, enum?: string[], format?: string, required?: boolean }} SchemaField
48
+ *
49
+ * @typedef {Object} ValueSourceContext
50
+ * @property {ParameterField[]} [parameters]
51
+ * @property {{ type: string, properties: Record<string, SchemaField> }} [entitySchema]
52
+ * @property {UserField[]} [userFields]
53
+ * @property {FunctionDefinition[]} [functions]
54
+ * @property {PriorCreate[]} [priorCreates]
55
+ * @property {ConfigType[]} [configTypes]
56
+ */
57
+
58
+ import Select from "./Select.svelte";
59
+ import Combobox from "./Combobox.svelte";
60
+ import Input from "./Input.svelte";
61
+ import Badge from "./Badge.svelte";
62
+ import Button from "./Button.svelte";
63
+ import Label from "./Label.svelte";
64
+ import ValueSourcePicker from "./ValueSourcePicker.svelte";
65
+
66
+ let {
67
+ /** @type {ValueSourceValue | null} */
68
+ value = $bindable(null),
69
+ /** @type {((next: ValueSourceValue | null) => void) | undefined} */
70
+ onchange = undefined,
71
+ /** @type {ValueSourceMode[]} */
72
+ allowed = [
73
+ "literal",
74
+ "parameter",
75
+ "entity-field",
76
+ "user-field",
77
+ "now",
78
+ "source-id",
79
+ "created-field",
80
+ "config-list",
81
+ "function",
82
+ "expression",
83
+ ],
84
+ /** @type {ValueSourceContext} */
85
+ context = {},
86
+ /** @type {string | undefined} */
87
+ expectedType = undefined,
88
+ /** @type {string | undefined} */
89
+ label = undefined,
90
+ /** @type {boolean} */
91
+ required = false,
92
+ /** @type {boolean} */
93
+ disabled = false,
94
+ /** @type {string | undefined} */
95
+ id = undefined,
96
+ /** @type {string} */
97
+ class: className = "",
98
+ ...rest
99
+ } = $props();
100
+
101
+ const uid = `vsp-${_vspUid++}`;
102
+ const groupId = $derived(id ?? uid);
103
+
104
+ const MODE_LABEL = /** @type {Record<ValueSourceMode, string>} */ ({
105
+ literal: "Literal",
106
+ parameter: "Parameter",
107
+ "entity-field": "Entity field",
108
+ "user-field": "User field",
109
+ now: "Current time",
110
+ "source-id": "Source entity id",
111
+ "created-field": "Prior-created entity",
112
+ "config-list": "Config rows",
113
+ function: "Function",
114
+ expression: "Expression",
115
+ });
116
+
117
+ const MODE_DESCRIPTION = /** @type {Record<ValueSourceMode, string>} */ ({
118
+ literal: "A typed-in value",
119
+ parameter: "Value of an action input",
120
+ "entity-field": "A field of the source entity",
121
+ "user-field": "A claim from the caller's JWT",
122
+ now: "ISO 8601 UTC timestamp at execution time",
123
+ "source-id": "ID of the entity being acted upon",
124
+ "created-field": "Field of an entity created earlier in this action",
125
+ "config-list": "List of org-scoped config rows",
126
+ function: "Result of a registered action function",
127
+ expression: "Inline arithmetic over $entity / $parameters / $user",
128
+ });
129
+
130
+ const modeOptions = $derived(
131
+ allowed.map((mode) => ({ value: mode, label: MODE_LABEL[mode] })),
132
+ );
133
+
134
+ const currentMode = $derived(value?.mode ?? null);
135
+
136
+ const isStoredModeForbidden = $derived(
137
+ currentMode !== null && !allowed.includes(currentMode),
138
+ );
139
+
140
+ function emit(next) {
141
+ value = next;
142
+ onchange?.(next);
143
+ }
144
+
145
+ function pickMode(/** @type {string} */ next) {
146
+ const mode = /** @type {ValueSourceMode} */ (next);
147
+ if (mode === currentMode) return;
148
+ emit(makeBlankForMode(mode));
149
+ }
150
+
151
+ /** @returns {ValueSourceValue} */
152
+ function makeBlankForMode(/** @type {ValueSourceMode} */ mode) {
153
+ switch (mode) {
154
+ case "literal":
155
+ return { mode, value: "" };
156
+ case "parameter":
157
+ return { mode, key: "" };
158
+ case "entity-field":
159
+ return { mode, field: "" };
160
+ case "user-field":
161
+ return { mode, key: "" };
162
+ case "now":
163
+ return { mode };
164
+ case "source-id":
165
+ return { mode };
166
+ case "created-field":
167
+ return { mode, index: 0, field: "id" };
168
+ case "config-list":
169
+ return { mode, configType: "" };
170
+ case "function":
171
+ return { mode, name: "", args: {} };
172
+ case "expression":
173
+ return { mode, expr: "" };
174
+ default: {
175
+ const exhaustive = /** @type {never} */ (mode);
176
+ throw new Error(`makeBlankForMode: ${String(exhaustive)}`);
177
+ }
178
+ }
179
+ }
180
+
181
+ function clear() {
182
+ emit(null);
183
+ }
184
+
185
+ // ----------- per-mode helpers -----------
186
+
187
+ function paramOptions() {
188
+ return (context.parameters ?? []).map((p) => ({
189
+ value: p.key,
190
+ label: `${p.label || p.key}`,
191
+ description: p.type,
192
+ }));
193
+ }
194
+ function entityFieldOptions() {
195
+ const props = context.entitySchema?.properties ?? {};
196
+ return Object.entries(props).map(([key, schema]) => ({
197
+ value: key,
198
+ label: key,
199
+ description: schema.type,
200
+ }));
201
+ }
202
+ function userFieldOptions() {
203
+ return (context.userFields ?? []).map((u) => ({
204
+ value: u.key,
205
+ label: u.label,
206
+ description: u.description,
207
+ }));
208
+ }
209
+ function functionOptions() {
210
+ return (context.functions ?? []).map((fn) => ({
211
+ value: fn.name,
212
+ label: fn.label || fn.name,
213
+ }));
214
+ }
215
+ function priorCreateOptions() {
216
+ return (context.priorCreates ?? []).map((c) => ({
217
+ value: String(c.index),
218
+ label: `${c.index}: ${c.entityType}`,
219
+ }));
220
+ }
221
+ function priorCreateFieldOptions() {
222
+ if (value?.mode !== "created-field") return [];
223
+ const c = (context.priorCreates ?? []).find(
224
+ (p) => p.index === value.index,
225
+ );
226
+ return (c?.fields ?? ["id"]).map((f) => ({ value: f, label: f }));
227
+ }
228
+ function configTypeOptions() {
229
+ return (context.configTypes ?? []).map((c) => ({
230
+ value: c.code,
231
+ label: c.label || c.code,
232
+ }));
233
+ }
234
+
235
+ // dangling-ref detector — true when the stored ref names something not in context
236
+ const isDangling = $derived.by(() => {
237
+ if (!value) return false;
238
+ switch (value.mode) {
239
+ case "parameter":
240
+ return (
241
+ value.key !== "" &&
242
+ !(context.parameters ?? []).some((p) => p.key === value.key)
243
+ );
244
+ case "entity-field":
245
+ return (
246
+ value.field !== "" &&
247
+ !((context.entitySchema?.properties ?? {})[value.field])
248
+ );
249
+ case "user-field":
250
+ return (
251
+ value.key !== "" &&
252
+ !(context.userFields ?? []).some((u) => u.key === value.key)
253
+ );
254
+ case "function":
255
+ return (
256
+ value.name !== "" &&
257
+ !(context.functions ?? []).some((fn) => fn.name === value.name)
258
+ );
259
+ case "config-list":
260
+ return (
261
+ value.configType !== "" &&
262
+ !(context.configTypes ?? []).some((c) => c.code === value.configType)
263
+ );
264
+ case "created-field":
265
+ return !((context.priorCreates ?? []).some((p) => p.index === value.index));
266
+ default:
267
+ return false;
268
+ }
269
+ });
270
+
271
+ // type-mismatch detector — yellow soft warn (never blocks submit)
272
+ const typeMismatch = $derived.by(() => {
273
+ if (!expectedType || !value) return false;
274
+ switch (value.mode) {
275
+ case "parameter": {
276
+ const p = (context.parameters ?? []).find((pp) => pp.key === value.key);
277
+ return p ? !typesCompatible(p.type, expectedType) : false;
278
+ }
279
+ case "entity-field": {
280
+ const f = (context.entitySchema?.properties ?? {})[value.field];
281
+ return f ? !typesCompatible(f.type, expectedType) : false;
282
+ }
283
+ case "now":
284
+ return !typesCompatible("datetime", expectedType);
285
+ case "config-list":
286
+ return !typesCompatible("list", expectedType);
287
+ default:
288
+ return false;
289
+ }
290
+ });
291
+
292
+ function typesCompatible(/** @type {string} */ a, /** @type {string} */ b) {
293
+ if (a === b) return true;
294
+ // engine coerces freely between string ↔ number / datetime;
295
+ // only flag obvious mismatches.
296
+ const numericLike = new Set(["int", "float", "number"]);
297
+ if (numericLike.has(a) && numericLike.has(b)) return true;
298
+ return false;
299
+ }
300
+
301
+ // function-arg child editor — each arg is itself a ValueSourcePicker.
302
+ function updateFunctionArg(/** @type {string} */ key, /** @type {ValueSourceValue | null} */ next) {
303
+ if (value?.mode !== "function") return;
304
+ const args = { ...value.args };
305
+ if (next === null) delete args[key];
306
+ else args[key] = next;
307
+ emit({ ...value, args });
308
+ }
309
+
310
+ function functionArgAllowed() {
311
+ // Args use the four context-ref modes plus literal and config-list.
312
+ // Function-as-arg is forbidden by the engine's resolver layering — depth ≤ 2.
313
+ return /** @type {ValueSourceMode[]} */ ([
314
+ "literal",
315
+ "parameter",
316
+ "entity-field",
317
+ "user-field",
318
+ "config-list",
319
+ ]);
320
+ }
321
+
322
+ function functionDef() {
323
+ if (value?.mode !== "function") return null;
324
+ return (context.functions ?? []).find((fn) => fn.name === value.name) ?? null;
325
+ }
326
+
327
+ function literalPlaceholder(/** @type {string | undefined} */ t) {
328
+ switch (t) {
329
+ case "int":
330
+ case "float":
331
+ case "number":
332
+ return "0";
333
+ case "bool":
334
+ return "true";
335
+ case "datetime":
336
+ return "2026-01-01T00:00:00Z";
337
+ case "uuid":
338
+ return "00000000-0000-0000-0000-000000000000";
339
+ default:
340
+ return "Type a value";
341
+ }
342
+ }
343
+
344
+ function coerceLiteral(/** @type {string} */ raw, /** @type {string | undefined} */ t) {
345
+ switch (t) {
346
+ case "int":
347
+ return raw === "" ? null : Number.parseInt(raw, 10);
348
+ case "float":
349
+ case "number":
350
+ return raw === "" ? null : Number.parseFloat(raw);
351
+ case "bool":
352
+ return raw === "true";
353
+ default:
354
+ return raw;
355
+ }
356
+ }
357
+ </script>
358
+
359
+ <div
360
+ class="vsp {className}"
361
+ class:vsp-disabled={disabled}
362
+ role="group"
363
+ aria-labelledby={label ? `${groupId}-label` : undefined}
364
+ {...rest}
365
+ >
366
+ {#if label}
367
+ <Label for={`${groupId}-mode`} id={`${groupId}-label`}>
368
+ {label}{#if required}<span class="vsp-required" aria-hidden="true">*</span>{/if}
369
+ </Label>
370
+ {/if}
371
+
372
+ <div class="vsp-row">
373
+ <div class="vsp-mode">
374
+ <Select
375
+ id={`${groupId}-mode`}
376
+ size="sm"
377
+ placeholder={value === null ? "Pick a source" : undefined}
378
+ value={currentMode ?? ""}
379
+ options={modeOptions}
380
+ onchange={pickMode}
381
+ {disabled}
382
+ />
383
+ </div>
384
+
385
+ <div class="vsp-detail">
386
+ {#if isStoredModeForbidden && value}
387
+ <div class="vsp-error-row">
388
+ <Badge variant="error">no longer allowed: {MODE_LABEL[value.mode]}</Badge>
389
+ <Button variant="ghost" size="sm" onclick={clear}>Clear</Button>
390
+ </div>
391
+ {:else if value === null}
392
+ <span class="vsp-placeholder">{MODE_DESCRIPTION[allowed[0]] ?? ""}</span>
393
+ {:else if value.mode === "literal"}
394
+ <Input
395
+ size="sm"
396
+ placeholder={literalPlaceholder(expectedType)}
397
+ value={String(value.value ?? "")}
398
+ oninput={(e) => emit({ mode: "literal", value: coerceLiteral(/** @type {HTMLInputElement} */ (e.target).value, expectedType) })}
399
+ {disabled}
400
+ />
401
+ {:else if value.mode === "parameter"}
402
+ <Combobox
403
+ size="sm"
404
+ placeholder="Pick a parameter"
405
+ items={paramOptions()}
406
+ value={value.key}
407
+ onchange={(k) => emit({ mode: "parameter", key: k })}
408
+ {disabled}
409
+ />
410
+ {:else if value.mode === "entity-field"}
411
+ <Combobox
412
+ size="sm"
413
+ placeholder="Pick a field"
414
+ items={entityFieldOptions()}
415
+ value={value.field}
416
+ onchange={(f) => emit({ mode: "entity-field", field: f })}
417
+ {disabled}
418
+ />
419
+ {:else if value.mode === "user-field"}
420
+ <Combobox
421
+ size="sm"
422
+ placeholder="Pick a user claim"
423
+ items={userFieldOptions()}
424
+ value={value.key}
425
+ onchange={(k) => emit({ mode: "user-field", key: k })}
426
+ {disabled}
427
+ />
428
+ {:else if value.mode === "now"}
429
+ <Badge variant="info">$now — current UTC timestamp</Badge>
430
+ {:else if value.mode === "source-id"}
431
+ <Badge variant="info">$source.id — id of the entity being acted upon</Badge>
432
+ {:else if value.mode === "config-list"}
433
+ <Combobox
434
+ size="sm"
435
+ placeholder="Pick a config type"
436
+ items={configTypeOptions()}
437
+ value={value.configType}
438
+ onchange={(c) => emit({ mode: "config-list", configType: c })}
439
+ {disabled}
440
+ />
441
+ <Badge variant="neutral">list</Badge>
442
+ {:else if value.mode === "expression"}
443
+ <Input
444
+ size="sm"
445
+ placeholder="$entity.X + 1"
446
+ value={value.expr}
447
+ oninput={(e) => emit({ mode: "expression", expr: /** @type {HTMLInputElement} */ (e.target).value })}
448
+ {disabled}
449
+ />
450
+ {:else if value.mode === "created-field"}
451
+ <div class="vsp-inline">
452
+ <Combobox
453
+ size="sm"
454
+ placeholder="prior create"
455
+ items={priorCreateOptions()}
456
+ value={String(value.index)}
457
+ onchange={(i) => emit({ mode: "created-field", index: Number(i), field: value.field })}
458
+ {disabled}
459
+ />
460
+ <Combobox
461
+ size="sm"
462
+ placeholder="field"
463
+ items={priorCreateFieldOptions()}
464
+ value={value.field}
465
+ onchange={(f) => emit({ mode: "created-field", index: value.index, field: f })}
466
+ {disabled}
467
+ />
468
+ </div>
469
+ {:else if value.mode === "function"}
470
+ <Combobox
471
+ size="sm"
472
+ placeholder="Pick a function"
473
+ items={functionOptions()}
474
+ value={value.name}
475
+ onchange={(n) => emit({ mode: "function", name: n, args: {} })}
476
+ {disabled}
477
+ />
478
+ {/if}
479
+
480
+ {#if value && !isStoredModeForbidden}
481
+ {#if isDangling}
482
+ <Badge variant="error">removed from context</Badge>
483
+ <Button variant="ghost" size="sm" onclick={clear}>Clear</Button>
484
+ {:else if typeMismatch}
485
+ <Badge variant="warning">type mismatch · expected {expectedType}</Badge>
486
+ {/if}
487
+ {/if}
488
+ </div>
489
+ </div>
490
+
491
+ {#if value?.mode === "function" && value.name}
492
+ {@const fn = functionDef()}
493
+ {#if fn}
494
+ <div class="vsp-args" role="group" aria-label={`${value.name} args`}>
495
+ <span class="vsp-args-title">Args</span>
496
+ {#each Object.entries(fn.argSchema) as [argKey, argSchema]}
497
+ <ValueSourcePicker
498
+ label={argKey}
499
+ value={value.args[argKey] ?? null}
500
+ allowed={functionArgAllowed()}
501
+ {context}
502
+ expectedType={argSchema.type}
503
+ onchange={(next) => updateFunctionArg(argKey, next)}
504
+ {disabled}
505
+ />
506
+ {/each}
507
+ </div>
508
+ {:else}
509
+ <Badge variant="error">function "{value.name}" not in registry</Badge>
510
+ {/if}
511
+ {/if}
512
+ </div>
513
+
514
+ <style>
515
+ .vsp {
516
+ display: flex;
517
+ flex-direction: column;
518
+ gap: var(--space-xs);
519
+ }
520
+
521
+ .vsp-required {
522
+ color: var(--color-text-error);
523
+ margin-inline-start: var(--space-3xs);
524
+ }
525
+
526
+ .vsp-row {
527
+ display: grid;
528
+ grid-template-columns: minmax(0, 11rem) minmax(0, 1fr);
529
+ gap: var(--space-sm);
530
+ align-items: center;
531
+ }
532
+
533
+ .vsp-mode {
534
+ min-width: 0;
535
+ }
536
+
537
+ .vsp-detail {
538
+ display: flex;
539
+ flex-wrap: wrap;
540
+ gap: var(--space-2xs);
541
+ align-items: center;
542
+ min-width: 0;
543
+ }
544
+
545
+ .vsp-detail :global(.input-group) {
546
+ flex: 1 1 auto;
547
+ min-width: 0;
548
+ }
549
+
550
+ .vsp-error-row {
551
+ display: flex;
552
+ gap: var(--space-2xs);
553
+ align-items: center;
554
+ }
555
+
556
+ .vsp-placeholder {
557
+ color: var(--color-text-muted);
558
+ font-family: var(--type-body-font);
559
+ font-size: var(--type-body-size);
560
+ }
561
+
562
+ .vsp-inline {
563
+ display: flex;
564
+ gap: var(--space-2xs);
565
+ flex: 1 1 auto;
566
+ min-width: 0;
567
+ }
568
+
569
+ .vsp-args {
570
+ margin-inline-start: var(--space-md);
571
+ padding: var(--space-sm);
572
+ border: var(--border-width) solid var(--color-border-subtle);
573
+ border-radius: var(--radius-md);
574
+ background: var(--color-surface-secondary);
575
+ display: flex;
576
+ flex-direction: column;
577
+ gap: var(--space-sm);
578
+ }
579
+
580
+ .vsp-args-title {
581
+ font-family: var(--type-label-font);
582
+ font-size: var(--type-label-size);
583
+ letter-spacing: var(--type-label-tracking);
584
+ text-transform: uppercase;
585
+ color: var(--color-text-muted);
586
+ }
587
+
588
+ .vsp-disabled {
589
+ opacity: 0.6;
590
+ pointer-events: none;
591
+ }
592
+ </style>
@@ -0,0 +1,105 @@
1
+ export default ValueSourcePicker;
2
+ export type ValueSourceMode = import("./ValueSourcePicker.helpers.js").ValueSourceMode;
3
+ export type ValueSourceValue = import("./ValueSourcePicker.helpers.js").ValueSourceValue;
4
+ export type ParameterField = {
5
+ key: string;
6
+ label: string;
7
+ type: string;
8
+ };
9
+ export type FunctionDefinition = {
10
+ name: string;
11
+ label?: string;
12
+ argSchema: Record<string, SchemaField>;
13
+ };
14
+ export type UserField = {
15
+ key: string;
16
+ label: string;
17
+ description?: string;
18
+ };
19
+ export type PriorCreate = {
20
+ index: number;
21
+ entityType: string;
22
+ fields: string[];
23
+ };
24
+ export type ConfigType = {
25
+ code: string;
26
+ label: string;
27
+ };
28
+ export type SchemaField = {
29
+ type: string;
30
+ enum?: string[];
31
+ format?: string;
32
+ required?: boolean;
33
+ };
34
+ export type ValueSourceContext = {
35
+ parameters?: ParameterField[];
36
+ entitySchema?: {
37
+ type: string;
38
+ properties: Record<string, SchemaField>;
39
+ };
40
+ userFields?: UserField[];
41
+ functions?: FunctionDefinition[];
42
+ priorCreates?: PriorCreate[];
43
+ configTypes?: ConfigType[];
44
+ };
45
+ type ValueSourcePicker = {
46
+ $on?(type: string, callback: (e: any) => void): () => void;
47
+ $set?(props: Partial<$$ComponentProps>): void;
48
+ };
49
+ /**
50
+ * ValueSourcePicker
51
+ *
52
+ * One picker for every action-engine slot that accepts "literal-or-reference".
53
+ * Authoring affordance for action editors (Foundry-style declarative actions):
54
+ * every Edit value, Create field, function arg, and config-query filter shares
55
+ * this control. Wire format matches admin-api/app/actions/refs.py byte-for-byte.
56
+ *
57
+ * Pair with the helpers shipped alongside (ValueSourcePicker.helpers.js):
58
+ * serializeValueSource(v) → { value, functionArgs? }
59
+ * parseValueSource(wire, functionArgs?) → ValueSourceValue | null
60
+ *
61
+ * Allowed modes are narrowed per consumer — see the per-consumer matrix in
62
+ * the consumer's spec (see issue #28 of the action-editor parity work).
63
+ *
64
+ * @example Edits' value slot
65
+ * <ValueSourcePicker
66
+ * label="VALUE"
67
+ * bind:value={editValue}
68
+ * allowed={['literal', 'parameter', 'function', 'now', 'expression']}
69
+ * context={{ parameters, functions }}
70
+ * expectedType={field.type}
71
+ * />
72
+ *
73
+ * @example Function arg (recursive — depth 2)
74
+ * <ValueSourcePicker
75
+ * label={argName}
76
+ * bind:value={argValue}
77
+ * allowed={['literal', 'parameter', 'entity-field', 'user-field', 'config-list']}
78
+ * context={{ parameters, entitySchema, userFields, configTypes }}
79
+ * />
80
+ */
81
+ declare const ValueSourcePicker: import("svelte").Component<{
82
+ value?: any;
83
+ onchange?: any;
84
+ allowed?: any[];
85
+ context?: Record<string, any>;
86
+ expectedType?: any;
87
+ label?: any;
88
+ required?: boolean;
89
+ disabled?: boolean;
90
+ id?: any;
91
+ class?: string;
92
+ } & Record<string, any>, {}, "value">;
93
+ import ValueSourcePicker from "./ValueSourcePicker.svelte";
94
+ type $$ComponentProps = {
95
+ value?: any;
96
+ onchange?: any;
97
+ allowed?: any[];
98
+ context?: Record<string, any>;
99
+ expectedType?: any;
100
+ label?: any;
101
+ required?: boolean;
102
+ disabled?: boolean;
103
+ id?: any;
104
+ class?: string;
105
+ } & Record<string, any>;
@@ -76,6 +76,11 @@ export { default as BottomNavItem } from "./BottomNavItem.svelte";
76
76
 
77
77
  // Complex
78
78
  export { default as Stepper } from "./Stepper.svelte";
79
+ export { default as ValueSourcePicker } from "./ValueSourcePicker.svelte";
80
+ export {
81
+ serializeValueSource,
82
+ parseValueSource,
83
+ } from "./ValueSourcePicker.helpers.js";
79
84
  export { default as CodeBlock } from "./CodeBlock.svelte";
80
85
  export { default as CodeEditor } from "./CodeEditor.svelte";
81
86
  export { default as CollapsibleSection } from "./CollapsibleSection.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",