dsh-motion-ui 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +63 -3
  3. package/VERIFICATION.md +26 -0
  4. package/client.js +876 -272
  5. package/index.js +43 -20
  6. package/lib/assets/mermaid.js +382 -382
  7. package/lib/assets/standalone-runtime.js +1 -1
  8. package/lib/assets/visual.js +13 -13
  9. package/lib/author-expression.js +7 -7
  10. package/lib/authoring-guide.js +3 -17
  11. package/lib/build-info.js +1 -1
  12. package/lib/canvas-design.js +2 -2
  13. package/lib/canvas-export.js +3 -3
  14. package/lib/canvas-system.js +1 -1
  15. package/lib/catalog.js +1 -2
  16. package/lib/delivery.js +1 -1
  17. package/lib/export-runtime-bundle.js +1 -1
  18. package/lib/fence-feedback.js +8 -2
  19. package/lib/freeform.js +4 -10
  20. package/lib/inspection.js +19 -6
  21. package/lib/motion-craft.js +31 -0
  22. package/lib/motion-design-skill.js +23 -0
  23. package/lib/motion-library.js +5 -4
  24. package/lib/normalize-authoring.js +9 -0
  25. package/lib/project-archive.js +16 -0
  26. package/lib/prompt-contract.js +22 -0
  27. package/lib/protocol.js +3 -3
  28. package/lib/refinement.js +36 -3
  29. package/lib/sandbox.js +4 -1
  30. package/lib/scene-entry.js +41 -0
  31. package/lib/style-system.js +2 -2
  32. package/lib/text-guide.js +9 -13
  33. package/lib/third-party/taste-skill-NOTICE.md +10 -0
  34. package/lib/timeline.js +29 -0
  35. package/lib/tool-json.js +5 -0
  36. package/lib/tool-schema.js +7 -7
  37. package/lib/ui-intent.js +4 -15
  38. package/lib/ui-skill.js +19 -26
  39. package/lib/video-tools.js +21 -0
  40. package/lib/video.js +296 -0
  41. package/lib/visual-audit.js +15 -4
  42. package/lib/visual-styles.js +1 -1
  43. package/lib/voice.js +72 -0
  44. package/package.json +17 -9
  45. package/skills/intelligent-ui/SKILL.md +14 -107
  46. package/skills/motion-design/ADAPTER.md +35 -0
  47. package/skills/motion-design/LICENSE +201 -0
  48. package/skills/motion-design/NOTICE.md +9 -0
  49. package/skills/motion-design/SKILL.md +388 -0
  50. package/skills/motion-design/SOURCES.md +16 -0
  51. package/skills/motion-design/upstream.json +8 -0
@@ -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
- import { toolJson } from './tool-json.js';
1
+ import { optionalToolJson } 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
- const values={state:toolJson(state??{},'state'),data:toolJson(data??{},'data'),derived:toolJson(derived??{},'derived')};
5
+ const values={state:optionalToolJson(state,'state',{}),data:optionalToolJson(data,'data',{}),derived:optionalToolJson(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,27 @@
1
+ import {componentFields} from './components.js';
2
+ import {toolJson} from './tool-json.js';
3
+ export function normalizeNodeIds(value){
4
+ if(typeof value==='string'&&!/^[\s\uFEFF]*[\[{"]/.test(value))return[value];
5
+ value=toolJson(value,'nodeIds');
6
+ if(value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).length===1&&Object.hasOwn(value,'item'))value=Array.isArray(value.item)?value.item:[value.item];
7
+ return value;
8
+ }
1
9
  // Keep the default observation small; request exact node source only when editing it.
2
- export function inspectRecord(record,state,derived,audits,{detail='summary',nodeIds=[]}={}){
10
+ export function inspectRecord(record,state,derived,audits,{detail='summary',nodeIds=[],query=''}={}){
11
+ nodeIds=normalizeNodeIds(nodeIds);
3
12
  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);
13
+ if(typeof query!=='string'||query.length>200)throw new Error('DSH UI: inspection query must be text of at most200 characters');
14
+ 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);
15
+ 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
16
  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
17
  const inferred=detail==='nodes'&&!nodeIds.length;
7
- if(inferred)nodeIds=nodes.slice(0,16).map(n=>n.id);
18
+ if(query.trim()&&nodeIds.length)throw new Error('DSH UI: choose query OR nodeIds, not both');
19
+ if(inferred)nodeIds=matches.slice(0,16).map(n=>n.id);
8
20
  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};
21
+ 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.'}};
22
+ 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
23
  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}}:{})}));
