@veil-runtime/ui 0.1.0 → 0.4.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.
@@ -0,0 +1,524 @@
1
+ import { assertBoundedData, assertSafeKey, copyData } from "./validation-safety.js";
2
+ import { attachInterfaceRenderers, detachInterfaceRenderers } from "./interface-transaction.js";
3
+ const identifier = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/;
4
+ const commonProperties = ["key", "component", "visible", "ariaLabel"];
5
+ const vocabulary = {
6
+ Stack: { properties: [...commonProperties, "children"], required: [], children: true, event: null },
7
+ Row: { properties: [...commonProperties, "children"], required: [], children: true, event: null },
8
+ Text: { properties: [...commonProperties, "text"], required: [], children: false, event: null },
9
+ Heading: { properties: [...commonProperties, "text"], required: ["text"], children: false, event: null },
10
+ Status: { properties: [...commonProperties, "text"], required: [], children: false, event: null },
11
+ Input: { properties: [...commonProperties, "label", "binding", "inputType", "validation"], required: ["label", "binding"], children: false, event: "state change" },
12
+ Textarea: { properties: [...commonProperties, "label", "binding", "validation"], required: ["label", "binding"], children: false, event: "state change" },
13
+ Select: { properties: [...commonProperties, "label", "binding", "validation", "options"], required: ["label", "binding", "options"], children: false, event: "state change" },
14
+ Button: { properties: [...commonProperties, "text", "action", "disabled", "validate"], required: ["text", "action"], children: false, event: "action" },
15
+ Table: { properties: [...commonProperties, "binding", "columns"], required: ["binding", "columns"], children: false, event: null },
16
+ List: { properties: [...commonProperties, "binding"], required: ["binding"], children: false, event: null },
17
+ };
18
+ const builtins = new Set(Object.keys(vocabulary));
19
+ const nodeKeys = new Set(["key", "component", "children", "text", "binding", "visible", "action", "label", "inputType", "columns", "ariaLabel", "disabled", "validation", "options", "validate"]);
20
+ /** JSON-safe description of the built-in vocabulary. Host extensions are trusted code and are never advertised here. */
21
+ export function getInterfaceCapabilities() {
22
+ return copyData({ version: 1, components: vocabulary, bindings: { sources: ["data", "state"], path: "dot-separated own-property path", missing: "empty display", visibility: "strict scalar equality; ne, lt, lte, gt, gte; all, any, not" },
23
+ propertyTypes: { key: "identifier", component: "component name", children: "node array", text: "string or binding", binding: "binding", visible: "condition", disabled: "Button condition", validation: "{required,minLength,maxLength,min,max}", validate: "Button boolean", options: "Select array of {value:string,label:string}", action: "declared action identifier", label: "string", inputType: ["text", "number", "checkbox"], columns: "array of {key:identifier,label:string}", ariaLabel: "string" },
24
+ events: { action: { fields: ["action", "source", "state", "revision"], declaration: "definition.actions and host allowedActions" }, state: { type: "scalar", source: "state binding" } },
25
+ limits: { treeDepth: 16, nodes: 512, textLength: 4096, tableRows: 100, tableColumns: 20, listItems: 100, transportBytes: 1048576 } });
26
+ }
27
+ const plain = (value) => !!value && typeof value === "object" && !Array.isArray(value);
28
+ const exact = (value, allowed) => {
29
+ for (const key of Object.keys(value))
30
+ if (!allowed.has(key))
31
+ throw new Error(`Unknown interface property: ${key}`);
32
+ };
33
+ const name = (value) => typeof value === "string" && identifier.test(value);
34
+ const scalar = (value) => value === null || typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value));
35
+ function binding(value) {
36
+ if (!plain(value))
37
+ throw new Error("Invalid binding");
38
+ exact(value, new Set(["source", "path"]));
39
+ if ((value.source !== "data" && value.source !== "state") || !name(value.path))
40
+ throw new Error("Invalid binding");
41
+ for (const part of value.path.split("."))
42
+ assertSafeKey(part);
43
+ }
44
+ function valueAt(source, path) {
45
+ let value = source;
46
+ for (const part of path.split("."))
47
+ value = plain(value) && Object.hasOwn(value, part) ? value[part] : undefined;
48
+ return value;
49
+ }
50
+ function visitNodes(node, callback) {
51
+ callback(node);
52
+ for (const child of node.children ?? [])
53
+ visitNodes(child, callback);
54
+ }
55
+ function validateBindings(definition, data, state) {
56
+ visitNodes(definition.screen, (node) => {
57
+ if (node.component === "Table") {
58
+ const rows = valueAt(node.binding.source === "data" ? data : state, node.binding.path);
59
+ if (rows === undefined)
60
+ return;
61
+ if (!Array.isArray(rows) || rows.length > 100 || rows.some((row) => !plain(row)))
62
+ throw new Error("Invalid or oversized table rows");
63
+ for (const row of rows)
64
+ for (const col of node.columns ?? [])
65
+ if (Object.hasOwn(row, col.key) && !scalar(row[col.key]))
66
+ throw new Error("Invalid table cell");
67
+ }
68
+ if (node.component === "List") {
69
+ const items = valueAt(node.binding.source === "data" ? data : state, node.binding.path);
70
+ if (items !== undefined && (!Array.isArray(items) || items.length > 100 || items.some((item) => !scalar(item))))
71
+ throw new Error("Invalid or oversized list items");
72
+ }
73
+ if (isInterfaceField(node)) {
74
+ const current = valueAt(state, node.binding.path);
75
+ if (current !== undefined && !scalar(current))
76
+ throw new Error("Invalid input state");
77
+ }
78
+ });
79
+ }
80
+ /** A definition owns its state shape. Retain values only for unchanged input identities. */
81
+ function retainedState(previous, next, oldState) {
82
+ const state = copyData(next.state ?? {});
83
+ if (previous.id !== next.id)
84
+ return state;
85
+ const inputs = new Map();
86
+ visitNodes(previous.screen, (node) => { if (isInterfaceField(node))
87
+ inputs.set(node.key, node); });
88
+ visitNodes(next.screen, (node) => {
89
+ if (!isInterfaceField(node))
90
+ return;
91
+ const old = inputs.get(node.key);
92
+ if (!old || old.component !== node.component || old.inputType !== node.inputType || old.binding?.path !== node.binding?.path)
93
+ return;
94
+ const path = node.binding.path;
95
+ const oldValue = valueAt(oldState, path);
96
+ const defaultValue = valueAt(state, path);
97
+ if (!scalar(oldValue) || defaultValue === undefined || typeof oldValue !== typeof defaultValue)
98
+ return;
99
+ const parts = path.split(".");
100
+ let target = state;
101
+ for (const part of parts.slice(0, -1)) {
102
+ if (!plain(target[part]))
103
+ return;
104
+ target = target[part];
105
+ }
106
+ target[parts.at(-1)] = oldValue;
107
+ });
108
+ return state;
109
+ }
110
+ export function defineInterface(definition) { return definition; }
111
+ /** Validate untrusted structured data before creating a controller or mounting it. */
112
+ export function validateInterfaceDefinition(value, components = []) {
113
+ assertBoundedData(value);
114
+ if (!plain(value))
115
+ throw new Error("Invalid interface definition");
116
+ exact(value, new Set(["id", "screen", "actions", "data", "state"]));
117
+ if (!name(value.id) || !plain(value.screen))
118
+ throw new Error("Invalid interface identity or screen");
119
+ for (const key of ["data", "state"])
120
+ if (value[key] !== undefined && !plain(value[key]))
121
+ throw new Error(`Invalid ${key}`);
122
+ if (value.actions !== undefined && (!Array.isArray(value.actions) || value.actions.some((action) => !name(action)) || new Set(value.actions).size !== value.actions.length))
123
+ throw new Error("Invalid actions");
124
+ const actions = (value.actions ?? []);
125
+ if (components.some((component) => !name(component) || builtins.has(component)) || new Set(components).size !== components.length)
126
+ throw new Error("Invalid component registry");
127
+ const known = new Set([...builtins, ...components]);
128
+ const keys = new Set();
129
+ let count = 0;
130
+ function visit(node, depth) {
131
+ if (!plain(node) || depth > 16 || ++count > 512)
132
+ throw new Error("Interface tree limit exceeded");
133
+ exact(node, nodeKeys);
134
+ if (!name(node.key) || keys.has(node.key))
135
+ throw new Error("Invalid or duplicate node key");
136
+ keys.add(node.key);
137
+ if (typeof node.component !== "string" || !known.has(node.component))
138
+ throw new Error(`Unknown component: ${String(node.component)}`);
139
+ if (builtins.has(node.component)) {
140
+ const spec = vocabulary[node.component];
141
+ for (const property of Object.keys(node))
142
+ if (!spec.properties.includes(property))
143
+ throw new Error(`Unsupported ${node.component} property: ${property}`);
144
+ for (const property of spec.required)
145
+ if (node[property] === undefined)
146
+ throw new Error(`${node.component} needs ${property}`);
147
+ }
148
+ if (node.text !== undefined) {
149
+ if (typeof node.text === "string") {
150
+ if (node.text.length > 4096)
151
+ throw new Error("Text limit exceeded");
152
+ }
153
+ else
154
+ binding(node.text);
155
+ }
156
+ if (node.binding !== undefined)
157
+ binding(node.binding);
158
+ if (node.visible !== undefined)
159
+ validateCondition(node.visible);
160
+ if (node.disabled !== undefined) {
161
+ if (node.component !== "Button")
162
+ throw new Error("Only Button accepts disabled");
163
+ validateCondition(node.disabled);
164
+ }
165
+ for (const field of ["label", "ariaLabel"])
166
+ if (node[field] !== undefined && (typeof node[field] !== "string" || node[field].length > 256))
167
+ throw new Error(`Invalid ${field}`);
168
+ if (node.action !== undefined && (!name(node.action) || !actions.includes(node.action)))
169
+ throw new Error("Action is not declared by the host");
170
+ if (node.inputType !== undefined && !["text", "number", "checkbox"].includes(node.inputType))
171
+ throw new Error("Invalid input type");
172
+ if (node.validate !== undefined && (node.component !== "Button" || typeof node.validate !== "boolean"))
173
+ throw new Error("Invalid validate flag");
174
+ if (node.options !== undefined) {
175
+ if (node.component !== "Select" || !Array.isArray(node.options) || node.options.length < 1 || node.options.length > 100)
176
+ throw new Error("Invalid select options");
177
+ const values = new Set();
178
+ for (const option of node.options) {
179
+ if (!plain(option))
180
+ throw new Error("Invalid option");
181
+ exact(option, new Set(["value", "label"]));
182
+ if (typeof option.value !== "string" || option.value.length > 256 || typeof option.label !== "string" || option.label.length > 256 || values.has(option.value))
183
+ throw new Error("Invalid option");
184
+ values.add(option.value);
185
+ }
186
+ }
187
+ if (node.validation !== undefined) {
188
+ if (!isInterfaceField(node) || !plain(node.validation))
189
+ throw new Error("Invalid validation");
190
+ exact(node.validation, new Set(["required", "minLength", "maxLength", "min", "max"]));
191
+ const rules = node.validation;
192
+ if (rules.required !== undefined && typeof rules.required !== "boolean")
193
+ throw new Error("Invalid required rule");
194
+ for (const key of ["minLength", "maxLength", "min", "max"])
195
+ if (rules[key] !== undefined && (typeof rules[key] !== "number" || !Number.isFinite(rules[key])))
196
+ throw new Error("Invalid validation bound");
197
+ for (const key of ["minLength", "maxLength"])
198
+ if (rules[key] !== undefined && (!Number.isInteger(rules[key]) || rules[key] < 0 || rules[key] > 4096 || node.inputType === "number" || node.inputType === "checkbox"))
199
+ throw new Error("Invalid length rule");
200
+ if ((rules.min !== undefined || rules.max !== undefined) && (node.component !== "Input" || node.inputType !== "number"))
201
+ throw new Error("Numeric rules require number input");
202
+ if (rules.minLength > rules.maxLength || rules.min > rules.max)
203
+ throw new Error("Reversed validation bounds");
204
+ }
205
+ if (node.columns !== undefined) {
206
+ if (!Array.isArray(node.columns) || node.columns.length < 1 || node.columns.length > 20 || node.columns.some((col) => !plain(col) || Object.keys(col).some((key) => key !== "key" && key !== "label") || !name(col.key) || typeof col.label !== "string" || col.label.length > 256))
207
+ throw new Error("Invalid table columns");
208
+ }
209
+ if (isInterfaceField(node) && (!node.binding || node.binding.source !== "state" || !node.label))
210
+ throw new Error("Input needs a state binding and label");
211
+ if (node.component === "Button" && (!node.action || !node.text))
212
+ throw new Error("Button needs an action and text");
213
+ if (node.component === "Table" && (!node.binding || !node.columns))
214
+ throw new Error("Table needs rows and columns");
215
+ if (node.component !== "Button" && node.action !== undefined)
216
+ throw new Error("Only Button accepts an action");
217
+ if (node.component !== "Input" && node.inputType !== undefined)
218
+ throw new Error("Only Input accepts inputType");
219
+ if (node.component !== "Table" && node.columns !== undefined)
220
+ throw new Error("Only Table accepts columns");
221
+ if (node.children !== undefined && (!Array.isArray(node.children) || (!(["Stack", "Row"].includes(node.component)) && !components.includes(node.component))))
222
+ throw new Error("Invalid children");
223
+ for (const child of node.children ?? [])
224
+ visit(child, depth + 1);
225
+ }
226
+ visit(value.screen, 0);
227
+ validateBindings(value, (value.data ?? {}), (value.state ?? {}));
228
+ }
229
+ export function createInterfaceController(definition, options = {}) {
230
+ const components = options.components ?? [];
231
+ validateInterfaceDefinition(definition, components);
232
+ if (options.allowedActions && definition.actions?.some((action) => !options.allowedActions.includes(action)))
233
+ throw new Error("Action is not allowed by host");
234
+ let current = copyData(definition);
235
+ let data = copyData(definition.data ?? {});
236
+ let state = copyData(definition.state ?? {});
237
+ validateBindings(current, data, state);
238
+ let revision = 0;
239
+ let disposed = false;
240
+ let dispatching = false;
241
+ let emitting = false;
242
+ let pending = false;
243
+ let updating = false;
244
+ const listeners = new Set();
245
+ const participants = new Set();
246
+ const copySnapshot = (value) => ({
247
+ definition: copyData(value.definition), data: copyData(value.data), state: copyData(value.state), revision: value.revision,
248
+ });
249
+ const snapshot = () => copySnapshot({ definition: current, data, state, revision });
250
+ const report = (error) => { try {
251
+ options.onError?.(error);
252
+ }
253
+ catch { /* Error reporting must not enter the interface runtime. */ } };
254
+ const emit = () => {
255
+ if (emitting) {
256
+ pending = true;
257
+ return;
258
+ }
259
+ emitting = true;
260
+ try {
261
+ let passes = 0;
262
+ do {
263
+ pending = false;
264
+ const accepted = snapshot();
265
+ for (const listener of listeners) {
266
+ try {
267
+ listener(copySnapshot(accepted));
268
+ }
269
+ catch (error) {
270
+ report(error);
271
+ }
272
+ if (revision !== accepted.revision) {
273
+ pending = true;
274
+ break;
275
+ }
276
+ }
277
+ if (++passes >= 32 && pending) {
278
+ report(new Error("Interface update loop limit exceeded"));
279
+ pending = false;
280
+ break;
281
+ }
282
+ } while (pending && !disposed);
283
+ }
284
+ finally {
285
+ emitting = false;
286
+ }
287
+ };
288
+ const accept = (nextDefinition, nextData, nextState) => {
289
+ if (updating)
290
+ throw new Error("Interface update already in progress");
291
+ updating = true;
292
+ const prepared = [];
293
+ try {
294
+ const candidate = { definition: nextDefinition, data: nextData, state: nextState, revision: revision + 1 };
295
+ for (const renderer of participants)
296
+ prepared.push(renderer(copySnapshot(candidate)));
297
+ if (disposed)
298
+ throw new Error("Interface controller was disposed during update");
299
+ for (const renderer of prepared) {
300
+ renderer.commit();
301
+ if (disposed)
302
+ throw new Error("Interface controller was disposed during update");
303
+ }
304
+ current = nextDefinition;
305
+ data = nextData;
306
+ state = nextState;
307
+ revision++;
308
+ }
309
+ catch (error) {
310
+ for (const renderer of prepared.reverse()) {
311
+ try {
312
+ renderer.discard();
313
+ }
314
+ catch (cleanupError) {
315
+ report(cleanupError);
316
+ }
317
+ }
318
+ throw error;
319
+ }
320
+ finally {
321
+ updating = false;
322
+ }
323
+ emit();
324
+ };
325
+ const controller = {
326
+ snapshot,
327
+ updateData(next, expectedRevision) { if (disposed || (expectedRevision !== undefined && expectedRevision !== revision))
328
+ return false; if (updating)
329
+ throw new Error("Interface update already in progress"); assertBoundedData(next); if (!plain(next))
330
+ throw new Error("Invalid data"); const copy = copyData(next); validateBindings(current, copy, state); accept(current, copy, state); return true; },
331
+ updateDefinition(next, expectedRevision) {
332
+ if (disposed || (expectedRevision !== undefined && expectedRevision !== revision))
333
+ return false;
334
+ if (updating)
335
+ throw new Error("Interface update already in progress");
336
+ validateInterfaceDefinition(next, components);
337
+ if (options.allowedActions && next.actions?.some((action) => !options.allowedActions.includes(action)))
338
+ throw new Error("Action is not allowed by host");
339
+ const copy = copyData(next);
340
+ const nextData = copyData(copy.data ?? {});
341
+ const nextState = retainedState(current, copy, state);
342
+ assertBoundedData(nextState);
343
+ validateBindings(copy, nextData, nextState);
344
+ accept(copy, nextData, nextState);
345
+ return true;
346
+ },
347
+ setState(path, value) {
348
+ if (disposed)
349
+ return;
350
+ if (updating)
351
+ throw new Error("Interface update already in progress");
352
+ if (!name(path) || !scalar(value))
353
+ throw new Error("Invalid state update");
354
+ const parts = path.split(".");
355
+ for (const part of parts)
356
+ assertSafeKey(part);
357
+ const next = copyData(state);
358
+ let target = next;
359
+ for (const part of parts.slice(0, -1)) {
360
+ const child = target[part];
361
+ if (child !== undefined && !plain(child))
362
+ throw new Error("State path is not an object");
363
+ if (child === undefined)
364
+ target[part] = {};
365
+ target = target[part];
366
+ }
367
+ target[parts.at(-1)] = value;
368
+ assertBoundedData(next);
369
+ validateBindings(current, data, next);
370
+ accept(current, data, next);
371
+ },
372
+ dispatch(action, source) {
373
+ if (disposed || dispatching)
374
+ return;
375
+ if (updating)
376
+ throw new Error("Interface update already in progress");
377
+ if (!current.actions?.includes(action) || !findAction(current.screen, source, action, data, state))
378
+ throw new Error("Action is not available");
379
+ let submit = false;
380
+ visitNodes(current.screen, node => { if (node.key === source)
381
+ submit = node.validate === true; });
382
+ if (submit && Object.keys(getInterfaceValidation({ definition: current, data, state })).length)
383
+ throw new Error("Interface validation failed");
384
+ dispatching = true;
385
+ try {
386
+ const result = options.onEvent?.({ action, source, state: copyData(state), revision });
387
+ if (result && typeof result === "object" && "then" in result)
388
+ void Promise.resolve(result).catch(report);
389
+ }
390
+ catch (error) {
391
+ report(error);
392
+ }
393
+ finally {
394
+ dispatching = false;
395
+ }
396
+ },
397
+ subscribe(listener) { if (disposed)
398
+ throw new Error("Interface controller is disposed"); listeners.add(listener); try {
399
+ listener(snapshot());
400
+ }
401
+ catch (error) {
402
+ listeners.delete(listener);
403
+ throw error;
404
+ } return () => { listeners.delete(listener); }; },
405
+ dispose() { disposed = true; listeners.clear(); participants.clear(); detachInterfaceRenderers(controller); },
406
+ };
407
+ attachInterfaceRenderers(controller, participants);
408
+ return controller;
409
+ }
410
+ function findAction(node, key, action, data, state) {
411
+ if (node.visible && !evaluateInterfaceCondition(node.visible, { data, state }))
412
+ return false;
413
+ if (node.disabled && evaluateInterfaceCondition(node.disabled, { data, state }))
414
+ return false;
415
+ return (node.key === key && node.action === action) || (node.children?.some((child) => findAction(child, key, action, data, state)) ?? false);
416
+ }
417
+ export function resolveInterfaceBinding(snapshot, bindingValue) {
418
+ return valueAt(bindingValue.source === "data" ? snapshot.data : snapshot.state, bindingValue.path);
419
+ }
420
+ function validateCondition(value) {
421
+ let count = 0;
422
+ function visit(value, depth) {
423
+ if (!plain(value) || depth > 8 || ++count > 64)
424
+ throw new Error("Invalid condition or condition limit exceeded");
425
+ if (Object.hasOwn(value, "all") || Object.hasOwn(value, "any")) {
426
+ const key = Object.hasOwn(value, "all") ? "all" : "any";
427
+ exact(value, new Set([key]));
428
+ const terms = value[key];
429
+ if (!Array.isArray(terms) || terms.length < 1 || terms.length > 16)
430
+ throw new Error("Invalid condition terms");
431
+ for (const term of terms)
432
+ visit(term, depth + 1);
433
+ }
434
+ else if (Object.hasOwn(value, "not")) {
435
+ exact(value, new Set(["not"]));
436
+ visit(value.not, depth + 1);
437
+ }
438
+ else {
439
+ binding(value.binding);
440
+ if (Object.hasOwn(value, "equals")) {
441
+ exact(value, new Set(["binding", "equals"]));
442
+ if (!scalar(value.equals))
443
+ throw new Error("Invalid condition value");
444
+ }
445
+ else {
446
+ exact(value, new Set(["binding", "operator", "value"]));
447
+ if (!["ne", "lt", "lte", "gt", "gte"].includes(value.operator) || !scalar(value.value))
448
+ throw new Error("Invalid condition operator or value");
449
+ if (value.operator !== "ne" && typeof value.value !== "number")
450
+ throw new Error("Numeric condition needs a number");
451
+ }
452
+ }
453
+ }
454
+ visit(value, 0);
455
+ }
456
+ /** Evaluate a validated condition without coercion or side effects. Missing numeric values never match. */
457
+ export function evaluateInterfaceCondition(condition, snapshot) {
458
+ if ("all" in condition)
459
+ return condition.all.every(term => evaluateInterfaceCondition(term, snapshot));
460
+ if ("any" in condition)
461
+ return condition.any.some(term => evaluateInterfaceCondition(term, snapshot));
462
+ if ("not" in condition)
463
+ return !evaluateInterfaceCondition(condition.not, snapshot);
464
+ const value = valueAt(snapshot[condition.binding.source], condition.binding.path);
465
+ if ("equals" in condition)
466
+ return value === condition.equals;
467
+ if (condition.operator === "ne")
468
+ return value !== condition.value;
469
+ if (typeof value !== "number" || !Number.isFinite(value) || typeof condition.value !== "number")
470
+ return false;
471
+ switch (condition.operator) {
472
+ case "lt": return value < condition.value;
473
+ case "lte": return value <= condition.value;
474
+ case "gt": return value > condition.value;
475
+ case "gte": return value >= condition.value;
476
+ }
477
+ }
478
+ export function isInterfaceField(node) {
479
+ return ["Input", "Textarea", "Select"].includes(node.component);
480
+ }
481
+ /** Errors are recomputed from accepted state; hidden fields are excluded, including hidden ancestors. */
482
+ export function getInterfaceValidation(snapshot) {
483
+ const errors = {};
484
+ function visit(node) {
485
+ if (node.visible && !evaluateInterfaceCondition(node.visible, snapshot))
486
+ return;
487
+ if (isInterfaceField(node)) {
488
+ const value = valueAt(snapshot.state, node.binding.path);
489
+ const rules = node.validation ?? {};
490
+ const empty = value === undefined || value === null || value === "";
491
+ let error = "";
492
+ if (rules.required && (empty || (node.inputType === "checkbox" && value !== true)))
493
+ error = "This field is required.";
494
+ else if (!empty) {
495
+ if (node.inputType === "number") {
496
+ if (typeof value !== "number" || !Number.isFinite(value))
497
+ error = "Enter a number.";
498
+ else if (rules.min !== undefined && value < rules.min)
499
+ error = `Minimum value is ${rules.min}.`;
500
+ else if (rules.max !== undefined && value > rules.max)
501
+ error = `Maximum value is ${rules.max}.`;
502
+ }
503
+ else if (node.inputType === "checkbox") {
504
+ if (typeof value !== "boolean")
505
+ error = "Enter a boolean.";
506
+ }
507
+ else if (typeof value !== "string")
508
+ error = "Enter text.";
509
+ else if (rules.minLength !== undefined && value.length < rules.minLength)
510
+ error = `Minimum length is ${rules.minLength}.`;
511
+ else if (rules.maxLength !== undefined && value.length > rules.maxLength)
512
+ error = `Maximum length is ${rules.maxLength}.`;
513
+ else if (node.component === "Select" && !node.options.some(option => option.value === value))
514
+ error = "Choose an available option.";
515
+ }
516
+ if (error)
517
+ errors[node.key] = error;
518
+ }
519
+ for (const child of node.children ?? [])
520
+ visit(child);
521
+ }
522
+ visit(snapshot.definition.screen);
523
+ return errors;
524
+ }
@@ -13,8 +13,8 @@ export declare const presentationPlanSchema: z.ZodObject<{
13
13
  subtle: "subtle";
14
14
  }>;
