@veil-runtime/ui 0.4.1 → 0.5.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.
package/dist/interface.js CHANGED
@@ -1,28 +1,45 @@
1
- import { assertBoundedData, assertSafeKey, copyData } from "./validation-safety.js";
1
+ import { assertBoundedData, assertSafeKey, boundedDataLimits, copyData } from "./validation-safety.js";
2
2
  import { attachInterfaceRenderers, detachInterfaceRenderers } from "./interface-transaction.js";
3
+ const rowIdentifier = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
4
+ const rowName = (value) => typeof value === "string" && rowIdentifier.exec(value)?.[0] === value;
3
5
  const identifier = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/;
6
+ const interfaceLimits = { treeDepth: 15, nodes: 512, textLength: 4096, tableRows: 100, tableColumns: 20, listItems: 100, selectOptions: 100, conditionDepth: 8, conditionNodes: 64, conditionTerms: 16, rowActions: 8, rowIdLength: 128 };
7
+ const presentationPhases = ["loading", "ready", "empty", "error"];
4
8
  const commonProperties = ["key", "component", "visible", "ariaLabel"];
5
9
  const vocabulary = {
6
10
  Stack: { properties: [...commonProperties, "children"], required: [], children: true, event: null },
7
11
  Row: { properties: [...commonProperties, "children"], required: [], children: true, event: null },
12
+ Grid: { properties: [...commonProperties, "children", "columns", "columnsAt", "gap"], required: [], children: true, event: null },
8
13
  Text: { properties: [...commonProperties, "text"], required: [], children: false, event: null },
9
14
  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" },
15
+ Status: { properties: [...commonProperties, "text", "phase"], required: [], children: false, event: null },
16
+ Input: { properties: [...commonProperties, "description", "disabled", "readOnly", "label", "binding", "inputType", "validation"], required: ["label", "binding"], children: false, event: "state change" },
17
+ Textarea: { properties: [...commonProperties, "description", "disabled", "readOnly", "label", "binding", "validation"], required: ["label", "binding"], children: false, event: "state change" },
18
+ Select: { properties: [...commonProperties, "description", "disabled", "label", "binding", "validation", "options"], required: ["label", "binding", "options"], children: false, event: "state change" },
14
19
  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 },
20
+ Table: { properties: [...commonProperties, "binding", "columns", "rowKey", "rowActions"], required: ["binding", "columns"], children: false, event: "row action (opt-in)" },
16
21
  List: { properties: [...commonProperties, "binding"], required: ["binding"], children: false, event: null },
17
22
  };
18
23
  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"]);
24
+ const nodeKeys = new Set(["phase", "key", "component", "children", "text", "binding", "visible", "action", "label", "inputType", "columns", "columnsAt", "gap", "ariaLabel", "disabled", "validation", "options", "validate", "rowKey", "rowActions", "description", "readOnly"]);
20
25
  /** JSON-safe description of the built-in vocabulary. Host extensions are trusted code and are never advertised here. */
21
26
  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 } });