24
+ 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.'};}
25
+ 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
26
  return result;
14
27
  }
@@ -0,0 +1,31 @@
1
+ import {validateDocument} from './protocol.js';
2
+
3
+ export const MOTION_CRAFT_GUIDE = `Motion craft: give movement a purpose (feedback, spatial continuity, causality or an occasional expressive moment). Choose one focal action; let connected objects react, then hold long enough to read. Keep identity/direction continuous across stages. Visual style controls composition/material/type; motion grammar controls how change is explained, across every style. Precise mechanisms need readable relationships, not arbitrary bounce. Quiet reading moments may be still. Use short interruptible transitions for frequent controls; never delay keyboard/click feedback. Avoid restarting entrances on state updates. Retrieve a targeted motion recipe for timelines, stages, path drawing or text; do not load the whole library.`;
4
+ export const TIMELINE_CONTRACT = `Optional DSHUI.timeline({id?:"main",duration:seconds,draw(time,info),loop?:false,autoplay?:true,chapters?:[{time,label}]}) returns state(),seek(seconds),play(),pause(),replay(),chapter(index),subscribe(fn)->cleanup,dispose(). draw(time,info) receives seconds, not a Canvas context. For Canvas2D use const ctx=canvas.getContext("2d"); draw(t){drawFrame(ctx,t)}. draw uses ABSOLUTE time and current parameters: no accumulating phase, Date.now, random-per-frame, independent CSS animation or asynchronous draw for exportable films. Seed/cache procedural detail. Timelines pause offscreen/background and honor reduced motion; seeking remains available. Build labelled controls when requested, outside the exported stage if appropriate. Read an input value before pause/seek, since subscribers may synchronize its value. Transient time is not persisted each frame. ui_catalog topic=motion scene=timeline/stages/path/typography returns one example. Video export captures a single saved sandbox with timeline id main; author its duration to match export duration.`;
5
+ export const VIDEO_GUIDE = `Video tools: preview_video captures exact source frames/contact sheet before encoding; read them and patch first. inspect_video reads an actual local video and optionally extracts timestamped JPEG samples/contact sheet; frames are sparse observations, not full semantic analysis or ASR. Read returned images with the host image tool. render_video exports one saved canvas documentId/nodeId to editable source + MP4; call render_canvas first, author DSHUI.timeline id main with #stage, then render_video only for an actual video request. process_video trims/joins local clips. synthesize_voice produces real narration, duration and provider-measured captions/words using configured MiniMax authentication (paid); it does not transcribe input video. Fit the timeline AFTER measuring voice duration. render_video/process_video audioTracks:[{path,role:voice|music|effect,volume,offset,loop,fadeIn,fadeOut}] mixes up to four tracks and replaces original audio. Voice cannot repeat or be cut; music can loop/fade at low volume. Alternatively legacy audioPath/audioVolume replaces soundtrack. captions:[{start,end,text}] uses exact output seconds; do not stretch captions across silent gaps. Sources/audio/recipe stay editable. No API key is needed for HTML motion, local Chrome/Edge + FFmpeg/FFprobe required. No invented assets or guessed timestamps. For a promotional-film brief query ui_catalog topic=video scene=promo for the production workflow. Failed export retains assets; never label queued/HTML-only output as a finished film.`;
6
+
7
+ export const PROMO_GUIDE = `One-brief film workflow (on demand, not everyday UI):
8
+ Choose the film language first. An explainer follows narration and gives examples time to teach. A product launch/reel follows musical beats and showcases authored visual capability: oversized kinetic type, bold art direction, object morphs, spatial/exploded diagrams and a curated montage. Do not turn every brief into a narrated pastel-card feature list. Read user reference frames before choosing the treatment; distinguish the actual film from a social video's surrounding title/border. Respect explicit reference direction over default styling. Music-led films can use concise on-stage copy without narration or subtitle tracks; voice is not mandatory. Preserve readable holds between energetic cuts and derive motion/cuts from one seekable timeline. Use measured/declared musical timing, label estimated BPM, and never claim to have listened when only audio metadata/spectra were inspected.
9
+ 1. Read the user's creative intent: explanatory film or expressive launch montage. For an explainer extract audience, promise and benefits; for a visual reel define palette, typography, spatial/material language, energy arc and brand ending. A visual reel may be abstract and need not explain features scene by scene or retain one object for the entire film. Use sensible defaults for unspecified creative choices; deliver rather than stopping at a plan. Never invent release availability, screenshots, benchmark claims or links.
10
+ 2. Only when narration is requested, write concise copy about problems solved and benefits; demonstrations support it. For music-led reels skip narration and use only the necessary brand/type. Keep displayed titles short and pronunciation copy separate. If voice is requested and configured, call synthesize_voice ONCE with the complete copy, then use returned measured duration/captions/words. No fabricated speech or time interpolation. If voice is unavailable report it and deliver the supported silent version; do not hunt for credentials.
11
+ 3. Choose timing for the chosen film language. Narrated explainers follow measured sentences and benefits. Music-led reels follow musical accents and visual contrast. Vary compositions: oversized kinetic type, morphing abstract charts, mechanical assembly, rotating volumes, organized particle fields and light paths are valid main visuals without explanatory captions. Choose motifs suited to the brief, not a mandatory checklist. Connect shots through motion direction, shape matching, occlusion and camera/scale hand-offs; allow energetic cuts and contrasting palettes with readable brand holds. Do not reduce a reel to repeated cards, a small dashboard or literal feature demonstrations. Prefer original HTML/SVG/Canvas artwork; keep abstract graphics free of invented benchmark claims and label schematic product UI as illustrative.
12
+ 4. Author one render_canvas #stage and one absolute-time DSHUI.timeline({id:'main',duration,draw,chapters}). Scope all graphics to the stage. Use a fixed 1920×1080 design coordinate system scaled to the viewport, minimum readable type about32px for key body copy, safe margins, subtitle clearance (reserve the bottom140px at1080p when captions are enabled; keep labels/footnotes above that area). Avoid CSS-only animations/random clocks; draw every property from t so seeking backwards recreates the frame. Gate flash/mask overlays to their actual active interval: a clamped fade-out is still1 BEFORE its start, so explicitly return alpha0 outside the interval. Restore Canvas drawing state between groups. Vary typography/space/shape while following the selected guided style; free mode chooses its own design. No glass unless explicitly requested.
13
+ 5. Use preview_video to inspect the first and last frames plus all scene interiors BEFORE full MP4 encoding; actually read returned contact sheet and individual frames with the host image tool. It costs only a few captures rather than rendering the whole film. complete=false reports location/time-specific diagnostics; patch those and preview again until complete=true. Valid frames are retained even when another frame fails; never treat an invalid sample as a checked image. Resolve returned location-specific warnings; HTML backgrounds use CSS gradients, SVG paint url(#id) belongs on SVG fill/stroke, not an HTML background-image. For moving numerical examples derive results from the same rounded inputs shown. Syntax diagnostics include author-script line/column without executing it in the Host. Patch the saved documentId with patch_ui/update_ui; exported HTML is a snapshot and editing it alone does not update render_video input; avoid generating another UI for the same film. Render final requested dimensions/fps, mix measured voice at volume1 with quieter music (roughly0.05–0.15); music-led reels may use music at normal level (roughly0.7–1), with fadeOut; do not overwrite sources or claim an unlicensed soundtrack is royalty-free. Include exact captions or authored on-stage copy; captions must disappear in gaps. Never truncate voice to fit an arbitrary round duration.
14
+ 6. Delivery gate: readable text, varied compositions, no visible overflow/blank scenes, requested audio/captions fit, clean source preview, real MP4 and editable assets. Evaluate the requested film language: explainers need visible causal demonstrations; visual launch reels need strong art direction, coherent spatial motion, smooth shot hand-offs and musical energy. Abstract graphics and expressive montage are complete outcomes; no slider or literal feature demonstration is mandatory. Replacing static card colors is insufficient. Inspect opening, at least one transition pair and the finale as well as scene interiors. Once these are met, deliver; do not keep re-rendering an otherwise usable film for minor aesthetic preferences. This is an outcome criterion, not a thinking/output/time limit. Inspect final metadata plus scene samples. If the same unexpected blank appears in the source preview AND an actually decoded MP4 frame at that time, inspect authored masks/opacity and time conditions; a valid frame or balanced save/restore does not prove the drawing is correct. Deliver verified MP4 and editable source/audio/recipe links, briefly state any missing assets or coverage limitations. Do not claim sparse-frame inspection is complete perceptual/audio review. Video requests require a real rendered file, not just HTML or a screenplay.
15
+ Film architecture example: scene boundaries come from measured narration OR music/visual accents; draw(t) selects a scene, computes local progress = clamp((t-start)/entranceDuration), applies eased transforms, then holds. Use shared brand tokens across scenes, distinct layouts, and persistent object identity across transitions. Transport controls live outside #stage and are not captured.`;
16
+
17
+ const baseCSS='*{box-sizing:border-box}body{margin:0}#stage{position:relative;width:100%;aspect-ratio:16/9;background:var(--canvas-soft,#eef5ff);overflow:hidden;color:var(--canvas-ink,#182537);font:16px system-ui}svg{width:100%;height:100%;display:block}#transport{display:flex;gap:8px;align-items:center;padding:10px}button{font:inherit;padding:8px 14px;border-radius:14px;border:1px solid var(--canvas-line,#cbd5e1);background:var(--canvas-bg,#fff);color:inherit}input{flex:1;min-width:80px}';
18
+ const transport=`const tl=DSHUI.timeline({duration:6,loop:true,autoplay:false,chapters:[{time:0,label:'建立关系'},{time:2,label:'传递变化'},{time:4,label:'观察结果'}],draw});const play=document.getElementById('play'),seek=document.getElementById('seek'),label=document.getElementById('time');play.addEventListener('click',()=>tl.state().playing?tl.pause():tl.play());seek.addEventListener('input',()=>{const next=Number(seek.value);tl.pause();tl.seek(next)});tl.subscribe(s=>{seek.value=s.time;label.textContent=s.time.toFixed(1)+' / '+s.duration+' 秒';play.textContent=s.playing?'暂停':'播放'});document.querySelectorAll('[data-chapter]').forEach(b=>b.addEventListener('click',()=>tl.chapter(Number(b.dataset.chapter))));addEventListener('pagehide',()=>tl.dispose(),{once:true});`;
19
+ const controls='<div id="transport"><button id="play">播放</button><input id="seek" aria-label="动画时间(秒)" type="range" min="0" max="6" step="0.01"><output id="time"></output></div>';
20
+ const recipes = [
21
+ {id:'timeline',name:'可定位时间轴',category:'clock',description:'绝对时间绘制,暂停、拖动和重播共享同一画面状态',html:'<div id="stage"><svg viewBox="0 0 640 360" role="img" aria-label="位置随时间变化的圆点"><path d="M80 180H560" stroke="var(--canvas-line,#cbd5e1)" stroke-width="4"/><circle id="dot" cy="180" r="24" fill="var(--canvas-accent,#527ef7)"/></svg></div>'+controls,script:'function draw(t){document.getElementById("dot").setAttribute("cx",80+480*t/6)}'+transport},
22
+ {id:'stages',name:'分步因果讲解',category:'explanation',description:'同一对象依次经历输入、传递和输出,章节可定位',html:'<div id="stage"><svg viewBox="0 0 640 360" role="img" aria-label="输入传递到输出"><path d="M100 180H540" stroke="var(--canvas-line,#cbd5e1)" stroke-width="6"/><g id="signal"><circle r="22" fill="var(--canvas-accent,#527ef7)"/></g><text id="caption" x="60" y="70" fill="currentColor" font-size="26"></text></svg></div>'+controls+'<button data-chapter="0">输入</button><button data-chapter="1">传递</button><button data-chapter="2">输出</button>',script:'function draw(t){const p=Math.max(0,Math.min(1,(t-1)/4));document.getElementById("signal").setAttribute("transform","translate("+(100+440*p)+",180)");document.getElementById("caption").textContent=t<2?"先建立输入":t<4?"沿连接传递变化":"观察输出结果"}'+transport},
23
+ {id:'path',name:'路径与方向',category:'diagram',description:'依次画出连接路径,保留起点、终点与方向',html:'<div id="stage"><svg viewBox="0 0 640 360" role="img" aria-label="沿路径传播的变化"><path id="route" d="M80 260C180 260 180 100 320 100S460 260 560 100" fill="none" stroke="var(--canvas-accent,#527ef7)" stroke-width="8" stroke-linecap="round"/><circle id="dot" r="14" fill="var(--canvas-ink,#182537)"/></svg></div>'+controls,script:'const path=document.getElementById("route"),length=path.getTotalLength();path.style.strokeDasharray=length;function draw(t){const p=Math.min(1,t/5),point=path.getPointAtLength(length*p);path.style.strokeDashoffset=length*(1-p);document.getElementById("dot").setAttribute("cx",point.x);document.getElementById("dot").setAttribute("cy",point.y)}'+transport},
24
+ {id:'typography',name:'语义文字动效',category:'text',description:'短句整体入场,保留阅读时间,避免逐字打断理解',html:'<div id="stage" style="display:grid;place-items:center"><h2 id="phrase" style="font-size:clamp(24px,5vw,52px);margin:24px">把复杂关系讲清楚</h2></div>'+controls,script:'function draw(t){const p=Math.min(1,t/.7),ease=1-Math.pow(1-p,3),el=document.getElementById("phrase");el.style.opacity=ease;el.style.transform="translateY("+((1-ease)*24)+"px)"}'+transport}
25
+ ];
26
+ export function craftCatalog(id){
27
+ if(!id)return {guide:MOTION_CRAFT_GUIDE,interface:TIMELINE_CONTRACT,recipes:recipes.map(({id,name,category,description})=>({id,name,category,description}))};
28
+ if(id==='reference')return{guide:'Reference-video analysis: inspect_video metadata first; select sparse times around the relevant transition, then READ returned JPEG paths/contact sheet with the host image tool. Compare focal-object position/scale/opacity, motion direction, reading holds and start/end continuity at the original timestamp intervals. Ask for denser samples only around an observed change (max12 per call). Report approximate duration/easing as visual estimates, not measured spring constants or fitted curves. No ASR is supplied. Adapt the mechanism into an absolute-time timeline, respect the current style and user content; do not duplicate the whole reference site or invent unobserved details.'};
29
+ const r=recipes.find(r=>r.id===id);if(!r)return null;
30
+ return {id:r.id,name:r.name,description:r.description,interface:TIMELINE_CONTRACT,document:validateDocument({version:1,title:r.name,fallback:r.description,nodes:[{id:'canvas',type:'sandbox',title:r.name,fallback:r.description,html:r.html,css:baseCSS,script:r.script,height:420}]}),guidance:'Original educational sample, adapt content and selected guided style. For MP4 export #stage is captured and transport controls excluded. Frame drawing must be synchronous and depend on absolute time. Do not copy illustrative relationships as real measurements.'};
31
+ }
@@ -0,0 +1,23 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import {createHash} from 'node:crypto';
3
+
4
+ export const MOTION_DESIGN_REFERENCE=Object.freeze({
5
+ id:'motion-design',name:'Motion Design — complete OpenDesign skill',
6
+ source:'https://github.com/nexu-io/open-design/tree/14fee234e6eb268058fed17ba979d8e22798dd34/skills/motion-design',
7
+ usage:'ui_catalog({topic:"motion",scene:"motion-design"}) or ui_catalog({topic:"video",scene:"promo"}) loads the complete original skill and separate DSH adapter on demand.'
8
+ });
9
+
10
+ // No source body is read or injected at import/registration time.
11
+ export async function loadMotionDesignSkill(){
12
+ const base=new URL('../skills/motion-design/',import.meta.url);
13
+ const [raw,adapter,provenance,sources]=await Promise.all([
14
+ readFile(new URL('SKILL.md',base)),readFile(new URL('ADAPTER.md',base),'utf8'),
15
+ readFile(new URL('upstream.json',base),'utf8'),readFile(new URL('SOURCES.md',base),'utf8')
16
+ ]);
17
+ const metadata=JSON.parse(provenance.replace(/^\uFEFF/,''));
18
+ const sha256=createHash('sha256').update(raw).digest('hex');
19
+ if(sha256!==metadata.sha256)throw Error('Motion UI: bundled motion-design skill integrity mismatch; restore the complete packaged skill.');
20
+ return {...MOTION_DESIGN_REFERENCE,complete:true,modified:false,license:'Apache-2.0',
21
+ original:raw.toString('utf8'),sources,provenance:{...metadata,sha256},adapter,
22
+ precedence:'Current user requirements and the DSH adapter govern host execution; apply the full upstream design craft.'};
23
+ }
@@ -1,5 +1,6 @@
1
1
  import {EXPRESSIVE_EXAMPLES} from './expressive-examples.js';
