dsh-motion-ui 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -23,11 +23,10 @@ function defineRenderTool(options) {
23
23
  return {...tool,async execute(args,exec){
24
24
  // Legacy adapters may stringify JSON. Decode here, without advertising a
25
25
  // string branch that encourages models to manually escape whole documents.
26
- const normalized=args&&Object.hasOwn(args,'document')?{...args,document:toolJson(args.document,'document')}:args;
27
- try{return await execute(normalized,exec);}catch(error){
28
- if(error.name==='ToolArgsError' && args && Object.hasOwn(args,'document')) validateDocument(toolJson(args.document,'document'));
29
- throw error;
30
- }
26
+ // Canonicalize adapter quirks before native schema checks, so a lossless
27
+ // items:{item:[...]} wrapper receives the same treatment as stored/replayed UI.
28
+ const normalized=args&&Object.hasOwn(args,'document')?{...args,document:validateDocument(toolJson(args.document,'document'))}:args;
29
+ return execute(normalized,exec);
31
30
  }};
32
31
  }
33
32
  export const Config=z.object({designMode:z.union(['guided','free']).default('guided').description('guided:设计技能与示例引导;free:只注入画布接口与交互约束,由模型自主设计。'),displayMode:z.union(['tail','inline','sidebar']).default('tail').description('tail:回答下方;inline:正文标记位置;sidebar:PC 侧栏。'),showDetails:z.boolean().default(false).description('显示参数状态、原始说明、视觉诊断和版本历史。默认关闭,保持界面简洁。')}).volatile();
@@ -53,7 +52,7 @@ export function apply(ctx,config={}) {
53
52
  })));
54
53
  ctx.effect(()=>ctx.tools.register(defineTool({
55
54
  name:'render_canvas',description:'Create a static or interactive HTML/SVG/CSS/JavaScript canvas in the conversation. You design all content, controls, layout and motion. No fixed UI composition. Same persistence, isolation and inspect/patch support as render_ui. Use the injected canvas contract; no external resources or host APIs. Direct native calls only.',
56
- parameters:{title:{type:'string',required:true},fallback:{type:'string',required:true},state:{type:'json'},data:{type:'json'},derived:{type:'json'},height:{type:'number'},css:{type:'string',description:'Emit before html for styled static streaming preview; use an empty string when no custom styles are needed.'},html:{type:'string',required:true},script:{type:'string'}},
55
+ parameters:{title:{type:'string',required:true},fallback:{type:'string',required:true},state:{type:'json'},data:{type:'json'},derived:{type:'json'},height:{type:'number'},css:{type:'string',description:'Canvas CSS; use an empty string when no custom styles are needed. UI is displayed after the assistant turn ends.'},html:{type:'string',required:true},script:{type:'string'}},
57
56
  output:{schema:{type:'json'},render:(_a,v)=>[{type:'text',text:fallbackText(v.document)+`\nUI documentId=${v.documentId}; documentRevision=${v.documentRevision??0}; canvas nodeId=canvas.`+placement(v)}],presentationMeta:(_a,v)=>({dshGenerativeUi:v})},
58
57
  async execute(args,exec){exec.signal.throwIfAborted();const record=await store.create(canvasDocument(args),{sessionId:exec.agent?.id??'unscoped',callId:exec.callId});exec.signal.throwIfAborted();return record;}
59
58
  })));
@@ -1,9 +1,16 @@
1
1
  // Keep this short and early in the injected contract, including free mode.