27
+ return copyData({ version: 5, 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",
28
+ pathPattern: identifier.source, separator: ".", emptyInteriorSegmentsAccepted: true, forbiddenSegments: ["__proto__", "prototype", "constructor"], ownPropertiesOnly: true, exactProperties: ["source", "path"] },
29
+ propertyTypes: { phase: "Status loading|ready|empty|error literal or binding", rowKey: "direct own-property identifier", rowActions: "array of {action:declared identifier,label:string}", key: "identifier", component: "component name", children: "node array", text: "string or binding", binding: "binding", visible: "condition", disabled: "Button or field condition", description: "field plain string; maximum 4096 UTF-16 units", readOnly: "text/number Input or Textarea boolean", 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: "Table array of {key:identifier,label:string,presentation?:text|badge} or Grid column count", columnsAt: "Grid {sm?,lg?} column counts", gap: ["none", "sm", "md", "lg"], ariaLabel: "string" },
30
+ events: { action: { fields: ["action", "source", "state", "revision"], optionalFields: ["rowId"], declaration: "definition.actions and host allowedActions" }, state: { type: "scalar", source: "state binding" } },
31
+ limits: { treeDepth: interfaceLimits.treeDepth, nodes: interfaceLimits.nodes, textLength: interfaceLimits.textLength, tableRows: interfaceLimits.tableRows, tableColumns: interfaceLimits.tableColumns, listItems: interfaceLimits.listItems, transportBytes: boundedDataLimits.bytes,
32
+ selectOptions: interfaceLimits.selectOptions, conditionDepth: interfaceLimits.conditionDepth, conditionNodes: interfaceLimits.conditionNodes, conditionTerms: interfaceLimits.conditionTerms,
33
+ rowActions: interfaceLimits.rowActions, rowIdLength: interfaceLimits.rowIdLength, aggregate: boundedDataLimits, treeDepthMeaning: "maximum child edges; aggregate traversal can reject shallower definitions with nested data or conditions" },
34
+ behavior: {
35
+ presentation: { component: "Status", phases: ["loading", "ready", "empty", "error"], phaseBinding: "required exact enum at every snapshot", message: "plain string; maximum 4096 UTF-16 units; absent text uses phase default", defaults: { loading: "Loading…", ready: "Ready.", empty: "No results.", error: "Unable to complete the request." }, accessibility: "polite atomic status; no aria-busy on message; tabindex=-1", focus: "retain eligible focus; lost/hidden/disabled focus moves to first visible phased Status", retry: "ordinary host-allowlisted Button; no automatic execution", transitions: "existing revision-protected updates; host must supply expectedRevision", orchestration: "host-owned" },
36
+ collections: { component: "Table", rowKey: "direct own property; optional without actions", rowIdPattern: rowIdentifier.source, rowIdMatching: "entire string; no trailing line terminators", duplicateOrMissingIds: "reject candidate atomically", missingBinding: "empty", rowActionsRequire: ["rowKey", "explicit host allowedActions"], presentations: ["text", "badge"], defaultPresentation: "text", badge: "neutral text; no inferred semantics or live region", dispatch: "dispatchRow(action,source,rowId,expectedRevision)", source: "Table.key", staleRevisionOrMissingRow: "false; no event", identityScope: ["interface id", "Table key", "binding source", "binding path", "rowKey", "rowId"], reconciliation: "retain matching rows and action buttons; focus follows retained controls", keyboard: "native Tab/Shift+Tab; Enter/Space", gesture: "capture revision at pointerdown/activation keydown; click-only activation uses rendered revision; reject changes during propagation", authorization: "rowId is an opaque lookup reference; host must authorize actual record", eventRowData: false },
37
+ grid: { columns: [1, 2, 3, 4], defaultColumns: 1, breakpoints: { sm: 600, lg: 1024 }, semantics: "mobile-first; sm applies at 600px and lg at 1024px; omitted values inherit the preceding count", gaps: { none: "0", sm: "0.5rem", md: "0.75rem", lg: "1.5rem" }, defaultGap: "md", tracks: "equal minmax(0,1fr)", order: "DOM and keyboard order follow children" },
38
+ conditions: { forms: ["equals", "operator", "all", "any", "not"], operators: ["ne", "lt", "lte", "gt", "gte"], orderedValueType: "finite number", equality: "strict", minTerms: 1, maxTerms: interfaceLimits.conditionTerms, maxDepth: interfaceLimits.conditionDepth, maxNodes: interfaceLimits.conditionNodes, missingNumericMatches: false },
39
+ validation: { fields: ["Input", "Textarea", "Select"], rules: ["required", "minLength", "maxLength", "min", "max"], lengthIntegerRange: [0, 4096], numericRulesRequire: { component: "Input", inputType: "number" }, lengthExcludedInputTypes: ["number", "checkbox"], reversedBounds: "reject", hiddenFields: "excluded", disabledFields: "excluded; state retained", readOnlyFields: "validated; host may set state", feedback: "eager on every accepted snapshot; describedby and errormessage", submissionFocus: "first invalid field on DOM validating button activation", trigger: "Button.validate true" },
40
+ actions: { declaration: "definition.actions", hostAllowlist: "allowedActions", source: "Button.key or Table.key for row events", optionalEventFields: ["rowId"], hiddenOrDisabled: "not dispatchable", eventFields: ["action", "source", "state", "revision"], stateCopy: true, authorization: "host-owned" },
41
+ updates: { data: "replace", definition: "replace", expectedRevision: "optional; stale returns false", invalidCandidate: "throws without advancing accepted revision", rendererPreparation: "before acceptance", subscriptions: "after acceptance", listenerFeedbackPasses: 32, compatibleState: ["same interface id", "same input key", "same component and input type", "same binding path", "compatible declared default"], disposal: "later data and definition updates return false" },
42
+ } });
26
43
  }
