stillmade-sdk 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENTS.md +8 -8
  2. package/IMAGE_DESCRIPTION.md +2 -0
  3. package/MODULES.md +68 -10
  4. package/NATIVE_MCP.md +18 -7
  5. package/PASTE_TO_LLM.md +121 -50
  6. package/PORTABLE.md +7 -3
  7. package/README.md +19 -6
  8. package/REMIX.md +6 -0
  9. package/SDK.md +226 -103
  10. package/TRANSCRIPTION.md +4 -2
  11. package/UI_AESTHETIC.md +2 -0
  12. package/api-index.json +1 -1
  13. package/blocks/narration/package.js +1 -1
  14. package/blocks/narration/versions/1.0.1/package.js +221 -0
  15. package/blocks/native/sm.animated-scene/versions/2.3.9/package.js +769 -0
  16. package/blocks/native/sm.board-editor/versions/2.2.11/package.js +551 -0
  17. package/blocks/native/sm.describe-media/versions/2.1.0/package.js +140 -0
  18. package/blocks/native/sm.panel-strip/versions/2.4.2/package.js +1316 -0
  19. package/blocks/native/sm.photo-notes/versions/1.1.0/package.js +170 -0
  20. package/blocks/native/sm.pipeline/versions/2.2.6/package.js +1095 -0
  21. package/blocks/screen-recorder/package.js +1 -1
  22. package/blocks/screen-recorder/versions/1.0.1/package.js +223 -0
  23. package/package.json +1 -1
  24. package/packages/block-cli/cli.js +91 -35
  25. package/packages/block-cli/dev-host/assets/host.js +35 -32
  26. package/packages/block-cli/dev-host/assets/index.css +1 -1
  27. package/packages/block-cli/dev-server.js +4 -0
  28. package/packages/block-cli/ready-gate.js +19 -4
  29. package/packages/block-cli/templates.js +33 -21
  30. package/packages/block-platform/block-readiness.js +10 -1
  31. package/packages/block-platform/catalog.js +14 -3
  32. package/packages/block-platform/chat-packages.js +6 -0
  33. package/packages/block-platform/flow-inspection.js +3 -1
  34. package/packages/block-platform/project-target.js +57 -0
  35. package/packages/block-platform/project-type-composer.js +4 -3
  36. package/packages/block-platform/project-types.js +5 -2
  37. package/packages/block-sdk/admission.js +4 -1
  38. package/packages/block-sdk/block-tier.js +23 -0
  39. package/packages/block-sdk/builder-contract.js +12 -10
  40. package/packages/block-sdk/capabilities.js +23 -10
  41. package/packages/block-sdk/collaboration-admission.js +3 -0
  42. package/packages/block-sdk/contracts.js +13 -1
  43. package/packages/block-sdk/example-media-data.js +16 -0
  44. package/packages/block-sdk/example-media.js +42 -0
  45. package/packages/block-sdk/first-party-identities.js +8 -0
  46. package/packages/block-sdk/first-party-packages.js +11 -1
  47. package/packages/block-sdk/index.d.ts +6 -2
  48. package/packages/block-sdk/local-admission.js +6 -5
  49. package/packages/block-sdk/media-transform.js +59 -3
  50. package/packages/block-sdk/mock-host.js +23 -2
  51. package/packages/block-sdk/mocks.js +4 -1
  52. package/packages/block-sdk/module-hosted.js +12 -2
  53. package/packages/block-sdk/port-types.d.ts +6 -0
  54. package/packages/block-sdk/port-types.js +32 -10
  55. package/packages/block-sdk/shared-agent-controls.js +25 -2
  56. package/packages/block-sdk/shared-controls.js +17 -3
  57. package/packages/block-sdk/testing.js +46 -3
  58. package/packages/block-sdk/view-design.js +67 -0
  59. package/packages/block-sdk/view-renderer.js +50 -8
  60. package/packages/design-system/block-kit.css +6 -4