2
2
  export const AUTHORING_GUIDE = `Authoring fast path:
3
+ - UI presentation waits for the whole assistant turn to end, including later reasoning/text. A successful render call saves the UI but does not mean it is already visible. Tool argument streaming does not preview UI. Text/reasoning stream normally; continue the user's task without waiting for the display or polling it.
4
+ - JSON object field order never matters. Every node, including each sandbox, must include a unique id. sandbox title/fallback default to the document's only when omitted; do not send null or empty values.
5
+ - Declaring state does NOT create controls. For each parameter the user should edit, add a native slider/input/select/toggle bound to state.key, or actual canvas HTML controls wired to DSHUI.setState with that key in stateKeys. A canvas subscribe callback only updates display; it does not create an input. Read-only state and static UI are valid; do not invent controls where the task does not need them. Check intended controls exist before calling the tool.
6
+ - Use render_ui/render_canvas directly for UI construction. This contract and ui_catalog are the protocol reference; installation source inspection is not a prerequisite. For unclear component/formula details consult ui_catalog, rather than grep/glob/shell searches of DSH checkouts or app.asar. File/search tools needed for the user's actual task or data remain appropriate.
7
+ - Formula round(x) takes ONE argument and uses JavaScript Math.round: nearest integer, ties toward positive infinity (round(2.5)=3, round(-2.5)=-2). It is not ceil/floor, which are not registered native formula functions. For ceiling/floor use Math.ceil/Math.floor in a canvas subscribed to declared shared state. Display decimals formats numbers without changing calculation precision.
3
8
  - Pass tool JSON fields as objects, not hand-escaped JSON strings. For custom HTML/SVG use render_canvas({title,fallback,html,css,script}) to avoid nesting source in document JSON.
4
9
  - Start with the smallest useful static UI. State/derived/actions are optional. A table can put its object rows directly in rows; reuse data/derived references only when useful. Example: {id:"t",type:"table",label:"方案比较",columns:[{key:"方案",label:"方案"}],rows:[{"方案":"A"}]}.
10
+ - For a short ordered process, use steps with a DIRECT items array: {id:"flow",type:"steps",items:[{title:"整理文件",description:"保留源码和说明,排除密钥。"},{title:"提交代码",description:"检查变更后提交。"}]}. Do not wrap items in {item:[...]}. status is optional: omit it for explanatory guides; use pending/active/done/error only for known task states. Keep one sentence per requested step; avoid extra command disclosures unless asked.
11
+ - Numeric controls need complete typed state: state:{people:{type:"number",default:2,min:1,max:12,step:1},days:{type:"number",default:5,min:2,max:15,step:1}}. Use numbers without quotes or units. Prefer derived:{food:{expr:"state.people * state.days * 100"}} to a deep op/args tree. children, options, columns and expression args are direct arrays. The host normalizes plain numeric strings only in explicitly numeric fields and exact {item:[...]} wrappers only at declared array positions; ordinary text/data retain their types.
5
12
  - Host defaults use rounded surfaces and soft theme-aware gradients. For guided canvas designs prefer 18..24px surface radii and subtle gradient fills with legible text. Let content determine height: no empty spacer sections, min-height:100vh or large fixed min-heights. A canvas auto-fits its rendered content after commit, including shrinking after filtering; height is only the initial placeholder. Use SVG viewBox bounds close to actual graphics plus reasonable padding.
6
13
  - metric.value accepts a finite number or display string/boolean. Numbers use decimals and unit; strings display verbatim ("约 12 GiB", "待确认"). Calculations and chart series still require real numbers, never formatted strings.
7
14
  - IDs, data/state/derived keys, column keys and formula references support Chinese letters and underscores. Use 1..64 letters/numbers/underscores, start with a letter/underscore, no spaces/dots/hyphens; never constructor/prototype/__proto__. Labels/text can be natural language.
8
15
  - Persist a canvas checklist/list/object with state:{checklist:{type:"json",default:[]}} and DSHUI.setState({checklist:nextArray}). json state allows bounded arrays/objects, no options; native input/select cannot edit it. A native checklist uses separate boolean states and toggle nodes. Static lists belong in nodes/data, not state. String state with an array/object default and no options is canonicalized to json without losing its structure.
9
- - Do not guess capabilities. On validation failure, fix the named field in the original document. Unknown properties are not discarded; unsupported code is not executed. Never claim successful delivery before the tool succeeds.`;
16
+ - Do not guess capabilities. On validation failure, fix the named field in the original document; do not deliver tiny probe/test UIs or search the DSH installation with shell tools. ui_catalog is the protocol reference. Missing bounds/operands need a real correction, not arbitrary guesses. Unknown properties are not discarded; unsupported code is not executed. Never claim successful delivery before the tool succeeds. A canvas with undeclared private DOM state does not have host persistence or state/derived followup snapshots.`;
package/lib/build-info.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by npm run build.
2
- export const BUILD_INFO = Object.freeze({"version":"0.5.4","id":"0.5.4+92e88973eabc","sourceHash":"92e88973eabc736e169fbb03366db183ca5cd1fbbe0e3a7e44e5742da708d96e"});
2
+ export const BUILD_INFO = Object.freeze({"version":"0.5.6","id":"0.5.6+1a92fbfce0c2","sourceHash":"1a92fbfce0c23812d59332a0de554fd7fd914d44fd715ab8e056ca0f19193bcf"});
package/lib/catalog.js CHANGED
@@ -7,7 +7,7 @@ export const PROTOCOL_GUIDE = AUTHORING_GUIDE+`\n\nDSH UI v1. Call render_ui({do
7
7
  Formula shorthand: {expr:"state.V * state.V / state.R"} compiles to the same validated interpreter. Supports explicit state.x/data.x/derived.x/item.x references, + - * /, == != < <= > >=, parentheses, condition ? yes : no, min/max/round/abs, double-quoted strings and booleans. Use it in derived, node text/value/max/rows or map.fields. No arbitrary functions, property chains, statements or side effects; complex animation belongs in render_canvas. Example derived:{power:{expr:"state.V * state.V / state.R"}}, metric.value:{ref:"derived.power"}.
8
8
  Document: {version:1,title:string,fallback:string,presentation?:{layout,accent,density},state?:{},data?:{},derived?:{},nodes:[],actions?:{}}.
9
9
  Scene layouts: dashboard (metrics/trends), workbench (parameter controls/results), comparison (parallel choice cards), guide (ordered instructions), status (observed task snapshot), report (reading and evidence). Accent: blue/teal/violet/amber/rose; density: comfortable/compact. Omit to keep the standard design. Choose based on the task, not at random. Card surface: outline/soft/featured. Featured indicates discussion focus, not an automatic recommendation.
10
- Additional nodes: badge {id,type,text,icon?,tone?}; progress {id,type,label,value,max,unit?,icon?,tone?} where values may be bindings and 0 <= value <= positive max; steps {id,type,items:[{title,description?,status:"pending"|"active"|"done"|"error"}]} (1..12 items); details {id,type,title,children,open?:boolean,icon?,tone?}. Progress and steps are declared snapshots, not a live host task subscription. Only claim real progress/completion from actual tool evidence; mark demo data visibly. Details keeps the content available in text fallback.
10
+ Additional nodes: badge {id,type,text,icon?,tone?}; progress {id,type,label,value,max,unit?,icon?,tone?} where values may be bindings and 0 <= value <= positive max; steps {id,type,items:[{title,description?,status?:"pending"|"active"|"done"|"error"}]} (direct array of 1..12 items, not {item:[...]}; omit status for static guides); details {id,type,title,children,open?:boolean,icon?,tone?}. Progress and steps are declared snapshots, not a live host task subscription. Only claim real progress/completion from actual tool evidence; mark demo data visibly. Details keeps the content available in text fallback.
11
11
  Streaming: use ONE normal native render_ui call. Write version,title,fallback,presentation,state,data,derived,actions BEFORE nodes where possible. The client previews completed, validated top-level subtrees as arguments stream; incomplete nodes/strings/calculations wait. Preview controls are disabled and never save state or send actions. Only a successful Host result activates the durable UI. No separate ui.begin/patch calls are required. A cancelled/failed generation has no interactive document. Prefer several small meaningful top-level sections over one enormous wrapping card.
12
12
  All IDs and dictionary keys: Unicode letters (including Chinese) or underscore, followed by letters/marks/digits/underscore, max64; unique node IDs. Standard components reject HTML, JS, URLs, styles and callbacks. The sandbox component below is the explicit exception; unlisted fields are always rejected.
13
13
  Freeform canvas: {id,type:"sandbox",title,html,css?:string,script?:string,height?:160..1000,fallback,stateKeys?:string[],dataKeys?:string[],derivedKeys?:string[]}. Use it for custom SVG diagrams, animation, simulations and bespoke interfaces that fixed components cannot express. HTML is a body fragment. Put CSS in css and ALL JavaScript in script (not inline handlers, script tags or remote imports). Inline SVG is supported. Each source field allows up to 49152 characters; the total document remains 128KiB. The iframe has no same-origin privilege, remote resources/network APIs are blocked by CSP, and no host tools are exposed. Never put credentials or private unrelated data in a canvas. It is browser origin isolation, not a CPU/memory hard sandbox.
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build.mjs.
2
- export const EXPORT_RUNTIME="(() => {\n // lib/presentation.js\n var UI_ICONS = Object.freeze([\"sparkles\", \"sliders\", \"layers\", \"chart\", \"gauge\", \"cpu\", \"database\", \"info\", \"check\", \"warning\", \"arrow\", \"target\"]);\n var UI_TONES = Object.freeze([\"neutral\", \"accent\", \"success\", \"warning\", \"danger\"]);\n var UI_LAYOUTS = Object.freeze([\"dashboard\", \"workbench\", \"comparison\", \"guide\", \"status\", \"report\"]);\n var UI_ACCENTS = Object.freeze([\"blue\", \"teal\", \"violet\", \"amber\", \"rose\"]);\n var UI_SURFACES = Object.freeze([\"outline\", \"soft\", \"featured\"]);\n\n // lib/visual-audit.js\n function measureVisual(root, nodeId = \"\") {\n const issues = [], seen = /* @__PURE__ */ new Set(), coverage = { nodeId, elements: 0, truncated: false, contrastChecked: 0, contrastUncovered: 0 };\n const add = (code, value, el2) => {\n const id = el2?.closest(\"[data-ui-node]\")?.getAttribute(\"data-ui-node\") ?? nodeId;\n const k = id + \":\" + code + \":\" + Math.round(value ?? 0);\n if (!seen.has(k) && issues.length < 40) {\n seen.add(k);\n issues.push({ code, nodeId: id, ...value === void 0 ? {} : { value: Number(value.toFixed(2)) } });\n }\n };\n const color = (s) => {\n let m = s.match(/^rgba?\\(([^)]+)\\)/);\n if (m) {\n const a = m[1].split(/[\\s,\\/]+/).map(Number);\n return [a[0] / 255, a[1] / 255, a[2] / 255, a[3] ?? 1];\n }\n m = s.match(/^color\\(srgb ([^)]+)\\)/);\n if (m) {\n const a = m[1].split(/[\\s\\/]+/).map(Number);\n return [a[0], a[1], a[2], a[3] ?? 1];\n }\n return null;\n };\n const luminance = (c) => c.slice(0, 3).map((v) => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);\n const widthRoot = root.id === \"canvas\" ? document.documentElement : root;\n if (widthRoot.scrollWidth > widthRoot.clientWidth + 2) add(\"overflow\", widthRoot.scrollWidth - widthRoot.clientWidth);\n const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);\n let el;\n while (el = walker.nextNode()) {\n if (coverage.elements >= 2e3) {\n coverage.truncated = true;\n break;\n }\n coverage.elements++;\n if (el.matches(\"[data-canvas-phase=error]\")) add(\"runtimeError\", void 0, el);\n const css = getComputedStyle(el), box = el.getBoundingClientRect();\n if (!box.width || !box.height || css.visibility === \"hidden\" || css.display === \"none\") continue;\n const text2 = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());\n if (text2) {\n let size = parseFloat(css.fontSize);\n if (el instanceof SVGElement && el.getScreenCTM) {\n const m = el.getScreenCTM();\n if (m) size *= Math.hypot(m.c, m.d);\n }\n if (size < 11.9) add(\"smallText\", size, el);\n const fg = color(css.color);\n let ancestor = el, bg = null, depth = 0;\n while (ancestor && depth++ < 64) {\n const style = getComputedStyle(ancestor);\n if (style.backgroundImage !== \"none\") break;\n const c = color(style.backgroundColor);\n if (c?.[3] === 1) {\n bg = c;\n break;\n }\n if (c?.[3] > 0) break;\n ancestor = ancestor.parentElement;\n }\n if (!(el instanceof SVGElement) && fg?.[3] === 1 && bg) {\n coverage.contrastChecked++;\n const a = luminance(fg), b = luminance(bg), ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);\n if (ratio < (size >= 24 || size >= 18.66 && parseFloat(css.fontWeight) >= 700 ? 3 : 4.5)) add(\"contrast\", ratio, el);\n } else coverage.contrastUncovered++;\n }\n if (el.matches(\"input:not([type=hidden]),select,textarea,button,[role=button]\") && !el.getAttribute(\"aria-label\") && !el.getAttribute(\"aria-labelledby\") && !el.labels?.length && !el.textContent.trim() && !el.getAttribute(\"title\")) add(\"missingLabel\", void 0, el);\n }\n Object.defineProperty(issues, \"coverage\", { value: coverage });\n return issues;\n }\n\n // lib/format-number.js\n function formatNumber(value, decimals = 2) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) throw new Error(\"DSH UI: formatNumber requires a finite number\");\n if (!Number.isInteger(decimals) || decimals < 0 || decimals > 6) throw new Error(\"DSH UI: formatNumber decimals must be an integer from 0 to 6\");\n return value.toLocaleString(\"en-US\", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });\n }\n\n // lib/canvas-system.js\n var CANVAS_SYSTEM_CSS = `\n:root{--canvas-radius:20px;--canvas-gap:16px;--canvas-positive:#167456;--canvas-negative:#b13b3b;--canvas-warning:#875b15}\n:root[data-canvas-scheme=dark]{--canvas-positive:#78d9b4;--canvas-negative:#ffaaaa;--canvas-warning:#edc87e}\n#canvas{display:flow-root}html,body{margin:0;min-width:0;background:var(--canvas-bg,#fff);color:var(--canvas-ink,#182537);font:14px/1.6 system-ui,\"Microsoft YaHei\",sans-serif}*{box-sizing:border-box}svg{max-width:100%;height:auto}button,input,select,textarea{font:inherit;color:inherit}button,input,select,textarea{border:1px solid var(--canvas-line,#ced4df);border-radius:8px;background:var(--canvas-bg,#fff);padding:8px 12px;max-width:100%}button{cursor:pointer}button:disabled{cursor:default;opacity:.55}input[type=range]{padding:0;accent-color:var(--canvas-accent,#3e60d3)}input[type=checkbox],input[type=radio]{accent-color:var(--canvas-accent,#3e60d3)}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--canvas-accent,#3e60d3);outline-offset:3px}\n.ui-stack{display:grid;gap:var(--canvas-gap)}.ui-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:var(--canvas-gap)}.ui-surface{padding:20px;border:1px solid var(--canvas-line,#ced4df);border-radius:var(--canvas-radius);background:var(--canvas-soft,#f3f5f8)}.ui-title{margin:0;font-size:clamp(20px,3vw,28px);line-height:1.3;letter-spacing:-.02em}.ui-caption{color:var(--canvas-muted,#526176);margin:8px 0;font-size:14px}.ui-value{font-size:clamp(24px,4vw,36px);font-variant-numeric:tabular-nums;line-height:1.2}.ui-controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.ui-field{display:grid;gap:6px;min-width:0}.ui-legend{display:flex;gap:16px;flex-wrap:wrap;font-size:13px}.ui-status{min-height:1.6em}.ui-error{color:var(--canvas-negative)}.ui-primary{background:var(--canvas-accent,#3e60d3);color:var(--canvas-accent-ink,#fff);border-color:transparent}\nsvg .ui-node{fill:var(--canvas-soft,#f3f5f8);stroke:var(--canvas-line,#ced4df);stroke-width:1}svg .ui-edge{fill:none;stroke:var(--canvas-muted,#526176);stroke-width:1.5}svg .ui-label{fill:var(--canvas-ink,#182537);font:500 14px system-ui,sans-serif}svg .ui-note{fill:var(--canvas-muted,#526176);font:400 13px system-ui,sans-serif}svg .ui-leader{fill:none;stroke:var(--canvas-muted,#526176);stroke-dasharray:3 4}svg .ui-accent{fill:color-mix(in srgb,var(--canvas-accent,#3e60d3) 12%,var(--canvas-bg,#fff));stroke:var(--canvas-accent,#3e60d3)}\n.ui-flow{display:flex;align-items:center;justify-content:center;gap:16px;padding:24px 8px;flex-wrap:wrap}.ui-step{display:grid;gap:6px;min-width:130px;max-width:240px;padding:16px 20px;border:1px solid var(--canvas-line);border-radius:6px;background:var(--canvas-bg)}.ui-step strong{font-size:16px;font-weight:600}.ui-step span{font-size:14px;color:var(--canvas-muted)}.ui-arrow{color:var(--canvas-muted);font-size:22px}.ui-callout{border-inline-start:3px solid var(--canvas-accent);padding:8px 16px;color:var(--canvas-ink)}\n.ui-surface,.ui-step{border-radius:var(--canvas-radius);background:linear-gradient(135deg,color-mix(in srgb,var(--canvas-accent,#3e60d3) 9%,var(--canvas-bg,#fff)),var(--canvas-soft,#f3f5f8))}button,input:not([type=checkbox]):not([type=radio]):not([type=range]),select,textarea{border-radius:14px}.ui-primary{background:linear-gradient(120deg,var(--canvas-accent,#3e60d3),color-mix(in srgb,var(--canvas-accent,#3e60d3) 85%,#152647))}.ui-callout{border-radius:14px;background:linear-gradient(120deg,color-mix(in srgb,var(--canvas-accent,#3e60d3) 7%,var(--canvas-bg,#fff)),var(--canvas-bg,#fff))}\n@media(max-width:480px){.ui-flow{flex-direction:column;padding:16px 8px;gap:12px}.ui-step{width:100%;max-width:none}.ui-arrow{transform:rotate(90deg)}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}`;\n\n // lib/sandbox.js\n var SANDBOX_LIMITS = Object.freeze({ source: 49152, height: 1e3, message: 16384, rate: 30 });\n function canvasSnapshot(node2, state2, data, derived, disabled, theme) {\n const pick = (keys, values) => Object.fromEntries((keys ?? []).map((k) => [k, values[k]]));\n return { state: pick(node2.stateKeys, state2), data: pick(node2.dataKeys, data), derived: pick(node2.derivedKeys, derived), disabled, theme };\n }\n function canvasPatch(message, node2) {\n if (!message || typeof message !== \"object\" || Array.isArray(message)) throw new Error(\"Invalid canvas patch\");\n const keys = Object.keys(message);\n if (!keys.length || keys.length > 64 || keys.some((k) => !(node2.stateKeys ?? []).includes(k))) throw new Error(\"Canvas state key is not declared\");\n if (JSON.stringify(message).length > SANDBOX_LIMITS.message) throw new Error(\"Canvas patch is too large\");\n return message;\n }\n function sandboxDocument(node2, channel2, nonce2) {\n const json = (value) => JSON.stringify(value).replace(/</g, \"\\\\u003c\").replace(/\\u2028/g, \"\\\\u2028\").replace(/\\u2029/g, \"\\\\u2029\");\n return `<!doctype html><html><head><meta charset=\"utf-8\"><meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; script-src 'nonce-${nonce2}'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; worker-src 'none'; base-uri 'none'; form-action 'none'\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><style>${CANVAS_SYSTEM_CSS}</style></head><body><main id=\"canvas\"></main><script nonce=\"${nonce2}\">(()=>{\n const channel=${json(channel2)}, source=${json({ html: node2.html, css: node2.css ?? \"\", script: node2.script ?? \"\" })};\n let snapshot={state:{},data:{},derived:{},disabled:true,theme:{}}, started=false;\n const listeners=new Set(), send=(type,payload)=>parent.postMessage({dshCanvas:1,channel,type,payload},'*');\n const report=e=>send('error',String(e?.message??e).slice(0,1000));\n const audit=${measureVisual.toString()};let auditTimer;const scheduleAudit=()=>{clearTimeout(auditTimer);auditTimer=setTimeout(()=>{const issues=audit(document.getElementById('canvas'),${json(node2.id ?? \"\")});send('audit',{issues,coverage:issues.coverage})},400)};\n addEventListener('resize',scheduleAudit);\n addEventListener('error',e=>report(e.error??e.message));addEventListener('unhandledrejection',e=>report(e.reason));\n const animations=new Set(), motion=matchMedia('(prefers-reduced-motion: reduce)');\n let raf=0,last=0,time=0;\n const visible=()=>snapshot.visible!==false&&!document.hidden;\n const canAnimate=()=>visible()&&!snapshot.disabled&&!motion.matches;\n const draw=delta=>{for(const fn of animations)try{fn({time,delta,reducedMotion:motion.matches,visible:visible()})}catch(e){animations.delete(fn);report(e)}};\n const tick=t=>{raf=0;if(!canAnimate()||!animations.size){last=0;draw(0);return}const delta=last?Math.min(.05,(t-last)/1000):0;last=t;time+=delta;draw(delta);if(animations.size)raf=requestAnimationFrame(tick)};\n const refresh=()=>{draw(0);if(canAnimate()&&animations.size){if(!raf){last=0;raf=requestAnimationFrame(tick)}}else{cancelAnimationFrame(raf);raf=0;last=0}};\n motion.addEventListener('change',refresh);document.addEventListener('visibilitychange',refresh);\n const formatNumber=${formatNumber.toString()};\n const api={formatNumber,get snapshot(){return structuredClone(snapshot)},subscribe(fn){if(typeof fn!=='function')throw Error('Listener required');listeners.add(fn);fn(api.snapshot);return()=>listeners.delete(fn)},setState(patch){if(!snapshot.disabled)send('patch',patch)},animate(fn){if(typeof fn!=='function')throw Error('Animation callback required');animations.add(fn);refresh();return()=>{animations.delete(fn);if(!animations.size){cancelAnimationFrame(raf);raf=0;last=0}}}};\n Object.defineProperty(window,'DSHUI',{value:Object.freeze(api),writable:false});\n for(const type of ['click','input','change','keydown','pointerdown'])document.addEventListener(type,e=>{if(snapshot.disabled){e.preventDefault();e.stopImmediatePropagation()}},true);\n addEventListener('message',event=>{\n const m=event.data;if(event.source!==parent||m?.dshCanvas!==1||m.channel!==channel||m.type!=='snapshot')return;\n snapshot=m.payload;for(const [k,v] of Object.entries(snapshot.theme??{}))document.documentElement.style.setProperty('--canvas-'+k,v);\n document.documentElement.style.colorScheme=snapshot.theme?.scheme??'light';document.documentElement.dataset.canvasScheme=snapshot.theme?.scheme??'light';\n const wasStarted=started;\n if(!started){started=true;document.getElementById('canvas').innerHTML=source.html;const style=document.createElement('style');style.textContent=source.css;document.head.append(style);const script=document.createElement('script');script.nonce=${json(nonce2)};script.textContent=source.script;document.body.append(script)}\n if(wasStarted)for(const fn of listeners)try{fn(api.snapshot)}catch(e){report(e)}\n refresh();if(!wasStarted)send('mounted');scheduleAudit();\n });\n let resizePending=false;new ResizeObserver(()=>{if(!started||resizePending)return;resizePending=true;requestAnimationFrame(()=>{resizePending=false;send('height',Math.ceil(document.getElementById('canvas').getBoundingClientRect().height))})}).observe(document.getElementById('canvas'));\n send('ready');\n})();<\\/script></body></html>`;\n }\n\n // lib/components.js\n var define = (fields2, purpose, presentation = \"\") => Object.freeze({ fields: Object.freeze(fields2), purpose, presentation });\n var COMPONENTS = Object.freeze({\n heading: define([\"text\"], \"Section heading\", \"tone\"),\n text: define([\"text\"], \"Readable explanation or bound text\", \"tone\"),\n row: define([\"children\"], \"Wrapping parallel content\"),\n column: define([\"children\"], \"Ordered vertical content\"),\n card: define([\"title\", \"children\", \"surface\"], \"Meaningful grouped section\", \"tone\"),\n details: define([\"title\", \"children\", \"open\"], \"Collapsible supporting content\", \"tone\"),\n badge: define([\"text\"], \"Short explicit status label\", \"tone\"),\n progress: define([\"label\", \"value\", \"max\", \"unit\"], \"Declared numeric progress snapshot\", \"tone\"),\n steps: define([\"items\"], \"Ordered declared steps\"),\n metric: define([\"label\", \"value\", \"unit\", \"decimals\"], \"Number with units, or a literal/bound status string\", \"tone\"),\n input: define([\"label\", \"bind\"], \"String or numeric state input\"),\n slider: define([\"label\", \"bind\"], \"Numeric state control\"),\n select: define([\"label\", \"bind\"], \"Selection from declared state options\"),\n toggle: define([\"label\", \"bind\"], \"Boolean state control\"),\n button: define([\"text\", \"action\"], \"Declared Agent follow-up\", \"icon\"),\n table: define([\"label\", \"rows\", \"columns\"], \"Supplied or derived values; inline rows or data/derived reference\"),\n chart: define([\"kind\", \"rows\", \"labelKey\", \"valueKey\", \"label\", \"unit\", \"xScale\"], \"Bar/line comparison with accessible data\"),\n sandbox: define([\"title\", \"html\", \"css\", \"script\", \"fallback\", \"height\", \"stateKeys\", \"dataKeys\", \"derivedKeys\"], \"Freeform isolated HTML/SVG/CSS/JavaScript visualization\")\n });\n function componentFields(type) {\n const c = Object.hasOwn(COMPONENTS, type) ? COMPONENTS[type] : void 0;\n return c && [\"id\", \"type\", ...c.fields, ...c.presentation ? [\"icon\"] : [], ...c.presentation === \"tone\" ? [\"tone\"] : []];\n }\n\n // lib/identifiers.js\n var IDENTIFIER_SOURCE = \"[\\\\p{L}_][\\\\p{L}\\\\p{M}\\\\p{N}_]{0,63}\";\n var pattern = new RegExp(`^${IDENTIFIER_SOURCE}$`, \"u\");\n var validIdentifier = (value) => typeof value === \"string\" && pattern.test(value) && ![\"constructor\", \"prototype\", \"__proto__\"].includes(value);\n\n // lib/author-expression.js\n function compileExpression(source) {\n const fail2 = () => {\n throw new Error(\"DSH UI: invalid formula; use state.x, data.x, derived.x, item.x, numbers, strings, + - * /, comparisons, ?: and min/max/round/abs\");\n };\n if (typeof source !== \"string\" || !source.trim() || source.length > 2048) fail2();\n const tokens = [];\n let offset = 0;\n const token2 = new RegExp(String.raw`\\s*(?:(\\d+(?:\\.\\d*)?|\\.\\d+)([eE][+-]?\\d+)?|(\"(?:[^\"\\\\]|\\\\.)*\")|(${IDENTIFIER_SOURCE}(?:\\.${IDENTIFIER_SOURCE})?)|(===|!==|<=|>=|==|!=|[+*/()?,:<>-]))`, \"uy\");\n while (offset < source.length) {\n if (!source.slice(offset).trim()) break;\n token2.lastIndex = offset;\n const m = token2.exec(source);\n if (!m) fail2();\n offset = token2.lastIndex;\n tokens.push(m[1] ? { number: Number(m[1] + (m[2] ?? \"\")) } : m[3] ? { string: JSON.parse(m[3]) } : m[4] ?? m[5]);\n if (tokens.length > 256) fail2();\n }\n let pos = 0;\n const operators = { \"==\": [\"eq\", 1], \"===\": [\"eq\", 1], \"!=\": [\"neq\", 1], \"!==\": [\"neq\", 1], \"<\": [\"lt\", 2], \"<=\": [\"lte\", 2], \">\": [\"gt\", 2], \">=\": [\"gte\", 2], \"+\": [\"add\", 3], \"-\": [\"sub\", 3], \"*\": [\"mul\", 4], \"/\": [\"div\", 4] };\n const take = (t) => {\n if (tokens[pos++] !== t) fail2();\n };\n function parse(min = 0, depth = 0) {\n if (depth > 12) fail2();\n const t = tokens[pos++];\n let out;\n if (t && typeof t === \"object\") out = { const: Object.hasOwn(t, \"number\") ? t.number : t.string };\n else if (t === \"true\" || t === \"false\") out = { const: t === \"true\" };\n else if (t === \"(\") {\n out = parse(0, depth + 1);\n take(\")\");\n } else if (t === \"-\" || t === \"+\") {\n const arg = parse(5, depth + 1);\n out = t === \"-\" ? { op: \"sub\", args: [{ const: 0 }, arg] } : arg;\n } else if (typeof t === \"string\" && t.split(\".\").length === 2 && [\"state\", \"data\", \"derived\", \"item\"].includes(t.split(\".\")[0]) && validIdentifier(t.split(\".\")[1])) out = { ref: t };\n else if ([\"min\", \"max\", \"round\", \"abs\"].includes(t)) {\n take(\"(\");\n const args = [parse(0, depth + 1)];\n while (tokens[pos] === \",\") {\n pos++;\n args.push(parse(0, depth + 1));\n if (args.length > 8) fail2();\n }\n take(\")\");\n out = { op: t, args };\n } else fail2();\n while (typeof tokens[pos] === \"string\" && Object.hasOwn(operators, tokens[pos]) && operators[tokens[pos]][1] >= min) {\n const [op, precedence] = operators[tokens[pos++]];\n out = { op, args: [out, parse(precedence + 1, depth + 1)] };\n }\n if (min === 0 && tokens[pos] === \"?\") {\n pos++;\n const yes = parse(0, depth + 1);\n take(\":\");\n out = { op: \"if\", args: [out, yes, parse(0, depth + 1)] };\n }\n return out;\n }\n const result = parse();\n if (pos !== tokens.length) fail2();\n return result;\n }\n function expandExpressions(doc2) {\n const expression = (e) => {\n if (!e || typeof e !== \"object\" || Array.isArray(e)) return e;\n if (Object.hasOwn(e, \"expr\")) {\n if (Object.keys(e).length !== 1) throw new Error(\"DSH UI: expr must be the only formula field\");\n return compileExpression(e.expr);\n }\n if (Array.isArray(e.args)) e.args = e.args.map(expression);\n if (e.map?.fields) for (const k of Object.keys(e.map.fields)) e.map.fields[k] = expression(e.map.fields[k]);\n return e;\n };\n if (doc2.derived && typeof doc2.derived === \"object\") for (const k of Object.keys(doc2.derived)) doc2.derived[k] = expression(doc2.derived[k]);\n const walk = (nodes) => {\n if (!Array.isArray(nodes)) return;\n for (const node2 of nodes) {\n if (!node2 || typeof node2 !== \"object\") continue;\n for (const field of [\"text\", \"value\", \"max\", \"rows\"]) if (Object.hasOwn(node2, field)) node2[field] = expression(node2[field]);\n walk(node2.children);\n }\n };\n walk(doc2.nodes);\n return doc2;\n }\n\n // lib/protocol.js\n var LIMITS = Object.freeze({ bytes: 131072, nodes: 160, depth: 12, rows: 500, expressions: 128, steps: 4096 });\n var own = (o, k) => Object.hasOwn(o, k);\n var fail = (message) => {\n throw new Error(`DSH UI: ${message}`);\n };\n var object = (x) => x !== null && typeof x === \"object\" && !Array.isArray(x);\n var finite = (x) => typeof x === \"number\" && Number.isFinite(x);\n var scalar = (x) => typeof x === \"string\" || typeof x === \"boolean\" || finite(x);\n var text = (x, at) => {\n if (typeof x !== \"string\" || x.length > 8192) fail(`${at} must be a string of at most 8192 characters`);\n };\n function fields(value, allowed, at) {\n if (!object(value)) fail(`${at} must be an object`);\n for (const k of Object.keys(value)) if (!allowed.includes(k)) {\n const hint = at === \"document\" ? \" Controls and visual components belong in nodes as registered typed nodes, not extra document fields.\" : \"\";\n fail(`unknown field ${at}.${k}; allowed fields at ${at}: ${allowed.join(\", \")}.${hint}`);\n }\n }\n function safeJson(value, depth = 0, source = false) {\n if (depth > 48) fail(\"JSON nesting is too deep\");\n if (value === null || typeof value === \"boolean\") return;\n if (typeof value === \"string\") {\n if (source) {\n if (value.length > SANDBOX_LIMITS.source) fail(\"canvas source exceeds 48 Ki characters\");\n return;\n }\n return text(value, \"value\");\n }\n if (typeof value === \"number\") {\n if (!finite(value)) fail(\"numbers must be finite\");\n return;\n }\n if (typeof value !== \"object\") fail(\"only JSON values are accepted\");\n if (Array.isArray(value) && value.length > LIMITS.rows) fail(\"too many array items\");\n for (const k of Object.keys(value)) {\n if ([\"__proto__\", \"prototype\", \"constructor\"].includes(k)) fail(\"unsafe property name\");\n safeJson(value[k], depth + 1, value.type === \"sandbox\" && [\"html\", \"css\", \"script\"].includes(k));\n }\n }\n function initialState(doc2) {\n return Object.fromEntries(Object.entries(doc2.state).map(([k, spec]) => [k, spec.default]));\n }\n function validateState(doc2, state2) {\n if (!object(state2) || Object.keys(state2).length !== Object.keys(doc2.state).length) fail(\"state keys do not match the document\");\n const result = {};\n for (const [k, s] of Object.entries(doc2.state)) {\n if (!own(state2, k)) fail(`missing state.${k}`);\n const v = state2[k];\n if (s.type === \"number\") {\n if (!finite(v) || v < s.min || v > s.max) fail(`state.${k} is outside its numeric range`);\n const offset = (v - s.min) / s.step;\n if (Math.abs(offset - Math.round(offset)) > 1e-6) fail(`state.${k} does not match its step`);\n } else if (s.type === \"json\") {\n if (!object(v) && !Array.isArray(v)) fail(`state.${k} must be an array or object for type json`);\n safeJson(v);\n if (new TextEncoder().encode(JSON.stringify(v)).length > 16384) fail(`state.${k} JSON exceeds 16 KiB`);\n } else if (s.type === \"string\") text(v, `state.${k}`);\n else if (typeof v !== \"boolean\") fail(`state.${k} must be boolean`);\n if (s.options && !s.options.includes(v)) fail(`invalid option for state.${k}`);\n result[k] = v;\n }\n return result;\n }\n var ARITY = { add: [1, 8], sub: [2, 2], mul: [1, 8], div: [2, 2], min: [1, 8], max: [1, 8], round: [1, 1], abs: [1, 1], eq: [2, 2], neq: [2, 2], lt: [2, 2], lte: [2, 2], gt: [2, 2], gte: [2, 2], if: [3, 3] };\n function reference(doc2, ref) {\n if (typeof ref !== \"string\" || ![\"state\", \"data\", \"derived\"].includes(ref.split(\".\")[0]) || ref.split(\".\").length !== 2 || !validIdentifier(ref.split(\".\")[1])) fail(`invalid reference ${JSON.stringify(ref)}; use state.key, data.key or derived.key with a letter/underscore identifier`);\n const [group, k] = ref.split(\".\");\n if (!own(doc2[group], k)) fail(`unknown reference ${ref}`);\n return [group, k];\n }\n function evaluate(doc2, state2) {\n const derived = {}, active = /* @__PURE__ */ new Set();\n let steps = 0, generatedRows = 0;\n const budget = () => {\n if (++steps > LIMITS.steps) fail(\"calculation budget exceeded\");\n };\n const resolve = (ref) => {\n budget();\n const [group, k] = reference(doc2, ref);\n if (group !== \"derived\") return (group === \"state\" ? state2 : doc2.data)[k];\n if (own(derived, k)) return derived[k];\n if (active.has(k)) fail(`cyclic calculation ${k}`);\n active.add(k);\n const result2 = expression(doc2.derived[k]);\n active.delete(k);\n derived[k] = result2;\n return result2;\n };\n const expression = (e, item) => {\n budget();\n if (own(e, \"map\")) {\n const rows = resolve(e.map.source);\n if ((generatedRows += rows.length) > LIMITS.rows) fail(\"generated row limit exceeded\");\n return rows.map((row) => Object.fromEntries(Object.entries(e.map.fields).map(([k, value]) => {\n const out2 = expression(value, row);\n if (!scalar(out2)) fail(\"generated cells must be scalar\");\n return [k, out2];\n })));\n }\n if (own(e, \"const\")) return e.const;\n if (own(e, \"ref\")) {\n if (e.ref.startsWith(\"item.\")) {\n const k = e.ref.slice(5);\n if (!item || !own(item, k) || !scalar(item[k])) fail(\"invalid item reference\");\n return item[k];\n }\n return resolve(e.ref);\n }\n if (e.op === \"if\") {\n const condition = expression(e.args[0], item);\n if (typeof condition !== \"boolean\") fail(\"if condition must be boolean\");\n return expression(e.args[condition ? 1 : 2], item);\n }\n const a = e.args.map((arg) => expression(arg, item));\n if (e.op === \"eq\") return a[0] === a[1];\n if (e.op === \"neq\") return a[0] !== a[1];\n if (!a.every(finite)) fail(`operator ${e.op} requires finite numbers`);\n let out;\n switch (e.op) {\n case \"add\":\n out = a.reduce((x, y) => x + y, 0);\n break;\n case \"sub\":\n out = a[0] - a[1];\n break;\n case \"mul\":\n out = a.reduce((x, y) => x * y, 1);\n break;\n case \"div\":\n if (a[1] === 0) fail(\"division by zero\");\n out = a[0] / a[1];\n break;\n case \"min\":\n out = Math.min(...a);\n break;\n case \"max\":\n out = Math.max(...a);\n break;\n case \"round\":\n out = Math.round(a[0]);\n break;\n case \"abs\":\n out = Math.abs(a[0]);\n break;\n case \"lt\":\n return a[0] < a[1];\n case \"lte\":\n return a[0] <= a[1];\n case \"gt\":\n return a[0] > a[1];\n case \"gte\":\n return a[0] >= a[1];\n default:\n fail(`unsupported operator ${e.op}`);\n }\n if (!finite(out)) fail(\"non-finite calculation result\");\n return out;\n };\n for (const k of Object.keys(doc2.derived)) resolve(`derived.${k}`);\n if (new TextEncoder().encode(JSON.stringify(derived)).length > LIMITS.bytes) fail(\"derived output exceeds 128 KiB\");\n const result = { derived, resolveValue: (value) => {\n if (!object(value)) return value;\n return expression(value);\n } };\n checkViewValues(doc2, result);\n return result;\n }\n function checkViewValues(doc2, evaluated) {\n function walk(list) {\n for (const n of list) {\n if (n.type === \"table\" || n.type === \"chart\") {\n const rows = evaluated.resolveValue(n.rows);\n if (!Array.isArray(rows) || rows.length > LIMITS.rows || !rows.every(object)) fail(\"table/chart rows must reference an array of objects\");\n if (n.type === \"chart\" && rows.some((r) => !own(r, n.labelKey) || !scalar(r[n.labelKey]) || !own(r, n.valueKey) || !finite(r[n.valueKey]))) fail(\"chart requires labels and finite numeric values\");\n if (n.type === \"chart\" && n.xScale === \"linear\" && rows.some((r) => !finite(r[n.labelKey]))) fail(\"linear chart requires numeric x values\");\n }\n if (n.type === \"metric\" && !scalar(evaluated.resolveValue(n.value))) fail(`node.${n.id}.value must resolve to a number, string or boolean; use table for rows or a canvas for structured data`);\n if (n.type === \"progress\") {\n const v = evaluated.resolveValue(n.value), max = evaluated.resolveValue(n.max);\n if (!finite(v) || !finite(max) || max <= 0 || v < 0 || v > max) fail(\"progress requires 0 <= value <= positive max\");\n }\n if (n.children) walk(n.children);\n }\n }\n walk(doc2.nodes);\n }\n function validateDocument(input) {\n safeJson(input);\n const json = JSON.stringify(input);\n if (new TextEncoder().encode(json).length > LIMITS.bytes) fail(\"document exceeds 128 KiB\");\n const doc2 = expandExpressions(JSON.parse(json));\n fields(doc2, [\"version\", \"title\", \"fallback\", \"state\", \"data\", \"derived\", \"nodes\", \"actions\", \"presentation\"], \"document\");\n if (doc2.presentation !== void 0) {\n fields(doc2.presentation, [\"layout\", \"accent\", \"density\"], \"presentation\");\n for (const [k, allowed] of Object.entries({ layout: UI_LAYOUTS, accent: UI_ACCENTS, density: [\"comfortable\", \"compact\"] }))\n if (doc2.presentation[k] !== void 0 && !allowed.includes(doc2.presentation[k])) fail(`unknown presentation ${k}`);\n }\n if (doc2.version !== 1) fail(\"unsupported protocol version\");\n text(doc2.title, \"title\");\n text(doc2.fallback, \"fallback\");\n if (!doc2.title.trim() || !doc2.fallback.trim()) fail(\"title and fallback cannot be empty\");\n for (const group of [\"state\", \"data\", \"derived\", \"actions\"]) {\n doc2[group] ??= {};\n if (!object(doc2[group]) || Object.keys(doc2[group]).length > 64) fail(`invalid ${group}`);\n for (const k of Object.keys(doc2[group])) if (!validIdentifier(k)) fail(`invalid key in ${group}: ${JSON.stringify(k)}; use 1..64 letters, numbers or underscores, starting with a letter/underscore (Chinese letters supported); no spaces, dots or hyphens. Put display labels in label/text`);\n }\n for (const [k, s] of Object.entries(doc2.state)) {\n fields(s, [\"type\", \"default\", \"min\", \"max\", \"step\", \"options\"], `state.${k}`);\n if (s.type === \"string\" && (Array.isArray(s.default) || object(s.default)) && !own(s, \"options\")) s.type = \"json\";\n if (![\"number\", \"string\", \"boolean\", \"json\"].includes(s.type)) fail(`unsupported state type ${k}; use number, string, boolean or json (arrays/objects)`);\n if (s.type === \"json\" && own(s, \"options\")) fail(`state.${k}: json state does not support select options; use scalar options or a canvas`);\n if (s.type === \"number\") {\n if (![s.min, s.max, s.step].every(finite) || s.min >= s.max || s.step <= 0) fail(`invalid numeric limits for ${k}`);\n if ((s.max - s.min) / s.step > 1e9) fail(`numeric step too small for ${k}`);\n } else if ([\"min\", \"max\", \"step\"].some((p) => own(s, p))) fail(`numeric limits on non-number ${k}`);\n if (s.options && (!Array.isArray(s.options) || !s.options.length || s.options.length > 64 || !s.options.every(scalar))) fail(`invalid options for ${k}`);\n }\n const defaults = validateState(doc2, initialState(doc2));\n let expressions = 0;\n for (const [k, spec] of Object.entries(doc2.state))\n for (const option of spec.options ?? []) validateState(doc2, { ...defaults, [k]: option });\n function validateExpression(e, depth = 0, itemRows) {\n if (++expressions > LIMITS.expressions || depth > LIMITS.depth) fail(\"expression limit exceeded\");\n if (!object(e)) fail(\"expression must be an object\");\n if (own(e, \"map\")) {\n if (depth !== 0) fail(\"map is only allowed at the root of a derived value\");\n fields(e, [\"map\"], \"expression\");\n fields(e.map, [\"source\", \"fields\"], \"map\");\n if (typeof e.map.source !== \"string\" || !e.map.source.startsWith(\"data.\")) fail('map source must reference static data using a string such as \"data.samples\"; put object rows in document.data.samples, not inline in map.source');\n const [, k] = reference(doc2, e.map.source), rows = doc2.data[k];\n if (!Array.isArray(rows) || rows.length > LIMITS.rows || !rows.every(object)) fail(\"map source must be object rows\");\n if (!object(e.map.fields) || !Object.keys(e.map.fields).length || Object.keys(e.map.fields).length > 16) fail(\"map requires 1..16 fields\");\n for (const [k2, value2] of Object.entries(e.map.fields)) {\n if (!validIdentifier(k2)) fail(`invalid map field ${JSON.stringify(k2).slice(0, 100)}; use a letter/underscore identifier such as powerW or \\u529F\\u7387, without spaces, dots or hyphens`);\n validateExpression(value2, depth + 1, rows);\n }\n return;\n }\n if (own(e, \"const\")) {\n fields(e, [\"const\"], \"expression\");\n if (!scalar(e.const)) fail(\"constant must be scalar\");\n return;\n }\n if (own(e, \"ref\")) {\n fields(e, [\"ref\"], \"expression\");\n if (typeof e.ref === \"string\" && e.ref.startsWith(\"item.\")) {\n const k = e.ref.slice(5);\n if (!itemRows || !validIdentifier(k) || itemRows.some((row) => !own(row, k) || !scalar(row[k]))) fail(\"invalid or unscoped item reference\");\n } else reference(doc2, e.ref);\n return;\n }\n fields(e, [\"op\", \"args\"], \"expression\");\n const arity = ARITY[e.op];\n if (!arity || !Array.isArray(e.args) || e.args.length < arity[0] || e.args.length > arity[1]) fail(\"invalid operator or argument count\");\n e.args.forEach((a) => validateExpression(a, depth + 1, itemRows));\n }\n Object.values(doc2.derived).forEach((e) => validateExpression(e));\n const visited = /* @__PURE__ */ new Set(), active = /* @__PURE__ */ new Set();\n function visit(k) {\n if (active.has(k)) fail(`cyclic calculation ${k}`);\n if (visited.has(k)) return;\n active.add(k);\n function walk(e) {\n if (e.ref?.startsWith(\"derived.\")) visit(e.ref.split(\".\")[1]);\n for (const a of e.args ?? []) walk(a);\n for (const value2 of Object.values(e.map?.fields ?? {})) walk(value2);\n }\n walk(doc2.derived[k]);\n active.delete(k);\n visited.add(k);\n }\n Object.keys(doc2.derived).forEach(visit);\n for (const [k, a] of Object.entries(doc2.actions)) {\n fields(a, [\"type\", \"intent\", \"stateKeys\"], `actions.${k}`);\n if (a.type !== \"agent.followup\") fail(\"only agent.followup actions are supported\");\n text(a.intent, \"action intent\");\n if (!a.intent.trim() || !Array.isArray(a.stateKeys) || a.stateKeys.length > 64 || new Set(a.stateKeys).size !== a.stateKeys.length || a.stateKeys.some((s) => !own(doc2.state, s))) fail(\"invalid action stateKeys\");\n }\n const ids = /* @__PURE__ */ new Set();\n let count2 = 0;\n const value = (v, at) => {\n if (object(v)) {\n if (own(v, \"map\")) fail(\"map belongs in derived\");\n validateExpression(v);\n } else if (!scalar(v)) fail(`invalid value at ${at}`);\n };\n function nodes(list, depth = 0) {\n if (!Array.isArray(list) || depth > LIMITS.depth) fail(\"invalid node children or depth\");\n for (const n of list) {\n if (++count2 > LIMITS.nodes) fail(\"too many nodes\");\n if (!object(n) || !validIdentifier(n.id) || ids.has(n.id)) fail(\"node ids must be unique identifiers\");\n ids.add(n.id);\n if (!Object.hasOwn(COMPONENTS, n.type)) fail(\"unsupported component \" + n.type);\n if ([\"table\", \"chart\"].includes(n.type) && typeof n.rows === \"string\" && /^(data|derived)\\./.test(n.rows)) n.rows = { ref: n.rows };\n switch (n.type) {\n case \"sandbox\": {\n text(n.title, \"canvas title\");\n text(n.fallback, \"canvas fallback\");\n if (!n.title.trim() || !n.fallback.trim() || typeof n.html !== \"string\" || !n.html.trim()) fail(\"canvas requires title, HTML and fallback\");\n for (const field of [\"html\", \"css\", \"script\"]) if (n[field] !== void 0 && (typeof n[field] !== \"string\" || n[field].length > SANDBOX_LIMITS.source)) fail(\"canvas source exceeds 48 Ki characters\");\n if (n.height !== void 0 && (!Number.isInteger(n.height) || n.height < 160 || n.height > SANDBOX_LIMITS.height)) fail(\"canvas height must be 160..1000\");\n for (const [field, group] of [[\"stateKeys\", \"state\"], [\"dataKeys\", \"data\"], [\"derivedKeys\", \"derived\"]]) if (n[field] !== void 0 && (!Array.isArray(n[field]) || n[field].length > 64 || new Set(n[field]).size !== n[field].length || n[field].some((k) => !own(doc2[group], k)))) fail(\"invalid canvas \" + field);\n break;\n }\n case \"row\":\n case \"column\":\n break;\n case \"card\":\n if (n.title !== void 0) text(n.title, \"card title\");\n if (n.surface !== void 0 && !UI_SURFACES.includes(n.surface)) fail(\"unknown card surface\");\n break;\n case \"details\":\n text(n.title, \"details title\");\n if (n.open !== void 0 && typeof n.open !== \"boolean\") fail(\"details open must be boolean\");\n break;\n case \"badge\":\n value(n.text, \"badge text\");\n break;\n case \"progress\":\n text(n.label, \"progress label\");\n value(n.value, \"progress value\");\n value(n.max, \"progress max\");\n if (n.unit !== void 0) text(n.unit, \"progress unit\");\n break;\n case \"steps\": {\n if (!Array.isArray(n.items) || !n.items.length || n.items.length > 12) fail(\"steps require 1..12 items\");\n for (const item of n.items) {\n fields(item, [\"title\", \"description\", \"status\"], \"step\");\n text(item.title, \"step title\");\n if (item.description !== void 0) text(item.description, \"step description\");\n if (![\"pending\", \"active\", \"done\", \"error\"].includes(item.status)) fail(\"unknown step status\");\n }\n break;\n }\n case \"heading\":\n case \"text\":\n value(n.text, \"text\");\n break;\n case \"metric\":\n text(n.label, \"metric label\");\n value(n.value, \"metric value\");\n if (n.unit !== void 0) text(n.unit, \"unit\");\n if (n.decimals !== void 0 && (!Number.isInteger(n.decimals) || n.decimals < 0 || n.decimals > 6)) fail(\"invalid decimals\");\n break;\n case \"input\":\n case \"slider\":\n case \"select\":\n case \"toggle\": {\n text(n.label, \"control label\");\n if (typeof n.bind !== \"string\" || !n.bind.startsWith(\"state.\")) fail(\"controls must bind state\");\n const [, k] = reference(doc2, n.bind), spec = doc2.state[k];\n if (n.type === \"slider\" && spec.type !== \"number\") fail(\"slider requires numeric state\");\n if (n.type === \"toggle\" && spec.type !== \"boolean\") fail(\"toggle requires boolean state\");\n if ([\"input\", \"select\"].includes(n.type) && spec.type === \"json\") fail(`node.${n.id}: structured state requires a sandbox with DSHUI.setState; use separate boolean states and toggle nodes for a native checklist`);\n if (n.type === \"input\" && spec.type === \"boolean\") fail(\"use toggle for boolean state\");\n if (n.type === \"select\" && !spec.options) fail(\"select requires options\");\n break;\n }\n case \"button\":\n text(n.text, \"button text\");\n if (!own(doc2.actions, n.action)) fail(\"unknown action\");\n break;\n case \"table\": {\n if (n.label !== void 0) text(n.label, \"table label\");\n if (!Array.isArray(n.rows)) value(n.rows, \"table rows\");\n if (!Array.isArray(n.columns) || !n.columns.length || n.columns.length > 16) fail(\"invalid table columns\");\n const cols = /* @__PURE__ */ new Set();\n for (const c of n.columns) {\n fields(c, [\"key\", \"label\", \"unit\"], \"column\");\n if (!validIdentifier(c.key) || cols.has(c.key)) fail(\"invalid or duplicate column key\");\n cols.add(c.key);\n text(c.label, \"column label\");\n if (c.unit !== void 0) text(c.unit, \"column unit\");\n }\n break;\n }\n case \"chart\":\n if (!Array.isArray(n.rows)) value(n.rows, \"chart rows\");\n if (![\"bar\", \"line\"].includes(n.kind) || !validIdentifier(n.labelKey) || !validIdentifier(n.valueKey)) fail(\"invalid chart\");\n text(n.label, \"chart label\");\n if (n.unit !== void 0) text(n.unit, \"chart unit\");\n if (n.xScale !== void 0 && (![\"category\", \"linear\"].includes(n.xScale) || n.xScale === \"linear\" && n.kind !== \"line\")) fail(\"invalid xScale\");\n break;\n default:\n fail(`unsupported component ${n.type}`);\n }\n if ([\"heading\", \"text\", \"card\", \"metric\", \"button\", \"badge\", \"progress\", \"details\"].includes(n.type)) {\n if (n.icon !== void 0 && !UI_ICONS.includes(n.icon)) fail(\"unknown UI icon\");\n if (n.type !== \"button\") {\n if (n.tone !== void 0 && !UI_TONES.includes(n.tone)) fail(\"unknown UI tone\");\n }\n }\n fields(n, componentFields(n.type), `node.${n.id}`);\n if ([\"row\", \"column\", \"card\", \"details\"].includes(n.type)) nodes(n.children, depth + 1);\n }\n }\n nodes(doc2.nodes);\n if (!count2) fail(\"at least one node is required\");\n evaluate(doc2, defaults);\n return doc2;\n }\n\n // src/export-runtime.js\n var payload = JSON.parse(document.getElementById(\"dsh-ui-export\").textContent);\n var doc = validateDocument(payload.document);\n var node = doc.nodes.flatMap(function flatten(n) {\n return [n, ...(n.children ?? []).flatMap(flatten)];\n }).find((n) => n.id === payload.nodeId && n.type === \"sandbox\");\n if (!node) throw Error(\"\\u5BFC\\u51FA\\u7684\\u753B\\u5E03\\u4E0D\\u5B58\\u5728\");\n var state = validateState(doc, payload.state);\n var connected = false;\n var start = Date.now();\n var count = 0;\n var token = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n var channel = token();\n var nonce = payload.nonce;\n var frame = document.createElement(\"iframe\");\n frame.title = node.title;\n frame.sandbox = \"allow-scripts\";\n frame.referrerPolicy = \"no-referrer\";\n frame.style.cssText = \"display:block;width:100%;height:\" + (node.height ?? 420) + \"px;border:0\";\n var status = document.getElementById(\"status\");\n var publish = () => {\n try {\n frame.contentWindow.postMessage({ dshCanvas: 1, channel, type: \"snapshot\", payload: { ...canvasSnapshot(node, state, doc.data, evaluate(doc, state).derived, false, payload.theme), visible: !document.hidden } }, \"*\");\n } catch (e) {\n status.textContent = \"\\u8BA1\\u7B97\\u5931\\u8D25\\uFF1A\" + e.message;\n }\n };\n addEventListener(\"message\", (event) => {\n const m = event.data;\n if (event.source !== frame.contentWindow || m?.channel !== channel || m.dshCanvas !== 1) return;\n if (Date.now() - start > 1e3) {\n start = Date.now();\n count = 0;\n }\n if (++count > SANDBOX_LIMITS.rate) return;\n if (m.type === \"ready\") {\n connected = true;\n publish();\n } else if (m.type === \"patch\" && connected) {\n try {\n const next = validateState(doc, { ...state, ...canvasPatch(m.payload, node) });\n evaluate(doc, next);\n state = next;\n status.textContent = \"\\u672C\\u5730\\u53C2\\u6570\\u5DF2\\u66F4\\u65B0\\uFF1B\\u5237\\u65B0\\u6587\\u4EF6\\u4F1A\\u56DE\\u5230\\u5BFC\\u51FA\\u65F6\\u7684\\u53C2\\u6570\\u3002\";\n } catch (e) {\n status.textContent = \"\\u53C2\\u6570\\u65E0\\u6548\\uFF1A\" + e.message;\n }\n publish();\n } else if (m.type === \"height\" && Number.isFinite(m.payload) && m.payload >= 0) frame.style.height = Math.max(32, Math.min(1e3, Math.ceil(m.payload))) + \"px\";\n else if (m.type === \"error\") status.textContent = \"\\u753B\\u5E03\\u8FD0\\u884C\\u5F02\\u5E38\\uFF1A\" + String(m.payload).slice(0, 1e3);\n });\n document.addEventListener(\"visibilitychange\", () => {\n if (connected) publish();\n });\n document.getElementById(\"surface\").append(frame);\n frame.srcdoc = sandboxDocument(node, channel, nonce);\n})();\n";
2
+ export const EXPORT_RUNTIME="(() => {\n // lib/presentation.js\n var UI_ICONS = Object.freeze([\"sparkles\", \"sliders\", \"layers\", \"chart\", \"gauge\", \"cpu\", \"database\", \"info\", \"check\", \"warning\", \"arrow\", \"target\"]);\n var UI_TONES = Object.freeze([\"neutral\", \"accent\", \"success\", \"warning\", \"danger\"]);\n var UI_LAYOUTS = Object.freeze([\"dashboard\", \"workbench\", \"comparison\", \"guide\", \"status\", \"report\"]);\n var UI_ACCENTS = Object.freeze([\"blue\", \"teal\", \"violet\", \"amber\", \"rose\"]);\n var UI_SURFACES = Object.freeze([\"outline\", \"soft\", \"featured\"]);\n\n // lib/visual-audit.js\n function measureVisual(root, nodeId = \"\") {\n const issues = [], seen = /* @__PURE__ */ new Set(), coverage = { nodeId, elements: 0, truncated: false, contrastChecked: 0, contrastUncovered: 0 };\n const add = (code, value, el2) => {\n const id = el2?.closest(\"[data-ui-node]\")?.getAttribute(\"data-ui-node\") ?? nodeId;\n const k = id + \":\" + code + \":\" + Math.round(value ?? 0);\n if (!seen.has(k) && issues.length < 40) {\n seen.add(k);\n issues.push({ code, nodeId: id, ...value === void 0 ? {} : { value: Number(value.toFixed(2)) } });\n }\n };\n const color = (s) => {\n let m = s.match(/^rgba?\\(([^)]+)\\)/);\n if (m) {\n const a = m[1].split(/[\\s,\\/]+/).map(Number);\n return [a[0] / 255, a[1] / 255, a[2] / 255, a[3] ?? 1];\n }\n m = s.match(/^color\\(srgb ([^)]+)\\)/);\n if (m) {\n const a = m[1].split(/[\\s\\/]+/).map(Number);\n return [a[0], a[1], a[2], a[3] ?? 1];\n }\n return null;\n };\n const luminance = (c) => c.slice(0, 3).map((v) => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);\n const widthRoot = root.id === \"canvas\" ? document.documentElement : root;\n if (widthRoot.scrollWidth > widthRoot.clientWidth + 2) add(\"overflow\", widthRoot.scrollWidth - widthRoot.clientWidth);\n const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);\n let el;\n while (el = walker.nextNode()) {\n if (coverage.elements >= 2e3) {\n coverage.truncated = true;\n break;\n }\n coverage.elements++;\n if (el.matches(\"[data-canvas-phase=error]\")) add(\"runtimeError\", void 0, el);\n const css = getComputedStyle(el), box = el.getBoundingClientRect();\n if (!box.width || !box.height || css.visibility === \"hidden\" || css.display === \"none\") continue;\n const text2 = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());\n if (text2) {\n let size = parseFloat(css.fontSize);\n if (el instanceof SVGElement && el.getScreenCTM) {\n const m = el.getScreenCTM();\n if (m) size *= Math.hypot(m.c, m.d);\n }\n if (size < 11.9) add(\"smallText\", size, el);\n const fg = color(css.color);\n let ancestor = el, bg = null, depth = 0;\n while (ancestor && depth++ < 64) {\n const style = getComputedStyle(ancestor);\n if (style.backgroundImage !== \"none\") break;\n const c = color(style.backgroundColor);\n if (c?.[3] === 1) {\n bg = c;\n break;\n }\n if (c?.[3] > 0) break;\n ancestor = ancestor.parentElement;\n }\n if (!(el instanceof SVGElement) && fg?.[3] === 1 && bg) {\n coverage.contrastChecked++;\n const a = luminance(fg), b = luminance(bg), ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);\n if (ratio < (size >= 24 || size >= 18.66 && parseFloat(css.fontWeight) >= 700 ? 3 : 4.5)) add(\"contrast\", ratio, el);\n } else coverage.contrastUncovered++;\n }\n if (el.matches(\"input:not([type=hidden]),select,textarea,button,[role=button]\") && !el.getAttribute(\"aria-label\") && !el.getAttribute(\"aria-labelledby\") && !el.labels?.length && !el.textContent.trim() && !el.getAttribute(\"title\")) add(\"missingLabel\", void 0, el);\n }\n Object.defineProperty(issues, \"coverage\", { value: coverage });\n return issues;\n }\n\n // lib/format-number.js\n function formatNumber(value, decimals = 2) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) throw new Error(\"DSH UI: formatNumber requires a finite number\");\n if (!Number.isInteger(decimals) || decimals < 0 || decimals > 6) throw new Error(\"DSH UI: formatNumber decimals must be an integer from 0 to 6\");\n return value.toLocaleString(\"en-US\", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });\n }\n\n // lib/canvas-system.js\n var CANVAS_SYSTEM_CSS = `\n:root{--canvas-radius:20px;--canvas-gap:16px;--canvas-positive:#167456;--canvas-negative:#b13b3b;--canvas-warning:#875b15}\n:root[data-canvas-scheme=dark]{--canvas-positive:#78d9b4;--canvas-negative:#ffaaaa;--canvas-warning:#edc87e}\n#canvas{display:flow-root}html,body{margin:0;min-width:0;background:var(--canvas-bg,#fff);color:var(--canvas-ink,#182537);font:14px/1.6 system-ui,\"Microsoft YaHei\",sans-serif}*{box-sizing:border-box}svg{max-width:100%;height:auto}button,input,select,textarea{font:inherit;color:inherit}button,input,select,textarea{border:1px solid var(--canvas-line,#ced4df);border-radius:8px;background:var(--canvas-bg,#fff);padding:8px 12px;max-width:100%}button{cursor:pointer}button:disabled{cursor:default;opacity:.55}input[type=range]{padding:0;accent-color:var(--canvas-accent,#3e60d3)}input[type=checkbox],input[type=radio]{accent-color:var(--canvas-accent,#3e60d3)}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--canvas-accent,#3e60d3);outline-offset:3px}\n.ui-stack{display:grid;gap:var(--canvas-gap)}.ui-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:var(--canvas-gap)}.ui-surface{padding:20px;border:1px solid var(--canvas-line,#ced4df);border-radius:var(--canvas-radius);background:var(--canvas-soft,#f3f5f8)}.ui-title{margin:0;font-size:clamp(20px,3vw,28px);line-height:1.3;letter-spacing:-.02em}.ui-caption{color:var(--canvas-muted,#526176);margin:8px 0;font-size:14px}.ui-value{font-size:clamp(24px,4vw,36px);font-variant-numeric:tabular-nums;line-height:1.2}.ui-controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.ui-field{display:grid;gap:6px;min-width:0}.ui-legend{display:flex;gap:16px;flex-wrap:wrap;font-size:13px}.ui-status{min-height:1.6em}.ui-error{color:var(--canvas-negative)}.ui-primary{background:var(--canvas-accent,#3e60d3);color:var(--canvas-accent-ink,#fff);border-color:transparent}\nsvg .ui-node{fill:var(--canvas-soft,#f3f5f8);stroke:var(--canvas-line,#ced4df);stroke-width:1}svg .ui-edge{fill:none;stroke:var(--canvas-muted,#526176);stroke-width:1.5}svg .ui-label{fill:var(--canvas-ink,#182537);font:500 14px system-ui,sans-serif}svg .ui-note{fill:var(--canvas-muted,#526176);font:400 13px system-ui,sans-serif}svg .ui-leader{fill:none;stroke:var(--canvas-muted,#526176);stroke-dasharray:3 4}svg .ui-accent{fill:color-mix(in srgb,var(--canvas-accent,#3e60d3) 12%,var(--canvas-bg,#fff));stroke:var(--canvas-accent,#3e60d3)}\n.ui-flow{display:flex;align-items:center;justify-content:center;gap:16px;padding:24px 8px;flex-wrap:wrap}.ui-step{display:grid;gap:6px;min-width:130px;max-width:240px;padding:16px 20px;border:1px solid var(--canvas-line);border-radius:6px;background:var(--canvas-bg)}.ui-step strong{font-size:16px;font-weight:600}.ui-step span{font-size:14px;color:var(--canvas-muted)}.ui-arrow{color:var(--canvas-muted);font-size:22px}.ui-callout{border-inline-start:3px solid var(--canvas-accent);padding:8px 16px;color:var(--canvas-ink)}\n.ui-surface,.ui-step{border-radius:var(--canvas-radius);background:linear-gradient(135deg,color-mix(in srgb,var(--canvas-accent,#3e60d3) 9%,var(--canvas-bg,#fff)),var(--canvas-soft,#f3f5f8))}button,input:not([type=checkbox]):not([type=radio]):not([type=range]),select,textarea{border-radius:14px}.ui-primary{background:linear-gradient(120deg,var(--canvas-accent,#3e60d3),color-mix(in srgb,var(--canvas-accent,#3e60d3) 85%,#152647))}.ui-callout{border-radius:14px;background:linear-gradient(120deg,color-mix(in srgb,var(--canvas-accent,#3e60d3) 7%,var(--canvas-bg,#fff)),var(--canvas-bg,#fff))}\n@media(max-width:480px){.ui-flow{flex-direction:column;padding:16px 8px;gap:12px}.ui-step{width:100%;max-width:none}.ui-arrow{transform:rotate(90deg)}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}`;\n\n // lib/sandbox.js\n var SANDBOX_LIMITS = Object.freeze({ source: 49152, height: 1e3, message: 16384, rate: 30 });\n function canvasSnapshot(node2, state2, data, derived, disabled, theme) {\n const pick = (keys, values) => Object.fromEntries((keys ?? []).map((k) => [k, values[k]]));\n return { state: pick(node2.stateKeys, state2), data: pick(node2.dataKeys, data), derived: pick(node2.derivedKeys, derived), disabled, theme };\n }\n function canvasPatch(message, node2) {\n if (!message || typeof message !== \"object\" || Array.isArray(message)) throw new Error(\"Invalid canvas patch\");\n const keys = Object.keys(message);\n if (!keys.length || keys.length > 64 || keys.some((k) => !(node2.stateKeys ?? []).includes(k))) throw new Error(\"Canvas state key is not declared\");\n if (JSON.stringify(message).length > SANDBOX_LIMITS.message) throw new Error(\"Canvas patch is too large\");\n return message;\n }\n function sandboxDocument(node2, channel2, nonce2) {\n const json = (value) => JSON.stringify(value).replace(/</g, \"\\\\u003c\").replace(/\\u2028/g, \"\\\\u2028\").replace(/\\u2029/g, \"\\\\u2029\");\n return `<!doctype html><html><head><meta charset=\"utf-8\"><meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; script-src 'nonce-${nonce2}'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; worker-src 'none'; base-uri 'none'; form-action 'none'\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><style>${CANVAS_SYSTEM_CSS}</style></head><body><main id=\"canvas\"></main><script nonce=\"${nonce2}\">(()=>{\n const channel=${json(channel2)}, source=${json({ html: node2.html, css: node2.css ?? \"\", script: node2.script ?? \"\" })};\n let snapshot={state:{},data:{},derived:{},disabled:true,theme:{}}, started=false;\n const listeners=new Set(), send=(type,payload)=>parent.postMessage({dshCanvas:1,channel,type,payload},'*');\n const report=e=>send('error',String(e?.message??e).slice(0,1000));\n const audit=${measureVisual.toString()};let auditTimer;const scheduleAudit=()=>{clearTimeout(auditTimer);auditTimer=setTimeout(()=>{const issues=audit(document.getElementById('canvas'),${json(node2.id ?? \"\")});send('audit',{issues,coverage:issues.coverage})},400)};\n addEventListener('resize',scheduleAudit);\n addEventListener('error',e=>report(e.error??e.message));addEventListener('unhandledrejection',e=>report(e.reason));\n const animations=new Set(), motion=matchMedia('(prefers-reduced-motion: reduce)');\n let raf=0,last=0,time=0;\n const visible=()=>snapshot.visible!==false&&!document.hidden;\n const canAnimate=()=>visible()&&!snapshot.disabled&&!motion.matches;\n const draw=delta=>{for(const fn of animations)try{fn({time,delta,reducedMotion:motion.matches,visible:visible()})}catch(e){animations.delete(fn);report(e)}};\n const tick=t=>{raf=0;if(!canAnimate()||!animations.size){last=0;draw(0);return}const delta=last?Math.min(.05,(t-last)/1000):0;last=t;time+=delta;draw(delta);if(animations.size)raf=requestAnimationFrame(tick)};\n const refresh=()=>{draw(0);if(canAnimate()&&animations.size){if(!raf){last=0;raf=requestAnimationFrame(tick)}}else{cancelAnimationFrame(raf);raf=0;last=0}};\n motion.addEventListener('change',refresh);document.addEventListener('visibilitychange',refresh);\n const formatNumber=${formatNumber.toString()};\n const api={formatNumber,get snapshot(){return structuredClone(snapshot)},subscribe(fn){if(typeof fn!=='function')throw Error('Listener required');listeners.add(fn);fn(api.snapshot);return()=>listeners.delete(fn)},setState(patch){if(!snapshot.disabled)send('patch',patch)},animate(fn){if(typeof fn!=='function')throw Error('Animation callback required');animations.add(fn);refresh();return()=>{animations.delete(fn);if(!animations.size){cancelAnimationFrame(raf);raf=0;last=0}}}};\n Object.defineProperty(window,'DSHUI',{value:Object.freeze(api),writable:false});\n for(const type of ['click','input','change','keydown','pointerdown'])document.addEventListener(type,e=>{if(snapshot.disabled){e.preventDefault();e.stopImmediatePropagation()}},true);\n addEventListener('message',event=>{\n const m=event.data;if(event.source!==parent||m?.dshCanvas!==1||m.channel!==channel||m.type!=='snapshot')return;\n snapshot=m.payload;for(const [k,v] of Object.entries(snapshot.theme??{}))document.documentElement.style.setProperty('--canvas-'+k,v);\n document.documentElement.style.colorScheme=snapshot.theme?.scheme??'light';document.documentElement.dataset.canvasScheme=snapshot.theme?.scheme??'light';\n const wasStarted=started;\n if(!started){started=true;document.getElementById('canvas').innerHTML=source.html;const style=document.createElement('style');style.textContent=source.css;document.head.append(style);const script=document.createElement('script');script.nonce=${json(nonce2)};script.textContent=source.script;document.body.append(script)}\n if(wasStarted)for(const fn of listeners)try{fn(api.snapshot)}catch(e){report(e)}\n refresh();if(!wasStarted)send('mounted');scheduleAudit();\n });\n let resizePending=false;new ResizeObserver(()=>{if(!started||resizePending)return;resizePending=true;requestAnimationFrame(()=>{resizePending=false;send('height',Math.ceil(document.getElementById('canvas').getBoundingClientRect().height))})}).observe(document.getElementById('canvas'));\n send('ready');\n})();<\\/script></body></html>`;\n }\n\n // lib/components.js\n var define = (fields2, purpose, presentation = \"\") => Object.freeze({ fields: Object.freeze(fields2), purpose, presentation });\n var COMPONENTS = Object.freeze({\n heading: define([\"text\"], \"Section heading\", \"tone\"),\n text: define([\"text\"], \"Readable explanation or bound text\", \"tone\"),\n row: define([\"children\"], \"Wrapping parallel content\"),\n column: define([\"children\"], \"Ordered vertical content\"),\n card: define([\"title\", \"children\", \"surface\"], \"Meaningful grouped section\", \"tone\"),\n details: define([\"title\", \"children\", \"open\"], \"Collapsible supporting content\", \"tone\"),\n badge: define([\"text\"], \"Short explicit status label\", \"tone\"),\n progress: define([\"label\", \"value\", \"max\", \"unit\"], \"Declared numeric progress snapshot\", \"tone\"),\n steps: define([\"items\"], \"Ordered declared steps\"),\n metric: define([\"label\", \"value\", \"unit\", \"decimals\"], \"Number with units, or a literal/bound status string\", \"tone\"),\n input: define([\"label\", \"bind\"], \"String or numeric state input\"),\n slider: define([\"label\", \"bind\"], \"Numeric state control\"),\n select: define([\"label\", \"bind\"], \"Selection from declared state options\"),\n toggle: define([\"label\", \"bind\"], \"Boolean state control\"),\n button: define([\"text\", \"action\"], \"Declared Agent follow-up\", \"icon\"),\n table: define([\"label\", \"rows\", \"columns\"], \"Supplied or derived values; inline rows or data/derived reference\"),\n chart: define([\"kind\", \"rows\", \"labelKey\", \"valueKey\", \"label\", \"unit\", \"xScale\"], \"Bar/line comparison with accessible data\"),\n sandbox: define([\"title\", \"html\", \"css\", \"script\", \"fallback\", \"height\", \"stateKeys\", \"dataKeys\", \"derivedKeys\"], \"Freeform isolated HTML/SVG/CSS/JavaScript visualization\")\n });\n function componentFields(type) {\n const c = Object.hasOwn(COMPONENTS, type) ? COMPONENTS[type] : void 0;\n return c && [\"id\", \"type\", ...c.fields, ...c.presentation ? [\"icon\"] : [], ...c.presentation === \"tone\" ? [\"tone\"] : []];\n }\n\n // lib/identifiers.js\n var IDENTIFIER_SOURCE = \"[\\\\p{L}_][\\\\p{L}\\\\p{M}\\\\p{N}_]{0,63}\";\n var pattern = new RegExp(`^${IDENTIFIER_SOURCE}$`, \"u\");\n var validIdentifier = (value) => typeof value === \"string\" && pattern.test(value) && ![\"constructor\", \"prototype\", \"__proto__\"].includes(value);\n\n // lib/author-expression.js\n function compileExpression(source) {\n const fail2 = () => {\n throw new Error(\"DSH UI: invalid formula; use state.x, data.x, derived.x, item.x, numbers, strings, + - * /, comparisons, ?: and min/max/round/abs\");\n };\n if (typeof source !== \"string\" || !source.trim() || source.length > 2048) fail2();\n const tokens = [];\n let offset = 0;\n const token2 = new RegExp(String.raw`\\s*(?:(\\d+(?:\\.\\d*)?|\\.\\d+)([eE][+-]?\\d+)?|(\"(?:[^\"\\\\]|\\\\.)*\")|(${IDENTIFIER_SOURCE}(?:\\.${IDENTIFIER_SOURCE})?)|(===|!==|<=|>=|==|!=|[+*/()?,:<>-]))`, \"uy\");\n while (offset < source.length) {\n if (!source.slice(offset).trim()) break;\n token2.lastIndex = offset;\n const m = token2.exec(source);\n if (!m) fail2();\n offset = token2.lastIndex;\n tokens.push(m[1] ? { number: Number(m[1] + (m[2] ?? \"\")) } : m[3] ? { string: JSON.parse(m[3]) } : m[4] ?? m[5]);\n if (tokens.length > 256) fail2();\n }\n let pos = 0;\n const operators = { \"==\": [\"eq\", 1], \"===\": [\"eq\", 1], \"!=\": [\"neq\", 1], \"!==\": [\"neq\", 1], \"<\": [\"lt\", 2], \"<=\": [\"lte\", 2], \">\": [\"gt\", 2], \">=\": [\"gte\", 2], \"+\": [\"add\", 3], \"-\": [\"sub\", 3], \"*\": [\"mul\", 4], \"/\": [\"div\", 4] };\n const take = (t) => {\n if (tokens[pos++] !== t) fail2();\n };\n function parse(min = 0, depth = 0) {\n if (depth > 12) fail2();\n const t = tokens[pos++];\n let out;\n if (t && typeof t === \"object\") out = { const: Object.hasOwn(t, \"number\") ? t.number : t.string };\n else if (t === \"true\" || t === \"false\") out = { const: t === \"true\" };\n else if (t === \"(\") {\n out = parse(0, depth + 1);\n take(\")\");\n } else if (t === \"-\" || t === \"+\") {\n const arg = parse(5, depth + 1);\n out = t === \"-\" ? { op: \"sub\", args: [{ const: 0 }, arg] } : arg;\n } else if (typeof t === \"string\" && t.split(\".\").length === 2 && [\"state\", \"data\", \"derived\", \"item\"].includes(t.split(\".\")[0]) && validIdentifier(t.split(\".\")[1])) out = { ref: t };\n else if ([\"min\", \"max\", \"round\", \"abs\"].includes(t)) {\n take(\"(\");\n const args = [parse(0, depth + 1)];\n while (tokens[pos] === \",\") {\n pos++;\n args.push(parse(0, depth + 1));\n if (args.length > 8) fail2();\n }\n take(\")\");\n out = { op: t, args };\n } else fail2();\n while (typeof tokens[pos] === \"string\" && Object.hasOwn(operators, tokens[pos]) && operators[tokens[pos]][1] >= min) {\n const [op, precedence] = operators[tokens[pos++]];\n out = { op, args: [out, parse(precedence + 1, depth + 1)] };\n }\n if (min === 0 && tokens[pos] === \"?\") {\n pos++;\n const yes = parse(0, depth + 1);\n take(\":\");\n out = { op: \"if\", args: [out, yes, parse(0, depth + 1)] };\n }\n return out;\n }\n const result = parse();\n if (pos !== tokens.length) fail2();\n return result;\n }\n function expandExpressions(doc2) {\n const expression2 = (e) => {\n if (!e || typeof e !== \"object\" || Array.isArray(e)) return e;\n if (Object.hasOwn(e, \"expr\")) {\n if (Object.keys(e).length !== 1) throw new Error(\"DSH UI: expr must be the only formula field\");\n return compileExpression(e.expr);\n }\n if (Array.isArray(e.args)) e.args = e.args.map(expression2);\n if (e.map?.fields) for (const k of Object.keys(e.map.fields)) e.map.fields[k] = expression2(e.map.fields[k]);\n return e;\n };\n if (doc2.derived && typeof doc2.derived === \"object\") for (const k of Object.keys(doc2.derived)) doc2.derived[k] = expression2(doc2.derived[k]);\n const walk = (nodes) => {\n if (!Array.isArray(nodes)) return;\n for (const node2 of nodes) {\n if (!node2 || typeof node2 !== \"object\") continue;\n for (const field of [\"text\", \"value\", \"max\", \"rows\"]) if (Object.hasOwn(node2, field)) node2[field] = expression2(node2[field]);\n walk(node2.children);\n }\n };\n walk(doc2.nodes);\n return doc2;\n }\n\n // lib/normalize-authoring.js\n var object = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v);\n var own = (v, k) => Object.hasOwn(v, k);\n var singleItem = (v) => object(v) && Object.keys(v).length === 1 && own(v, \"item\");\n var array = (v) => singleItem(v) && Array.isArray(v.item) ? v.item : v;\n var numericOps = /* @__PURE__ */ new Set([\"add\", \"sub\", \"mul\", \"div\", \"min\", \"max\", \"round\", \"abs\", \"lt\", \"lte\", \"gt\", \"gte\"]);\n function number(v) {\n if (typeof v !== \"string\" || !/^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:e[+-]?\\d+)?$/i.test(v.trim())) return v;\n const n = Number(v);\n return Number.isFinite(n) && (!Number.isInteger(n) || Number.isSafeInteger(n)) ? n : v;\n }\n var boolean = (v) => v === \"true\" ? true : v === \"false\" ? false : v;\n function expression(e, numeric = false, logical = false) {\n if (!object(e)) return e;\n if (own(e, \"const\")) {\n if (numeric) e.const = number(e.const);\n if (logical) e.const = boolean(e.const);\n }\n if (own(e, \"args\")) e.args = array(e.args);\n if (Array.isArray(e.args)) e.args.forEach((arg, i) => expression(arg, numericOps.has(e.op) || e.op === \"if\" && i > 0 && numeric, e.op === \"if\" && i === 0));\n if (object(e.map?.fields)) Object.values(e.map.fields).forEach((e2) => expression(e2));\n return e;\n }\n function normalizeAuthoring(doc2) {\n if (!object(doc2)) return doc2;\n if (own(doc2, \"version\")) doc2.version = number(doc2.version);\n if (object(doc2.state)) for (const k of Object.keys(doc2.state)) {\n let s = doc2.state[k];\n if (typeof s === \"string\" && s.trim().startsWith(\"{\")) {\n try {\n const parsed = JSON.parse(s);\n if (object(parsed)) s = parsed;\n } catch {\n }\n }\n if (singleItem(s) && object(s.item)) s = s.item;\n doc2.state[k] = s;\n if (!object(s)) continue;\n if (own(s, \"options\")) s.options = array(s.options);\n if (s.type === \"number\") {\n for (const field of [\"default\", \"min\", \"max\", \"step\"]) if (own(s, field)) s[field] = number(s[field]);\n if (Array.isArray(s.options)) s.options = s.options.map(number);\n } else if (s.type === \"boolean\") {\n if (own(s, \"default\")) s.default = boolean(s.default);\n if (Array.isArray(s.options)) s.options = s.options.map(boolean);\n }\n }\n if (object(doc2.derived)) Object.values(doc2.derived).forEach((e) => expression(e));\n if (object(doc2.actions)) {\n for (const action of Object.values(doc2.actions)) if (object(action) && own(action, \"stateKeys\")) action.stateKeys = array(action.stateKeys);\n }\n function nodes(list) {\n list = array(list);\n if (!Array.isArray(list)) return list;\n for (const node2 of list) {\n if (!object(node2)) continue;\n if (node2.type === \"sandbox\") {\n if (!own(node2, \"title\")) node2.title = doc2.title;\n if (!own(node2, \"fallback\")) node2.fallback = doc2.fallback;\n }\n for (const field of [\"children\", \"items\", \"columns\", \"stateKeys\", \"dataKeys\", \"derivedKeys\"]) if (own(node2, field)) node2[field] = array(node2[field]);\n if ([\"table\", \"chart\"].includes(node2.type) && own(node2, \"rows\")) node2.rows = array(node2.rows);\n for (const field of [\"decimals\", \"height\"]) if (own(node2, field)) node2[field] = number(node2[field]);\n if (node2.type === \"details\" && own(node2, \"open\")) node2.open = boolean(node2.open);\n for (const field of [\"text\", \"value\", \"max\", \"rows\"]) if (own(node2, field)) expression(node2[field], node2.type === \"progress\");\n if ([\"row\", \"column\", \"card\", \"details\"].includes(node2.type) && own(node2, \"children\")) node2.children = nodes(node2.children);\n }\n return list;\n }\n if (own(doc2, \"nodes\")) doc2.nodes = nodes(doc2.nodes);\n return doc2;\n }\n\n // lib/protocol.js\n var LIMITS = Object.freeze({ bytes: 131072, nodes: 160, depth: 12, rows: 500, expressions: 128, steps: 4096 });\n var own2 = (o, k) => Object.hasOwn(o, k);\n var fail = (message) => {\n throw new Error(`DSH UI: ${message}`);\n };\n var object2 = (x) => x !== null && typeof x === \"object\" && !Array.isArray(x);\n var finite = (x) => typeof x === \"number\" && Number.isFinite(x);\n var scalar = (x) => typeof x === \"string\" || typeof x === \"boolean\" || finite(x);\n var text = (x, at) => {\n if (typeof x !== \"string\" || x.length > 8192) fail(`${at} must be a string of at most 8192 characters`);\n };\n function fields(value, allowed, at) {\n if (!object2(value)) fail(`${at} must be an object`);\n for (const k of Object.keys(value)) if (!allowed.includes(k)) {\n const hint = at === \"document\" ? \" Controls and visual components belong in nodes as registered typed nodes, not extra document fields.\" : \"\";\n fail(`unknown field ${at}.${k}; allowed fields at ${at}: ${allowed.join(\", \")}.${hint}`);\n }\n }\n function safeJson(value, depth = 0, source = false) {\n if (depth > 48) fail(\"JSON nesting is too deep\");\n if (value === null || typeof value === \"boolean\") return;\n if (typeof value === \"string\") {\n if (source) {\n if (value.length > SANDBOX_LIMITS.source) fail(\"canvas source exceeds 48 Ki characters\");\n return;\n }\n return text(value, \"value\");\n }\n if (typeof value === \"number\") {\n if (!finite(value)) fail(\"numbers must be finite\");\n return;\n }\n if (typeof value !== \"object\") fail(\"only JSON values are accepted\");\n if (Array.isArray(value) && value.length > LIMITS.rows) fail(\"too many array items\");\n for (const k of Object.keys(value)) {\n if ([\"__proto__\", \"prototype\", \"constructor\"].includes(k)) fail(\"unsafe property name\");\n safeJson(value[k], depth + 1, value.type === \"sandbox\" && [\"html\", \"css\", \"script\"].includes(k));\n }\n }\n function initialState(doc2) {\n return Object.fromEntries(Object.entries(doc2.state).map(([k, spec]) => [k, spec.default]));\n }\n function validateState(doc2, state2) {\n if (!object2(state2) || Object.keys(state2).length !== Object.keys(doc2.state).length) fail(\"state keys do not match the document\");\n const result = {};\n for (const [k, s] of Object.entries(doc2.state)) {\n if (!own2(state2, k)) fail(`missing state.${k}`);\n const v = state2[k];\n if (s.type === \"number\") {\n if (!finite(v) || v < s.min || v > s.max) fail(`state.${k} is outside its numeric range`);\n const offset = (v - s.min) / s.step;\n if (Math.abs(offset - Math.round(offset)) > 1e-6) fail(`state.${k} does not match its step`);\n } else if (s.type === \"json\") {\n if (!object2(v) && !Array.isArray(v)) fail(`state.${k} must be an array or object for type json`);\n safeJson(v);\n if (new TextEncoder().encode(JSON.stringify(v)).length > 16384) fail(`state.${k} JSON exceeds 16 KiB`);\n } else if (s.type === \"string\") text(v, `state.${k}`);\n else if (typeof v !== \"boolean\") fail(`state.${k} must be boolean`);\n if (s.options && !s.options.includes(v)) fail(`invalid option for state.${k}`);\n result[k] = v;\n }\n return result;\n }\n var ARITY = { add: [1, 8], sub: [2, 2], mul: [1, 8], div: [2, 2], min: [1, 8], max: [1, 8], round: [1, 1], abs: [1, 1], eq: [2, 2], neq: [2, 2], lt: [2, 2], lte: [2, 2], gt: [2, 2], gte: [2, 2], if: [3, 3] };\n function reference(doc2, ref) {\n if (typeof ref !== \"string\" || ![\"state\", \"data\", \"derived\"].includes(ref.split(\".\")[0]) || ref.split(\".\").length !== 2 || !validIdentifier(ref.split(\".\")[1])) fail(`invalid reference ${JSON.stringify(ref)}; use state.key, data.key or derived.key with a letter/underscore identifier`);\n const [group, k] = ref.split(\".\");\n if (!own2(doc2[group], k)) fail(`unknown reference ${ref}`);\n return [group, k];\n }\n function evaluate(doc2, state2) {\n const derived = {}, active = /* @__PURE__ */ new Set();\n let steps = 0, generatedRows = 0;\n const budget = () => {\n if (++steps > LIMITS.steps) fail(\"calculation budget exceeded\");\n };\n const resolve = (ref) => {\n budget();\n const [group, k] = reference(doc2, ref);\n if (group !== \"derived\") return (group === \"state\" ? state2 : doc2.data)[k];\n if (own2(derived, k)) return derived[k];\n if (active.has(k)) fail(`cyclic calculation ${k}`);\n active.add(k);\n const result2 = expression2(doc2.derived[k]);\n active.delete(k);\n derived[k] = result2;\n return result2;\n };\n const expression2 = (e, item) => {\n budget();\n if (own2(e, \"map\")) {\n const rows = resolve(e.map.source);\n if ((generatedRows += rows.length) > LIMITS.rows) fail(\"generated row limit exceeded\");\n return rows.map((row) => Object.fromEntries(Object.entries(e.map.fields).map(([k, value]) => {\n const out2 = expression2(value, row);\n if (!scalar(out2)) fail(\"generated cells must be scalar\");\n return [k, out2];\n })));\n }\n if (own2(e, \"const\")) return e.const;\n if (own2(e, \"ref\")) {\n if (e.ref.startsWith(\"item.\")) {\n const k = e.ref.slice(5);\n if (!item || !own2(item, k) || !scalar(item[k])) fail(\"invalid item reference\");\n return item[k];\n }\n return resolve(e.ref);\n }\n if (e.op === \"if\") {\n const condition = expression2(e.args[0], item);\n if (typeof condition !== \"boolean\") fail(\"if condition must be boolean\");\n return expression2(e.args[condition ? 1 : 2], item);\n }\n const a = e.args.map((arg) => expression2(arg, item));\n if (e.op === \"eq\") return a[0] === a[1];\n if (e.op === \"neq\") return a[0] !== a[1];\n if (!a.every(finite)) fail(`operator ${e.op} requires finite numbers`);\n let out;\n switch (e.op) {\n case \"add\":\n out = a.reduce((x, y) => x + y, 0);\n break;\n case \"sub\":\n out = a[0] - a[1];\n break;\n case \"mul\":\n out = a.reduce((x, y) => x * y, 1);\n break;\n case \"div\":\n if (a[1] === 0) fail(\"division by zero\");\n out = a[0] / a[1];\n break;\n case \"min\":\n out = Math.min(...a);\n break;\n case \"max\":\n out = Math.max(...a);\n break;\n case \"round\":\n out = Math.round(a[0]);\n break;\n case \"abs\":\n out = Math.abs(a[0]);\n break;\n case \"lt\":\n return a[0] < a[1];\n case \"lte\":\n return a[0] <= a[1];\n case \"gt\":\n return a[0] > a[1];\n case \"gte\":\n return a[0] >= a[1];\n default:\n fail(`unsupported operator ${e.op}`);\n }\n if (!finite(out)) fail(\"non-finite calculation result\");\n return out;\n };\n for (const k of Object.keys(doc2.derived)) resolve(`derived.${k}`);\n if (new TextEncoder().encode(JSON.stringify(derived)).length > LIMITS.bytes) fail(\"derived output exceeds 128 KiB\");\n const result = { derived, resolveValue: (value) => {\n if (!object2(value)) return value;\n return expression2(value);\n } };\n checkViewValues(doc2, result);\n return result;\n }\n function checkViewValues(doc2, evaluated) {\n function walk(list) {\n for (const n of list) {\n if (n.type === \"table\" || n.type === \"chart\") {\n const rows = evaluated.resolveValue(n.rows);\n if (!Array.isArray(rows) || rows.length > LIMITS.rows || !rows.every(object2)) fail(\"table/chart rows must reference an array of objects\");\n if (n.type === \"chart\" && rows.some((r) => !own2(r, n.labelKey) || !scalar(r[n.labelKey]) || !own2(r, n.valueKey) || !finite(r[n.valueKey]))) fail(\"chart requires labels and finite numeric values\");\n if (n.type === \"chart\" && n.xScale === \"linear\" && rows.some((r) => !finite(r[n.labelKey]))) fail(\"linear chart requires numeric x values\");\n }\n if (n.type === \"metric\" && !scalar(evaluated.resolveValue(n.value))) fail(`node.${n.id}.value must resolve to a number, string or boolean; use table for rows or a canvas for structured data`);\n if (n.type === \"progress\") {\n const v = evaluated.resolveValue(n.value), max = evaluated.resolveValue(n.max);\n if (!finite(v) || !finite(max) || max <= 0 || v < 0 || v > max) fail(\"progress requires 0 <= value <= positive max\");\n }\n if (n.children) walk(n.children);\n }\n }\n walk(doc2.nodes);\n }\n function validateDocument(input) {\n safeJson(input);\n const json = JSON.stringify(input);\n if (new TextEncoder().encode(json).length > LIMITS.bytes) fail(\"document exceeds 128 KiB\");\n const normalized = normalizeAuthoring(JSON.parse(json));\n safeJson(normalized);\n const doc2 = expandExpressions(normalized);\n fields(doc2, [\"version\", \"title\", \"fallback\", \"state\", \"data\", \"derived\", \"nodes\", \"actions\", \"presentation\"], \"document\");\n if (doc2.presentation !== void 0) {\n fields(doc2.presentation, [\"layout\", \"accent\", \"density\"], \"presentation\");\n for (const [k, allowed] of Object.entries({ layout: UI_LAYOUTS, accent: UI_ACCENTS, density: [\"comfortable\", \"compact\"] }))\n if (doc2.presentation[k] !== void 0 && !allowed.includes(doc2.presentation[k])) fail(`unknown presentation ${k}`);\n }\n if (doc2.version !== 1) fail(\"unsupported protocol version\");\n text(doc2.title, \"title\");\n text(doc2.fallback, \"fallback\");\n if (!doc2.title.trim() || !doc2.fallback.trim()) fail(\"title and fallback cannot be empty\");\n for (const group of [\"state\", \"data\", \"derived\", \"actions\"]) {\n doc2[group] ??= {};\n if (!object2(doc2[group]) || Object.keys(doc2[group]).length > 64) fail(`invalid ${group}`);\n for (const k of Object.keys(doc2[group])) if (!validIdentifier(k)) fail(`invalid key in ${group}: ${JSON.stringify(k)}; use 1..64 letters, numbers or underscores, starting with a letter/underscore (Chinese letters supported); no spaces, dots or hyphens. Put display labels in label/text`);\n }\n for (const [k, s] of Object.entries(doc2.state)) {\n fields(s, [\"type\", \"default\", \"min\", \"max\", \"step\", \"options\"], `state.${k}`);\n if (s.type === \"string\" && (Array.isArray(s.default) || object2(s.default)) && !own2(s, \"options\")) s.type = \"json\";\n if (![\"number\", \"string\", \"boolean\", \"json\"].includes(s.type)) fail(`unsupported state type ${k}; use number, string, boolean or json (arrays/objects)`);\n if (s.type === \"json\" && own2(s, \"options\")) fail(`state.${k}: json state does not support select options; use scalar options or a canvas`);\n if (s.type === \"number\") {\n for (const field of [\"default\", \"min\", \"max\", \"step\"]) if (!finite(s[field])) fail(`state.${k}.${field} must be a finite JSON number; received ${s[field] === null ? \"null\" : typeof s[field]}. Supply {type:\"number\",default:2,min:1,max:12,step:1}; do not omit limits or include units in numeric fields`);\n if (s.min >= s.max || s.step <= 0) fail(`invalid numeric limits for state.${k}: require min < max and step > 0; received min=${s.min}, max=${s.max}, step=${s.step}`);\n if ((s.max - s.min) / s.step > 1e9) fail(`numeric step too small for ${k}`);\n } else if ([\"min\", \"max\", \"step\"].some((p) => own2(s, p))) fail(`numeric limits on non-number ${k}`);\n if (s.options !== void 0 && (!Array.isArray(s.options) || !s.options.length || s.options.length > 64 || !s.options.every(scalar))) fail(`state.${k}.options must be a direct array of 1..64 scalar values, e.g. options:[\"3\",\"5\",\"7\"], not {item:[...]}`);\n }\n const defaults = validateState(doc2, initialState(doc2));\n let expressions = 0;\n for (const [k, spec] of Object.entries(doc2.state))\n for (const option of spec.options ?? []) validateState(doc2, { ...defaults, [k]: option });\n function validateExpression(e, depth = 0, itemRows, at = \"expression\") {\n if (++expressions > LIMITS.expressions || depth > LIMITS.depth) fail(\"expression limit exceeded\");\n if (!object2(e)) fail(`${at}: expression must be an object`);\n if (own2(e, \"map\")) {\n if (depth !== 0) fail(\"map is only allowed at the root of a derived value\");\n fields(e, [\"map\"], \"expression\");\n fields(e.map, [\"source\", \"fields\"], \"map\");\n if (typeof e.map.source !== \"string\" || !e.map.source.startsWith(\"data.\")) fail('map source must reference static data using a string such as \"data.samples\"; put object rows in document.data.samples, not inline in map.source');\n const [, k] = reference(doc2, e.map.source), rows = doc2.data[k];\n if (!Array.isArray(rows) || rows.length > LIMITS.rows || !rows.every(object2)) fail(\"map source must be object rows\");\n if (!object2(e.map.fields) || !Object.keys(e.map.fields).length || Object.keys(e.map.fields).length > 16) fail(\"map requires 1..16 fields\");\n for (const [k2, value2] of Object.entries(e.map.fields)) {\n if (!validIdentifier(k2)) fail(`invalid map field ${JSON.stringify(k2).slice(0, 100)}; use a letter/underscore identifier such as powerW or \\u529F\\u7387, without spaces, dots or hyphens`);\n validateExpression(value2, depth + 1, rows, `${at}.map.fields.${k2}`);\n }\n return;\n }\n if (own2(e, \"const\")) {\n fields(e, [\"const\"], \"expression\");\n if (!scalar(e.const)) fail(\"constant must be scalar\");\n return;\n }\n if (own2(e, \"ref\")) {\n fields(e, [\"ref\"], \"expression\");\n if (typeof e.ref === \"string\" && e.ref.startsWith(\"item.\")) {\n const k = e.ref.slice(5);\n if (!itemRows || !validIdentifier(k) || itemRows.some((row) => !own2(row, k) || !scalar(row[k]))) fail(\"invalid or unscoped item reference\");\n } else reference(doc2, e.ref);\n return;\n }\n fields(e, [\"op\", \"args\"], \"expression\");\n const arity = ARITY[e.op];\n if (!arity || !Array.isArray(e.args) || e.args.length < arity[0] || e.args.length > arity[1]) fail(`${at}: invalid operator or argument count for ${JSON.stringify(e.op)}; args must be a direct array${arity ? ` with ${arity[0]}..${arity[1]} expressions` : \" and op must be registered\"}. Received ${Array.isArray(e.args) ? e.args.length + \" arguments\" : typeof e.args}. Prefer {expr:\"state.people * state.days\"} for arithmetic; missing operands cannot be inferred`);\n e.args.forEach((a, i) => validateExpression(a, depth + 1, itemRows, `${at}.args[${i}]`));\n }\n Object.entries(doc2.derived).forEach(([k, e]) => validateExpression(e, 0, void 0, `derived.${k}`));\n const visited = /* @__PURE__ */ new Set(), active = /* @__PURE__ */ new Set();\n function visit(k) {\n if (active.has(k)) fail(`cyclic calculation ${k}`);\n if (visited.has(k)) return;\n active.add(k);\n function walk(e) {\n if (e.ref?.startsWith(\"derived.\")) visit(e.ref.split(\".\")[1]);\n for (const a of e.args ?? []) walk(a);\n for (const value2 of Object.values(e.map?.fields ?? {})) walk(value2);\n }\n walk(doc2.derived[k]);\n active.delete(k);\n visited.add(k);\n }\n Object.keys(doc2.derived).forEach(visit);\n for (const [k, a] of Object.entries(doc2.actions)) {\n fields(a, [\"type\", \"intent\", \"stateKeys\"], `actions.${k}`);\n if (a.type !== \"agent.followup\") fail(\"only agent.followup actions are supported\");\n text(a.intent, \"action intent\");\n if (!a.intent.trim() || !Array.isArray(a.stateKeys) || a.stateKeys.length > 64 || new Set(a.stateKeys).size !== a.stateKeys.length || a.stateKeys.some((s) => !own2(doc2.state, s))) fail(\"invalid action stateKeys\");\n }\n const ids = /* @__PURE__ */ new Map();\n let count2 = 0;\n const value = (v, at) => {\n if (object2(v)) {\n if (own2(v, \"map\")) fail(\"map belongs in derived\");\n validateExpression(v);\n } else if (!scalar(v)) fail(`invalid value at ${at}`);\n };\n function nodes(list, depth = 0, path = \"nodes\") {\n if (!Array.isArray(list) || depth > LIMITS.depth) fail(\"invalid node children or depth\");\n for (const [index, n] of list.entries()) {\n const at = `${path}[${index}]`;\n if (++count2 > LIMITS.nodes) fail(\"too many nodes\");\n if (!object2(n)) fail(`${at} must be a node object`);\n if (!own2(n, \"id\")) fail(`${at}.id is missing (type=${JSON.stringify(n.type)}). Every node, including sandbox, needs its own id; add e.g. id:\"canvas\". JSON field order does not matter`);\n if (!validIdentifier(n.id)) fail(`${at}.id is invalid: ${JSON.stringify(n.id)}. Use 1..64 letters/numbers/underscores starting with a letter/underscore`);\n if (ids.has(n.id)) fail(`${at}.id duplicates ${JSON.stringify(n.id)} first declared at ${ids.get(n.id)}. Give each node a unique id; state keys and node ids are separate namespaces`);\n ids.set(n.id, `${at}.id`);\n if (!Object.hasOwn(COMPONENTS, n.type)) fail(\"unsupported component \" + n.type);\n if ([\"table\", \"chart\"].includes(n.type) && typeof n.rows === \"string\" && /^(data|derived)\\./.test(n.rows)) n.rows = { ref: n.rows };\n switch (n.type) {\n case \"sandbox\": {\n text(n.title, `${at}.title (canvas title)`);\n text(n.fallback, `${at}.fallback (canvas fallback)`);\n if (!n.title.trim()) fail(`${at}.title must not be empty`);\n if (!n.fallback.trim()) fail(`${at}.fallback must not be empty`);\n if (typeof n.html !== \"string\" || !n.html.trim()) fail(`${at}.html must be a non-empty HTML string; script/css/fallback belong beside html on the sandbox node, not inside it`);\n for (const field of [\"html\", \"css\", \"script\"]) if (n[field] !== void 0 && (typeof n[field] !== \"string\" || n[field].length > SANDBOX_LIMITS.source)) fail(\"canvas source exceeds 48 Ki characters\");\n if (n.height !== void 0 && (!Number.isInteger(n.height) || n.height < 160 || n.height > SANDBOX_LIMITS.height)) fail(\"canvas height must be 160..1000\");\n for (const [field, group] of [[\"stateKeys\", \"state\"], [\"dataKeys\", \"data\"], [\"derivedKeys\", \"derived\"]]) if (n[field] !== void 0 && (!Array.isArray(n[field]) || n[field].length > 64 || new Set(n[field]).size !== n[field].length || n[field].some((k) => !own2(doc2[group], k)))) fail(\"invalid canvas \" + field);\n break;\n }\n case \"row\":\n case \"column\":\n break;\n case \"card\":\n if (n.title !== void 0) text(n.title, \"card title\");\n if (n.surface !== void 0 && !UI_SURFACES.includes(n.surface)) fail(\"unknown card surface\");\n break;\n case \"details\":\n text(n.title, \"details title\");\n if (n.open === \"true\" || n.open === \"false\") n.open = n.open === \"true\";\n if (n.open !== void 0 && typeof n.open !== \"boolean\") fail(`node.${n.id}.open must be boolean true or false`);\n break;\n case \"badge\":\n value(n.text, \"badge text\");\n break;\n case \"progress\":\n text(n.label, \"progress label\");\n value(n.value, \"progress value\");\n value(n.max, \"progress max\");\n if (n.unit !== void 0) text(n.unit, \"progress unit\");\n break;\n case \"steps\": {\n const at2 = `node.${n.id}.items`;\n if (!Array.isArray(n.items)) fail(`${at2} must be an array of 1..12 step objects, received ${n.items === null ? \"null\" : typeof n.items}. Use items:[{\"title\":\"First step\",\"description\":\"One sentence\"}], not items:{item:[...]}. status is optional for a static guide`);\n if (!n.items.length || n.items.length > 12) fail(`${at2} requires 1..12 items; received ${n.items.length}. Split a longer guide into separate steps nodes`);\n for (const [i, item] of n.items.entries()) {\n const stepAt = `${at2}[${i}]`;\n fields(item, [\"title\", \"description\", \"status\"], stepAt);\n text(item.title, `${stepAt}.title`);\n if (item.description !== void 0) text(item.description, `${stepAt}.description`);\n if (item.status !== void 0 && ![\"pending\", \"active\", \"done\", \"error\"].includes(item.status)) fail(`${stepAt}.status must be pending, active, done or error; omit status for a static guide`);\n }\n break;\n }\n case \"heading\":\n case \"text\":\n value(n.text, \"text\");\n break;\n case \"metric\":\n text(n.label, \"metric label\");\n value(n.value, \"metric value\");\n if (n.unit !== void 0) text(n.unit, \"unit\");\n if (n.decimals !== void 0 && (!Number.isInteger(n.decimals) || n.decimals < 0 || n.decimals > 6)) fail(\"invalid decimals\");\n break;\n case \"input\":\n case \"slider\":\n case \"select\":\n case \"toggle\": {\n text(n.label, \"control label\");\n if (typeof n.bind !== \"string\" || !n.bind.startsWith(\"state.\")) fail(\"controls must bind state\");\n const [, k] = reference(doc2, n.bind), spec = doc2.state[k];\n if (n.type === \"slider\" && spec.type !== \"number\") fail(\"slider requires numeric state\");\n if (n.type === \"toggle\" && spec.type !== \"boolean\") fail(\"toggle requires boolean state\");\n if ([\"input\", \"select\"].includes(n.type) && spec.type === \"json\") fail(`node.${n.id}: structured state requires a sandbox with DSHUI.setState; use separate boolean states and toggle nodes for a native checklist`);\n if (n.type === \"input\" && spec.type === \"boolean\") fail(\"use toggle for boolean state\");\n if (n.type === \"select\" && !spec.options) fail(\"select requires options\");\n break;\n }\n case \"button\":\n text(n.text, \"button text\");\n if (!own2(doc2.actions, n.action)) fail(\"unknown action\");\n break;\n case \"table\": {\n if (n.label !== void 0) text(n.label, \"table label\");\n if (!Array.isArray(n.rows)) value(n.rows, \"table rows\");\n if (!Array.isArray(n.columns) || !n.columns.length || n.columns.length > 16) fail(\"invalid table columns\");\n const cols = /* @__PURE__ */ new Set();\n for (const c of n.columns) {\n fields(c, [\"key\", \"label\", \"unit\"], \"column\");\n if (!validIdentifier(c.key) || cols.has(c.key)) fail(\"invalid or duplicate column key\");\n cols.add(c.key);\n text(c.label, \"column label\");\n if (c.unit !== void 0) text(c.unit, \"column unit\");\n }\n break;\n }\n case \"chart\":\n if (!Array.isArray(n.rows)) value(n.rows, \"chart rows\");\n if (![\"bar\", \"line\"].includes(n.kind) || !validIdentifier(n.labelKey) || !validIdentifier(n.valueKey)) fail(\"invalid chart\");\n text(n.label, \"chart label\");\n if (n.unit !== void 0) text(n.unit, \"chart unit\");\n if (n.xScale !== void 0 && (![\"category\", \"linear\"].includes(n.xScale) || n.xScale === \"linear\" && n.kind !== \"line\")) fail(\"invalid xScale\");\n break;\n default:\n fail(`unsupported component ${n.type}`);\n }\n if ([\"heading\", \"text\", \"card\", \"metric\", \"button\", \"badge\", \"progress\", \"details\"].includes(n.type)) {\n if (n.icon !== void 0 && !UI_ICONS.includes(n.icon)) fail(\"unknown UI icon\");\n if (n.type !== \"button\") {\n if (n.tone !== void 0 && !UI_TONES.includes(n.tone)) fail(\"unknown UI tone\");\n }\n }\n fields(n, componentFields(n.type), `node.${n.id}`);\n if ([\"row\", \"column\", \"card\", \"details\"].includes(n.type)) nodes(n.children, depth + 1, `${at}.children`);\n }\n }\n nodes(doc2.nodes);\n if (!count2) fail(\"at least one node is required\");\n evaluate(doc2, defaults);\n return doc2;\n }\n\n // src/export-runtime.js\n var payload = JSON.parse(document.getElementById(\"dsh-ui-export\").textContent);\n var doc = validateDocument(payload.document);\n var node = doc.nodes.flatMap(function flatten(n) {\n return [n, ...(n.children ?? []).flatMap(flatten)];\n }).find((n) => n.id === payload.nodeId && n.type === \"sandbox\");\n if (!node) throw Error(\"\\u5BFC\\u51FA\\u7684\\u753B\\u5E03\\u4E0D\\u5B58\\u5728\");\n var state = validateState(doc, payload.state);\n var connected = false;\n var start = Date.now();\n var count = 0;\n var token = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n var channel = token();\n var nonce = payload.nonce;\n var frame = document.createElement(\"iframe\");\n frame.title = node.title;\n frame.sandbox = \"allow-scripts\";\n frame.referrerPolicy = \"no-referrer\";\n frame.style.cssText = \"display:block;width:100%;height:\" + (node.height ?? 420) + \"px;border:0\";\n var status = document.getElementById(\"status\");\n var publish = () => {\n try {\n frame.contentWindow.postMessage({ dshCanvas: 1, channel, type: \"snapshot\", payload: { ...canvasSnapshot(node, state, doc.data, evaluate(doc, state).derived, false, payload.theme), visible: !document.hidden } }, \"*\");\n } catch (e) {\n status.textContent = \"\\u8BA1\\u7B97\\u5931\\u8D25\\uFF1A\" + e.message;\n }\n };\n addEventListener(\"message\", (event) => {\n const m = event.data;\n if (event.source !== frame.contentWindow || m?.channel !== channel || m.dshCanvas !== 1) return;\n if (Date.now() - start > 1e3) {\n start = Date.now();\n count = 0;\n }\n if (++count > SANDBOX_LIMITS.rate) return;\n if (m.type === \"ready\") {\n connected = true;\n publish();\n } else if (m.type === \"patch\" && connected) {\n try {\n const next = validateState(doc, { ...state, ...canvasPatch(m.payload, node) });\n evaluate(doc, next);\n state = next;\n status.textContent = \"\\u672C\\u5730\\u53C2\\u6570\\u5DF2\\u66F4\\u65B0\\uFF1B\\u5237\\u65B0\\u6587\\u4EF6\\u4F1A\\u56DE\\u5230\\u5BFC\\u51FA\\u65F6\\u7684\\u53C2\\u6570\\u3002\";\n } catch (e) {\n status.textContent = \"\\u53C2\\u6570\\u65E0\\u6548\\uFF1A\" + e.message;\n }\n publish();\n } else if (m.type === \"height\" && Number.isFinite(m.payload) && m.payload >= 0) frame.style.height = Math.max(32, Math.min(1e3, Math.ceil(m.payload))) + \"px\";\n else if (m.type === \"error\") status.textContent = \"\\u753B\\u5E03\\u8FD0\\u884C\\u5F02\\u5E38\\uFF1A\" + String(m.payload).slice(0, 1e3);\n });\n document.addEventListener(\"visibilitychange\", () => {\n if (connected) publish();\n });\n document.getElementById(\"surface\").append(frame);\n frame.srcdoc = sandboxDocument(node, channel, nonce);\n})();\n";
package/lib/freeform.js CHANGED
@@ -7,7 +7,7 @@ export function canvasDocument({title,fallback,html,css,script,height,state,data
7
7
  return validateDocument({version:1,title,fallback,...values,nodes:[{id:'canvas',type:'sandbox',title,fallback,html,...(css===undefined?{}:{css}),...(script===undefined?{}:{script}),...(height===undefined?{}:{height}),stateKeys:Object.keys(values.state),dataKeys:Object.keys(values.data),derivedKeys:Object.keys(values.derived)}]});
8
8
  }
9
9
  export const FREE_UI_CONTRACT=AUTHORING_GUIDE+`\nYou have a freeform visual UI surface (static or interactive) in the conversation. Choose the graphics, structure, typography, colors, layout and animation yourself to suit the user's intent. No design template, card composition, accent palette, example UI or dashboard convention is prescribed. Use ordinary text when a UI would not help.
10
- Call render_canvas({title,fallback,html,css?,script?,height?,state?,data?,derived?}) directly, not inside PTC/run_code. For styled streaming preview emit css (empty string if unused) before html, then script. Preview is static until commit and parameter restoration. Optional host .ui-* primitives exist but no composition is required. html is a body fragment supporting HTML/SVG; put all CSS in css and JavaScript in script, use addEventListener rather than HTML event attributes. Each source field max 49152 characters, whole document max128KiB, height160..1000. Code runs only after commit and parameter restoration. No external resources, imports, eval or host APIs; the isolated frame is not a CPU/memory hard limit. The existing frame is updated with inspect_ui/patch_ui, never arbitrary host access.
10
+ Call render_canvas({title,fallback,html,css?,script?,height?,state?,data?,derived?}) directly, not inside PTC/run_code. The UI is displayed after the whole assistant turn ends, then parameter restoration enables controls; do not claim it is already visible merely because the tool succeeded. Source field order does not affect validation. Optional host .ui-* primitives exist but no composition is required. html is a body fragment supporting HTML/SVG; put all CSS in css and JavaScript in script, use addEventListener rather than HTML event attributes. Each source field max 49152 characters, whole document max128KiB, height160..1000. Code runs only after commit and parameter restoration. No external resources, imports, eval or host APIs; the isolated frame is not a CPU/memory hard limit. The existing frame is updated with inspect_ui/patch_ui, never arbitrary host access.
11
11
  The user-facing content and all controls may be authored inside your canvas. State important to the user belongs in state, e.g. {speed:{type:"number",default:1,min:0.5,max:3,step:0.5},running:{type:"boolean",default:false}}. Strings require type:"string",default; options:[string,...] is optional. Structured lists/objects use type:"json",default:[]/{} (16KiB per value). Bind custom controls through DSHUI.setState({speed:2}); use DSHUI.subscribe(snapshot=>...) to draw snapshot.state/data/derived. Do not write state from each animation frame or echo subscribe updates back into setState. State updates are validated and persisted. Reload may reset transient animation phase.
12
12
  Prefer DSHUI.animate(({time,delta,reducedMotion,visible})=>...) for motion; seconds, delta capped0.05, returns unsubscribe. Integrate delta only while running; reducedMotion needs a meaningful static view. Stop/replacement disposes the frame. DSHUI.formatNumber(value, decimals=2) formats finite numbers with 0..6 decimal places and comma grouping, exactly matching native metrics and fallback; use it for shared readouts instead of mixing toFixed rounding. Theme is in snapshot.theme and CSS variables --canvas-bg/soft/ink/muted/line/accent. Use readable text and named keyboard-operable controls, adapt to narrow and wide widths; ensure information remains available in a useful fallback. Be truthful about data, assumptions and simulation accuracy.
13
13
  If native controls or mixed component layouts would help, render_ui and ui_catalog remain available; query the catalog for their exact contract when needed. They are optional, not the default layout.
@@ -0,0 +1,68 @@
1
+ // Lossless transport repairs only at protocol-typed positions. Never traverse
2
+ // arbitrary data/state payloads or rewrite labels, HTML, CSS or JavaScript.
3
+ const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
4
+ const own = (v,k) => Object.hasOwn(v,k);
5
+ const singleItem = v => object(v) && Object.keys(v).length === 1 && own(v,'item');
6
+ const array = v => singleItem(v) && Array.isArray(v.item) ? v.item : v;
7
+ const numericOps = new Set(['add','sub','mul','div','min','max','round','abs','lt','lte','gt','gte']);
8
+ function number(v) {
9
+ if (typeof v !== 'string' || !/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(v.trim())) return v;
10
+ const n = Number(v);
11
+ return Number.isFinite(n) && (!Number.isInteger(n) || Number.isSafeInteger(n)) ? n : v;
12
+ }
13
+ const boolean = v => v === 'true' ? true : v === 'false' ? false : v;
14
+ function expression(e, numeric = false, logical = false) {
15
+ if (!object(e)) return e;
16
+ if (own(e,'const')) {
17
+ if (numeric) e.const = number(e.const);
18
+ if (logical) e.const = boolean(e.const);
19
+ }
20
+ if (own(e,'args')) e.args = array(e.args);
21
+ if (Array.isArray(e.args)) e.args.forEach((arg,i) => expression(arg,numericOps.has(e.op) || e.op === 'if' && i > 0 && numeric,e.op === 'if' && i === 0));
22
+ if (object(e.map?.fields)) Object.values(e.map.fields).forEach(e => expression(e));
23
+ return e;
24
+ }
25
+ export function normalizeAuthoring(doc) {
26
+ if (!object(doc)) return doc;
27
+ if (own(doc,'version')) doc.version = number(doc.version);
28
+ if (object(doc.state)) for (const k of Object.keys(doc.state)) {
29
+ let s = doc.state[k];
30
+ // Decode a single JSON object only where a state specification is required.
31
+ if (typeof s === 'string' && s.trim().startsWith('{')) {
32
+ try { const parsed = JSON.parse(s); if (object(parsed)) s = parsed; } catch { /* Validator reports the state path. */ }
33
+ }
34
+ if (singleItem(s) && object(s.item)) s = s.item;
35
+ doc.state[k] = s;
36
+ if (!object(s)) continue;
37
+ if (own(s,'options')) s.options = array(s.options);
38
+ if (s.type === 'number') {
39
+ for (const field of ['default','min','max','step']) if (own(s,field)) s[field] = number(s[field]);
40
+ if (Array.isArray(s.options)) s.options = s.options.map(number);
41
+ } else if (s.type === 'boolean') {
42
+ if (own(s,'default')) s.default = boolean(s.default);
43
+ if (Array.isArray(s.options)) s.options = s.options.map(boolean);
44
+ }
45
+ }
46
+ if (object(doc.derived)) Object.values(doc.derived).forEach(e => expression(e));
47
+ if (object(doc.actions)) for (const action of Object.values(doc.actions)) if (object(action) && own(action,'stateKeys')) action.stateKeys = array(action.stateKeys);
48
+ function nodes(list) {
49
+ list = array(list);
50
+ if (!Array.isArray(list)) return list;
51
+ for (const node of list) {
52
+ if (!object(node)) continue;
53
+ if (node.type === 'sandbox') {
54
+ if (!own(node,'title')) node.title = doc.title;
55
+ if (!own(node,'fallback')) node.fallback = doc.fallback;
56
+ }
57
+ for (const field of ['children','items','columns','stateKeys','dataKeys','derivedKeys']) if (own(node,field)) node[field] = array(node[field]);
58
+ if (['table','chart'].includes(node.type) && own(node,'rows')) node.rows = array(node.rows);
59
+ for (const field of ['decimals','height']) if (own(node,field)) node[field] = number(node[field]);
60
+ if (node.type === 'details' && own(node,'open')) node.open = boolean(node.open);
61
+ for (const field of ['text','value','max','rows']) if (own(node,field)) expression(node[field],node.type === 'progress');
62
+ if (['row','column','card','details'].includes(node.type) && own(node,'children')) node.children = nodes(node.children);
63
+ }
64
+ return list;
65
+ }
66
+ if (own(doc,'nodes')) doc.nodes = nodes(doc.nodes);
67
+ return doc;
68
+ }
package/lib/protocol.js CHANGED
@@ -4,6 +4,7 @@ import { COMPONENTS, componentFields } from './components.js';
4
4
  import { formatNumber } from './format-number.js';
5
5
  import { expandExpressions } from './author-expression.js';
6
6
  import { validIdentifier as key } from './identifiers.js';
7
+ import { normalizeAuthoring } from './normalize-authoring.js';
7
8
  export const LIMITS = Object.freeze({ bytes: 131072, nodes: 160, depth: 12, rows: 500, expressions: 128, steps: 4096 });
8
9
  const own = (o, k) => Object.hasOwn(o, k);
9
10
  const fail = message => { throw new Error(`DSH UI: ${message}`); };
@@ -135,7 +136,10 @@ export function validateDocument(input) {
135
136
  safeJson(input);
136
137
  const json = JSON.stringify(input);
137
138
  if (new TextEncoder().encode(json).length > LIMITS.bytes) fail('document exceeds 128 KiB');
138
- const doc = expandExpressions(JSON.parse(json));
139
+ const normalized = normalizeAuthoring(JSON.parse(json));
140
+ // State spec JSON decoding must undergo the same unsafe-key/depth checks.
141
+ safeJson(normalized);
142
+ const doc = expandExpressions(normalized);
139
143
  fields(doc, ['version', 'title', 'fallback', 'state', 'data', 'derived', 'nodes', 'actions', 'presentation'], 'document');
140
144
  if (doc.presentation !== undefined) {
141
145
  fields(doc.presentation, ['layout', 'accent', 'density'], 'presentation');
@@ -157,17 +161,18 @@ export function validateDocument(input) {
157
161
  if (!['number', 'string', 'boolean', 'json'].includes(s.type)) fail(`unsupported state type ${k}; use number, string, boolean or json (arrays/objects)`);
158
162
  if (s.type === 'json' && own(s,'options')) fail(`state.${k}: json state does not support select options; use scalar options or a canvas`);
159
163
  if (s.type === 'number') {
160
- if (![s.min, s.max, s.step].every(finite) || s.min >= s.max || s.step <= 0) fail(`invalid numeric limits for ${k}`);
164
+ for (const field of ['default','min','max','step']) if (!finite(s[field])) fail(`state.${k}.${field} must be a finite JSON number; received ${s[field] === null ? 'null' : typeof s[field]}. Supply {type:"number",default:2,min:1,max:12,step:1}; do not omit limits or include units in numeric fields`);
165
+ if (s.min >= s.max || s.step <= 0) fail(`invalid numeric limits for state.${k}: require min < max and step > 0; received min=${s.min}, max=${s.max}, step=${s.step}`);
161
166
  if ((s.max - s.min) / s.step > 1e9) fail(`numeric step too small for ${k}`);
162
167
  } else if (['min', 'max', 'step'].some(p => own(s, p))) fail(`numeric limits on non-number ${k}`);
163
- if (s.options && (!Array.isArray(s.options) || !s.options.length || s.options.length > 64 || !s.options.every(scalar))) fail(`invalid options for ${k}`);
168
+ if (s.options !== undefined && (!Array.isArray(s.options) || !s.options.length || s.options.length > 64 || !s.options.every(scalar))) fail(`state.${k}.options must be a direct array of 1..64 scalar values, e.g. options:["3","5","7"], not {item:[...]}`);
164
169
  }
165
170
  const defaults = validateState(doc, initialState(doc)); let expressions = 0;
166
171
  for (const [k, spec] of Object.entries(doc.state))
167
172
  for (const option of spec.options ?? []) validateState(doc, { ...defaults, [k]: option });
168
- function validateExpression(e, depth = 0, itemRows) {
173
+ function validateExpression(e, depth = 0, itemRows, at = 'expression') {
169
174
  if (++expressions > LIMITS.expressions || depth > LIMITS.depth) fail('expression limit exceeded');
170
- if (!object(e)) fail('expression must be an object');
175
+ if (!object(e)) fail(`${at}: expression must be an object`);
171
176
  if (own(e, 'map')) {
172
177
  if (depth !== 0) fail('map is only allowed at the root of a derived value');
173
178
  fields(e, ['map'], 'expression'); fields(e.map, ['source', 'fields'], 'map');
@@ -175,7 +180,7 @@ export function validateDocument(input) {
175
180
  const [, k] = reference(doc, e.map.source), rows = doc.data[k];
176
181
  if (!Array.isArray(rows) || rows.length > LIMITS.rows || !rows.every(object)) fail('map source must be object rows');
177
182
  if (!object(e.map.fields) || !Object.keys(e.map.fields).length || Object.keys(e.map.fields).length > 16) fail('map requires 1..16 fields');
178
- for (const [k, value] of Object.entries(e.map.fields)) { if (!key(k)) fail(`invalid map field ${JSON.stringify(k).slice(0,100)}; use a letter/underscore identifier such as powerW or 功率, without spaces, dots or hyphens`); validateExpression(value, depth + 1, rows); }
183
+ for (const [k, value] of Object.entries(e.map.fields)) { if (!key(k)) fail(`invalid map field ${JSON.stringify(k).slice(0,100)}; use a letter/underscore identifier such as powerW or 功率, without spaces, dots or hyphens`); validateExpression(value, depth + 1, rows, `${at}.map.fields.${k}`); }
179
184
  return;
180
185
  }
181
186
  if (own(e, 'const')) { fields(e, ['const'], 'expression'); if (!scalar(e.const)) fail('constant must be scalar'); return; }
@@ -188,10 +193,10 @@ export function validateDocument(input) {
188
193
  }
189
194
  fields(e, ['op', 'args'], 'expression');
190
195
  const arity = ARITY[e.op];
191
- if (!arity || !Array.isArray(e.args) || e.args.length < arity[0] || e.args.length > arity[1]) fail('invalid operator or argument count');
192
- e.args.forEach(a => validateExpression(a, depth + 1, itemRows));
196
+ if (!arity || !Array.isArray(e.args) || e.args.length < arity[0] || e.args.length > arity[1]) fail(`${at}: invalid operator or argument count for ${JSON.stringify(e.op)}; args must be a direct array${arity ? ` with ${arity[0]}..${arity[1]} expressions` : ' and op must be registered'}. Received ${Array.isArray(e.args) ? e.args.length+' arguments' : typeof e.args}. Prefer {expr:"state.people * state.days"} for arithmetic; missing operands cannot be inferred`);
197
+ e.args.forEach((a,i) => validateExpression(a, depth + 1, itemRows, `${at}.args[${i}]`));
193
198
  }
194
- Object.values(doc.derived).forEach(e => validateExpression(e));
199
+ Object.entries(doc.derived).forEach(([k,e]) => validateExpression(e,0,undefined,`derived.${k}`));
195
200
  // Check every reference edge, including inactive conditional branches.
196
201
  const visited = new Set(), active = new Set();
197
202
  function visit(k) {
@@ -207,20 +212,27 @@ export function validateDocument(input) {
207
212
  text(a.intent, 'action intent');
208
213
  if (!a.intent.trim() || !Array.isArray(a.stateKeys) || a.stateKeys.length > 64 || new Set(a.stateKeys).size !== a.stateKeys.length || a.stateKeys.some(s => !own(doc.state, s))) fail('invalid action stateKeys');
209
214
  }
210
- const ids = new Set(); let count = 0;
215
+ const ids = new Map(); let count = 0;
211
216
  const value = (v, at) => { if (object(v)) { if(own(v,'map'))fail('map belongs in derived');validateExpression(v); } else if (!scalar(v)) fail(`invalid value at ${at}`); };
212
- function nodes(list, depth = 0) {
217
+ function nodes(list, depth = 0, path = 'nodes') {
213
218
  if (!Array.isArray(list) || depth > LIMITS.depth) fail('invalid node children or depth');
214
- for (const n of list) {
219
+ for (const [index,n] of list.entries()) {
220
+ const at = `${path}[${index}]`;
215
221
  if (++count > LIMITS.nodes) fail('too many nodes');
216
- if (!object(n) || !key(n.id) || ids.has(n.id)) fail('node ids must be unique identifiers'); ids.add(n.id);
222
+ if (!object(n)) fail(`${at} must be a node object`);
223
+ if (!own(n,'id')) fail(`${at}.id is missing (type=${JSON.stringify(n.type)}). Every node, including sandbox, needs its own id; add e.g. id:"canvas". JSON field order does not matter`);
224
+ if (!key(n.id)) fail(`${at}.id is invalid: ${JSON.stringify(n.id)}. Use 1..64 letters/numbers/underscores starting with a letter/underscore`);
225
+ if (ids.has(n.id)) fail(`${at}.id duplicates ${JSON.stringify(n.id)} first declared at ${ids.get(n.id)}. Give each node a unique id; state keys and node ids are separate namespaces`);
226
+ ids.set(n.id,`${at}.id`);
217
227
  if (!Object.hasOwn(COMPONENTS, n.type)) fail('unsupported component ' + n.type);
218
228
  if (['table','chart'].includes(n.type) && typeof n.rows === 'string' && /^(data|derived)\./.test(n.rows)) n.rows={ref:n.rows};
219
229
  switch (n.type) {
220
230
  case 'sandbox': {
221
231
 
222
- text(n.title, 'canvas title'); text(n.fallback, 'canvas fallback');
223
- if (!n.title.trim() || !n.fallback.trim() || typeof n.html !== 'string' || !n.html.trim()) fail('canvas requires title, HTML and fallback');
232
+ text(n.title, `${at}.title (canvas title)`); text(n.fallback, `${at}.fallback (canvas fallback)`);
233
+ if (!n.title.trim()) fail(`${at}.title must not be empty`);
234
+ if (!n.fallback.trim()) fail(`${at}.fallback must not be empty`);
235
+ if (typeof n.html !== 'string' || !n.html.trim()) fail(`${at}.html must be a non-empty HTML string; script/css/fallback belong beside html on the sandbox node, not inside it`);
224
236
  for (const field of ['html', 'css', 'script']) if (n[field] !== undefined && (typeof n[field] !== 'string' || n[field].length > SANDBOX_LIMITS.source)) fail('canvas source exceeds 48 Ki characters');
225
237
  if (n.height !== undefined && (!Number.isInteger(n.height) || n.height < 160 || n.height > SANDBOX_LIMITS.height)) fail('canvas height must be 160..1000');
226
238
  for (const [field, group] of [['stateKeys', 'state'], ['dataKeys', 'data'], ['derivedKeys', 'derived']]) if (n[field] !== undefined && (!Array.isArray(n[field]) || n[field].length > 64 || new Set(n[field]).size !== n[field].length || n[field].some(k => !own(doc[group], k)))) fail('invalid canvas ' + field);
@@ -228,16 +240,22 @@ export function validateDocument(input) {
228
240
  }
229
241
  case 'row': case 'column': break;
230
242
  case 'card': if (n.title !== undefined) text(n.title, 'card title'); if (n.surface !== undefined && !UI_SURFACES.includes(n.surface)) fail('unknown card surface'); break;
231
- case 'details': text(n.title, 'details title'); if (n.open !== undefined && typeof n.open !== 'boolean') fail('details open must be boolean'); break;
243
+ case 'details':
244
+ text(n.title, 'details title');
245
+ if (n.open === 'true' || n.open === 'false') n.open = n.open === 'true';
246
+ if (n.open !== undefined && typeof n.open !== 'boolean') fail(`node.${n.id}.open must be boolean true or false`);
247
+ break;
232
248
  case 'badge': value(n.text, 'badge text'); break;
233
249
  case 'progress': text(n.label, 'progress label'); value(n.value, 'progress value'); value(n.max, 'progress max'); if (n.unit !== undefined) text(n.unit, 'progress unit'); break;
234
250
  case 'steps': {
235
-
236
- if (!Array.isArray(n.items) || !n.items.length || n.items.length > 12) fail('steps require 1..12 items');
237
- for (const item of n.items) {
238
- fields(item, ['title', 'description', 'status'], 'step'); text(item.title, 'step title');
239
- if (item.description !== undefined) text(item.description, 'step description');
240
- if (!['pending', 'active', 'done', 'error'].includes(item.status)) fail('unknown step status');
251
+ const at = `node.${n.id}.items`;
252
+ if (!Array.isArray(n.items)) fail(`${at} must be an array of 1..12 step objects, received ${n.items === null ? 'null' : typeof n.items}. Use items:[{"title":"First step","description":"One sentence"}], not items:{item:[...]}. status is optional for a static guide`);
253
+ if (!n.items.length || n.items.length > 12) fail(`${at} requires 1..12 items; received ${n.items.length}. Split a longer guide into separate steps nodes`);
254
+ for (const [i, item] of n.items.entries()) {
255
+ const stepAt = `${at}[${i}]`;
256
+ fields(item, ['title', 'description', 'status'], stepAt); text(item.title, `${stepAt}.title`);
257
+ if (item.description !== undefined) text(item.description, `${stepAt}.description`);
258
+ if (item.status !== undefined && !['pending', 'active', 'done', 'error'].includes(item.status)) fail(`${stepAt}.status must be pending, active, done or error; omit status for a static guide`);
241
259
  } break;
242
260
  }
243
261
  case 'heading': case 'text': value(n.text, 'text'); break;
@@ -268,7 +286,7 @@ export function validateDocument(input) {
268
286
  if (n.type !== 'button') { if (n.tone !== undefined && !UI_TONES.includes(n.tone)) fail('unknown UI tone'); }
269
287
  }
270
288
  fields(n, componentFields(n.type), `node.${n.id}`);
271
- if (['row', 'column', 'card', 'details'].includes(n.type)) nodes(n.children, depth + 1);
289
+ if (['row', 'column', 'card', 'details'].includes(n.type)) nodes(n.children, depth + 1, `${at}.children`);
272
290
  }
273
291
  }
274
292
  nodes(doc.nodes);
@@ -292,7 +310,7 @@ export function fallbackText(doc) {
292
310
  if (n.type === 'sandbox') lines.push(n.title, n.fallback);
293
311
  if ((n.type === 'card' || n.type === 'details') && n.title) lines.push(n.title);
294
312
  if (n.type === 'progress') lines.push(`${n.label}: ${resolveValue(n.value)} / ${resolveValue(n.max)} ${n.unit ?? ''}`);
295
- if (n.type === 'steps') for (const item of n.items) lines.push(`[${item.status}] ${item.title}${item.description ? ': ' + item.description : ''}`);
313
+ if (n.type === 'steps') for (const [i,item] of n.items.entries()) lines.push(`${item.status ? `[${item.status}]` : `${i+1}.`} ${item.title}${item.description ? ': ' + item.description : ''}`);
296
314
  if (n.type === 'metric') { const v=resolveValue(n.value); lines.push(`${n.label}: ${typeof v === 'number' ? formatNumber(v,n.decimals ?? 2) : String(v)} ${n.unit ?? ''}`); }
297
315
  if (n.type === 'table') { if(n.label)lines.push(n.label); lines.push(n.columns.map(c => c.label + (c.unit ? ` (${c.unit})` : '')).join(' | ')); for (const row of resolveValue(n.rows)) lines.push(n.columns.map(c => Object.hasOwn(row, c.key) ? String(row[c.key]) : '—').join(' | ')); }
298
316
  if (n.type === 'chart') { lines.push(n.label + (n.unit ? ` (${n.unit})` : '')); for (const r of resolveValue(n.rows)) lines.push(`${r[n.labelKey]}: ${r[n.valueKey]}`); }