27
44
  const plain = (value) => !!value && typeof value === "object" && !Array.isArray(value);
28
45
  const exact = (value, allowed) => {
@@ -54,20 +71,37 @@ function visitNodes(node, callback) {
54
71
  }
55
72
  function validateBindings(definition, data, state) {
56
73
  visitNodes(definition.screen, (node) => {
74
+ if (node.component === "Status" && node.phase !== undefined) {
75
+ const phase = typeof node.phase === "string" ? node.phase : valueAt(node.phase.source === "data" ? data : state, node.phase.path);
76
+ if (!presentationPhases.includes(phase))
77
+ throw new Error("Invalid Status phase");
78
+ const text = typeof node.text === "object" ? valueAt(node.text.source === "data" ? data : state, node.text.path) : node.text;
79
+ if (node.text !== undefined && (typeof text !== "string" || text.length > interfaceLimits.textLength))
80
+ throw new Error("Invalid Status message");
81
+ }
57
82
  if (node.component === "Table") {
58
83
  const rows = valueAt(node.binding.source === "data" ? data : state, node.binding.path);
59
84
  if (rows === undefined)
60
85
  return;
61
- if (!Array.isArray(rows) || rows.length > 100 || rows.some((row) => !plain(row)))
86
+ if (!Array.isArray(rows) || rows.length > interfaceLimits.tableRows || rows.some((row) => !plain(row)))
62
87
  throw new Error("Invalid or oversized table rows");
88
+ if (node.rowKey) {
89
+ const ids = new Set();
90
+ for (const row of rows) {
91
+ const id = Object.hasOwn(row, node.rowKey) ? row[node.rowKey] : undefined;
92
+ if (!rowName(id) || ids.has(id))
93
+ throw new Error("Invalid, missing or duplicate row ID");
94
+ ids.add(id);
95
+ }
96
+ }
63
97
  for (const row of rows)
64
- for (const col of node.columns ?? [])
98
+ for (const col of Array.isArray(node.columns) ? node.columns : [])
65
99
  if (Object.hasOwn(row, col.key) && !scalar(row[col.key]))
66
100
  throw new Error("Invalid table cell");
67
101
  }
68
102
  if (node.component === "List") {
69
103
  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))))
104
+ if (items !== undefined && (!Array.isArray(items) || items.length > interfaceLimits.listItems || items.some((item) => !scalar(item))))
71
105
  throw new Error("Invalid or oversized list items");
72
106
  }