15
15
  density: z.ZodEnum<{
16
- compact: "compact";
17
16
  comfortable: "comfortable";
17
+ compact: "compact";
18
18
  }>;
19
19
  sections: z.ZodArray<z.ZodString>;
20
20
  }, z.core.$strict>>;
@@ -1,6 +1,8 @@
1
1
  import { assertBoundedData, copyData } from "./validation-safety.js";
2
2
  import { z } from "zod";
3
- const groupSchema = z.object({ id: z.string().regex(/^[a-zA-Z0-9_-]+$/).max(48), title: z.string().min(1).max(80).optional(), description: z.string().min(1).max(240).optional(), emphasis: z.enum(["normal", "prominent", "subtle"]), density: z.enum(["comfortable", "compact"]), sections: z.array(z.string().min(1).max(120)).min(1).max(256) }).strict();
3
+ const emphasisSchema = z.enum(["normal", "prominent", "subtle"]);
4
+ const densitySchema = z.enum(["comfortable", "compact"]);
5
+ const groupSchema = z.object({ id: z.string().regex(/^[a-zA-Z0-9_-]+$/).max(48), title: z.string().min(1).max(80).optional(), description: z.string().min(1).max(240).optional(), emphasis: emphasisSchema, density: densitySchema, sections: z.array(z.string().min(1).max(120)).min(1).max(256) }).strict();
4
6
  export const presentationPlanSchema = z.object({ title: z.string().min(1).max(100).optional(), description: z.string().min(1).max(240).optional(), groups: z.array(groupSchema).max(12) }).strict();
