dsh-motion-ui 0.6.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 +88 -0
- package/README.md +55 -1
- package/VERIFICATION.md +46 -0
- package/client.js +2136 -1095
- package/examples/mechanism-assembly.json +50 -0
- package/examples/mechanism-crank.json +103 -0
- package/examples/mechanism-gears.json +103 -0
- package/index.js +132 -106
- package/lib/assets/standalone-runtime.js +3 -1
- package/lib/assets/visual.js +3 -1
- package/lib/author-expression.js +9 -8
- package/lib/authoring-guide.js +3 -16
- 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 +5 -6
- package/lib/components.js +2 -2
- package/lib/delivery.js +1 -1
- package/lib/export-runtime-bundle.js +1 -1
- package/lib/expressive-examples.js +6 -0
- package/lib/fence-feedback.js +8 -2
- package/lib/freeform.js +4 -9
- package/lib/inspection.js +11 -6
- package/lib/mechanism-catalog.js +24 -0
- package/lib/mechanism-css.js +13 -0
- package/lib/mechanism-entry.js +20 -0
- package/lib/mechanism-geometry.js +62 -0
- package/lib/mechanism-render.js +24 -0
- package/lib/mechanism-runtime-bundle.js +2 -0
- package/lib/mechanism-scenes.js +20 -0
- package/lib/motion-library.js +17 -0
- package/lib/normalize-authoring.js +15 -2
- package/lib/prompt-contract.js +22 -0
- package/lib/protocol.js +26 -11
- package/lib/refinement.js +3 -2
- package/lib/sandbox.js +2 -0
- package/lib/scene-entry.js +41 -0
- package/lib/style-system.js +28 -0
- package/lib/text-guide.js +9 -13
- package/lib/third-party/anatomy-LICENSE.txt +21 -0
- package/lib/third-party/anatomy-NOTICE.md +13 -0
- package/lib/third-party/anatomy-iso.mjs +674 -0
- package/lib/third-party/lucian-uxui-LICENSE.txt +21 -0
- package/lib/third-party/lucian-uxui-NOTICE.md +1 -0
- package/lib/third-party/taste-skill-NOTICE.md +10 -0
- package/lib/tool-schema.js +9 -9
- package/lib/ui-intent.js +4 -15
- package/lib/ui-skill.js +19 -25
- package/lib/visual-audit.js +15 -4
- package/lib/visual-styles.js +134 -22
- package/package.json +92 -92
- package/skills/intelligent-ui/SKILL.md +22 -102
package/lib/protocol.js
CHANGED
|
@@ -56,7 +56,7 @@ export function validateState(doc, state) {
|
|
|
56
56
|
}
|
|
57
57
|
return result;
|
|
58
58
|
}
|
|
59
|
-
const ARITY = { add: [1, 8], sub: [2, 2], mul: [1, 8], div: [2, 2], min: [1, 8], max: [1, 8], round: [1, 1], abs: [1, 1], eq: [2, 2], neq: [2, 2], lt: [2, 2], lte: [2, 2], gt: [2, 2], gte: [2, 2], if: [3, 3] };
|
|
59
|
+
const ARITY = { add: [1, 8], sub: [2, 2], mul: [1, 8], div: [2, 2], min: [1, 8], max: [1, 8], round: [1, 1], ceil: [1, 1], floor: [1, 1], abs: [1, 1], eq: [2, 2], neq: [2, 2], lt: [2, 2], lte: [2, 2], gt: [2, 2], gte: [2, 2], if: [3, 3] };
|
|
60
60
|
function reference(doc, ref) {
|
|
61
61
|
if (typeof ref !== 'string' || !['state','data','derived'].includes(ref.split('.')[0]) || ref.split('.').length !== 2 || !key(ref.split('.')[1])) fail(`invalid reference ${JSON.stringify(ref)}; use state.key, data.key or derived.key with a letter/underscore identifier`);
|
|
62
62
|
const [group, k] = ref.split('.');
|
|
@@ -100,7 +100,9 @@ export function evaluate(doc, state) {
|
|
|
100
100
|
case 'div': if (a[1] === 0) fail('division by zero'); out = a[0] / a[1]; break;
|
|
101
101
|
case 'min': out = Math.min(...a); break;
|
|
102
102
|
case 'max': out = Math.max(...a); break;
|
|
103
|
-
case 'round': out = Math.round(a[0]); break;
|
|
103
|
+
case 'round': out = Math.round(a[0]); break;
|
|
104
|
+
case 'ceil': out = Math.ceil(a[0]); break;
|
|
105
|
+
case 'floor': out = Math.floor(a[0]); break;
|
|
104
106
|
case 'abs': out = Math.abs(a[0]); break;
|
|
105
107
|
case 'lt': return a[0] < a[1];
|
|
106
108
|
case 'lte': return a[0] <= a[1];
|
|
@@ -112,7 +114,9 @@ export function evaluate(doc, state) {
|
|
|
112
114
|
};
|
|
113
115
|
for (const k of Object.keys(doc.derived)) resolve(`derived.${k}`);
|
|
114
116
|
if (new TextEncoder().encode(JSON.stringify(derived)).length > LIMITS.bytes) fail('derived output exceeds 128 KiB');
|
|
117
|
+
const resolveCell=cell=>{const out=expression(cell);if(!scalar(out))fail('inline row formula cells must resolve to a string, boolean or finite number');return out};
|
|
115
118
|
const result = { derived, resolveValue: value => {
|
|
119
|
+
if (Array.isArray(value)) return value.map(row => object(row) ? Object.fromEntries(Object.entries(row).map(([k,cell])=>[k,object(cell)&&['const','ref','op'].some(field=>own(cell,field))?resolveCell(cell):cell])) : row);
|
|
116
120
|
if (!object(value)) return value;
|
|
117
121
|
return expression(value);
|
|
118
122
|
} };
|
|
@@ -123,7 +127,10 @@ function checkViewValues(doc, evaluated) {
|
|
|
123
127
|
if (n.type === 'table' || n.type === 'chart') {
|
|
124
128
|
const rows = evaluated.resolveValue(n.rows);
|
|
125
129
|
if (!Array.isArray(rows) || rows.length > LIMITS.rows || !rows.every(object)) fail('table/chart rows must reference an array of objects');
|
|
126
|
-
if (n.type === 'chart'
|
|
130
|
+
if (n.type === 'chart') {
|
|
131
|
+
const index=rows.findIndex(r=>!own(r,n.labelKey)||!scalar(r[n.labelKey])||!own(r,n.valueKey)||!finite(r[n.valueKey]));
|
|
132
|
+
if(index>=0){const row=rows[index],missing=[n.labelKey,n.valueKey].filter(k=>!own(row,k));fail('chart requires labels and finite numeric values. node.'+n.id+'.rows['+index+']: '+(missing.length?'missing field(s) '+missing.join(', '):n.valueKey+' must resolve to a finite number')+'. Available keys: '+Object.keys(row).slice(0,16).join(', ')+'. For live values use inline rows:[{'+n.labelKey+':"项目",'+n.valueKey+':{ref:"derived.amount"}}]; static data labels alone contain no amounts. Keep all nodes/state/derived when correcting the document');}
|
|
133
|
+
}
|
|
127
134
|
if (n.type === 'chart' && n.xScale === 'linear' && rows.some(r => !finite(r[n.labelKey]))) fail('linear chart requires numeric x values');
|
|
128
135
|
}
|
|
129
136
|
if (n.type === 'metric' && !scalar(evaluated.resolveValue(n.value))) fail(`node.${n.id}.value must resolve to a number, string or boolean; use table for rows or a canvas for structured data`);
|
|
@@ -150,7 +157,7 @@ export function validateDocument(input) {
|
|
|
150
157
|
for (const [k, allowed] of Object.entries({ layout: UI_LAYOUTS, accent: UI_ACCENTS, density: ['comfortable', 'compact'] }))
|
|
151
158
|
if (doc.presentation[k] !== undefined && !allowed.includes(doc.presentation[k])) fail(`unknown presentation ${k}`);
|
|
152
159
|
}
|
|
153
|
-
if (doc.version !== 1) fail('
|
|
160
|
+
if (doc.version !== 1) fail(own(doc,'version') ? 'document.version must be 1' : 'document.version is missing. render_ui creates a complete document, not a partial patch: send {version:1,title,fallback,nodes:[...]} for shared controls/formulas, or {title,items:[...]} for extended visuals. Include all content, not only data/derived');
|
|
154
161
|
text(doc.title, 'title'); text(doc.fallback, 'fallback');
|
|
155
162
|
if (!doc.title.trim() || !doc.fallback.trim()) fail('title and fallback cannot be empty');
|
|
156
163
|
for (const group of ['state', 'data', 'derived', 'actions']) {
|
|
@@ -171,11 +178,13 @@ export function validateDocument(input) {
|
|
|
171
178
|
} else if (['min', 'max', 'step'].some(p => own(s, p))) fail(`numeric limits on non-number ${k}`);
|
|
172
179
|
if (s.options !== undefined && (!Array.isArray(s.options) || !s.options.length || s.options.length > 64 || !s.options.every(scalar))) fail(`state.${k}.options must be a direct array of 1..64 scalar values, e.g. options:["3","5","7"], not {item:[...]}`);
|
|
173
180
|
}
|
|
174
|
-
const defaults = validateState(doc, initialState(doc)); let expressions = 0;
|
|
181
|
+
const defaults = validateState(doc, initialState(doc)); let expressions = 0; const expressionShapes=new Set();
|
|
175
182
|
for (const [k, spec] of Object.entries(doc.state))
|
|
176
183
|
for (const option of spec.options ?? []) validateState(doc, { ...defaults, [k]: option });
|
|
177
184
|
function validateExpression(e, depth = 0, itemRows, at = 'expression') {
|
|
178
|
-
if (
|
|
185
|
+
if (depth > LIMITS.depth) fail(at+': expression nesting exceeds '+LIMITS.depth);
|
|
186
|
+
const shape=JSON.stringify(e);
|
|
187
|
+
if(!expressionShapes.has(shape)){expressionShapes.add(shape);if(++expressions>LIMITS.expressions)fail(at+': expression limit exceeded (128 distinct expressions). Reuse derived references instead of expanding long formulas; keep the complete document when retrying');}
|
|
179
188
|
if (!object(e)) fail(`${at}: expression must be an object`);
|
|
180
189
|
if (own(e, 'map')) {
|
|
181
190
|
if (depth !== 0) fail('map is only allowed at the root of a derived value');
|
|
@@ -217,9 +226,10 @@ export function validateDocument(input) {
|
|
|
217
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');
|
|
218
227
|
}
|
|
219
228
|
const ids = new Map(); let count = 0;
|
|
220
|
-
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}`); };
|
|
221
230
|
function nodes(list, depth = 0, path = 'nodes') {
|
|
222
|
-
if (!Array.isArray(list)
|
|
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
|
+
if (depth > LIMITS.depth) fail(path+' exceeds maximum node depth '+LIMITS.depth);
|
|
223
233
|
for (const [index,n] of list.entries()) {
|
|
224
234
|
const at = `${path}[${index}]`;
|
|
225
235
|
if (++count > LIMITS.nodes) fail('too many nodes');
|
|
@@ -245,6 +255,7 @@ export function validateDocument(input) {
|
|
|
245
255
|
if (!n.fallback.trim()) fail(`${at}.fallback must not be empty`);
|
|
246
256
|
if (typeof n.html !== 'string' || !n.html.trim()) fail(`${at}.html must be a non-empty HTML string; script/css/fallback belong beside html on the sandbox node, not inside it`);
|
|
247
257
|
for (const field of ['html', 'css', 'script']) if (n[field] !== undefined && (typeof n[field] !== 'string' || n[field].length > SANDBOX_LIMITS.source)) fail('canvas source exceeds 48 Ki characters');
|
|
258
|
+
if(n.libraries!==undefined&&(!Array.isArray(n.libraries)||n.libraries.length>1||n.libraries.some(v=>v!=='mechanism')))fail('canvas libraries supports only ["mechanism"] or []');
|
|
248
259
|
if (n.height !== undefined && (!Number.isInteger(n.height) || n.height < 160 || n.height > SANDBOX_LIMITS.height)) fail('canvas height must be 160..1000');
|
|
249
260
|
for (const [field, group] of [['stateKeys', 'state'], ['dataKeys', 'data'], ['derivedKeys', 'derived']]) if (n[field] !== undefined && (!Array.isArray(n[field]) || n[field].length > 64 || new Set(n[field]).size !== n[field].length || n[field].some(k => !own(doc[group], k)))) fail('invalid canvas ' + field);
|
|
250
261
|
break;
|
|
@@ -289,7 +300,8 @@ export function validateDocument(input) {
|
|
|
289
300
|
if (n.type === 'toggle' && spec.type !== 'boolean') fail('toggle requires boolean state');
|
|
290
301
|
if (['input','select'].includes(n.type) && spec.type === 'json') fail(`node.${n.id}: structured state requires a sandbox with DSHUI.setState; use separate boolean states and toggle nodes for a native checklist`);
|
|
291
302
|
if (n.type === 'input' && spec.type === 'boolean') fail('use toggle for boolean state');
|
|
292
|
-
if (n.type === 'select' && !spec.options) fail('select requires options');
|
|
303
|
+
if (n.type === 'select' && !spec.options) fail('select requires options');
|
|
304
|
+
if(n.type==='select'&&n.variant!==undefined&&!['dropdown','segmented'].includes(n.variant))fail(`node.${n.id}.variant must be dropdown or segmented`);break;
|
|
293
305
|
}
|
|
294
306
|
case 'code':
|
|
295
307
|
if(typeof n.code!=='string'||n.code.length>SANDBOX_LIMITS.source)fail('code must be a string of at most 48 Ki characters');
|
|
@@ -298,18 +310,21 @@ export function validateDocument(input) {
|
|
|
298
310
|
case 'divider': if(n.label!==undefined)text(n.label,'divider label');break;
|
|
299
311
|
case 'table': {
|
|
300
312
|
if (n.label !== undefined) text(n.label, 'table label');
|
|
301
|
-
if (!Array.isArray(n.rows)) value(n.rows, '
|
|
313
|
+
if (!Array.isArray(n.rows)) value(n.rows, 'node.'+n.id+'.rows');
|
|
302
314
|
if (!Array.isArray(n.columns) || !n.columns.length || n.columns.length > 16) fail('invalid table columns');
|
|
303
315
|
const cols = new Set();
|
|
304
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;
|
|
305
317
|
}
|
|
306
|
-
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;
|
|
307
319
|
default: fail(`unsupported component ${n.type}`);
|
|
308
320
|
}
|
|
309
321
|
if (['heading', 'text','callout', 'card', 'metric', 'button','link', 'badge', 'progress', 'details'].includes(n.type)) {
|
|
310
322
|
if (n.icon !== undefined && !UI_ICONS.includes(n.icon)) fail('unknown UI icon');
|
|
311
323
|
if (!['button','link'].includes(n.type)) { if (n.tone !== undefined && !UI_TONES.includes(n.tone)) fail('unknown UI tone'); }
|
|
312
324
|
}
|
|
325
|
+
if (['table','chart'].includes(n.type) && Array.isArray(n.rows)) {
|
|
326
|
+
for (const [i,row] of n.rows.entries()) if(object(row)) for(const [k,cell] of Object.entries(row)) if(object(cell)&&['const','ref','op'].some(field=>own(cell,field))) value(cell,at+'.rows['+i+'].'+k);
|
|
327
|
+
}
|
|
313
328
|
fields(n, componentFields(n.type), `node.${n.id}`);
|
|
314
329
|
if (['row', 'column', 'card', 'details'].includes(n.type)) nodes(n.children, depth + 1, `${at}.children`);
|
|
315
330
|
}
|
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
|
}
|
package/lib/sandbox.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {MECHANISM_RUNTIME} from './mechanism-runtime-bundle.js';
|
|
1
2
|
import { measureVisual } from './visual-audit.js';
|
|
2
3
|
import { formatNumber } from './format-number.js';
|
|
3
4
|
import { CANVAS_SYSTEM_CSS } from './canvas-system.js';
|
|
@@ -35,6 +36,7 @@ export function sandboxDocument(node, channel, nonce) {
|
|
|
35
36
|
motion.addEventListener('change',refresh);document.addEventListener('visibilitychange',refresh);
|
|
36
37
|
const formatNumber=${formatNumber.toString()};
|
|
37
38
|
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}}}};
|
|
39
|
+
${node.libraries?.includes('mechanism')?MECHANISM_RUNTIME.replace(/<\/script/gi,'<\\/script')+';api.mechanism=__MotionMechanism.createMechanismAPI(api);':''}
|
|
38
40
|
Object.defineProperty(window,'DSHUI',{value:Object.freeze(api),writable:false});
|
|
39
41
|
for(const type of ['click','input','change','keydown','pointerdown'])document.addEventListener(type,e=>{if(snapshot.disabled){e.preventDefault();e.stopImmediatePropagation()}},true);
|
|
40
42
|
addEventListener('message',event=>{
|
|
@@ -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.'}}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Visual grammar, shared by native rendering, previews and model guidance.
|
|
2
|
+
// IDs are stable configuration values; no additional user switches are introduced.
|
|
3
|
+
export const STYLE_SYSTEM=Object.freeze({
|
|
4
|
+
editorial:{description:'开放正文 · 杂志层级 · 细线分组',grammar:'编辑式阅读',composition:'先给一个明确结论,再用开放正文、左右对齐比较和细线段落展开。用 column 组织文章,card 在此风格中是章节,不是封闭面板;不要给每段套框。指标嵌在解释旁,流程像文中的编号提纲。',typography:'标题有衬线阅读感,正文行距舒展;标题、正文、注释至少三个层级,强调依赖字重和位置。',surfaces:'透明正文、细分隔线、低阴影或无阴影;保持结构性留白,不加大面积渐变。',controls:'表单像文章中的设置行,标签与当前值左右对齐;按钮紧凑,代码像有边注的摘录。',avoid:'避免大彩色卡片、每个数字一个盒子、粗重边框和装饰性背景。',native:{title:27,weight:650,gap:24,radius:12,metric:32,feedback:210,gain:.25}},
|
|
5
|
+
soft:{description:'笔记色块 · 柔圆分组 · 舒缓节奏',grammar:'柔和彩色笔记',composition:'像清晰友好的笔记:把相关内容放在柔圆色块里,先提示主题,再给两三项要点。章节标题可以是小标签;比较项如并排便签,流程像一串有间距的笔记。',typography:'圆润标题和温和字重;正文清楚,数值与单位贴近,次要说明放在组内。',surfaces:'低饱和彩色便签、不同章节轻微交替色块、不对称的大圆角;使用实色底或少量柔和渐变。',controls:'控件内嵌在柔色分组,选择器像标签,操作有柔和回弹;避免每行都变成边框表单。',avoid:'避免工程网格、重黑海报、透明玻璃、泛白低对比度文字。',native:{title:25,weight:700,gap:20,radius:28,metric:36,feedback:390,gain:.7}},
|
|
6
|
+
technical:{description:'工程网格 · 数据条带 · 编号连接',grammar:'技术说明与工程图',composition:'像工程说明:按输入→机制→输出组织;优先对齐的数据条带、编号流程、表格和有意义的连接线。指标放成紧凑测量带而非一排装饰卡;比较围绕相同维度对齐。',typography:'中文正文保持正常字号,数值/单位/序号用等宽字体与 tabular-nums;标题简短精确,明确基准、单位和假设。',surfaces:'细线网格、规则的小圆角、清晰边界和表头、无浮动投影。网格仅在图解背景使用,文字区域保持干净。',controls:'标签和输入严格对齐,参数区像仪表设置行;按钮和分段选项采用规则矩形,保留键盘及全局反馈。',avoid:'避免胶囊堆叠、巨大海报数字、彩色便签、装饰箭头、靠小字硬塞信息。',native:{title:22,weight:750,gap:16,radius:10,metric:28,feedback:180,gain:.3}},
|
|
7
|
+
expressive:{description:'海报层级 · 非对称重点 · 醒目数字',grammar:'活力展示与重点海报',composition:'先选一个真正的视觉中心:大标题或关键数字,再用不对称分栏展开解释。主要结论占更大空间,次要比较退后;流程用醒目的序号与短句。不要把每一项都做成同等大小卡片。',typography:'短而有力的重标题、大数字、紧凑标题行距,正文仍易读。最多一个主要强调区,其余留白和细线辅助。',surfaces:'实色色块、粗细明确的边线、局部高对比强调;可用橙/珊瑚/暖黄,但不能整页争抢注意力。',controls:'主要操作醒目、次要操作轻量;表单和数据保持对齐,回弹比阅读风格明显但不空闲循环。',avoid:'避免六张等大的指标卡、满屏渐变、全屏大留白、无意义数字装饰。',native:{title:34,weight:850,gap:22,radius:20,metric:50,feedback:480,gain:1.1}},
|
|
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
|
+
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
|
+
});
|
|
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
|
+
// Same facts, intentionally different content composition. Model examples are
|
|
13
|
+
// complete small documents; preview uses the same grammar without live controls.
|
|
14
|
+
export function grammarExample(id){
|
|
15
|
+
const heading={type:'heading',text:'先交付一个可用版本'},body={type:'text',text:'把目标缩小,把关键体验做好。'},note={type:'text',text:'先验证,再逐步完善。',color:'muted'};
|
|
16
|
+
const choice=(title,text)=>({type:'card',title,children:[{type:'text',text}]});
|
|
17
|
+
const choices=[choice('先做小版本','验证重点'),choice('先铺全功能','覆盖需求')];
|
|
18
|
+
const metrics=[{type:'metric',label:'推进阶段',value:3,unit:'步'},{type:'metric',label:'核心目标',value:1,unit:'个'}];
|
|
19
|
+
const flow={type:'steps',items:[{title:'明确目标',description:'先抓住重点。'},{title:'解释关系',description:'用图形说明。'},{title:'给出行动',description:'收成下一步。'}]};
|
|
20
|
+
let preset='brief',blocks;
|
|
21
|
+
if(id==='editorial'){preset='reading';blocks=[heading,body,{type:'row',columns:2,children:choices},{type:'row',columns:2,children:metrics},flow,note]}
|
|
22
|
+
else if(id==='soft'){blocks=[{type:'card',title:'今天的重点',children:[heading,body]},{type:'row',columns:2,children:choices},flow,{type:'row',columns:2,children:metrics},note]}
|
|
23
|
+
else if(id==='technical'){blocks=[heading,body,{type:'row',columns:2,children:metrics},flow,{type:'table',label:'推进方式',columns:[{key:'method',label:'方案'},{key:'focus',label:'重点'}],rows:[{method:'先做小版本',focus:'验证重点'},{method:'先铺全功能',focus:'覆盖需求'}]},note]}
|
|
24
|
+
else if(id==='expressive'){preset='minimal';blocks=[heading,{type:'row',columns:2,children:[{type:'column',children:[metrics[0],body]},{type:'column',children:[metrics[1],note]}]},{type:'row',columns:2,children:choices},flow]}
|
|
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
|
+
else{blocks=[heading,body,{type:'row',columns:2,children:metrics},{type:'row',columns:2,children:choices},flow,note]}
|
|
27
|
+
return {tool:'render_text',args:{title:'把复杂问题讲清楚',preset,blocks}};
|
|
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,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Anatomy attribution
|
|
2
|
+
|
|
3
|
+
Selected isometric camera, projection, contour and fitting helpers are bundled from
|
|
4
|
+
Anatomy by Ryan, MIT licensed:
|
|
5
|
+
https://github.com/wheresryan22/anatomy
|
|
6
|
+
|
|
7
|
+
Source revision: 454a0ef801b6d9b905f08309d094491c7085b3dd
|
|
8
|
+
Original file: kit/iso-kit.mjs (retained as anatomy-iso.mjs).
|
|
9
|
+
License: anatomy-LICENSE.txt. Copyright (c) 2026 Ryan.
|
|
10
|
+
|
|
11
|
+
Motion UI's scene validation, renderer, state bridge, controls, simplified mechanism
|
|
12
|
+
models and examples are original integration code. This is a compact explanatory
|
|
13
|
+
renderer, not Anatomy's full collision/audit system or its beta 3D/WebGL runtime.
|