dsh-motion-ui 0.5.3 → 0.5.5

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/lib/protocol.js CHANGED
@@ -3,11 +3,11 @@ import { SANDBOX_LIMITS } from './sandbox.js';
3
3
  import { COMPONENTS, componentFields } from './components.js';
4
4
  import { formatNumber } from './format-number.js';
5
5
  import { expandExpressions } from './author-expression.js';
6
+ import { validIdentifier as key } from './identifiers.js';
6
7
  export const LIMITS = Object.freeze({ bytes: 131072, nodes: 160, depth: 12, rows: 500, expressions: 128, steps: 4096 });
7
8
  const own = (o, k) => Object.hasOwn(o, k);
8
9
  const fail = message => { throw new Error(`DSH UI: ${message}`); };
9
10
  const object = x => x !== null && typeof x === 'object' && !Array.isArray(x);
10
- const key = x => typeof x === 'string' && /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(x) && !['constructor', 'prototype', '__proto__'].includes(x);
11
11
  const finite = x => typeof x === 'number' && Number.isFinite(x);
12
12
  const scalar = x => typeof x === 'string' || typeof x === 'boolean' || finite(x);
13
13
  const text = (x, at) => { if (typeof x !== 'string' || x.length > 8192) fail(`${at} must be a string of at most 8192 characters`); };
@@ -41,6 +41,10 @@ export function validateState(doc, state) {
41
41
  if (!finite(v) || v < s.min || v > s.max) fail(`state.${k} is outside its numeric range`);
42
42
  const offset = (v - s.min) / s.step;
43
43
  if (Math.abs(offset - Math.round(offset)) > 1e-6) fail(`state.${k} does not match its step`);
44
+ } else if (s.type === 'json') {
45
+ if (!object(v) && !Array.isArray(v)) fail(`state.${k} must be an array or object for type json`);
46
+ safeJson(v);
47
+ if (new TextEncoder().encode(JSON.stringify(v)).length > 16384) fail(`state.${k} JSON exceeds 16 KiB`);
44
48
  } else if (s.type === 'string') text(v, `state.${k}`);
45
49
  else if (typeof v !== 'boolean') fail(`state.${k} must be boolean`);
46
50
  if (s.options && !s.options.includes(v)) fail(`invalid option for state.${k}`);
@@ -50,7 +54,7 @@ export function validateState(doc, state) {
50
54
  }
51
55
  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] };