73
107
  if (isInterfaceField(node)) {
@@ -128,7 +162,7 @@ export function validateInterfaceDefinition(value, components = []) {
128
162
  const keys = new Set();
129
163
  let count = 0;
130
164
  function visit(node, depth) {
131
- if (!plain(node) || depth > 16 || ++count > 512)
165
+ if (!plain(node) || depth > interfaceLimits.treeDepth || ++count > interfaceLimits.nodes)
132
166
  throw new Error("Interface tree limit exceeded");
133
167
  exact(node, nodeKeys);
134
168
  if (!name(node.key) || keys.has(node.key))
@@ -147,21 +181,35 @@ export function validateInterfaceDefinition(value, components = []) {
147
181
  }
148
182
  if (node.text !== undefined) {
149
183
  if (typeof node.text === "string") {
150
- if (node.text.length > 4096)
184
+ if (node.text.length > interfaceLimits.textLength)
151
185
  throw new Error("Text limit exceeded");
152
186
  }
153
187
  else
154
188
  binding(node.text);
155
189
  }
190
+ if (node.phase !== undefined) {
191
+ if (node.component !== "Status")
192
+ throw new Error("Only Status accepts phase");
193
+ if (typeof node.phase === "string") {
194
+ if (!presentationPhases.includes(node.phase))
195
+ throw new Error("Invalid Status phase");
196
+ }
197
+ else
198
+ binding(node.phase);
199
+ }
156
200
  if (node.binding !== undefined)
157
201
  binding(node.binding);
158
202
  if (node.visible !== undefined)
159
203
  validateCondition(node.visible);
160
204
  if (node.disabled !== undefined) {
161
- if (node.component !== "Button")
162
- throw new Error("Only Button accepts disabled");
205
+ if (node.component !== "Button" && !isInterfaceField(node))
206
+ throw new Error("Only Button or fields accept disabled");
163
207
  validateCondition(node.disabled);
164
208
  }
209
+ if (node.description !== undefined && (!isInterfaceField(node) || typeof node.description !== "string" || node.description.length > interfaceLimits.textLength))
210
+ throw new Error("Invalid field description");
211
+ if (node.readOnly !== undefined && (typeof node.readOnly !== "boolean" || !(node.component === "Textarea" || (node.component === "Input" && node.inputType !== "checkbox"))))
212
+ throw new Error("Invalid readOnly field");
165
213
  for (const field of ["label", "ariaLabel"])
166
214
  if (node[field] !== undefined && (typeof node[field] !== "string" || node[field].length > 256))
167
215
  throw new Error(`Invalid ${field}`);
@@ -172,7 +220,7 @@ export function validateInterfaceDefinition(value, components = []) {
172
220
  if (node.validate !== undefined && (node.component !== "Button" || typeof node.validate !== "boolean"))
173
221
  throw new Error("Invalid validate flag");
174
222
  if (node.options !== undefined) {
175
- if (node.component !== "Select" || !Array.isArray(node.options) || node.options.length < 1 || node.options.length > 100)
223
+ if (node.component !== "Select" || !Array.isArray(node.options) || node.options.length < 1 || node.options.length > interfaceLimits.selectOptions)
176
224
  throw new Error("Invalid select options");
177
225
  const values = new Set();
178
226
  for (const option of node.options) {
@@ -202,10 +250,36 @@ export function validateInterfaceDefinition(value, components = []) {
202
250
  if (rules.minLength > rules.maxLength || rules.min > rules.max)
203
251
  throw new Error("Reversed validation bounds");
204
252
  }
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))
253
+ if (node.component === "Grid") {
254
+ if (node.columns !== undefined && ![1, 2, 3, 4].includes(node.columns))
255
+ throw new Error("Invalid Grid columns");
256
+ if (node.columnsAt !== undefined && (!plain(node.columnsAt) || Object.keys(node.columnsAt).some((key) => !["sm", "lg"].includes(key)) || Object.values(node.columnsAt).some((count) => ![1, 2, 3, 4].includes(count))))
257
+ throw new Error("Invalid Grid breakpoints");
258
+ if (node.gap !== undefined && !["none", "sm", "md", "lg"].includes(node.gap))
259
+ throw new Error("Invalid Grid gap");
260
+ }
261
+ else if (node.columns !== undefined) {
262
+ if (!Array.isArray(node.columns) || node.columns.length < 1 || node.columns.length > interfaceLimits.tableColumns || node.columns.some((col) => !plain(col) || Object.keys(col).some((key) => key !== "key" && key !== "label" && key !== "presentation") || (col.presentation !== undefined && !["text", "badge"].includes(col.presentation)) || !name(col.key) || typeof col.label !== "string" || col.label.length > 256))
207
263
  throw new Error("Invalid table columns");
208
264
  }
265
+ if (node.rowKey !== undefined) {
266
+ if (node.component !== "Table" || !name(node.rowKey))
267
+ throw new Error("Invalid Table rowKey");
268
+ assertSafeKey(node.rowKey);
269
+ }
270
+ if (node.rowActions !== undefined) {
271
+ if (node.component !== "Table" || !node.rowKey || !Array.isArray(node.rowActions) || node.rowActions.length < 1 || node.rowActions.length > interfaceLimits.rowActions)
272
+ throw new Error("Invalid Table rowActions");
273
+ const ids = new Set();
274
+ for (const item of node.rowActions) {
275
+ if (!plain(item))
276
+ throw new Error("Invalid row action");
277
+ exact(item, new Set(["action", "label"]));
278
+ if (!name(item.action) || !actions.includes(item.action) || ids.has(item.action) || typeof item.label !== "string" || !item.label.trim() || item.label.length > 256)
279
+ throw new Error("Invalid or undeclared row action");
280
+ ids.add(item.action);
281
+ }
282
+ }
209
283
  if (isInterfaceField(node) && (!node.binding || node.binding.source !== "state" || !node.label))
210
284
  throw new Error("Input needs a state binding and label");
211
285
  if (node.component === "Button" && (!node.action || !node.text))
@@ -216,9 +290,9 @@ export function validateInterfaceDefinition(value, components = []) {
216
290
  throw new Error("Only Button accepts an action");
217
291
  if (node.component !== "Input" && node.inputType !== undefined)
218
292
  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))))
293
+ if (node.component !== "Table" && node.component !== "Grid" && node.columns !== undefined)
294
+ throw new Error("Only Table and Grid accept columns");
295
+ if (node.children !== undefined && (!Array.isArray(node.children) || (!(["Stack", "Row", "Grid"].includes(node.component)) && !components.includes(node.component))))
222
296
  throw new Error("Invalid children");
223
297
  for (const child of node.children ?? [])
224
298
  visit(child, depth + 1);
@@ -228,9 +302,17 @@ export function validateInterfaceDefinition(value, components = []) {
228
302
  }
229
303
  export function createInterfaceController(definition, options = {}) {
230
304
  const components = options.components ?? [];
305
+ const allowedActions = options.allowedActions ? [...options.allowedActions] : undefined;
306
+ const checkActions = (candidate) => {
307
+ visitNodes(candidate.screen, node => {
308
+ if (node.rowActions && !allowedActions)
309
+ throw new Error("Table row actions require explicit host allowedActions");
310
+ });
311
+ if (allowedActions && candidate.actions?.some(action => !allowedActions.includes(action)))
312
+ throw new Error("Action is not allowed by host");
313
+ };
231
314
  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");
315
+ checkActions(definition);
234
316
  let current = copyData(definition);
235
317
  let data = copyData(definition.data ?? {});
236
318
  let state = copyData(definition.state ?? {});
@@ -334,8 +416,7 @@ export function createInterfaceController(definition, options = {}) {
334
416
  if (updating)
335
417
  throw new Error("Interface update already in progress");
336
418
  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");
419
+ checkActions(next);
339
420
  const copy = copyData(next);
340
421
  const nextData = copyData(copy.data ?? {});
341
422
  const nextState = retainedState(current, copy, state);
@@ -369,6 +450,44 @@ export function createInterfaceController(definition, options = {}) {
369
450
  validateBindings(current, data, next);
370
451
  accept(current, data, next);
371
452
  },
453
+ dispatchRow(action, source, rowId, expectedRevision) {
454
+ if (!name(action) || !name(source) || !rowName(rowId) || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0)
455
+ throw new Error("Invalid row dispatch");
456
+ if (disposed || dispatching)
457
+ return false;
458
+ if (updating)
459
+ throw new Error("Interface update already in progress");
460
+ if (expectedRevision !== revision)
461
+ return false;
462
+ let table;
463
+ const find = (node) => {
464
+ if (node.visible && !evaluateInterfaceCondition(node.visible, { data, state }))
465
+ return;
466
+ if (node.key === source && node.component === "Table")
467
+ table = node;
468
+ for (const child of node.children ?? [])
469
+ find(child);
470
+ };
471
+ find(current.screen);
472
+ if (!table?.rowKey || !allowedActions?.includes(action) || !current.actions?.includes(action) || !table.rowActions?.some(item => item.action === action))
473
+ throw new Error("Row action is not available");
474
+ const rows = valueAt(table.binding.source === "data" ? data : state, table.binding.path);
475
+ if (!Array.isArray(rows) || rows.filter(row => Object.hasOwn(row, table.rowKey) && row[table.rowKey] === rowId).length !== 1)
476
+ return false;
477
+ dispatching = true;
478
+ try {
479
+ const result = options.onEvent?.({ action, source, rowId, state: copyData(state), revision });
480
+ if (result && typeof result === "object" && "then" in result)
481
+ void Promise.resolve(result).catch(report);
482
+ }
483
+ catch (error) {
484
+ report(error);
485
+ }
486
+ finally {
487
+ dispatching = false;
488
+ }
489
+ return true;
490
+ },
372
491
  dispatch(action, source) {
373
492
  if (disposed || dispatching)
374
493
  return;
@@ -420,13 +539,13 @@ export function resolveInterfaceBinding(snapshot, bindingValue) {
420
539
  function validateCondition(value) {
421
540
  let count = 0;
422
541
  function visit(value, depth) {
423
- if (!plain(value) || depth > 8 || ++count > 64)
542
+ if (!plain(value) || depth > interfaceLimits.conditionDepth || ++count > interfaceLimits.conditionNodes)
424
543
  throw new Error("Invalid condition or condition limit exceeded");
425
544
  if (Object.hasOwn(value, "all") || Object.hasOwn(value, "any")) {
426
545
  const key = Object.hasOwn(value, "all") ? "all" : "any";
427
546
  exact(value, new Set([key]));
428
547
  const terms = value[key];
429
- if (!Array.isArray(terms) || terms.length < 1 || terms.length > 16)
548
+ if (!Array.isArray(terms) || terms.length < 1 || terms.length > interfaceLimits.conditionTerms)
430
549
  throw new Error("Invalid condition terms");
431
550
  for (const term of terms)
432
551
  visit(term, depth + 1);
@@ -478,13 +597,13 @@ export function evaluateInterfaceCondition(condition, snapshot) {
478
597
  export function isInterfaceField(node) {
479
598
  return ["Input", "Textarea", "Select"].includes(node.component);
480
599
  }
481
- /** Errors are recomputed from accepted state; hidden fields are excluded, including hidden ancestors. */
600
+ /** Errors are recomputed from accepted state; hidden and disabled fields are excluded, including hidden ancestors. */
482
601
  export function getInterfaceValidation(snapshot) {
483
602
  const errors = {};
484
603
  function visit(node) {
485
604
  if (node.visible && !evaluateInterfaceCondition(node.visible, snapshot))
486
605
  return;
487
- if (isInterfaceField(node)) {
606
+ if (isInterfaceField(node) && !(node.disabled && evaluateInterfaceCondition(node.disabled, snapshot))) {
488
607
  const value = valueAt(snapshot.state, node.binding.path);
489
608
  const rules = node.validation ?? {};
490
609
  const empty = value === undefined || value === null || value === "";
@@ -1,3 +1,13 @@
1
+ /** Internal transport limits. Hosts must also limit bytes before decoding JSON. */
2
+ /** Internal aggregate traversal budget, shared with public capability metadata. */
3
+ export declare const boundedDataLimits: {
4
+ readonly depth: 32;
5
+ readonly nodes: 10000;
6
+ readonly keys: 256;
7
+ readonly items: 1000;
8
+ readonly string: 65536;
9
+ readonly bytes: 1048576;
10
+ };
1
11
  export declare function assertSafeKey(key: string): void;
2
12
  /** Inspect descriptors, never getters; bound work before recursive or Zod validation. */
3
13
  export declare function assertBoundedData(value: unknown): void;
@@ -1,5 +1,7 @@
1
1
  /** Internal transport limits. Hosts must also limit bytes before decoding JSON. */
2
- const limits = { depth: 32, nodes: 10000, keys: 256, items: 1000, string: 65536, bytes: 1048576 };
2
+ /** Internal aggregate traversal budget, shared with public capability metadata. */
3
+ export const boundedDataLimits = { depth: 32, nodes: 10000, keys: 256, items: 1000, string: 65536, bytes: 1048576 };
4
+ const limits = boundedDataLimits;
3
5
  const reserved = new Set(["__proto__", "prototype", "constructor"]);
4
6
  export function assertSafeKey(key) {
5
7
  if (reserved.has(key))
package/docs/API.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # Public API inventory
2
2
 
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.
3
+ The current source is the unpublished **@veil-runtime/ui v0.5.0 candidate**; v0.4.2 is the published baseline recorded by this repository. Its package exports are the root `@veil-runtime/ui` and the `/inspection`, `/composition`, `/interface`, and `/dom` subpaths. Import through these entry points rather than internal `dist` files. The root also re-exports the interface and composition modules; the subpaths make their boundaries explicit. Updates prepare detached custom instances before controller acceptance.
4
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.
5
+ `/interface` exports `defineInterface`, `validateInterfaceDefinition`, `createInterfaceController`, `getInterfaceCapabilities`, `resolveInterfaceBinding`, `getInterfaceValidation`, and `evaluateInterfaceCondition`, with their public types including `BuiltinInterfaceDefinition` and `InterfaceConditionExpression`. `/dom` exports `mountInterface` and `createMountedInterface` alongside the composition mount. A trusted `InterfaceDOMComponent` can return an `HTMLElement` or `{ element, update?, dispose? }`. See [UNIVERSAL-INTERFACE.md](UNIVERSAL-INTERFACE.md) and the [v0.4.0 contract guide](v0.4.0.md) for the preserved validation and conditions. The v0.5.0 built-ins and migration requirements are described in the current universal guide and [release notes](RELEASE-0.5.0.md).
6
+
7
+ The table below inventories the preserved capability-description API that first shipped in v0.1.0; `/composition` preserves the later one-screen composition API. The v0.3.0 release-candidate records describe when `/interface` and its DOM adapter were introduced, not their current release status or complete current behavior.
6
8
 
7
9
  | Area | Root modules / representative API |
8
10
  |---|---|
@@ -16,4 +18,14 @@ The preview keeps all existing root exports; no existing symbol or subpath was r
16
18
  | Experimental optional presentation | `capability-view-derivation` provider interfaces and `deriveCapabilityView`; `presentation-composition`, `buildPresentationEnvelope`, `deterministicPresentation`, `validatePresentationPlan`, `composePresentation`, `presentationPlanSchema`, `sectionId` |
17
19
  | Inspection subpath | `@veil-runtime/ui/inspection`: `buildCompositionInspection` and inspection model types |
18
20
 
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.
21
+ 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 with declared range `^0.4.0` and is unnecessary for standalone `/interface` and `/dom` consumers. This range does not claim Core v0.6.0 compatibility. UI has no compiled Core runtime import. A host may use UI's descriptors in a browser and keep Core execution on its server.
22
+
23
+ ## v0.5.0 interface additions
24
+
25
+ `/interface` and the root export `GridColumns`, `GridGap`, `GridBreakpoints`, `InterfaceCellPresentation`, `InterfaceRowAction`, `InterfaceTableOptions` and `InterfacePresentationPhase`. `InterfaceColumn` adds `presentation`; `InterfaceEvent` adds optional `rowId`; `InterfaceController` requires `dispatchRow(action, source, rowId, expectedRevision): boolean`. The final manifest version is 5.
26
+
27
+ Other public interface helpers are `evaluateInterfaceCondition`, `getInterfaceValidation` and `isInterfaceField`. `defineInterface` is a typing helper, not validation. Call `validateInterfaceDefinition` or create a controller to validate received JSON. The broad `InterfaceNode` permits registered custom names; use `BuiltinInterfaceDefinition` for component-specific static checks. Runtime validation remains authoritative.
28
+
29
+ `/dom` exports `mountComposedApplication`, `DOMComponent`, `DOMComponentRegistry`, `mountInterface`, `createMountedInterface`, `InterfaceDOMComponent`, `InterfaceDOMComponentInstance` and `InterfaceDOMRegistry`. `/composition` exports its definition/controller, handler, snapshot and result types, and `defineComposedApplication`, `validateComposedApplication`, `createCompositionController`.
30
+
31
+ Custom controller implementations must implement the required row method with the documented governance checks. The native `mountInterface` additionally requires the private preparation bridge installed by `createInterfaceController`; a structurally matching custom controller alone cannot mount with this adapter. Use the supplied controller for native mounts, or a custom adapter consuming snapshots/subscriptions. No private bridge import is supported. See [migration guidance](RELEASE-0.5.0.md).
@@ -0,0 +1,109 @@
1
+ # Host-driven asynchronous presentation (v0.5.0)
2
+
3
+ ## Design decision (before implementation)
4
+
5
+ Extend the existing Status leaf with optional `phase`, a literal `loading`,
6
+ `ready`, `empty` or `error`, or an existing `{source,path}` binding resolving to
7
+ exactly one of those strings. No phase means legacy Status behavior. A phased
8
+ Status supplies a reusable accessible message and focus destination. Existing
9
+ conditions compose content and ordinary Buttons request retry. A state container
10
+ would duplicate condition evaluation, subtree lifecycle and field validation;
11
+ conditions alone lack reusable state semantics, safe message bounds and a focus
12
+ destination. No new state engine or controller API is needed.
13
+
14
+ TypeScript: `InterfacePresentationPhase = "loading" | "ready" | "empty" | "error"`;
15
+ `Status.phase?: InterfacePresentationPhase | InterfaceBinding`. Text remains the
16
+ existing string or binding. For phased Status only, supplied text must resolve to
17
+ a string of at most 4096 UTF-16 units, even when hidden. A missing text property
18
+ uses `Loading…`, `Ready.`, `No results.` or `Unable to complete the request.`.
19
+ An explicit empty string is accepted. Missing/invalid phase or supplied bound text
20
+ rejects the whole candidate without revision/DOM changes. Unknown properties,
21
+ unsafe paths, aggregate bounds and existing tree limits remain authoritative.
22
+
23
+ ## JSON contract example
24
+
25
+ ```json
26
+ {
27
+ "id": "results",
28
+ "actions": ["retry"],
29
+ "data": { "phase": "error", "message": "Results are unavailable." },
30
+ "screen": {
31
+ "key": "root", "component": "Stack", "children": [
32
+ {
33
+ "key": "status", "component": "Status",
34
+ "phase": { "source": "data", "path": "phase" },
35
+ "text": { "source": "data", "path": "message" }
36
+ },
37
+ {
38
+ "key": "retry", "component": "Button", "text": "Retry", "action": "retry",
39
+ "visible": { "binding": { "source": "data", "path": "phase" }, "equals": "error" }
40
+ }
41
+ ]
42
+ }
43
+ }
44
+ ```
45
+
46
+ Mount with `allowedActions: ["retry"]` and an `onEvent` host handler. In that
47
+ handler, authorize the action, accept a loading snapshot using `event.revision`,
48
+ then capture `controller.snapshot().revision` for the operation's completion.
49
+ Submit ready/empty/error with that captured revision. Each data update replaces
50
+ the entire data object, so retain other data explicitly. `ready` and `empty` are
51
+ host decisions; Veil does not infer them from collection length.
52
+
53
+ ## Semantics and lifecycle
54
+
55
+ Phased Status retains its keyed paragraph, `role=status`, `aria-live=polite` and
56
+ `aria-atomic=true` across transitions. `data-phase` exposes the accepted phase.
57
+ The message is not marked aria-busy, so loading announcements are not deferred.
58
+ Errors are polite informational messages, not automatic urgent alerts. Text is
59
+ rendered as textContent, never HTML. Veil cannot identify credentials in strings:
60
+ the host must supply approved public messages, never raw exception details,
61
+ stack traces or credentials. Object-valued messages are rejected, not serialized.
62
+ Actual live announcements depend on the browser and assistive technology;
63
+ automated accessibility checks do not certify screen reader behavior.
64
+
65
+ `tabindex=-1` makes phased Status a programmatic focus destination without adding
66
+ a Tab stop. Compatible controls retain identity, focus, selection and user state.
67
+ If an accepted snapshot removes, disables or hides the focused control (including
68
+ an ancestor), focus moves to the first visible phased Status in screen order.
69
+ No move occurs on mount, while focus is outside this interface, when the focused
70
+ control remains eligible, or when there is no visible phased Status. Legacy
71
+ screens retain their existing behavior. Hosts with multiple independent regions
72
+ should keep the intended destination first, or manage focus themselves after the
73
+ update. No automatic focus returns to a formerly hidden control.
74
+
75
+ Hidden children remain mounted under existing conditional semantics, retain field
76
+ state and cannot dispatch actions or edit state. Hidden fields skip validation.
77
+ Disposal removes listeners and DOM; later updates return false. Phase itself does
78
+ not hide, disable, authorize or validate anything. Tables, Lists, Grid and forms
79
+ compose through existing children, conditions and bindings without special cases.
80
+ All bindings (including hidden collections) must remain valid in every snapshot.
81
+
82
+ ## Host protocol and trust boundary
83
+
84
+ Use `updateData(fullReplacement, expectedRevision)` or
85
+ `updateDefinition(definition, expectedRevision)`. Every accepted update advances
86
+ the existing revision. Capture the revision **after** accepting loading; submit
87
+ completion with that revision. A stale completion returns false. Any intervening
88
+ edit/update invalidates that token, including unrelated field edits. A host must
89
+ explicitly decide whether to discard or rebase a result; never blindly retry a
90
+ stale write. Revisions prevent older snapshots overwriting newer snapshots only
91
+ when the host supplies the token. Host operation IDs, cancellation, deduplication,
92
+ authorization and idempotency remain host responsibilities.
93
+
94
+ Retry is an ordinary declared Button action governed by `allowedActions` and
95
+ current visibility/disabled conditions. The host must explicitly allow `retry`,
96
+ authorize the operation and handle its result. No special retry flag, automatic
97
+ retry, timer, network request, promise ownership, job or task queue is introduced.
98
+ As with existing Buttons, dispatch checks current eligibility; phase does not
99
+ provide a gesture revision lock or business authorization. Host callbacks and
100
+ onError diagnostics are never copied into a Status by the renderer.
101
+
102
+ The capability manifest advances from v4 to v5 with phase vocabulary and behavior
103
+ metadata. Existing definitions and controller/event signatures remain compatible;
104
+ consumers pinning an exact manifest version must accept v5. The separate composed
105
+ application Status contract is unchanged.
106
+
107
+ See `examples/universal/async-presentation.mjs` for JSON and the host revision
108
+ protocol. v0.5.0 is an unpublished candidate ready for release qualification only
109
+ after verification, not a release.