2
2
  import {validateDocument} from './protocol.js';
3
+ import {craftCatalog,MOTION_CRAFT_GUIDE} from './motion-craft.js';
3
4
  const ref=key=>({ref:key}),constant=value=>({const:value});
4
5
  export const MOTION_RECIPES=Object.freeze([
5
6
  ...EXPRESSIVE_EXAMPLES,
@@ -9,10 +10,10 @@ export const MOTION_RECIPES=Object.freeze([
9
10
  {id:'plot',name:'参数图解',description:'安全函数表达式,本地实时重绘',document:{title:'振幅探索',items:[{type:'plot',title:'y = a sin(x)',xMin:-6.28,xMax:6.28,series:[{expr:'a*sin(x)',label:'正弦',params:[{name:'a',value:1,min:0,max:3,step:0.1}]}]},{type:'text',content:'调整 a 改变振幅。函数在浏览器本地绘制,无需再次调用模型。'}]}}
10
11
  ]);
11
12
  export function motionCatalog(id){
12
- if(!id)return{recipes:MOTION_RECIPES.map(({id,name,description})=>({id,name,description})),usage:'ui_catalog({topic:"motion",scene:"plan"}) returns one complete example; adapt real content and selected guided style. Do not load every recipe.'};
13
+ if(!id)return{...craftCatalog(),recipes:[...MOTION_RECIPES.map(({id,name,description})=>({id,name,description})),...craftCatalog().recipes],usage:'ui_catalog({topic:"motion",scene:"timeline"}) returns one complete example; adapt real content and selected guided style. Do not load every recipe.'};
14
+ const craft=craftCatalog(id);if(craft)return craft;
13
15
  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
16
  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
17
  }
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.';
18
+ 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.\n'+MOTION_CRAFT_GUIDE;
19
+ 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,16 @@
1
+ import {readFile,writeFile} from 'node:fs/promises';
2
+ import {join} from 'node:path';
3
+ import {crc32} from 'node:zlib';
4
+ // Small, standard ZIP (stored entries). Only this job's generated filenames
5
+ // enter the archive. No shell/archiver dependency or user-directory traversal.
6
+ export async function archiveProject(directory,files){
7
+ const locals=[],central=[];let offset=0;
8
+ for(const name of [...new Set(files)]){
9
+ if(!/^[\w-]+\.[\w]+$/.test(name))throw Error('Motion video: invalid project filename');
10
+ const body=await readFile(join(directory,name)),n=Buffer.from(name),crc=crc32(body),h=Buffer.alloc(30);
11
+ h.writeUInt32LE(0x04034b50);h.writeUInt16LE(20,4);h.writeUInt16LE(0x800,6);h.writeUInt32LE(crc,14);h.writeUInt32LE(body.length,18);h.writeUInt32LE(body.length,22);h.writeUInt16LE(n.length,26);
12
+ locals.push(h,n,body);const c=Buffer.alloc(46);c.writeUInt32LE(0x02014b50);c.writeUInt16LE(20,4);c.writeUInt16LE(20,6);c.writeUInt16LE(0x800,8);c.writeUInt32LE(crc,16);c.writeUInt32LE(body.length,20);c.writeUInt32LE(body.length,24);c.writeUInt16LE(n.length,28);c.writeUInt32LE(offset,42);central.push(c,n);offset+=h.length+n.length+body.length;
13
+ }
14
+ const size=central.reduce((n,b)=>n+b.length,0),end=Buffer.alloc(22);end.writeUInt32LE(0x06054b50);end.writeUInt16LE(central.length/2,8);end.writeUInt16LE(central.length/2,10);end.writeUInt32LE(size,12);end.writeUInt32LE(offset,16);
15
+ await writeFile(join(directory,'project.zip'),Buffer.concat([...locals,...central,end]));
16
+ }
@@ -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,'DSHUI.timeline supports seek/play/pause; query motion:timeline. Requested films: render_video/inspect_video/process_video; synthesize_voice adds speech. Query ui_catalog topic=video scene=promo for the complete motion-design skill and DSH adapter.',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,8 +1,40 @@
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:'画布报告运行异常' });
4
- export function refineDocument(document, changes) {
3
+ import {toolJson} from './tool-json.js';
4
+ export const AUDIT_MESSAGES = Object.freeze({ overflow: '内容超出视口宽度', smallText: '文字实际显示尺寸小于 12px', contrast: '文字与背景对比度偏低', missingLabel: '交互控件缺少可访问名称',runtimeError:'画布报告运行异常',clippedText:'文字可能被隐藏或裁切',bottomWhitespace:'内容下方存在明显空白' });
5
+ export function normalizeNodeEdits(changes) {
6
+ changes=toolJson(changes,'changes');
7
+ // Some providers serialize a JSON array as {item:{id,props}}. Only unwrap
8
+ // an unambiguous transport envelope; ordinary document/data objects stay opaque.
9
+ if(changes&&typeof changes==='object'&&!Array.isArray(changes)&&Object.keys(changes).length===1&&Object.hasOwn(changes,'item')){
10
+ const item=changes.item;
11
+ if(Array.isArray(item))changes=item;
12
+ else if(item&&typeof item==='object'&&!Array.isArray(item)&&Object.hasOwn(item,'id')&&Object.hasOwn(item,'props'))changes=[item];
13
+ }
14
+ if(changes&&typeof changes==='object'&&!Array.isArray(changes)){
15
+ const keys=Object.keys(changes);
16
+ if(keys.length===2&&keys.includes('id')&&keys.includes('props'))changes=[changes];
17
+ else if(keys.length&&keys.length<=32&&keys.every(id=>changes[id]&&typeof changes[id]==='object'&&!Array.isArray(changes[id])))changes=keys.map(id=>({id,props:changes[id]}));
18
+ }
5
19
  if (!Array.isArray(changes) || !changes.length || changes.length > 32) throw new Error('DSH UI: changes requires 1..32 node edits');
20
+ const merged=new Map();
21
+ for(let change of changes){
22
+ if(!change||typeof change!=='object'||Array.isArray(change))throw Error('DSH UI: each edit must be {id,props}');
23
+ // A few typed-array adapters leak the PARENT parameter as an empty field
24
+ // on its item. Remove only this empty mirror, never opaque props/source,
25
+ // nonempty nested edits, arbitrary extra fields or missing identity.
26
+ if(Object.hasOwn(change,'changes')&&(change.changes==null||(typeof change.changes==='string'&&!change.changes.trim())||(Array.isArray(change.changes)&&!change.changes.length))){const {changes:emptyMirror,...rest}=change;change=rest}
27
+ const extra=Object.keys(change).find(k=>!['id','props'].includes(k));
28
+ if(extra)throw Error('DSH UI: unknown edit field '+extra+'; each edit allows id,props. Put node properties inside props.');
29
+ const props=toolJson(change.props,'props');if(!props||typeof props!=='object'||Array.isArray(props)||!Object.keys(props).length)throw Error('DSH UI: edit props required');
30
+ const previous=merged.get(change.id);
31
+ if(previous){for(const key of Object.keys(props))if(Object.hasOwn(previous.props,key)&&JSON.stringify(previous.props[key])!==JSON.stringify(props[key]))throw Error('DSH UI: conflicting edits at node.'+change.id+'.'+key);previous.props={...previous.props,...props}}
32
+ else merged.set(change.id,{id:change.id,props:{...props}});
33
+ }
34
+ return [...merged.values()];
35
+ }
36
+ export function refineDocument(document, changes) {
37
+ changes=normalizeNodeEdits(changes);
6
38
  const next = structuredClone(document), nodes = new Map();
7
39
  const walk = list => list.forEach(n => { nodes.set(n.id,n); if(n.children)walk(n.children); });walk(next.nodes);
8
40
  const seen = new Set();
@@ -26,6 +58,7 @@ export function validateAudit(value){
26
58
  });
27
59
  return {width:value.width,theme:value.theme,coverage:checkedCoverage,issues:value.issues.map(i=>{
28
60
  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})};
61
+ 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};}
62
+ return {code:i.code,nodeId:i.nodeId,message:AUDIT_MESSAGES[i.code],...(target?{target}:{}),...(i.value===undefined?{}:{value:i.value})};
30
63
  })};
