stillmade-sdk 0.1.47 → 0.1.48

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 (73) hide show
  1. package/AGENTS.md +6 -2
  2. package/FRAME_VIEWS.md +71 -5
  3. package/INTERFACE_DESIGN.md +65 -2
  4. package/MODULES.md +26 -3
  5. package/PASTE_TO_LLM.md +25 -10
  6. package/README.md +15 -0
  7. package/SDK.md +349 -51
  8. package/blocks/native/sm.board-editor/versions/3.0.0/package.js +921 -0
  9. package/blocks/native/sm.editor/versions/3.0.0/package.js +751 -0
  10. package/blocks/native/sm.panel-strip/versions/3.0.0/package.js +350 -0
  11. package/blocks/native/sm.photo-editor/versions/1.0.0/package.js +6693 -0
  12. package/blocks/native/sm.voiceover/versions/3.0.0/package.js +513 -0
  13. package/docs/build/block-builder-contract.md +2 -2
  14. package/docs/build/custom-interface.md +23 -3
  15. package/docs/build/frame-views.md +71 -5
  16. package/docs/build/hosted-text.md +8 -3
  17. package/docs/build/interface-design.md +65 -2
  18. package/docs/build/media-analysis.md +18 -1
  19. package/docs/build/modules.md +26 -3
  20. package/docs/build/with-your-ai.md +14 -0
  21. package/docs/build/workspaces.md +42 -0
  22. package/docs/reference/authoring-guide.md +4 -4
  23. package/docs/reference/cli.md +8 -3
  24. package/docs/reference/execution.md +13 -5
  25. package/docs/reference/first-party-blocks.md +5 -4
  26. package/docs/reference/generation-models.md +4 -0
  27. package/docs/reference/limits.md +9 -2
  28. package/docs/reference/permissions.md +4 -0
  29. package/docs/reference/view-bridge.md +6 -3
  30. package/docs/reference/view-rules.md +1 -1
  31. package/docs/tools/llm.md +25 -10
  32. package/package.json +1 -1
  33. package/packages/block-cli/cli.js +124 -12
  34. package/packages/block-cli/dev-host/assets/host.js +55 -30
  35. package/packages/block-cli/dev-host/assets/index.css +1 -1
  36. package/packages/block-cli/dev-server.js +74 -23
  37. package/packages/block-cli/format.js +20 -1
  38. package/packages/block-cli/ready-gate.js +34 -2
  39. package/packages/block-cli/templates.js +61 -0
  40. package/packages/block-cli/type-packages.js +7 -4
  41. package/packages/block-cli/workspace-dev.js +124 -0
  42. package/packages/block-cli/workspace-pack.js +62 -0
  43. package/packages/block-cli/workspace.js +304 -0
  44. package/packages/block-platform/block-readiness.js +5 -4
  45. package/packages/block-platform/catalog.js +5 -3
  46. package/packages/block-platform/node-defaults.js +2 -2
  47. package/packages/block-platform/plain-descriptions.js +1 -0
  48. package/packages/block-platform/type-workspace.js +8 -0
  49. package/packages/block-sdk/admission.js +2 -2
  50. package/packages/block-sdk/builder-contract.js +3 -3
  51. package/packages/block-sdk/capabilities.js +32 -12
  52. package/packages/block-sdk/contracts.js +11 -2
  53. package/packages/block-sdk/first-party-identities.js +9 -0
  54. package/packages/block-sdk/first-party-packages.js +9 -1
  55. package/packages/block-sdk/folder-package.js +34 -2
  56. package/packages/block-sdk/generation-catalog.js +7 -0
  57. package/packages/block-sdk/index.d.ts +13 -5
  58. package/packages/block-sdk/index.js +1 -1
  59. package/packages/block-sdk/mock-host.js +7 -0
  60. package/packages/block-sdk/module-hosted.js +11 -1
  61. package/packages/block-sdk/module-media-access.js +5 -3
  62. package/packages/block-sdk/permission-summary.js +1 -1
  63. package/packages/block-sdk/portable-media-actions.d.ts +4 -1
  64. package/packages/block-sdk/portable-media-actions.js +16 -1
  65. package/packages/block-sdk/remix-policy.js +2 -2
  66. package/packages/block-sdk/staged-releases.js +5 -0
  67. package/packages/block-sdk/stillmade-pack.js +64 -0
  68. package/packages/block-sdk/testing.js +2 -2
  69. package/packages/block-sdk/view-bridge.js +6 -3
  70. package/packages/block-sdk/view-design.js +59 -12
  71. package/packages/block-sdk/view.js +47 -9
  72. package/packages/design-system/block-kit.css +6 -0
  73. package/packages/design-system/theme.css +2 -0
