stillmade-sdk 0.1.46 → 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 (78) hide show
  1. package/.stillmade/sdk-baseline.json +1 -1
  2. package/AGENTS.md +6 -2
  3. package/FRAME_VIEWS.md +71 -5
  4. package/INTERFACE_DESIGN.md +105 -15
  5. package/MODULES.md +26 -3
  6. package/PASTE_TO_LLM.md +27 -10
  7. package/README.md +15 -0
  8. package/SDK.md +393 -64
  9. package/UI_AESTHETIC.md +13 -0
  10. package/blocks/native/sm.board-editor/versions/3.0.0/package.js +921 -0
  11. package/blocks/native/sm.editor/versions/3.0.0/package.js +751 -0
  12. package/blocks/native/sm.panel-strip/versions/3.0.0/package.js +350 -0
  13. package/blocks/native/sm.photo-editor/versions/1.0.0/package.js +6693 -0
  14. package/blocks/native/sm.voiceover/versions/3.0.0/package.js +513 -0
  15. package/docs/build/block-builder-contract.md +2 -2
  16. package/docs/build/custom-interface.md +23 -3
  17. package/docs/build/frame-views.md +71 -5
  18. package/docs/build/hosted-text.md +8 -3
  19. package/docs/build/interface-design.md +105 -15
  20. package/docs/build/media-analysis.md +18 -1
  21. package/docs/build/modules.md +26 -3
  22. package/docs/build/with-your-ai.md +14 -0
  23. package/docs/build/workspaces.md +42 -0
  24. package/docs/reference/authoring-guide.md +5 -4
  25. package/docs/reference/cli.md +8 -3
  26. package/docs/reference/execution.md +13 -5
  27. package/docs/reference/first-party-blocks.md +5 -4
  28. package/docs/reference/generation-models.md +4 -0
  29. package/docs/reference/limits.md +9 -2
  30. package/docs/reference/permissions.md +4 -0
  31. package/docs/reference/view-bridge.md +6 -3
  32. package/docs/reference/view-rules.md +2 -1
  33. package/docs/tools/llm.md +27 -10
  34. package/examples/custom-interface.stillmade.json +2 -2
  35. package/package.json +1 -1
  36. package/packages/block-cli/cli.js +124 -12
  37. package/packages/block-cli/dev-host/assets/host.js +63 -37
  38. package/packages/block-cli/dev-host/assets/index.css +1 -1
  39. package/packages/block-cli/dev-server.js +74 -23
  40. package/packages/block-cli/format.js +20 -1
  41. package/packages/block-cli/ready-gate.js +34 -2
  42. package/packages/block-cli/templates.js +61 -0
  43. package/packages/block-cli/type-packages.js +7 -4
  44. package/packages/block-cli/workspace-dev.js +124 -0
  45. package/packages/block-cli/workspace-pack.js +62 -0
  46. package/packages/block-cli/workspace.js +304 -0
  47. package/packages/block-platform/block-readiness.js +5 -4
  48. package/packages/block-platform/catalog.js +5 -3
  49. package/packages/block-platform/node-defaults.js +2 -2
  50. package/packages/block-platform/plain-descriptions.js +1 -0
  51. package/packages/block-platform/type-workspace.js +8 -0
  52. package/packages/block-sdk/admission.js +2 -2
  53. package/packages/block-sdk/builder-contract.js +3 -3
  54. package/packages/block-sdk/capabilities.js +32 -12
  55. package/packages/block-sdk/contracts.js +11 -2
  56. package/packages/block-sdk/examples.js +2 -2
  57. package/packages/block-sdk/first-party-identities.js +9 -0
  58. package/packages/block-sdk/first-party-packages.js +9 -1
  59. package/packages/block-sdk/folder-package.js +34 -2
  60. package/packages/block-sdk/generation-catalog.js +7 -0
  61. package/packages/block-sdk/index.d.ts +13 -5
  62. package/packages/block-sdk/index.js +1 -1
  63. package/packages/block-sdk/mock-host.js +7 -0
  64. package/packages/block-sdk/module-hosted.js +11 -1
  65. package/packages/block-sdk/module-media-access.js +5 -3
  66. package/packages/block-sdk/permission-summary.js +1 -1
  67. package/packages/block-sdk/portable-media-actions.d.ts +4 -1
  68. package/packages/block-sdk/portable-media-actions.js +16 -1
  69. package/packages/block-sdk/remix-policy.js +2 -2
  70. package/packages/block-sdk/staged-releases.js +5 -0
  71. package/packages/block-sdk/stillmade-pack.js +64 -0
  72. package/packages/block-sdk/testing.js +2 -2
  73. package/packages/block-sdk/view-bridge.js +6 -3
  74. package/packages/block-sdk/view-design.js +65 -17
  75. package/packages/block-sdk/view-renderer.js +17 -1
  76. package/packages/block-sdk/view.js +47 -9
  77. package/packages/design-system/block-kit.css +23 -0
  78. package/packages/design-system/theme.css +10 -7
