@vellumai/assistant 0.11.1 → 0.11.2-staging.2

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 (318) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +1 -1
  3. package/eslint.config.mjs +28 -8
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  12. package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  13. package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
  14. package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
  15. package/openapi.yaml +292 -5
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  18. package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
  19. package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
  20. package/src/__tests__/compaction-events.test.ts +139 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +35 -3
  22. package/src/__tests__/config-schema.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +180 -7
  25. package/src/__tests__/conversation-error.test.ts +211 -4
  26. package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
  27. package/src/__tests__/conversation-queue.test.ts +126 -0
  28. package/src/__tests__/conversation-retry-route.test.ts +46 -7
  29. package/src/__tests__/conversation-slash.test.ts +4 -2
  30. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  31. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  32. package/src/__tests__/document-create-dedupe.test.ts +2 -1
  33. package/src/__tests__/document-find-replace.test.ts +2 -1
  34. package/src/__tests__/document-tool-security.test.ts +2 -1
  35. package/src/__tests__/document-update-default-surface.test.ts +2 -1
  36. package/src/__tests__/document-workspace-file.test.ts +467 -0
  37. package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
  38. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  39. package/src/__tests__/http-user-message-parity.test.ts +66 -0
  40. package/src/__tests__/list-messages-attachments.test.ts +87 -0
  41. package/src/__tests__/list-messages-provider-error.test.ts +143 -0
  42. package/src/__tests__/list-messages-system-card.test.ts +100 -0
  43. package/src/__tests__/llm-resolver.test.ts +19 -9
  44. package/src/__tests__/managed-profile-guard.test.ts +23 -0
  45. package/src/__tests__/notification-platform-adapter.test.ts +130 -2
  46. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  47. package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
  48. package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
  49. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  50. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  51. package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
  52. package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
  53. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
  54. package/src/__tests__/skills.test.ts +27 -0
  55. package/src/__tests__/slack-channels-routes.test.ts +0 -2
  56. package/src/__tests__/slack-share-routes.test.ts +0 -3
  57. package/src/__tests__/slack-users-routes.test.ts +0 -2
  58. package/src/__tests__/subagent-tools.test.ts +11 -0
  59. package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
  60. package/src/__tests__/tool-result-spool.test.ts +5 -4
  61. package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
  62. package/src/__tests__/turn-events-store.test.ts +26 -0
  63. package/src/__tests__/ui-visual-surface.test.ts +695 -0
  64. package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
  65. package/src/__tests__/visible-app-context.test.ts +189 -0
  66. package/src/__tests__/workspace-git-service.test.ts +173 -33
  67. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
  68. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  69. package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
  70. package/src/agent/loop.ts +36 -7
  71. package/src/api/events/context-window-usage.ts +31 -0
  72. package/src/api/events/notification-intent.ts +8 -0
  73. package/src/api/events/ui-surface-pending.ts +35 -0
  74. package/src/api/index.ts +14 -0
  75. package/src/api/responses/conversation-message.ts +25 -4
  76. package/src/api/surfaces.ts +90 -1
  77. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  78. package/src/approvals/guardian-decision-primitive.ts +4 -0
  79. package/src/calls/__tests__/call-setup-router.test.ts +156 -26
  80. package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
  81. package/src/calls/call-setup-router.ts +80 -36
  82. package/src/calls/voice-session-bridge.ts +173 -26
  83. package/src/cli/commands/inference.help.ts +3 -3
  84. package/src/cli/commands/notifications.help.ts +3 -2
  85. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
  86. package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
  87. package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
  88. package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
  89. package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
  90. package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
  91. package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
  92. package/src/cli/commands/platform/index.help.ts +92 -0
  93. package/src/cli/commands/platform/index.ts +7 -0
  94. package/src/cli/commands/platform/invoices.ts +132 -0
  95. package/src/cli/commands/usage.help.ts +1 -1
  96. package/src/cli/lib/list-installed-plugins.ts +2 -1
  97. package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
  98. package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
  99. package/src/config/assistant-feature-flags.ts +7 -2
  100. package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
  101. package/src/config/bundled-skills/subagent/SKILL.md +3 -1
  102. package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
  103. package/src/config/bundled-skills/visualize/SKILL.md +163 -0
  104. package/src/config/call-site-defaults.ts +3 -2
  105. package/src/config/default-profile-catalog.ts +75 -70
  106. package/src/config/default-profile-names.ts +13 -28
  107. package/src/config/env-registry.ts +1 -0
  108. package/src/config/feature-flag-registry.json +16 -0
  109. package/src/config/llm-resolver.ts +21 -5
  110. package/src/config/loader.ts +18 -11
  111. package/src/config/schemas/llm.ts +1 -9
  112. package/src/config/schemas/memory-retrospective.ts +9 -0
  113. package/src/config/schemas/monitoring.ts +28 -2
  114. package/src/config/schemas/workspace-git.ts +15 -0
  115. package/src/config/seed-inference-profiles.ts +19 -5
  116. package/src/context/post-turn-tool-result-truncation.ts +2 -2
  117. package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
  118. package/src/conversations/message-consolidation.ts +17 -15
  119. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
  120. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
  121. package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
  122. package/src/daemon/conversation-agent-loop.ts +107 -22
  123. package/src/daemon/conversation-error.ts +169 -48
  124. package/src/daemon/conversation-messaging.ts +87 -4
  125. package/src/daemon/conversation-process.ts +57 -45
  126. package/src/daemon/conversation-runtime-assembly.ts +57 -0
  127. package/src/daemon/conversation-store.ts +36 -1
  128. package/src/daemon/conversation-surfaces.ts +68 -6
  129. package/src/daemon/conversation-turn-finalize.ts +76 -18
  130. package/src/daemon/conversation.ts +122 -27
  131. package/src/daemon/lifecycle.ts +9 -0
  132. package/src/daemon/message-types/conversations.ts +8 -0
  133. package/src/daemon/message-types/surfaces.ts +3 -0
  134. package/src/documents/document-store.ts +247 -10
  135. package/src/home/__tests__/feed-types.test.ts +43 -8
  136. package/src/home/__tests__/feed-writer.test.ts +59 -0
  137. package/src/home/feed-types.ts +34 -7
  138. package/src/home/feed-writer.ts +15 -6
  139. package/src/live-voice/__tests__/activity-label.test.ts +95 -0
  140. package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
  141. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
  142. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  143. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  144. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  145. package/src/live-voice/activity-label.ts +169 -0
  146. package/src/live-voice/live-activity-reporter.ts +35 -6
  147. package/src/live-voice/live-voice-session.ts +282 -41
  148. package/src/live-voice/protocol.ts +35 -0
  149. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
  150. package/src/messaging/providers/slack/adapter.ts +86 -19
  151. package/src/messaging/providers/slack/api.test.ts +85 -1
  152. package/src/messaging/providers/slack/api.ts +121 -288
  153. package/src/messaging/providers/slack/client.ts +20 -251
  154. package/src/messaging/providers/slack/send.test.ts +4 -9
  155. package/src/messaging/providers/slack/send.ts +1 -1
  156. package/src/messaging/providers/slack/types.ts +5 -0
  157. package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
  158. package/src/messaging/providers/slack/web-api-transport.ts +367 -0
  159. package/src/messaging/providers/slack/withdraw.ts +6 -14
  160. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  161. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  162. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  163. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  164. package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
  165. package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
  166. package/src/monitoring/file-descriptors.ts +262 -0
  167. package/src/monitoring/process-memory.ts +4 -11
  168. package/src/monitoring/resource-sampler.ts +5 -0
  169. package/src/monitoring/worker.ts +12 -0
  170. package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
  171. package/src/notifications/__tests__/broadcaster.test.ts +347 -7
  172. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  173. package/src/notifications/__tests__/decision-engine.test.ts +349 -29
  174. package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
  175. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  176. package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
  177. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  178. package/src/notifications/adapters/macos.ts +3 -0
  179. package/src/notifications/adapters/platform.ts +90 -14
  180. package/src/notifications/adapters/telegram.ts +6 -4
  181. package/src/notifications/assistant-reply-producer.ts +219 -0
  182. package/src/notifications/broadcaster.ts +523 -317
  183. package/src/notifications/copy-composer.ts +27 -6
  184. package/src/notifications/decision-engine.ts +154 -77
  185. package/src/notifications/deterministic-checks.ts +6 -7
  186. package/src/notifications/edit-notification.ts +8 -4
  187. package/src/notifications/emit-signal.ts +49 -16
  188. package/src/notifications/guardian-delivery-recorder.ts +8 -2
  189. package/src/notifications/home-feed-side-effect.ts +43 -6
  190. package/src/notifications/notification-utils.ts +39 -4
  191. package/src/notifications/signal.ts +10 -0
  192. package/src/notifications/types.ts +17 -0
  193. package/src/persistence/bookmark-crud.ts +18 -9
  194. package/src/persistence/conversation-attention-store.ts +26 -0
  195. package/src/persistence/conversation-crud.ts +79 -32
  196. package/src/persistence/conversation-queries.ts +30 -15
  197. package/src/persistence/conversation-title-service.ts +5 -170
  198. package/src/persistence/conversation-types.ts +192 -1
  199. package/src/persistence/db-init.ts +14 -1
  200. package/src/persistence/db-maintenance.ts +5 -4
  201. package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
  202. package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
  203. package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
  204. package/src/persistence/embeddings/embedding-local.ts +2 -1
  205. package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
  206. package/src/persistence/embeddings/plugin-index.ts +84 -12
  207. package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
  208. package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
  209. package/src/persistence/planner-statistics.ts +14 -0
  210. package/src/persistence/schema/documents.ts +26 -11
  211. package/src/persistence/steps.ts +2 -0
  212. package/src/platform/client.test.ts +172 -2
  213. package/src/platform/client.ts +178 -55
  214. package/src/plugin-api/conversation-turn.ts +13 -0
  215. package/src/plugin-api/model-profiles.test.ts +6 -2
  216. package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
  217. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
  218. package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
  219. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
  220. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
  224. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
  225. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
  226. package/src/plugins/defaults/memory/host-utils.ts +5 -0
  227. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
  228. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
  229. package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
  230. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
  231. package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
  232. package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
  233. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
  234. package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
  235. package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
  236. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
  237. package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
  238. package/src/plugins/defaults/memory/v2/injection.ts +48 -3
  239. package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
  240. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
  241. package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
  242. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  243. package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
  244. package/src/plugins/types.ts +17 -0
  245. package/src/prompts/templates/system-sections.ts +2 -2
  246. package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
  247. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
  248. package/src/providers/call-site-routing.ts +77 -14
  249. package/src/providers/connection-resolution.ts +42 -2
  250. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  251. package/src/providers/inference/adapter-factory.ts +70 -12
  252. package/src/providers/model-catalog.ts +0 -11
  253. package/src/providers/model-intents.ts +11 -0
  254. package/src/providers/registry.ts +8 -0
  255. package/src/providers/retry.ts +85 -17
  256. package/src/providers/types.ts +8 -1
  257. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  258. package/src/runtime/agent-wake.ts +132 -9
  259. package/src/runtime/channel-approval-types.ts +25 -0
  260. package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
  261. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
  262. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
  263. package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
  264. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
  265. package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
  266. package/src/runtime/routes/consolidation-routes.ts +1 -1
  267. package/src/runtime/routes/conversation-management-routes.ts +18 -6
  268. package/src/runtime/routes/conversation-query-routes.ts +14 -23
  269. package/src/runtime/routes/conversation-routes.ts +113 -8
  270. package/src/runtime/routes/credential-routes.ts +1 -5
  271. package/src/runtime/routes/documents-routes.ts +207 -12
  272. package/src/runtime/routes/identity-routes.ts +3 -93
  273. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
  274. package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
  275. package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
  276. package/src/runtime/routes/notification-routes.ts +4 -1
  277. package/src/runtime/routes/platform-routes.ts +238 -3
  278. package/src/runtime/routes/playground/guard.ts +1 -2
  279. package/src/runtime/routes/slack-channel-routes.ts +2 -4
  280. package/src/runtime/routes/workspace-commit-routes.ts +49 -2
  281. package/src/runtime/routes/workspace-routes.ts +11 -12
  282. package/src/runtime/routes/workspace-utils.ts +49 -2
  283. package/src/runtime/services/conversation-serializer.ts +2 -4
  284. package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
  285. package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
  286. package/src/subagent/__tests__/consult-context.test.ts +84 -0
  287. package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
  288. package/src/subagent/consult-context.ts +410 -0
  289. package/src/subagent/consult-prompt.ts +38 -5
  290. package/src/subagent/manager.ts +9 -4
  291. package/src/subagent/types.ts +8 -0
  292. package/src/telemetry/telemetry-event-sources.ts +7 -0
  293. package/src/telemetry/telemetry-wire-source.json +1 -1
  294. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  295. package/src/telemetry/turn-events-store.ts +30 -1
  296. package/src/telemetry/types.ts +24 -0
  297. package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
  298. package/src/tools/browser/pinned-tabs.ts +3 -1
  299. package/src/tools/subagent/spawn.ts +23 -1
  300. package/src/tools/terminal/safe-env.ts +1 -0
  301. package/src/tools/ui-surface/definitions.ts +39 -1
  302. package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
  303. package/src/tools/ui-surface/visual-validation.ts +787 -0
  304. package/src/tools/workflows/run-workflow.test.ts +1 -0
  305. package/src/util/__tests__/short-title.test.ts +229 -0
  306. package/src/util/__tests__/worker-compute.test.ts +65 -0
  307. package/src/util/cgroup-cpu.ts +93 -0
  308. package/src/util/errors.ts +27 -0
  309. package/src/util/process-tree.ts +19 -0
  310. package/src/util/short-title.ts +189 -0
  311. package/src/util/worker-compute.ts +83 -0
  312. package/src/workspace/byok-default-profile-ensure.ts +25 -15
  313. package/src/workspace/git-service.ts +79 -30
  314. package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
  315. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  316. package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
  317. package/src/workspace/migrations/registry.ts +6 -0
  318. package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
