dsh-motion-ui 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,8 @@
1
+ export const REPAIR_GUIDANCE=Object.freeze({
2
+ empty:'Complete the user task with a visible answer. If UI helps, actually render it or write a complete source fence; otherwise answer in plain text. Do not end with only a promise. This notice does not require UI or limit reasoning/output.',
3
+ tool:'Motion UI delivery is incomplete. Correct the failed tool arguments and resubmit the complete document, or give a useful text fallback. Do not repeat successful UIs. Consult ui_catalog/validate_ui only for unknown syntax. Do not finish with a promise; this notice does not limit reasoning/output.',
4
+ fence:'Correct only the failed source fence, preserving its intended content. Do not duplicate successful UIs or render an already successful fence again. Consult ui_catalog/validate_ui for unknown syntax; explain unresolved defects with useful text. This notice does not limit reasoning/output.'
5
+ });
1
6
  import {randomUUID,createHash} from 'node:crypto';
2
7
  import {extractUiFences} from './fences.js';
3
8
  import {resolveSourceFence} from './delivery.js';
@@ -22,7 +27,7 @@ export function registerFenceFeedback(ctx,enabled=()=>true){
22
27
  if(event.type==='assistant/message'){state.lastText=textOf(event.data.message?.content).slice(0,262144);state.blocks.set(event.data.step??0,state.lastText)}
23
28
  if(event.type==='tool/call')state.seenToolCall=true;
24
29
  if(event.type==='user/message'&&event.data.source?.kind==='user'){state.request=textOf(event.data.content).slice(0,8192);state.silentRequested=/(?:不用|不必|无需|不要)回复|do not (?:respond|reply)|no (?:response|reply) (?:needed|required)/i.test(state.request)}
25
- if(event.type==='tool/call'&&['render_text','render_ui','render_canvas','render_mechanism','update_ui','patch_ui'].includes(event.data.name))state.calls.set(event.data.callId,event.data.name);
30
+ if(event.type==='tool/call'&&['render_text','render_ui','render_canvas','render_mechanism','render_scene','update_ui','patch_ui'].includes(event.data.name))state.calls.set(event.data.callId,event.data.name);
26
31
  if(event.type==='tool/result'){
27
32
  const message=event.data.message,name=state.calls.get(message?.toolCallId);
28
33
  if(name){if(message.isError){const diagnostic=name+': '+textOf(message.content).slice(0,1200);state.toolFailures.set(fingerprint(diagnostic),diagnostic)}else state.delivered=true}
@@ -53,7 +58,8 @@ export function registerFenceFeedback(ctx,enabled=()=>true){
53
58
  if(!state.silentRequested&&!state.delivered&&isUiDeliveryAnnouncement(visible,state.request)&&!state.corrected.has(promiseKey))failures.push({key:promiseKey,empty:true,diagnostic:'The visible answer only announces a UI, but no UI was delivered. Complete the promised work instead of ending with a delivery announcement.'});
54
59
  if(!failures.length)return;
55
60
  const chosen=failures.slice(0,4);state.count++;chosen.forEach(f=>state.corrected.add(f.key));
56
- const text=chosen.map(f=>'[motion-repair:'+f.key+'] '+f.diagnostic).join('\n')+'\n'+(chosen.some(f=>f.empty)?'Complete the user task with an actual visible answer. If a UI helps, submit it through the appropriate rendering tool or a complete source fence; otherwise answer in plain text. Do not finish with only a plan or promise. This notice does not require a UI, limit reasoning or stop generation.':'Motion UI delivery is incomplete. '+(chosen.some(f=>f.tool)?'A rendering tool failed and your last response contained no visible answer. Correct the actual failed tool arguments and submit a complete UI, or provide a useful text fallback. A promise to render later is not delivery. ':'Correct only the failed source fence(s), retaining their intended content. ')+'Do not repeat successful UIs or call render_ui for an already rendered fence. Use ui_catalog or validate_ui for exact syntax. If the same defect cannot be corrected, give a useful text fallback. This notice does not limit your reasoning or stop generation.');
61
+ const kind=chosen.some(f=>f.empty)?'empty':chosen.some(f=>f.tool)?'tool':'fence';
62
+ const text=chosen.map(f=>'[motion-repair:'+f.key+'] '+f.diagnostic).join('\n')+'\n'+REPAIR_GUIDANCE[kind];
57
63
  const source=(agent.session.header?.version??4)>=4?{kind:'plugin:motion-ui',form:'notice',summary:'Motion UI delivery repair'}:{kind:'plugin',plugin:'motion-ui',form:'notice',summary:'Motion UI delivery repair'};
58
64
  const id=randomUUID();state.notices.add(id);
59
65
  try{agent.steer({id,role:'user',content:[{type:'text',text}],source})}catch(e){ctx.logger?.warn?.('Motion UI correction could not be queued: '+e.message)}
package/lib/freeform.js CHANGED
@@ -1,15 +1,9 @@
1
1
  import { toolJson } from './tool-json.js';
2
2
  import { validateDocument } from './protocol.js';
3
- import { AUTHORING_GUIDE } from './authoring-guide.js';
3
+ import {MODEL_CONTRACT} from './prompt-contract.js';
4
4
  export function canvasDocument({title,fallback,html,css,script,height,libraries,state,data,derived}){
5
5
  const values={state:toolJson(state??{},'state'),data:toolJson(data??{},'data'),derived:toolJson(derived??{},'derived')};
6
6
  for(const [key,value] of Object.entries(values))if(!value||typeof value!=='object'||Array.isArray(value))throw new Error('DSH UI: '+key+' must be an object');
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}),...(libraries===undefined?{}:{libraries}),stateKeys:Object.keys(values.state),dataKeys:Object.keys(values.data),derivedKeys:Object.keys(values.derived)}]});
8
8
  }
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?,libraries?,state?,data?,derived?}) directly, not inside PTC/run_code. A stable inert HTML/SVG preview appears progressively during generation; after the whole assistant turn ends, parameter restoration enables controls. Prefer title/fallback/css before html for early styled previews. Do not claim interaction is active 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
- 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
- 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
- For common mechanisms the optional render_mechanism({scene:"assembly"|"gears"|"crank",title?,rpm?,teeth?,radius?,running?,angle?,explode?}) delivers a complete adjustable diagram without writing HTML. Fixed driver16 teeth / rod length70; source-free convenience is optional in free mode. For custom assemblies, exploded views, gears or crank mechanisms, an optional local helper avoids writing large SVG meshes: call ui_catalog({topic:"mechanism"}) for summaries or add scene:"assembly"/"gears"/"crank" for one complete example. A sandbox node (or render_canvas) enables it with libraries:["mechanism"], then DSHUI.mechanism.mountDemo("#diagram","gears",{bind:{rpm:"rpm",running:"running"}}) uses declared shared state. The helper also exposes mount, scenes, gearPair, sliderCrank and follow. This is explanatory geometry/kinematics, not engineering collision, force or manufacturing validation. Keep ordinary simple diagrams small; do not impose high part counts or idle animations.
14
- 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.
15
- inspect_ui defaults to a compact summary; detail:"nodes",nodeIds:["canvas"] reads canvas source. patch_ui requires documentId,current documentRevision,changes:[{id:"canvas",props:{html/css/script/...}}]. It preserves declared state and other nodes; replacing source restarts that canvas. Observations are heuristic and scoped to recorded viewport/theme/revision, not a beauty or safety score. Empty reports mean unobserved, do not poll or invent success. Limit automatic correction to two rounds per user request, then simplify or explain unresolved problems. Explicit new user edits start a new request. This is a behavior budget, not a provider inference timeout.`;
9
+ export const FREE_UI_CONTRACT='No design template, visual style, palette, typography scale, composition or example is prescribed in free mode. Choose them yourself; native components and canvas helpers are optional interfaces.\n\n'+MODEL_CONTRACT;
package/lib/inspection.js CHANGED
@@ -1,14 +1,19 @@
1
+ import {componentFields} from './components.js';
1
2
  // Keep the default observation small; request exact node source only when editing it.
2
- export function inspectRecord(record,state,derived,audits,{detail='summary',nodeIds=[]}={}){
3
+ export function inspectRecord(record,state,derived,audits,{detail='summary',nodeIds=[],query=''}={}){
3
4
  if(!['summary','nodes','full'].includes(detail))throw new Error('DSH UI: invalid inspection detail');
4
- const flat=list=>list.flatMap(n=>[n,...flat(n.children??[])]),nodes=flat(record.document.nodes);
5
+ if(typeof query!=='string'||query.length>200)throw new Error('DSH UI: inspection query must be text of at most200 characters');
6
+ const locations=new Map();const flat=(list,parentId=null,path='nodes')=>list.flatMap((n,index)=>{locations.set(n.id,{path:path+'['+index+']',parentId,position:index+1});return[n,...flat(n.children??[],n.id,path+'['+index+'].children')]});const nodes=flat(record.document.nodes);
7
+ const matches=query.trim()?nodes.filter(n=>[n.id,n.type,n.title,n.label,n.text,n.code,n.type==='visual'?JSON.stringify(n.spec):''].some(v=>typeof v==='string'&&v.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()))):nodes;
5
8
  if(!Array.isArray(nodeIds)||nodeIds.length>16||nodeIds.some(id=>typeof id!=='string'||!nodes.some(n=>n.id===id)))throw new Error('DSH UI: nodeIds requires up to 16 existing IDs');
6
9
  const inferred=detail==='nodes'&&!nodeIds.length;
7
- if(inferred)nodeIds=nodes.slice(0,16).map(n=>n.id);
10
+ if(query.trim()&&nodeIds.length)throw new Error('DSH UI: choose query OR nodeIds, not both');
11
+ if(inferred)nodeIds=matches.slice(0,16).map(n=>n.id);
8
12
  const bounded=value=>Buffer.byteLength(JSON.stringify(value??{}))<=8192?value:{omitted:true,reason:'Value exceeds 8KiB summary budget; request full inspection.',keys:Object.keys(value??{})};
9
- const result={documentId:record.documentId,documentRevision:record.documentRevision??0,title:record.document.title,state:detail==='full'?state:bounded(state),derived:detail==='full'?derived:bounded(derived),audits,detail};
13
+ const result={documentId:record.documentId,documentRevision:record.documentRevision??0,title:record.document.title,state:detail==='full'?state:bounded(state),derived:detail==='full'?derived:bounded(derived),audits,detail,visualCheck:{status:audits.length?(audits.some(a=>a.issues.length)?'issues-observed':'no-issues-in-covered-checks'):'unobserved',observedViews:audits.length,issueCount:audits.reduce((n,a)=>n+a.issues.length,0),note:'Heuristic client observations only; absence is not a pass. Targets are scoped to this UI/canvas, not the host page.'}};
14
+ if(query.trim())result.search={query:query.trim(),matched:matches.length,returned:Math.min(matches.length,16),targets:matches.slice(0,16).map(n=>({id:n.id,type:n.type,...locations.get(n.id)}))};
10
15
  if(detail==='full')result.document=record.document;
11
- else if(detail==='nodes'){result.nodes=nodes.filter(n=>nodeIds.includes(n.id));if(inferred&&nodes.length>16)result.selection={returned:16,total:nodes.length,remainingIds:nodes.slice(16).map(n=>n.id),hint:'Pass nodeIds to inspect additional nodes.'};}
12
- else result.nodes=nodes.map(n=>({id:n.id,type:n.type,...Object.fromEntries(['title','label','text','bind'].filter(k=>typeof n[k]==='string').map(k=>[k,n[k].slice(0,160)])),...(n.type==='sandbox'?{sourceCharacters:{html:n.html.length,css:n.css?.length??0,script:n.script?.length??0}}:{})}));
16
+ else if(detail==='nodes'){result.nodes=nodes.filter(n=>nodeIds.includes(n.id));if(inferred&&matches.length>16)result.selection={returned:16,total:matches.length,remainingIds:matches.slice(16).map(n=>n.id),hint:'Pass nodeIds to inspect additional nodes.'};}
17
+ else result.nodes=(query.trim()?matches.slice(0,16):nodes).map(n=>({id:n.id,type:n.type,...locations.get(n.id),editableProps:componentFields(n.type).filter(k=>!['id','type','children','bind','action'].includes(k)),...Object.fromEntries(['title','label','text','bind'].filter(k=>typeof n[k]==='string').map(k=>[k,n[k].slice(0,160)])),...(n.type==='sandbox'?{sourceCharacters:{html:n.html.length,css:n.css?.length??0,script:n.script?.length??0}}:{})}));
13
18
  return result;
14
19
  }
@@ -13,6 +13,5 @@ export function motionCatalog(id){
13
13
  const recipe=MOTION_RECIPES.find(r=>r.id===id);if(!recipe)throw Error('Unknown motion recipe; choose '+MOTION_RECIPES.map(r=>r.id).join(', '));
14
14
  return{...recipe,document:validateDocument(recipe.document),guidance:'All feedback follows actual state/results. Native controls and graph transitions are built in; no extra JS is needed. Preview is read-only until turn end. Do not copy sample prices as real data or simulate success with a timer.'};
15
15
  }
16
- export const MOTION_GUIDE='Use motion to explain an actual change or confirm a user action, not to decorate every answer. Across ALL visual styles and free mode, native primary link/button uses gradient expansion, magnetic hover and rolling labels; numeric input shows a stepper, toggle stretches and can be dragged, and metric changes roll vertically. Retrieve motion scenes action/quantity/elastic for these patterns. Native select supports variant:"segmented" for 2..8 short options (more options use dropdown); sliders show filled value tracks, toggles and copy buttons have built-in feedback. Values are exact immediately, graphics may transition briefly; do not animate fake progress or declare success before completion. ui_catalog({topic:"motion",scene:"plan"|"checklist"|"copy"|"plot"}) retrieves one small working example when needed. Respect reduced motion and keep static text stable; no library or source checkout search is required.';
17
-
18
- export const MOTION_INTERFACE='Native controls have built-in interaction feedback in every style and free mode: primary button/link hover fill and rolling label, draggable elastic toggle, numeric input stepper, exact metric update feedback, segmented select, slider fill and real copy confirmation. Native feedback respects reduced motion. Canvas animation is model-authored; the host does not rewrite its source. No visual palette or layout is imposed.';
16
+ export const MOTION_GUIDE='Native controls already provide local feedback: segmented select for2..8 short options, slider fill, numeric stepper, elastic toggle, button/link hover and real copy confirmation. Values stay exact immediately; related graphics may transition. No custom script is required for native feedback. For richer motion retrieve one motion recipe; choose animation that explains a real change, honor reduced motion, never simulate task success.';
17
+ export const MOTION_INTERFACE='Native control feedback is built in and respects reduced motion; canvas animation is model-authored. No palette or layout is imposed.';
@@ -15,6 +15,7 @@ function number(v) {
15
15
  const boolean = v => v === 'true' ? true : v === 'false' ? false : v;
16
16
  function expression(e, numeric = false, logical = false) {
17
17
  if (!object(e)) return e;
18
+ if (Object.keys(e).length===1 && own(e,'expr') && typeof e.expr==='number' && Number.isFinite(e.expr)) { e.const=e.expr;delete e.expr; }
18
19
  if (own(e,'const')) {
19
20
  if (numeric) e.const = number(e.const);
20
21
  if (logical) e.const = boolean(e.const);
@@ -29,6 +30,7 @@ export function normalizeAuthoring(doc) {
29
30
  if (object(doc)&&!own(doc,'nodes')&&(own(doc,'items')||typeof doc.type==='string')) doc=visualDocument(doc);
30
31
  if (!object(doc)) return doc;
31
32
  if (own(doc,'version')) doc.version = number(doc.version);
33
+ if(object(doc.presentation)&&doc.presentation.layout==='flow'&&(!own(doc.presentation,'preset')||doc.presentation.preset==='flow')){doc.presentation.layout='guide';doc.presentation.preset='flow';}
32
34
  if (object(doc.state)) for (const k of Object.keys(doc.state)) {
33
35
  let s = doc.state[k];
34
36
  // Decode a single JSON object only where a state specification is required.
@@ -54,6 +56,12 @@ export function normalizeAuthoring(doc) {
54
56
  if (!Array.isArray(list)) return list;
55
57
  for (const node of list) {
56
58
  if (!object(node)) continue;
59
+ // Code always has a copy button. false is conflicting intent and stays invalid.
60
+ if(node.type==='code'&&(node.copyable===true||node.copyable==='true'))delete node.copyable;
61
+ if(['slider','input'].includes(node.type)&&typeof node.bind==='string'&&node.bind.startsWith('state.')){
62
+ const spec=doc.state?.[node.bind.slice(6)];
63
+ if(spec?.type==='number')for(const field of ['min','max','step'])if(own(node,field)&&number(node[field])===spec[field])delete node[field];
64
+ }
57
65
  if(['text','heading','callout'].includes(node.type)&&own(node,'content')){
58
66
  // Collapse aliases only when no conflicting content would be lost.
59
67
  if(!own(node,'text')){node.text=node.content;delete node.content}
@@ -107,6 +115,7 @@ export function normalizeAuthoring(doc) {
107
115
  if (node.type === 'details' && own(node,'open')) node.open = boolean(node.open);
108
116
  if(node.type==='text'&&own(node,'copyable'))node.copyable=boolean(node.copyable);
109
117
  if(node.type==='code'&&own(node,'wrap'))node.wrap=boolean(node.wrap);
118
+ if(['table','chart'].includes(node.type)&&Array.isArray(node.rows))for(const row of node.rows)if(object(row))for(const cell of Object.values(row))expression(cell);
110
119
  for (const field of ['text','value','max','rows']) if (own(node,field)) expression(node[field],node.type === 'progress');
111
120
  if (['row','column','card','details'].includes(node.type) && own(node,'children')) node.children = nodes(node.children);
112
121
  }
@@ -0,0 +1,22 @@
1
+ import {AUTHORING_GUIDE} from './authoring-guide.js';
2
+ // Shared interface only; guided mode adds design/style, free mode does not.
3
+ export const TOOL_DIRECTORY=`Motion UI entries (native calls, never inside PTC/run_code): render_scene=flow/comparison/allocation/budget from short task data; render_text=readable text/lists/comparisons/copyable text/code/links; render_ui=native state/formulas or extended visuals; render_canvas=custom HTML/SVG/JS; render_mechanism=assembly/gears/crank with generated controls (driver16 teeth, rod70). Static explanations are complete UIs too. Optional short entry render_scene: flow/comparison items; allocation total/unit/items[{label,percent}] sum100; budget values:{people,nights,roomRate,transportPerPerson}, two adults/room. Explicitly label assumed prices. Other content stays custom.
4
+ ui_catalog is optional: text=typography, native=full v1 protocol, visual=extended fields, canvas=API/design/examples, style=selected guided style and examples, templates=scene examples, motion=interaction recipes, mechanism=custom part API. Prefer one targeted topic/scene, not all. No installation-source search is required; file/search tools remain available for the user's actual task. validate_ui checks without displaying a UI; if used, require valid:true AND complete:true before rendering. A successful render still delivers the UI.`;
5
+ export const NATIVE_CONTRACT=`Native v1: render_ui({document:{version:1,title,fallback,nodes:[],state?:{},data?:{},derived?:{},actions?:{},presentation?:{layout,accent,density,preset}}}). IDs: unique Unicode-letter/underscore identifiers, max64, then letters/digits/underscores; no constructor/prototype/__proto__. presentation.layout ONLY dashboard/workbench/comparison/guide/status/report; omit for Host defaults. Typography presets are separate: flow/reading/list are NOT layouts (flow uses layout:"guide" or render_text preset:"flow").
6
+ render_text({title?,preset?,accent?,blocks:[]}) accepts strings/native nodes with auto IDs. Presets reading/brief/comparison/list/callout/scorecard/flow/minimal. text/heading:{text OR spans:[{text,size?,weight?,color?}],size?:12..48|sm/md/lg/xl/hero,weight?:400|500|600|700|800,color?:default/muted/accent/success/warning/danger/violet|hex,align?,lineHeight?:1.2..2.4}. Reusable text: copyable:true,title?. code:{type:"code",code,lang?,title?,wrap?}, copied literally, never executed. link/button:{text,href:http(s) OR path:real local file,variant?:primary/secondary/text}; opens only on click, no command/custom URI in path. Native text is not HTML.
7
+ Native nodes {id,type,...}: row/column/card use children:[nodes]; row.columns:1..4, gap:0..40; card.title optional. metric:{label,value:number|string|boolean|binding,unit?,decimals?:0..6}. table:{columns:[{key,label,unit?}],rows:[objects]|binding,label?}. chart:{kind:"bar"|"line",rows,labelKey,valueKey}; series are numeric. steps:{items:[{title,description?,status?:pending|active|done|error}]} has1..12 direct items; omit status for explanation. Extended root {title,items:[components]} is separate, never mixed with version/nodes; native text uses text, extended uses content. Query visual fields when needed; multi-question grading uses radio + one submit for all groups.
8
+ State keys: number={type:"number",default,min,max,step}; string={type:"string",default,options?:[scalars]}; boolean={type:"boolean",default}; json={type:"json",default:[]|{}} (16KiB/value, canvas structured state). slider/input:{id,type,label,bind:"state.key"}; bounds belong in state, never slider/input nodes. select/toggle also bind state; select needs options. Declared state creates NO controls. Canvas stateKeys are bare keys.
9
+ Derived:{total:{expr:"state.quantity * 12"}}; bindings {ref:"derived.total"} or {expr:"..."}. Supports state/data/derived refs, + - * /, comparisons, condition ? yes : no, min/max/round/ceil/floor/abs. round/ceil/floor/abs each take ONE argument; round uses Math.round, ties toward positive infinity. ceil(state.people / 2) handles room counts. expr must be a STRING (literal 100 or {expr:"100"}, never {expr:100}). Numeric add cannot concatenate text; use metric label/unit. No String()/toFixed()/pow/trig/arbitrary JS; use a canvas. Prevent zero denominators/non-finite results. decimals only formats display.
10
+ Chart/table rows may contain formula cells: [{label:"住宿",value:{expr:"ceil(state.people / 2) * state.room"}}]. data is literal. Complex many-row maps are in native catalog; no invented callbacks/operators. Share calculation sources, units and rounding. Agent follow-up: actions:{key:{type:"agent.followup",intent,stateKeys:[bare keys]}}, button.action:"key" requests a new turn; it does not execute host tools.`;
11
+ export const CANVAS_CONTRACT=`Canvas: render_canvas({title,fallback,html,css?,script?,height?:160..1000,libraries?:["mechanism"],state?,data?,derived?}). html is a body fragment, CSS in css, JS in script using addEventListener. Each source field max49152 characters; whole document128KiB. Inline SVG/system fonts work; external images/fonts/imports/network, eval, inline handlers and host APIs do not. Origin isolation is not a CPU/memory hard limit; avoid unbounded/heavy loops. A v1 sandbox node adds id,type:"sandbox",title,fallback,html/css/script and stateKeys/dataKeys/derivedKeys:[bare keys].
12
+ DSHUI.snapshot/subscribe(fn) expose {state,data,derived,disabled,visible,theme}; subscribe supplies current values immediately and returns cleanup. DSHUI.setState({key:value}) validates/persists declared user changes. Build actual labelled controls for editable parameters; subscribe updates displays, not inputs. Never echo subscribe changes or save each animation frame. Read shared derived, don't recalculate it independently. DSHUI.formatNumber(number,decimals=2) matches native rounding/grouping (0..6 decimals).
13
+ DSHUI.animate(({time,delta,reducedMotion,visible})=>...) returns cleanup; seconds, delta<=0.05. Integrate delta only while running; offscreen/background pauses, reduced motion gets a useful static frame. State changes preserve iframe; source replacement/reset disposes it and may reset transient phase. Important parameters persist. Theme vars --canvas-bg/soft/ink/muted/line/accent and snapshot.theme.scheme follow Host. Content determines height; SVG viewBox fits graphics. Keyboard controls, readable labels and fallback remain useful without animation. Optional .ui-* helpers (ui-edge, ui-label, ui-controls) are documented in canvas catalog.
14
+ Declare libraries:["mechanism"] for DSHUI.mechanism.mount/mountDemo/gearPair/sliderCrank/follow; query mechanism for custom parts/bindings. render_mechanism needs no source/catalog lookup. Educational geometry/kinematics only, not force/contact/collision/manufacturing validation.`;
15
+ export const DELIVERY_CONTRACT=`Presentation mode: inline. Complete content previews read-only; scripts/controls activate after the whole assistant turn ends and parameter restoration. Tool success saves the UI; continue without waiting/polling the preview. Multiple documents may accompany one reply. After each successful render_text/render_ui/render_canvas/render_mechanism/render_scene insert its returned documentId once at the intended position:
16
+ \`\`\`dsh-ui
17
+ <returned documentId>
18
+ \`\`\`
19
+ Never invent IDs or repeat the same UI source/content elsewhere. Extended JSON dsh-ui/motion-ui source fences or standalone svg fences are alternative paths; choose one path per UI. Do not end with only a promise.
20
+ Edit: inspect_ui for revision/state/derived/observations; absent report means unobserved, not passed. Do not poll empty reports or claim unperformed checks. Find the requested part with query:text/label/type (returns IDs/paths/positions); use detail:"nodes" with query OR nodeIds for source edits. Patch that node instead of rebuilding the whole UI. patch_ui changes existing scalar/source props; update_ui changes structure/parameters with stable operationId. Preserve original documentId/marker and compatible values. Conflicts require reread; identical retries reuse operationId.
21
+ Correct the named field in the complete original document, not probes/partial renders. Automatic correction is at most two rounds per user request; then simplify or explain remaining failure with a useful answer. New user edits start a new request. This is a behavior budget, not a reasoning/output/token/time limit. Never invent measurements, sources, progress or action success. UI/animation is not tool execution evidence; host/external actions follow normal DSH permissions.`;
22
+ export const MODEL_CONTRACT=[AUTHORING_GUIDE,NATIVE_CONTRACT,CANVAS_CONTRACT,DELIVERY_CONTRACT,TOOL_DIRECTORY].join('\n\n');
package/lib/protocol.js CHANGED
@@ -226,7 +226,7 @@ export function validateDocument(input) {
226
226
  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');
227
227
  }