@@ -4,13 +4,21 @@ export function installSharedControls(document,api,onError,textControls=null,loc
4
4
  const bindings=new Map(),TEXT_TYPES=['text','search','email','tel','url'];
5
5
  const lockedInputs=new Set(lockedInputKeys);
6
6
  const deferred=new Map(),running=new Set(),deferredErrors=new Map();let closed=false;
7
+ // November 1, 2026 (live controller check): Chat set Narration's voice input to nova, and the screen showed Onyx, the
8
+ // fieldset's HTML default, because shared state had no voice yet. A shared field with the name of a Step input starts from
9
+ // that input's value until someone picks one; a value the field cannot show is ignored.
10
+ let inputDefaults={};
11
+ const shows=(radios,node,value)=>radios?radios.some(radio=>radio.value===String(value)):node.tagName!=='SELECT'||[...node.options].some(option=>option.value===String(value));
7
12
  const runDeferred=(key,entry)=>{
8
13
  clearTimeout(entry.timer);deferred.delete(key);if(closed)return;
9
14
  let task;task=Promise.resolve().then(()=>{if(!closed)return entry.callback();}).catch(error=>{deferredErrors.set(key,error);onError(error.message);}).finally(()=>running.delete(task));
10
15
  running.add(task);return task;
11
16
  };
12
17
  const scheduleSharedEdit=(key,callback)=>{
13
- if(closed||typeof key!=='string'||!/^[a-zA-Z][a-zA-Z0-9_-]{0,99}$/.test(key)||typeof callback!=='function')throw Error('Use a named shared edit callback');
18
+ // Which part is wrong (November 2026: a Block author read this as being about the callback when its key had a +).
19
+ if(closed)throw Error('This interface has closed; shared edits stopped.');
20
+ if(typeof key!=='string'||!/^[a-zA-Z][a-zA-Z0-9_-]{0,99}$/.test(key))throw Error('A shared edit key starts with a letter and holds only letters, digits, - and _ (up to 100 characters).');
21
+ if(typeof callback!=='function')throw Error('Give scheduleSharedEdit a function to run.');
14
22
  if(!deferred.has(key)&&deferred.size+running.size>=32||!deferredErrors.has(key)&&deferredErrors.size>=32)throw Error('Too many scheduled shared edits');
15
23
  deferredErrors.delete(key);
16
24
  const previous=deferred.get(key),entry={callback,started:previous?.started??Date.now(),timer:null};
@@ -53,7 +61,7 @@ export function installSharedControls(document,api,onError,textControls=null,loc
53
61
  const picked=()=>radios.find(radio=>radio.checked)?.value??'';
54
62
  const write=value=>{const before=radios?picked():node.type==='checkbox'?node.checked:node.value;if(radios)for(const radio of radios)radio.checked=radio.value===String(value??'');else if(node.type==='checkbox')node.checked=!!value;else if(node.value!==String(value??''))node.value=String(value??'');if((radios?picked():node.type==='checkbox'?node.checked:node.value)!==before)queueMicrotask(()=>{if(!disposed)api.announce?.();});};
55
63
  const read=()=>radios?picked():node.type==='checkbox'?node.checked:['number','range'].includes(node.type)?node.valueAsNumber:node.value;
56
- let fallback=read();
64
+ let fallback=Object.hasOwn(inputDefaults,key)&&shows(radios,node,inputDefaults[key])?inputDefaults[key]:read();
57
65
  const flush=()=>{
58
66
  clearTimeout(timer);timer=null;if(saving)return inFlight;if(disposed||composing||!dirty||!canEdit||problem)return;
59
67
  const value=draft;dirty=false;dirtySince=null;saving=true;let failed=false;
@@ -115,7 +123,7 @@ export function installSharedControls(document,api,onError,textControls=null,loc
115
123
  node.addEventListener('input',change);node.addEventListener('change',change);node.addEventListener(leave,flush);
116
124
  node.addEventListener('compositionstart',compositionStart);node.addEventListener('compositionend',compositionEnd);
117
125
  const dispose=()=>{disposed=true;composing=false;clearTimeout(timer);unsubscribe();node.removeEventListener('input',change);node.removeEventListener('change',change);node.removeEventListener(leave,flush);node.removeEventListener('compositionstart',compositionStart);node.removeEventListener('compositionend',compositionEnd);node.disabled=originallyDisabled;bindings.delete(key);};
118
- bindings.set(key,{dispose,flush,status(){return {dirty,saving,problem,canEdit,composing,interrupted:retainedComposition!==undefined};},setDefault(value){fallback=value;receive(api.getShared());}});return dispose;
126
+ bindings.set(key,{dispose,flush,status(){return {dirty,saving,problem,canEdit,composing,interrupted:retainedComposition!==undefined};},setDefault(value){fallback=value;receive(api.getShared());},setInputDefault(value){if(shows(radios,node,value))this.setDefault(value);}});return dispose;
119
127
  };
120
128
  const bindAnnotated=()=>{
121
129
  const nodes=[...document.querySelectorAll('[data-stillmade-share]')];
@@ -155,5 +163,11 @@ export function installSharedControls(document,api,onError,textControls=null,loc
155
163
  return {bindShared,bindAnnotated,scheduleSharedEdit,flushAll,assertNoComposition,getBindingStatus:key=>bindings.get(key)?.status(),setSharedDefaults(values){
156
164
  if(!values||typeof values!=='object'||Array.isArray(values)||Object.keys(values).length>100||JSON.stringify(values).length>65536)throw Error('Use bounded shared control defaults');
157
165
  for(const [key,value] of Object.entries(values))bindings.get(key)?.setDefault(value);
166
+ },useInputDefaults(input){
167
+ // Only a default that changed is applied again: the host sends the input often, and re-writing every field from shared
168
+ // state each time hid a view that overwrote other people's edits from the two-participant check.
169
+ const previous=inputDefaults;inputDefaults={};
170
+ if(input&&typeof input==='object'&&!Array.isArray(input))for(const [key,value] of Object.entries(input))if(['string','number','boolean'].includes(typeof value))inputDefaults[key]=value;
171
+ for(const [key,binding] of bindings)if(Object.hasOwn(inputDefaults,key)&&!(Object.hasOwn(previous,key)&&previous[key]===inputDefaults[key]))binding.setInputDefault(inputDefaults[key]);
158
172
  },dispose(){closed=true;for(const entry of deferred.values())clearTimeout(entry.timer);deferred.clear();for(const binding of [...bindings.values()])binding.dispose();}};
159
173
  }
@@ -3,6 +3,7 @@ import {requiresDesktopExecution,validateDesktopExecution} from './desktop-execu
3
3
  import {validatePortableMetadata} from './portable.js';
4
4
  import {initialBlockState,validateBlockState} from './state.js';
5
5
  import { assert, digest, plain, safeDocument, stableStringify, validateManifest, validateValues } from './contracts.js';
6
+ import {FILE_MEDIA_TYPES,DOCUMENT_FORMATS} from './data-values.js';
6
7
  import { validateRecipe } from './recipe.js';
7
8
  import { validateJavaScript } from './javascript.js';
8
9
  import { runPackageAsync } from './runtime.js';
@@ -12,6 +13,46 @@ import {CAPABILITY_LIMITS,validateCapability,prepareCapabilityInvocation,validat
12
13
  import {validateNativeApplicationPackage,NATIVE_APPLICATION_RUNTIME_REQUIREMENT} from './native-application-package.js';
13
14
  import {validateModuleDescriptor,MODULE_LIMITS} from './module-runtime.js';
14
15
  import {validateMocks} from './mocks.js';
16
+ // October 27, 2026: in-app builds of module Blocks failed three repairs on "output document must be text": the
17
+ // message named neither the fixture nor where the bad value was, so each repair changed the code and kept it. A
18
+ // module fixture whose expected value is the wrong shape now says which fixture and port, and what that type holds.
19
+ const reference=kind=>`{kind:'${kind}', assetId:'…', versionId:'…', url:'…'}, the saved reference exactly as the host returned it`;
20
+ const VALUE_SHAPES=Object.freeze({
21
+ text:'a string',
22
+ file:`{kind:'file', assetId:'…', versionId:'…', mimeType:${FILE_MEDIA_TYPES.map(type=>`'${type}'`).join('|')}, url?, name?, bytes?}`,
23
+ document:`{schemaVersion:1, format:${DOCUMENT_FORMATS.map(format=>`'${format}'`).join('|')}, text:'…', title?:'…'} with no other keys`,
24
+ table:`{columns:['name', …], rows:[{name: text, number, true/false or null}], schemaVersion?:1} with no other keys`,
25
+ image:reference('image'),video:reference('video'),audio:reference('audio'),
26
+ asset:`{assetId:'…', versionId:'…', kind?, url?}, a saved reference exactly as the host returned it`,
27
+ });
28
+ const valueShape=type=>type.endsWith('[]')?VALUE_SHAPES[type.slice(0,-2)]&&`a plain array of ${type.slice(0,-2)} values, each ${VALUE_SHAPES[type.slice(0,-2)]}, with no wrapper objects`:VALUE_SHAPES[type];
29
+ function fixtureExpectedMessage(outputs,fixture,error){
30
+ const name=/^output (.+?) must be /.exec(error.message)?.[1],type=Object.hasOwn(outputs,name??'')?String(outputs[name].type):null;
31
+ if(!type)return `Fixture "${fixture.name}": its expected outputs are wrong, not the code (${error.message}). expected holds exactly the declared output ports.`;
32
+ const shape=valueShape(type);
33
+ return `Fixture "${fixture.name}": expected.${name} is not a valid ${type} value, so the bad value is in this fixture's expected outputs, not the code (${error.message}).${shape?` ${/^[aeiou]/.test(type)?'An':'A'} ${type} value is ${shape}.`:''}`;
34
+ }
35
+ // October 27, 2026: a reference the stand-in host wrote (files.write in a module test) carries fields the host makes
36
+ // (assetId local-written-file-N, versionId, url, bytes) that no model can predict. A module fixture compares it by
37
+ // shape: an expected {kind, mimeType, name}, with those fields left out or given, matches when kind, mimeType and name
38
+ // match. Every other value is compared exactly.
39
+ const WRITTEN_KINDS=['file','image','video','audio'],HOST_MADE=['assetId','versionId','url','bytes'];
40
+ const standInWritten=value=>plain(value)&&/^local-written-(?:file|image|video|audio)-\d+$/.test(String(value.assetId));
41
+ const writtenShape=value=>plain(value)&&WRITTEN_KINDS.includes(value.kind)&&typeof value.mimeType==='string'&&Object.keys(value).every(key=>['kind','mimeType','name',...HOST_MADE].includes(key));
42
+ // expected, with each reference that matches a stand-in-written output by shape replaced by that output.
43
+ function matchWritten(actual,expected){
44
+ if(standInWritten(actual))return writtenShape(expected)&&['kind','mimeType','name'].every(key=>expected[key]===actual[key])?actual:expected;
45
+ if(Array.isArray(actual)&&Array.isArray(expected))return expected.map((value,index)=>matchWritten(actual[index],value));
46
+ if(plain(actual)&&plain(expected))return Object.fromEntries(Object.entries(expected).map(([key,value])=>[key,matchWritten(actual[key],value)]));
47
+ return expected;
48
+ }
49
+ // A module fixture's expected outputs for the type check: a file or media reference given by shape gets stand-in values
50
+ // for the host-made fields it leaves out.
51
+ function withStandInHostFields(outputs,expected){
52
+ if(!plain(outputs)||!plain(expected))return expected;
53
+ const fill=(value,type)=>type.endsWith('[]')?(Array.isArray(value)?value.map(item=>fill(item,type.slice(0,-2))):value):WRITTEN_KINDS.includes(type)&&value?.kind===type&&writtenShape(value)?{assetId:`local-written-${type}-1`,versionId:'v1',...value}:value;
54
+ return Object.fromEntries(Object.entries(expected).map(([name,value])=>[name,Object.hasOwn(outputs,name)?fill(value,String(outputs[name]?.type)):value]));
55
+ }
15
56
  export function validatePackage(pkg) {
16
57
  safeDocument(pkg);
17
58
  if(pkg?.manifest?.runtime==='native-application')return validateNativeApplicationPackage(pkg);
@@ -54,7 +95,8 @@ export function validatePackage(pkg) {
54
95
  // hosted calls, actions and transforms it makes then cost nothing and give the same result each run.
55
96
  if(pkg.manifest.runtime === 'module')for(const fixture of pkg.tests){
56
97
  assert(fixture&&typeof fixture.name==='string'&&fixture.name.trim().length>0&&fixture.name.length<=200&&Object.keys(fixture).every(key=>['name','input','expected','mocks'].includes(key)),'TEST','Module fixtures need a name, input and the exact expected outputs, and may record mocks');
57
- validateValues(pkg.manifest.inputs,fixture.input);validateValues(pkg.manifest.outputs,fixture.expected,'output');
98
+ validateValues(pkg.manifest.inputs,fixture.input);
99
+ try{validateValues(pkg.manifest.outputs,withStandInHostFields(pkg.manifest.outputs,fixture.expected),'output');}catch(error){assert(false,error.code||'TYPE',fixtureExpectedMessage(pkg.manifest.outputs,fixture,error));}
58
100
  if(fixture.mocks!==undefined)validateMocks(fixture.mocks);
59
101
  }
60
102
  if(pkg.manifest.runtime === 'capability'){
@@ -97,10 +139,11 @@ export async function testPackage(pkg, { timeoutMs = 10000, wasmLocation, comfyu
97
139
  if(pkg.manifest.runtime === 'comfyui')testComfyOutputs(pkg.manifest,outputs,test.expectations,{metadata});
98
140
  else if(pkg.manifest.runtime === 'capability')testCapabilityOutputs(pkg.manifest,outputs,test.expectations);
99
141
  else {
100
- const actual=stableStringify(outputs),expected=stableStringify(test.expected);
142
+ const wanted=pkg.manifest.runtime==='module'?matchWritten(outputs,test.expected):test.expected;
143
+ const actual=stableStringify(outputs),expected=stableStringify(wanted);
101
144
  // Each output that differs, with both values, so a long output (a transcript) never hides the
102
145
  // one that differs; a module's failed host calls (in its log) say why (October 22, 2026).
103
- const differing=[...new Set([...Object.keys(test.expected||{}),...Object.keys(outputs||{})])].filter(key=>stableStringify(outputs?.[key])!==stableStringify(test.expected?.[key]));
146
+ const differing=[...new Set([...Object.keys(test.expected||{}),...Object.keys(outputs||{})])].filter(key=>stableStringify(outputs?.[key])!==stableStringify(wanted?.[key]));
104
147
  const failedCalls=(logs||[]).filter(line=>/ failed: /.test(line)).slice(0,3);
105
148
  assert(actual===expected,'BEHAVIOR',`Output differs from expected fixture${differing.length?` in ${differing.join(', ')}`:''}. ${differing.length?differing.slice(0,4).map(key=>`${key}: expected ${String(stableStringify(test.expected?.[key])).slice(0,600)}; received ${String(stableStringify(outputs?.[key])).slice(0,600)}`).join('. '):`Expected ${expected.slice(0,1200)}; received ${actual.slice(0,1200)}`}${failedCalls.length?`. Host calls that failed: ${failedCalls.join('; ')}`:''}`);
106
149
  }
@@ -42,6 +42,7 @@ export const BLOCK_INTERFACE_PRINCIPLES=Object.freeze([
42
42
  'Status sits beside the action it describes (`sm-status`), in a few words.',
43
43
  'Keep the page in normal scroll flow; no sticky media or control strips unless the editing task needs them.',
44
44
  'Use the width: from 860px wide the kit puts the controls on the left at a reading width (300–380px) and the result (`sm-stage`) fills the right, in its real shape; under that everything stacks with the result first and the action within reach. Extra width goes to the result, never to wider text fields. Before there is a result, the stage previews what will be made from the inputs. `data-layout="stack"` opts out.',
45
+ 'Size with the kit, never fixed sizes: no fixed pixel widths or heights for the workspace or stage, no 100vh, no position:fixed panels. StillMade zooms a Block\'s screen to fill the space it has (up to 150%, never down to a phone layout on a computer), so a Block laid out with the kit\'s spacing fills its Step without empty space.',
45
46
  'Settings sit side by side (`sm-settings`), next to what they change. Three or four short options are one segmented row (`data-segmented`), not a row of big tiles.',
46
47
  'Lists fill the width at a small size: input files are small tiles (`sm-gallery data-size="small"`), many items are one line each (`sm-list`): a thumbnail, one line of text, a short fact. Tapping an item opens all of its fields in place. Extra width means more items per row, never bigger cards.',
47
48
  'On a phone chip and segment rows scroll sideways instead of wrapping; long lists show their first three with "N more" (`data-peek`) and long text five lines until tapped (`sm-clamp`).',
@@ -135,6 +136,58 @@ function topLevelRows(html){
135
136
  return rows;
136
137
  }
137
138
 
139
+ // November 1, 2026 (owner, zoom plan): a screen held to a narrow width leaves most of a wide Step empty, and StillMade's
140
+ // zoom cannot fill it (it stops before the kit's two-column layout from 860px would change). The narrowest fixed or
141
+ // maximum width on what holds the whole screen (html, body, the outermost element, a lone wrapper inside it), from its
142
+ // inline style or a stylesheet rule outside a small-screen @media block; null when nothing holds it.
143
+ const KIT_TWO_COLUMNS=860;
144
+ function narrowScreen(view){
145
+ const html=String(view?.html||''),lower=html.toLowerCase(),holders=[{tag:'html',classes:[],id:''},{tag:'body',classes:[],id:''}];
146
+ const width=declarations=>{let narrowest=null;for(const match of String(declarations).matchAll(/(?:^|[;{\s])(?:max-width|width)\s*:\s*(\d{1,5}(?:\.\d+)?)(px|rem|em)\b/gi)){const px=Number(match[1])*(match[2].toLowerCase()==='px'?1:16);if(px>=200&&px<KIT_TWO_COLUMNS)narrowest=Math.min(narrowest??Infinity,px);}return narrowest;};
147
+ // The outermost elements, and the children of a lone outermost one; a lone child is a wrapper too.
148
+ // The last '>' found is reused while it is still ahead, and attributes are read from a bounded slice, so broken tags
149
+ // ("<a<a<a…>") stay linear.
150
+ const top=[],inner=[];let depth=0,end=-1;
151
+ for(let at=lower.indexOf('<');at!==-1;at=lower.indexOf('<',at+1)){
152
+ if(end<at)end=lower.indexOf('>',at);if(end===-1)break;
153
+ const tag=/^<(\/?)([a-z][a-z0-9-]*)/.exec(lower.slice(at,Math.min(end+1,at+40)));
154
+ if(!tag)continue;
155
+ const [,closing,name]=tag;
156
+ if(closing){depth=Math.max(0,depth-1);if(depth===0&&top.length>1)break;continue;}
157
+ if(['script','style','template'].includes(name)){const close=lower.indexOf(`</${name}`,end);if(close===-1)break;at=close;continue;}
158
+ const attributes=html.slice(at,Math.min(end,at+2000)),element={tag:name,classes:(/\sclass\s*=\s*["']([^"']*)["']/i.exec(attributes)?.[1]||'').split(/\s+/).filter(Boolean),id:/\sid\s*=\s*["']([^"']*)["']/i.exec(attributes)?.[1]||'',style:/\sstyle\s*=\s*["']([^"']*)["']/i.exec(attributes)?.[1]||''};
159
+ if(depth===0)top.push(element);else if(depth===1&&top.length===1)inner.push(element);
160
+ if(!VOID_TAGS.has(name)&&lower[end-1]!=='/')depth++;
161
+ }
162
+ holders.push(...top.filter(()=>top.length===1),...(inner.length===1?inner:[]));
163
+ for(const element of holders.slice(2)){const px=width(element.style);if(px)return {px,where:`the style on <${element.tag}>`};}
164
+ const holds=selector=>{
165
+ const last=selector.trim().split(/[\s>+~]+/).pop().replace(/^:root$/i,'html'),parts=/^([a-z][a-z0-9-]*|\*)?((?:[.#][\w-]+)*)$/i.exec(last);
166
+ if(!parts||!parts[1]&&!parts[2])return false;
167
+ const classes=[...parts[2].matchAll(/\.([\w-]+)/g)].map(match=>match[1]),id=/#([\w-]+)/.exec(parts[2])?.[1]||'';
168
+ return holders.some(element=>(!parts[1]||parts[1]==='*'||parts[1].toLowerCase()===element.tag)&&classes.every(name=>element.classes.includes(name))&&(!id||id===element.id));
169
+ };
170
+ // One pass over the stylesheet, comments skipped: each rule with the @media blocks around it.
171
+ const css=String(view?.css||''),around=[];let prelude='',narrowest=null;
172
+ for(let index=0;index<css.length;index++){
173
+ const char=css[index];
174
+ if(char==='/'&&css[index+1]==='*'){const close=css.indexOf('*/',index+2);if(close===-1)break;index=close+1;continue;}
175
+ if(char==='{'){
176
+ const head=prelude.trim();prelude='';
177
+ if(head.startsWith('@')){around.push(head);continue;}
178
+ const close=css.indexOf('}',index);if(close===-1)break;
179
+ const smallOnly=around.some(rule=>/^@media\b/i.test(rule)&&/max-width/i.test(rule));
180
+ const px=!smallOnly&&head.split(',').some(holds)?width(css.slice(index+1,close)):null;
181
+ if(px&&(!narrowest||px<narrowest.px))narrowest={px,where:head.slice(0,60)};
182
+ index=close;continue;
183
+ }
184
+ if(char==='}'){around.pop();prelude='';continue;}
185
+ if(char===';'&&!around.length){prelude='';continue;}
186
+ prelude+=char;
187
+ }
188
+ return narrowest;
189
+ }
190
+
138
191
  export function reviewViewDesign(view){
139
192
  if(!view)return {passed:true,issues:[],warnings:[],principles:BLOCK_INTERFACE_PRINCIPLES};
140
193
  const html=view.html||'',javascript=view.javascript||'',primary=html.replace(/<details\b[\s\S]*?<\/details>/gi,'');
@@ -157,10 +210,24 @@ export function reviewViewDesign(view){
157
210
  if(rareTool)warnings.push(`"${rareTool.slice(0,40)}" is its own section. Rare tools (update, schedule, history, reload) are named icon buttons beside the action (.sm-icon in .sm-actions), never sections under the work.`);
158
211
  const rows=topLevelRows(primary);
159
212
  if(rows>COMPACT_ROW_LIMIT)warnings.push(`The workspace has ${rows} visible rows besides the stage and may not fit one laptop screen (an estimate: this check counts rows and cannot measure height). Put short settings side by side in .sm-settings and secondary ones in a closed .sm-more.`);
213
+ const narrow=narrowScreen(view);
214
+ if(narrow)warnings.push(`The screen is held to ${Math.round(narrow.px)}px wide (${narrow.where}), so a wide Step shows mostly empty space, and StillMade's zoom cannot fill it. Drop the fixed or maximum width: the Block kit lays out the controls and the result across the width.`);
160
215
  warnings.push(...blockKitProblems(view).map(problem=>problem.message));
161
216
  return {passed:issues.length===0,issues,warnings,principles:BLOCK_INTERFACE_PRINCIPLES};
162
217
  }
163
218
 
219
+ // October 30, 2026: Chat presses a button on a Block's own screen only when it carries data-stillmade-action
220
+ // (naming what it does); an unmarked button is out of Chat's reach. The number of buttons in view.html without one.
221
+ export function unmarkedButtons(view){
222
+ const html=String(view?.html||''),lower=html.toLowerCase();let count=0;
223
+ for(let start=lower.indexOf('<button');start!==-1;start=lower.indexOf('<button',start+7)){
224
+ if(!/[\s>/]/.test(lower[start+7]||''))continue;
225
+ const end=lower.indexOf('>',start);if(end===-1)break;
226
+ if(!/\sdata-stillmade-action\s*=/.test(lower.slice(start,end)))count++;
227
+ }
228
+ return count;
229
+ }
230
+
164
231
  // What a NEW Block's interface must get right. Each problem is {rule, message}.
165
232
  // The builder contract's DOD-15 (the SDK's `ready`, StillMade's builder) fails
166
233
  // on 'action', 'price' and 'heading'; StillMade's own builder also repairs
@@ -6,6 +6,38 @@ export function installViewRenderer(document,onError=()=>{},onControlsChanged=()
6
6
  const listeners=new Set();
7
7
  const actions=new WeakMap(),compositionState=new WeakMap(),compositions=new Map(),conflicts=new Map();let disposed=false,revision=0,sequence=0,dispatching=false;const roots=new Set();
8
8
  const notify=()=>{try{onControlsChanged();}catch{}};
9
+ // October 30, 2026: Chat also reads and presses a view's plain HTML controls: a button marked
10
+ // data-stillmade-action, and a labelled input, select or textarea. Shared fields (data-stillmade-share, reached
11
+ // as shared fields), device-only controls (data-stillmade-local) and file, password and hidden inputs are never
12
+ // offered. Pressing one clicks it, and setting one sends its input and change events, as a person's use does;
13
+ // whatever that starts keeps its own approval (an assisted run still opens its cost review).
14
+ const later=globalThis.setTimeout?.bind(globalThis),cancel=globalThis.clearTimeout?.bind(globalThis),Observer=later&&cancel?document.defaultView?.MutationObserver:null;
15
+ const SKIPPED_TYPES=new Set(['file','password','hidden','submit','reset','image','button']);
16
+ const ownText=node=>{let value='';for(const child of node.childNodes)if(child.nodeType===3)value+=child.nodeValue;else if(child.nodeType===1&&!child.matches('input,select,textarea,button'))value+=' '+child.textContent;return value;};
17
+ const plainLabel=node=>{
18
+ let value=node.getAttribute('aria-label')||'';const by=node.getAttribute('aria-labelledby');
19
+ if(!value&&by)value=by.split(/\s+/).map(id=>document.getElementById(id)?.textContent||'').join(' ');
20
+ if(!value&&node.labels?.length)value=ownText(node.labels[0]);
21
+ if(!value.trim()&&node.tagName==='BUTTON')value=node.textContent||'';
22
+ return String(value.trim()?value:node.getAttribute('title')||'').replace(/\s+/g,' ').trim().slice(0,240);
23
+ };
24
+ const plainNode=node=>{
25
+ const meta=actions.get(node);if(meta&&!meta.plain)return false;
26
+ if(node.closest('[data-stillmade-share],[data-stillmade-local]'))return false;
27
+ if(node.tagName==='BUTTON')return node.hasAttribute('data-stillmade-action');
28
+ if(node.tagName==='INPUT'&&SKIPPED_TYPES.has(node.type))return false;
29
+ return node.hasAttribute('data-stillmade-action')||!!plainLabel(node);
30
+ };
31
+ const plainControls=()=>{
32
+ const out=[];
33
+ for(const node of document.querySelectorAll('button,input,select,textarea')){
34
+ if(out.length>=200)break;if(!plainNode(node))continue;
35
+ if(!actions.has(node))actions.set(node,{plain:true,event:node.tagName==='BUTTON'?'click':node.tagName==='SELECT'||['checkbox','radio'].includes(node.type)?'change':'input',root:document.documentElement,handle:'view-control-'+String(++sequence)});
36
+ out.push(node);
37
+ }
38
+ return out;
39
+ };
40
+ const actionOf=node=>{const meta=actions.get(node);return meta.plain?String(node.getAttribute('data-stillmade-action')||node.id||node.getAttribute('name')||plainLabel(node)).slice(0,120):meta.action;};
9
41
  const check=(ok,message)=>{if(!ok)throw Error(message);};
10
42
  const text=(value,max=10000)=>{check(typeof value==='string'&&value.length<=max,'Dynamic control text exceeds its limit');return value;};
11
43
  const textControl=node=>node.tagName==='TEXTAREA'||node.tagName==='INPUT'&&node.type==='text';
@@ -129,28 +161,38 @@ export function installViewRenderer(document,onError=()=>{},onControlsChanged=()
129
161
  if(conflict)throw Error(compositionState.get(conflict[0]).problem);
130
162
  check(![...compositions].some(([node,root])=>root.contains(node)&&node.isConnected&&!node.disabled),'Finish composing text before continuing.');
131
163
  };
132
- const currentControls=()=>{const seen=new Set(),out=[];for(const root of roots)if(root.isConnected)for(const node of root.querySelectorAll('button,input,textarea,select')){const meta=actions.get(node);if(meta?.action&&meta.root===root&&!seen.has(node)){seen.add(node);out.push(node);}}return out;};
164
+ const currentControls=()=>{const seen=new Set(),out=[];for(const root of roots)if(root.isConnected)for(const node of root.querySelectorAll('button,input,textarea,select')){const meta=actions.get(node);if(meta?.action&&meta.root===root&&!seen.has(node)){seen.add(node);out.push(node);}}for(const node of plainControls())if(!seen.has(node)){seen.add(node);out.push(node);}return out;};
133
165
  const hidden=node=>{for(let current=node;current;current=current.parentElement){if(current.hidden)return true;const style=document.defaultView?.getComputedStyle(current);if(style?.display==='none'||['hidden','collapse'].includes(style?.visibility)||style?.opacity==='0')return true;}return false;};
134
- const label=node=>String(node.getAttribute('aria-label')||node.textContent||node.placeholder||'Control').slice(0,240);
166
+ const label=node=>actions.get(node)?.plain?plainLabel(node)||actionOf(node)||'Control':String(node.getAttribute('aria-label')||node.textContent||node.placeholder||'Control').slice(0,240);
135
167
  const choices=node=>node.tagName==='SELECT'?Array.from(node.options).map(option=>({value:option.value,label:option.textContent})):null;
136
- const identity=node=>{const meta=actions.get(node);return JSON.stringify({action:meta.action,event:meta.event,root:meta.root.id,tag:node.tagName,type:node.type,value:node.value,checked:node.checked,disabled:node.disabled,hidden:hidden(node),label:label(node),choices:choices(node)});};
137
- const lockControls=locked=>{for(const node of currentControls())node.disabled=locked||actions.get(node).declaredDisabled;};
138
- const inspectControls=({offset=0,limit=20}={})=>{check(!disposed,'Dynamic controls are closed');check(Number.isSafeInteger(offset)&&offset>=0&&Number.isSafeInteger(limit)&&limit>=1&&limit<=20,'Choose a bounded control page');const nodes=currentControls();check(offset<=nodes.length,'Choose a current control page');return {revision,total:nodes.length,offset,nextOffset:offset+limit<nodes.length?offset+limit:null,controls:nodes.slice(offset,offset+limit).map(node=>{const meta=actions.get(node),value=String(node.value??'');return {id:meta.handle,action:meta.action,label:label(node),tag:node.tagName.toLowerCase(),type:node.type,event:meta.event,disabled:node.disabled,hidden:hidden(node),value:value.slice(0,240),valuePartial:value.length>240,...(typeof node.checked==='boolean'?{checked:node.checked}:{}),...(node.tagName==='select'?{choices:choices(node).slice(0,40),choicesPartial:node.options.length>40}:{}),identity:identity(node)};})};};
168
+ const identity=node=>{const meta=actions.get(node);return JSON.stringify({action:actionOf(node),event:meta.event,root:meta.root.id,tag:node.tagName,type:node.type,value:node.value,checked:node.checked,disabled:node.disabled,hidden:hidden(node),label:label(node),choices:choices(node)});};
169
+ const lockControls=locked=>{for(const node of currentControls())if(!actions.get(node).plain)node.disabled=locked||actions.get(node).declaredDisabled;};
170
+ // A plain control appearing, going or changing its state is a new control list; checked at most every 250 ms.
171
+ let plainSignature=null,plainTimer=null;
172
+ const plainState=()=>plainControls().map(node=>actions.get(node).handle+(node.disabled?':off':'')+(hidden(node)?':hidden':'')).join(',');
173
+ const plainChanged=()=>{plainTimer=null;if(disposed||plainSignature===null)return;let next;try{next=plainState();}catch{return;}if(next!==plainSignature){plainSignature=next;revision++;notify();}};
174
+ const observer=Observer?new Observer(()=>{if(plainTimer===null&&!disposed)plainTimer=later(plainChanged,250);}):null;
175
+ observer?.observe(document.documentElement,{subtree:true,childList:true,attributes:true,attributeFilter:['disabled','hidden','class','data-stillmade-action','aria-label']});
176
+ const inspectControls=({offset=0,limit=20}={})=>{check(!disposed,'Dynamic controls are closed');check(Number.isSafeInteger(offset)&&offset>=0&&Number.isSafeInteger(limit)&&limit>=1&&limit<=20,'Choose a bounded control page');const nodes=currentControls();plainSignature=plainState();check(offset<=nodes.length,'Choose a current control page');return {revision,total:nodes.length,offset,nextOffset:offset+limit<nodes.length?offset+limit:null,controls:nodes.slice(offset,offset+limit).map(node=>{const meta=actions.get(node),value=String(node.value??'');return {id:meta.handle,action:actionOf(node),label:label(node),tag:node.tagName.toLowerCase(),type:node.type,event:meta.event,disabled:node.disabled,hidden:hidden(node),value:value.slice(0,240),valuePartial:value.length>240,...(typeof node.checked==='boolean'?{checked:node.checked}:{}),...(node.tagName==='SELECT'?{choices:choices(node).slice(0,40),choicesPartial:node.options.length>40}:{}),identity:identity(node)};})};};
139
177
  const dispatchControl=async(args,settle=async()=>{})=>{
140
178
  check(!dispatching,'A declared control is already running');assertCommitted();
141
179
  check(args&&typeof args==='object'&&!Array.isArray(args)&&Object.keys(args).every(key=>['id','revision','identity','value','checked'].includes(key))&&typeof args.id==='string'&&args.revision===revision&&typeof args.identity==='string','The declared control changed');
142
180
  const node=currentControls().find(node=>actions.get(node).handle===args.id),meta=node&&actions.get(node);
143
- check(node&&identity(node)===args.identity,'The declared control changed');check(!node.disabled&&!hidden(node),'This declared control is unavailable');check(listeners.size>0,'This declared control has no native handler');
181
+ check(node&&identity(node)===args.identity,'The declared control changed');check(!node.disabled&&!hidden(node),'This declared control is unavailable');check(meta.plain||listeners.size>0,'This declared control has no native handler');
144
182
  if(node.tagName==='BUTTON')check(!Object.hasOwn(args,'value')&&!Object.hasOwn(args,'checked'),'Buttons take no input value');
145
183
  else if(['checkbox','radio'].includes(node.type)){check(typeof args.checked==='boolean'&&!Object.hasOwn(args,'value'),'Choose a checkbox value');node.checked=args.checked;}
146
184
  else{check((typeof args.value==='string'&&args.value.length<=10000||typeof args.value==='number'&&Number.isFinite(args.value))&&!Object.hasOwn(args,'checked'),'Choose a bounded control value');if(node.tagName==='SELECT')check(Array.from(node.options).some(option=>option.value===String(args.value)),'Choose a declared option');node.value=String(args.value);}
147
185
  const payload={action:meta.action,value:node.value,checked:node.checked,event:meta.event};
148
186
  dispatching=true;lockControls(true);
187
+ if(meta.plain)try{if(node.tagName==='BUTTON')node.click();else for(const type of ['input','change'])node.dispatchEvent(new document.defaultView.Event(type,{bubbles:true}));check(!disposed,'Dynamic controls are closed');await settle();check(!disposed,'Dynamic controls are closed');return {settled:true};}
188
+ finally{dispatching=false;lockControls(false);notify();}
149
189
  try{const pending=[];for(const listener of listeners){try{pending.push(Promise.resolve(listener({...payload})));}catch(error){pending.push(Promise.reject(error));}}const results=await Promise.allSettled(pending);check(!disposed,'Dynamic controls are closed');await settle();check(!disposed,'Dynamic controls are closed');const failure=results.find(result=>result.status==='rejected');if(failure)throw failure.reason;return {settled:true};}
150
190
  finally{dispatching=false;lockControls(false);notify();}
151
191
  };
152
- return Object.freeze({render,inspectControls,dispatchControl,onAction(listener){check(typeof listener==='function'&&listeners.size<16,'Register at most 16 action listeners');listeners.add(listener);return()=>listeners.delete(listener);},assertCommitted,async flushAll(){
192
+ // Where a declared control sits in this document, for a host that points at it.
193
+ const locateControl=id=>{check(!disposed,'Dynamic controls are closed');const node=currentControls().find(node=>actions.get(node).handle===id);if(!node||hidden(node))return null;const rect=node.getBoundingClientRect();return {x:rect.left,y:rect.top,width:rect.width,height:rect.height};};
194
+ return Object.freeze({render,inspectControls,dispatchControl,locateControl,onAction(listener){check(typeof listener==='function'&&listeners.size<16,'Register at most 16 action listeners');listeners.add(listener);return()=>listeners.delete(listener);},assertCommitted,async flushAll(){
153
195
  assertCommitted();
154
196
  return {queued:true};
155
- },dispose(){disposed=true;roots.clear();for(const node of compositions.keys())compositionState.get(node).cancelled=true;compositions.clear();conflicts.clear();listeners.clear();}});
197
+ },dispose(){disposed=true;observer?.disconnect();if(plainTimer!==null)cancel?.(plainTimer);roots.clear();for(const node of compositions.keys())compositionState.get(node).cancelled=true;compositions.clear();conflicts.clear();listeners.clear();}});
156
198
  }
@@ -101,10 +101,12 @@
101
101
  }
102
102
 
103
103
  /* ---------- Choices as pictures: replaces word dropdowns and radio lists ---------- */
104
+ /* Captions never go under 12 px, the readable size the platform's phone check asks of every Block (October 27, 2026: a
105
+ generated choice read 11.4 px under a 14 px root and failed every Block that has one). */
104
106
  :where(.sm-choices){display:grid;gap:var(--s-2,8px);grid-template-columns:repeat(auto-fill,minmax(var(--sm-tile,96px),1fr));margin:0;padding:0;border:0;min-width:0}
105
107
  :where(.sm-choices[data-size="wide"]){--sm-tile:160px}
106
108
  :where(.sm-choices) > :where(legend){padding:0;margin-bottom:6px;font-size:var(--fs-body-s,.875rem);color:var(--fg-muted)}
107
- :where(.sm-choices) > :where(label){position:relative;display:grid;justify-items:center;align-content:start;gap:6px;padding:var(--s-2,8px);border:1px solid var(--border-tok);border-radius:var(--r-3,8px);background:var(--bg-elev-1);color:var(--fg-muted);font-size:var(--fs-caption,.8125rem);text-align:center;cursor:pointer}
109
+ :where(.sm-choices) > :where(label){position:relative;display:grid;justify-items:center;align-content:start;gap:6px;padding:var(--s-2,8px);border:1px solid var(--border-tok);border-radius:var(--r-3,8px);background:var(--bg-elev-1);color:var(--fg-muted);font-size:max(12px,var(--fs-caption,.8125rem));text-align:center;cursor:pointer}
108
110
  :where(.sm-choices) > :where(label):hover{border-color:var(--border-strong);color:var(--fg)}
109
111
  :where(.sm-choices) :where(input[type="radio"],input[type="checkbox"]){position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
110
112
  :where(.sm-choices) > :where(label:has(input:checked)){border-color:var(--fg);color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)}
@@ -135,7 +137,7 @@
135
137
  :where(.sm-settings){display:grid;gap:var(--s-3,12px) var(--s-4,16px);grid-template-columns:repeat(auto-fill,minmax(min(100%,11rem),1fr));align-items:start;min-width:0}
136
138
  :where(.sm-settings) > :where(.sm-field){grid-template-columns:minmax(0,1fr);gap:4px}
137
139
  :where(.sm-settings) > :where(.sm-field:not(:has(textarea,.sm-choices,.sm-gallery,.sm-list))){--sm-control:6rem;display:flex;flex-wrap:wrap;align-items:center;gap:4px var(--s-2,8px)}
138
- :where(.sm-settings) > :where(.sm-choices) > :where(legend),:where(.sm-settings) :where(.sm-label){font-size:var(--fs-caption,.8125rem)}
140
+ :where(.sm-settings) > :where(.sm-choices) > :where(legend),:where(.sm-settings) :where(.sm-label){font-size:max(12px,var(--fs-caption,.8125rem))}
139
141
  /* On a phone, a row of chips or segments scrolls sideways instead of wrapping. */
140
142
  @container (max-width:519px){
141
143
  :where(.sm-choices[data-compact],.sm-choices[data-segmented]){display:flex;flex-wrap:nowrap;width:auto;overflow-x:auto;scrollbar-width:none}
@@ -157,7 +159,7 @@
157
159
  :where(.sm-list) > :where(li) > :where(details) > :where(:not(summary)){padding:var(--s-2,8px) var(--s-3,12px) var(--s-3,12px)}
158
160
  :where(.sm-list) :where(img,video,canvas,.sm-swatch){flex:none;width:40px;height:26px;object-fit:cover;border-radius:var(--r-2,4px);background:var(--bg-inset)}
159
161
  :where(.sm-list) :where(.sm-line){flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
160
- :where(.sm-list) :where(small){flex:none;color:var(--fg-subtle);font-size:var(--fs-caption,.8125rem);white-space:nowrap;font-variant-numeric:tabular-nums}
162
+ :where(.sm-list) :where(small){flex:none;color:var(--fg-subtle);font-size:max(12px,var(--fs-caption,.8125rem));white-space:nowrap;font-variant-numeric:tabular-nums}
161
163
  :where(.sm-list) > :where(.sm-list-more){display:none}
162
164
  @container (max-width:519px){
163
165
  :where(.sm-list[data-peek]:not([data-open])) > :where(li:nth-child(n+4):not(.sm-list-more)){display:none}
@@ -168,7 +170,7 @@
168
170
  :where(.sm-gallery){display:grid;gap:var(--s-2,8px);grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--sm-tile,140px)),1fr));margin:0;padding:0;list-style:none}
169
171
  :where(.sm-gallery) > :where(*){position:relative;margin:0;border-radius:var(--r-3,8px);overflow:hidden;background:var(--bg-inset)}
170
172
  :where(.sm-gallery) :where(img,video,canvas){display:block;width:100%;aspect-ratio:var(--sm-tile-ratio,1);object-fit:cover}
171
- :where(.sm-gallery) :where(figcaption){padding:6px 8px;font-size:var(--fs-caption,.8125rem);color:var(--fg-muted)}
173
+ :where(.sm-gallery) :where(figcaption){padding:6px 8px;font-size:max(12px,var(--fs-caption,.8125rem));color:var(--fg-muted)}
172
174
  :where(.sm-gallery) :where([aria-selected="true"],.is-selected){box-shadow:inset 0 0 0 2px var(--fg)}
173
175
  /* Small tiles: more per row as the screen widens, never bigger tiles. */
174
176
  :where(.sm-gallery[data-size="small"]){--sm-tile:92px;--sm-tile-ratio:3/2}