@@ -0,0 +1,304 @@
1
+ // A workspace: one folder holding several Blocks, the code they share and the workflows (Project Types) that join
2
+ // them (owner, November 6, 2026: build several Blocks at once outside StillMade, in Claude Code, Codex or Cursor).
3
+ //
4
+ // stillmade.workspace.json {"schemaVersion":1,"name":"Listing kit","version":"1.0.0"}
5
+ // blocks/<name>/ one Block folder each (stillmade.block.json ...), the same layout `create` makes
6
+ // shared/ code module Blocks import from src/main.js; `build` bundles it into each Block
7
+ // workflows/<name>.json a Project Type whose Steps may name a Block by its folder ("block": "listing-facts")
8
+ //
9
+ // Every Block in it is still its own Block: it passes its own readiness gate and imports on its own.
10
+ import {readFile,writeFile,mkdir,readdir,lstat} from 'node:fs/promises';
11
+ import {join,resolve,dirname,relative,sep,basename} from 'node:path';
12
+ import {createRequire} from 'node:module';
13
+ import {fileURLToPath,pathToFileURL} from 'node:url';
14
+ import {renderBuilderContract} from '../block-sdk/builder-contract.js';
15
+ import {SDK_VERSION} from '../block-sdk/contracts.js';
16
+
17
+ export const WORKSPACE_FILE='stillmade.workspace.json';
18
+ export const BLOCKS_DIR='blocks',WORKFLOWS_DIR='workflows',SHARED_DIR='shared';
19
+ export const WORKSPACE_STATE='.stillmade/workspace.json';
20
+ const NAME=/^[a-z][a-z0-9-]{0,59}$/,VERSION=/^\d+\.\d+\.\d+$/;
21
+ const usage=message=>Object.assign(new Error(message),{code:'USAGE'});
22
+ const fail=(message,code='WORKSPACE')=>Object.assign(new Error(message),{code});
23
+ const exists=async path=>{try{await lstat(path);return true;}catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return false;throw error;}};
24
+ const json=value=>`${JSON.stringify(value,null,2)}\n`;
25
+ const posix=path=>path.split(sep).join('/');
26
+
27
+ export function workspaceConfig(value){
28
+ if(!value||typeof value!=='object'||Array.isArray(value)||value.schemaVersion!==1)throw fail(`${WORKSPACE_FILE} needs "schemaVersion": 1.`);
29
+ const extra=Object.keys(value).filter(key=>!['schemaVersion','name','version','description'].includes(key));
30
+ if(extra.length)throw fail(`${WORKSPACE_FILE} does not take ${extra.join(', ')}. It holds schemaVersion, name, version and description.`);
31
+ if(typeof value.name!=='string'||!value.name.trim()||value.name.length>80)throw fail(`${WORKSPACE_FILE}: name is the workspace's name, up to 80 characters.`);
32
+ if(typeof value.version!=='string'||!VERSION.test(value.version))throw fail(`${WORKSPACE_FILE}: version is three numbers, such as 1.0.0.`);
33
+ if(value.description!==undefined&&(typeof value.description!=='string'||value.description.length>500))throw fail(`${WORKSPACE_FILE}: description is up to 500 characters.`);
34
+ return value;
35
+ }
36
+
37
+ export async function isWorkspaceRoot(path){return exists(join(path,WORKSPACE_FILE));}
38
+
39
+ /** The workspace holding path (a folder or a file in it), or null: {root, config}. */
40
+ export async function findWorkspace(path){
41
+ let folder=resolve(path);
42
+ try{if(!(await lstat(folder)).isDirectory())folder=dirname(folder);}catch{folder=dirname(folder);}
43
+ for(let depth=0;depth<24;depth++){
44
+ let text=null;
45
+ try{text=await readFile(join(folder,WORKSPACE_FILE),'utf8');}catch(error){if(!['ENOENT','ENOTDIR'].includes(error.code))throw error;}
46
+ if(text!==null){let value;try{value=JSON.parse(text);}catch(error){throw fail(`${WORKSPACE_FILE}: ${error.message}`);}return {root:folder,config:workspaceConfig(value)};}
47
+ const up=dirname(folder);if(up===folder)return null;folder=up;
48
+ }
49
+ return null;
50
+ }
51
+ export async function requireWorkspace(path,command){
52
+ const found=await findWorkspace(path);
53
+ if(!found)throw usage(`${command} works inside a workspace. Start one with \`stillmade-block init <folder>\`, or use \`stillmade-block create <folder>\` for a single Block.`);
54
+ return found;
55
+ }
56
+
57
+ /** A name for a folder: lower case words joined by hyphens. */
58
+ export function folderName(text){
59
+ const name=String(text||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^[^a-z]+|-+$/g,'').slice(0,60);
60
+ if(!NAME.test(name))throw usage(`Name the item with letters, such as price-drop-alert.`);
61
+ return name;
62
+ }
63
+
64
+ /** Everything in the workspace, in folder order. Never throws for one broken item; it carries a problem instead. */
65
+ export async function workspaceItems(root){
66
+ const listing=async folder=>{try{return (await readdir(join(root,folder),{withFileTypes:true})).sort((a,b)=>a.name<b.name?-1:a.name>b.name?1:0);}catch(error){if(error.code==='ENOENT')return [];throw error;}};
67
+ const blocks=[],workflows=[],shared=[];
68
+ for(const entry of await listing(BLOCKS_DIR)){
69
+ if(!entry.isDirectory()||entry.name.startsWith('.'))continue;
70
+ const path=join(root,BLOCKS_DIR,entry.name);let manifest=null,problem=null;
71
+ try{manifest=JSON.parse(await readFile(join(path,'stillmade.block.json'),'utf8'));}
72
+ catch(error){if(error.code==='ENOENT')continue;problem=`stillmade.block.json: ${error.message}`;}
73
+ blocks.push({kind:'block',name:entry.name,path,manifest,problem,source:await exists(join(path,'src','main.js'))});
74
+ }
75
+ for(const entry of await listing(WORKFLOWS_DIR)){
76
+ // <name>.samples.json beside a workflow holds the inputs its preview runs with; it is not a workflow.
77
+ if(!entry.isFile()||!entry.name.endsWith('.json')||entry.name.endsWith('.samples.json'))continue;
78
+ const path=join(root,WORKFLOWS_DIR,entry.name);let type=null,problem=null;
79
+ try{type=JSON.parse(await readFile(path,'utf8'));}catch(error){problem=`${entry.name}: ${error.message}`;}
80
+ workflows.push({kind:'workflow',name:entry.name.slice(0,-5),path,type,problem});
81
+ }
82
+ const walk=async(folder,prefix)=>{for(const entry of await listing(folder)){if(entry.name.startsWith('.')||entry.name==='node_modules')continue;if(entry.isDirectory())await walk(`${folder}/${entry.name}`,`${prefix}${entry.name}/`);else if(entry.isFile())shared.push(`${prefix}${entry.name}`);}};
83
+ await walk(SHARED_DIR,'');
84
+ return {blocks,workflows,shared};
85
+ }
86
+
87
+ /** What a Block's kind reads as in the preview and the import list. */
88
+ export function blockKind(manifest){
89
+ const permissions=manifest?.permissions||{};
90
+ if(Array.isArray(manifest?.triggers)&&manifest.triggers.length)return 'automation';
91
+ if((permissions.connections||[]).length||(permissions.capabilities||[]).includes('connection.execute'))return 'connector';
92
+ return 'block';
93
+ }
94
+
95
+ // --- Workflows ------------------------------------------------------------------------------------------------
96
+
97
+ /**
98
+ * A workflow file as StillMade's Project Type: each Step that names a Block by folder ("block": "listing-facts")
99
+ * gets that Block's blockId and version, and the Block's folder joins packages, which the CLI then embeds.
100
+ */
101
+ export async function compileWorkflow(type,{root,file}){
102
+ if(!Array.isArray(type?.stages)||!type.stages.some(stage=>stage&&typeof stage==='object'&&Object.hasOwn(stage,'block')))return type;
103
+ const where=file?posix(relative(root,file)):'The workflow',packages=[...(type.packages||[])],added=new Set();
104
+ const stages=[];
105
+ for(const stage of type.stages){
106
+ if(!stage||typeof stage!=='object'||!Object.hasOwn(stage,'block')){stages.push(stage);continue;}
107
+ const {block,...rest}=stage;
108
+ if(typeof block!=='string'||!NAME.test(block))throw fail(`${where}: Step ${stage.id||'?'} names block ${JSON.stringify(block)}. Give the folder name of a Block in ${BLOCKS_DIR}/.`);
109
+ if(rest.blockId!==undefined||rest.version!==undefined)throw fail(`${where}: Step ${stage.id||'?'} names both block and blockId. Keep "block" (the folder); the id and version come from that Block.`);
110
+ const folder=join(root,BLOCKS_DIR,block);let manifest;
111
+ try{manifest=JSON.parse(await readFile(join(folder,'stillmade.block.json'),'utf8'));}
112
+ catch(error){throw fail(error.code==='ENOENT'?`${where}: Step ${stage.id||'?'} names block "${block}", but ${BLOCKS_DIR}/${block} has no stillmade.block.json.`:`${BLOCKS_DIR}/${block}/stillmade.block.json: ${error.message}`);}
113
+ stages.push({...rest,blockId:manifest.id,version:manifest.version});
114
+ if(!added.has(folder)){added.add(folder);packages.push(folder);}
115
+ }
116
+ return {...type,stages,packages};
117
+ }
118
+
119
+ /** Each workspace Block's tests/mocks.json by id@version, so a workflow's checks and preview answer its hosted calls
120
+ * the way the Block's own test does (a Type file carries no mocks). */
121
+ export async function workspaceMocks(root){
122
+ const {blocks}=await workspaceItems(root),found=new Map();
123
+ for(const item of blocks){
124
+ if(!item.manifest?.id)continue;
125
+ let text;try{text=await readFile(join(item.path,'tests','mocks.json'),'utf8');}catch{continue;}
126
+ try{found.set(`${item.manifest.id}@${item.manifest.version}`,JSON.parse(text));}catch(error){throw fail(`${BLOCKS_DIR}/${item.name}/tests/mocks.json: ${error.message}`);}
127
+ }
128
+ return found;
129
+ }
130
+
131
+ const PORT_TYPES=port=>String(port?.type||'');
132
+ /** Connections between consecutive Steps where an earlier Step's output has the same name, or the same meaning and type, as a later input. */
133
+ export function suggestConnections(stages){
134
+ const connections=[];
135
+ for(let index=1;index<stages.length;index++){
136
+ const target=stages[index];
137
+ for(const [name,port] of Object.entries(target.manifest.inputs||{})){
138
+ let from=null;
139
+ for(let back=index-1;back>=0&&!from;back--){
140
+ const outputs=Object.entries(stages[back].manifest.outputs||{});
141
+ const match=outputs.find(([out])=>out===name)||outputs.find(([,out])=>out?.semantic&&out.semantic===port?.semantic&&PORT_TYPES(out)===PORT_TYPES(port));
142
+ if(match)from={stage:stages[back].id,port:match[0]};
143
+ }
144
+ if(from)connections.push({from,to:{stage:target.id,port:name}});
145
+ }
146
+ }
147
+ return connections;
148
+ }
149
+
150
+ /** workflows/<name>.json with a Step for each named Block (every Block when none are named), connected where the ports agree. */
151
+ export async function addWorkflow(root,name,{blocks:names=[],config}){
152
+ const slug=folderName(name),file=join(root,WORKFLOWS_DIR,`${slug}.json`);
153
+ if(await exists(file))throw usage(`${WORKFLOWS_DIR}/${slug}.json already exists.`);
154
+ const {blocks}=await workspaceItems(root);
155
+ const chosen=names.length?names.map(block=>{const found=blocks.find(item=>item.name===block);if(!found)throw usage(`There is no Block folder ${BLOCKS_DIR}/${block}. The Blocks here: ${blocks.map(item=>item.name).join(', ')||'none yet'}.`);return found;}):blocks;
156
+ if(!chosen.length)throw usage(`Add a Block first (stillmade-block add <name>); a workflow is made of the workspace's Blocks.`);
157
+ const broken=chosen.find(item=>!item.manifest);if(broken)throw fail(`${BLOCKS_DIR}/${broken.name}: ${broken.problem}`);
158
+ const used=new Set(),stages=chosen.map(item=>{let id=item.name.split('-')[0];while(used.has(id))id=`${id}-${used.size+1}`;used.add(id);return {id,block:item.name,label:item.manifest.name,manifest:item.manifest};});
159
+ const readable=slug.replace(/-/g,' ');
160
+ const type={
161
+ schemaVersion:1,id:`creator.${slug}`,version:'1.0.0',name:readable[0].toUpperCase()+readable.slice(1),
162
+ description:`Replace with one sentence saying what ${readable} makes for the person.`,
163
+ stages:[...(config?.first?[config.first]:[]),...stages.map(({manifest,...stage})=>stage)],
164
+ connections:suggestConnections(stages),
165
+ };
166
+ await mkdir(dirname(file),{recursive:true});
167
+ await writeFile(file,json(type),{flag:'wx'});
168
+ return {created:file,kind:'workflow',steps:type.stages.map(stage=>stage.block||stage.blockId),connections:type.connections.length,
169
+ next:'Connect any other output to an input by hand in "connections": {"from":{"stage":"<Step id>","port":"<output>"},"to":{"stage":"<Step id>","port":"<input>"}}, replace the placeholder description, then run `stillmade-block test` from the workspace folder.'};
170
+ }
171
+
172
+ // --- Shared code ----------------------------------------------------------------------------------------------
173
+
174
+ /** esbuild from the workspace's own install (`npm install` adds it), else from beside the SDK. */
175
+ export async function loadBundler(root){
176
+ for(const base of [join(root,'package.json'),fileURLToPath(import.meta.url)]){
177
+ try{const path=createRequire(base).resolve('esbuild');return await import(pathToFileURL(path).href);}catch{}
178
+ }
179
+ return null;
180
+ }
181
+ const IMPORTS=/(?:^|[;\n}])\s*(?:import\s*[\w{*'"]|export\s[^;'"]*?from\s*['"])|\bimport\s*\(/;
182
+ const BANNER='// Built by `stillmade-block build` from src/main.js and the workspace\'s shared/ code. Edit those files, not this one.';
183
+
184
+ /**
185
+ * Bundles src/main.js of every module Block that has one (with what it imports from shared/) into its
186
+ * module/ entry, writing only files that changed. Returns {built, unchanged, failed, shared}: shared maps each
187
+ * Block to the shared/ files it uses. bundler: an esbuild-compatible {build}; found in the workspace by default.
188
+ */
189
+ export async function buildWorkspace(root,{only,bundler}={}){
190
+ const {blocks}=await workspaceItems(root),targets=blocks.filter(item=>item.source&&item.manifest?.runtime==='module'&&(!only||only.includes(item.name)));
191
+ const report={built:[],unchanged:[],failed:[],shared:{}};
192
+ if(!targets.length)return report;
193
+ bundler??=await loadBundler(root);
194
+ for(const item of targets){
195
+ let entry='main.js';
196
+ try{entry=JSON.parse(await readFile(join(item.path,'module.json'),'utf8')).entry||entry;}catch(error){if(error.code!=='ENOENT'){report.failed.push({block:item.name,message:`module.json: ${error.message}`});continue;}}
197
+ const output=join(item.path,'module',entry);
198
+ // Code that imports nothing needs no bundler: it is the module as it is.
199
+ if(!bundler){
200
+ let text;try{text=await readFile(join(item.path,'src','main.js'),'utf8');}catch(error){report.failed.push({block:item.name,message:`src/main.js: ${error.message}`});continue;}
201
+ if(IMPORTS.test(text)){report.failed.push({block:item.name,message:'src/main.js imports other files. Install the workspace\'s tools once with `npm install` (it adds esbuild, which joins them into the Block), then build again.'});continue;}
202
+ report.shared[item.name]=[];
203
+ let current=null;try{current=await readFile(output,'utf8');}catch{}
204
+ if(current===text){report.unchanged.push(item.name);continue;}
205
+ await mkdir(dirname(output),{recursive:true});await writeFile(output,text);report.built.push(item.name);continue;
206
+ }
207
+ try{
208
+ const result=await bundler.build({entryPoints:[join(item.path,'src','main.js')],bundle:true,format:'esm',platform:'neutral',target:'es2022',write:false,metafile:true,logLevel:'silent',banner:{js:BANNER},absWorkingDir:root});
209
+ const text=result.outputFiles[0].text;
210
+ report.shared[item.name]=Object.keys(result.metafile.inputs).map(input=>posix(input)).filter(input=>input.startsWith(`${SHARED_DIR}/`)).map(input=>input.slice(SHARED_DIR.length+1)).sort();
211
+ let current=null;try{current=await readFile(output,'utf8');}catch{}
212
+ if(current===text){report.unchanged.push(item.name);continue;}
213
+ await mkdir(dirname(output),{recursive:true});await writeFile(output,text);report.built.push(item.name);
214
+ }catch(error){
215
+ const first=error?.errors?.[0];
216
+ report.failed.push({block:item.name,message:first?`${first.location?`${first.location.file}:${first.location.line}: `:''}${first.text}`:String(error?.message||error)});
217
+ }
218
+ }
219
+ await rememberBuild(root,report);
220
+ return report;
221
+ }
222
+ async function rememberBuild(root,report){
223
+ const path=join(root,WORKSPACE_STATE);let state={};try{state=JSON.parse(await readFile(path,'utf8'));}catch{}
224
+ state.shared={...(state.shared||{}),...report.shared};
225
+ state.buildFailures=Object.fromEntries([...Object.entries(state.buildFailures||{}).filter(([name])=>!(report.built.includes(name)||report.unchanged.includes(name)||report.failed.some(item=>item.block===name))),...report.failed.map(item=>[item.block,item.message])]);
226
+ await mkdir(dirname(path),{recursive:true});await writeFile(path,json(state));
227
+ }
228
+ export async function readWorkspaceState(root){try{return JSON.parse(await readFile(join(root,WORKSPACE_STATE),'utf8'));}catch{return {};}}
229
+
230
+ // A module Block runs one file; an import of another file fails only when it runs. Said before then.
231
+ const RELATIVE_IMPORT=/(?:^|[;\n}])\s*(?:import|export)\s[^;'"]*?from\s*['"](\.{1,2}\/[^'"]+)['"]|\bimport\s*\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/;
232
+ export async function unbundledImport(blockPath){
233
+ let entry='main.js';try{entry=JSON.parse(await readFile(join(blockPath,'module.json'),'utf8')).entry||entry;}catch{}
234
+ let text;try{text=await readFile(join(blockPath,'module',entry),'utf8');}catch{return null;}
235
+ const match=RELATIVE_IMPORT.exec(text);
236
+ return match?{file:`module/${entry}`,specifier:match[1]||match[2]}:null;
237
+ }
238
+
239
+ // --- init -----------------------------------------------------------------------------------------------------
240
+
241
+ /** The readiness gate as the agents' end-of-turn hook: the project's own install of the SDK when this CLI runs from one. */
242
+ function gateScript(){
243
+ const here=fileURLToPath(new URL('./ready-gate.js',import.meta.url));
244
+ const installed=/[\\/]node_modules[\\/]stillmade-sdk[\\/]/.test(here);
245
+ return {claude:installed?'"$CLAUDE_PROJECT_DIR/node_modules/stillmade-sdk/packages/block-cli/ready-gate.js"':JSON.stringify(here),cursor:installed?'node_modules/stillmade-sdk/packages/block-cli/ready-gate.js':JSON.stringify(here)};
246
+ }
247
+ export const WORKSPACE_GUIDE=`# This folder is a StillMade workspace
248
+
249
+ It holds several StillMade Blocks, the code they share and the workflows that join them. Keep every Block here.
250
+
251
+ - A new Block goes in this folder: \`npx stillmade-block add <name> [template]\` makes \`blocks/<name>/\`
252
+ (\`npx stillmade-block templates\` lists the templates). Never start a Block somewhere else for this person.
253
+ - A Block that calls a signed-in app (Gmail, Slack, Notion...) starts from the \`connector\` template:
254
+ \`npx stillmade-block add <name> connector --app gmail\`. One that runs by itself on a schedule, a webhook or a saved
255
+ video starts from \`trigger-schedule\`, \`trigger-webhook\` or \`trigger-media\`.
256
+ - A workflow (a StillMade Project Type) is \`workflows/<name>.json\`: \`npx stillmade-block add-type <name> [block ...]\`.
257
+ Its Steps name Blocks by folder (\`"block": "listing-facts"\`); the id and version come from that Block.
258
+ \`--when schedule\` starts it with StillMade's When Step, which makes it an automation.
259
+ - Code two or more module Blocks use lives in \`shared/\`. Such a Block keeps its code in \`src/main.js\` and imports
260
+ from \`../../../shared/...\`; \`npx stillmade-block build\` joins it into the Block's \`module/main.js\`. Never edit a
261
+ built \`module/main.js\` by hand. Run \`npm install\` once so the build has esbuild.
262
+ - The preview is \`npx stillmade-block dev\` from this folder (http://127.0.0.1:4800). It shows every Block and
263
+ workflow, opens the Block you last saved, and reloads by itself. If it is already running, do not start another.
264
+ - Check everything with \`npx stillmade-block test\` and \`npx stillmade-block gate\` from this folder. A Block is not
265
+ ready until the gate passes for it; report the status the gate prints.
266
+ - \`npx stillmade-block pack\` writes one \`.stillmade-pack\` file with every Block and workflow. In StillMade, Create,
267
+ then Import, takes it and asks which ones to bring in.
268
+
269
+ Every Block here follows the StillMade Block builder contract below, exactly as a Block made alone does.
270
+
271
+ `;
272
+ export async function initWorkspace(folder,{name}={}){
273
+ if(await exists(join(folder,WORKSPACE_FILE)))throw usage(`${folder} is already a workspace.`);
274
+ if(await exists(folder)){const entries=(await readdir(folder)).filter(entry=>!entry.startsWith('.'));if(entries.length)throw usage(`${folder} is not empty. Start a workspace in a new folder.`);}
275
+ const title=(name||basename(folder).replace(/[-_]+/g,' ')).trim().slice(0,80)||'Blocks';
276
+ const config={schemaVersion:1,name:title[0].toUpperCase()+title.slice(1),version:'1.0.0'};
277
+ const gate=gateScript();
278
+ const files={
279
+ [WORKSPACE_FILE]:json(config),
280
+ [`${BLOCKS_DIR}/.gitkeep`]:'',[`${WORKFLOWS_DIR}/.gitkeep`]:'',
281
+ [`${SHARED_DIR}/README.md`]:`# Shared code\n\nModules here are for the workspace's module Blocks. A Block imports them from its \`src/main.js\`\n(\`import {x} from '../../../shared/x.js'\`) and \`stillmade-block build\` joins them into that Block's \`module/main.js\`,\nso every packed Block still stands alone.\n`,
282
+ 'package.json':json({name:folderName(basename(folder)||'blocks'),private:true,type:'module',scripts:{dev:'stillmade-block dev',build:'stillmade-block build',test:'stillmade-block test',gate:'stillmade-block gate',pack:'stillmade-block pack'},devDependencies:{esbuild:'0.25.10','stillmade-sdk':`^${SDK_VERSION}`}}),
283
+ '.gitignore':'node_modules/\n.stillmade/\n*.stillmade-pack\n',
284
+ 'AGENTS.md':WORKSPACE_GUIDE+renderBuilderContract('agents-md'),
285
+ 'CLAUDE.md':'Read AGENTS.md first: this folder is a StillMade workspace, and AGENTS.md says where new Blocks go and how they are checked.\n',
286
+ '.cursor/rules/stillmade-workspace.mdc':`---\ndescription: StillMade workspace\nalwaysApply: true\n---\n${WORKSPACE_GUIDE}Read AGENTS.md for the full Block builder contract.\n`,
287
+ '.claude/settings.json':json({hooks:{Stop:[{hooks:[{type:'command',command:`node ${gate.claude} --claude`,timeout:600}]}]}}),
288
+ '.cursor/hooks.json':json({version:1,hooks:{stop:[{command:`node ${gate.cursor} --cursor`,timeout:600}]}}),
289
+ };
290
+ await mkdir(folder,{recursive:true});
291
+ for(const [path,text] of Object.entries(files)){await mkdir(dirname(join(folder,path)),{recursive:true});await writeFile(join(folder,path),text,{flag:'wx'});}
292
+ return {created:folder,kind:'workspace',name:config.name,files:Object.keys(files),next:'Run `npm install` once in this folder, then `npx stillmade-block add <name> [template]` for each Block and `npx stillmade-block dev` to preview them all.'};
293
+ }
294
+
295
+ /** After `create` made blocks/<name>: a module Block keeps its code in src/main.js, built into module/. */
296
+ export async function adoptWorkspaceBlock(path){
297
+ const manifest=JSON.parse(await readFile(join(path,'stillmade.block.json'),'utf8'));
298
+ if(manifest.runtime!=='module')return {source:null};
299
+ let entry='main.js';try{entry=JSON.parse(await readFile(join(path,'module.json'),'utf8')).entry||entry;}catch{}
300
+ let text;try{text=await readFile(join(path,'module',entry),'utf8');}catch{return {source:null};}
301
+ await mkdir(join(path,'src'),{recursive:true});
302
+ await writeFile(join(path,'src','main.js'),text,{flag:'wx'});
303
+ return {source:'src/main.js'};
304
+ }
@@ -15,7 +15,7 @@ import {initialBlockState,validateBlockState} from '../block-sdk/state.js';
15
15
  import {createUniversalResolver} from '../block-sdk/universal-resolver.js';
16
16
  import {requiresDesktopExecution} from '../block-sdk/desktop-execution.js';
17
17
  import {mobileActivationStatus} from '../block-sdk/platform-checks.js';
18
- import {blockKitProblems,BLOCK_KIT_READINESS_RULES,unmarkedButtons,viewWordingNotes} from '../block-sdk/view-design.js';
18
+ import {blockKitProblems,BLOCK_KIT_READINESS_RULES,unmarkedButtons,viewWordingNotes,interfaceMode} from '../block-sdk/view-design.js';
19
19
  import {FIRST_PARTY_SDK_PACKAGES} from '../block-sdk/first-party-packages.js';
20
20
  import {previewProjectType} from './type-preview.js';
21
21
  import {semanticRegistryForManifests} from '../block-sdk/package-semantics.js';
@@ -59,7 +59,7 @@ export function jobWordingNotes(pkg){
59
59
  // box on the Step page, so only an input with no control at all is noted.
60
60
  const lined=Object.entries(manifest.inputs||{}).filter(([name,port])=>{const control=(manifest.ui||[]).find(item=>item?.port===name);return port?.type==='text'&&!control&&(pkg?.tests||[]).some(test=>typeof test?.input?.[name]==='string'&&test.input[name].includes('\n'));}).map(([name])=>name);
61
61
  if(lined.length)notes.push(`${lined.length===1?`The input ${lined[0]} has examples`:`The inputs ${lined.join(', ')} have examples`} of several lines but ${lined.length===1?'is':'are'} not declared multi-line, so a host can draw one line and lose the line breaks. Add {"control":"text","port":"${lined[0]}","multiline":true} to manifest.ui${lined.length===1?'':' for each'}.`);
62
- if(pkg?.view)notes.push(...viewWordingNotes(pkg.view));
62
+ if(pkg?.view)notes.push(...viewWordingNotes(pkg.view,{mode:interfaceMode(manifest)}));
63
63
  if(bare.length)notes.push(`${bare.length===1?`The input ${bare[0]} is`:`The inputs ${bare.join(', ')} are`} named only by type, so the drop area reads "Drop a ${bare[0].replace(/s$/,'')} here". Name each for what the person brings (sermonVideo), unless any file of that kind does the job.`);
64
64
  return notes;
65
65
  }
@@ -177,12 +177,13 @@ export async function assessBlockReadiness(pkg,{catalog=FIRST_PARTY_SDK_PACKAGES
177
177
  else notVerified('DOD-13',sharedEditing?.reason||'The shared-interface source contract passed; two-participant runtime behavior has not been checked.');
178
178
  // A new interface names its action, shows no price and no page heading before device checks
179
179
  // count. StillMade's own built-in Blocks (sm.*) are redesigned separately.
180
- const kit=String(manifest.id).startsWith('sm.')?[]:blockKitProblems(pkg.view).filter(problem=>BLOCK_KIT_READINESS_RULES.includes(problem.rule));
180
+ const kit=String(manifest.id).startsWith('sm.')?[]:blockKitProblems(pkg.view,{mode:interfaceMode(manifest)}).filter(problem=>BLOCK_KIT_READINESS_RULES.includes(problem.rule));
181
181
  if(kit.length)failed('DOD-15',kit.map(problem=>problem.message).join(' '));
182
182
  const ui=platformChecks?mobileActivationStatus(platformChecks,sourceDigest,manifest):null;
183
183
  if(ui?.ready)verified('DOD-15',ui.reason);else notVerified('DOD-15',ui?.reason||'Device UI checks have not run for this exact package.');
184
184
  // October 30, 2026: a button Chat cannot press is a documented limitation, never a refusal.
185
- const unmarked=String(manifest.id).startsWith('sm.')?0:unmarkedButtons(pkg.view);
185
+ // November 6, 2026: an app's tool buttons are its own (an editor has dozens); only its run button needs the mark.
186
+ const unmarked=String(manifest.id).startsWith('sm.')||interfaceMode(manifest)==='app'?0:unmarkedButtons(pkg.view);
186
187
  if(unmarked){const warning=`${unmarked===1?'A button':`${unmarked} buttons`} in the view ${unmarked===1?'has':'have'} no data-stillmade-action, so Chat cannot press ${unmarked===1?'it':'them'}. Give each button data-stillmade-action naming what it does.`,current=items.get('DOD-15');notVerified('DOD-15',current?.state==='not-verified'?`${current.reason} ${warning}`:warning);}
187
188
 
188
189
  if(hosted){
@@ -1,4 +1,5 @@
1
1
  import {FIRST_PARTY_SDK_PACKAGES} from '../block-sdk/first-party-packages.js';
2
+ import {STAGED_FIRST_PARTY_RELEASES} from '../block-sdk/staged-releases.js';
2
3
  import { SDK_VERSION, ROOT_CHAT_ID } from '../block-sdk/contracts.js';
3
4
  import {VIDEO_ASSETS_BLOCK} from './video-assets.js';
4
5
  import {HOST_RELEASE_MANIFEST_SNAPSHOTS} from './host-release-manifests.js';
@@ -129,8 +130,9 @@ export const AUTOMATION_PARTS=new Set([]);
129
130
  // November 4, 2026 (owner): Generate (stillmade.generate) is the one Block for every image and video model; Generate
130
131
  // image and Generate video stay only as the Canvas's own generation nodes.
131
132
  export const HIDDEN_BLOCKS=new Set(['sm.generate-image','sm.generate-video','sm.final-edit','sm.edit-assistant','sm.character','sm.location','sm.style','sm.compare','sm.score','sm.rough-cut',...BLOCK_TOOLS,...AUTOMATION_PARTS]);
132
- // Exact releases to hide (id@version) when one release of a shown Block should not be offered; none today.
133
- export const HIDDEN_RELEASES=new Set([]);
133
+ // Exact releases to hide (id@version) when one release of a shown Block should not be offered: the staged first-party
134
+ // releases (staged-releases.js), which resolve for exact pins only.
135
+ export const HIDDEN_RELEASES=new Set([...STAGED_FIRST_PARTY_RELEASES]);
134
136
  export const isHiddenBlock=block=>HIDDEN_BLOCKS.has(block.manifest.id)||HIDDEN_RELEASES.has(`${block.manifest.id}@${block.manifest.version}`);
135
137
  // Export video is offered as the original Editor opened at its Export (1.0.0), so
136
138
  // people can still change the cut before rendering (the owner, Oct 15). The
@@ -154,7 +156,7 @@ export const isOfferedBlock=block=>block.manifest.id!==STARTING_CHAT_BLOCK.manif
154
156
  // none of, so planning keeps the newest render-only release in its place. Chat
155
157
  // builds then fold it into the Editor (compact-project.js), so people still get
156
158
  // the Editor at its Export.
157
- const newestRelease=id=>SDK_BUILTIN_BLOCKS.filter(block=>block.manifest.id===id).reduce((best,block)=>!best||newer(block.manifest.version,best.manifest.version)?block:best,null);
159
+ const newestRelease=id=>SDK_BUILTIN_BLOCKS.filter(block=>block.manifest.id===id&&!HIDDEN_RELEASES.has(`${id}@${block.manifest.version}`)).reduce((best,block)=>!best||newer(block.manifest.version,best.manifest.version)?block:best,null);
158
160
  // What people browse (Add Block, the Marketplace): one card per Block. The Editor is the full video
159
161
  // editor (its host release); its portable releases stay discoverable for planning only (October 22, 2026:
160
162
  // Add Block listed Video editor, Editor 2.2.11 and Editor 2.2.12).
@@ -34,7 +34,7 @@ export function defaultNodeData(type, request = {}, sceneNumber = 1, {llmModel,
34
34
  projectTitle: request.projectTitle || '',
35
35
  thumbnailBrief: prompt,
36
36
  text: prompt,
37
- model: 'gpt-image-2',
37
+ model: 'gpt-image-2.5-sunburst',
38
38
  aspectRatio: '16:9',
39
39
  resolution: '1K',
40
40
  variations: 1,
@@ -56,7 +56,7 @@ export function defaultNodeData(type, request = {}, sceneNumber = 1, {llmModel,
56
56
  readablePrompt: request.prompt || `Scene ${sceneNumber} image prompt.`,
57
57
  characterRefs: [],
58
58
  locationRefs: [],
59
- model: 'gpt-image-2',
59
+ model: 'gpt-image-2.5-sunburst',
60
60
  aspectRatio: '16:9',
61
61
  quality: 'medium',
62
62
  resolution: '1K',
@@ -21,6 +21,7 @@ export const PLAIN_BLOCK_DESCRIPTIONS=Object.freeze({
21
21
  'sm.grade':'Change the colors of chosen clips with ready-made looks or simple sliders. Your originals stay as they are.',
22
22
  'sm.transition':'Pick transitions between clips, with their timing and sounds, then apply them in the Editor.',
23
23
  'sm.compare':'Compare two images with a slider and keep the one you prefer.',
24
+ 'sm.photo-editor':'Edit a photo: light, color, crop and straighten, presets, before and after. Saved at full size.',
24
25
  'sm.photo-notes':'Rate up to four photos for sharpness, light, people and faces.',
25
26
  'sm.export-timeline':'Download your timeline to open in another editor, with or without its media.',
26
27
  'sm.captions':'Turn a transcript or text into captions with word highlighting, then add them to the timeline.',
@@ -80,6 +80,14 @@ export async function compileTypeWorkspace(type,{experimentalBrowser,experimenta
80
80
  });
81
81
  return { definition, digest: await digest(definition), canvas: { nodes, edges }, activeStage: definition.stages.find(stage=>stage.enabled !== false)?.id || null };
82
82
  }
83
+ // A project made of one Block, as Marketplace's "Use block" starts it (owner, November 6, 2026: it opened the
84
+ // Block on its own instead of a project with it). Built-in Blocks come from the catalog when the project is
85
+ // compiled; any other Block travels inside the workflow as its exact package.
86
+ export function singleBlockType(pkg) {
87
+ const manifest = pkg.manifest || pkg, builtin = BUILTIN_BLOCKS.some(item => item.manifest.id === manifest.id && item.manifest.version === manifest.version) || SUPPORTED_HOST_BLOCKS.includes(manifest.id);
88
+ return { schemaVersion: 1, id: `workspace.${manifest.id.replace(/^sm\./, '')}`, version: '1.0.0', name: manifest.name, description: manifest.description,
89
+ stages: [{ id: 'workspace', blockId: manifest.id, version: manifest.version, label: manifest.name }], ...(builtin ? {} : { packages: [structuredClone(pkg)] }) };
90
+ }
83
91
  // A test fixture many tests assert on (its stage ids and order), not a model Type: its Export video Step beside the
84
92
  // Editor breaks the builder contract ("One job per Step"). The create-type scaffold (TYPE_SCAFFOLD) is the model.
85
93
  export const RECIPE_TYPE_EXAMPLE = {
@@ -8,7 +8,7 @@ import {testPlatforms} from './platform-checks.js';
8
8
  import {requiresDesktopExecution} from './desktop-execution.js';
9
9
  import {runPackageAsync} from './runtime.js';
10
10
  import {validateViewAppearance} from './appearance.js';
11
- import {reviewViewDesign} from './view-design.js';
11
+ import {reviewViewDesign,interfaceMode} from './view-design.js';
12
12
  import {reviewCollaborationContract} from './collaboration-admission.js';
13
13
  import {reviewModelPortability} from './model-portability.js';
14
14
  import {sharedAgentControls} from './shared-agent-controls.js';
@@ -99,7 +99,7 @@ export function scanPackageSource(pkg) {
99
99
  else results.push({name:'Static code review',passed:true,message:pkg.manifest.runtime === 'comfyui' ? 'Only reviewed core image nodes and bounded typed bindings; no custom Python, dependencies, credentials, or paths' : pkg.manifest.runtime === 'capability' ? `Bounded declarative ${pkg.capability.operation} request only; provider, model, payment and approval belong to the host. No live execution has been verified.` : 'Only allowlisted recipe operations and bounded JSON are accepted'});
100
100
  if(pkg.view){
101
101
  results.push({name:'Sandbox interface review',passed:true,message:pkg.view.runtime==='frame'?'Frame view: any interface code, compiled with task guards and run in an opaque-origin frame with no network, generated code, navigation, popups or WebAssembly':'Bounded HTML controls and reviewed standalone UI JavaScript; opaque-origin frame, no navigation or remote resources'});
102
- const design=reviewViewDesign(pkg.view),notes=[...design.issues,...design.warnings];
102
+ const design=reviewViewDesign(pkg.view,{mode:interfaceMode(pkg.manifest)}),notes=[...design.issues,...design.warnings];
103
103
  results.push({name:'Task interface design',passed:design.passed,message:notes.length?notes.join(' '):'The task object leads, choices are bounded, and secondary detail stays progressively disclosed.'});
104
104
  }
105
105
  try {validateViewAppearance(pkg.view);results.push({name:'Appearance compatibility',passed:true,message:pkg.view?.appearance==='original'?'Interface preserves its declared original colors; StillMade theming is optional. Sandbox and shared-state checks still apply.':'Interface follows Light, Dark and White; original preview colors are preserved'});} catch(error) {results.push(pkg.view?.runtime==='frame'?{name:'Appearance compatibility',passed:true,advisory:true,message:`Advice: ${error.message} Frame views receive the theme variables; using them keeps the interface right in Light, Dark and White.`}:{name:'Appearance compatibility',passed:false,message:error.message});}
@@ -9,7 +9,7 @@
9
9
  import {HOSTED_OPERATION_NAMES} from './contracts.js';
10
10
 
11
11
  export const BUILDER_CONTRACT_ID='stillmade.block-builder';
12
- export const BUILDER_CONTRACT_VERSION='1.17.0';
12
+ export const BUILDER_CONTRACT_VERSION='1.18.0';
13
13
  export const BUILDER_CONTRACT_STAMP=`${BUILDER_CONTRACT_ID}@${BUILDER_CONTRACT_VERSION}`;
14
14
  export const BUILDER_CONTRACT_DOCS_PATH='/docs/build/block-builder-contract';
15
15
  // Block names appear in step bars, cards and pickers; new releases keep them short.
@@ -86,8 +86,8 @@ const SECTIONS=Object.freeze([
86
86
  // pictures its full-screen button; a video without controls stays the Block's own. Also that day (owner: "there is no
87
87
  // separation and I cannot tell what is going on"): a pick is the kit's ink fill, and media that cannot show is a quiet tile.
88
88
  rule('ui','block',
89
- 'Prefer `manifest.ui`; custom views use the Block kit (`sm-*`) unless a look is named. Pictures over words: preview each option before choosing; few words, fold the rest; buttons carry `data-stillmade-action`; the start button names the action (never "Run" or "Make it") and shows no price (StillMade adds it). A job fits one screen unscrolled and a 320px phone; an app (editor, configurator, CAD tool) works like that app: full canvas, panels, tools, undo, never a form. An app puts `data-stillmade-layout="own"` on its root: StillMade never centers or zooms it. `<video controls>` gets StillMade\'s player; leave out `controls` when the Block drives playback. A pick keeps the kit\'s ink fill; media that cannot show is a wordless `sm-media-off` tile. Every part shows at once: the result\'s place is drawn before the first run in its own shape and size, so nothing pops in; on wide screens inputs sit left and the result right; on phones they stack, the result after the inputs. Actions and status sit on the stage (`sm-actions`, `sm-status` in `sm-stage`); the run button stays rightmost, so making again never moves.',
90
- 'If the capability needs UI, use the Block UI SDK: generated `manifest.ui` controls first; a sandboxed custom view (`view.html`/`view.css`/`view.js`) only when the core interaction needs one. Build custom views from the Block kit the host injects (`block-kit.css`): the result in `sm-stage`, drawn before the first run in the shape and size of what is coming (`data-ratio="9:16"` for a vertical clip, lines for text, a player bar for sound) so every part of the screen is in place at once and the result fills a place the person already sees instead of popping in somewhere unexpected; StillMade draws it the same way on the Steps it draws; on a wide screen what goes in sits on the left and the result on the right, side by side, and they stack only on narrow screens and phones, the result\'s place after the inputs with the job\'s action row on it (`sm-actions` and `sm-status` inside `sm-stage`: the kit puts the status top-left and the actions top-right in the player\'s cream blocks, over a band above the media; the run button is always the rightmost, so a button that spends credits never moves and making again is pressed where it made the first time, with Download and other result actions to its left), picks as pictures in `sm-choices`, the picked one in the kit\'s ink fill (never restyled to an outline, a tint or the page\'s background) so it reads at a glance, one solid `sm-primary` naming the action in the job\'s words ("Plan the tour", "Generate voiceover"; never "Run" or "Make it") and no price (StillMade\'s run button adds it), extras in a closed `sm-more`; a look the person names wins. Fit one laptop screen without scrolling: label beside control, status in a few words, files as tiles, rare tools as icons, (i) on unclear controls. These compact rules are for a job or an ingredient. Size the view to the Block\'s tier (block-tier.js): an app, a workspace a person works in for a while (video editor, 3D configurator, CAD or drawing tool), looks and works like that kind of app instead of a compact page: its canvas or stage fills the view, with the panels, toolbars, inspector or timeline such an app has, direct manipulation (select, drag, zoom), keyboard shortcuts and undo; it keeps the host appearance and Block kit controls, its panels may scroll, and on a phone its tools fold into sheets while the canvas stays usable. Never shrink an app into a form of fields and one button. StillMade centers a standard view narrower than its Step and zooms a view to fill its space; a stage shows what it holds in its middle as one group (a caption stays with its picture) and several results fill its width as one row, on a Block\'s screen and on the pages StillMade draws; an app that lays itself out (full-size canvas or WebGL, its own scroll, pan or zoom, its own Ctrl/Cmd +/- keys) puts `data-stillmade-layout="own"` on its outermost element, and StillMade then never centers, zooms or takes those keys. StillMade draws one player for every `<video controls>` (play, timeline, time, sound and full screen, shown for 3 s the first time it comes into view, then while the pointer is on the video, with keys and touch) and a full-screen button on result pictures in `sm-stage` or `sm-gallery` and on any element marked `data-stillmade-expand` (the same value makes one set of pictures, `off` removes it); `data-stillmade-player="native"` keeps the browser\'s controls, and a `<video>` without `controls` is the Block\'s own and is never touched. A picture or clip that cannot show here (kept on another device, or it did not open) keeps its place as a quiet `sm-media-off` tile in its own shape with the `image-off` glyph and the reason in its label, never words in its place. Drop words nobody needs; put helpful ones in a closed `sm-hint`. Mark each button `data-stillmade-action` (`run` on the one that runs the Block, which the device checks press once; a page that checks first keeps `run` on that button for both presses; what it does on the others, `"play sample"`) and label each field: Chat presses and fills them. Expose the capability, not the implementation: sensible defaults, previews, progress, clear errors, visible outputs. Show no raw JSON, internal IDs, filesystem details, provider terms or developer settings unless the Block is a developer tool. When a person chooses among options they need to hear or see (voices, music, fonts, looks, templates, images), let them preview each option before choosing: a sample or thumbnail from the Block\'s own files or the host\'s free samples, never a paid generation. Skip previews only when there is nothing to preview. Choices StillMade owns (voices, models in the run review) already show samples. Follow the host appearance (Light, Dark and White) and one elastic layout: stacked on a 320px phone with no sideways scroll (the inputs, the button in normal flow, never pinned, then the result\'s place under it), and from 860px the controls left (≤380px), the result filling the right. Short settings sit side by side, each label beside its control (`sm-field`, `sm-settings`; three or four short options as one `data-segmented` row); many items are one line each (`sm-list`) that open in place; input files are small tiles (`sm-gallery data-size="small"`); on a phone chip rows scroll sideways and long lists show three. Compacting moves controls, never deletes them.'),
89
+ 'Prefer `manifest.ui`; custom views use the Block kit (`sm-*`) unless a look is named. Pictures over words: preview each option before choosing; few words, fold the rest; buttons carry `data-stillmade-action`; the start button names the action (never "Run" or "Make it") and shows no price (StillMade adds it). A job fits one screen unscrolled and a 320px phone; an app (editor, configurator, CAD tool) sets `manifest.kind: "workspace"` and works like that app: full canvas, panels, tools, undo, never a form; checks then use the app rules. An app puts `data-stillmade-layout="own"` on its root: StillMade never centers or zooms it. `<video controls>` gets StillMade\'s player; leave out `controls` when the Block drives playback. A pick keeps the kit\'s ink fill; media that cannot show is a wordless `sm-media-off` tile. Every part shows at once: the result\'s place is drawn before the first run in its own shape and size, so nothing pops in; on wide screens inputs sit left and the result right; on phones they stack, the result after the inputs. Actions and status sit on the stage (`sm-actions`, `sm-status` in `sm-stage`); the run button stays rightmost, so making again never moves.',
90
+ 'If the capability needs UI, use the Block UI SDK: generated `manifest.ui` controls first; a sandboxed custom view (`view.html`/`view.css`/`view.js`) only when the core interaction needs one. Build custom views from the Block kit the host injects (`block-kit.css`): the result in `sm-stage`, drawn before the first run in the shape and size of what is coming (`data-ratio="9:16"` for a vertical clip, lines for text, a player bar for sound) so every part of the screen is in place at once and the result fills a place the person already sees instead of popping in somewhere unexpected; StillMade draws it the same way on the Steps it draws; on a wide screen what goes in sits on the left and the result on the right, side by side, and they stack only on narrow screens and phones, the result\'s place after the inputs with the job\'s action row on it (`sm-actions` and `sm-status` inside `sm-stage`: the kit puts the status top-left and the actions top-right in the player\'s cream blocks, over a band above the media; the run button is always the rightmost, so a button that spends credits never moves and making again is pressed where it made the first time, with Download and other result actions to its left), picks as pictures in `sm-choices`, the picked one in the kit\'s ink fill (never restyled to an outline, a tint or the page\'s background) so it reads at a glance, one solid `sm-primary` naming the action in the job\'s words ("Plan the tour", "Generate voiceover"; never "Run" or "Make it") and no price (StillMade\'s run button adds it), extras in a closed `sm-more`; a look the person names wins. Fit one laptop screen without scrolling: label beside control, status in a few words, files as tiles, rare tools as icons, (i) on unclear controls. These compact rules are for a job or an ingredient. Size the view to the Block\'s tier (block-tier.js): an app, a workspace a person works in for a while (video editor, 3D configurator, CAD or drawing tool, photo editor), declares `manifest.kind: "workspace"` and looks and works like that kind of app instead of a compact page, and StillMade\'s checks (admission, readiness, its own builder) then apply the app rules instead of the compact ones: no limit on controls in view, no Block kit page, its own layout, the craft\'s own words, and only its run button (`data-stillmade-action="run"`) held to naming what it makes: its canvas or stage fills the view, with the panels, toolbars, inspector or timeline such an app has, direct manipulation (select, drag, zoom), keyboard shortcuts and undo; it keeps the host appearance and Block kit controls, its panels may scroll, and on a phone its tools fold into sheets while the canvas stays usable. Never shrink an app into a form of fields and one button. StillMade centers a standard view narrower than its Step and zooms a view to fill its space; a stage shows what it holds in its middle as one group (a caption stays with its picture) and several results fill its width as one row, on a Block\'s screen and on the pages StillMade draws; an app that lays itself out (full-size canvas or WebGL, its own scroll, pan or zoom, its own Ctrl/Cmd +/- keys) puts `data-stillmade-layout="own"` on its outermost element, and StillMade then never centers, zooms or takes those keys. StillMade draws one player for every `<video controls>` (play, timeline, time, sound and full screen, shown for 3 s the first time it comes into view, then while the pointer is on the video, with keys and touch) and a full-screen button on result pictures in `sm-stage` or `sm-gallery` and on any element marked `data-stillmade-expand` (the same value makes one set of pictures, `off` removes it); `data-stillmade-player="native"` keeps the browser\'s controls, and a `<video>` without `controls` is the Block\'s own and is never touched. A picture or clip that cannot show here (kept on another device, or it did not open) keeps its place as a quiet `sm-media-off` tile in its own shape with the `image-off` glyph and the reason in its label, never words in its place. Drop words nobody needs; put helpful ones in a closed `sm-hint`. Mark each button `data-stillmade-action` (`run` on the one that runs the Block, which the device checks press once; a page that checks first keeps `run` on that button for both presses; what it does on the others, `"play sample"`) and label each field: Chat presses and fills them. Expose the capability, not the implementation: sensible defaults, previews, progress, clear errors, visible outputs. Show no raw JSON, internal IDs, filesystem details, provider terms or developer settings unless the Block is a developer tool. When a person chooses among options they need to hear or see (voices, music, fonts, looks, templates, images), let them preview each option before choosing: a sample or thumbnail from the Block\'s own files or the host\'s free samples, never a paid generation. Skip previews only when there is nothing to preview. Choices StillMade owns (voices, models in the run review) already show samples. Follow the host appearance (Light, Dark and White) and one elastic layout: stacked on a 320px phone with no sideways scroll (the inputs, the button in normal flow, never pinned, then the result\'s place under it), and from 860px the controls left (≤380px), the result filling the right. Short settings sit side by side, each label beside its control (`sm-field`, `sm-settings`; three or four short options as one `data-segmented` row); many items are one line each (`sm-list`) that open in place; input files are small tiles (`sm-gallery data-size="small"`); on a phone chip rows scroll sideways and long lists show three. Compacting moves controls, never deletes them.'),
91
91
  rule('multiplayer','block',
92
92
  'StillMade shares every screen control (`data-stillmade-local` keeps one local); a view sharing its own state (`updateShared`) adds `onShared`, `canEdit`, no re-run on remote edits.',
93
93
  'Projects are multiplayer, and StillMade builds it: every ordinary control on a Block\'s screen (text, number, range, checkbox, color, select, textarea and a kit choice group) is shared by itself, under its `data-stillmade-share`, else its `name`, else its `id`, including controls the screen adds later; the Block writes no sharing code. `StillMade.bindShared(field, elementId)` shares a control under another field name. Mark a truly device-only control (playback, hover, device selection), or an element around several, with `data-stillmade-local`. A view that publishes its own structured state (`StillMade.updateShared(values, before, options)` or `StillMade.connectDocument`) shares its controls itself: StillMade then steps aside, and it subscribes through `StillMade.onShared(callback)`; private variables and canvas drawings are not shared unless the view publishes them. Observe shared outputs as well as state, honor `canEdit` in every mutating callback (read-only participants cannot write), never run, click or call a paid integration in response to a remote update, and preserve conflicting drafts. Shared background work uses `sharedWork` with the `work.shared` permission.'),