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.
- package/CHANGELOG.md +22 -0
- package/README.md +19 -1
- package/VERIFICATION.md +10 -0
- package/client.js +391 -222
- package/index.js +31 -16
- package/lib/author-expression.js +7 -7
- package/lib/authoring-guide.js +3 -17
- package/lib/build-info.js +1 -1
- package/lib/canvas-design.js +2 -2
- package/lib/canvas-system.js +1 -1
- package/lib/catalog.js +1 -2
- package/lib/delivery.js +1 -1
- package/lib/export-runtime-bundle.js +1 -1
- package/lib/fence-feedback.js +8 -2
- package/lib/freeform.js +2 -8
- package/lib/inspection.js +11 -6
- package/lib/motion-library.js +2 -3
- package/lib/normalize-authoring.js +9 -0
- package/lib/prompt-contract.js +22 -0
- package/lib/protocol.js +3 -3
- package/lib/refinement.js +3 -2
- package/lib/scene-entry.js +41 -0
- package/lib/style-system.js +2 -2
- package/lib/text-guide.js +9 -13
- package/lib/third-party/taste-skill-NOTICE.md +10 -0
- package/lib/tool-schema.js +7 -7
- package/lib/ui-intent.js +4 -15
- package/lib/ui-skill.js +19 -26
- package/lib/visual-audit.js +15 -4
- package/lib/visual-styles.js +1 -1
- package/package.json +1 -1
- package/skills/intelligent-ui/SKILL.md +14 -107
package/lib/fence-feedback.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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&&
|
|
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
|
}
|
package/lib/motion-library.js
CHANGED
|
@@ -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='
|
|
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, '
|
|
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, '
|
|
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
|
-
|
|
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.'}}
|
package/lib/style-system.js
CHANGED
|
@@ -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
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
package/lib/tool-schema.js
CHANGED
|
@@ -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:'
|
|
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
|
|
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
|
|
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('
|
|
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"}}.
|
|
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
|
|
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,
|
|
2
|
-
export const UI_INTENT_POLICY
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
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: {
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|
package/lib/visual-audit.js
CHANGED
|
@@ -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
|
|
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=
|
|
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
|
-
|
|
13
|
-
|
|
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
|
}
|
package/lib/visual-styles.js
CHANGED
|
@@ -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
|
|
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')}
|