5
7
  export function sectionId(kind, capability) { return kind + ":" + capability; }
6
8
  export function buildPresentationEnvelope(primary, related) { return { applicationSurface: primary.capability, sections: [{ id: sectionId("primary", primary.capability), capability: primary.capability, surface: primary.view.surface, label: primary.view.title, required: true, movable: false, groupable: false, defaultOrder: 0 }, ...related.map((section, index) => ({ id: sectionId("related", section.capability), capability: section.capability, surface: section.view?.surface ?? "error", label: section.label, required: true, movable: true, groupable: true, defaultOrder: index + 1 }))], immutable: ["capabilities", "fields", "metrics", "bindings", "workflow", "risk", "actions", "surface semantics", "execution"] }; }
package/docs/API.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Public API inventory
2
2
 
3
- The preview keeps all existing root exports; no existing symbol or subpath was removed. The root index exports modules below. All interfaces/types exported by those modules remain available in the declarations. Package consumers import through the root or `/inspection`, not paths inside dist.
3
+ The v0.3.0-rc.2 candidate adds `@veil-runtime/ui/interface` (`defineInterface`, `validateInterfaceDefinition`, `createInterfaceController`, `getInterfaceCapabilities`, `resolveInterfaceBinding`, and types including `BuiltinInterfaceDefinition`) and `mountInterface`, `createMountedInterface`, plus `InterfaceDOMComponentInstance` in `/dom`. `InterfaceDOMComponent` may still return an `HTMLElement`; it may now return `{ element, update?, dispose? }`. Updates prepare detached custom instances before controller acceptance. See [UNIVERSAL-INTERFACE.md](UNIVERSAL-INTERFACE.md). The v0.2.0 composition API remains available through `/composition` and `/dom`. The inventory below describes preserved v0.1.0 APIs.
4
+
5
+ The preview keeps all existing root exports; no existing symbol or subpath was removed. The root index exports modules below. All interfaces/types exported by those modules remain available in the declarations. Package consumers import through the root or documented `/inspection`, `/composition`, `/interface`, and `/dom` subpaths, not paths inside dist.
4
6
 