52
56
  function reference(doc, ref) {
53
- if (typeof ref !== 'string' || !/^(state|data|derived)\.[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(ref)) fail('invalid reference');
57
+ 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`);
54
58
  const [group, k] = ref.split('.');
55
59
  if (!own(doc[group], k)) fail(`unknown reference ${ref}`);
56
60
  return [group, k];
@@ -118,7 +122,7 @@ function checkViewValues(doc, evaluated) {
118
122
  if (n.type === 'chart' && rows.some(r => !own(r, n.labelKey) || !scalar(r[n.labelKey]) || !own(r, n.valueKey) || !finite(r[n.valueKey]))) fail('chart requires labels and finite numeric values');
119
123
  if (n.type === 'chart' && n.xScale === 'linear' && rows.some(r => !finite(r[n.labelKey]))) fail('linear chart requires numeric x values');
120
124
  }
121
- if (n.type === 'metric' && !finite(evaluated.resolveValue(n.value))) fail('metric requires a numeric value');
125
+ 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`);
122
126
  if (n.type === 'progress') {
123
127
  const v = evaluated.resolveValue(n.value), max = evaluated.resolveValue(n.max);
124
128
  if (!finite(v) || !finite(max) || max <= 0 || v < 0 || v > max) fail('progress requires 0 <= value <= positive max');
@@ -144,11 +148,14 @@ export function validateDocument(input) {
144
148
  for (const group of ['state', 'data', 'derived', 'actions']) {
145
149
  doc[group] ??= {};
146
150
  if (!object(doc[group]) || Object.keys(doc[group]).length > 64) fail(`invalid ${group}`);
147
- for (const k of Object.keys(doc[group])) if (!key(k)) fail(`invalid key in ${group}`);
151
+ for (const k of Object.keys(doc[group])) if (!key(k)) fail(`invalid key in ${group}: ${JSON.stringify(k)}; use 1..64 letters, numbers or underscores, starting with a letter/underscore (Chinese letters supported); no spaces, dots or hyphens. Put display labels in label/text`);
148
152
  }
149
153
  for (const [k, s] of Object.entries(doc.state)) {
150
154
  fields(s, ['type', 'default', 'min', 'max', 'step', 'options'], `state.${k}`);
151
- if (!['number', 'string', 'boolean'].includes(s.type)) fail(`unsupported state type ${k}`);
155
+ // Preserve structured checklist data instead of coercing it to a string.
156
+ if (s.type === 'string' && (Array.isArray(s.default) || object(s.default)) && !own(s,'options')) s.type = 'json';
157
+ if (!['number', 'string', 'boolean', 'json'].includes(s.type)) fail(`unsupported state type ${k}; use number, string, boolean or json (arrays/objects)`);
158
+ if (s.type === 'json' && own(s,'options')) fail(`state.${k}: json state does not support select options; use scalar options or a canvas`);
152
159
  if (s.type === 'number') {
153
160
  if (![s.min, s.max, s.step].every(finite) || s.min >= s.max || s.step <= 0) fail(`invalid numeric limits for ${k}`);
154
161
  if ((s.max - s.min) / s.step > 1e9) fail(`numeric step too small for ${k}`);
@@ -168,7 +175,7 @@ export function validateDocument(input) {
168
175
  const [, k] = reference(doc, e.map.source), rows = doc.data[k];
169
176
  if (!Array.isArray(rows) || rows.length > LIMITS.rows || !rows.every(object)) fail('map source must be object rows');
170
177
  if (!object(e.map.fields) || !Object.keys(e.map.fields).length || Object.keys(e.map.fields).length > 16) fail('map requires 1..16 fields');
171
- for (const [k, value] of Object.entries(e.map.fields)) { if (!key(k)) fail(`invalid map field ${JSON.stringify(k).slice(0,100)}; use an ASCII identifier matching [a-zA-Z][a-zA-Z0-9_]{0,63}, such as powerW. Put Chinese display text in column/chart labels, not internal field keys`); validateExpression(value, depth + 1, rows); }
178
+ for (const [k, value] of Object.entries(e.map.fields)) { if (!key(k)) fail(`invalid map field ${JSON.stringify(k).slice(0,100)}; use a letter/underscore identifier such as powerW or 功率, without spaces, dots or hyphens`); validateExpression(value, depth + 1, rows); }
172
179
  return;
173
180
  }
174
181
  if (own(e, 'const')) { fields(e, ['const'], 'expression'); if (!scalar(e.const)) fail('constant must be scalar'); return; }
@@ -208,6 +215,7 @@ export function validateDocument(input) {
208
215
  if (++count > LIMITS.nodes) fail('too many nodes');
209
216
  if (!object(n) || !key(n.id) || ids.has(n.id)) fail('node ids must be unique identifiers'); ids.add(n.id);
210
217
  if (!Object.hasOwn(COMPONENTS, n.type)) fail('unsupported component ' + n.type);
218
+ if (['table','chart'].includes(n.type) && typeof n.rows === 'string' && /^(data|derived)\./.test(n.rows)) n.rows={ref:n.rows};
211
219
  switch (n.type) {
212
220
  case 'sandbox': {
213
221
 
@@ -220,16 +228,25 @@ export function validateDocument(input) {
220
228
  }
221
229
  case 'row': case 'column': break;
222
230
  case 'card': if (n.title !== undefined) text(n.title, 'card title'); if (n.surface !== undefined && !UI_SURFACES.includes(n.surface)) fail('unknown card surface'); break;
223
- case 'details': text(n.title, 'details title'); if (n.open !== undefined && typeof n.open !== 'boolean') fail('details open must be boolean'); break;
231
+ case 'details':
232
+ text(n.title, 'details title');
233
+ if (n.open === 'true' || n.open === 'false') n.open = n.open === 'true';
234
+ if (n.open !== undefined && typeof n.open !== 'boolean') fail(`node.${n.id}.open must be boolean true or false`);
235
+ break;
224
236
  case 'badge': value(n.text, 'badge text'); break;
225
237
  case 'progress': text(n.label, 'progress label'); value(n.value, 'progress value'); value(n.max, 'progress max'); if (n.unit !== undefined) text(n.unit, 'progress unit'); break;
226
238
  case 'steps': {
227
-
228
- if (!Array.isArray(n.items) || !n.items.length || n.items.length > 12) fail('steps require 1..12 items');
229
- for (const item of n.items) {
230
- fields(item, ['title', 'description', 'status'], 'step'); text(item.title, 'step title');
231
- if (item.description !== undefined) text(item.description, 'step description');
232
- if (!['pending', 'active', 'done', 'error'].includes(item.status)) fail('unknown step status');
239
+ // Some function-call adapters emit an XML-style singular item wrapper.
240
+ // Unwrap only this exact, lossless shape; never drop sibling fields.
241
+ if (object(n.items) && Object.keys(n.items).length === 1 && Array.isArray(n.items.item)) n.items = n.items.item;
242
+ const at = `node.${n.id}.items`;
243
+ if (!Array.isArray(n.items)) fail(`${at} must be an array of 1..12 step objects, received ${n.items === null ? 'null' : typeof n.items}. Use items:[{"title":"First step","description":"One sentence"}], not items:{item:[...]}. status is optional for a static guide`);
244
+ if (!n.items.length || n.items.length > 12) fail(`${at} requires 1..12 items; received ${n.items.length}. Split a longer guide into separate steps nodes`);
245
+ for (const [i, item] of n.items.entries()) {
246
+ const stepAt = `${at}[${i}]`;
247
+ fields(item, ['title', 'description', 'status'], stepAt); text(item.title, `${stepAt}.title`);
248
+ if (item.description !== undefined) text(item.description, `${stepAt}.description`);
249
+ if (item.status !== undefined && !['pending', 'active', 'done', 'error'].includes(item.status)) fail(`${stepAt}.status must be pending, active, done or error; omit status for a static guide`);
233
250
  } break;
234
251
  }
235
252
  case 'heading': case 'text': value(n.text, 'text'); break;
@@ -240,18 +257,19 @@ export function validateDocument(input) {
240
257
  const [, k] = reference(doc, n.bind), spec = doc.state[k];
241
258
  if (n.type === 'slider' && spec.type !== 'number') fail('slider requires numeric state');
242
259
  if (n.type === 'toggle' && spec.type !== 'boolean') fail('toggle requires boolean state');
260
+ 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`);
243
261
  if (n.type === 'input' && spec.type === 'boolean') fail('use toggle for boolean state');
244
262
  if (n.type === 'select' && !spec.options) fail('select requires options'); break;
245
263
  }
246
264
  case 'button': text(n.text, 'button text'); if (!own(doc.actions, n.action)) fail('unknown action'); break;
247
265
  case 'table': {
248
- if(Array.isArray(n.rows))fail('table.rows must use {ref:"data.rows"}; move the array into document.data.rows, not inline in the table');
249
- value(n.rows, 'table rows');
266
+ if (n.label !== undefined) text(n.label, 'table label');
267
+ if (!Array.isArray(n.rows)) value(n.rows, 'table rows');
250
268
  if (!Array.isArray(n.columns) || !n.columns.length || n.columns.length > 16) fail('invalid table columns');
251
269
  const cols = new Set();
252
270
  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;
253
271
  }
254
- case 'chart': value(n.rows, 'chart rows'); if (!['bar', 'line'].includes(n.kind) || !key(n.labelKey) || !key(n.valueKey)) fail('invalid chart'); text(n.label, 'chart label'); if (n.unit !== undefined) text(n.unit, 'chart unit'); if (n.xScale !== undefined && (!['category', 'linear'].includes(n.xScale) || n.xScale === 'linear' && n.kind !== 'line')) fail('invalid xScale'); break;
272
+ case 'chart': if (!Array.isArray(n.rows)) value(n.rows, 'chart rows'); if (!['bar', 'line'].includes(n.kind) || !key(n.labelKey) || !key(n.valueKey)) fail('invalid chart'); text(n.label, 'chart label'); if (n.unit !== undefined) text(n.unit, 'chart unit'); if (n.xScale !== undefined && (!['category', 'linear'].includes(n.xScale) || n.xScale === 'linear' && n.kind !== 'line')) fail('invalid xScale'); break;
255
273
  default: fail(`unsupported component ${n.type}`);
256
274
  }
257
275
  if (['heading', 'text', 'card', 'metric', 'button', 'badge', 'progress', 'details'].includes(n.type)) {
@@ -283,9 +301,9 @@ export function fallbackText(doc) {
283
301
  if (n.type === 'sandbox') lines.push(n.title, n.fallback);
284
302
  if ((n.type === 'card' || n.type === 'details') && n.title) lines.push(n.title);
285
303
  if (n.type === 'progress') lines.push(`${n.label}: ${resolveValue(n.value)} / ${resolveValue(n.max)} ${n.unit ?? ''}`);
286
- if (n.type === 'steps') for (const item of n.items) lines.push(`[${item.status}] ${item.title}${item.description ? ': ' + item.description : ''}`);
287
- if (n.type === 'metric') lines.push(`${n.label}: ${formatNumber(resolveValue(n.value),n.decimals ?? 2)} ${n.unit ?? ''}`);
288
- if (n.type === 'table') { lines.push(n.columns.map(c => c.label + (c.unit ? ` (${c.unit})` : '')).join(' | ')); for (const row of resolveValue(n.rows)) lines.push(n.columns.map(c => Object.hasOwn(row, c.key) ? String(row[c.key]) : '—').join(' | ')); }
304
+ if (n.type === 'steps') for (const [i,item] of n.items.entries()) lines.push(`${item.status ? `[${item.status}]` : `${i+1}.`} ${item.title}${item.description ? ': ' + item.description : ''}`);
305
+ if (n.type === 'metric') { const v=resolveValue(n.value); lines.push(`${n.label}: ${typeof v === 'number' ? formatNumber(v,n.decimals ?? 2) : String(v)} ${n.unit ?? ''}`); }
306
+ if (n.type === 'table') { if(n.label)lines.push(n.label); lines.push(n.columns.map(c => c.label + (c.unit ? ` (${c.unit})` : '')).join(' | ')); for (const row of resolveValue(n.rows)) lines.push(n.columns.map(c => Object.hasOwn(row, c.key) ? String(row[c.key]) : '—').join(' | ')); }
289
307
  if (n.type === 'chart') { lines.push(n.label + (n.unit ? ` (${n.unit})` : '')); for (const r of resolveValue(n.rows)) lines.push(`${r[n.labelKey]}: ${r[n.valueKey]}`); }
290
308
  if (n.children) walk(n.children);
291
309
  } }
package/lib/stream.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { LIMITS, validateDocument } from './protocol.js';
2
+ import { toolJson } from './tool-json.js';
2
3
 
3
4
  // Parse complete JSON tokens only. Never finish a string, number or model expression.
4
5
  // Partial containers retain completed members, but only the document wrapper and
@@ -62,7 +63,7 @@ export function previewDocument(raw) {
62
63
  try {
63
64
  const root=parseToolArguments(raw);if(!root)return null;
64
65
  const document = root.members?.get('document');
65
- if(document?.complete&&typeof document.value==='string'&&root.members.size===1)return validateDocument(JSON.parse(document.value));
66
+ if(document?.complete&&typeof document.value==='string'&&root.members.size===1)return validateDocument(toolJson(document.value,'document'));
66
67
  if (!document?.members) return null;
67
68
  if ([...root.members.keys()].some(k => k !== 'document')) return null;
68
69
  const doc = { ...document.value }, nodes = document.members.get('nodes');
package/lib/tool-json.js CHANGED
@@ -1,7 +1,26 @@
1
1
  // DSH adapters may represent `type: json` fields as JSON strings.
2
2
  // Decode only the tool field, once; strict protocol validation still applies.
3
+ function clean(source) {
4
+ let input=source.trim().replace(/^\uFEFF/, '');
5
+ const fence=input.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i);
6
+ if(fence)input=fence[1];
7
+ let output='',quoted=false,escaped=false;
8
+ for(let i=0;i<input.length;i++){
9
+ const c=input[i];
10
+ if(quoted){output+=c;if(escaped)escaped=false;else if(c==='\\')escaped=true;else if(c==='"')quoted=false;continue;}
11
+ if(c==='"')quoted=true;
12
+ if(c===',' && /^\s*[}\]]/.test(input.slice(i+1)))continue;
13
+ output+=c;
14
+ }
15
+ return output;
16
+ }
3
17
  export function toolJson(value, field, maxBytes=131072){
4
18
  if(typeof value!=='string')return value;
5
- if(Buffer.byteLength(value,'utf8')>maxBytes)throw new Error(`DSH UI: ${field} JSON exceeds size limit`);
6
- try{return JSON.parse(value)}catch{throw new Error(`DSH UI: ${field} must contain valid JSON. Fix quoting/escaping. For a standalone HTML/SVG canvas, render_canvas accepts separate html/css/script fields.`)}
19
+ if(new TextEncoder().encode(value).length>maxBytes)throw new Error(`DSH UI: ${field} JSON exceeds size limit`);
20
+ const source=clean(value);
21
+ try{return JSON.parse(source)}catch(error){
22
+ const position=error.message.match(/position (\d+)/)?.[1];
23
+ const location=position===undefined?'':` at line ${source.slice(0,Number(position)).split('\n').length}, column ${Number(position)-source.lastIndexOf('\n',Number(position)-1)}`;
24
+ throw new Error(`DSH UI: ${field} must contain valid JSON${location}. Pass an object directly, not a manually escaped JSON string. Do not use single-quoted keys or raw newlines inside strings. For HTML/SVG use render_canvas with separate html/css/script fields. No partial document was saved.`);
25
+ }
7
26
  }
@@ -0,0 +1,39 @@
1
+ import {COMPONENTS} from './components.js';
2
+ // Expose the document skeleton to native function calling, not just prose.
3
+ // Per-component and expression checks remain in the shared runtime validator.
4
+ const dictionary=description=>({type:'object',additionalProperties:true,description});
5
+ export const DOCUMENT_PARAMETER={
6
+ required:true,
7
+ description:'Pass the document as an OBJECT. Do not manually escape/serialize it. For HTML/SVG source prefer render_canvas.',
8
+ type:'object',additionalProperties:false,
9
+ properties:{
10
+ version:{type:'number',const:1,required:true},
11
+ title:{type:'string',required:true},
12
+ fallback:{type:'string',required:true,description:'Useful plain-text explanation if the UI is unavailable.'},
13
+ nodes:{type:'array',required:true,items:{type:'object',additionalProperties:true,properties:{
14
+ id:{type:'string',required:true,description:'Unique letter/underscore identifier; Chinese supported, max64.'},
15
+ type:{type:'string',required:true,enum:Object.keys(COMPONENTS)},
16
+ label:{type:'string'},title:{type:'string'},
17
+ items:{type:'array',description:'steps only: direct array, not {item:[...]}. 1..12 step objects. Omit status for a static process guide.',items:{type:'object',additionalProperties:false,properties:{
18
+ title:{type:'string',required:true},description:{type:'string'},status:{type:'string',enum:['pending','active','done','error']}
19
+ }}},
20
+ open:{type:'boolean',description:'details only; defaults to closed.'},
21
+ icon:{type:'string'},tone:{type:'string'},surface:{type:'string'},
22
+ max:{type:'json',description:'progress only: positive numeric value or binding.'},unit:{type:'string'},decimals:{type:'number'},
23
+ action:{type:'string',description:'button only: a key declared in document.actions.'},
24
+ kind:{type:'string',enum:['bar','line']},labelKey:{type:'string'},valueKey:{type:'string'},xScale:{type:'string'},
25
+ html:{type:'string'},css:{type:'string'},script:{type:'string'},fallback:{type:'string'},height:{type:'number'},
26
+ stateKeys:{type:'array',items:{type:'string'}},dataKeys:{type:'array',items:{type:'string'}},derivedKeys:{type:'array',items:{type:'string'}},
27
+ text:{type:'json',description:'Literal scalar or {ref}/{expr}.'},
28
+ value:{type:'json',description:'metric: number or display string/boolean, or {ref}/{expr}. progress: numeric.'},
29
+ rows:{type:'json',description:'table/chart: inline array of objects, {ref:"data.rows"}/{ref:"derived.rows"}, or scoped reference string.'},
30
+ columns:{type:'array',items:{type:'object',additionalProperties:false,properties:{key:{type:'string',required:true},label:{type:'string',required:true},unit:{type:'string'}}}},
31
+ 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'}}
32
+ }}},
33
+ state:dictionary('key -> {type:number/string/boolean/json,default,...}. Numeric state needs min,max,step. json is bounded array/object state for canvas controls.'),
34
+ data:dictionary('Static data; Chinese identifier keys supported. Omit if rows are inline.'),
35
+ derived:dictionary('Local expressions or maps. Omit when unused.'),
36
+ actions:dictionary('Declared agent.followup actions. Omit when unused.'),
37
+ presentation:dictionary('Optional layout/accent/density from registered tokens.')
38
+ }
39
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-motion-ui",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Interactive UI, HTML/SVG animation and local calculations for DeepSeek Harness Web, with injected design guidance.",
5
5
  "type": "module",
6
6
  "main": "index.js",