31
64
  }
package/lib/sandbox.js CHANGED
@@ -2,6 +2,7 @@ import {MECHANISM_RUNTIME} from './mechanism-runtime-bundle.js';
2
2
  import { measureVisual } from './visual-audit.js';
3
3
  import { formatNumber } from './format-number.js';
4
4
  import { CANVAS_SYSTEM_CSS } from './canvas-system.js';
5
+ import {createTimelineAPI} from './timeline.js';
5
6
  // Shared bounds and document construction. Generated code runs only in an opaque-origin frame.
6
7
  export const SANDBOX_LIMITS = Object.freeze({ source: 49152, height: 1000, message: 16384, rate: 30 });
7
8
  export function canvasSnapshot(node, state, data, derived, disabled, theme) {
@@ -29,13 +30,15 @@ export function sandboxDocument(node, channel, nonce) {
29
30
  const animations=new Set(), motion=matchMedia('(prefers-reduced-motion: reduce)');
30
31
  let raf=0,last=0,time=0;
31
32
  const visible=()=>snapshot.visible!==false&&!document.hidden;
32
- const canAnimate=()=>visible()&&!snapshot.disabled&&!motion.matches;
33
+ const canAnimate=()=>visible()&&!snapshot.disabled&&!motion.matches&&!window.__DSHMotionRender;
33
34
  const draw=delta=>{for(const fn of animations)try{fn({time,delta,reducedMotion:motion.matches,visible:visible()})}catch(e){animations.delete(fn);report(e)}};
34
35
  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)};