5
7
  | Area | Root modules / representative API |
6
8
  |---|---|
@@ -14,4 +16,4 @@ The preview keeps all existing root exports; no existing symbol or subpath was r
14
16
  | Experimental optional presentation | `capability-view-derivation` provider interfaces and `deriveCapabilityView`; `presentation-composition`, `buildPresentationEnvelope`, `deterministicPresentation`, `validatePresentationPlan`, `composePresentation`, `presentationPlanSchema`, `sectionId` |
15
17
  | Inspection subpath | `@veil-runtime/ui/inspection`: `buildCompositionInspection` and inspection model types |
16
18
 
17
- The qualification consumer exercises both supported subpaths, actual Core capability creation, strict public declarations, and browser bundling. Published export conditions intentionally support ESM import, not direct CommonJS require. Core is a nonoptional peer for the TypeScript capability contract; UI has no compiled Core runtime import. A host may use UI's descriptors in a browser and keep Core execution on its server.
19
+ Qualification exercises the legacy Core capability API and the independent universal interface subpaths, strict public declarations, and browser bundling. Published export conditions intentionally support ESM import, not direct CommonJS require. Core is an optional peer and is unnecessary for `/interface` and `/dom` consumers. UI has no compiled Core runtime import. A host may use UI's descriptors in a browser and keep Core execution on its server.
@@ -0,0 +1,67 @@
1
+ # Application composition foundation (v0.2.0-rc.1)
2
+
3
+ ## Architecture assessment
4
+
5
+ **Confirmed in v0.1.0:** `ApplicationManifest` and `createUIRegistry` describe Core capability graphs. `deriveUIFields`, rich input schemas, and `validateRichCapabilityInput` supply field metadata and bounded validation. Relationships, actions, transitions, and workflow metadata describe possible interactions. The package does not render, own client state, or call a host operation. The React laboratory is outside the published package. The v0.1.0 Git tag exists and `npm view @veil-runtime/ui@0.1.0 version` confirms npm publication; package metadata and documentation call it a Developer Preview. The root exports and `/inspection` remain supported.
6
+
7
+ **New in this branch:** `ComposedApplication` defines one screen tree with a form, field bindings, action IDs, and optional initial values. `createCompositionController` owns one instance's state and submission lifecycle. `mountComposedApplication` renders a native HTML form and subscribes to that controller. Registered DOM components can wrap or extend the tree. These are separate from `ApplicationManifest`; neither replaces the capability graph.
8
+
9
+ The new architecture is deliberately one-screen and one-level of form state. A composed definition is plain bounded data. It holds no executable code; handlers and custom components are registered separately by trusted host code. The controller snapshots the definition, validates all fields before action execution, passes a copied input to a handler, prevents duplicate submissions, and exposes copied state snapshots. The host owns the handler's authorization, transport, persistence, result reconciliation, and server validation. The mount disposer removes DOM listeners and its subscription. Call `controller.dispose()` when the application instance is finished; pending handler results are ignored, and the host remains responsible for cancelling its own network request if needed.
10
+
11
+ ## Install and use
12
+
13
+ `@veil-runtime/core` is an optional peer for this API. New composition consumers may install only `@veil-runtime/ui`; existing Core-based graph APIs continue to work when Core is installed. The package remains ESM with Node 24+ for server-side use. Use a browser bundler for the DOM entry point.
14
+
15
+ ```ts
16
+ import { createCompositionController, defineComposedApplication } from '@veil-runtime/ui/composition';
17
+ import { mountComposedApplication } from '@veil-runtime/ui/dom';
18
+
19
+ const definition = defineComposedApplication({
20
+ id: 'entry', title: 'Treatment entry',
21
+ fields: { lot: { type: 'string', required: true, description: 'Lot', minLength: 2 } },
22
+ actions: ['save'],
23
+ screen: { component: 'Form', children: [
24
+ { component: 'Field', field: 'lot' },
25
+ { component: 'Submit', action: 'save', text: 'Save' },
26
+ { component: 'Status' },
27
+ ] },
28
+ });
29
+ const controller = createCompositionController(definition, {
30
+ save: async (input) => { await myAuthorizedOperation(input); return { ok: true }; },
31
+ });
32
+ const dispose = mountComposedApplication(document.getElementById('app')!, controller);
33
+ // When finished with this instance:
34
+ dispose();
35
+ controller.dispose();
36
+ ```
37
+
38
+ The complete browser example is in [`examples/composed`](../examples/composed). From the repository root, build the package, run `node packages/ui/examples/composed/build.mjs`, then serve `packages/ui/examples/composed` with any static HTTP server. Its action is mocked: lot `FAIL` returns `{ ok: false }`, while other valid entries succeed. The bundle is generated and is not source control input. With Chrome installed, `npm --workspace @veil-runtime/ui run test:browser` checks mobile layout, field errors, Enter-key submission, success, and failure.
39
+
40
+ ## Contracts
41
+
42
+ Built-in components are `Stack`, `Form`, `Field`, `Submit`, `Text`, and `Status`. The schema requires every declared field and action to appear once. Fields and submits must be inside a form; nested forms are rejected. `Field` uses the existing rich input field vocabulary for labels, constraints, and control choice. Primitive fields, dates, multiline text, and finite enums are supported. Structured object and array fields require future work; they are rejected in this renderer. `setField` updates one value and emits a snapshot. `submit` validates the complete input and invokes only its registered handler. The handler must return exactly `{ ok: true }` or `{ ok: false }`; malformed results, rejection, and thrown errors fail safely. States are `idle`, `submitting`, `success`, and `error`. Failures produce a generic public message, without leaking handler details.
43
+
44
+ To register an application-specific component, include its name when creating the controller and supply the same name to the DOM mount:
45
+
46
+ ```ts
47
+ const components = {
48
+ Card(_node, { renderChildren }) {
49
+ const card = document.createElement('section');
50
+ card.className = 'card';
51
+ card.append(...renderChildren());
52
+ return card;
53
+ },
54
+ };
55
+ const controller = createCompositionController(definition, handlers, Object.keys(components));
56
+ mountComposedApplication(target, controller, components);
57
+ ```
58
+
59
+ The DOM renderer writes strings with `textContent`, uses native label/control association, native submit behavior (including Enter), visible field errors, `aria-invalid`, and a polite live status region. On invalid submission it focuses the first invalid field. It uses a single-column layout with full-width controls. Mounting a new screen into the same target disposes the previous mount after the replacement renders successfully; a failed replacement leaves the prior screen mounted. The caller still owns the controller and must dispose it separately. Host CSS controls theme and may strengthen contrast and focus styles. Custom renderers are trusted code and must implement their own accessibility and escaping.
60
+
61
+ Untrusted definitions and inputs must be byte-limited before decoding. The library then applies the v0.1.0 bounded-data rules and rejects unknown components, invalid bindings, duplicate fields/actions, unsafe keys, and invalid initial values. Schema validation is not authorization or server-side business validation. Avoid rendering or running untrusted custom component code.
62
+
63
+ ## Core boundary and compatibility
64
+
65
+ Core integration is an action-handler choice. A host may call its authorized Core operation in `save`; the composition package does not import Core at runtime, construct Core capabilities, or change Core's policies. The server must revalidate input and enforce authorization independently. Existing v0.1.0 root exports, `/inspection`, graph behavior, and deterministic validation remain available. The new `/composition` and `/dom` subpaths let an independent TypeScript consumer avoid Core declarations. The root declaration surface still includes legacy Core-based types and therefore still requires Core for strict type-checking of legacy imports.
66
+
67
+ This foundation does not provide navigation, nested or repeating forms, asynchronous field validation, conditional visibility, persistence, a drag-and-drop builder, backend APIs, or automatic Core mapping. Chromium mobile and desktop smoke checks cover labels, focus, errors, keyboard submission, escaping and remounting. Firefox, WebKit, and assistive technology behavior remain unverified. A release candidate requires maintainer review of compatibility and security evidence.
package/docs/DECISIONS.md CHANGED
@@ -9,7 +9,7 @@ Baseline: commit `4c073681e69ef5e323e5ba1a0ff11b4e93898eac`, Astra audit dated 2
9
9
  5. Real calendar dates/UTC instants are required across backend and field validators. Format-only acceptance of impossible dates is intentionally removed. Existing schema vocabulary and signatures remain intact.
