@morit/cli 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +53 -18
  2. package/assets/docs/README.md +105 -0
  3. package/assets/docs/ai-response-and-timeline.md +125 -0
  4. package/assets/docs/ai-skill-and-docx-workflow.md +83 -0
  5. package/assets/docs/app-builder.md +56 -0
  6. package/assets/docs/authentication.md +140 -0
  7. package/assets/docs/components.md +159 -0
  8. package/assets/docs/design-tokens-responsive.md +148 -0
  9. package/assets/docs/docs-index.json +93 -0
  10. package/assets/docs/examples-notion.md +83 -0
  11. package/assets/docs/examples-school-life.md +74 -0
  12. package/assets/docs/getting-started.md +132 -0
  13. package/assets/docs/information-hierarchy.md +81 -0
  14. package/assets/docs/instances-and-connectors.md +93 -0
  15. package/assets/docs/lifecycle-and-api.md +158 -0
  16. package/assets/docs/local-cli.md +124 -0
  17. package/assets/docs/manifest.md +208 -0
  18. package/assets/docs/packaging-and-testing.md +115 -0
  19. package/assets/docs/permissions-and-data.md +131 -0
  20. package/assets/docs/platform-compatibility.md +62 -0
  21. package/assets/docs/project-structure.md +102 -0
  22. package/assets/docs/remote-mcp.md +152 -0
  23. package/assets/docs/school-life-privacy.md +49 -0
  24. package/assets/docs/screens-layout-navigation.md +95 -0
  25. package/assets/docs/sdk-and-mcp.md +182 -0
  26. package/assets/docs/tool-and-skill.md +163 -0
  27. package/assets/docs/troubleshooting.md +117 -0
  28. package/assets/docs/ui-extensions.md +70 -0
  29. package/assets/docs/ui-runtime-v2.md +273 -0
  30. package/assets/docs/verification.md +128 -0
  31. package/assets/plugin_contract.json +222 -1
  32. package/package.json +1 -1
  33. package/src/cli.js +27 -3
  34. package/src/secure-store.js +113 -43
  35. package/src/workspace.js +297 -86
package/src/workspace.js CHANGED
@@ -41,6 +41,18 @@ const UI_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
41
41
  const UI_STATE_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
42
42
  const UI_BINDING = /^(?:state|data|item)(?:\.[A-Za-z0-9_-]+){0,12}$/;
43
43
  const UI_TEMPLATE_BINDING = /\{\{\s*([^{}]+?)\s*\}\}/g;
44
+ const UI_SINGLE_TEMPLATE_BINDING = /^\{\{\s*([^{}]+?)\s*\}\}$/;
45
+ const UI_ASSET_PATH = /^assets\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\.(?:gif|jpe?g|png|webp)$/i;
46
+ const UI_HEX_COLOR = /^#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
47
+ const UI_COLOR_TOKENS = new Set([
48
+ "primary", "on_primary", "primary_container", "on_primary_container",
49
+ "secondary", "on_secondary", "secondary_container", "on_secondary_container",
50
+ "tertiary", "on_tertiary", "tertiary_container", "on_tertiary_container",
51
+ "error", "on_error", "error_container", "on_error_container", "surface",
52
+ "on_surface", "surface_variant", "on_surface_variant", "outline", "outline_variant",
53
+ "inverse_surface", "inverse_on_surface", "inverse_primary", "shadow", "scrim", "transparent",
54
+ ]);
55
+ const UI_ICON_TOKENS = new Set(contract.ui_runtime.icon_tokens);
44
56
  const MANIFEST_FIELDS = new Set(contract.manifest_fields);
45
57
  const CAPABILITY_KINDS = new Set(contract.capability_kinds);
46
58
  const CAPABILITY_FIELDS = new Set(contract.object_fields.capability);
