/** * Validation for the `visual` ui_show surface: a self-contained HTML fragment * rendered inside a sandboxed frame. * * The frame has no network access and injects a fixed vocabulary of design * tokens on `:root`, so an external sub-resource silently never loads and a * `var()` to * anything outside that vocabulary resolves to nothing. Both failures render a * blank or unthemed widget with no error anywhere, so they are caught here and * reported as teaching errors the model can act on before the surface is ever * emitted. */ /** * Upper bound on fragment size. Sized to what one model response can emit * with room to spare: a fragment past this cannot be produced in a single * ui_show call, so the turn burns its output budget and renders nothing. */ const MAX_HTML_CHARS = 24000; const RAMP_STEPS = [ "100", "200", "300", "400", "500", "600", "700", "800", "900", "950", ] as const; /** Neutral ramps carry an extra lightest step; the accents start at 100. */ const NEUTRAL_PALETTES = ["moss", "stone"] as const; const ACCENT_PALETTES = ["forest", "emerald", "danger", "amber"] as const; const PALETTE_PROPERTIES: readonly string[] = [ ...NEUTRAL_PALETTES.flatMap((palette) => ["50", ...RAMP_STEPS].map((step) => `--color-${palette}-${step}`), ), ...ACCENT_PALETTES.flatMap((palette) => RAMP_STEPS.map((step) => `--color-${palette}-${step}`), ), ]; /** * The CSS custom properties that exist inside a widget frame. The host injects * exactly these onto `:root` of the sandboxed iframe. * * Mirrors `WIDGET_TOKEN_PROPERTIES` in * `clients/web/src/utils/widget-tokens.ts` — the two must change together. */ export const WIDGET_TOKEN_PROPERTIES: readonly string[] = [ // Surfaces "--surface-base", "--surface-lift", "--surface-overlay", "--surface-active", "--surface-hover", "--surface-sunken", // Content "--content-default", "--content-emphasised", "--content-secondary", "--content-tertiary", "--content-quiet", "--content-strong", "--content-faint", "--content-disabled", "--content-inset", // Borders "--border-base", "--border-subtle", "--border-element", "--border-hover", "--border-disabled", "--border-active", // System / status "--system-positive-strong", "--system-positive-weak", "--system-positive-on-weak", "--system-negative-strong", "--system-negative-weak", "--system-negative-on-weak", "--system-negative-hover", "--system-mid-strong", "--system-mid-weak", "--system-info-strong", "--system-info-weak", // Fonts "--font-sans", "--font-mono", "--font-serif", // Radius "--radius-xs", "--radius-sm", "--radius-md", "--radius-lg", "--radius-xl", "--radius-xxl", "--radius-pill", // Palettes ...PALETTE_PROPERTIES, ]; /** Lookup form of {@link WIDGET_TOKEN_PROPERTIES}. */ const WIDGET_TOKEN_NAMES: ReadonlySet = new Set( WIDGET_TOKEN_PROPERTIES, ); /** * Complete vocabulary, quoted back to the model when a fragment references a * variable that does not exist. Enumerating every non-palette name (they are * short) lets the first retry succeed instead of the model guessing plausible * names or re-reading the skill. */ const TOKEN_FAMILY_SUMMARY = WIDGET_TOKEN_PROPERTIES.filter((name) => !name.startsWith("--color-")).join( ", ", ) + ", and the palette ramps --color--<50-950> and --color--<100-950>"; /** * Sub-resource loads the sandbox blocks outright. Catching them here turns a * silently blank widget into an actionable error. */ const EXTERNAL_RESOURCE_PATTERNS: { pattern: RegExp; what: string }[] = [ { pattern: /]*\bsrc\s*=/i, what: "a