10
10
  6. Compiler output has no source/declaration maps, avoiding references to omitted source files. Build manifests fingerprint the source, build scripts, package metadata, compiler configuration and outputs. Packing builds and verifies; artifacts and legal files are inspected in a clean consumer. Repeated tarball bytes must match.
11
11
  7. LICENSE/NOTICE apply to this independent package. No third-party dependency is bundled in dist; dependency licenses remain in their own packages. Production licenses are inventoried separately from laboratory tooling, with an explicit allowlist that fails on an unreviewed license.
12
- 8. Publication enablement removes `private: true` while retaining the failing `prepublishOnly` guard against source-directory publication. Only an exact qualified tarball is eligible for separately authorized publication; npm does not run directory lifecycle hooks for a tarball. Public npm access and the `preview` tag are explicit defaults. Qualification requires the public metadata, verifies directory refusal and performs a tarball-only dry-run with matching integrity. Neither qualification nor CI publishes or supplies publication credentials. The maintainer must approve the final SHA-256 and release action explicitly.
12
+ 8. Publication enablement removes `private: true` while retaining the failing `prepublishOnly` guard against source-directory publication. Only an exact qualified tarball is eligible for separately authorized publication; npm does not run directory lifecycle hooks for a tarball. Public npm access was the initial default, with the `preview` tag for release candidates. The stable 0.4.0 metadata sets `latest`. Qualification requires the public metadata, verifies directory refusal and performs a tarball-only dry-run with matching integrity. Neither qualification nor CI publishes or supplies publication credentials. The maintainer must approve the final SHA-256 and release action explicitly.
13
13
 
14
14
  Strict output checks exposed previously undeclared lab results. Required compatibility-only fixes now return explicit public DTOs for Orders, Tickets, and Tasks; history remains available through order.history instead of the undeclared order.get.history field. No validator exception was added. A targeted output-contract regression covers the adapters.
15
15