@@ -135,7 +147,7 @@ export class LocalWorkspace {
135
147
  version: "1.0.0",
136
148
  cloud_project_id: null,
137
149
  required_secrets: [],
138
- min_morit_version: "1.7.5",
150
+ min_morit_version: "1.7.6",
139
151
  max_morit_version: "1.999.999",
140
152
  permissions: [],
141
153
  capabilities: [],
@@ -739,11 +751,11 @@ function validateManifest(manifest, files) {
739
751
  if (manifest.category !== undefined && !/^[a-z][a-z0-9_-]{1,39}$/.test(String(manifest.category))) {
740
752
  throw new Error("manifest.category is invalid");
741
753
  }
742
- const keywords = manifest.keywords || [];
754
+ const keywords = manifest.keywords === undefined ? [] : manifest.keywords;
743
755
  if (!Array.isArray(keywords) || keywords.length > 12 || new Set(keywords).size !== keywords.length || keywords.some(
744
756
  (value) => typeof value !== "string" || !value.trim() || value.length > 40
745
757
  )) throw new Error("manifest.keywords must contain up to 12 unique short strings");
746
- const developer = manifest.developer || {};
758
+ const developer = manifest.developer === undefined ? {} : manifest.developer;
747
759
  assertObject(developer, "manifest.developer");
748
760
  rejectUnknownFields(developer, DEVELOPER_FIELDS, "manifest.developer");
749
761
  if (developer.name !== undefined && (typeof developer.name !== "string" || developer.name.length > 120)) {
@@ -762,15 +774,15 @@ function validateManifest(manifest, files) {
762
774
  for (const field of ["version", "min_morit_version", "max_morit_version"]) {
763
775
  if (!VERSION.test(String(manifest[field] || ""))) throw new Error(`manifest.${field} must be a semantic version`);
764
776
  }
765
- const requestedPermissions = validatePermissions(manifest.permissions || [], "manifest.permissions");
777
+ const requestedPermissions = validatePermissions(manifest.permissions, "manifest.permissions");
766
778
  if (!["purge", "retain"].includes(manifest.data_policy)) throw new Error("manifest.data_policy must be purge or retain");
767
779
  const capabilities = objectCollection(manifest.capabilities, "manifest.capabilities", 64);
768
780
  const uiExtensions = objectCollection(manifest.ui_extensions, "manifest.ui_extensions", 64);
769
- const credentials = objectCollection(manifest.credentials || [], "manifest.credentials", 16);
770
- const slashCommands = objectCollection(manifest.slash_commands || [], "manifest.slash_commands", 32);
771
- const connectors = objectCollection(manifest.connectors || [], "manifest.connectors", 32);
772
- const dependencies = objectCollection(manifest.dependencies || [], "manifest.dependencies", 16);
773
- const requiredSecrets = objectCollection(manifest.required_secrets || [], "manifest.required_secrets", 32);
781
+ const credentials = objectCollection(manifest.credentials === undefined ? [] : manifest.credentials, "manifest.credentials", 16);
782
+ const slashCommands = objectCollection(manifest.slash_commands === undefined ? [] : manifest.slash_commands, "manifest.slash_commands", 32);
783
+ const connectors = objectCollection(manifest.connectors === undefined ? [] : manifest.connectors, "manifest.connectors", 32);
784
+ const dependencies = objectCollection(manifest.dependencies === undefined ? [] : manifest.dependencies, "manifest.dependencies", 16);
785
+ const requiredSecrets = objectCollection(manifest.required_secrets === undefined ? [] : manifest.required_secrets, "manifest.required_secrets", 32);
774
786
  if (!capabilities.length && !uiExtensions.length) throw new Error("plugin does not declare any extension");
775
787
 
776
788
  const capabilityIds = new Set();
@@ -782,13 +794,13 @@ function validateManifest(manifest, files) {
782
794
  if (!CAPABILITY_KINDS.has(capability.kind)) throw new Error(`unknown capability kind: ${capability.kind}`);
783
795
  requireText(capability.title, 80, `capability ${capability.id} title`);
784
796
  requireText(capability.description, 500, `capability ${capability.id} description`);
785
- const permissions = validatePermissions(capability.permissions || [], `capability ${capability.id} permissions`);
797
+ const permissions = validatePermissions(capability.permissions, `capability ${capability.id} permissions`);
786
798
  requireSubset(permissions, requestedPermissions, `capability ${capability.id} permissions`);
787
- const timeout = capability.timeout_seconds ?? 10;
799
+ const timeout = capability.timeout_seconds === undefined ? 10 : capability.timeout_seconds;
788
800
  if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0.1 || timeout > 30) {
789
801
  throw new Error(`capability ${capability.id} timeout must be between 0.1 and 30 seconds`);
790
802
  }
791
- const runtime = capability.runtime || {};
803
+ const runtime = capability.runtime === undefined ? {} : capability.runtime;
792
804
  assertObject(runtime, `capability ${capability.id} runtime`);
793
805
  assertJsonSize(runtime, 32 * 1024, `capability ${capability.id} runtime`);
794
806
  const adapter = runtime.adapter;
@@ -810,14 +822,17 @@ function validateManifest(manifest, files) {
810
822
  requireUniqueIdentifier(extension.id, uiIds, "UI extension");
811
823
  if (!UI_POINTS.has(extension.point)) throw new Error(`unknown UI extension point: ${extension.point}`);
812
824
  requireText(extension.title, 80, `UI ${extension.id} title`);
813
- const order = extension.order ?? 0;
825
+ const order = extension.order === undefined ? 0 : extension.order;
814
826
  if (!Number.isInteger(order) || order < -10000 || order > 10000) throw new Error(`UI ${extension.id} order is invalid`);
815
- const permissions = validatePermissions(extension.permissions || [], `UI ${extension.id} permissions`);
827
+ const permissions = validatePermissions(extension.permissions, `UI ${extension.id} permissions`);
816
828
  requireSubset(permissions, requestedPermissions, `UI ${extension.id} permissions`);
817
- assertObject(extension.config || {}, `UI ${extension.id} config`);
818
- assertJsonSize(extension.config || {}, 32 * 1024, `UI ${extension.id} config`);
829
+ const extensionConfig = extension.config === undefined ? {} : extension.config;
830
+ assertObject(extensionConfig, `UI ${extension.id} config`);
831
+ // Twelve legal component levels add an object and a children array per
832
+ // level before semantic UI validation runs.
833
+ assertJsonSize(extensionConfig, 32 * 1024, `UI ${extension.id} config`, 40);
819
834
  }
820
- for (const extension of uiExtensions) validateUiConfig(extension, capabilities, uiIds);
835
+ for (const extension of uiExtensions) validateUiConfig(extension, capabilities, uiIds, files);
821
836
 
822
837
  const credentialIds = validateCredentials(credentials, requestedPermissions);
823
838
  const secretIds = validateRequiredSecrets(requiredSecrets);
@@ -896,15 +911,15 @@ function requireText(value, maximum, label) {
896
911
  }
897
912
  }
898
913
 
899
- function assertJsonSize(value, maximum, label) {
900
- validateJsonValue(value, 0, label);
914
+ function assertJsonSize(value, maximum, label, maximumDepth = 12) {
915
+ validateJsonValue(value, 0, label, maximumDepth);
901
916
  if (Buffer.byteLength(JSON.stringify(value), "utf8") > maximum) {
902
917
  throw new Error(`${label} is too large`);
903
918
  }
904
919
  }
905
920
 
906
- function validateJsonValue(value, depth, label) {
907
- if (depth > 12) throw new Error(`${label} is too deeply nested`);
921
+ function validateJsonValue(value, depth, label, maximumDepth) {
922
+ if (depth > maximumDepth) throw new Error(`${label} is too deeply nested`);
908
923
  if (value === null || typeof value === "boolean" || typeof value === "string") return;
909
924
  if (typeof value === "number") {
910
925
  if (!Number.isFinite(value)) throw new Error(`${label} contains a non-finite number`);
@@ -912,14 +927,14 @@ function validateJsonValue(value, depth, label) {
912
927
  }
913
928
  if (Array.isArray(value)) {
914
929
  if (value.length > 256) throw new Error(`${label} contains an oversized array`);
915
- for (const item of value) validateJsonValue(item, depth + 1, label);
930
+ for (const item of value) validateJsonValue(item, depth + 1, label, maximumDepth);
916
931
  return;
917
932
  }
918
933
  if (value && typeof value === "object") {
919
934
  if (Object.keys(value).length > 256) throw new Error(`${label} contains an oversized object`);
920
935
  for (const [key, item] of Object.entries(value)) {
921
936
  if (key.length > 256) throw new Error(`${label} contains an oversized key`);
922
- validateJsonValue(item, depth + 1, label);
937
+ validateJsonValue(item, depth + 1, label, maximumDepth);
923
938
  }
924
939
  return;
925
940
  }
@@ -938,7 +953,7 @@ function validateCredentials(credentials, requestedPermissions) {
938
953
  requireUniqueIdentifier(credential.id, ids, "credential");
939
954
  requireText(credential.label, 80, `credential ${credential.id} label`);
940
955
  requireText(credential.description, 500, `credential ${credential.id} description`);
941
- const kind = credential.kind || "api_token";
956
+ const kind = credential.kind === undefined ? "api_token" : credential.kind;
942
957
  if (!CREDENTIAL_KINDS.has(kind)) throw new Error(`credential ${credential.id} kind is invalid`);
943
958
  for (const field of ["required", "allow_multiple"]) {
944
959
  if (credential[field] !== undefined && typeof credential[field] !== "boolean") {
@@ -1009,20 +1024,20 @@ function validateConnectors(connectors, credentialIds, secretIds) {
1009
1024
  if (endpoint !== undefined && endpoint !== null && (
1010
1025
  typeof endpoint !== "string" || !endpoint.startsWith("https://") || endpoint.length > 2048 || /[\r\n@]/.test(endpoint)
1011
1026
  )) throw new Error(`connector ${connector.id} endpoint is invalid`);
1012
- const timeout = connector.timeout_seconds ?? 10;
1027
+ const timeout = connector.timeout_seconds === undefined ? 10 : connector.timeout_seconds;
1013
1028
  if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0.1 || timeout > 30) {
1014
1029
  throw new Error(`connector ${connector.id} timeout is invalid`);
1015
1030
  }
1016
- const retry = connector.retry || {};
1031
+ const retry = connector.retry === undefined ? {} : connector.retry;
1017
1032
  assertObject(retry, `connector ${connector.id} retry`);
1018
1033
  rejectUnknownFields(retry, new Set(["max_attempts"]), `connector ${connector.id} retry`);
1019
- const attempts = retry.max_attempts ?? 2;
1034
+ const attempts = retry.max_attempts === undefined ? 2 : retry.max_attempts;
1020
1035
  if (!Number.isInteger(attempts) || attempts < 1 || attempts > 4) throw new Error(`connector ${connector.id} retry count is invalid`);
1021
- const rate = connector.rate_limit || {};
1036
+ const rate = connector.rate_limit === undefined ? {} : connector.rate_limit;
1022
1037
  assertObject(rate, `connector ${connector.id} rate_limit`);
1023
1038
  rejectUnknownFields(rate, new Set(["requests", "period_seconds"]), `connector ${connector.id} rate_limit`);
1024
- const requests = rate.requests ?? 60;
1025
- const period = rate.period_seconds ?? 60;
1039
+ const requests = rate.requests === undefined ? 60 : rate.requests;
1040
+ const period = rate.period_seconds === undefined ? 60 : rate.period_seconds;
1026
1041
  if (!Number.isInteger(requests) || requests < 1 || requests > 10000 || !Number.isInteger(period) || period < 1 || period > 3600) {
1027
1042
  throw new Error(`connector ${connector.id} rate limit is invalid`);
1028
1043
  }
@@ -1053,15 +1068,16 @@ function validateDependencies(dependencies, pluginId) {
1053
1068
  }
1054
1069
  if (dependency.required !== undefined && typeof dependency.required !== "boolean") throw new Error(`dependency ${dependency.id} required must be boolean`);
1055
1070
  for (const [field, fallback] of [["min_version", "0.0.0"], ["max_version", "999.999.999"]]) {
1056
- if (!VERSION.test(String(dependency[field] || fallback))) throw new Error(`dependency ${dependency.id} ${field} is invalid`);
1071
+ const version = dependency[field] === undefined ? fallback : dependency[field];
1072
+ if (typeof version !== "string" || !VERSION.test(version)) throw new Error(`dependency ${dependency.id} ${field} is invalid`);
1057
1073
  }
1058
1074
  if (dependency.package_path !== undefined && dependency.package_path !== null) {
1059
1075
  const path = String(dependency.package_path).replaceAll("\\", "/");
1060
1076
  if (!/^children\/[A-Za-z0-9._-]+\.mplg$/.test(path) || path.length > 240) throw new Error(`dependency ${dependency.id} package_path is invalid`);
1061
1077
  dependency.package_path = path;
1062
1078
  }
1063
- const exposed = dependency.exposed_capabilities || [];
1064
- if (!Array.isArray(exposed) || exposed.length > 32 || new Set(exposed).size !== exposed.length || exposed.some((value) => !IDENTIFIER.test(String(value)))) {
1079
+ const exposed = dependency.exposed_capabilities === undefined ? [] : dependency.exposed_capabilities;
1080
+ if (!Array.isArray(exposed) || exposed.length > 32 || new Set(exposed).size !== exposed.length || exposed.some((value) => typeof value !== "string" || !IDENTIFIER.test(value))) {
1065
1081
  throw new Error(`dependency ${dependency.id} exposed_capabilities are invalid`);
1066
1082
  }
1067
1083
  }
@@ -1081,8 +1097,9 @@ function validateSlashCommands(commands, capabilities) {
1081
1097
  requireText(command.title, 80, `slash command ${command.id} title`);
1082
1098
  requireText(command.description, 500, `slash command ${command.id} description`);
1083
1099
  if (!executable.has(command.capability)) throw new Error(`slash command ${command.id} targets a non-executable capability`);
1084
- assertObject(command.argument_template || {}, `slash command ${command.id} argument_template`);
1085
- assertJsonSize(command.argument_template || {}, 16 * 1024, `slash command ${command.id} argument_template`);
1100
+ const argumentTemplate = command.argument_template === undefined ? {} : command.argument_template;
1101
+ assertObject(argumentTemplate, `slash command ${command.id} argument_template`);
1102
+ assertJsonSize(argumentTemplate, 16 * 1024, `slash command ${command.id} argument_template`);
1086
1103
  }
1087
1104
  }
1088
1105
 
@@ -1107,7 +1124,8 @@ function validateRuntime(capability, runtime, permissions, credentials, connecto
1107
1124
  }
1108
1125
  if (adapter === "child_plugin") {
1109
1126
  const dependency = dependencyById.get(runtime.dependency_id);
1110
- if (!dependency || !(dependency.exposed_capabilities || []).includes(runtime.capability)) throw new Error("child_plugin requires an exposed dependency capability");
1127
+ const exposed = dependency?.exposed_capabilities === undefined ? [] : dependency.exposed_capabilities;
1128
+ if (!dependency || !Array.isArray(exposed) || !exposed.includes(runtime.capability)) throw new Error("child_plugin requires an exposed dependency capability");
1111
1129
  }
1112
1130
  if (adapter === "calendar_store") {
1113
1131
  if (!permissions.has("storage")) throw new Error("calendar_store requires storage permission");
@@ -1130,7 +1148,7 @@ function validateRuntimeReferences(capabilities, connectors, connectorIds, depen
1130
1148
  const connectorById = new Map(connectors.map((value) => [value.id, value]));
1131
1149
  const dependencyIds = new Set(dependencies.map((value) => value.id));
1132
1150
  for (const capability of capabilities) {
1133
- const runtime = capability.runtime || {};
1151
+ const runtime = capability.runtime === undefined ? {} : capability.runtime;
1134
1152
  if (runtime.connector_id) {
1135
1153
  if (!connectorIds.has(runtime.connector_id)) throw new Error(`capability ${capability.id} references an unknown connector`);
1136
1154
  if (!["http_json", "mcp_http"].includes(runtime.adapter)) throw new Error(`capability ${capability.id} connector is unsupported by its runtime`);
@@ -1142,34 +1160,38 @@ function validateRuntimeReferences(capabilities, connectors, connectorIds, depen
1142
1160
  }
1143
1161
  }
1144
1162
 
1145
- function validateUiConfig(extension, capabilities, routeIds) {
1146
- const config = extension.config || {};
1163
+ function validateUiConfig(extension, capabilities, routeIds, files) {
1164
+ const config = extension.config === undefined ? {} : extension.config;
1147
1165
  const executable = new Set(capabilities.filter((value) => ["tool", "skill", "provider", "notification"].includes(value.kind)).map((value) => value.id));
1148
1166
  if (config.ui_schema === 2) {
1149
- if (config.placement !== undefined && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
1167
+ if (config.placement != null && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
1150
1168
  validateUiRuntimeV2(config, executable, routeIds);
1169
+ for (const asset of uiRuntimeAssetPaths(config.view)) {
1170
+ if (!(asset in files)) throw new Error(`UI image asset is not packaged: ${asset}`);
1171
+ }
1151
1172
  return;
1152
1173
  }
1153
1174
  const allowed = new Set(["icon", "description", "component", "sections", "actions", "capability", "label", "form"]);
1154
1175
  rejectUnknownFields(config, allowed, `UI ${extension.id} config`);
1155
- if (config.icon !== undefined && !IDENTIFIER.test(String(config.icon))) throw new Error(`UI ${extension.id} icon is invalid`);
1176
+ if (config.icon !== undefined && (typeof config.icon !== "string" || !IDENTIFIER.test(config.icon))) throw new Error(`UI ${extension.id} icon is invalid`);
1156
1177
  if (config.description !== undefined) requireText(config.description, 240, `UI ${extension.id} description`);
1157
1178
  if (config.component !== undefined && !["action", "card", "settings_action"].includes(config.component)) throw new Error(`UI ${extension.id} component is invalid`);
1158
- const sections = config.sections || [];
1179
+ const sections = config.sections === undefined ? [] : config.sections;
1159
1180
  if (!Array.isArray(sections) || sections.length > 12) throw new Error(`UI ${extension.id} sections are invalid`);
1160
1181
  for (const section of sections) {
1161
1182
  rejectUnknownFields(section, new Set(["heading", "body"]), `UI ${extension.id} section`);
1162
1183
  if (section.heading !== undefined) requireText(section.heading, 80, `UI ${extension.id} section heading`);
1163
1184
  requireText(section.body, 1200, `UI ${extension.id} section body`);
1164
1185
  }
1165
- const actions = config.actions || [];
1186
+ const actions = config.actions === undefined ? [] : config.actions;
1166
1187
  if (!Array.isArray(actions) || actions.length > 6) throw new Error(`UI ${extension.id} actions are invalid`);
1167
1188
  const actionIds = new Set();
1168
1189
  for (const action of actions) {
1169
1190
  rejectUnknownFields(action, new Set(["id", "label", "capability", "style"]), `UI ${extension.id} action`);
1170
1191
  requireUniqueIdentifier(action.id, actionIds, `UI ${extension.id} action`);
1171
1192
  requireText(action.label, 60, `UI ${extension.id} action label`);
1172
- if (!executable.has(action.capability) || !["primary", "secondary"].includes(action.style || "secondary")) throw new Error(`UI ${extension.id} action is invalid`);
1193
+ const style = action.style === undefined ? "secondary" : action.style;
1194
+ if (!executable.has(action.capability) || !["primary", "secondary"].includes(style)) throw new Error(`UI ${extension.id} action is invalid`);
1173
1195
  }
1174
1196
  if ((config.capability === undefined) !== (config.label === undefined)) throw new Error(`UI ${extension.id} shorthand action is incomplete`);
1175
1197
  if (config.capability !== undefined && (actions.length || !executable.has(config.capability))) throw new Error(`UI ${extension.id} shorthand action is invalid`);
@@ -1207,22 +1229,24 @@ function validateLegacyUiForm(form, executable, extensionId) {
1207
1229
  }
1208
1230
 
1209
1231
  function validateUiRuntimeV2(config, executable, routes) {
1210
- rejectUnknownFields(config, new Set(["ui_schema", "icon", "description", "placement", "initial_state", "data_sources", "view"]), "UI Runtime v2");
1232
+ rejectUnknownFields(config, new Set(contract.ui_runtime.config_fields), "UI Runtime v2");
1211
1233
  if (config.ui_schema !== 2) throw new Error("UI Runtime version must be 2");
1212
- optionalUiText(config.icon, 128, { identifier: true });
1234
+ validateUiIcon(config.icon);
1213
1235
  optionalUiText(config.description, 240);
1214
- if (config.placement !== undefined) {
1236
+ if (config.placement != null) {
1215
1237
  const placement = config.placement;
1216
1238
  rejectUnknownFields(placement, new Set(["section_id", "section_title", "section_order", "layout", "show_header"]), "UI placement");
1217
1239
  optionalUiText(placement.section_id, 128, { required: true, identifier: true });
1218
1240
  optionalUiText(placement.section_title, 80, { required: true });
1219
- const order = placement.section_order ?? 0;
1241
+ const order = placement.section_order === undefined ? 0 : placement.section_order;
1220
1242
  if (!Number.isInteger(order) || order < -10000 || order > 10000) throw new Error("UI placement order is invalid");
1221
- if (!["stack", "horizontal", "grid"].includes(placement.layout || "stack")) throw new Error("UI placement layout is invalid");
1222
- if (typeof (placement.show_header ?? true) !== "boolean") throw new Error("UI placement show_header must be boolean");
1243
+ const layout = placement.layout === undefined ? "stack" : placement.layout;
1244
+ if (!["stack", "horizontal", "grid"].includes(layout)) throw new Error("UI placement layout is invalid");
1245
+ const showHeader = placement.show_header === undefined ? true : placement.show_header;
1246
+ if (typeof showHeader !== "boolean") throw new Error("UI placement show_header must be boolean");
1223
1247
  }
1224
1248
 
1225
- const state = config.initial_state || {};
1249
+ const state = config.initial_state === undefined ? {} : config.initial_state;
1226
1250
  assertObject(state, "UI initial_state");
1227
1251
  if (Object.keys(state).length > 32) throw new Error("UI initial_state is too large");
1228
1252
  for (const [key, value] of Object.entries(state)) {
@@ -1231,12 +1255,13 @@ function validateUiRuntimeV2(config, executable, routes) {
1231
1255
  }
1232
1256
  const stateKeys = new Set(Object.keys(state));
1233
1257
 
1234
- const sources = config.data_sources || [];
1258
+ const sources = config.data_sources === undefined ? [] : config.data_sources;
1235
1259
  if (!Array.isArray(sources) || sources.length > 8) throw new Error("UI data_sources are invalid");
1236
1260
  const sourceIds = new Set();
1237
1261
  for (const source of sources) {
1238
1262
  rejectUnknownFields(source, new Set(["id", "capability", "trigger", "query", "arguments", "refresh_seconds"]), "UI data source");
1239
- if (!UI_IDENTIFIER.test(String(source.id || "")) || sourceIds.has(source.id) || !executable.has(source.capability) || !["load", "manual"].includes(source.trigger || "load")) {
1263
+ const trigger = source.trigger === undefined ? "load" : source.trigger;
1264
+ if (typeof source.id !== "string" || !UI_IDENTIFIER.test(source.id) || sourceIds.has(source.id) || !executable.has(source.capability) || !["load", "manual"].includes(trigger)) {
1240
1265
  throw new Error("UI data source reference is invalid");
1241
1266
  }
1242
1267
  sourceIds.add(source.id);
@@ -1244,68 +1269,205 @@ function validateUiRuntimeV2(config, executable, routes) {
1244
1269
  for (const source of sources) {
1245
1270
  optionalUiTemplate(source.query, 2000);
1246
1271
  validateTemplateReferences(source.query, stateKeys, sourceIds);
1247
- assertObject(source.arguments || {}, "UI data source arguments");
1248
- validateUiBindingValue(source.arguments || {}, 0, stateKeys, sourceIds);
1249
- if (source.refresh_seconds !== undefined && (!Number.isInteger(source.refresh_seconds) || source.refresh_seconds < 30 || source.refresh_seconds > 86400)) {
1272
+ const sourceArguments = source.arguments === undefined ? {} : source.arguments;
1273
+ assertObject(sourceArguments, "UI data source arguments");
1274
+ validateUiBindingValue(sourceArguments, 0, stateKeys, sourceIds);
1275
+ if (source.refresh_seconds != null && (!Number.isInteger(source.refresh_seconds) || source.refresh_seconds < 30 || source.refresh_seconds > 86400)) {
1250
1276
  throw new Error("UI refresh must be between 30 and 86400 seconds");
1251
1277
  }
1252
1278
  }
1279
+ validateUiTheme(config.theme);
1280
+ validateUiAppBar(config.app_bar, executable, routes, sourceIds, stateKeys);
1281
+ validateUiNavigation(config.navigation, executable, routes, sourceIds, stateKeys);
1253
1282
  assertObject(config.view, "UI Runtime v2 view");
1254
- validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys);
1283
+ validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys, null);
1284
+ }
1285
+
1286
+ function validateUiTheme(theme) {
1287
+ if (theme == null) return;
1288
+ rejectUnknownFields(theme, new Set(contract.ui_runtime.theme_fields), "UI theme");
1289
+ const colors = theme.color_scheme === undefined ? {} : theme.color_scheme;
1290
+ assertObject(colors, "UI color scheme");
1291
+ const roles = new Set([...UI_COLOR_TOKENS].filter((value) => value !== "transparent"));
1292
+ rejectUnknownFields(colors, roles, "UI color scheme");
1293
+ for (const value of Object.values(colors)) {
1294
+ if (typeof value !== "string" || !UI_HEX_COLOR.test(value)) throw new Error("UI theme color is invalid");
1295
+ }
1296
+ if (theme.radius !== undefined) validateUiNumber(theme.radius, "UI theme radius", 0, 64);
1297
+ if (theme.spacing !== undefined) validateUiNumber(theme.spacing, "UI theme spacing", 0, 32);
1298
+ const density = theme.density === undefined ? "standard" : theme.density;
1299
+ if (!["compact", "standard", "comfortable"].includes(density)) throw new Error("UI theme density is invalid");
1300
+ }
1301
+
1302
+ function validateUiAppBar(appBar, executable, routes, sources, stateKeys) {
1303
+ if (appBar == null) return;
1304
+ rejectUnknownFields(appBar, new Set(contract.ui_runtime.app_bar_fields), "UI app bar");
1305
+ optionalUiTemplate(appBar.title, 120, true);
1306
+ validateTemplateReferences(appBar.title, stateKeys, sources);
1307
+ optionalUiTemplate(appBar.subtitle, 160);
1308
+ validateTemplateReferences(appBar.subtitle, stateKeys, sources);
1309
+ for (const key of ["center_title", "pinned"]) if (appBar[key] !== undefined && typeof appBar[key] !== "boolean") throw new Error("UI app bar flag is invalid");
1310
+ const ids = new Set();
1311
+ if (appBar.leading != null) ids.add(validateUiMenuItem(appBar.leading, executable, routes, sources, stateKeys, false));
1312
+ const actions = appBar.actions === undefined ? [] : appBar.actions;
1313
+ if (!Array.isArray(actions) || actions.length > 6) throw new Error("UI app bar actions are invalid");
1314
+ for (const action of actions) {
1315
+ const id = validateUiMenuItem(action, executable, routes, sources, stateKeys, false);
1316
+ if (ids.has(id)) throw new Error("UI app bar action is duplicated");
1317
+ ids.add(id);
1318
+ }
1319
+ }
1320
+
1321
+ function validateUiNavigation(navigation, executable, routes, sources, stateKeys) {
1322
+ if (navigation == null) return;
1323
+ rejectUnknownFields(navigation, new Set(contract.ui_runtime.navigation_fields), "UI navigation");
1324
+ const navigationType = navigation.type === undefined ? "adaptive" : navigation.type;
1325
+ if (!["tabs", "bar", "rail", "drawer", "adaptive"].includes(navigationType)) throw new Error("UI navigation type is invalid");
1326
+ if (!Array.isArray(navigation.items) || navigation.items.length < 2 || navigation.items.length > 8) throw new Error("UI navigation items are invalid");
1327
+ const selectedKey = navigation.selected_state_key;
1328
+ if (selectedKey != null && (typeof selectedKey !== "string" || !stateKeys.has(selectedKey))) throw new Error("UI navigation state is unknown");
1329
+ const persist = navigation.persist === undefined ? false : navigation.persist;
1330
+ if (typeof persist !== "boolean") throw new Error("UI navigation persistence is invalid");
1331
+ const labelBehavior = navigation.label_behavior === undefined ? "auto" : navigation.label_behavior;
1332
+ if (!["auto", "always", "selected", "never"].includes(labelBehavior)) throw new Error("UI navigation label behavior is invalid");
1333
+ if (navigation.rail_breakpoint !== undefined && (!Number.isInteger(navigation.rail_breakpoint) || navigation.rail_breakpoint < 480 || navigation.rail_breakpoint > 1600)) throw new Error("UI navigation breakpoint is invalid");
1334
+ const ids = new Set();
1335
+ for (const item of navigation.items) {
1336
+ const id = validateUiMenuItem(item, executable, routes, sources, stateKeys, true);
1337
+ if (ids.has(id)) throw new Error("UI navigation item is duplicated");
1338
+ ids.add(id);
1339
+ if (selectedKey != null && !("value" in item)) throw new Error("stateful UI navigation item requires a value");
1340
+ }
1341
+ }
1342
+
1343
+ function validateUiMenuItem(item, executable, routes, sources, stateKeys, navigation) {
1344
+ rejectUnknownFields(item, new Set(contract.ui_runtime.menu_item_fields), "UI menu item");
1345
+ optionalUiText(item.id, 128, { required: true, identifier: true });
1346
+ optionalUiTemplate(item.label, 80, true);
1347
+ validateTemplateReferences(item.label, stateKeys, sources);
1348
+ for (const key of ["icon", "selected_icon"]) validateUiIcon(item[key]);
1349
+ if (!navigation && item.selected_icon !== undefined) throw new Error("UI app bar action cannot have a selected icon");
1350
+ const showAs = item.show_as === undefined ? "auto" : item.show_as;
1351
+ if (!["auto", "always", "overflow"].includes(showAs)) throw new Error("UI menu presentation is invalid");
1352
+ if (navigation && item.show_as !== undefined) throw new Error("UI navigation item cannot set show_as");
1353
+ if ("value" in item) {
1354
+ if (!navigation) throw new Error("UI app bar action cannot have a value");
1355
+ validateUiJson(item.value, 0);
1356
+ }
1357
+ validateUiAction(item.action, executable, routes, sources, stateKeys, true);
1358
+ return item.id;
1255
1359
  }
1256
1360
 
1257
- function validateUiNode(node, depth, counter, executable, routes, sources, stateKeys) {
1258
- const nodeKeys = new Set(["id", "type", "props", "children", "action", "visible_when"]);
1361
+ function uiRuntimeAssetPaths(view) {
1362
+ const result = new Set();
1363
+ function visit(node) {
1364
+ if (!node || typeof node !== "object" || Array.isArray(node)) return;
1365
+ if (typeof node.props?.asset === "string") result.add(node.props.asset);
1366
+ if (Array.isArray(node.children)) for (const child of node.children) visit(child);
1367
+ }
1368
+ visit(view);
1369
+ return result;
1370
+ }
1371
+
1372
+ function validateUiNode(node, depth, counter, executable, routes, sources, stateKeys, parentType) {
1373
+ const nodeKeys = new Set(contract.ui_runtime.node_fields);
1259
1374
  rejectUnknownFields(node, nodeKeys, "UI component");
1260
1375
  counter.value += 1;
1261
1376
  if (depth > 12 || counter.value > 160) throw new Error("UI component tree is too large");
1262
1377
  if (!new Set(contract.ui_nodes).has(node.type)) throw new Error(`unknown UI component type: ${node.type}`);
1263
- if (node.id !== undefined && !UI_IDENTIFIER.test(String(node.id))) throw new Error("UI component id is invalid");
1264
- const props = node.props || {};
1378
+ if (node.id != null && (typeof node.id !== "string" || !UI_IDENTIFIER.test(node.id))) throw new Error("UI component id is invalid");
1379
+ const props = node.props === undefined ? {} : node.props;
1265
1380
  validateUiProps(node.type, props, sources, stateKeys);
1266
1381
  validateUiCondition(node.visible_when, stateKeys, sources);
1382
+ if (node.action != null && !["surface", "card", "button", "chip"].includes(node.type)) {
1383
+ throw new Error("UI action requires an interactive component");
1384
+ }
1267
1385
  validateUiAction(node.action, executable, routes, sources, stateKeys);
1268
- const children = node.children || [];
1386
+ const children = node.children === undefined ? [] : node.children;
1269
1387
  if (!Array.isArray(children) || children.length > 32) throw new Error("UI component children are invalid");
1270
- const leaves = new Set(["text", "icon", "divider", "spacer", "button", "chip", "metric", "progress", "calendar", "chart", "field", "select", "switch", "empty"]);
1388
+ const leaves = new Set(["text", "icon", "image", "avatar", "divider", "spacer", "button", "chip", "metric", "progress", "calendar", "chart", "field", "select", "switch", "empty"]);
1271
1389
  if (leaves.has(node.type) && children.length) throw new Error("leaf UI component cannot have children");
1272
1390
  if (["list", "timeline"].includes(node.type) && children.length !== 1) throw new Error("list UI component requires one item template");
1391
+ if (["positioned", "scroll", "padding", "center", "expanded", "badge"].includes(node.type) && children.length !== 1) throw new Error("UI component requires exactly one child");
1392
+ if (node.type === "surface" && !children.length) throw new Error("surface UI component requires composed children");
1393
+ if (node.type === "stack" && !children.some((child) => child?.type !== "positioned")) {
1394
+ const boundedWidth = props.width !== undefined || props.max_width !== undefined;
1395
+ const boundedHeight = props.height !== undefined || props.max_height !== undefined;
1396
+ if (!boundedWidth || !boundedHeight) throw new Error("positioned-only stack requires bounded width and height");
1397
+ }
1398
+ if (node.type === "positioned" && parentType !== "stack") throw new Error("positioned UI component requires a stack parent");
1399
+ if (node.type === "expanded" && !["row", "column"].includes(parentType)) throw new Error("expanded UI component requires a row or column parent");
1273
1400
  for (const child of children) {
1274
1401
  assertObject(child, "UI component child");
1275
- validateUiNode(child, depth + 1, counter, executable, routes, sources, stateKeys);
1402
+ validateUiNode(child, depth + 1, counter, executable, routes, sources, stateKeys, node.type);
1276
1403
  }
1277
1404
  }
1278
1405
 
1279
1406
  function validateUiProps(nodeType, props, sources, stateKeys) {
1280
- const allowed = new Set([
1281
- "text", "title", "subtitle", "label", "supporting", "icon", "style", "tone", "align",
1282
- "max_lines", "spacing", "padding", "columns", "stack_at", "min_item_width", "size", "value",
1283
- "source", "empty_text", "limit", "state_key", "placeholder", "input_type", "options", "persist",
1284
- "selected", "dense", "chart_type", "x_key", "y_key", "date_key", "title_key", "show_legend", "full_width",
1285
- ]);
1407
+ const allowed = new Set(contract.ui_runtime.prop_fields);
1286
1408
  rejectUnknownFields(props, allowed, "UI component props");
1287
- for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value"]) {
1409
+ const surfaceProps = new Set([
1410
+ "spacing", "padding", "margin", "width", "height", "min_width", "max_width", "min_height", "max_height",
1411
+ "alignment", "color", "background_color", "foreground_color", "border_color", "border_width",
1412
+ "border_radius", "elevation", "opacity", "clip", "enabled", "tooltip", "semantic_label", "exclude_semantics",
1413
+ ]);
1414
+ if (nodeType === "surface") rejectUnknownFields(props, surfaceProps, "surface UI component props");
1415
+ for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value", "tooltip", "semantic_label"]) {
1288
1416
  if (props[key] !== undefined) {
1289
1417
  optionalUiTemplate(props[key], 1200, true);
1290
1418
  validateTemplateReferences(props[key], stateKeys, sources);
1291
1419
  }
1292
1420
  }
1293
- for (const key of ["icon", "style", "tone", "align", "input_type", "chart_type", "x_key", "y_key", "date_key", "title_key"]) {
1421
+ for (const key of ["style", "tone", "align", "input_type", "chart_type", "x_key", "y_key", "date_key", "title_key"]) {
1294
1422
  if (props[key] !== undefined) optionalUiText(props[key], 80, { required: true, identifier: true });
1295
1423
  }
1296
- for (const key of ["max_lines", "spacing", "padding", "columns", "size", "limit"]) {
1424
+ if (props.icon !== undefined) validateUiIcon(props.icon, true);
1425
+ if (props.max_lines !== undefined && (!Number.isInteger(props.max_lines) || props.max_lines < 1 || props.max_lines > 100)) throw new Error("UI numeric property max_lines is invalid");
1426
+ for (const key of ["columns", "size", "limit"]) {
1297
1427
  if (props[key] !== undefined && (!Number.isInteger(props[key]) || props[key] < 0 || props[key] > 100)) throw new Error(`UI numeric property ${key} is invalid`);
1298
1428
  }
1429
+ if (props.spacing !== undefined) validateUiNumber(props.spacing, "UI spacing", 0, 128);
1430
+ for (const key of ["padding", "margin"]) if (props[key] !== undefined) validateUiInsets(props[key], `UI ${key}`);
1431
+ for (const key of ["width", "height", "min_width", "max_width", "min_height", "max_height"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI ${key}`, 0, 4096);
1432
+ for (const [minimum, maximum] of [["min_width", "max_width"], ["min_height", "max_height"]]) {
1433
+ if (props[minimum] !== undefined && props[maximum] !== undefined && props[minimum] > props[maximum]) throw new Error("UI size constraints are invalid");
1434
+ }
1435
+ const alignments = new Set(["top_left", "top_center", "top_right", "center_left", "center", "center_right", "bottom_left", "bottom_center", "bottom_right"]);
1436
+ if (props.alignment !== undefined && !alignments.has(props.alignment)) throw new Error("UI alignment is invalid");
1437
+ if (props.main_axis_alignment !== undefined && !["start", "end", "center", "space_between", "space_around", "space_evenly"].includes(props.main_axis_alignment)) throw new Error("UI main-axis alignment is invalid");
1438
+ if (props.cross_axis_alignment !== undefined && !["start", "end", "center", "stretch", "baseline"].includes(props.cross_axis_alignment)) throw new Error("UI cross-axis alignment is invalid");
1439
+ if (props.main_axis_size !== undefined && !["min", "max"].includes(props.main_axis_size)) throw new Error("UI main-axis size is invalid");
1440
+ for (const key of ["color", "background_color", "foreground_color", "border_color"]) if (props[key] !== undefined) validateUiColor(props[key], `UI ${key}`);
1441
+ if (props.border_width !== undefined) validateUiNumber(props.border_width, "UI border width", 0, 8);
1442
+ if (props.border_radius !== undefined) validateUiNumber(props.border_radius, "UI border radius", 0, 64);
1443
+ if (props.elevation !== undefined) validateUiNumber(props.elevation, "UI elevation", 0, 24);
1444
+ if (props.opacity !== undefined) validateUiNumber(props.opacity, "UI opacity", 0, 1);
1445
+ if (props.aspect_ratio !== undefined) validateUiNumber(props.aspect_ratio, "UI aspect ratio", 0.1, 20);
1446
+ if (props.flex !== undefined && (!Number.isInteger(props.flex) || props.flex < 1 || props.flex > 24)) throw new Error("UI flex is invalid");
1447
+ for (const key of ["left", "top", "right", "bottom"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI position ${key}`, -4096, 4096);
1299
1448
  if (props.stack_at !== undefined && (!Number.isInteger(props.stack_at) || props.stack_at < 0 || props.stack_at > 1200)) throw new Error("UI stack_at is invalid");
1300
1449
  if (props.min_item_width !== undefined && (!Number.isInteger(props.min_item_width) || props.min_item_width < 96 || props.min_item_width > 600)) throw new Error("UI min_item_width is invalid");
1301
- for (const key of ["persist", "selected", "dense", "show_legend", "full_width"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
1450
+ for (const key of ["persist", "selected", "dense", "show_legend", "full_width", "exclude_semantics", "clip", "shrink_wrap", "enabled"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
1451
+ if (props.fit !== undefined && !["contain", "cover", "fill", "fit_width", "fit_height", "none", "scale_down"].includes(props.fit)) throw new Error("UI image fit is invalid");
1452
+ if (props.scroll_direction !== undefined && !["vertical", "horizontal"].includes(props.scroll_direction)) throw new Error("UI scroll direction is invalid");
1453
+ if (["image", "avatar"].includes(nodeType)) validateUiImageSource(props, sources);
1454
+ else if (["asset", "url", "fit"].some((key) => props[key] !== undefined)) throw new Error("image properties require an image or avatar component");
1455
+ if (nodeType === "positioned" && !["left", "top", "right", "bottom"].some((key) => props[key] !== undefined)) throw new Error("positioned UI component requires an offset");
1456
+ if (nodeType === "positioned" && (
1457
+ ["left", "right", "width"].every((key) => props[key] !== undefined)
1458
+ || ["top", "bottom", "height"].every((key) => props[key] !== undefined)
1459
+ )) throw new Error("positioned UI component over-constrains an axis");
1460
+ if (nodeType !== "positioned" && ["left", "top", "right", "bottom"].some((key) => props[key] !== undefined)) throw new Error("position offsets require a positioned UI component");
1461
+ if (nodeType !== "expanded" && props.flex !== undefined) throw new Error("flex requires an expanded UI component");
1462
+ if (nodeType !== "scroll" && ["scroll_direction", "shrink_wrap"].some((key) => props[key] !== undefined)) throw new Error("scroll properties require a scroll UI component");
1302
1463
  if (["list", "timeline", "calendar", "chart"].includes(nodeType)) {
1303
1464
  if (typeof props.source !== "string" || !UI_BINDING.test(props.source) || !props.source.startsWith("data.") || !sources.has(props.source.split(".")[1])) throw new Error("UI list source is invalid");
1304
1465
  }
1305
- if (nodeType === "chart" && !["bar", "line", "donut"].includes(props.chart_type || "bar")) throw new Error("UI chart type is invalid");
1306
- if (nodeType === "calendar" && props.state_key !== undefined && !stateKeys.has(props.state_key)) throw new Error("UI calendar state is unknown");
1466
+ const chartType = props.chart_type === undefined ? "bar" : props.chart_type;
1467
+ if (nodeType === "chart" && !["bar", "line", "donut"].includes(chartType)) throw new Error("UI chart type is invalid");
1468
+ if (nodeType === "calendar" && props.state_key != null && !stateKeys.has(props.state_key)) throw new Error("UI calendar state is unknown");
1307
1469
  if (["field", "select", "switch"].includes(nodeType) && !stateKeys.has(props.state_key)) throw new Error("UI state field is unknown");
1308
- if (props.options !== undefined) {
1470
+ if (props.options != null) {
1309
1471
  if (nodeType !== "select" || !Array.isArray(props.options) || props.options.length < 1 || props.options.length > 32) throw new Error("UI select options are invalid");
1310
1472
  for (const option of props.options) {
1311
1473
  if (!option || typeof option !== "object" || Array.isArray(option) || Object.keys(option).sort().join(",") !== "label,value") throw new Error("UI select option is invalid");
@@ -1315,8 +1477,47 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
1315
1477
  }
1316
1478
  }
1317
1479
 
1480
+ function validateUiImageSource(props, sources) {
1481
+ const supplied = ["asset", "url"].filter((key) => props[key] !== undefined);
1482
+ if (supplied.length !== 1) throw new Error("image UI component requires exactly one safe source");
1483
+ if (props.asset !== undefined) {
1484
+ if (typeof props.asset !== "string" || props.asset.length > 240 || !UI_ASSET_PATH.test(props.asset) || props.asset.split("/").includes("..")) throw new Error("UI image asset is invalid");
1485
+ return;
1486
+ }
1487
+ if (typeof props.url !== "string" || props.url.length > 2000) throw new Error("UI image URL binding is invalid");
1488
+ const match = UI_SINGLE_TEMPLATE_BINDING.exec(props.url);
1489
+ if (!match || !UI_BINDING.test(match[1].trim())) throw new Error("UI image URL must be a capability result binding");
1490
+ const parts = match[1].trim().split(".");
1491
+ if (!["data", "item"].includes(parts[0])) throw new Error("UI image URL binding is invalid");
1492
+ if (parts[0] === "data" && (parts.length < 2 || !sources.has(parts[1]))) throw new Error("UI image data source is unknown");
1493
+ }
1494
+
1495
+ function validateUiColor(value, label) {
1496
+ if (typeof value !== "string" || (!UI_COLOR_TOKENS.has(value) && !UI_HEX_COLOR.test(value))) throw new Error(`${label} is invalid`);
1497
+ }
1498
+
1499
+ function validateUiIcon(value, required = false) {
1500
+ if (value == null && !required) return;
1501
+ if (typeof value !== "string" || !UI_ICON_TOKENS.has(value)) throw new Error("UI icon token is invalid");
1502
+ }
1503
+
1504
+ function validateUiNumber(value, label, minimum, maximum) {
1505
+ if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`${label} is invalid`);
1506
+ }
1507
+
1508
+ function validateUiInsets(value, label) {
1509
+ if (typeof value === "number") {
1510
+ validateUiNumber(value, label, 0, 128);
1511
+ return;
1512
+ }
1513
+ assertObject(value, label);
1514
+ const keys = Object.keys(value).sort().join(",");
1515
+ if (!["all", "horizontal,vertical", "bottom,left,right,top"].includes(keys)) throw new Error(`${label} is invalid`);
1516
+ for (const item of Object.values(value)) validateUiNumber(item, label, 0, 128);
1517
+ }
1518
+
1318
1519
  function validateUiCondition(condition, stateKeys, sources) {
1319
- if (condition === undefined) return;
1520
+ if (condition == null) return;
1320
1521
  rejectUnknownFields(condition, new Set(["path", "equals", "not_equals", "exists"]), "UI visibility condition");
1321
1522
  if (typeof condition.path !== "string" || !UI_BINDING.test(condition.path)) throw new Error("UI visibility path is invalid");
1322
1523
  validateKnownBinding(condition.path, stateKeys, sources);
@@ -1325,27 +1526,37 @@ function validateUiCondition(condition, stateKeys, sources) {
1325
1526
  if (checks[0] !== "exists") validateUiJson(condition[checks[0]], 0);
1326
1527
  }
1327
1528
 
1328
- function validateUiAction(action, executable, routes, sources, stateKeys) {
1329
- if (action === undefined) return;
1330
- rejectUnknownFields(action, new Set(["type", "capability", "query", "arguments", "store", "target", "source", "values", "persist"]), "UI action");
1529
+ function validateUiAction(action, executable, routes, sources, stateKeys, required = false) {
1530
+ if (action == null) {
1531
+ if (required) throw new Error("UI action is required");
1532
+ return;
1533
+ }
1534
+ rejectUnknownFields(action, new Set(contract.ui_runtime.action_fields), "UI action");
1331
1535
  if (!["invoke", "navigate", "set_state", "refresh", "back"].includes(action.type)) throw new Error("UI action type is invalid");
1332
1536
  if (action.type === "invoke") {
1333
1537
  if (!executable.has(action.capability)) throw new Error("UI action targets a non-executable capability");
1334
1538
  optionalUiTemplate(action.query, 2000);
1335
1539
  validateTemplateReferences(action.query, stateKeys, sources);
1336
- assertObject(action.arguments || {}, "UI action arguments");
1337
- validateUiBindingValue(action.arguments || {}, 0, stateKeys, sources);
1338
- if (action.store !== undefined && !sources.has(action.store)) throw new Error("UI action store is unknown");
1540
+ const actionArguments = action.arguments === undefined ? {} : action.arguments;
1541
+ assertObject(actionArguments, "UI action arguments");
1542
+ validateUiBindingValue(actionArguments, 0, stateKeys, sources);
1543
+ if (action.store != null && !sources.has(action.store)) throw new Error("UI action store is unknown");
1339
1544
  } else if (action.type === "navigate") {
1340
1545
  if (!routes.has(action.target)) throw new Error("UI navigation target is unknown");
1546
+ const transition = action.transition === undefined ? "platform" : action.transition;
1547
+ if (!["platform", "fade", "slide", "none"].includes(transition)) throw new Error("UI navigation transition is invalid");
1548
+ const replace = action.replace === undefined ? false : action.replace;
1549
+ if (typeof replace !== "boolean") throw new Error("UI navigation replacement is invalid");
1341
1550
  } else if (action.type === "set_state") {
1342
1551
  assertObject(action.values, "UI state action values");
1343
1552
  if (!Object.keys(action.values).length || Object.keys(action.values).some((key) => !stateKeys.has(key))) throw new Error("UI state action targets an unknown state key");
1344
1553
  validateUiBindingValue(action.values, 0, stateKeys, sources);
1345
- if (typeof (action.persist ?? false) !== "boolean") throw new Error("UI state persistence flag is invalid");
1554
+ const persist = action.persist === undefined ? false : action.persist;
1555
+ if (typeof persist !== "boolean") throw new Error("UI state persistence flag is invalid");
1346
1556
  } else if (action.type === "refresh") {
1347
1557
  if (!sources.has(action.source)) throw new Error("UI refresh source is unknown");
1348
1558
  } else if (Object.keys(action).length !== 1) throw new Error("back UI action cannot contain arguments");
1559
+ if (action.type !== "navigate" && (action.transition !== undefined || action.replace !== undefined)) throw new Error("navigation options require a navigate UI action");
1349
1560
  }
1350
1561
 
1351
1562
  function validateUiBindingValue(value, depth, stateKeys, sources) {
@@ -1467,7 +1678,7 @@ function normalizeCapabilitySource(value, label) {
1467
1678
  throw new Error(`${label}.runtime must be an object`);
1468
1679
  }
1469
1680
  if (shorthand.length) {
1470
- const runtime = { ...(capability.runtime || {}) };
1681
+ const runtime = { ...(capability.runtime === undefined ? {} : capability.runtime) };
1471
1682
  for (const name of shorthand) {
1472
1683
  if (Object.hasOwn(runtime, name) && !canonicalJson(runtime[name]).equals(canonicalJson(capability[name]))) {
1473
1684
  throw new Error(`${label}.${name} conflicts with ${label}.runtime.${name}`);