228
228
  const ids = new Map(); let count = 0;
229
- 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}`); };
229
+ const value = (v, at) => { if (object(v)) { if(own(v,'map'))fail('map belongs in derived');validateExpression(v,0,undefined,at); } else if (!scalar(v)) fail(`invalid value at ${at}`); };
230
230
  function nodes(list, depth = 0, path = 'nodes') {
231
231
  if (!Array.isArray(list)) fail(path==='nodes' ? 'document.nodes is missing or is not an array. State/derived define values but do not create visible UI. Include nodes:[{id:"heading",type:"heading",text:"..."},...] and actual controls in the SAME complete document; do not resend state/data/derived alone. Declared state keys: '+Object.keys(doc.state).join(', ') : path+' must be an array of child nodes; use children:[{id,type,...}]');
232
232
  if (depth > LIMITS.depth) fail(path+' exceeds maximum node depth '+LIMITS.depth);
@@ -310,12 +310,12 @@ export function validateDocument(input) {
310
310
  case 'divider': if(n.label!==undefined)text(n.label,'divider label');break;
311
311
  case 'table': {
312
312
  if (n.label !== undefined) text(n.label, 'table label');
313
- if (!Array.isArray(n.rows)) value(n.rows, 'table rows');
313
+ if (!Array.isArray(n.rows)) value(n.rows, 'node.'+n.id+'.rows');
314
314
  if (!Array.isArray(n.columns) || !n.columns.length || n.columns.length > 16) fail('invalid table columns');
315
315
  const cols = new Set();
316
316
  for (const c of n.columns) { fields(c, ['key', 'label', 'unit'], 'column'); if (!key(c.key) || cols.has(c.key)) fail('invalid or duplicate column key'); cols.add(c.key); text(c.label, 'column label'); if (c.unit !== undefined) text(c.unit, 'column unit'); } break;
317
317
  }
318
- case 'chart': if (!Array.isArray(n.rows)) value(n.rows, 'chart rows'); if (!['bar', 'line'].includes(n.kind) || !key(n.labelKey) || !key(n.valueKey)) fail('invalid chart'); text(n.label, 'chart label'); if (n.unit !== undefined) text(n.unit, 'chart unit'); if (n.xScale !== undefined && (!['category', 'linear'].includes(n.xScale) || n.xScale === 'linear' && n.kind !== 'line')) fail('invalid xScale'); break;
318
+ case 'chart': if(n.rows===undefined)fail('node.'+n.id+'.rows is missing; supply rows:[{label:"项目",value:123}] or a rows binding, plus labelKey/valueKey matching its fields. Do not invent missing amounts'); if (!Array.isArray(n.rows)) value(n.rows, 'node.'+n.id+'.rows'); if (!['bar', 'line'].includes(n.kind) || !key(n.labelKey) || !key(n.valueKey)) fail('invalid chart'); text(n.label, 'chart label'); if (n.unit !== undefined) text(n.unit, 'chart unit'); if (n.xScale !== undefined && (!['category', 'linear'].includes(n.xScale) || n.xScale === 'linear' && n.kind !== 'line')) fail('invalid xScale'); break;
319
319
  default: fail(`unsupported component ${n.type}`);
320
320
  }
321
321
  if (['heading', 'text','callout', 'card', 'metric', 'button','link', 'badge', 'progress', 'details'].includes(n.type)) {
package/lib/refinement.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { componentFields } from './components.js';
2
2
  import { validateDocument } from './protocol.js';
3
- export const AUDIT_MESSAGES = Object.freeze({ overflow: '内容超出视口宽度', smallText: '文字实际显示尺寸小于 12px', contrast: '文字与背景对比度偏低', missingLabel: '交互控件缺少可访问名称',runtimeError:'画布报告运行异常' });
3
+ export const AUDIT_MESSAGES = Object.freeze({ overflow: '内容超出视口宽度', smallText: '文字实际显示尺寸小于 12px', contrast: '文字与背景对比度偏低', missingLabel: '交互控件缺少可访问名称',runtimeError:'画布报告运行异常',clippedText:'文字可能被隐藏或裁切',bottomWhitespace:'内容下方存在明显空白' });
4
4
  export function refineDocument(document, changes) {
5
5
  if (!Array.isArray(changes) || !changes.length || changes.length > 32) throw new Error('DSH UI: changes requires 1..32 node edits');
6
6
  const next = structuredClone(document), nodes = new Map();
@@ -26,6 +26,7 @@ export function validateAudit(value){
26
26
  });
27
27
  return {width:value.width,theme:value.theme,coverage:checkedCoverage,issues:value.issues.map(i=>{
28
28
  if(!i || !Object.hasOwn(AUDIT_MESSAGES,i.code) || typeof i.nodeId!=='string' || i.nodeId.length>64 || (i.value!==undefined && (!Number.isFinite(i.value)||Math.abs(i.value)>100000)))throw new Error('DSH UI: invalid audit issue');
29
- return {code:i.code,nodeId:i.nodeId,message:AUDIT_MESSAGES[i.code],...(i.value===undefined?{}:{value:i.value})};
29
+ let target;if(i.target!==undefined){const t=i.target;if(!t||typeof t!=='object'||Array.isArray(t)||Object.keys(t).some(k=>!['selector','text','tag'].includes(k))||['selector','text','tag'].some(k=>typeof t[k]!=='string'||t[k].length>({selector:256,text:120,tag:32}[k])))throw new Error('DSH UI: invalid audit target');target={selector:t.selector,text:t.text,tag:t.tag};}
30
+ return {code:i.code,nodeId:i.nodeId,message:AUDIT_MESSAGES[i.code],...(target?{target}:{}),...(i.value===undefined?{}:{value:i.value})};
30
31
  })};
31
32
  }
@@ -0,0 +1,41 @@
1
+ import{validateDocument,LIMITS}from'./protocol.js';
2
+ const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
3
+ const fail=(at,message)=>{throw Error('DSH UI: '+at+' '+message)};
4
+ const fields=(v,allowed,at)=>{if(!object(v))fail(at,'must be an object');for(const k of Object.keys(v))if(!allowed.includes(k))fail(at+'.'+k,'is not supported; use '+allowed.join(', '))};
5
+ const text=(v,at)=>{if(typeof v!=='string'||!v.trim()||v.length>8192)fail(at,'must be non-empty text of at most8192 characters');return v};
6
+ const num=(v,at,min,max,integer=false)=>{if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max||(integer&&!Number.isInteger(v)))fail(at,`must be ${integer?'an integer':'a number'} in ${min}..${max}; supply actual or explicitly assumed values`);return v};
7
+ const metric=(id,label,ref,unit='',decimals=0)=>({id,type:'metric',label,value:{ref:'derived.'+ref},unit,decimals});
8
+ const state=(value,max,step=1)=>({type:'number',default:value,min:0,max,step});
9
+ export const SCENE_IDS=Object.freeze(['flow','comparison','allocation','budget']);
10
+ export const SCENE_PARAMETERS={scene:{type:'string',required:true,enum:SCENE_IDS},title:{type:'string'},items:{type:'json',description:'flow: [{title,description?}] 1..12 (titles omit step numbers; Host numbers them); comparison: [{title,description?,value?,unit?,points?:[text]}] 2..4; allocation: [{label,percent}] 2..8, sum100. No fabricated facts.'},total:{type:'number',description:'allocation: positive initial total, max100000, up to3 decimals.'},unit:{type:'string',description:'allocation total unit; default 分钟.'},values:{type:'json',description:'budget requires {people:integer1..100,nights:integer1..365,roomRate:number0..100000,transportPerPerson:number0..100000}. Two adults per room, ceil(people/2). Monetary values max2 decimals; assumed values must be labeled in title or explanation.'}};
11
+ export function sceneDocument(args={}){
12
+ // Reject excessive input before traversing, including unsupported extras.
13
+ if(new TextEncoder().encode(JSON.stringify(args)).length>LIMITS.bytes)fail('render_scene','exceeds128KiB');
14
+ fields(args,['scene','title','items','total','unit','values'],'render_scene');
15
+ if(!SCENE_IDS.includes(args.scene))fail('scene','must be '+SCENE_IDS.join('/'));
16
+ const used={flow:['items'],comparison:['items'],allocation:['items','total','unit'],budget:['values']}[args.scene];
17
+ for(const k of Object.keys(args))if(!['scene','title',...used].includes(k))fail(k,'does not apply to '+args.scene);
18
+ const doc={version:1,title:args.title??{flow:'步骤指引',comparison:'方案对比',allocation:'时间与数量分配',budget:'旅行预算估算'}[args.scene],fallback:'',nodes:[]};text(doc.title,'title');
19
+ if(args.scene==='flow'||args.scene==='comparison'||args.scene==='allocation'){
20
+ const [min,max]=args.scene==='flow'?[1,12]:args.scene==='comparison'?[2,4]:[2,8];
21
+ if(!Array.isArray(args.items)||args.items.length<min||args.items.length>max)fail('items',`requires a direct array of ${min}..${max} complete objects`);
22
+ args.items.forEach((v,i)=>{fields(v,args.scene==='flow'?['title','description']:args.scene==='comparison'?['title','description','value','unit','points']:['label','percent'],'items['+i+']');text(v[args.scene==='allocation'?'label':'title'],'items['+i+'].'+(args.scene==='allocation'?'label':'title'));if(v.description!==undefined)text(v.description,'items['+i+'].description')});
23
+ }
24
+ if(args.scene==='flow'){
25
+ doc.presentation={layout:'guide',preset:'flow'};doc.nodes=[{id:'steps',type:'steps',items:structuredClone(args.items)}];doc.fallback=args.items.map((v,i)=>(i+1)+'. '+v.title+(v.description?':'+v.description:'')).join('\n');
26
+ }else if(args.scene==='comparison'){
27
+ doc.presentation={layout:'comparison',preset:'comparison'};doc.nodes=[{id:'options',type:'row',columns:args.items.length,children:args.items.map((v,i)=>{const children=[];if(v.description)children.push({id:'description_'+i,type:'text',text:v.description});if(v.value!==undefined){if(!['number','string','boolean'].includes(typeof v.value))fail('items['+i+'].value','must be scalar');if(v.unit!==undefined)text(v.unit,'items['+i+'].unit');children.push({id:'value_'+i,type:'metric',label:v.title,value:v.value,...(v.unit?{unit:v.unit}:{})})}else if(v.unit!==undefined)fail('items['+i+'].unit','requires a value');if(v.points!==undefined){if(!Array.isArray(v.points)||v.points.length>8)fail('items['+i+'].points','requires up to8 texts');v.points.forEach((t,j)=>children.push({id:'point_'+i+'_'+j,type:'text',text:text(t,'items['+i+'].points['+j+']')}))}if(!children.length)fail('items['+i+']','needs description, value or points, not only a title');return{id:'option_'+i,type:'card',title:v.title,children}})}];doc.fallback=args.items.map(v=>[v.title,v.description,v.value===undefined?'':String(v.value)+(v.unit??''),...(v.points??[])].filter(Boolean).join(';')).join('\n');
28
+ }else if(args.scene==='allocation'){
29
+ const total=num(args.total,'total',.001,100000);if(Math.abs(total*1000-Math.round(total*1000))>1e-7)fail('total','supports up to3 decimals');const unit=args.unit??'分钟';text(unit,'unit');let sum=0;args.items.forEach((v,i)=>sum+=num(v.percent,'items['+i+'].percent',0,100));if(Math.abs(sum-100)>1e-6)fail('items.percent','must sum to100; received '+sum);
30
+ doc.presentation={layout:'workbench'};doc.state={total:state(total,Math.max(total*3,100),Number.isInteger(total)?1:.001)};doc.derived=Object.fromEntries(args.items.map((v,i)=>['part_'+i,{expr:'state.total * '+v.percent+' / 100'}]));
31
+ doc.nodes=[{id:'total',type:'slider',label:'总量('+unit+')',bind:'state.total'},{id:'allocation',type:'row',columns:Math.min(args.items.length,4),children:args.items.map((v,i)=>metric('part_'+i,v.label,'part_'+i,unit,2))},{id:'distribution',type:'chart',kind:'bar',label:'分配结果',unit,labelKey:'label',valueKey:'value',rows:args.items.map((v,i)=>({label:v.label,value:{ref:'derived.part_'+i}}))}];doc.fallback='初始总量 '+total+unit+';'+args.items.map(v=>v.label+' '+v.percent+'%').join(',')+'。调节总量后按相同比例分配。';
32
+ }else{
33
+ fields(args.values,['people','nights','roomRate','transportPerPerson'],'values');const v=args.values;num(v.people,'values.people',1,100,true);num(v.nights,'values.nights',1,365,true);for(const k of['roomRate','transportPerPerson']){num(v[k],'values.'+k,0,100000);if(Math.abs(v[k]*100-Math.round(v[k]*100))>1e-6)fail('values.'+k,'supports up to2 decimal places')}
34
+ doc.presentation={layout:'workbench'};doc.state={people:{...state(v.people,100),min:1},nights:{...state(v.nights,365),min:1},roomRate:state(v.roomRate,100000,.01),transportPerPerson:state(v.transportPerPerson,100000,.01)};
35
+ doc.derived={rooms:{expr:'ceil(state.people / 2)'},lodging:{expr:'derived.rooms * state.nights * state.roomRate'},transport:{expr:'state.people * state.transportPerPerson'},total:{expr:'derived.lodging + derived.transport'},lodgingShare:{expr:'derived.total > 0 ? round(derived.lodging / derived.total * 1000) / 10 : 0'},transportShare:{expr:'derived.total > 0 ? round(derived.transport / derived.total * 1000) / 10 : 0'}};
36
+ doc.nodes=[{id:'parameters',type:'card',title:'调整参数',children:Object.entries({people:'人数(人)',nights:'入住晚数(晚)',roomRate:'每房每晚(元)',transportPerPerson:'每人交通(元)'}).map(([k,label])=>({id:k,type:k==='people'||k==='nights'?'slider':'input',label,bind:'state.'+k}))},{id:'totals',type:'row',columns:3,children:[metric('rooms','房间数','rooms','间'),metric('lodging','住宿','lodging','元',2),metric('total','总计','total','元',2)]},{id:'costs',type:'table',columns:[{key:'label',label:'项目'},{key:'value',label:'金额',unit:'元'},{key:'share',label:'占比',unit:'%'}],rows:[{label:'住宿',value:{ref:'derived.lodging'},share:{ref:'derived.lodgingShare'}},{label:'交通',value:{ref:'derived.transport'},share:{ref:'derived.transportShare'}}]},{id:'distribution',type:'chart',kind:'bar',label:'住宿与交通',unit:'元',labelKey:'label',valueKey:'value',rows:[{label:'住宿',value:{ref:'derived.lodging'}},{label:'交通',value:{ref:'derived.transport'}}]},{id:'basis',type:'text',text:'两位成人一间房,房间数向上取整;住宿 = 房间数 × 入住晚数 × 每房每晚,交通按人数计算。只计算输入的住宿与交通,未含其他费用;输入价格由用户或回答提供。'}];doc.fallback='按输入价格估算住宿与交通:'+v.people+'人,'+v.nights+'晚,房价'+v.roomRate+'元,每人交通'+v.transportPerPerson+'元。两位成人一间房,房间数向上取整;不是实时价格或报价。';
37
+ }
38
+ return validateDocument(doc);
39
+ }
40
+ export function previewScene(raw){try{if(typeof raw!=='string'||raw.length>LIMITS.bytes)return null;return sceneDocument(JSON.parse(raw))}catch{return null}}
41
+ export function sceneReference(){return{topic:'quick',tool:'render_scene',scenes:SCENE_IDS,examples:[{scene:'flow',items:[{title:'明确目标'},{title:'交付结果'}]},{scene:'comparison',items:[{title:'方案A',description:'适合日常使用'},{title:'方案B',description:'适合深入分析'}]},{scene:'allocation',total:60,unit:'分钟',items:[{label:'阅读',percent:40},{label:'练习',percent:40},{label:'复盘',percent:20}]},{scene:'budget',title:'假设旅行预算',values:{people:3,nights:3,roomRate:600,transportPerPerson:800}}],note:'Examples are illustrative, not measured data. Use supplied/explicitly assumed task values. Full custom UI remains available via render_ui/render_canvas.'}}
@@ -8,7 +8,7 @@ export const STYLE_SYSTEM=Object.freeze({
8
8
  prismframe:{description:'应用模块 · 实色控件 · 偏移底面',grammar:'明亮扁平的轻量应用',composition:'像可直接使用的轻量应用:任务标题、输入模块、结果模块、解释区职责清楚。模块使用稳定网格,操作靠近对应结果;比较、流程和数据看板各自采用合适结构,不固定成同一套卡片。',typography:'圆润清晰的系统字体、明确模块标题、中等大数字;数字绑定名称和单位,图形承担关系说明。',surfaces:'浅彩底、白色实心模块、细彩色边线和扁平偏移底面;局部多色用来区分职责,避免每块同色同形。',controls:'整齐且有面积的实色表单行、清晰状态标签,主要操作有轻微偏移底面;不是磨砂玻璃。',avoid:'避免文档式满屏细线、海报式超大字、透明层叠、视频页码和字幕、重复标题。',native:{title:26,weight:780,gap:20,radius:24,metric:36,feedback:320,gain:.65}},
9
9
  aurora:{description:'产品层级 · 暖白曲面 · 局部渐彩',grammar:'柔光渐彩的精致产品界面',composition:'像精致的产品面板:暖白开放背景上安排少量实心表面,标题简洁、指标突出、控件顺手。用宽窄和疏密区分主要结果与次要说明;按钮、表单、表格、代码与文字要属于同一套语言。',typography:'近黑标题、清晰的字重差、大而利落的数字、柔和次要说明;不把文字都染成紫色。',surfaces:'暖白实色、24..30px 圆润表面、柔和投影、局部紫蓝青渐彩强调;不把整页铺成紫面板。',controls:'圆润内嵌输入、胶囊选择器、深色主操作与渐彩图标圆点;真实操作触发渐变扩散和弹性反馈。',avoid:'禁止玻璃/backdrop-filter、泛白低对比度、仅加一个演示按钮就算风格、空闲漂浮。',native:{title:28,weight:780,gap:24,radius:28,metric:42,feedback:460,gain:1}}
10
10
  });
11
- export function grammarPrompt(id){const g=STYLE_SYSTEM[id];return `视觉语言:${g.grammar}\n构图:${g.composition}\n文字层级:${g.typography}\n表面与边界:${g.surfaces}\n控件与反馈:${g.controls}\n避免:${g.avoid}\n同一任务在此风格下应具有可辨识的层级与构图,不能只换强调色。所有风格共用原生交互能力;动画只回应真实操作、支持 reduced motion。保持内容完整和 DOM 阅读顺序,不为了风格重排计算含义。用户明确指定的尺寸、颜色与布局优先。禁止玻璃材质。示例演示构图,不要求每次输出所有模块;简单需求用简短正文或一组数字,不能为了展示风格扩写内容。`;}
11
+ export function grammarPrompt(id){const g=STYLE_SYSTEM[id];return `视觉语言:${g.grammar}\n构图:${g.composition}\n文字层级:${g.typography}\n表面与边界:${g.surfaces}\n控件与反馈:${g.controls}\n避免:${g.avoid}\n同一任务应有可辨识的构图与层级,不能只换强调色。保持内容和DOM阅读顺序。原生交互共用,节奏随风格变化;禁止玻璃。示例展示语言,不要求每次使用全部模块。`;}
12
12
  // Same facts, intentionally different content composition. Model examples are
13
13
  // complete small documents; preview uses the same grammar without live controls.
14
14
  export function grammarExample(id){
@@ -25,4 +25,4 @@ export function grammarExample(id){
25
25
  else if(id==='prismframe'){blocks=[heading,body,{type:'row',columns:2,children:choices},{type:'row',columns:2,children:metrics},{type:'card',title:'行动路径',children:[flow]},note]}
26
26
  else{blocks=[heading,body,{type:'row',columns:2,children:metrics},{type:'row',columns:2,children:choices},flow,note]}
27
27
  return {tool:'render_text',args:{title:'把复杂问题讲清楚',preset,blocks}};
28
- }
28
+ }
package/lib/text-guide.js CHANGED
@@ -1,13 +1,9 @@
1
- export const TEXT_GUIDE=`## 日常对话的轻量排版
2
- 日常解释、建议、摘要、对照和资料入口可以直接用 render_text,不需要用户提醒“做 UI”,也不必写 HTML/CSS/JS。只有排版确实更易读时使用;一句话能说清的事实仍可直接回答。不要为了使用 UI 把短回答扩成看板、加无用交互或重复标题。用户要求多块内容时逐块交付,首次工具成功不代表整个任务完成;提交结论前核对是否遗漏用户指定的流程、对比、按钮或文件入口。
3
- render_text({title?:string,preset?:string,accent?:string,blocks:[string|node,...]})。纯字符串是一段文字;blocks 中的节点可省略 id,嵌套 children 的 id 也会自动补齐。render_ui 仍要求显式 id。state/data/derived/actions 不属于 render_text;需要本地计算时用 render_ui,需要自绘动画时用 render_canvas。
4
- 预设:reading 自然正文(无外框);brief 轻边框面板;comparison 并排比较;list 图标/标题/说明与分隔线;callout 少量强调;scorecard 标签与数值两端对齐;flow 纵向流程;minimal 最少留白。根据当前这一块内容选择,八种可在同一回复混排,不必统一用卡片。
5
- text/heading 支持 size(12..48px 或 sm/md/lg/xl/hero)、weight(400/500/600/700/800)、color(default/muted/accent/success/warning/danger/violet 或 #RGB/#RRGGBB)、align(left/center/right)、lineHeight(1.2..2.4)。正文通常 16..18px,标题 20..26px,脚注 14px;不用过小字号或整段彩色。主题色令牌自动适应深浅主题,自选十六进制色需要兼顾两种主题的对比度。局部强调用 spans:[{text:"普通文字"},{text:"关键结论",weight:700,color:"success"}] 替代 text,不能同时提供两者。只接受声明式字段,不输出 style、className、CSS 或 HTML。
6
- row 的 columns:1..4 可指定等宽列,gap:0..40 控制间距;column 也支持 gap。divider 可选 label。card 用于真实的信息分组;不要求每段文字包一层卡片。按钮使用 link:{type:"link",text,href:"https://...",variant:"primary"|"secondary"|"text"},本地文件用 path 替代 href。网页只接受 http/https;path 是文件路径,不能放命令、file: URL 或自定义启动协议。按钮只在用户点击后打开,文件走 DSH 原生 opener;打开文件不能伪称执行了脚本或工具。
7
- 同一回复可以多次 render_text/render_ui/render_canvas:每块各自保存文档、参数、主题和画布;相同节点 id 可在不同文档重复,单个文档内仍须唯一。按正文讲解顺序提交,多块小而完整的 UI 比一个巨大应用更适合混排。不要为了多样式复制相同内容。inline 模式下,每次成功结果都有自己的 documentId,各自放一个 fenced dsh-ui 标记到对应段落;同一个 ID 只标记一次,不用后一个结果覆盖前一个。生成期间逐步显示经过校验的完整内容;HTML/SVG 先提供静态预览,整个回复结束后恢复参数并启用交互。优先把 title、fallback、css 放在 html 前,原生 nodes/blocks 按阅读顺序输出,使预览更早可读。不要等待预览,也不要为了预览增加工具调用。
8
- 可直接复用的文案、提示词、邮件正文用 text 的 copyable:true,并可加 title;右上复制按钮只复制正文,spans 的样式不会混进剪贴板。代码、命令和配置用 {type:"code",lang:"bash",code:"git status",title?:string,wrap?:boolean};代码默认提供右上复制按钮,原样保留缩进、换行和反引号,只展示不执行。长行默认横向滚动,wrap:true 可换行。不必给每段普通讲解加复制框;用户需要直接使用的内容才用。不要为了可复制再把同一段内容在正文重复一遍。
9
- 最小示例:render_text({preset:"reading",blocks:[{type:"heading",text:"先看结论"},{type:"text",spans:[{text:"优先保证 "},{text:"可读性",weight:700,color:"accent"},{text:",再补充必要细节。"}]}]})。
10
- 对比示例:render_text({title:"两种推理方案(示意)",preset:"comparison",blocks:[{type:"row",columns:2,children:[{type:"column",children:["方案 A · 大 MoE",{type:"text",text:"30 tok/s",size:26,weight:700},"完成任务 180 秒"]},{type:"column",children:["方案 B · Dense",{type:"text",text:"10 tok/s",size:26,weight:700},"完成任务 120 秒"]}]},{type:"text",text:"示意数据,非模型实测。",size:14,color:"muted"}]})。
11
- 入口示例:render_text({preset:"minimal",blocks:[{type:"row",children:[{type:"link",text:"查看项目",href:"https://github.com/Scorp1o117/dsh-motion-ui",variant:"primary"},{type:"link",text:"打开报告",path:"report.html",variant:"secondary"}]}]})。文件必须来自当前任务的真实产物,不能臆造路径。`;
12
-
13
- export const TEXT_INTERFACE=`Native lightweight entry: render_text({title?,preset?,accent?,blocks:[]}). blocks are strings or native nodes with auto IDs; presets reading/brief/comparison/list/callout/scorecard/flow/minimal. Text/heading support size 12..48 or sm/md/lg/xl/hero, weight 400/500/600/700/800, color default/muted/accent/success/warning/danger/violet or hex, align left/center/right, lineHeight 1.2..2.4. Reusable text adds copyable:true and title?. Code nodes {type:"code",code:string,lang?,title?,wrap?} always have an upper-right copy button and display literal source without executing it. Inline spans:[{text,weight?,color?,size?}] replaces text. link:{text,href:http(s) URL OR path:local file,variant:primary/secondary/text} opens only on a user click through the Host. Several independent UI documents may accompany one answer, each with its own styling, state and successful documentId marker. No mandatory text preset or visual composition; use plain prose whenever it suffices.`;
1
+ export const TEXT_INTERFACE=`Native lightweight entry: render_text({title?,preset?,accent?,blocks:[]}). blocks are strings or native nodes with auto IDs; presets reading/brief/comparison/list/callout/scorecard/flow/minimal. Text/heading support size 12..48 or sm/md/lg/xl/hero, weight 400/500/600/700/800, color default/muted/accent/success/warning/danger/violet or hex, align left/center/right, lineHeight 1.2..2.4. Reusable text adds copyable:true and title?. Code nodes {type:"code",code:string,lang?,title?,wrap?} always have an upper-right copy button and display literal source without executing it. Inline spans:[{text,weight?,color?,size?}] replaces text. link:{text,href:http(s) URL OR path:local file,variant:primary/secondary/text} opens only on a user click through the Host. Several independent UI documents may accompany one answer, each with its own styling, state and successful documentId marker. No mandatory text preset or visual composition; use plain prose whenever it suffices.`;
2
+ export const TEXT_GUIDE=TEXT_INTERFACE+`
3
+ Preset meanings: reading=open prose; brief=compact panel; comparison=aligned alternatives; list=divided entries; callout=one emphasis; scorecard=label/value alignment; flow=ordered steps; minimal=lightweight text. A preset is per document and does not replace the selected visual style. row.columns1..4 and gap0..40 are optional. Native nodes accept registered fields only; state/derived/actions belong in render_ui.
4
+ Examples (call tool with args):
5
+ `+[
6
+ {tool:'render_text',args:{preset:'flow',blocks:[{type:'steps',items:[{title:'整理文件',description:'保留源码和说明。'},{title:'检查提交',description:'确认内容后提交。'}]}]}},
7
+ {tool:'render_text',args:{blocks:[{type:'text',title:'可复制文案',copyable:true,text:'这是可以直接使用的正文。'},{type:'code',lang:'bash',code:'git status\ngit diff'}]}},
8
+ {tool:'render_text',args:{preset:'minimal',blocks:[{type:'link',text:'查看项目',href:'https://github.com/Scorp1o117/dsh-motion-ui',variant:'primary'}]}}
9
+ ].map(v=>JSON.stringify(v)).join('\n');
@@ -0,0 +1,10 @@
1
+ # Taste Skill design guidance reference
2
+
3
+ Motion UI's compact guidance is an original adaptation of general ideas from
4
+ https://github.com/Leonxlnx/taste-skill: content-driven composition, hierarchy,
5
+ substantive simplicity, useful interactions and honest visual review.
6
+
7
+ Reviewed 2026-10-14: skills/taste-skill/SKILL.md and gpt/soft/minimalist variants.
8
+ No upstream scripts or skill text, randomized website workflow, mandatory imagery,
9
+ marketing structure or fixed aesthetic bans are bundled or executed. Motion UI
10
+ retains its own style selection, sandbox contracts and conversation delivery.
@@ -4,14 +4,14 @@ import {COMPONENTS} from './components.js';
4
4
  const dictionary=description=>({type:'object',additionalProperties:true,description});
5
5
  export const DOCUMENT_PARAMETER={
6
6
  required:true,
7
- description:'Pass one COMPLETE object, never manually serialize or send a partial patch. v1 always needs nodes:[...] beside state/derived; state alone creates no controls. Choose v1 {version:1,title,fallback,nodes} for shared state/formulas/canvas, OR extended {title,items:[components]} for charts/plots/quiz/forms/mermaid/3D. Do not mix the two root languages.',
7
+ description:'Complete v1 {version:1,title,fallback,nodes,state?,derived?} OR extended {title,items:[components]}. No mixed roots, partial patches or serialized JSON. State alone creates no controls.',
8
8
  type:'object',additionalProperties:false,
9
9
  properties:{
10
10
  version:{type:'number',const:1},
11
11
  title:{type:'string',required:true},
12
12
  fallback:{type:'string',description:'Required for v1 nodes: useful plain-text explanation if UI is unavailable.'},
13
- items:{type:'array',description:'Extended visual components: use their compact type-specific fields. See injected guide or ui_catalog topic=visual.',items:{type:'json'}},
14
- nodes:{type:'array',items:{type:'object',additionalProperties:true,properties:{
13
+ items:{type:'array',description:'Extended fields: ui_catalog topic=visual.',items:{type:'json'}},
14
+ nodes:{type:'array',description:'REQUIRED for version:1: all visible nodes and control nodes, in this same call. Never submit only state/derived.',items:{type:'object',additionalProperties:true,properties:{
15
15
  id:{type:'string',required:true,description:'Unique letter/underscore identifier; Chinese supported, max64.'},
16
16
  type:{type:'string',required:true,enum:Object.keys(COMPONENTS)},
17
17
  spec:{type:'json',description:'visual only: extended {title?,items:[components]} composition.'},stateKey:{type:'string',description:'visual only: optional dedicated json state key, auto-created when omitted.'},warnings:{type:'json'},
@@ -32,13 +32,13 @@ export const DOCUMENT_PARAMETER={
32
32
  libraries:{type:'array',items:{type:'string',enum:['mechanism']},description:'Optional local mechanism drawing helper; ui_catalog topic=mechanism.'},stateKeys:{type:'array',items:{type:'string'}},dataKeys:{type:'array',items:{type:'string'}},derivedKeys:{type:'array',items:{type:'string'}},
33
33
  text:{type:'json',description:'Literal scalar or {ref}/{expr}.'},
34
34
  value:{type:'json',description:'metric: number or display string/boolean, or {ref}/{expr}. progress: numeric.'},
35
- rows:{type:'json',description:'table/chart: inline rows with literal or formula cells, e.g. [{label:"餐饮",value:{ref:"derived.food"}}] or [{label:"住宿",value:{expr:"ceil(state.people / 2) * state.room"}}]. This is the simplest live breakdown; no map required. Also accepts {ref:"data.rows"}/{ref:"derived.rows"}, or scoped reference string.'},
35
+ rows:{type:'json',description:'table/chart: inline object rows, including {ref}/{expr} cells, or data/derived rows binding. No map needed for a short breakdown.'},
36
36
  columns:{type:'json',description:'table: column object array; row: optional integer 1..4 for equal grid columns.'},
37
37
  bind:{type:'string',description:'Controls bind state.key.'},children:{type:'array',description:'row/column/card/details: array of registered nodes using the same fields and type-specific rules. steps uses items instead of children.',items:{type:'json'}}
38
38
  }}},
39
- state:dictionary('key -> state specification object, e.g. {people:{type:"number",default:2,min:1,max:12,step:1}}. Numeric defaults/min/max/step are JSON numbers, no units. String/boolean/json state keeps its declared type; options is a direct scalar array. Do not omit numeric limits. json is bounded array/object state for canvas controls.'),
39
+ state:dictionary('Typed states, e.g. {people:{type:"number",default:2,min:1,max:12,step:1}}. Use numeric bounds/defaults, direct options arrays; json stores bounded canvas arrays/objects.'),
40
40
  data:dictionary('Static data; Chinese identifier keys supported. Omit if rows are inline.'),
41
- derived:dictionary('Local formulas, e.g. {food:{expr:"state.people * state.days * 100"}}. Prefer expr shorthand to deeply nested op/args. round(x), ceil(x), floor(x) and abs(x) each take one argument. ceil(state.people / 2) is supported directly; do not search source or switch to a canvas for rounding. op/args uses direct arrays and numeric constants, not item wrappers. Use ui_catalog for protocol questions, not installation source searches. Omit when unused.'),
41
+ derived:dictionary('Local formulas, e.g. {food:{expr:"state.people * state.days * 100"}}. round/ceil/floor/abs take one argument. Other expression forms/maps: ui_catalog topic=native. Omit unused derived.'),
42
42
  actions:dictionary('Declared agent.followup actions. Omit when unused.'),
43
43
  presentation:dictionary('Optional layout/accent/density and preset: reading/brief/comparison/list/callout/scorecard/flow/minimal. Every document owns its own styles.')
44
44
  }
@@ -47,4 +47,4 @@ export const DOCUMENT_PARAMETER={
47
47
  // Keep the native transport schema shallow. A recursively expanded union of
48
48
  // every component makes the lightweight tool harder to call; the shared
49
49
  // runtime validator still enforces exact component fields and limits.
50
- export const TEXT_BLOCKS_PARAMETER={type:'array',required:true,items:{type:'json'},description:'Direct array of text strings or native v1 nodes with optional IDs. Examples: [{type:"text",text:"Answer"}], or [{type:"steps",items:[{title:"First",description:"One sentence"}]}]. Reusable text: {type:"text",copyable:true,title:"Draft",text:"..."}; code: {type:"code",lang:"bash",code:"..."}, always copyable. Layout nodes use children:[nodes]; text may use spans:[{text,weight?,color?}] instead of text. Keep all content/styling inside blocks. Do not serialize the array or wrap it in {item:[...]}. ui_catalog topic=quick supplies exact fields and examples.'};
50
+ export const TEXT_BLOCKS_PARAMETER={type:'array',required:true,items:{type:'json'},description:'Direct array of strings/native nodes with optional IDs, e.g. [{type:"text",text:"Answer"}]. steps uses items:[{title,description?}], layouts use children:[nodes]. Copyable text: copyable:true; code:{type:"code",lang,code}. Keep styling/content inside blocks. No JSON string or {item:[...]} wrapper.'};
package/lib/ui-intent.js CHANGED
@@ -1,15 +1,4 @@
1
- // Shared behavior, not a visual template: free mode keeps the same intent policy.
2
- export const UI_INTENT_POLICY = `## 表达选择与首次交付
3
- 简单公式可用 {expr:"state.V * state.V / state.R"},无需堆叠 op/args。支持显式 state/data/derived/item 引用、四则运算、比较、三元条件及 min/max/round/abs;编译后仍按原协议校验。函数、语句和其他 JavaScript 不受支持,复杂动画使用画布。
4
- 无需等待用户说“UI”“可视化”或指定工具。先用一个简短判断选择表达方式,然后直接回答或提交界面,不向用户复述设计推理:
5
- - 用户明确要求交互界面、图解或动画:交付对应 UI;已要求纯文字、不要界面、只给代码时遵从用户。
6
- - 用户没提 UI,但问题的关键是参数变化如何影响结果、多个方案的权衡、时间趋势、空间结构或流程关系:当图形或操作比文字明显更易懂时主动使用 UI。不能只因出现数字、多个条目或技术名词就触发。
7
- - 简单事实、单次数值换算、短定义、普通代码排错:直接文字。一个静态小表格足够时不必创建交互应用;静态图解、方案对比、流程和看板也都是 UI 的正常用途。不确定有无收益时选更简单的表达,不询问“要不要我做个 UI”。
8
- 对照:问“复利是什么,简短解释”→文字;问“5万元按3%、5%、7%存10年,为什么后期差距更大”→可主动给增长曲线与年数调整,配准确数值和简短解释;问“解释七速自行车各档如何影响踏频和行进”→可主动给传动示意与档位选择;问“1 GiB是多少字节”→文字。例子只指导时机,不规定构图或配色。
9
- 每块界面只解决一个核心关系;一轮回复可按讲解顺序生成多块独立界面。立即选择最小完整方案并调用工具,不穷举设计选项,不先写长篇视觉规划,不等待“完美”再提交。每块默认用一幅静态主图、一个短流程、一个对齐对比或一个简洁看板即可;没有明确交互收益时不建 state、不写 script、不加控件和动画。静态可视化本身就是完整交付;只有用户需要操作时才保证对应交互。用户的必要要求不能省略。
10
- 仅数值试算优先原生组件;计算超出 derived 白名单(例如幂、三角函数)时不要臆造算子或堆复杂表达式,改用小画布中的单一计算函数同步图与数值。常见装配/齿轮/曲柄连杆讲解优先用 render_mechanism 的少量参数直接交付;其他结构或运动讲解选择自由画布,使用 render_canvas 的独立源码字段,按需读取 mechanism 辅助库接口。避免首次创建多页应用、自制组件库或庞大绘图引擎。除非用户或宿主另有要求,对话内的简短交互讲解无需建立项目、编辑文件或制定任务清单。工具提交前检查绑定、初始图可见、改变参数有反馈、标签/单位和 fallback;不为简单任务反复读全部目录。
11
- 必要的布局与排版从首次提交就做好:DSH 以 PC 横屏为主要使用场景,优先保证 1365px/1920px 桌面视口中实际聊天区域的构图与可读性,不假设画布等于整个浏览器宽度。主体突出、文字可读、控件有标签,装饰不挤占内容;手机适配是补充,不为窄屏牺牲桌面主体。Canvas 2D 按实际 CSS 像素布局,不整体缩放到文字难辨;动态数值和关键标签可放 HTML,绘图高度匹配内容,避免大块空白。
12
- 首屏应能看见主要关系、关键操作及当前结果。用户问“为什么变化不同”,优先用能对照变化的图形;发光、粒子或装饰不能代替趋势或关系证据。主图紧邻控件,推导、完整表格和次要说明随后呈现;同一数值不在指标、图注、卡片和正文反复展示。不要把所有可用组件都用一遍,也不为减少滚动而缩小字。比较不同单位的量,分别标轴或明确使用相对同一基准的无量纲倍数,不能把原始数值放在同一未说明尺度上暗示快慢。这些是信息优先级要求,具体图形、构图与风格仍按问题自行设计。
13
- 不把“最小”理解成粗糙或缺功能。交付所有必要内容后简短说明如何操作和假设;针对实际问题再局部修改。主动增强最多一次且确有价值,这是完成用户要求后的额外润色,不限制同一回复的 UI 数量或必要工具次数;用户要求几块不同内容就分别完成,不能第一块成功后提前结束。自动纠错仍最多两轮。无法交付时提供有效解释并如实说明,不声称已展示。
14
- 交付检查:如果决定生成 UI,先实际调用渲染工具并检查成功结果,或写出完整的 UI 源码围栏,然后再结束回复。“现在补上组件”“我会画图”只是承诺,不是交付。工具报错后修正实际参数并重新提交;若无法完成,给出有用的文字答案和真实失败原因,不用另一句承诺收尾。
15
- 这是一项行为要求,不能保证模型推理时长;宿主没有提供真实计时信息时不要虚报交付秒数。`;
1
+ // Shared behavior, without aesthetic rules or provider inference caps.
2
+ export const UI_INTENT_POLICY=`## 表达选择与实际交付
3
+ 用户明确要求UI、图解、动画或交互时,实际交付;要求纯文字或只给代码时遵从。无需用户点名UI:参数与结果、方案权衡、趋势、流程或空间关系用图形明显更易懂时主动使用。简单事实、短定义、一次换算、普通代码排错直接 plain text。例:1 GiB的字节数→文字;比较不同利率的长期增长→可视化;外啮合齿轮的方向和速比→机制图解。静态排版和图解也是完整UI,不强行加交互。用户强调“一眼看懂先后顺序”“并排比较”“参数变化看结果”时,这是明确的表达目标:即使没提UI,也优先用轻量界面承载;短流程可直接render_scene,不额外添加交互。普通简短列表不强制UI。
4
+ 每块围绕一个核心关系,按要求完成全部内容;一轮可有多块独立UI。先交付最小完整作品,不把“最小”理解成缺功能或粗糙,也不展开冗长设计复盘、穷举方案或为对话界面创建工程。首次工具成功不等于整个任务完成,不能用“准备调用”代替实际交付。外部数据正常用任务工具获取,真实数据与示例/假设分清。`;
package/lib/ui-skill.js CHANGED
@@ -1,22 +1,17 @@
1
- import {MOTION_GUIDE,MOTION_INTERFACE} from './motion-library.js';
2
- import { readFileSync } from 'node:fs';
3
- import { PROTOCOL_GUIDE } from './catalog.js';
4
- import { CANVAS_DESIGN_GUIDE } from './canvas-design.js';
5
- import { FREE_UI_CONTRACT } from './freeform.js';
6
- import { UI_INTENT_POLICY } from './ui-intent.js';
7
- import { CANVAS_SYSTEM_GUIDE } from './canvas-system.js';
8
- import {QUICK_EXAMPLES,QUICK_GUIDE} from './quick-examples.js';
9
- import {TEXT_GUIDE,TEXT_INTERFACE} from './text-guide.js';
10
- import {DEFAULT_VISUAL_STYLE,stylePrompt,styleExamples,visualStyle} from './visual-styles.js';
11
- import {VISUAL_GUIDE,VISUAL_INTERFACE} from './visual.js';
12
-
1
+ import {MOTION_GUIDE} from './motion-library.js';
2
+ import {readFileSync} from 'node:fs';
3
+ import {MODEL_CONTRACT} from './prompt-contract.js';
4
+ import {FREE_UI_CONTRACT} from './freeform.js';
5
+ import {UI_INTENT_POLICY} from './ui-intent.js';
6
+ import {DEFAULT_VISUAL_STYLE,stylePrompt,visualStyle} from './visual-styles.js';
7
+
13
8
  // Authored examples are deliberately small enough to be present in every model's context.
14
9
  export const SKILL_EXAMPLES = [
15
10
  {
16
11
  version: 1, title: '示例 · 数量与费用', fallback: '演示:单价 12 元,数量默认 2,总额 24 元。不是报价。',
17
12
  presentation: { layout: 'workbench', accent: 'teal', density: 'comfortable' },
18
13
  state: { quantity: { type: 'number', default: 2, min: 1, max: 10, step: 1 } },
19
- derived: { total: { op: 'mul', args: [{ ref: 'state.quantity' }, { const: 12 }] } },
14
+ derived: { total: { expr: 'state.quantity * 12' } },
20
15
  nodes: [
21
16
  { id: 'quantity', type: 'slider', label: '数量(件)', bind: 'state.quantity' },
22
17
  { id: 'total', type: 'metric', label: '演示总额', value: { ref: 'derived.total' }, unit: '元', decimals: 0 },
@@ -39,16 +34,14 @@ export const SKILL_EXAMPLES = [
39
34
  }
40
35
  ];
41
36
 
42
- const skill = readFileSync(new URL('../skills/intelligent-ui/SKILL.md', import.meta.url), 'utf8');
43
- export const UI_SKILL_SECTIONS = Object.freeze([
44
- { name: 'generative-ui:design-skill', order: 850, interpolate: false, text: TEXT_GUIDE+'\n\n'+MOTION_GUIDE+'\n\n'+UI_INTENT_POLICY + '\n\n' + skill + '\n\nCanvas composition details:\n' + CANVAS_DESIGN_GUIDE + '\n\nConstruction handbook:\n' + CANVAS_SYSTEM_GUIDE },
45
- { name: 'generative-ui:protocol', order: 851, interpolate: false, text: PROTOCOL_GUIDE },
46
- { name: 'generative-ui:worked-examples', order: 852, interpolate: false, text: QUICK_GUIDE+'\nThese valid render_ui examples start with static flow and comparison; the last adds a control only for a calculation. Adapt to the user, not their demo claims. Submit as render_ui({document: ...}). Animation examples remain available via ui_catalog({topic:"canvas"}).\n' + [QUICK_EXAMPLES[0],QUICK_EXAMPLES[1],SKILL_EXAMPLES[0]].map(doc => JSON.stringify(doc)).join('\n') }
47
- ]);
48
- export function promptSections(mode='guided',style=DEFAULT_VISUAL_STYLE){
49
- if(!['guided','free'].includes(mode))throw new Error('DSH UI: designMode must be guided or free');
50
- visualStyle(style);
51
- return mode==='guided'?UI_SKILL_SECTIONS.map((s,i)=>({...s,text:i===0?VISUAL_GUIDE+'\n\n'+s.text+'\n\n'+stylePrompt(style):i===2?QUICK_GUIDE+'\nSelected visual-style examples follow. Each entry names the tool and its args; call that tool with args only. The shared calculation example preserves the same protocol and interaction.\n'+styleExamples(style)+'\n'+JSON.stringify({tool:'render_ui',args:{document:SKILL_EXAMPLES[0]}}):s.text})):UI_SKILL_SECTIONS.map((s,i)=>({...s,text:i===0?VISUAL_INTERFACE+'\n\n'+TEXT_INTERFACE+'\n\n'+MOTION_INTERFACE+'\n\n'+UI_INTENT_POLICY+'\n\n'+FREE_UI_CONTRACT:''}));
52
- }
53
-
54
-
37
+ const workedExamples=style=>'Selected visual-style examples (adapt the composition to the task; no obligation to reproduce every module):\n'+JSON.stringify(visualStyle(style).examples[0])+'\n'+JSON.stringify({tool:'render_ui',args:{document:SKILL_EXAMPLES[0]}});
38
+ const skill=readFileSync(new URL('../skills/intelligent-ui/SKILL.md',import.meta.url),'utf8');
39
+ export const UI_SKILL_SECTIONS=Object.freeze([
40
+ {name:'generative-ui:design-skill',order:850,interpolate:false,text:UI_INTENT_POLICY+'\n\n'+skill+'\n\n'+MOTION_GUIDE},
41
+ {name:'generative-ui:protocol',order:851,interpolate:false,text:MODEL_CONTRACT},
42
+ {name:'generative-ui:worked-examples',order:852,interpolate:false,text:workedExamples(DEFAULT_VISUAL_STYLE)}
43
+ ]);
44
+ export function promptSections(mode='guided',style=DEFAULT_VISUAL_STYLE){
45
+ if(!['guided','free'].includes(mode))throw Error('DSH UI: designMode must be guided or free');visualStyle(style);
46
+ return UI_SKILL_SECTIONS.map((section,i)=>({...section,text:mode==='free'?(i===0?UI_INTENT_POLICY+'\n\n'+FREE_UI_CONTRACT:''):i===0?section.text+'\n\n'+stylePrompt(style):i===2?workedExamples(style):section.text}));
47
+ }
@@ -1,18 +1,29 @@
1
1
  // Serialized into the opaque frame; it never reads host content.
2
2
  export function measureVisual(root, nodeId='') {
3
3
  const issues=[],seen=new Set(),coverage={nodeId,elements:0,truncated:false,contrastChecked:0,contrastUncovered:0};
4
- const add=(code,value,el)=>{const id=el?.closest('[data-ui-node]')?.getAttribute('data-ui-node')??nodeId;const k=id+':'+code+':'+Math.round(value??0);if(!seen.has(k)&&issues.length<40){seen.add(k);issues.push({code,nodeId:id,...(value===undefined?{}:{value:Number(value.toFixed(2))})});}};
4
+ const styleCache=new WeakMap(),styleOf=el=>{let css=styleCache.get(el);if(!css){css=getComputedStyle(el);styleCache.set(el,css)}return css};
5
+ const target=el=>{if(!el)return undefined;const tag=el.tagName.toLowerCase(),id=el.id;return{selector:id&&id.length<=64?'#'+CSS.escape(id):tag,text:(el.textContent??'').trim().replace(/\s+/g,' ').slice(0,120),tag}};
6
+ const add=(code,value,el)=>{if(issues.length>=40)return;const id=el?.closest('[data-ui-node]')?.getAttribute('data-ui-node')??nodeId;const location=target(el);const k=id+':'+code+':'+(location?.selector??'')+':'+(location?.text??'')+':'+Math.round(value??0);if(!seen.has(k)&&issues.length<40){seen.add(k);issues.push({code,nodeId:id,...(location?{target:location}:{}),...(value===undefined?{}:{value:Number(value.toFixed(2))})});}};
5
7
  const color=s=>{let m=s.match(/^rgba?\(([^)]+)\)/);if(m){const a=m[1].split(/[\s,\/]+/).map(Number);return[a[0]/255,a[1]/255,a[2]/255,a[3]??1]}m=s.match(/^color\(srgb ([^)]+)\)/);if(m){const a=m[1].split(/[\s\/]+/).map(Number);return[a[0],a[1],a[2],a[3]??1]}return null;};
6
8
  const luminance=c=>c.slice(0,3).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((s,v,i)=>s+v*[.2126,.7152,.0722][i],0);
9
+ const blend=(front,back)=>front.slice(0,3).map((v,i)=>v*front[3]+back[i]*(1-front[3])).concat(1);
10
+ const background=el=>{const layers=[];let n=el;for(let i=0;n&&i<64;i++,n=n.parentElement){const c=styleOf(n);if(c.backgroundImage!=='none')return null;const colorValue=color(c.backgroundColor);if(!colorValue)return null;if(colorValue[3]>0)layers.push(colorValue);if(colorValue[3]===1){let out=layers.pop();while(layers.length)out=blend(layers.pop(),out);return out;}}return null;};
11
+ let contentBottom=root.getBoundingClientRect().top;
7
12
  const widthRoot=root.id==='canvas'?document.documentElement:root;if(widthRoot.scrollWidth>widthRoot.clientWidth+2)add('overflow',widthRoot.scrollWidth-widthRoot.clientWidth);
8
13
  const walker=document.createTreeWalker(root,NodeFilter.SHOW_ELEMENT);let el;while((el=walker.nextNode())){if(coverage.elements>=2000){coverage.truncated=true;break}coverage.elements++;if(el.matches('[data-canvas-phase=error]'))add('runtimeError',undefined,el);
9
- const css=getComputedStyle(el),box=el.getBoundingClientRect();if(!box.width||!box.height||css.visibility==='hidden'||css.display==='none')continue;
14
+ const css=styleOf(el),box=el.getBoundingClientRect();if(!box.width||!box.height||css.visibility==='hidden'||css.display==='none')continue;
10
15
  const text=[...el.childNodes].some(n=>n.nodeType===3&&n.textContent.trim());
11
16
  if(text){let size=parseFloat(css.fontSize);if(el instanceof SVGElement&&el.getScreenCTM){const m=el.getScreenCTM();if(m)size*=Math.hypot(m.c,m.d)}if(size<11.9)add('smallText',size,el);
12
- const fg=color(css.color);let ancestor=el,bg=null,depth=0;while(ancestor&&depth++<64){const style=getComputedStyle(ancestor);if(style.backgroundImage!=='none')break;const c=color(style.backgroundColor);if(c?.[3]===1){bg=c;break}if(c?.[3]>0)break;ancestor=ancestor.parentElement;}
13
- if(!(el instanceof SVGElement)&&fg?.[3]===1&&bg){coverage.contrastChecked++;const a=luminance(fg),b=luminance(bg),ratio=(Math.max(a,b)+.05)/(Math.min(a,b)+.05);if(ratio<(size>=24||(size>=18.66&&parseFloat(css.fontWeight)>=700)?3:4.5))add('contrast',ratio,el);}else coverage.contrastUncovered++;
17
+ if(!(el instanceof SVGElement)){
18
+ const fg=color(css.color),bg=background(el);let opacitySafe=true,opacityDepth=0;for(let a=el;a;a=a.parentElement){if(++opacityDepth>64||parseFloat(styleOf(a).opacity)<1){opacitySafe=false;break}}
19
+ if(fg&&bg&&opacitySafe){coverage.contrastChecked++;const a=luminance(blend(fg,bg)),b=luminance(bg),ratio=(Math.max(a,b)+.05)/(Math.min(a,b)+.05);if(ratio<(size>=24||(size>=18.66&&parseFloat(css.fontWeight)>=700)?3:4.5))add('contrast',ratio,el);}else coverage.contrastUncovered++;
20
+ const range=document.createRange();range.selectNodeContents(el);const bounds=range.getBoundingClientRect();let a=el,depth=0;while(a&&a!==root.parentElement&&depth++<24){const c=styleOf(a),clip=a.getBoundingClientRect();if((['hidden','clip'].includes(c.overflowX)&&(bounds.left<clip.left-2||bounds.right>clip.right+2))||(['hidden','clip'].includes(c.overflowY)&&(bounds.top<clip.top-2||bounds.bottom>clip.bottom+2))){add('clippedText',undefined,el);break}a=a.parentElement}
21
+ }else coverage.contrastUncovered++;
22
+
14
23
  }
24
+ if(text||el.matches('svg,canvas,img,video,input,select,textarea,button'))contentBottom=Math.max(contentBottom,box.bottom);
15
25
  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',undefined,el);
16
26
  }
27
+ const rect=root.getBoundingClientRect(),gap=rect.bottom-contentBottom;if(!coverage.truncated&&root.id==='canvas'&&rect.height>320&&gap>Math.max(96,rect.height*.25))add('bottomWhitespace',gap,root);
17
28
  Object.defineProperty(issues,'coverage',{value:coverage});return issues;
18
29
  }
@@ -138,5 +138,5 @@ export function visualStyle(id=DEFAULT_VISUAL_STYLE){
138
138
  if(!style)throw Error('DSH UI: unknown visualStyle; use '+VISUAL_STYLE_IDS.join('/'));
139
139
  return style;
140
140
  }
141
- export function stylePrompt(id){const s=visualStyle(id);return `## 用户选择的视觉风格:${s.name} (${s.id})\n采用${s.name}风格。\n${s.prompt}\n这套风格替代通用指导和旧案例中的默认配色、材质与圆角建议。只影响新生成内容的视觉方向;协议、工具入口、状态、权限、准确性、可读性与必要功能要求保持不变。引导模式下原生文本、卡片、指标、控件和代码块会自动跟随当前所选视觉风格;整份 UI 的原生内容和 HTML/SVG 应使用一致的色彩与层级。原生组件会改变表面、层级、控件与流程形态。通常省略 size/weight/color 和 presentation.accent,让所选风格管理默认层级;只有用户明确指定或确有语义强调才覆盖。不要每种风格都手写同一套字号。title 已显示文档标题,正文不再重复相同标题;模型负责根据上述构图原则组织内容,不要所有风格都生成等大卡片网格。少量语义色强调真实重点。不要为了给原生内容上色而额外生成 HTML。确实需要特殊图解时再使用小画布。用户在当前请求中明确指定的风格优先。不要为展示风格增加不必要的画布、代码、动画或工具调用。`}
141
+ export function stylePrompt(id){const s=visualStyle(id);return '## 用户选择的视觉风格:'+s.name+' ('+s.id+')\n采用'+s.name+'风格。\n'+s.prompt+'\n原生组件自动遵循此风格,通常省略size/weight/color和presentation.accent;明确要求或语义强调才覆盖。HTML/SVG与原生内容保持一致层级。通用指导不覆盖这里的配色、材质、圆角或阴影;按任务组织内容,不为展示风格增加画布或工具调用。'}
142
142
  export function styleExamples(id){return visualStyle(id).examples.map(example=>JSON.stringify(example)).join('\n')}