@@ -6,11 +6,17 @@ import {validateConnectedCapability,prepareConnectedInvocation,validateConnected
6
6
  import {IMAGE_GENERATION_MODELS,VIDEO_GENERATION_MODELS,GENERATION_LIMITS,imageSettings,videoSettings,validateSeed} from './generation-catalog.js';
7
7
 
8
8
  export const CAPABILITY_LIMITS=Object.freeze({prompt:32000,contextBytes:24000,system:8000,output:32768,maxTokens:4096,fixtures:3,includes:8,includeLength:256,packageBytes:262144,metadataBytes:8192});
9
+ // text.generate takes long structured jobs too (November 6, 2026: a panel plan's instructions are 19,400 characters and its
10
+ // reply up to 24,000 tokens). Its price grows with the size a call may reach (packages/credit-pricing textCallCredits).
11
+ export const TEXT_GENERATE_LIMITS=Object.freeze({system:32000,prompt:64000,maxTokens:24000,output:131072});
9
12
  export const SPEECH_CAPABILITY_LIMITS=Object.freeze({text:3800,duration:1400,bytes:67108864,sampleRate:24000,channels:1});
10
13
  // Measured catalog audio (voices, music, sound effects): MP3 or WAV as generated.
11
14
  // The host reads the page or searches; the Block only names what to read and why.
12
15
  // media.analyze: up to four images, or one video with one closer-inspected range of at most 12 seconds.
13
16
  export const MEDIA_ANALYZE_LIMITS=Object.freeze({images:4,question:2000,range:12});
17
+ // reply:"text" (November 6, 2026): the Block's own instructions and question over one to ten pictures, answered in one
18
+ // vision call as text (a panel's text placement, a style check across frames), priced per picture like an analysis.
19
+ export const MEDIA_REPLY_LIMITS=Object.freeze({images:10,question:8000,instructions:8000,maxTokens:4000});
14
20
  // October 30, 2026: detail:"check" is a photo check (owner's option C): images only, at most four a call, each read
15
21
  // smaller and answered in a short summary, priced per call rather than per image (server tiers.js 'media:check').
16
22
  const MEDIA_ANALYZE_DETAILS=Object.freeze(['check']);
@@ -202,10 +208,16 @@ export function validateCapability(capability,manifest){
202
208
  return true;
203
209
  }
204
210
  if(operation==='media.analyze'){
205
- assert(only(capability,['schemaVersion','operation','media','question','range','detail','output'])&&['schemaVersion','operation','media','question','output'].every(key=>own(capability,key))&&capability.schemaVersion===1&&capability.operation===operation,'CAPABILITY_SCHEMA','Declare schema 1 media.analyze with media, question and output, and optionally a range {start,end} of number inputs for a video. Payment belongs to the host.');
211
+ assert(only(capability,['schemaVersion','operation','media','question','range','detail','reply','instructions','maxTokens','output'])&&['schemaVersion','operation','media','question','output'].every(key=>own(capability,key))&&capability.schemaVersion===1&&capability.operation===operation,'CAPABILITY_SCHEMA','Declare schema 1 media.analyze with media, question and output, and optionally a range {start,end} of number inputs for a video, or reply:"text" with instructions and maxTokens. Payment belongs to the host.');
212
+ const reply=own(capability,'reply');
213
+ if(reply){
214
+ assert(capability.reply==='text'&&['image','image[]'].includes(manifest.inputs[capability.media?.$input]?.type)&&!own(capability,'range')&&!own(capability,'detail')&&manifest.outputs[capability.output]?.type==='text','CAPABILITY_SCHEMA','reply:"text" asks about one image or image[] input (never a video) and returns one text output; it takes no range or detail.');
215
+ assert(!own(capability,'instructions')||typeof capability.instructions==='string'&&capability.instructions.length<=MEDIA_REPLY_LIMITS.instructions,'CAPABILITY_LIMIT','Reply instructions contain at most 8,000 characters');
216
+ assert(!own(capability,'maxTokens')||Number.isInteger(capability.maxTokens)&&capability.maxTokens>=1&&capability.maxTokens<=MEDIA_REPLY_LIMITS.maxTokens,'CAPABILITY_LIMIT','Choose a reply token limit from 1 to 4,000');
217
+ }else assert(!own(capability,'instructions')&&!own(capability,'maxTokens'),'CAPABILITY_SCHEMA','instructions and maxTokens belong to reply:"text"');
206
218
  if(own(capability,'detail'))assert(MEDIA_ANALYZE_DETAILS.includes(capability.detail)&&['image','image[]'].includes(manifest.inputs[capability.media?.$input]?.type)&&!own(capability,'range'),'CAPABILITY_SCHEMA','detail:"check" is a short photo check: it takes one image or image[] input (never a video) and no range.');
207
219
  assert(exact(capability.media,['$input'])&&['image','image[]','video'].includes(manifest.inputs[capability.media.$input]?.type),'CAPABILITY_BINDING','media must reference the declared video, image or image[] input');
208
- assert(typeof capability.question==='string'&&capability.question.trim().length>0&&capability.question.length<=MEDIA_ANALYZE_LIMITS.question,'CAPABILITY_LIMIT','The analysis question must contain 1 to 2,000 characters');
220
+ assert(typeof capability.question==='string'&&capability.question.trim().length>0&&capability.question.length<=(reply?MEDIA_REPLY_LIMITS.question:MEDIA_ANALYZE_LIMITS.question),'CAPABILITY_LIMIT',reply?'The reply question must contain 1 to 8,000 characters':'The analysis question must contain 1 to 2,000 characters');
209
221
  const numbers=Object.keys(manifest.inputs).filter(name=>manifest.inputs[name].type==='number');
210
222
  if(own(capability,'range'))assert(plain(capability.range)&&exact(capability.range,['start','end'])&&['start','end'].every(key=>exact(capability.range[key],['$input'])&&manifest.inputs[capability.range[key].$input]?.type==='number')&&capability.range.start.$input!==capability.range.end.$input,'CAPABILITY_BINDING','range binds start and end to two declared number inputs');
211
223
  assert(numbers.length===(own(capability,'range')?2:0),'CAPABILITY_BINDING','Declare number inputs only as the bound start and end of range');
@@ -250,15 +262,15 @@ export function validateCapability(capability,manifest){
250
262
  // Context reaches the model only when the Block lists it; an unlisted input
251
263
  // (for example a saved script a view only displays) is never sent.
252
264
  if(own(capability,'context'))assert(Array.isArray(capability.context)&&capability.context.length>0&&capability.context.length<=3&&new Set(capability.context).size===capability.context.length&&capability.context.every(name=>typeof name==='string'&&name!==capability.prompt.$input&&own(manifest.inputs,name)),'CAPABILITY_BINDING','context lists one to three other declared input names to send to the model');
253
- assert(typeof capability.system==='string'&&capability.system.length<=CAPABILITY_LIMITS.system,'CAPABILITY_LIMIT','System instructions must contain at most 8,000 characters');
254
- assert(Number.isInteger(capability.maxTokens)&&capability.maxTokens>=1&&capability.maxTokens<=CAPABILITY_LIMITS.maxTokens,'CAPABILITY_LIMIT','Choose an output token limit from 1 to 4096');
265
+ assert(typeof capability.system==='string'&&capability.system.length<=TEXT_GENERATE_LIMITS.system,'CAPABILITY_LIMIT','System instructions must contain at most 32,000 characters');
266
+ assert(Number.isInteger(capability.maxTokens)&&capability.maxTokens>=1&&capability.maxTokens<=TEXT_GENERATE_LIMITS.maxTokens,'CAPABILITY_LIMIT','Choose an output token limit from 1 to 24,000');
255
267
  assert(typeof capability.output==='string'&&own(manifest.outputs,capability.output),'CAPABILITY_BINDING','Output must name the exact declared text or script port');
256
268
  // Earlier turns of a conversation: one json input holding [{role, content}], sent before the prompt.
257
269
  if(own(capability,'history'))assert(exact(capability.history,['$input'])&&manifest.inputs[capability.history.$input]?.type==='json'&&capability.history.$input!==capability.prompt.$input&&!(capability.context||[]).includes(capability.history.$input),'CAPABILITY_BINDING','history references one declared json input holding earlier conversation turns');
258
270
  // Structured replies: a json output port pairs with the JSON schema the reply must match.
259
271
  const json=manifest.outputs[capability.output].type==='json';
260
272
  assert(json===own(capability,'schema'),'CAPABILITY_SCHEMA',json?'A json output needs capability.schema, the JSON schema its reply must match':'capability.schema needs a json output port');
261
- if(json){validateJsonSchema(capability.schema);assert(textSystem(capability).length<=CAPABILITY_LIMITS.system,'CAPABILITY_LIMIT','System instructions and the JSON schema together must contain at most 8,000 characters');}
273
+ if(json){validateJsonSchema(capability.schema);assert(textSystem(capability).length<=TEXT_GENERATE_LIMITS.system,'CAPABILITY_LIMIT','System instructions and the JSON schema together must contain at most 32,000 characters');}
262
274
  const out=manifest.outputs[capability.output];
263
275
  // A structured answer (a decision, score, extracted fields) is one object
264
276
  // port with a flat field schema the host asks the model to fill.
@@ -320,14 +332,15 @@ export function prepareCapabilityInvocation(pkg,input){
320
332
  const values=validateValues(pkg.manifest.inputs,input),name=pkg.capability.media.$input,type=pkg.manifest.inputs[name].type,raw=values[name];
321
333
  const saved=(value,kind)=>{assert(plain(value)&&value.kind===kind&&['assetId','versionId','url'].every(key=>typeof value[key]==='string'&&value[key].trim()),'CAPABILITY_INPUT',`Media analysis needs a saved ${kind} with a URL`);return {kind,assetId:value.assetId,versionId:value.versionId,url:value.url};};
322
334
  const list=type==='image[]'?raw:[raw];
323
- assert(Array.isArray(list)&&list.length>=1&&list.length<=MEDIA_ANALYZE_LIMITS.images,'CAPABILITY_INPUT','Provide one to four images to analyze');
335
+ const most=pkg.capability.reply==='text'?MEDIA_REPLY_LIMITS.images:MEDIA_ANALYZE_LIMITS.images;
336
+ assert(Array.isArray(list)&&list.length>=1&&list.length<=most,'CAPABILITY_INPUT',most===4?'Provide one to four images to analyze':'Provide one to ten images to ask about');
324
337
  const media=list.map(value=>saved(value,type==='video'?'video':'image'));
325
338
  let range={};
326
339
  if(own(pkg.capability,'range')){
327
340
  const start=values[pkg.capability.range.start.$input],end=values[pkg.capability.range.end.$input];
328
341
  if(start!==undefined||end!==undefined){assert(Number.isFinite(start)&&Number.isFinite(end)&&start>=0&&end>start&&end-start<=MEDIA_ANALYZE_LIMITS.range+1e-6,'CAPABILITY_INPUT','A video range needs a start and a later end, at most 12 seconds apart');range={range:{start,end}};}
329
342
  }
330
- return {operation:'media.analyze',media,question:pkg.capability.question,...range,...(own(pkg.capability,'detail')?{detail:pkg.capability.detail}:{})};
343
+ return {operation:'media.analyze',media,question:pkg.capability.question,...range,...(own(pkg.capability,'detail')?{detail:pkg.capability.detail}:{}),...(pkg.capability.reply==='text'?{reply:'text',...(own(pkg.capability,'instructions')?{instructions:pkg.capability.instructions}:{}),...(own(pkg.capability,'maxTokens')?{maxTokens:pkg.capability.maxTokens}:{})}:{})};
331
344
  }
332
345
  if(pkg.capability.operation==='web.fetch'){
333
346
  const values=validateValues(pkg.manifest.inputs,input),url=values[pkg.capability.url.$input];
@@ -357,7 +370,8 @@ export function prepareCapabilityInvocation(pkg,input){
357
370
  const values=validateValues(pkg.manifest.inputs,input),name=pkg.capability.prompt.$input;
358
371
  assert(own(values,name),'CAPABILITY_INPUT','Provide the declared prompt input or a default');
359
372
  const prompt=textValue(values[name],pkg.manifest.inputs[name]);
360
- assert(typeof prompt==='string'&&prompt.trim().length>0&&prompt.length<=CAPABILITY_LIMITS.prompt,'CAPABILITY_LIMIT','Provide a nonempty prompt of at most 32,000 characters');
373
+ const promptLimit=pkg.capability.operation==='text.generate'?TEXT_GENERATE_LIMITS.prompt:CAPABILITY_LIMITS.prompt;
374
+ assert(typeof prompt==='string'&&prompt.trim().length>0&&prompt.length<=promptLimit,'CAPABILITY_LIMIT',`Provide a nonempty prompt of at most ${promptLimit.toLocaleString('en-US')} characters`);
361
375
  if(pkg.capability.operation==='image.generate'){
362
376
  const raw=own(pkg.capability,'references')?values[pkg.capability.references.$input]:undefined,list=raw===undefined?[]:Array.isArray(raw)?raw:[raw];
363
377
  assert(list.length<=GENERATION_LIMITS.references,'CAPABILITY_LIMIT','Use at most 8 reference images');
@@ -373,9 +387,9 @@ export function prepareCapabilityInvocation(pkg,input){
373
387
  const instructionsName=pkg.capability.prompt.$instructions,instructions=instructionsName&&typeof values[instructionsName]==='string'&&values[instructionsName].trim()?values[instructionsName].trim():null;
374
388
  const full=instructions?`Instructions:\n${instructions}\n\nInput:\n${prompt}`:prompt;
375
389
  const withContext=full+projectContextSection(pkg.capability.context||[],values);
376
- assert(withContext.length<=CAPABILITY_LIMITS.prompt,'CAPABILITY_LIMIT','The prompt and its project context exceed 32,000 characters. Shorten the prompt or choose less project context.');
390
+ assert(withContext.length<=TEXT_GENERATE_LIMITS.prompt,'CAPABILITY_LIMIT','The prompt and its project context exceed 64,000 characters. Shorten the prompt or choose less project context.');
377
391
  const history=own(pkg.capability,'history')?conversationHistory(values[pkg.capability.history.$input]):[];
378
- assert(withContext.length+history.reduce((sum,turn)=>sum+turn.content.length,0)<=CAPABILITY_LIMITS.prompt,'CAPABILITY_LIMIT','The conversation and prompt exceed 32,000 characters. Send fewer earlier turns.');
392
+ assert(withContext.length+history.reduce((sum,turn)=>sum+turn.content.length,0)<=TEXT_GENERATE_LIMITS.prompt,'CAPABILITY_LIMIT','The conversation and prompt exceed 64,000 characters. Send fewer earlier turns.');
379
393
  const out=pkg.manifest.outputs[pkg.capability.output],base=textSystem(pkg.capability),system=out.type==='object'?`${base}\n\nReply with exactly one JSON object and nothing else, with these fields: ${Object.entries(out.schema).map(([key,type])=>`"${key}" (${type==='boolean'?'true or false':type==='string'?'text':'number'})`).join(', ')}.`:base;
380
394
  return {operation:'text.generate',system,prompt:withContext,maxTokens:pkg.capability.maxTokens,...(history.length?{history}:{})};
381
395
  }
@@ -418,6 +432,12 @@ export function capabilityOutputs(pkg,text){
418
432
  const outputs={[pkg.capability.output]:edit};validateValues(pkg.manifest.outputs,outputs,'output');return outputs;
419
433
  }
420
434
  if(pkg.capability.operation==='media.analyze'){
435
+ // reply:"text": the host returns {reply}, the model's answer as text.
436
+ if(pkg.capability.reply==='text'){
437
+ const answer=text?.reply;
438
+ assert(plain(text)&&exact(text,['reply'])&&typeof answer==='string'&&answer.trim()&&answer.length<=TEXT_GENERATE_LIMITS.output,'CAPABILITY_OUTPUT','A picture reply returns one bounded text answer');
439
+ const outputs={[pkg.capability.output]:answer};validateValues(pkg.manifest.outputs,outputs,'output');return outputs;
440
+ }
421
441
  // The host returns {items:[{kind,summary,...evidence}]}; a json port keeps it all, a text port keeps the summaries.
422
442
  const result=structuredClone(text),items=result?.items;
423
443
  assert(plain(result)&&exact(result,['items'])&&Array.isArray(items)&&items.length>=1&&items.length<=MEDIA_ANALYZE_LIMITS.images&&items.every(item=>plain(item)&&['image','video'].includes(item.kind)&&typeof item.summary==='string'&&item.summary.trim()&&item.summary.length<=8000),'CAPABILITY_OUTPUT','Media analysis returns one bounded summary per analyzed item');
@@ -441,7 +461,7 @@ export function capabilityOutputs(pkg,text){
441
461
  validateGeneratedImageReference(text);
442
462
  return {[pkg.capability.output]:structuredClone(text)};
443
463
  }
444
- assert(typeof text==='string'&&text.trim().length>0&&text.length<=CAPABILITY_LIMITS.output,'CAPABILITY_OUTPUT','The host must return nonempty generated text of at most 32,768 characters');
464
+ assert(typeof text==='string'&&text.trim().length>0&&text.length<=TEXT_GENERATE_LIMITS.output,'CAPABILITY_OUTPUT','The host must return nonempty generated text of at most 131,072 characters');
445
465
  const name=pkg.capability.output,port=pkg.manifest.outputs[name];
446
466
  if(port.type==='object'){
447
467
  let value;try{value=JSON.parse(text.trim().replace(/^```(?:json)?\s*/i,'').replace(/\s*```$/,''));}catch{assert(false,'CAPABILITY_OUTPUT','The AI did not return the requested JSON fields');}
@@ -684,7 +704,7 @@ export function testCapabilityOutputs(manifest,outputs,expectations){
684
704
  }
685
705
  if(manifest.outputs[name].type==='object')return {passed:true,checks:[{output:name,kind:'object',fields:Object.keys(values[name]).length}]};
686
706
  const text=textValue(values[name],manifest.outputs[name],{output:true}),expected=expectations[name];
687
- assert(typeof text==='string'&&text.trim().length>0&&text.length<=CAPABILITY_LIMITS.output,'CAPABILITY_OUTPUT','The host must return nonempty generated text of at most 32,768 characters');
707
+ assert(typeof text==='string'&&text.trim().length>0&&text.length<=TEXT_GENERATE_LIMITS.output,'CAPABILITY_OUTPUT','The host must return nonempty generated text of at most 131,072 characters');
688
708
  assert(text.length>=expected.minLength&&text.length<=expected.maxLength,'CAPABILITY_EXPECTATION',`${name} did not match the expected text length`);
689
709
  for(const literal of expected.includes||[])assert(text.includes(literal),'CAPABILITY_EXPECTATION',`${name} did not contain an expected literal phrase`);
690
710
  return {passed:true,checks:[{output:name,kind:manifest.outputs[name].type,length:text.length,includesMatched:expected.includes?.length||0}]};
@@ -31,7 +31,7 @@ const networkProblem=entries=>{try{networkDeclarations(entries);return '';}catch
31
31
  const networkValid=entries=>!networkProblem(entries);
32
32
  export const ROOT_CHAT_ID = 'stillmade.starting-chat';
33
33
  export const TYPES = Object.freeze(['text', 'number', 'integer', 'boolean', 'json', 'object', 'image', 'video', 'audio', 'asset', 'script', 'transcript', 'brief', 'bible', 'shot-plan', 'panel-document', 'board-document', 'scene-document', 'timeline', 'timeline-edit', 'file-bundle', 'workspace-edit', 'timeline-range', 'research', 'character', 'location', 'style', 'pipeline', 'approval', 'scene', 'shot', 'mask', 'depth', 'pose', 'metadata', 'project-context', 'file', 'document', 'table', 'url', 'date', 'color']);
34
- export const PORTABLE_ACTIONS = Object.freeze(['final.render','stock.search','presenter.generate','quality.analyze','image.generate','image.generate.batch','media.generate','youtube.upload']);
34
+ export const PORTABLE_ACTIONS = Object.freeze(['final.render','stock.search','stock.select','stock.auto','gif.search','gif.save','presenter.generate','quality.analyze','image.generate','image.generate.batch','media.generate','youtube.upload']);
35
35
  // The composite kind (a Block made of other Blocks) is off for now (October 20, 2026, owner):
36
36
  // a Block is one thing with its own compact interface. No built-in uses it; a package that
37
37
  // declares it fails validation with 'Unsupported block kind'.
@@ -97,6 +97,13 @@ export function assertStorableText(value, path = [], depth = 0) {
97
97
  assertStorableText(child, [...path, key], depth + 1);
98
98
  }
99
99
  }
100
+ // Whether a package is a published first-party package. A first-party module package also carries its files' text
101
+ // (moduleSource, checked against their digests before it runs) while its SDK folder holds the same files, so the
102
+ // comparison leaves that copy out (November 6, 2026: Voiceover 3.0.0 is the first first-party module Block with a folder).
103
+ export function sameFirstPartySource(published,candidate){
104
+ const sourceless=value=>{if(!value||typeof value!=='object'||!Object.hasOwn(value,'moduleSource'))return value;const {moduleSource,...rest}=value;return rest;};
105
+ return !!published&&stableStringify(sourceless(published))===stableStringify(sourceless(candidate));
106
+ }
100
107
  export function stableStringify(value) {
101
108
  safeDocument(value);
102
109
  const visit = v => Array.isArray(v) ? v.map(visit) : plain(v) ? Object.fromEntries(Object.keys(v).sort().map(k => [k, visit(v[k])])) : v;
@@ -258,7 +265,9 @@ export function validateManifest(manifest, { builtIn = false } = {}) {
258
265
  check(manifest.ui.filter(other => plain(other) && UI_OUTPUT_PRESENTATIONS.includes(other.control) && other.port === c.port).length === 1, 'ui', 'An output must have one unambiguous presentation');
259
266
  continue;
260
267
  }
261
- check(plain(c) && UI_INPUT_CONTROLS.includes(c.control) && Object.hasOwn(manifest.inputs || {}, c.port), 'ui', 'Control must bind to a declared input');
268
+ // Says which control is wrong and why (November 6, 2026: a coding agent's textarea control got only "Control must
269
+ // bind to a declared input" and had to guess).
270
+ check(plain(c) && UI_INPUT_CONTROLS.includes(c.control) && Object.hasOwn(manifest.inputs || {}, c.port), 'ui', !plain(c)?'Control must bind to a declared input':!UI_INPUT_CONTROLS.includes(c.control)?`Control must bind to a declared input: ${JSON.stringify(c.control)} is not a control. Use one of ${UI_INPUT_CONTROLS.join(', ')} (a text box of several lines is text with multiline: true)`:`Control must bind to a declared input: the ${c.control} control names ${JSON.stringify(c.port)}, which is not one of the inputs`);
262
271
  // A text input of several lines (one fact per line, a list) declares it, so every host draws a box that keeps its line breaks.
263
272
  if (plain(c) && c.multiline !== undefined) check(c.multiline === true && c.control === 'text' && manifest.inputs?.[c.port]?.type === 'text', 'ui', 'multiline: true goes on a text control bound to a text input');
264
273
  if (plain(c) && Object.hasOwn(UI_CONTROL_TYPES, c.control)) check(UI_CONTROL_TYPES[c.control].includes(manifest.inputs?.[c.port]?.type), 'ui', `The ${c.control} control binds a ${UI_CONTROL_TYPES[c.control].join(', ')} input`);
@@ -22,8 +22,8 @@ export const customInterface = {
22
22
  ...textCleanup,
23
23
  manifest:{...textCleanup.manifest,id:'example.clean-text-interface',version:'1.1.0',name:'Clean text studio',description:'Edit text, run the cleanup, and compare the result in a responsive self-contained workspace.'},
24
24
  view:{
25
- html:'<main class="sm-block" data-layout="split"><output class="sm-stage" id="result" aria-live="polite" data-stillmade-state="empty">Your result shows here</output><div class="sm-field"><label for="source">Text</label><textarea id="source" data-stillmade-input="text" data-stillmade-share="text"></textarea></div><div class="sm-actions"><button class="sm-primary" id="run" data-stillmade-action="run">Clean text</button></div></main>',
26
- css:'#result{place-items:start;min-height:160px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--fg)}#result[data-stillmade-state="empty"]{place-items:center;color:var(--fg-muted)}',
25
+ html:'<main class="sm-block" data-layout="split"><section class="sm-stage" id="stage" aria-live="polite"><div class="sm-actions"><button class="sm-primary" id="run" data-stillmade-action="run">Clean text</button></div><output id="result" data-stillmade-state="empty">Your result shows here</output></section><div class="sm-field"><label for="source">Text</label><textarea id="source" data-stillmade-input="text" data-stillmade-share="text"></textarea></div></main>',
26
+ css:'#stage{min-height:160px}#result{justify-self:stretch;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--fg)}#result[data-stillmade-state="empty"]{justify-self:center;color:var(--fg-muted)}',
27
27
  javascript:'const source=document.getElementById("source"), button=document.getElementById("run"), result=document.getElementById("result"); StillMade.onInput(input=>{source.value=input.text||"";document.querySelector("main").dataset.stillmadeState=source.value?"populated":"empty"}); button.addEventListener("click",async()=>{button.disabled=true;result.dataset.stillmadeState="loading";result.textContent="Cleaning…";try{const output=await StillMade.run({text:source.value});result.dataset.stillmadeState="success";result.textContent=output.text||"Empty result"}catch(error){result.dataset.stillmadeState="error";result.textContent=error.message}finally{button.disabled=false}});',
28
28
  },
29
29
  };
@@ -4,6 +4,7 @@
4
4
  export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
5
5
  "sm.ai-decide@1.0.0",
6
6
  "sm.ai-write@1.0.0",
7
+ "sm.board-editor.hosted@3.0.0",
7
8
  "sm.board-editor@2.0.0",
8
9
  "sm.board-editor@2.1.0",
9
10
  "sm.board-editor@2.2.0",
@@ -18,6 +19,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
18
19
  "sm.board-editor@2.2.7",
19
20
  "sm.board-editor@2.2.8",
20
21
  "sm.board-editor@2.2.9",
22
+ "sm.board-editor@3.0.0",
21
23
  "sm.captions@2.0.0",
22
24
  "sm.character@2.0.0",
23
25
  "sm.character@2.0.1",
@@ -29,6 +31,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
29
31
  "sm.edit-assistant@2.0.0",
30
32
  "sm.edit-assistant@2.0.1",
31
33
  "sm.edit-assistant@2.0.2",
34
+ "sm.editor.hosted@3.0.0",
32
35
  "sm.editor@2.0.0",
33
36
  "sm.editor@2.1.0",
34
37
  "sm.editor@2.2.0",
@@ -44,6 +47,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
44
47
  "sm.editor@2.2.7",
45
48
  "sm.editor@2.2.8",
46
49
  "sm.editor@2.2.9",
50
+ "sm.editor@3.0.0",
47
51
  "sm.export-audio@2.0.0",
48
52
  "sm.export-timeline@2.0.0",
49
53
  "sm.export-video@2.0.0",
@@ -65,6 +69,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
65
69
  "sm.location@2.0.1",
66
70
  "sm.meta-creative@1.0.0",
67
71
  "sm.motion-graphics@2.0.0",
72
+ "sm.panel-strip.hosted@3.0.0",
68
73
  "sm.panel-strip@2.0.0",
69
74
  "sm.panel-strip@2.1.0",
70
75
  "sm.panel-strip@2.2.0",
@@ -79,6 +84,8 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
79
84
  "sm.panel-strip@2.4.0",
80
85
  "sm.panel-strip@2.4.1",
81
86
  "sm.panel-strip@2.4.2",
87
+ "sm.panel-strip@3.0.0",
88
+ "sm.photo-editor@1.0.0",
82
89
  "sm.photo-notes@1.0.0",
83
90
  "sm.photo-notes@1.1.0",
84
91
  "sm.pipeline@2.0.0",
@@ -105,6 +112,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
105
112
  "sm.video-assets@2.1.2",
106
113
  "sm.video-assets@2.1.3",
107
114
  "sm.video-assets@2.1.4",
115
+ "sm.voiceover.hosted@3.0.0",
108
116
  "sm.voiceover@2.0.0",
109
117
  "sm.voiceover@2.1.0",
110
118
  "sm.voiceover@2.1.1",
@@ -113,6 +121,7 @@ export const FIRST_PARTY_SDK_IDENTITIES=Object.freeze(new Set([
113
121
  "sm.voiceover@2.1.4",
114
122
  "sm.voiceover@2.1.5",
115
123
  "sm.voiceover@2.1.6",
124
+ "sm.voiceover@3.0.0",
116
125
  "sm.when@1.0.0",
117
126
  "sm.when@1.1.0",
118
127
  "sm.write-summary@1.0.0",
@@ -5,6 +5,7 @@ import importMediaV202 from '../../blocks/native/sm.import-media/versions/2.0.2/
5
5
  import editorV229 from '../../blocks/native/sm.editor/versions/2.2.9/package.js';
6
6
  import editorV2210 from '../../blocks/native/sm.editor/versions/2.2.10/package.js';
7
7
  import editorV2211 from '../../blocks/native/sm.editor/versions/2.2.11/package.js';
8
+ import editorV300 from '../../blocks/native/sm.editor/versions/3.0.0/package.js';
8
9
  import editorV2212 from '../../blocks/native/sm.editor/versions/2.2.12/package.js';
9
10
  import captions from '../../blocks/native/sm.captions/versions/2.0.0/package.js';
10
11
  import exportTimeline from '../../blocks/native/sm.export-timeline/versions/2.0.0/package.js';
@@ -40,6 +41,8 @@ import voiceoverV213 from '../../blocks/native/sm.voiceover/versions/2.1.3/packa
40
41
  import voiceoverV214 from '../../blocks/native/sm.voiceover/versions/2.1.4/package.js';
41
42
  import voiceoverV215 from '../../blocks/native/sm.voiceover/versions/2.1.5/package.js';
42
43
  import voiceoverV216 from '../../blocks/native/sm.voiceover/versions/2.1.6/package.js';
44
+ // 3.0.0 (November 6, 2026): the original Voiceover screen as an SDK module Block with a frame view.
45
+ import voiceoverV300 from '../../blocks/native/sm.voiceover/versions/3.0.0/package.js';
43
46
  import generateVideo from '../../blocks/native/sm.generate-video/versions/2.0.0/package.js';
44
47
  import generateVideoV201 from '../../blocks/native/sm.generate-video/versions/2.0.1/package.js';
45
48
  import character from '../../blocks/native/sm.character/versions/2.0.0/package.js';
@@ -100,6 +103,8 @@ import panelStripV237 from '../../blocks/native/sm.panel-strip/versions/2.3.7/pa
100
103
  import panelStripV240 from '../../blocks/native/sm.panel-strip/versions/2.4.0/package.js';
101
104
  import panelStripV241 from '../../blocks/native/sm.panel-strip/versions/2.4.1/package.js';
102
105
  import panelStripV242 from '../../blocks/native/sm.panel-strip/versions/2.4.2/package.js';
106
+ // 3.0.0 (November 6, 2026): the original Image Panels screen as an SDK module Block with a frame view.
107
+ import panelStripV300 from '../../blocks/native/sm.panel-strip/versions/3.0.0/package.js';
103
108
  import boardEditorV210 from '../../blocks/native/sm.board-editor/versions/2.1.0/package.js';
104
109
  import boardEditorV220 from '../../blocks/native/sm.board-editor/versions/2.2.0/package.js';
105
110
  import boardEditorV221 from '../../blocks/native/sm.board-editor/versions/2.2.1/package.js';
@@ -113,12 +118,15 @@ import boardEditorV228 from '../../blocks/native/sm.board-editor/versions/2.2.8/
113
118
  import boardEditorV229 from '../../blocks/native/sm.board-editor/versions/2.2.9/package.js';
114
119
  import boardEditorV2210 from '../../blocks/native/sm.board-editor/versions/2.2.10/package.js';
115
120
  import boardEditorV2211 from '../../blocks/native/sm.board-editor/versions/2.2.11/package.js';
121
+ import boardEditorV300 from '../../blocks/native/sm.board-editor/versions/3.0.0/package.js';
116
122
 
117
123
  import photoNotes from '../../blocks/native/sm.photo-notes/versions/1.0.0/package.js';
118
124
  import photoNotesV110 from '../../blocks/native/sm.photo-notes/versions/1.1.0/package.js';
125
+ // November 6, 2026: a photo editor made by StillMade, an app-mode Block (kind "workspace").
126
+ import photoEditor from '../../blocks/native/sm.photo-editor/versions/1.0.0/package.js';
119
127
 
120
128
  // Exact, independently executable first-party releases. Host workspaces do not
121
129
  // belong here. Changing implementation requires a new immutable version.
122
130
  const freeze=value=>{if(value&&typeof value==='object'&&!Object.isFrozen(value)){Object.values(value).forEach(freeze);Object.freeze(value);}return value;};
123
- export const FIRST_PARTY_SDK_PACKAGES = Object.freeze([freeze(generate),freeze(photoNotesV110),freeze(photoNotes),freeze(exportVideoV210),freeze(exportVideo),freeze(exportAudio),freeze(motionGraphics),freeze(audioScore),freeze(importMediaV202),freeze(importMediaV201),freeze(editorV2212),freeze(editorV2211),freeze(editorV2210),freeze(editorV229),freeze(editorV228),freeze(panelStripV242),freeze(panelStripV241),freeze(panelStripV240),freeze(panelStripV237),freeze(panelStripV236),freeze(boardEditorV2211),freeze(boardEditorV2210),freeze(boardEditorV229),freeze(pipelineV226),freeze(pipelineV225),freeze(pipelineV224),freeze(videoAssetsV214),freeze(videoAssetsV213),freeze(voiceoverV216),freeze(voiceoverV215),freeze(voiceoverV214),freeze(voiceoverV213),freeze(roughCutV202),freeze(roughCutV201),freeze(finalEditV202),freeze(finalEditV201),freeze(editAssistantV202),freeze(editAssistantV201),freeze(gradeV202),freeze(gradeV201),freeze(transitionV202),freeze(transitionV201),freeze(compareV202),freeze(compareV201),freeze(compare),freeze(grade),freeze(transition),freeze(exportTimeline),freeze(captions),freeze(importMedia),freeze(generateImageV210),freeze(generateImage),freeze(writeSummary),freeze(automationV110),freeze(automationWhen),freeze(automationAiDecide),freeze(connectApp),freeze(automationContactFields),freeze(automationAiWrite),freeze(automationMetaCreative),freeze(voiceover),freeze(voiceoverV210),freeze(voiceoverV211),freeze(voiceoverV212),freeze(generateVideoV201),freeze(generateVideo),freeze(characterV201),freeze(character),freeze(locationV201),freeze(location),freeze(styleV201),freeze(style),freeze(videoAssets),freeze(videoAssetsV210),freeze(videoAssetsV211),freeze(videoAssetsV212),freeze(roughCut),freeze(finalEdit),freeze(editAssistant),freeze(boardEditor),freeze(boardEditorV210),freeze(boardEditorV220),freeze(boardEditorV221),freeze(boardEditorV222),freeze(boardEditorV223),freeze(boardEditorV224),freeze(boardEditorV225),freeze(boardEditorV226),freeze(boardEditorV227),freeze(boardEditorV228),freeze(panelStripV200),freeze(panelStrip),freeze(panelStripV220),freeze(panelStripV230),freeze(panelStripV231),freeze(panelStripV232),freeze(panelStripV233),freeze(panelStripV234),freeze(panelStripV235),freeze(editor),freeze(editorV210),freeze(editorV220),freeze(editorV221),freeze(editorV222),freeze(editorV223),freeze(editorV224),freeze(editorV225),freeze(editorV226),freeze(editorV227),freeze(pipelineV200),freeze(pipeline),freeze(pipelineV220),freeze(pipelineV221),freeze(pipelineV223),freeze(pipelineV222) ]);
131
+ export const FIRST_PARTY_SDK_PACKAGES = Object.freeze([freeze(generate),freeze(photoEditor),freeze(photoNotesV110),freeze(photoNotes),freeze(exportVideoV210),freeze(exportVideo),freeze(exportAudio),freeze(motionGraphics),freeze(audioScore),freeze(importMediaV202),freeze(importMediaV201),freeze(editorV300),freeze(editorV2212),freeze(editorV2211),freeze(editorV2210),freeze(editorV229),freeze(editorV228),freeze(panelStripV300),freeze(panelStripV242),freeze(panelStripV241),freeze(panelStripV240),freeze(panelStripV237),freeze(panelStripV236),freeze(boardEditorV300),freeze(boardEditorV2211),freeze(boardEditorV2210),freeze(boardEditorV229),freeze(pipelineV226),freeze(pipelineV225),freeze(pipelineV224),freeze(videoAssetsV214),freeze(videoAssetsV213),freeze(voiceoverV300),freeze(voiceoverV216),freeze(voiceoverV215),freeze(voiceoverV214),freeze(voiceoverV213),freeze(roughCutV202),freeze(roughCutV201),freeze(finalEditV202),freeze(finalEditV201),freeze(editAssistantV202),freeze(editAssistantV201),freeze(gradeV202),freeze(gradeV201),freeze(transitionV202),freeze(transitionV201),freeze(compareV202),freeze(compareV201),freeze(compare),freeze(grade),freeze(transition),freeze(exportTimeline),freeze(captions),freeze(importMedia),freeze(generateImageV210),freeze(generateImage),freeze(writeSummary),freeze(automationV110),freeze(automationWhen),freeze(automationAiDecide),freeze(connectApp),freeze(automationContactFields),freeze(automationAiWrite),freeze(automationMetaCreative),freeze(voiceover),freeze(voiceoverV210),freeze(voiceoverV211),freeze(voiceoverV212),freeze(generateVideoV201),freeze(generateVideo),freeze(characterV201),freeze(character),freeze(locationV201),freeze(location),freeze(styleV201),freeze(style),freeze(videoAssets),freeze(videoAssetsV210),freeze(videoAssetsV211),freeze(videoAssetsV212),freeze(roughCut),freeze(finalEdit),freeze(editAssistant),freeze(boardEditor),freeze(boardEditorV210),freeze(boardEditorV220),freeze(boardEditorV221),freeze(boardEditorV222),freeze(boardEditorV223),freeze(boardEditorV224),freeze(boardEditorV225),freeze(boardEditorV226),freeze(boardEditorV227),freeze(boardEditorV228),freeze(panelStripV200),freeze(panelStrip),freeze(panelStripV220),freeze(panelStripV230),freeze(panelStripV231),freeze(panelStripV232),freeze(panelStripV233),freeze(panelStripV234),freeze(panelStripV235),freeze(editor),freeze(editorV210),freeze(editorV220),freeze(editorV221),freeze(editorV222),freeze(editorV223),freeze(editorV224),freeze(editorV225),freeze(editorV226),freeze(editorV227),freeze(pipelineV200),freeze(pipeline),freeze(pipelineV220),freeze(pipelineV221),freeze(pipelineV223),freeze(pipelineV222) ]);
124
132
  export const firstPartySdkPackage = manifest => FIRST_PARTY_SDK_PACKAGES.find(pkg => pkg.manifest.id === manifest?.id && pkg.manifest.version === manifest?.version);
@@ -4,8 +4,9 @@
4
4
  // use it, so a folder made anywhere (the CLI, Codex, Claude Code, by hand) imports
5
5
  // exactly as the CLI packs it. Pure JavaScript: the caller supplies the files.
6
6
  import {VIEW_SOURCE_FILES,VIEW_CONFIG_FILE,viewFromSource} from './view-source.js';
7
- import {describeViewFiles} from './view.js';
7
+ import {describeViewFiles,compileFrameViewCode,isFrameViewCodeFile} from './view.js';
8
8
  import {describeModuleFiles,MODULE_LIMITS} from './module-runtime.js';
9
+ import {MOCKS_FILE} from './mocks.js';
9
10
 
10
11
  export const BLOCK_FOLDER_MANIFEST='stillmade.block.json';
11
12
  export const BLOCK_FOLDER_LIMITS=Object.freeze({jsonBytes:4000000,javascriptBytes:262144,viewScriptBytes:4194304,viewSourceBytes:262144});
@@ -14,7 +15,7 @@ const SOURCE=/^src\/(run\.js|recipe\.json|comfyui\.json|capability\.json|desktop
14
15
  /** Whether a path relative to a Block folder belongs to the Block. Everything
15
16
  * else (node_modules, build output, notes, .git) is left out. */
16
17
  export function blockFolderPath(path){
17
- return path===BLOCK_FOLDER_MANIFEST||path==='module.json'||path==='tests/fixtures.json'||SOURCE.test(path)||path.startsWith('module/')||path.startsWith('src/view-files/');
18
+ return path===BLOCK_FOLDER_MANIFEST||path==='module.json'||path==='tests/fixtures.json'||path===MOCKS_FILE||SOURCE.test(path)||path.startsWith('module/')||path.startsWith('src/view-files/');
18
19
  }
19
20
 
20
21
  const missing=path=>Object.assign(new Error(`Missing ${path}`),{code:'ENOENT'});
@@ -61,9 +62,21 @@ export async function packageFromFolder({read,list}){
61
62
  if(view?.runtime==='frame'){
62
63
  const assets=await folder('src/view-files/');
63
64
  if(Object.keys(assets).length){view.files=await describeViewFiles(assets);viewFiles=Object.fromEntries(Object.entries(assets).map(([name,value])=>[`view:${name}`,value]));}
65
+ // Code files (.js) are checked here as StillMade checks them before running one: one classic script each.
66
+ for(const [name,file] of Object.entries(view.files?.files||{}))if(isFrameViewCodeFile(file))compileFrameViewCode(new TextDecoder('utf-8',{fatal:true}).decode(assets[name]),name);
64
67
  }
65
68
  const desktop=await json('src/desktop.json',{optional:true});
66
69
  const pkg={manifest,...source,...(desktop!==undefined?{desktop}:{}),...(view!==undefined?{view}:{}),tests:await json('tests/fixtures.json')};
70
+ // A module Block's tests run against tests/mocks.json on the creator's machine. Each fixture without its own mocks
71
+ // carries those recorded answers, so StillMade's import review runs the tests the same way (November 6, 2026: a Gmail
72
+ // connector Block passed the gate locally and failed its import review with "no recorded answer").
73
+ if(manifest.runtime==='module'&&Array.isArray(pkg.tests)&&pkg.tests.some(test=>test&&typeof test==='object'&&test.mocks===undefined)){
74
+ const shared=await json(MOCKS_FILE,{optional:true});
75
+ if(shared&&typeof shared==='object'&&!Array.isArray(shared)&&Object.keys(shared).length){
76
+ const carried=await portableMocks(shared,read);
77
+ pkg.tests=pkg.tests.map(test=>test&&typeof test==='object'&&test.mocks===undefined?{...test,mocks:carried}:test);
78
+ }
79
+ }
67
80
  return {pkg,files:moduleFiles||viewFiles?{...(moduleFiles||{}),...(viewFiles||{})}:null};
68
81
  }
69
82
 
@@ -73,3 +86,22 @@ export function packageFilesBySha(pkg,files){
73
86
  for(const [name,file] of [...Object.entries(pkg.module?.files||{}),...Object.entries(pkg.view?.files?.files||{}).map(([name,file])=>[`view:${name}`,file])])if(files?.[name])bySha[file.sha256]=files[name];
74
87
  return bySha;
75
88
  }
89
+
90
+ // tests/mocks.json as a fixture's mocks: a file it maps to a path in the Block folder travels as {base64} (up to 1 MB;
91
+ // a larger one stays on the creator's machine, and a test that reads it runs only there).
92
+ const MOCK_FILE_BYTES=1048576;
93
+ const safeMockPath=path=>typeof path==='string'&&path.length>0&&path.length<=512&&!path.startsWith('/')&&!path.startsWith('\\')&&!/^[A-Za-z]:/.test(path)&&!path.includes('\0')&&!path.split(/[\\/]/).includes('..');
94
+ async function portableMocks(mocks,read){
95
+ if(!mocks.files||typeof mocks.files!=='object')return mocks;
96
+ const files={};
97
+ for(const [name,value] of Object.entries(mocks.files)){
98
+ if(typeof value!=='string'){files[name]=value;continue;}
99
+ // Only a file inside the Block folder: relative, no .. part, no drive or NUL (validateMocks refuses the rest anyway).
100
+ if(!safeMockPath(value))continue;
101
+ let bytes;try{bytes=await read(value);}catch{bytes=undefined;}
102
+ if(bytes&&bytes.byteLength<=MOCK_FILE_BYTES)files[name]={base64:toBase64(bytes)};
103
+ }
104
+ const {files:_,...rest}=mocks;
105
+ return Object.keys(files).length?{...rest,files}:rest;
106
+ }
107
+ function toBase64(bytes){let text='';for(let index=0;index<bytes.length;index+=32768)text+=String.fromCharCode(...bytes.subarray(index,index+32768));return btoa(text);}
@@ -16,6 +16,13 @@ export const IMAGE_GENERATION_MODELS=Object.freeze({
16
16
  'nano-banana-2-lite':image('Nano Banana 2 Lite',{aspectRatios:NANO_ASPECTS,resolutions:['1K','2K','4K'],references:8,seed:true}),
17
17
  'nano-banana-pro':image('Nano Banana Pro',{aspectRatios:NANO_ASPECTS,resolutions:['1K','2K','4K'],references:8,seed:true}),
18
18
  'gpt-image-2':image('GPT Image 2',{aspectRatios:['auto','16:9','9:16','1:1','4:3','3:4'],resolutions:['1K','2K','4K'],qualities:['low','medium','high'],references:8}),
19
+ // GPT Image 2.5 (November 2026): the same settings as GPT Image 2 at the same token rates, about a quarter of its price at
20
+ // medium and high. Flare is the everyday one, Sunburst the one for precise edits; both cost the same.
21
+ 'gpt-image-2.5-flare':image('GPT Image 2.5 Flare',{aspectRatios:['auto','16:9','9:16','1:1','4:3','3:4'],resolutions:['1K','2K','4K'],qualities:['low','medium','high'],references:8}),
22
+ 'gpt-image-2.5-sunburst':image('GPT Image 2.5 Sunburst',{aspectRatios:['auto','16:9','9:16','1:1','4:3','3:4'],resolutions:['1K','2K','4K'],qualities:['low','medium','high'],references:8}),
23
+ // GPT Image 2.6 (OpenAI, November 4, 2026): Flare and Sunburst again, about half the wait of 2.5, the same settings.
24
+ 'gpt-image-2.6-flare':image('GPT Image 2.6 Flare',{aspectRatios:['auto','16:9','9:16','1:1','4:3','3:4'],resolutions:['1K','2K','4K'],qualities:['low','medium','high'],references:8}),
25
+ 'gpt-image-2.6-sunburst':image('GPT Image 2.6 Sunburst',{aspectRatios:['auto','16:9','9:16','1:1','4:3','3:4'],resolutions:['1K','2K','4K'],qualities:['low','medium','high'],references:8}),
19
26
  'gpt-image-1.5':image('GPT-Image 1.5',{aspectRatios:['16:9','9:16','1:1'],qualities:['low','medium','high']}),
20
27
  'kling-image-v3':image('Kling Image V3',{aspectRatios:['21:9','16:9','9:16','1:1','4:3','3:4','3:2','2:3'],resolutions:['1K','2K']}),
21
28
  'seedream-v4.5':image('Seedream 4.5',{aspectRatios:['16:9','9:16','1:1','4:3','3:4'],resolutions:['2K','4K'],seed:true}),
@@ -49,6 +49,8 @@ export function testPackage(pkg: BlockPackage, options?: { timeoutMs?: number; c
49
49
  export const SDK_VERSION: '0.1.0';
50
50
  export const ROOT_CHAT_ID: 'stillmade.starting-chat';
51
51
  export function stableStringify(value: unknown): string;
52
+ /** Whether `candidate` is the published first-party package `published` (its carried module text, moduleSource, left out). */
53
+ export function sameFirstPartySource(published: unknown, candidate: unknown): boolean;
52
54
  /** Throws when any text or field name in a package holds the NUL character (\u0000), which StillMade cannot store; the message names where it is (tests.1.mocks.files.tiny-1.text). Import review, validate, test, pack and ready run it. */
53
55
  export function assertStorableText(value: unknown): void;
54
56
  export function validateManifest(value: unknown): { valid: boolean; errors: { path: string; message: string }[] };
@@ -129,7 +131,7 @@ export function resolveContextInputs(manifest: Manifest, values: Record<string,
129
131
  export function projectReadSnapshot(manifest: Manifest, context?: Record<string, unknown>, fields?: string[]): Record<string, unknown>;
130
132
 
131
133
  export function validateView(view: unknown): true;
132
- export const FRAME_VIEW_LIMITS: Readonly<{htmlBytes:262144;cssBytes:262144;javascriptBytes:4194304;assetFiles:32;assetBytes:8388608;taskMs:250;longTaskMs:1000}>;
134
+ export const FRAME_VIEW_LIMITS: Readonly<{htmlBytes:262144;cssBytes:262144;javascriptBytes:4194304;assetFiles:32;assetBytes:8388608;codeFiles:64;codeFileBytes:4194304;codeBytes:16777216;mediaPreviews:24;taskMs:250;longTaskMs:1000}>;
133
135
  export const FRAME_VIEW_ASSET_TYPES: readonly string[];
134
136
  export function isFrameView(view: unknown): view is FrameView;
135
137
  export function validateFrameView(view: FrameView): true;
@@ -139,11 +141,13 @@ export function parseFrameViewJavaScript(source: string): {ast: unknown};
139
141
  export function instrumentFrameViewJavaScript(source: string, guard: string): string;
140
142
  export function describeViewFiles(files: Record<string, Uint8Array|ArrayBuffer|string>): Promise<NonNullable<FrameView['files']>>;
141
143
  /** In a frame view, the StillMade object also offers its asset files. */
142
- export interface FrameViewAssets { asset(path: string): string; assetBytes(path: string): ArrayBuffer; }
143
- export const VIEW_LIMITS: Readonly<{htmlBytes:65536;cssBytes:32768;javascriptBytes:65536;messageBytes:4194304;runsPerMinute:30;imagePreviewsPerMinute:60;imagePreviewMs:15000;imagePreviewEdge:512}>;
144
+ export interface FrameViewAssets { asset(path: string): string; assetBytes(path: string): ArrayBuffer; loadCode(path: string): Promise<void>; }
145
+ export const VIEW_LIMITS: Readonly<{htmlBytes:65536;cssBytes:32768;javascriptBytes:65536;messageBytes:4194304;runsPerMinute:120;imagePreviewsPerMinute:240;voicePreviewsPerMinute:30;allowanceCredits:2000;imagePreviewMs:15000;imagePreviewEdge:512;imagePreviewMaxEdge:2048}>;
144
146
 
145
147
  /** A display-only copy. Never substitute this URL for the original run input. */
146
- export interface BlockImagePreview { url: `data:image/png;base64,${string}`; width: number; height: number; }
148
+ export interface BlockImagePreview { url: `data:image/png;base64,${string}`|`data:image/jpeg;base64,${string}`; width: number; height: number; }
149
+ /** `edge`: the longest side of the display copy, 64 to 2048 pixels (default 512). */
150
+ export interface BlockImagePreviewOptions { edge?: number; }
147
151
  export interface BlockImagePixels { width: number; height: number; data: number[]; role?: string; }
148
152
  export interface BlockImageReference { kind: 'image'; assetId: string; versionId: string; url: string; role?: string; }
149
153
  export type DesktopPermission = "screen.capture"|"cursor.track"|"camera.capture"|"microphone.capture"|"clipboard.read"|"clipboard.write"|"media.pick"|"notifications.show"|"power.keep-awake"|"native.tools"|"folder.read"|"folder.write";
@@ -171,7 +175,7 @@ export interface BlockViewNode { tag: BlockViewTag; text?: string; children?: Bl
171
175
  export interface BlockViewAction { action: string; value: string; checked: boolean; event: 'click'|'input'|'change'; }
172
176
  export interface BlockMediaReference { kind: 'video'|'audio'; assetId: string; versionId: string; url: string; role?: string; }
173
177
  export interface BlockMediaPreview { kind: 'video'|'audio'; url: string; mimeType: string; bytes: number; }
174
- export interface BlockViewBridge { useOutput(outputs: Record<string, BlockMediaReference|BlockMediaReference[]>): Promise<Record<string, unknown>>; previewMedia(value: BlockMediaReference): Promise<BlockMediaPreview>; releaseMedia(url: string): void; render(targetId: string, tree: BlockViewNode[]): void; onAction(callback: (event: BlockViewAction) => void|Promise<void>): () => void; desktop(operation: DesktopPermission|DesktopToolOperation|import('./block-storage.js').BlockStorageOperation|"record.start"|"record.stop"|"record.cancel"|"session.close"|"live.start"|"live.stop"|"live.status"|"folder.pick"|"folder.shared"|"folder.forget"|"folder.list", args?: Record<string,unknown>): Promise<Record<string,unknown>>; readonly input: Record<string, unknown>; onInput(callback: (input: Record<string, unknown>) => void): () => void; run(input: Record<string, unknown>): Promise<Record<string, unknown>>; previewImage(value: BlockImageReference|BlockImagePixels): Promise<BlockImagePreview>; }
178
+ export interface BlockViewBridge { useOutput(outputs: Record<string, BlockMediaReference|BlockMediaReference[]>): Promise<Record<string, unknown>>; previewMedia(value: BlockMediaReference): Promise<BlockMediaPreview>; releaseMedia(url: string): void; render(targetId: string, tree: BlockViewNode[]): void; onAction(callback: (event: BlockViewAction) => void|Promise<void>): () => void; desktop(operation: DesktopPermission|DesktopToolOperation|import('./block-storage.js').BlockStorageOperation|"record.start"|"record.stop"|"record.cancel"|"session.close"|"live.start"|"live.stop"|"live.status"|"folder.pick"|"folder.shared"|"folder.forget"|"folder.list", args?: Record<string,unknown>): Promise<Record<string,unknown>>; readonly input: Record<string, unknown>; onInput(callback: (input: Record<string, unknown>) => void): () => void; run(input: Record<string, unknown>): Promise<Record<string, unknown>>; previewImage(value: BlockImageReference|BlockImagePixels, options?: BlockImagePreviewOptions): Promise<BlockImagePreview>; }
175
179
 
176
180
  /** Validate hosted UI colors independently of the preserved original stylesheet. */
177
181
  export function validateViewAppearance(view?: BlockView, options?: {appearance?:'host'|'original'}): true;
@@ -254,6 +258,10 @@ export type CapabilityExecutor = (pkg: BlockPackage, input: Record<string, unkno
254
258
  export const CAPABILITY_LIMITS: Readonly<{prompt:32000;system:8000;output:32768;maxTokens:4096;fixtures:3;includes:8;includeLength:256;packageBytes:262144;metadataBytes:8192}>;
255
259
  /** media.analyze bounds: images per run, question length and the closer-inspected video range in seconds. */
256
260
  export const MEDIA_ANALYZE_LIMITS: Readonly<{images:4;question:2000;range:12}>;
261
+ /** media.analyze reply:"text": the Block's own question and instructions about one to ten pictures, answered as text. */
262
+ export const MEDIA_REPLY_LIMITS: Readonly<{images:10;question:8000;instructions:8000;maxTokens:4000}>;
263
+ /** text.generate's limits: long structured jobs are priced by their size. */
264
+ export const TEXT_GENERATE_LIMITS: Readonly<{system:32000;prompt:64000;maxTokens:24000;output:131072}>;
257
265
  /** web.fetch and web.research bounds: URL, instruction and topic lengths, sources kept and answer size. */
258
266
  export const WEB_CAPABILITY_LIMITS: Readonly<{url:2048;instruction:4000;topic:500;sources:12;answer:32768}>;
259
267
  /** image.describe with an image[] input sends one to four images together. */
@@ -27,7 +27,7 @@ export * from './admission.js';
27
27
  export {CONTEXT_FIELDS,resolveContextInputs,projectReadSnapshot} from './context.js';
28
28
 
29
29
  export {validateView,VIEW_LIMITS,FRAME_VIEW_LIMITS,FRAME_VIEW_ASSET_TYPES,isFrameView,validateFrameView,parseFrameViewJavaScript,instrumentFrameViewJavaScript,describeViewFiles} from './view.js';
30
- export {reviewViewDesign,blockKitProblems,BLOCK_KIT_READINESS_RULES,BLOCK_INTERFACE_PRINCIPLES,BLOCK_KIT_CLASSES,INTERFACE_DESIGN_PRINCIPLES,INTERFACE_DESIGN_CONVENTIONS,INTERFACE_DESIGN_RULES,INTERFACE_AUTHORING_RULES} from './view-design.js';
30
+ export {reviewViewDesign,interfaceMode,interfacePrinciples,INTERFACE_MODES,SHARED_INTERFACE_PRINCIPLES,JOB_INTERFACE_PRINCIPLES,APP_INTERFACE_PRINCIPLES,blockKitProblems,BLOCK_KIT_READINESS_RULES,BLOCK_INTERFACE_PRINCIPLES,BLOCK_KIT_CLASSES,INTERFACE_DESIGN_PRINCIPLES,INTERFACE_DESIGN_CONVENTIONS,INTERFACE_DESIGN_RULES,INTERFACE_AUTHORING_RULES} from './view-design.js';
31
31
 
32
32
  export {validateViewAppearance,APPEARANCE_TOKENS} from './appearance.js';
33
33
 
@@ -116,6 +116,8 @@ export function createMockHost({mocks:given={},readLocalFile,manifest,pickedMedi
116
116
  if(operation==='audio.music'||operation==='audio.sfx'){const limits=AUDIO_CAPABILITY_LIMITS[operation==='audio.music'?'music':'sfx'];return reference.audio({seconds:Math.min(limits.max,Math.max(limits.min,Number(request.settings?.durationSec)||limits.default)),sampleRate:8000});}
117
117
  if(operation==='audio.transcribe')return transcript(Number(request.audio?.duration));
118
118
  if(operation==='web.research')return {summary:`Stand-in findings about ${clip(request.topic,200)||'the topic'}.`,sources:[{url:'https://example.com/source',title:'Example source'}]};
119
+ // reply:"text" (the Block's own question over its pictures) answers in words, as the host's picture reply does.
120
+ if(operation==='media.analyze'&&request.reply==='text')return {reply:'A stand-in answer: a dark frame with a light band across the middle.'};
119
121
  if(operation==='media.analyze'){const list=Array.isArray(request.media)?request.media:[request.media];return {items:list.map(item=>({kind:item?.kind==='video'?'video':'image',summary:'A stand-in summary: a dark frame with a light band across the middle.',timeline:[],observations:[{observation:'A light band crosses the middle of the frame.',confidence:0.9}],detectedText:[],issues:[],confidence:0.9}))};}
120
122
  if(operation==='image.describe')return 'Stand-in description: a dark frame with a light band across the middle.';
121
123
  if(operation==='web.fetch')return `Stand-in answer from ${clip(request.url,200)}.`;
@@ -209,6 +211,11 @@ export function createMockHost({mocks:given={},readLocalFile,manifest,pickedMedi
209
211
  return {key:item.key,kind:made.kind,assetId:made.assetId,versionId:made.versionId,url:made.url,...(Number.isFinite(seconds)?{duration:seconds}:{})};
210
212
  }),failures:[]};
211
213
  if(operation==='stock.search')return {results:[]};
214
+ if(operation==='stock.select')return {asset:{...reference.image(),license:structuredClone(action.request.result.license||null)}};
215
+ if(operation==='stock.auto')return {asset:null,noMatch:true};
216
+ if(operation==='gif.search')return {enabled:true,results:[]};
217
+ if(operation==='gif.save')return {asset:reference.image()};
218
+ if(operation==='final.render')return {asset:{...(action.request.kind==='audio'?reference.audio():reference.video()),duration:action.request.timeline.targetDuration},receipt:null};
212
219
  fail(`There is no recorded stand-in for the ${operation} action. Add one under actions["${operation}"] in ${MOCKS_FILE}.`);
213
220
  },
214
221
  // The same request check and result validation as the app: a module's hosted call becomes a capability package.
@@ -15,7 +15,7 @@ const port=(type,description)=>({type,description,required:true});
15
15
  // Each operation: its ports, its capability descriptor and the input values.
16
16
  const SHAPES={
17
17
  'text.generate':request=>{
18
- if(!text(request.prompt,32000))fail('text.generate needs a prompt of up to 32,000 characters');
18
+ if(!text(request.prompt,64000))fail('text.generate needs a prompt of up to 64,000 characters');
19
19
  const schema=request.schema;
20
20
  return {inputs:{prompt:port('text','Prompt from the module')},outputs:{result:port(schema?'json':'text','Generated result')},capability:{prompt:{$input:'prompt'},system:typeof request.system==='string'?request.system:'',maxTokens:Number.isInteger(request.maxTokens)?request.maxTokens:1200,output:'result',...(schema?{schema}:{})},input:{prompt:request.prompt}};
21
21
  },
@@ -75,6 +75,16 @@ const SHAPES={
75
75
  },
76
76
  'media.analyze':request=>{
77
77
  const list=Array.isArray(request.media)?request.media:[request.media],kind=list[0]?.kind;
78
+ // reply:'text' (November 6, 2026): the module's own instructions and question over one to ten saved pictures, one call.
79
+ if(request.reply!==undefined){
80
+ if(request.reply!=='text')fail('media.analyze reply is "text"');
81
+ if(!list.length||list.length>10||!list.every(value=>media(value,'image')))fail('media.analyze reply:"text" asks about one to ten saved images');
82
+ if(!text(request.question,8000))fail('media.analyze reply:"text" needs a question of up to 8,000 characters');
83
+ if(request.instructions!==undefined&&!(typeof request.instructions==='string'&&request.instructions.length<=8000))fail('media.analyze instructions are text of at most 8,000 characters');
84
+ if(request.maxTokens!==undefined&&!(Number.isInteger(request.maxTokens)&&request.maxTokens>=1&&request.maxTokens<=4000))fail('media.analyze maxTokens is 1 to 4,000');
85
+ const many=Array.isArray(request.media);
86
+ return {inputs:{media:port(many?'image[]':'image','Pictures to ask about')},outputs:{result:port('text','The answer')},capability:{media:{$input:'media'},question:request.question,reply:'text',...(request.instructions?{instructions:request.instructions}:{}),...(request.maxTokens?{maxTokens:request.maxTokens}:{}),output:'result'},input:{media:request.media}};
87
+ }
78
88
  if(!list.length||!['image','video'].includes(kind)||!list.every(value=>media(value,kind))||(kind==='video'&&list.length!==1)||list.length>4)fail('media.analyze needs one saved video or one to four saved images');
79
89
  if(!text(request.question,2000))fail('media.analyze needs a question');
80
90
  // October 30, 2026: detail:'check' is the short photo check, priced per call of up to four photos.
@@ -58,12 +58,14 @@ const FIX='Pass the reference the Block received as input, or the one StillMade
58
58
  * vouches for (project media it read). The checks throw MODULE_FILE_READ.
59
59
  */
60
60
  export function moduleMediaAccess(input){
61
- const usable=new Set(mediaReferences(input).map(mediaReferenceKey));
61
+ const usable=new Set(mediaReferences(input).map(mediaReferenceKey)),addresses=new Set(mediaReferences(input).map(ref=>ref.url));
62
62
  const allowed=ref=>!!ref&&typeof ref==='object'&&usable.has(mediaReferenceKey(ref));
63
63
  return {
64
64
  allowed,
65
- add(ref){usable.add(mediaReferenceKey(ref));},
66
- remember(value){if(value&&typeof value==='object'&&mediaReferences(value)[0]===value)usable.add(mediaReferenceKey(value));return value;},
65
+ // A render plan names media by address (final.render's timeline): only addresses of the run's usable files count.
66
+ allowsUrl:url=>typeof url==='string'&&addresses.has(url),
67
+ add(ref){usable.add(mediaReferenceKey(ref));if(typeof ref?.url==='string')addresses.add(ref.url);},
68
+ remember(value){if(value&&typeof value==='object'&&mediaReferences(value)[0]===value){usable.add(mediaReferenceKey(value));if(typeof value.url==='string')addresses.add(value.url);}return value;},
67
69
  // Every reference in a request, and inside its references, must be usable.
68
70
  check(value,what){if(!mediaReferences(value,{strict:true,within:true}).every(allowed))throw fail(`${what} use only files ${RULE}. ${FIX}`,'MODULE_FILE_READ');},
69
71
  checkFile(ref,call){if(!allowed(ref))throw fail(`${call} only files ${RULE}. ${FIX}`,'MODULE_FILE_READ');},
@@ -35,7 +35,7 @@ const HOSTED={
35
35
  'workspace.propose':'suggests workspace edits','connection.execute':'uses an app you connected','comfyui.execute':'runs a ComfyUI workflow',
36
36
  };
37
37
  const ACTIONS={
38
- 'final.render':'renders your final video','stock.search':'searches stock media','presenter.generate':'makes a presenter video',
38
+ 'final.render':'renders your final video','stock.search':'searches stock media','stock.select':'keeps stock media you pick','stock.auto':'finds stock media for a description','gif.search':'searches GIFs','gif.save':'saves GIFs you pick','presenter.generate':'makes a presenter video',
39
39
  'quality.analyze':'checks quality','image.generate':'generates images','image.generate.batch':'generates images in batches',
40
40
  'media.generate':'generates media','youtube.upload':'uploads to your YouTube channel after you confirm',
41
41
  };
@@ -1,4 +1,7 @@
1
- export type PortableMediaActionOperation='final.render'|'stock.search'|'presenter.generate'|'quality.analyze'|'image.generate';
1
+ export type PortableMediaActionOperation='final.render'|'stock.search'|'stock.select'|'stock.auto'|'gif.search'|'gif.save'|'presenter.generate'|'quality.analyze'|'image.generate';
2
2
  export interface PortableMediaAction {schemaVersion:1;kind:'stillmade.media-action';operation:PortableMediaActionOperation;request:Record<string,unknown>;resultPort:string|null}
3
3
  export declare const PORTABLE_MEDIA_ACTION_KIND:'stillmade.media-action';
4
4
  export declare function portableMediaAction(value:unknown,permissions?:string[]):Readonly<PortableMediaAction>;
5
+ export const STOCK_AUTO_LIMITS:Readonly<{queries:10;excludeUrls:300}>;
6
+ export const GIF_LIMITS:Readonly<{results:24}>;
7
+ export function isGiphyMediaUrl(url:unknown):boolean;