35
36
  const refresh=()=>{draw(0);if(canAnimate()&&animations.size){if(!raf){last=0;raf=requestAnimationFrame(tick)}}else{cancelAnimationFrame(raf);raf=0;last=0}};
36
37
  motion.addEventListener('change',refresh);document.addEventListener('visibilitychange',refresh);
37
38
  const formatNumber=${formatNumber.toString()};
38
39
  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}}}};
40
+ let soundTick=0,soundPlaying,soundTime,soundVisible;const timelineAPI=(${createTimelineAPI.toString()})(api.animate,!!window.__DSHMotionRender,s=>{if(snapshot.motionSound){const now=performance.now();if(now-soundTick>=100||s.playing!==soundPlaying||s.visible!==soundVisible||(!s.playing&&s.time!==soundTime)){soundTick=now;soundPlaying=s.playing;soundTime=s.time;soundVisible=s.visible;send('timeline',s)}}});api.timeline=timelineAPI.timeline;
41
+ Object.defineProperty(window,'__DSHMotionCapture',{value:timelineAPI.capture,writable:false});
39
42
  ${node.libraries?.includes('mechanism')?MECHANISM_RUNTIME.replace(/<\/script/gi,'<\\/script')+';api.mechanism=__MotionMechanism.createMechanismAPI(api);':''}
40
43
  Object.defineProperty(window,'DSHUI',{value:Object.freeze(api),writable:false});
41
44
  for(const type of ['click','input','change','keydown','pointerdown'])document.addEventListener(type,e=>{if(snapshot.disabled){e.preventDefault();e.stopImmediatePropagation()}},true);
@@ -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.