@@ -0,0 +1,787 @@
1
+ /**
2
+ * Validation for the `visual` ui_show surface: a self-contained HTML fragment
3
+ * rendered inside a sandboxed frame.
4
+ *
5
+ * The frame has no network access and injects a fixed vocabulary of design
6
+ * tokens on `:root`, so an external sub-resource silently never loads and a
7
+ * `var()` to
8
+ * anything outside that vocabulary resolves to nothing. Both failures render a
9
+ * blank or unthemed widget with no error anywhere, so they are caught here and
10
+ * reported as teaching errors the model can act on before the surface is ever
11
+ * emitted.
12
+ */
13
+
14
+ /**
15
+ * Upper bound on fragment size. Sized to what one model response can emit
16
+ * with room to spare: a fragment past this cannot be produced in a single
17
+ * ui_show call, so the turn burns its output budget and renders nothing.
18
+ */
19
+ const MAX_HTML_CHARS = 24000;
20
+
21
+ const RAMP_STEPS = [
22
+ "100",
23
+ "200",
24
+ "300",
25
+ "400",
26
+ "500",
27
+ "600",
28
+ "700",
29
+ "800",
30
+ "900",
31
+ "950",
32
+ ] as const;
33
+
34
+ /** Neutral ramps carry an extra lightest step; the accents start at 100. */
35
+ const NEUTRAL_PALETTES = ["moss", "stone"] as const;
36
+ const ACCENT_PALETTES = ["forest", "emerald", "danger", "amber"] as const;
37
+
38
+ const PALETTE_PROPERTIES: readonly string[] = [
39
+ ...NEUTRAL_PALETTES.flatMap((palette) =>
40
+ ["50", ...RAMP_STEPS].map((step) => `--color-${palette}-${step}`),
41
+ ),
42
+ ...ACCENT_PALETTES.flatMap((palette) =>
43
+ RAMP_STEPS.map((step) => `--color-${palette}-${step}`),
44
+ ),
45
+ ];
46
+
47
+ /**
48
+ * The CSS custom properties that exist inside a widget frame. The host injects
49
+ * exactly these onto `:root` of the sandboxed iframe.
50
+ *
51
+ * Mirrors `WIDGET_TOKEN_PROPERTIES` in
52
+ * `clients/web/src/utils/widget-tokens.ts` — the two must change together.
53
+ */
54
+ export const WIDGET_TOKEN_PROPERTIES: readonly string[] = [
55
+ // Surfaces
56
+ "--surface-base",
57
+ "--surface-lift",
58
+ "--surface-overlay",
59
+ "--surface-active",
60
+ "--surface-hover",
61
+ "--surface-sunken",
62
+ // Content
63
+ "--content-default",
64
+ "--content-emphasised",
65
+ "--content-secondary",
66
+ "--content-tertiary",
67
+ "--content-quiet",
68
+ "--content-strong",
69
+ "--content-faint",
70
+ "--content-disabled",
71
+ "--content-inset",
72
+ // Borders
73
+ "--border-base",
74
+ "--border-subtle",
75
+ "--border-element",
76
+ "--border-hover",
77
+ "--border-disabled",
78
+ "--border-active",
79
+ // System / status
80
+ "--system-positive-strong",
81
+ "--system-positive-weak",
82
+ "--system-negative-strong",
83
+ "--system-negative-weak",
84
+ "--system-negative-hover",
85
+ "--system-mid-strong",
86
+ "--system-mid-weak",
87
+ "--system-info-strong",
88
+ "--system-info-weak",
89
+ // Fonts
90
+ "--font-sans",
91
+ "--font-mono",
92
+ "--font-serif",
93
+ // Radius
94
+ "--radius-xs",
95
+ "--radius-sm",
96
+ "--radius-md",
97
+ "--radius-lg",
98
+ "--radius-xl",
99
+ "--radius-xxl",
100
+ "--radius-pill",
101
+ // Palettes
102
+ ...PALETTE_PROPERTIES,
103
+ ];
104
+
105
+ /** Lookup form of {@link WIDGET_TOKEN_PROPERTIES}. */
106
+ const WIDGET_TOKEN_NAMES: ReadonlySet<string> = new Set(
107
+ WIDGET_TOKEN_PROPERTIES,
108
+ );
109
+
110
+ /**
111
+ * Complete vocabulary, quoted back to the model when a fragment references a
112
+ * variable that does not exist. Enumerating every non-palette name (they are
113
+ * short) lets the first retry succeed instead of the model guessing plausible
114
+ * names or re-reading the skill.
115
+ */
116
+ const TOKEN_FAMILY_SUMMARY =
117
+ WIDGET_TOKEN_PROPERTIES.filter((name) => !name.startsWith("--color-")).join(
118
+ ", ",
119
+ ) +
120
+ ", and the palette ramps --color-<moss|stone>-<50-950> and --color-<forest|emerald|danger|amber>-<100-950>";
121
+
122
+ /**
123
+ * Sub-resource loads the sandbox blocks outright. Catching them here turns a
124
+ * silently blank widget into an actionable error.
125
+ */
126
+ const EXTERNAL_RESOURCE_PATTERNS: { pattern: RegExp; what: string }[] = [
127
+ { pattern: /<script\b[^>]*\bsrc\s*=/i, what: "a <script src=...> tag" },
128
+ {
129
+ pattern: /<link\b[^>]*\bstylesheet\b/i,
130
+ what: "a <link rel=stylesheet> tag",
131
+ },
132
+ { pattern: /@import\b/i, what: "a CSS @import rule" },
133
+ ];
134
+
135
+ /** Every `var(--name)` reference in the fragment. */
136
+ const VAR_REFERENCE_PATTERN = /var\(\s*(--[a-zA-Z0-9-]+)/g;
137
+
138
+ /** Every `--name:` declaration, i.e. properties the fragment defines itself. */
139
+ const CUSTOM_PROPERTY_DECLARATION_PATTERN = /(--[a-zA-Z0-9-]+)\s*:/g;
140
+
141
+ /** Properties the fragment sets from script: `setProperty('--name', …)`. */
142
+ const SET_PROPERTY_DECLARATION_PATTERN =
143
+ /setProperty\(\s*["'](--[a-zA-Z0-9-]+)["']/g;
144
+
145
+ /**
146
+ * Fragments legitimately carry `#`-prefixed identifiers that are not colours:
147
+ * SVG paint and filter references, in-page anchors, and numeric character
148
+ * references. Removing them before the colour scan keeps the check unambiguous.
149
+ */
150
+ const NON_COLOR_HASH_PATTERNS: RegExp[] = [
151
+ /url\(\s*['"]?#[^)]*\)/gi,
152
+ /href\s*=\s*["']#[^"']*["']/gi,
153
+ /&#x?[0-9a-fA-F]+;/g,
154
+ ];
155
+
156
+ /** `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`. */
157
+ const HEX_TOKEN_PATTERN = /#([0-9a-fA-F]+)\b/g;
158
+ const HEX_COLOR_LENGTHS = new Set([3, 4, 6, 8]);
159
+
160
+ /** Functional colour notations whose first argument is a literal number. */
161
+ const FUNCTIONAL_COLOR_PATTERN =
162
+ /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*(?:\d|\.\d|-\d)[^)]*\)/gi;
163
+
164
+ /** How many offending values a teaching error quotes back. */
165
+ const MAX_QUOTED_PROBLEMS = 5;
166
+
167
+ /** Every root `<svg …>` open tag. Nested `<svg>` is not a thing we author. */
168
+ const SVG_OPEN_TAG_PATTERN = /<svg\b[^>]*>/gi;
169
+
170
+ const VIEWBOX_ATTRIBUTE_PATTERN = /\bviewBox\s*=/i;
171
+
172
+ /** A `width`/`height` attribute whose value is a bare or `px` pixel count. */
173
+ const SVG_PIXEL_SIZE_PATTERN =
174
+ /\b(width|height)\s*=\s*["'](\d+(?:\.\d+)?)(px)?["']/gi;
175
+
176
+ /**
177
+ * Widest an `<svg>` can be sized in pixels before it overruns the frame. The
178
+ * container gives a visual about 660px of usable width, and the drawing
179
+ * conventions lay out against a 680-unit canvas; anything past that is only
180
+ * safe as a viewBox, which scales.
181
+ */
182
+ const MAX_SVG_PIXEL_SIZE = 680;
183
+
184
+ /**
185
+ * The fix for an unscalable `<svg>`, quoted verbatim so the retry lands in one
186
+ * call rather than guessing at a smaller pixel width.
187
+ */
188
+ const VIEWBOX_FIX =
189
+ 'Give the root svg a viewBox="0 0 W H" (W and H are the drawing\'s own coordinate extent, W 680 by convention) plus width="100%", and remove any fixed pixel width and height. ' +
190
+ "A viewBox is what makes a drawing scale to whatever width the frame has; a pixel-sized svg keeps its size and is silently clipped at the right edge, with no scrollbar and nothing on screen to say content is missing.";
191
+
192
+ /** `<rect>` open tags, the one shape whose extent is exact from its attributes. */
193
+ const SVG_RECT_TAG_PATTERN = /<rect\b[^>]*>/gi;
194
+
195
+ /** Numbers of a `viewBox="minX minY width height"` value. */
196
+ const VIEWBOX_VALUE_PATTERN =
197
+ /\bviewBox\s*=\s*["']\s*(-?[\d.]+)[\s,]+(-?[\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i;
198
+
199
+ /** Sub-pixel slack, so a rounded coordinate is not reported as an overrun. */
200
+ const VIEWBOX_BOUNDS_TOLERANCE = 0.5;
201
+
202
+ function numericAttribute(tag: string, name: string): number | undefined {
203
+ const match = new RegExp(`\\b${name}\\s*=\\s*["'](-?[\\d.]+)`, "i").exec(tag);
204
+ if (!match) {
205
+ return undefined;
206
+ }
207
+ const value = Number(match[1]);
208
+ return Number.isFinite(value) ? value : undefined;
209
+ }
210
+
211
+ /**
212
+ * Shapes drawn outside their own `viewBox`. An SVG clips at the viewBox edge,
213
+ * so a node placed past it is not laid out badly, it is invisible. What a
214
+ * reader sees is a diagram whose last box is sliced off, with nothing to
215
+ * indicate more was drawn.
216
+ */
217
+ function collectViewBoxOverrunProblems(html: string): string[] {
218
+ const overruns = new Set<string>();
219
+
220
+ for (const svg of html.matchAll(SVG_OPEN_TAG_PATTERN)) {
221
+ const box = VIEWBOX_VALUE_PATTERN.exec(svg[0]);
222
+ if (!box) {
223
+ continue;
224
+ }
225
+ const minX = Number(box[1]);
226
+ const minY = Number(box[2]);
227
+ const maxX = minX + Number(box[3]);
228
+ const maxY = minY + Number(box[4]);
229
+
230
+ const bodyStart = (svg.index ?? 0) + svg[0].length;
231
+ const closeIndex = html.indexOf("</svg", bodyStart);
232
+ const body = html.slice(
233
+ bodyStart,
234
+ closeIndex === -1 ? html.length : closeIndex,
235
+ );
236
+
237
+ for (const rect of body.matchAll(SVG_RECT_TAG_PATTERN)) {
238
+ const x = numericAttribute(rect[0], "x") ?? 0;
239
+ const y = numericAttribute(rect[0], "y") ?? 0;
240
+ const width = numericAttribute(rect[0], "width");
241
+ const height = numericAttribute(rect[0], "height");
242
+ if (width !== undefined && x + width > maxX + VIEWBOX_BOUNDS_TOLERANCE) {
243
+ overruns.add(
244
+ `x=${x} width=${width} ends at ${x + width}, past ${maxX}`,
245
+ );
246
+ }
247
+ if (
248
+ height !== undefined &&
249
+ y + height > maxY + VIEWBOX_BOUNDS_TOLERANCE
250
+ ) {
251
+ overruns.add(
252
+ `y=${y} height=${height} ends at ${y + height}, past ${maxY}`,
253
+ );
254
+ }
255
+ }
256
+ }
257
+
258
+ if (overruns.size === 0) {
259
+ return [];
260
+ }
261
+ return [
262
+ `Shapes drawn outside the viewBox: ${quote([...overruns])}. ` +
263
+ "An SVG clips at its viewBox edge, so these render sliced off or not at all, with nothing on screen to say " +
264
+ "content is missing. Either move the shapes back inside the box or widen the viewBox to cover them, " +
265
+ "and re-check the packing math: everything has to fit between the viewBox bounds, gaps included.",
266
+ ];
267
+ }
268
+
269
+ /**
270
+ * `transform` attributes anywhere in the fragment. The layout system is built
271
+ * on absolute viewBox coordinates (grid slots, extent checks, label centring),
272
+ * and a translated group invites mixing local and absolute coordinates on its
273
+ * children — the classic symptom is every label rendering below or beside its
274
+ * box. Banning the attribute keeps one coordinate system for everything.
275
+ */
276
+ function collectTransformProblems(html: string): string[] {
277
+ const count = [...html.matchAll(/\stransform\s*=\s*["']/g)].length;
278
+ if (count === 0) {
279
+ return [];
280
+ }
281
+ return [
282
+ `${count} element(s) carry a transform attribute. Every coordinate in the fragment is an absolute viewBox coordinate; ` +
283
+ "remove each transform and place the element directly. A node's text centres at the rect's own position: " +
284
+ 'x = rect x + width/2 with text-anchor="middle", y = rect y + 22 for a single line (or +20 and +38 for a two-line node). ' +
285
+ "Translated groups mix local and absolute coordinates and put labels outside their boxes.",
286
+ ];
287
+ }
288
+
289
+ /**
290
+ * Style rules that paint a surface token as an inheritable fill. `fill`
291
+ * inherits from a group to its text children, so `.note{fill:var(--surface-sunken)}`
292
+ * declared after the text classes silently repaints every label in the tile's
293
+ * own background colour. A surface fill is legitimate only when the rule is
294
+ * scoped to shape elements.
295
+ */
296
+ const SHAPE_SCOPED_SELECTOR_PATTERN =
297
+ /\b(?:rect|path|circle|ellipse|line|polyline|polygon)\b/;
298
+
299
+ function collectSurfaceFillOnTextProblems(html: string): string[] {
300
+ const offenders: string[] = [];
301
+ for (const rule of html.matchAll(/([^{}<>]{1,120})\{([^{}]*)\}/g)) {
302
+ const selector = rule[1].trim();
303
+ const body = rule[2];
304
+ if (!/fill\s*:\s*var\(--surface-/.test(body)) {
305
+ continue;
306
+ }
307
+ if (SHAPE_SCOPED_SELECTOR_PATTERN.test(selector)) {
308
+ continue;
309
+ }
310
+ offenders.push(selector);
311
+ }
312
+ if (offenders.length === 0) {
313
+ return [];
314
+ }
315
+ return [
316
+ `Style rule(s) ${quote(offenders)} set fill to a surface token without scoping to a shape. ` +
317
+ "fill inherits to text, so a group-level surface fill repaints the labels inside it in the background colour " +
318
+ "and they disappear. Scope background fills to the shape (`.note rect{fill:var(--surface-sunken)}`) and keep " +
319
+ "text fills on content tokens or ramp stops.",
320
+ ];
321
+ }
322
+
323
+ /**
324
+ * Problems with how an `<svg>` is sized. Without a `viewBox` the drawing has no
325
+ * intrinsic coordinate system to scale, so it renders at its literal size and
326
+ * the frame crops whatever does not fit.
327
+ */
328
+ function collectSvgSizingProblems(html: string): string[] {
329
+ const problems: string[] = [];
330
+ const oversized: string[] = [];
331
+ let missingViewBox = false;
332
+
333
+ for (const match of html.matchAll(SVG_OPEN_TAG_PATTERN)) {
334
+ const tag = match[0];
335
+ if (VIEWBOX_ATTRIBUTE_PATTERN.test(tag)) {
336
+ continue;
337
+ }
338
+ missingViewBox = true;
339
+ for (const size of tag.matchAll(SVG_PIXEL_SIZE_PATTERN)) {
340
+ if (Number(size[2]) > MAX_SVG_PIXEL_SIZE) {
341
+ oversized.push(`${size[1]}="${size[2]}${size[3] ?? ""}"`);
342
+ }
343
+ }
344
+ }
345
+
346
+ if (missingViewBox) {
347
+ problems.push(`An <svg> element has no viewBox attribute. ${VIEWBOX_FIX}`);
348
+ }
349
+ if (oversized.length > 0) {
350
+ problems.push(
351
+ `An <svg> without a viewBox is sized past the ${MAX_SVG_PIXEL_SIZE}px the frame can show: ${quote(
352
+ oversized,
353
+ )}. Everything beyond that width is cropped away. ${VIEWBOX_FIX}`,
354
+ );
355
+ }
356
+
357
+ return problems;
358
+ }
359
+
360
+ /** Every ramp-token reference, with the palette and stop split out. */
361
+ const RAMP_TOKEN_PATTERN = new RegExp(
362
+ `--color-(${[...NEUTRAL_PALETTES, ...ACCENT_PALETTES].join(
363
+ "|",
364
+ )})-(50|${RAMP_STEPS.join("|")})\\b`,
365
+ "g",
366
+ );
367
+
368
+ /**
369
+ * The two ends of a ramp, the stops that only read against a fill from the
370
+ * opposite end. The host mirrors ramp stops under the dark scheme, so an end
371
+ * stop swaps sides with the theme while the page background it might be
372
+ * painted on does not — pairing is what keeps the relationship intact.
373
+ */
374
+ const DARK_RAMP_STOPS: ReadonlySet<string> = new Set([
375
+ "700",
376
+ "800",
377
+ "900",
378
+ "950",
379
+ ]);
380
+ const LIGHT_RAMP_STOPS: ReadonlySet<string> = new Set(["50", "100", "200"]);
381
+ /** Fill stops light enough to carry dark ramp text. */
382
+ const LIGHT_FILL_STOPS: ReadonlySet<string> = new Set([
383
+ "50",
384
+ "100",
385
+ "200",
386
+ "300",
387
+ ]);
388
+
389
+ /** `color:` declarations, excluding `background-color:`, `stop-color:`, etc. */
390
+ const TEXT_COLOR_DECLARATION_PATTERN = /(?<![-\w])color\s*:\s*[^;}"'>]*/gi;
391
+
392
+ /** Open tags of the two SVG elements that paint glyphs. */
393
+ const SVG_TEXT_TAG_PATTERN = /<(?:text|tspan)\b[^>]*>/gi;
394
+
395
+ /** Any element's open tag, the span that carries one element's own paint. */
396
+ const START_TAG_PATTERN = /<[a-zA-Z][^>]*>/g;
397
+
398
+ /** `<g>` open and close tags, used to bound the group around a label. */
399
+ const GROUP_BOUNDARY_PATTERN = /<g\b[^>]*>|<\/g\s*>/gi;
400
+
401
+ /** Class names an element carries, used to reach the rules that paint it. */
402
+ const CLASS_ATTRIBUTE_PATTERN = /\bclass\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
403
+
404
+ /**
405
+ * Style rules, used to spot the ones that paint text via `fill:`. The selector
406
+ * is bounded by `<`, `>`, `;` and `}` so it cannot swallow preceding markup.
407
+ */
408
+ const CSS_RULE_PATTERN = /[^{};<>]*\{[^{}]*\}/g;
409
+ const FILL_DECLARATION_PATTERN = /\bfill\s*:/i;
410
+ const FONT_DECLARATION_PATTERN = /\bfont(?:-[a-z]+)?\s*:/i;
411
+ const TEXT_SELECTOR_PATTERN = /(?:^|[\s,>+~])(?:text|tspan)\b/i;
412
+
413
+ /**
414
+ * How much markup either side of a painted label counts as "beside it". A
415
+ * two-line SVG node — rect plus title plus subtitle — runs about 350
416
+ * characters, so this reaches the shape a label belongs to without reaching the
417
+ * rest of the drawing.
418
+ */
419
+ const LOCAL_PAIRING_WINDOW = 400;
420
+
421
+ /**
422
+ * Largest `<g>` that still reads as one paired shape. Past this a group is a
423
+ * layout container, and treating it as a pairing context would clear a palette
424
+ * for the whole drawing.
425
+ */
426
+ const MAX_LOCAL_GROUP_CHARS = 2000;
427
+
428
+ type SourceRange = { start: number; end: number };
429
+
430
+ type CssRule = SourceRange & { selector: string; body: string };
431
+
432
+ /** Every `{…}` rule in the fragment, split into selector and body. */
433
+ function collectCssRules(html: string): CssRule[] {
434
+ const rules: CssRule[] = [];
435
+ for (const match of html.matchAll(CSS_RULE_PATTERN)) {
436
+ const text = match[0];
437
+ const start = match.index ?? 0;
438
+ const braceIndex = text.indexOf("{");
439
+ rules.push({
440
+ start,
441
+ end: start + text.length,
442
+ selector: text.slice(0, braceIndex),
443
+ body: text.slice(braceIndex),
444
+ });
445
+ }
446
+ return rules;
447
+ }
448
+
449
+ function collectRanges(html: string, pattern: RegExp): SourceRange[] {
450
+ const ranges: SourceRange[] = [];
451
+ for (const match of html.matchAll(pattern)) {
452
+ const start = match.index ?? 0;
453
+ ranges.push({ start, end: start + match[0].length });
454
+ }
455
+ return ranges;
456
+ }
457
+
458
+ /** Spans of balanced `<g>…</g>` pairs. Unclosed groups are ignored. */
459
+ function collectGroupRanges(html: string): SourceRange[] {
460
+ const ranges: SourceRange[] = [];
461
+ const open: number[] = [];
462
+ for (const match of html.matchAll(GROUP_BOUNDARY_PATTERN)) {
463
+ const index = match.index ?? 0;
464
+ if (match[0].startsWith("</")) {
465
+ const start = open.pop();
466
+ if (start !== undefined) {
467
+ ranges.push({ start, end: index + match[0].length });
468
+ }
469
+ } else if (!match[0].endsWith("/>")) {
470
+ open.push(index);
471
+ }
472
+ }
473
+ return ranges;
474
+ }
475
+
476
+ /**
477
+ * Spans of the fragment that paint text: `color:` declarations, `<text>` and
478
+ * `<tspan>` open tags, and style rules whose `fill:` lands on glyphs — either
479
+ * because the rule also sets a font property or because its selector names a
480
+ * text element. A ramp token inside one of these is a text colour.
481
+ */
482
+ function collectTextColorRanges(html: string, rules: CssRule[]): SourceRange[] {
483
+ const ranges = [
484
+ ...collectRanges(html, TEXT_COLOR_DECLARATION_PATTERN),
485
+ ...collectRanges(html, SVG_TEXT_TAG_PATTERN),
486
+ ];
487
+ for (const rule of rules) {
488
+ if (
489
+ FILL_DECLARATION_PATTERN.test(rule.body) &&
490
+ (FONT_DECLARATION_PATTERN.test(rule.body) ||
491
+ TEXT_SELECTOR_PATTERN.test(rule.selector))
492
+ ) {
493
+ ranges.push({ start: rule.start, end: rule.end });
494
+ }
495
+ }
496
+ return ranges;
497
+ }
498
+
499
+ function isInsideRange(index: number, ranges: SourceRange[]): boolean {
500
+ return ranges.some((range) => index >= range.start && index < range.end);
501
+ }
502
+
503
+ /** The tightest range containing the index, if any. */
504
+ function innermostRange<T extends SourceRange>(
505
+ index: number,
506
+ ranges: T[],
507
+ ): T | undefined {
508
+ let found: T | undefined;
509
+ for (const range of ranges) {
510
+ if (index < range.start || index >= range.end) {
511
+ continue;
512
+ }
513
+ if (!found || range.end - range.start < found.end - found.start) {
514
+ found = range;
515
+ }
516
+ }
517
+ return found;
518
+ }
519
+
520
+ /** Whether a stop of `palette` from `allowed` is painted anywhere in `text`. */
521
+ function hasCounterpartStop(
522
+ text: string,
523
+ palette: string,
524
+ allowed: ReadonlySet<string>,
525
+ ): boolean {
526
+ for (const match of text.matchAll(RAMP_TOKEN_PATTERN)) {
527
+ if (match[1] === palette && allowed.has(match[2])) {
528
+ return true;
529
+ }
530
+ }
531
+ return false;
532
+ }
533
+
534
+ /**
535
+ * The markup a directly painted label is paired against: its own open tag, the
536
+ * window of markup around it, the group immediately containing it, and the
537
+ * bodies of the rules that paint the classes named in that window.
538
+ */
539
+ function localPairingContexts(
540
+ html: string,
541
+ index: number,
542
+ rules: CssRule[],
543
+ tags: SourceRange[],
544
+ groups: SourceRange[],
545
+ ): string[] {
546
+ const nearby = html.slice(
547
+ Math.max(0, index - LOCAL_PAIRING_WINDOW),
548
+ index + LOCAL_PAIRING_WINDOW,
549
+ );
550
+ const contexts = [nearby];
551
+
552
+ const tag = innermostRange(index, tags);
553
+ if (tag) {
554
+ contexts.push(html.slice(tag.start, tag.end));
555
+ }
556
+
557
+ const group = innermostRange(index, groups);
558
+ if (group && group.end - group.start <= MAX_LOCAL_GROUP_CHARS) {
559
+ contexts.push(html.slice(group.start, group.end));
560
+ }
561
+
562
+ const classNames = new Set<string>();
563
+ for (const match of nearby.matchAll(CLASS_ATTRIBUTE_PATTERN)) {
564
+ for (const name of (match[1] ?? match[2] ?? "").split(/\s+/)) {
565
+ if (name) {
566
+ classNames.add(name);
567
+ }
568
+ }
569
+ }
570
+ for (const rule of rules) {
571
+ if ([...classNames].some((name) => rule.selector.includes(`.${name}`))) {
572
+ contexts.push(rule.body);
573
+ }
574
+ }
575
+
576
+ return contexts;
577
+ }
578
+
579
+ /**
580
+ * The host mirrors each ramp stop onto its opposite under the dark scheme, so a
581
+ * ramp is theme-adaptive only relative to itself: a matched fill and text pair
582
+ * inverts together and stays legible, while a lone ramp label is left against
583
+ * the page background, which takes no part in that mirror. Ramp-coloured text
584
+ * therefore only reads when it sits on a matching ramp fill — and the fill has
585
+ * to be the one actually behind the glyphs.
586
+ *
587
+ * Pairing is therefore local. A label painted directly — an inline `style`, a
588
+ * `fill` attribute on `<text>` — pairs against its own element, the markup
589
+ * around it, the group containing it, or the rules painting the classes in that
590
+ * window. A label painted through a style rule pairs inside that rule, and
591
+ * otherwise against the fragment as a whole: which elements a selector reaches
592
+ * is not knowable without a DOM, so the rule's own placement carries no signal.
593
+ */
594
+ function collectRampContrastProblems(html: string): string[] {
595
+ const rules = collectCssRules(html);
596
+ const textRanges = collectTextColorRanges(html, rules);
597
+ const tags = collectRanges(html, START_TAG_PATTERN);
598
+ const groups = collectGroupRanges(html);
599
+
600
+ const occurrences = [...html.matchAll(RAMP_TOKEN_PATTERN)].map((match) => ({
601
+ token: match[0],
602
+ palette: match[1],
603
+ stop: match[2],
604
+ index: match.index ?? 0,
605
+ }));
606
+
607
+ const fillStops = new Map<string, Set<string>>();
608
+ for (const occurrence of occurrences) {
609
+ if (isInsideRange(occurrence.index, textRanges)) {
610
+ continue;
611
+ }
612
+ const stops = fillStops.get(occurrence.palette) ?? new Set<string>();
613
+ stops.add(occurrence.stop);
614
+ fillStops.set(occurrence.palette, stops);
615
+ }
616
+
617
+ const hasFillStopIn = (
618
+ palette: string,
619
+ allowed: ReadonlySet<string>,
620
+ ): boolean =>
621
+ [...(fillStops.get(palette) ?? [])].some((stop) => allowed.has(stop));
622
+
623
+ const unpairedDark = new Set<string>();
624
+ const unpairedLight = new Set<string>();
625
+
626
+ for (const occurrence of occurrences) {
627
+ if (!isInsideRange(occurrence.index, textRanges)) {
628
+ continue;
629
+ }
630
+ const bucket = DARK_RAMP_STOPS.has(occurrence.stop)
631
+ ? unpairedDark
632
+ : LIGHT_RAMP_STOPS.has(occurrence.stop)
633
+ ? unpairedLight
634
+ : undefined;
635
+ if (!bucket) {
636
+ continue;
637
+ }
638
+ const counterparts =
639
+ bucket === unpairedDark ? LIGHT_FILL_STOPS : DARK_RAMP_STOPS;
640
+
641
+ const rule = innermostRange(occurrence.index, rules);
642
+ const paired = rule
643
+ ? hasCounterpartStop(rule.body, occurrence.palette, counterparts) ||
644
+ hasFillStopIn(occurrence.palette, counterparts)
645
+ : localPairingContexts(html, occurrence.index, rules, tags, groups).some(
646
+ (context) =>
647
+ hasCounterpartStop(context, occurrence.palette, counterparts),
648
+ );
649
+
650
+ if (!paired) {
651
+ bucket.add(occurrence.token);
652
+ }
653
+ }
654
+
655
+ const problems: string[] = [];
656
+ if (unpairedDark.size > 0) {
657
+ problems.push(
658
+ `Dark ramp stops used as text colour with no matching light fill behind them: ${quote(
659
+ [...unpairedDark],
660
+ )}. ` +
661
+ "Ramp stops mirror across their own ramp in dark mode, so a dark stop used as text flips to a " +
662
+ "light tint there while the page background takes no part in that mirror — a ramp-coloured " +
663
+ "label sitting on it has no reliable contrast in either theme. Ramp-coloured text has to sit " +
664
+ "on a matching light ramp " +
665
+ "fill (a 50–300 stop of the same palette) set on the same element, in the same style rule, or " +
666
+ "on the group immediately around it — or use a --content-* token for text that sits on the " +
667
+ "page background.",
668
+ );
669
+ }
670
+ if (unpairedLight.size > 0) {
671
+ problems.push(
672
+ `Light ramp stops used as text colour with no matching dark fill behind them: ${quote(
673
+ [...unpairedLight],
674
+ )}. ` +
675
+ "Light ramp text on the transparent widget background is invisible in light mode, and in dark " +
676
+ "mode the stop mirrors to the darkest end of its own ramp — invisible there too. " +
677
+ "Ramp-coloured text has to sit on a matching dark ramp " +
678
+ "fill (a 700–950 stop of the same palette) set on the same element, in the same style rule, or " +
679
+ "on the group immediately around it — or use a --content-* token for text that sits on the " +
680
+ "page background.",
681
+ );
682
+ }
683
+
684
+ return problems;
685
+ }
686
+
687
+ function collectDeclaredProperties(html: string): Set<string> {
688
+ const declared = new Set<string>();
689
+ for (const match of html.matchAll(CUSTOM_PROPERTY_DECLARATION_PATTERN)) {
690
+ declared.add(match[1]);
691
+ }
692
+ for (const match of html.matchAll(SET_PROPERTY_DECLARATION_PATTERN)) {
693
+ declared.add(match[1]);
694
+ }
695
+ return declared;
696
+ }
697
+
698
+ function collectUnknownVariables(html: string): string[] {
699
+ const declared = collectDeclaredProperties(html);
700
+ const unknown = new Set<string>();
701
+ for (const match of html.matchAll(VAR_REFERENCE_PATTERN)) {
702
+ const name = match[1];
703
+ if (!WIDGET_TOKEN_NAMES.has(name) && !declared.has(name)) {
704
+ unknown.add(name);
705
+ }
706
+ }
707
+ return [...unknown];
708
+ }
709
+
710
+ function collectColorLiterals(html: string): string[] {
711
+ let scannable = html;
712
+ for (const pattern of NON_COLOR_HASH_PATTERNS) {
713
+ scannable = scannable.replace(pattern, " ");
714
+ }
715
+
716
+ const literals: string[] = [];
717
+ for (const match of scannable.matchAll(HEX_TOKEN_PATTERN)) {
718
+ if (HEX_COLOR_LENGTHS.has(match[1].length)) {
719
+ literals.push(match[0]);
720
+ }
721
+ }
722
+ for (const match of scannable.matchAll(FUNCTIONAL_COLOR_PATTERN)) {
723
+ literals.push(match[0]);
724
+ }
725
+ return literals;
726
+ }
727
+
728
+ function quote(values: string[]): string {
729
+ const shown = values.slice(0, MAX_QUOTED_PROBLEMS).join(", ");
730
+ const extra = values.length - MAX_QUOTED_PROBLEMS;
731
+ return extra > 0 ? `${shown}, and ${extra} more` : shown;
732
+ }
733
+
734
+ /**
735
+ * Everything wrong with a `visual` fragment, as a list of problems the model
736
+ * can fix in one pass. Empty means the fragment is renderable.
737
+ */
738
+ export function validateVisualHtml(html: string): string[] {
739
+ const problems: string[] = [];
740
+
741
+ if (html.length > MAX_HTML_CHARS) {
742
+ problems.push(
743
+ `The fragment is ${html.length} characters, over the ${MAX_HTML_CHARS} limit. ` +
744
+ "A fragment this size cannot be emitted in one call. Simplify it: cut nodes, drop subtitles, remove decorative markup, or split the idea across two visuals with prose between them.",
745
+ );
746
+ }
747
+
748
+ for (const { pattern, what } of EXTERNAL_RESOURCE_PATTERNS) {
749
+ if (pattern.test(html)) {
750
+ problems.push(
751
+ `The fragment contains ${what}. The sandbox has no network access, so external resources never load. ` +
752
+ "Inline the styles and draw charts and diagrams by hand in SVG.",
753
+ );
754
+ }
755
+ }
756
+
757
+ const unknown = collectUnknownVariables(html);
758
+ if (unknown.length > 0) {
759
+ problems.push(
760
+ `Undefined CSS variables: ${quote(
761
+ unknown,
762
+ )}. Only the injected design tokens exist inside the ` +
763
+ "frame — every other name resolves to nothing and the declaration is dropped. " +
764
+ `The vocabulary is ${TOKEN_FAMILY_SUMMARY}. ` +
765
+ "To use a variable of your own, declare it in the fragment's own style block first.",
766
+ );
767
+ }
768
+
769
+ const literals = collectColorLiterals(html);
770
+ if (literals.length > 0) {
771
+ problems.push(
772
+ `Hardcoded colour values: ${quote(
773
+ literals,
774
+ )}. Every colour — text, background, border, SVG ` +
775
+ "fill and stroke — comes from an injected variable, so the visual follows the user's theme. " +
776
+ `Replace each literal with a token from ${TOKEN_FAMILY_SUMMARY}.`,
777
+ );
778
+ }
779
+
780
+ problems.push(...collectSvgSizingProblems(html));
781
+ problems.push(...collectViewBoxOverrunProblems(html));
782
+ problems.push(...collectRampContrastProblems(html));
783
+ problems.push(...collectTransformProblems(html));
784
+ problems.push(...collectSurfaceFillOnTextProblems(html));
785
+
786
+ return problems;
787
+ }