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
@@ -1,5 +1,5 @@
1
1
  import {assert,plain,safeDocument} from './contracts.js';
2
- import {finalRenderRequest} from './portable-media-contracts.js';
2
+ import {finalRenderRequest,stockSearchResult} from './portable-media-contracts.js';
3
3
  import {IMAGE_GENERATION_MODELS,VIDEO_GENERATION_MODELS,GENERATION_LIMITS,AUDIO_GENERATION,imageSettings,videoSettings,audioSettings,validateSeed} from './generation-catalog.js';
4
4
 
5
5
  export const PORTABLE_MEDIA_ACTION_KIND='stillmade.media-action';
@@ -45,12 +45,27 @@ export function mediaGenerateRequest(request){
45
45
  return request;
46
46
  }
47
47
 
48
+ export const STOCK_AUTO_LIMITS=Object.freeze({queries:10,excludeUrls:300});
49
+ export const GIF_LIMITS=Object.freeze({results:24});
50
+ /** A Giphy media address, as gif.search returns it: the only GIF addresses gif.save fetches. */
51
+ export function isGiphyMediaUrl(url){
52
+ if(typeof url!=='string'||url.length>2048)return false;
53
+ try{const parsed=new URL(url);return parsed.protocol==='https:'&&/^(media[0-9]?|i)\.giphy\.com$/.test(parsed.hostname)&&/^\/(media\/)?[A-Za-z0-9_./-]+\.(gif|webp|mp4)$/.test(parsed.pathname)&&!parsed.username&&!parsed.password&&!parsed.port;}catch{return false;}
54
+ }
55
+
48
56
  export function portableMediaAction(value,permissions=[]){
49
57
  safeDocument(value);
50
58
  assert(exact(value,['schemaVersion','kind','operation','request','resultPort'])&&value.schemaVersion===1&&value.kind===PORTABLE_MEDIA_ACTION_KIND&&permissions.includes(value.operation),'PORTABLE_ACTION','This Block did not declare the requested media action.');
51
59
  assert(value.resultPort===null||text(value.resultPort,80),'PORTABLE_ACTION','The media action result port is invalid.');
52
60
  if(value.operation==='final.render')finalRenderRequest(value.request);
53
61
  else if(value.operation==='stock.search')assert(exact(value.request,['query','type'])&&text(value.request.query,500)&&['photo','video','audio'].includes(value.request.type),'PORTABLE_ACTION','Stock search needs a query and approved media type.');
62
+ // November 6, 2026 (Image Panels as a real Block): keeping a stock result a person or the Block picked, finding one
63
+ // for a description, and GIFs. stock.select and stock.auto charge as StillMade's own stock picker does (at most 1
64
+ // credit per pull bucket); gif.search and gif.save are free.
65
+ else if(value.operation==='stock.select'){assert(exact(value.request,['result']),'PORTABLE_ACTION','Keeping stock media needs one result from stock.search.');stockSearchResult(value.request.result);}
66
+ else if(value.operation==='stock.auto')assert(exact(value.request,['queries','kind','excludeUrls'])&&Array.isArray(value.request.queries)&&value.request.queries.length>0&&value.request.queries.length<=STOCK_AUTO_LIMITS.queries&&value.request.queries.every(query=>text(query,400))&&['photo','video'].includes(value.request.kind)&&Array.isArray(value.request.excludeUrls)&&value.request.excludeUrls.length<=STOCK_AUTO_LIMITS.excludeUrls&&value.request.excludeUrls.every(url=>text(url,2048)),'PORTABLE_ACTION','Finding stock media needs 1 to 10 searches, photo or video, and the addresses to skip (up to 300).');
67
+ else if(value.operation==='gif.search')assert(exact(value.request,['query','limit'])&&typeof value.request.query==='string'&&value.request.query.length<=200&&Number.isInteger(value.request.limit)&&value.request.limit>=1&&value.request.limit<=GIF_LIMITS.results,'PORTABLE_ACTION','GIF search needs a query of up to 200 characters and 1 to 24 results.');
68
+ else if(value.operation==='gif.save')assert(exact(value.request,['url'])&&isGiphyMediaUrl(value.request.url),'PORTABLE_ACTION','Saving a GIF needs the address of a gif.search result.');
54
69
  else if(value.operation==='presenter.generate')assert(exact(value.request,['board','prompt','voiceover'])&&plain(value.request.board)&&value.request.board.schemaVersion===1&&text(value.request.prompt,4000)&&plain(value.request.voiceover),'PORTABLE_ACTION','Presenter generation needs a version 1 Whiteboard, prompt, and narration context.');
55
70
  else if(value.operation==='quality.analyze')assert(exact(value.request,['assets'])&&Array.isArray(value.request.assets)&&value.request.assets.length>0&&value.request.assets.length<=16&&value.request.assets.every(asset),'PORTABLE_ACTION','Quality analysis needs 1–16 exact saved image versions.');
56
71
  else if(value.operation==='image.generate')assert(exact(value.request,['prompt'])&&text(value.request.prompt,4000),'PORTABLE_ACTION','Image generation needs one bounded visual prompt.');
@@ -1,5 +1,5 @@
1
1
  import {firstPartySdkPackage} from './first-party-packages.js';
2
- import {assert,ROOT_CHAT_ID,safeDocument,stableStringify} from './contracts.js';
2
+ import {assert,ROOT_CHAT_ID,safeDocument,sameFirstPartySource} from './contracts.js';
3
3
  import {recognizedLicense,validatePortableMetadata,isPortableLicenseExpression} from './portable.js';
4
4
 
5
5
  const licenses=new Set(['MIT','Apache-2.0','BSD-2-Clause','BSD-3-Clause','ISC']);
@@ -17,7 +17,7 @@ export function blockRemixPolicy(content,{checkPreference=true}={}) {
17
17
  if(!licenses.has(m.license)&&!(content.portable&&isPortableLicenseExpression(m.license)))return blocked('LICENSE_RESTRICTED','This license needs review before automatic remixing. Missing, custom and copyleft terms cannot be cleared by a checkbox.');
18
18
  try {
19
19
  safeDocument(content);
20
- if(m.id?.startsWith('sm.'))assert(stableStringify(firstPartySdkPackage(m)??null)===stableStringify(content),'FIRST_PARTY_SOURCE','Use the exact published first-party package before creating a remix.');
20
+ if(m.id?.startsWith('sm.'))assert(sameFirstPartySource(firstPartySdkPackage(m),content),'FIRST_PARTY_SOURCE','Use the exact published first-party package before creating a remix.');
21
21
  if(content.portable)validatePortableMetadata(content);
22
22
  else assert(recognizedLicense(m.provenance?.notice)===m.license,'LICENSE_BLOCKED','Retain the complete original license and copyright notice before remixing this Block.');
23
23
  } catch(error){return blocked(error.code||'LICENSE_BLOCKED',error.message);}
@@ -0,0 +1,5 @@
1
+ // Releases that resolve for exact pins but are not offered yet: never discovered, picked as a Block's newest release or
2
+ // proposed as an update (November 6, 2026: Voiceover 3.0.0 waits for the built-in Types to switch to the replaced
3
+ // screens together; docs/BUILTIN_BLOCK_REPLACEMENTS_PLAN.md). Remove an entry to release it.
4
+ export const STAGED_FIRST_PARTY_RELEASES = Object.freeze(new Set(['sm.voiceover@3.0.0','sm.panel-strip@3.0.0','sm.board-editor@3.0.0','sm.editor@3.0.0']));
5
+ export const isStagedRelease = manifest => STAGED_FIRST_PARTY_RELEASES.has(`${manifest?.id}@${manifest?.version}`);
@@ -0,0 +1,64 @@
1
+ // The .stillmade-pack file: several Blocks and workflows (Project Types) from one workspace, for StillMade's Import
2
+ // to list and ask which to bring in (owner, November 6, 2026). It is a ZIP archive:
3
+ //
4
+ // pack.json {format, name, version, items: [{kind, blockKind?, id, version, name, path, sha256, needs?}]}
5
+ // blocks/<file> each Block exactly as `stillmade-block pack` writes it alone
6
+ // workflows/<file> each workflow as a Project Type file with its Blocks embedded
7
+ //
8
+ // Every item is a file today's Import already takes on its own; the pack only lists them together.
9
+ // Pure functions: the CLI and the browser share them.
10
+
11
+ export const PACK_FORMAT='stillmade-pack/1';
12
+ export const PACK_SUFFIX='.stillmade-pack';
13
+ export const PACK_INDEX='pack.json';
14
+ export const PACK_LIMITS=Object.freeze({items:100,bytes:128*1048576,indexBytes:262144});
15
+ const KINDS=new Set(['block','workflow']),BLOCK_KINDS=new Set(['block','connector','automation']);
16
+ const PATH=/^(blocks|workflows)\/[A-Za-z0-9][A-Za-z0-9._@-]{0,160}$/,ID=/^[a-z0-9][a-z0-9._-]{0,127}$/i,VERSION=/^\d+\.\d+\.\d+$/,SHA=/^[a-f0-9]{64}$/;
17
+ const fail=message=>Object.assign(new Error(message),{code:'STILLMADE_PACK'});
18
+
19
+ /** Checks pack.json's shape. Returns it. */
20
+ export function validatePackIndex(index){
21
+ if(!index||typeof index!=='object'||Array.isArray(index)||index.format!==PACK_FORMAT)throw fail(`This is not a StillMade pack (${PACK_INDEX} needs "format": "${PACK_FORMAT}").`);
22
+ if(typeof index.name!=='string'||!index.name.trim()||index.name.length>80)throw fail('The pack has no name.');
23
+ if(typeof index.version!=='string'||!VERSION.test(index.version))throw fail('The pack version is three numbers, such as 1.0.0.');
24
+ if(!Array.isArray(index.items)||!index.items.length||index.items.length>PACK_LIMITS.items)throw fail(`A pack lists 1 to ${PACK_LIMITS.items} items.`);
25
+ const paths=new Set(),identities=new Set();
26
+ for(const item of index.items){
27
+ if(!item||typeof item!=='object'||!KINDS.has(item.kind))throw fail('Each pack item is a block or a workflow.');
28
+ if(item.kind==='block'&&!BLOCK_KINDS.has(item.blockKind))throw fail(`${item.id||'A Block'} needs blockKind block, connector or automation.`);
29
+ if(typeof item.id!=='string'||!ID.test(item.id)||typeof item.version!=='string'||!VERSION.test(item.version))throw fail('Each pack item has an id and a version such as 1.0.0.');
30
+ if(typeof item.name!=='string'||!item.name.trim()||item.name.length>120)throw fail(`${item.id} has no name.`);
31
+ if(typeof item.path!=='string'||!PATH.test(item.path)||paths.has(item.path))throw fail(`${item.id} names a file that is not in blocks/ or workflows/, or one used twice.`);
32
+ if(typeof item.sha256!=='string'||!SHA.test(item.sha256))throw fail(`${item.id} has no file digest.`);
33
+ const identity=`${item.kind}:${item.id}@${item.version}`;if(identities.has(identity))throw fail(`${item.id}@${item.version} is listed twice.`);
34
+ if(item.needs!==undefined&&(item.kind!=='workflow'||!Array.isArray(item.needs)||item.needs.some(need=>typeof need!=='string'||!/^[^@]+@\d+\.\d+\.\d+$/.test(need))))throw fail(`${item.id}: needs lists Blocks as id@version, on workflows only.`);
35
+ paths.add(item.path);identities.add(identity);
36
+ }
37
+ return index;
38
+ }
39
+
40
+ /**
41
+ * Reads an unzipped pack ({path: Uint8Array}). digest(bytes) resolves a hex SHA-256.
42
+ * Returns {index, items: [{...item, bytes, fileName}]}, each item's bytes checked against its digest.
43
+ */
44
+ export async function readPackFiles(files,{digest}){
45
+ const raw=files[PACK_INDEX];if(!raw)throw fail(`This file has no ${PACK_INDEX}, so it is not a StillMade pack.`);
46
+ if(raw.byteLength>PACK_LIMITS.indexBytes)throw fail(`${PACK_INDEX} is too large.`);
47
+ let index;try{index=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(raw));}catch(error){throw fail(`${PACK_INDEX}: ${error.message}`);}
48
+ validatePackIndex(index);
49
+ const listed=new Set([PACK_INDEX,...index.items.map(item=>item.path)]);
50
+ const extra=Object.keys(files).filter(path=>!path.endsWith('/')&&!listed.has(path));
51
+ if(extra.length)throw fail(`The pack holds files it does not list: ${extra.slice(0,3).join(', ')}.`);
52
+ const items=[];
53
+ for(const item of index.items){
54
+ const bytes=files[item.path];if(!bytes)throw fail(`${item.name}: ${item.path} is missing from the pack.`);
55
+ if(await digest(bytes)!==item.sha256)throw fail(`${item.name}: ${item.path} does not match its recorded digest. Pack the workspace again.`);
56
+ items.push({...item,bytes,fileName:item.path.slice(item.path.indexOf('/')+1)});
57
+ }
58
+ return {index,items};
59
+ }
60
+
61
+ /** The Blocks a workflow item needs that the pack also carries, by item index. */
62
+ export function packNeeds(index,item){
63
+ return (item.needs||[]).map(need=>index.items.findIndex(other=>other.kind==='block'&&`${other.id}@${other.version}`===need)).filter(position=>position>=0);
64
+ }
@@ -2,7 +2,7 @@ import {firstPartySdkPackage} from './first-party-packages.js';
2
2
  import {requiresDesktopExecution,validateDesktopExecution} from './desktop-execution.js';
3
3
  import {validatePortableMetadata} from './portable.js';
4
4
  import {initialBlockState,validateBlockState} from './state.js';
5
- import { assert, digest, plain, safeDocument, stableStringify, validateManifest, validateValues } from './contracts.js';
5
+ import { assert, digest, plain, safeDocument, stableStringify, validateManifest, validateValues, sameFirstPartySource } from './contracts.js';
6
6
  import {FILE_MEDIA_TYPES,DOCUMENT_FORMATS} from './data-values.js';
7
7
  import { validateRecipe } from './recipe.js';
8
8
  import { validateJavaScript } from './javascript.js';
@@ -59,7 +59,7 @@ export function validatePackage(pkg) {
59
59
  safeDocument(pkg);
60
60
  if(pkg?.manifest?.runtime==='native-application')return validateNativeApplicationPackage(pkg);
61
61
  assert(pkg && Object.keys(pkg).every(k => ['manifest', 'recipe', 'code', 'module', 'moduleSource', 'comfyui', 'capability', 'tests', 'view', 'portable', 'desktop'].includes(k)), 'PACKAGE', 'Unknown package field');
62
- if(typeof pkg.manifest?.id==='string'&&pkg.manifest.id.startsWith('sm.'))assert(stableStringify(firstPartySdkPackage(pkg.manifest)??null)===stableStringify(pkg),'FIRST_PARTY_SOURCE','Use the exact published first-party package, or create a remix with a new identity.');
62
+ if(typeof pkg.manifest?.id==='string'&&pkg.manifest.id.startsWith('sm.'))assert(sameFirstPartySource(firstPartySdkPackage(pkg.manifest),pkg),'FIRST_PARTY_SOURCE','Use the exact published first-party package, or create a remix with a new identity.');
63
63
  const result = validateManifest(pkg.manifest);
64
64
  assert(result.valid, 'MANIFEST', JSON.stringify(result.errors));
65
65
  validateDesktopExecution(pkg);
@@ -18,7 +18,7 @@ export const VIEW_BRIDGE=Object.freeze([
18
18
  {name:'useOutput',group:'Inputs and runs',signature:'useOutput(outputs: Partial<Out>): Promise<Record<string, unknown>>',summary:'Chooses media the view already holds (input media or a desktop recording) as the Block outputs without running. Only complete references the host issued are accepted.',where:saved},
19
19
  {name:'schemaFields',group:'Inputs and runs',signature:'readonly schemaFields: Readonly<Record<string, unknown>>',summary:'Helpers that build form fields from the input ports and their schemas, honoring inputs the host locked.',where:all},
20
20
  // Shared state
21
- {name:'getShared',group:'Shared state',signature:'getShared(): {state: Record<string, unknown>; outputs: Record<string, unknown>; memory: unknown; canEdit: boolean}',summary:'A copy of the view state shared by everyone in the project, the last outputs, the Block memory and whether this person may edit.',where:all},
21
+ {name:'getShared',group:'Shared state',signature:'getShared(): {state: Record<string, unknown>; outputs: Record<string, unknown>; made: Array<StillMadeImage | StillMadeVideo | StillMadeAudio>; memory: unknown; canEdit: boolean}',summary:'A copy of the view state shared by everyone in the project, the last outputs, every picture, clip or sound this Step\'s runs made (newest first, up to 400, which the view may preview and pass to a run), the Block memory and whether this person may edit.',where:all},
22
22
  {name:'onShared',group:'Shared state',signature:'onShared(callback: (shared: ReturnType<StillMadeView["getShared"]>) => void): () => void',summary:'Receives the shared state now and after every change by anyone. Returns a function that stops listening.',where:all},
23
23
  {name:'updateShared',group:'Shared state',signature:'updateShared(values: Record<string, unknown>, preconditions?: Record<string, unknown>, options?: Record<string, unknown>): Promise<unknown>',summary:'Changes shared fields. Keep each independently edited value in its own field so two people never conflict; a field someone else changed first is refused.',where:all},
24
24
  {name:'bindShared',group:'Shared state',signature:'bindShared(key: string, id: string): () => void',summary:'Binds a form element (by id) to one shared field: it shows other people\'s edits and saves this person\'s edits, keeping drafts while they type.',where:all},
@@ -29,12 +29,15 @@ export const VIEW_BRIDGE=Object.freeze([
29
29
  {name:'diffDocument',group:'Shared state',signature:'diffDocument(before: Record<string, unknown>, after: Record<string, unknown>): Array<Record<string, unknown>>',summary:'The minimal list of edits between two versions of a document.',where:all},
30
30
  {name:'applyDocument',group:'Shared state',signature:'applyDocument(document: Record<string, unknown>, patches: Array<Record<string, unknown>>): Record<string, unknown>',summary:'Applies edits from diffDocument to a document.',where:all},
31
31
  // Media
32
- {name:'previewImage',group:'Media',signature:'previewImage(value: StillMadeImage): Promise<{url: string; width: number; height: number}>',summary:'A display-only PNG copy (at most 512 pixels a side) of an image the view may show. Keep passing the original value to run.',where:all},
33
- {name:'previewMedia',group:'Media',signature:'previewMedia(value: StillMadeVideo | StillMadeAudio): Promise<{kind: "video" | "audio"; url: string}>',summary:'A playable blob: URL for audio or video the view may play. Call releaseMedia when done.',where:all},
32
+ {name:'previewImage',group:'Media',signature:'previewImage(value: StillMadeImage, options?: {edge?: number}): Promise<{url: string; width: number; height: number}>',summary:'A display-only copy of an image the view may show: 512 pixels on its longest side, or `edge` (64 to 2048) for an editor that needs more. PNG, or JPEG for a large opaque copy. Keep passing the original value to run.',where:all},
33
+ {name:'previewMedia',group:'Media',signature:'previewMedia(value: StillMadeVideo | StillMadeAudio): Promise<{kind: "video" | "audio"; url: string; mimeType: string; bytes: number; blob: Blob}>',summary:'A playable blob: URL for audio or video the view may play, and its bytes (blob) for decoding, such as a waveform. Call releaseMedia when done; a standard view holds two at a time, a frame view 24.',where:all},
34
34
  {name:'releaseMedia',group:'Media',signature:'releaseMedia(url: string): void',summary:'Frees a URL previewMedia returned.',where:all},
35
+ {name:'previewVoice',group:'Media',signature:'previewVoice(voice: string): Promise<{url: string}>',summary:'A playable blob: URL of the free sample of a StillMade voice (a voice id from the generation models list), for a Block that declares audio.speech. StillMade reads it; the view has no network.',where:all},
36
+ {name:'allowSpending',group:'Media',signature:'allowSpending(request: {credits: number, label: string}): Promise<{allowed: boolean, credits: number}>',summary:'From a person\'s press of a button that showed a total, ask StillMade once to approve it: the Step\'s later runs spend from it silently at StillMade\'s prices for 30 minutes. In a project Step only, never from Chat, up to VIEW_LIMITS.allowanceCredits.',where:all},
35
37
  {name:'asset',group:'Media',signature:'asset(path: string): string',summary:'A blob: URL for one of the view\'s own asset files, for <img>, new FontFace() or <video>.',where:frame},
36
38
  {name:'live',group:'Media',signature:'readonly live: {start(options: {camera?: boolean; microphone?: boolean; fps?: number; width?: number; sampleRate?: number}): Promise<{live: true; camera: boolean; microphone: boolean; fps: number; width: number; height: number; sampleRate: number}>; stop(): Promise<{live: false}>; status(): Promise<{live: boolean; [key: string]: unknown}>; onFrame(callback: (frame: ImageBitmap, info: {index: number; timestamp: number; width: number; height: number}) => void): () => void; onAudio(callback: (samples: Float32Array, info: {index: number; timestamp: number; sampleRate: number}) => void): () => void; onEnd(callback: (event: {reason: string}) => void): () => void}',summary:'The camera, the microphone or both as they happen (declare camera.capture and microphone.capture). Nothing reaches the view until the person presses Start in StillMade, which then shows that the device is on and a Turn off button. Frames arrive as ImageBitmaps, closed when your callback returns, so draw or copy them there; a slow view gets fewer frames, never a backlog. Audio arrives as mono Float32Array chunks. Nothing is saved. In browsers and StillMade Desktop.',where:all},
37
39
  {name:'assetBytes',group:'Media',signature:'assetBytes(path: string): ArrayBuffer',summary:'A copy of one of the view\'s own asset files.',where:frame},
40
+ {name:'loadCode',group:'Media',signature:'loadCode(path: string): Promise<void>',summary:'Runs one of the view\'s own code files (a .js file in src/view-files/) once, when the view needs it, so a large interface starts with only the code its first screen uses. StillMade checks the file against its digest first. The file is one classic script; what it defines is the view\'s to use.',where:frame},
38
41
  // Generated interface
39
42
  {name:'render',group:'Generated interface',signature:'render(targetId: string, tree: Array<Record<string, unknown>>): void',summary:'Renders a tree of StillMade controls into an element, for standard views that build their interface from data.',where:all},
40
43
  {name:'onAction',group:'Generated interface',signature:'onAction(callback: (event: {action: string; value: unknown; checked: boolean; event: string}) => void | Promise<void>): () => void',summary:'Receives actions from controls render drew.',where:all},
@@ -3,21 +3,32 @@
3
3
  // each item. Most of this is guidance: reviewViewDesign() turns a few of the
4
4
  // items into warnings and one into an admission issue (INTERFACE_DESIGN_RULES).
5
5
 
6
+ import {blockTier} from './block-tier.js';
7
+
8
+ // Two interface modes (owner, November 6, 2026: "what if someone is making a CAD software"). A job, the default,
9
+ // is one deliverable a person makes in a few steps, and the compact rules below are written for it. An app is
10
+ // software a person works in for a while (CAD, a photo editor, a timeline, a canvas); the manifest says so with
11
+ // kind:"workspace" (block-tier.js calls that tier "app"), and it follows the shared rules plus APP_INTERFACE_PRINCIPLES
12
+ // instead of the job-only ones. Both modes keep everything else the SDK is for: inputs and outputs, multiplayer,
13
+ // hosted operations and credits, StillMade's file picker, no prices on screen.
14
+ export const INTERFACE_MODES=Object.freeze(['job','app']);
15
+ export const interfaceMode=manifest=>blockTier(manifest)==='app'?'app':'job';
16
+
6
17
  // The default build-out. The host injects packages/design-system/block-kit.css
7
18
  // into every Block view, so these classes work without shipping any CSS.
8
19
  export const BLOCK_KIT_CSS_PATH='packages/design-system/block-kit.css';
9
20
  export const BLOCK_KIT_REFERENCE_PATH='packages/design-system/block-kit.html';
10
21
  export const BLOCK_KIT_CLASSES=Object.freeze({
11
22
  'sm-block':'The one workspace. From 860px wide, a direct sm-stage child moves to the right and the controls keep a 300–380px column on the left; data-layout="stack" keeps one column, "split" puts the stage first at 1.6:1.',
12
- 'sm-stage':'The thing being made: preview, media or result. The biggest area; data-ratio="16:9|9:16|1:1".',
23
+ 'sm-stage':'The thing being made: preview, media or result. The biggest area; data-ratio="16:9|9:16|1:1". A job\'s sm-actions and sm-status go inside it, and the kit lays them over its top corners.',
13
24
  'sm-empty':'What an empty stage shows: an .sm-glyph, at most five words and one action.',
14
25
  'sm-choices':'A fieldset of radio labels shown as pictures (img, .sm-swatch or .sm-glyph) and one or two words; replaces word dropdowns. data-compact for short text chips, data-segmented for one connected row (Low / Med / High). The pick is filled with the kit\'s ink; a disabled fieldset keeps its pick and fades the rest. On a phone chips and segments scroll sideways.',
15
26
  'sm-field':'One short setting on one row, its label beside the control; on a phone the control wraps under the label only when both do not fit. A textarea, picture choices, file tiles or a list keep the label above. Group several in .sm-fields.',
16
27
  'sm-settings':'Short settings side by side, each label beside its control (a narrow cell wraps the control under its label); .sm-choices[data-segmented] inside for three or four short options.',
17
28
  'sm-list':'Many items one line each (thumbnail, one line of text, a short fact); an item opens in place with <details>. data-peek shows three on a phone.',
18
29
  'sm-clamp':'Long text that shows five lines on a phone until opened (data-open).',
19
- 'sm-actions':'The action row: one .sm-primary, optional .sm-quiet buttons and .sm-icon buttons (aria-label and title required).',
20
- 'sm-status':'Live state beside the action: data-state="loading|success|waiting|error" and a few words.',
30
+ 'sm-actions':'The action row: one .sm-primary, optional .sm-quiet buttons and .sm-icon buttons (aria-label and title required). Inside sm-stage it sits over the stage\'s top-right corner, and the run button (data-stillmade-action="run") is always its rightmost.',
31
+ 'sm-status':'Live state in a few words: data-state="loading|success|waiting|error". Inside sm-stage it sits top-left, opposite the actions; elsewhere beside the action.',
21
32
  'sm-more':'A <details> for secondary settings; starts closed.',
22
33
  'sm-hint':'A <details> (i) for an explanation that might help; its words show on hover or focus, or a tap on touch screens.',
23
34
  'sm-gallery':'A grid of results or media. data-size="small" for input files: more tiles per row as the screen widens, never bigger tiles.',
@@ -28,10 +39,13 @@ export const BLOCK_KIT_CLASSES=Object.freeze({
28
39
  });
29
40
 
30
41
  // The design principles for every Block interface, in priority order.
42
+ // Every principle below applies to a job; the ones that start with these words apply only to a job, never to an app.
43
+ const JOB_ONLY_PRINCIPLES=['Function first','Show the thing being made','One obvious next action','Actions on the stage','Strong defaults','Compact:','Rare tools','One workspace','One page per job','Status goes in','Keep the page in normal scroll flow','Use the width','Size with the kit','Group what the person sets','Lists fill the width','On a phone chip'];
31
44
  export const BLOCK_INTERFACE_PRINCIPLES=Object.freeze([
32
45
  'Function first: unless the person asks for a design direction, build from the Block kit (`sm-block`, `sm-stage`, `sm-choices`, `sm-actions`, `sm-more`) and ship little or no CSS of your own. A direction the person names wins over the kit.',
33
46
  'Show the thing being made: the preview, media or result gets the largest area (`sm-stage`), and every part shows at once: before the first run the result\'s place is drawn in the shape and size of what is coming (a 9:16 frame for a vertical clip, lines for text, a player bar for sound), so the result fills it instead of popping in. On a wide screen what goes in sits on the left and the result on the right; they stack only on narrow screens and phones. A picture or clip the Block makes fills it in its own shape, as large as fits; several fill it as a grid, each big enough to watch. Small tiles are for the files a person gives the Block, never for what it makes. A clip is a `<video controls>`, which StillMade plays in its own player with full screen, and a picture in `sm-stage` or `sm-gallery` (or marked `data-stillmade-expand`) gets StillMade\'s full-screen button; leave out `controls` only when the Block drives playback itself. Never a JSON dump, source editor, manifest or diagnostic panel unless the task is about source.',
34
47
  'One obvious next action: one solid `sm-primary` in the kit\'s ink, never restyled to the background or a ghost outline; peers are `sm-quiet` buttons or named icons, and close is an X icon. Seven or fewer choices in view.',
48
+ 'Actions on the stage (owner, November 5, 2026): a job\'s `sm-actions` and `sm-status` go inside its `sm-stage`, and the kit lays them over it, the status top-left and the actions top-right, in the cream blocks of StillMade\'s player, with a band kept above the media so they never cover it; the media fills the rest of the stage. A button that spends credits never moves: the run button (`data-stillmade-action="run"`) is always the rightmost and stays in that spot when it makes again ("Make video", then "Make video again"), and what the result adds (Download, Share) goes to its left. Controls on media (StillMade\'s player, the full-screen square) show for 3 seconds the first time it comes into view, then only while it is hovered or tapped. StillMade\'s own Step pages do the same. An app that lays itself out keeps its own toolbar.',
35
49
  'Speak the job unless the person or creator asks otherwise. The action button names what it does in the job\'s words (`manifest.action`: "Plan the tour"; AI pictures, video and sound start with Generate and say what, the person\'s own media cut or rendered says Make: "Generate voiceover", "Make 6 clips"), never "Make it", "Run", "Run step", "Submit", "Process" or a bare "Generate"; progress counts the job\'s units ("4 of 9 rooms"), never "Processing"; results and problems use its words in one line. Show no credit numbers: StillMade puts the price beside the verb on its own run button ("Write · 1 credit"), with a chip for the model, quality or count when they can change. Pressing it runs at that price; a confirmation appears only when the server price is higher than the one shown.',
36
50
  'Pictures over words: choices people see or hear are thumbnails, swatches, samples or glyphs (`sm-choices`), not word lists. Icons carry an accessible name and tooltip; never emoji.',
37
51
  'A picked option reads at a glance: keep the kit\'s ink fill on an `sm-choices` pick, never restyled to an outline, a tint or the page\'s background.',
@@ -45,9 +59,9 @@ export const BLOCK_INTERFACE_PRINCIPLES=Object.freeze([
45
59
  'An (i) beside anything a newcomer could misread: a setting, status or number whose effect is not obvious from its label gets a closed `sm-hint` with one or two sentences. Give every input that needs one a `description`; StillMade shows it as that (i). Obvious controls get none.',
46
60
  'One workspace: no cards inside cards, no second title, step counter or Back/Next bar; the host owns navigation.',
47
61
  'One page per job (November 3, 2026): a guided job\'s screen holds the inputs and settings, then a cheap check before the expensive run (the script and a voice sample before the video, a second `StillMade.run` that StillMade prices on its own), then the result; never a second Block or Step for the check. A workspace a person works in (a CAD tool, a canvas, an editor) is its own Block, and turning what it made into something else is the next one.',
48
- 'Status sits beside the action it describes (`sm-status`), in a few words.',
62
+ 'Status goes in `sm-status`, in a few words: top-left on the stage when the actions are there, otherwise beside the action it describes.',
49
63
  'Keep the page in normal scroll flow; no sticky media or control strips unless the editing task needs them.',
50
- 'Use the width: from 860px wide the kit puts the controls on the left at a reading width (300–380px) and the result (`sm-stage`) fills the right, in its real shape; under that everything stacks in the page\'s order: the inputs, the action in normal flow (never pinned), then the result\'s place under it, so put `sm-stage` after `sm-actions`. Extra width goes to the result, never to wider text fields. Before there is a result, the stage previews what will be made from the inputs. `data-layout="stack"` opts out.',
64
+ 'Use the width: from 860px wide the kit puts the controls on the left at a reading width (300–380px) and the result (`sm-stage`) fills the right, in its real shape; under that everything stacks in the page\'s order: the inputs, then the result\'s place with its actions on it (never a pinned bar), so `sm-stage` comes after the inputs. Extra width goes to the result, never to wider text fields. Before there is a result, the stage previews what will be made from the inputs. `data-layout="stack"` opts out.',
51
65
  'Size with the kit, never fixed sizes: no fixed pixel widths or heights for the workspace or stage, no 100vh, no position:fixed panels. StillMade zooms a Block\'s screen to fill the space it has (up to 150%, never down to a phone layout on a computer), so a Block laid out with the kit\'s spacing fills its Step without empty space, and it centers a standard view narrower than its Step. The kit\'s `sm-stage` shows what it holds in its middle as one group (a caption stays with its picture), and several results in a stage fill its width as one row; the pages StillMade draws follow the same rule. 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.',
52
66
  'Group what the person sets in one `sm-fields` (or `sm-settings` for short ones): the kit gives the group its own surface, so the inputs, the settings and the result read as separate parts. Settings sit side by side (`sm-settings`), next to what they change. Three or four short options are one segmented row (`data-segmented`), not a row of big tiles.',
53
67
  'Lists fill the width at a small size: input files are small tiles (`sm-gallery data-size="small"`), many items are one line each (`sm-list`): a thumbnail, one line of text, a short fact. Tapping an item opens all of its fields in place. Extra width means more items per row, never bigger cards.',
@@ -59,6 +73,24 @@ export const BLOCK_INTERFACE_PRINCIPLES=Object.freeze([
59
73
  'Days and times are picked, never typed: a `date` input is a calendar, with a list of times when it is about a time (`postAt`, `startsAt`, a label with Time, When or Starts). In a custom view use `<input type="date">` or `type="datetime-local"`, never a text box for a date.',
60
74
  'Posting is its own Step: sending a result to Instagram, TikTok, email or any app is a connector Step after the Block, worded by StillMade ("Post to Instagram"). A Block never posts from inside itself.',
61
75
  ]);
76
+ const jobOnly=rule=>JOB_ONLY_PRINCIPLES.some(start=>rule.startsWith(start));
77
+ /** The principles both modes follow. */
78
+ export const SHARED_INTERFACE_PRINCIPLES=Object.freeze(BLOCK_INTERFACE_PRINCIPLES.filter(rule=>!jobOnly(rule)));
79
+ /** The principles only a job follows (compact layout, the Block kit's page, one action). */
80
+ export const JOB_INTERFACE_PRINCIPLES=Object.freeze(BLOCK_INTERFACE_PRINCIPLES.filter(jobOnly));
81
+ /** What an app (manifest.kind "workspace") follows instead of the job-only principles. */
82
+ export const APP_INTERFACE_PRINCIPLES=Object.freeze([
83
+ 'An app (manifest.kind "workspace") is software a person works in for a while: a CAD or 3D tool, a photo editor, a timeline, a canvas, a studio. Its work surface (viewport, canvas, image, timeline) owns the page, laid out like the best apps of its kind, with the tool rail, panels, inspector, layers, timeline and menus the craft needs and as many controls as it needs. Never shrink it into a form of fields and one button.',
84
+ 'It lays itself out: `data-stillmade-layout="own"` on its outermost element, so StillMade never centers or zooms it and leaves Ctrl/Cmd +/- to it. Full-size panels, fixed toolbars and its own scroll, pan and zoom are fine.',
85
+ 'Work happens on the surface: select, drag, zoom and pan, keyboard shortcuts for the common tools, undo and redo.',
86
+ 'Speak the craft: tools and panels use the words people of that craft use (Exposure, Keyframe, Extrude, Layers, Mask), never a plain-language rewrite. A button that spends credits still names what it makes ("Generate fill"), carries `data-stillmade-action="run"` and shows no price; StillMade adds it.',
87
+ 'Order, not sparseness: tools grouped in panels that fold, the most used in view, rare ones in menus. No intros or paragraphs on the surface; a tool that needs explaining gets a tooltip or an (i).',
88
+ 'StillMade\'s appearance (Light, Dark and White) and the Block kit\'s controls (buttons, fields, sliders, `sm-choices` with thumbnails for presets) wherever they fit; the layout is the app\'s own. A look the person names wins.',
89
+ 'On a phone the work surface stays usable and the panels fold into sheets or one tool strip.',
90
+ 'What the person makes leaves the app as the Block\'s outputs (and, while they work, its shared state), so the next Step and the other people in the project get it.',
91
+ ]);
92
+ /** The principles a Block in this mode follows, in order. */
93
+ export const interfacePrinciples=(mode='job')=>mode==='app'?Object.freeze([...SHARED_INTERFACE_PRINCIPLES,...APP_INTERFACE_PRINCIPLES]):BLOCK_INTERFACE_PRINCIPLES;
62
94
  // The compact layout rules (October 18–19, 2026), for every Block interface,
63
95
  // generated or custom. StillMade's in-app builders and the SDK guide both read them.
64
96
  export const COMPACT_INTERFACE_RULES=Object.freeze(BLOCK_INTERFACE_PRINCIPLES.filter(rule=>/^(?:Compact:|Status is|Files are tiles|An \(i\)|Use the width)/.test(rule)));
@@ -273,32 +305,45 @@ export function looseFields(html){
273
305
  }
274
306
  return loose;
275
307
  }
276
- function jobWordWarnings(view,primary){
277
- const warnings=[],lower=primary.toLowerCase();
308
+ // An app's tool buttons say what the craft says ("OK" in a dialog, "Create" in a menu); only the button that runs the
309
+ // Block (data-stillmade-action="run") is held to naming what it makes.
310
+ const runButton=tag=>/\sdata-stillmade-action\s*=\s*["']?run["'\s>]/.test(tag+'>');
311
+ function jobWordWarnings(view,primary,mode='job'){
312
+ const warnings=[],lower=primary.toLowerCase(),app=mode==='app';
278
313
  for(let start=lower.indexOf('<button'),close=0;start!==-1;start=lower.indexOf('<button',close+9)){
279
314
  const open=lower.indexOf('>',start);close=open===-1?-1:lower.indexOf('</button>',open);if(close===-1)break;
315
+ if(app&&!runButton(lower.slice(start,open)))continue;
280
316
  const label=primary.slice(open+1,close).replace(/<[^<>]*>/g,' ').replace(/\s+/g,' ').trim();
281
317
  if(GENERIC_JOB_ACTIONS.test(label)){warnings.push(`Wording: a button says "${label}". Name what it does in the job's words ("Plan the tour", "Generate voiceover", "Make 6 clips"), unless the person asked for "${label}".`);break;}
282
318
  }
283
319
  if(VAGUE_PROGRESS.test(String(view.javascript||'')))warnings.push('Wording: progress says "Processing" or "Loading". Count the job\'s units: stillmade.progress(0.4, "Moving through the kitchen, 4 of 9 rooms").');
284
320
  if(HOW_IT_WORKS.test(primary.replace(/<[^<>]*>/g,' ')))warnings.push('Wording: a line explains where the Block runs or how it saves. Drop it; StillMade tells the person what they need.');
285
321
  if(typedDate(primary))warnings.push('Wording: a date or time is typed into a text box. Use <input type="date"> or type="datetime-local", or a date input in the manifest, which StillMade shows as a calendar.');
286
- if(fadedPrimary(view.css))warnings.push('Wording: the CSS gives .sm-primary the page\'s background, so the main button blends in. Keep the kit\'s solid ink button.');
287
- if(hiddenPick(view.css))warnings.push('Wording: the CSS restyles the picked choice (.sm-choices label:has(input:checked)), so the pick no longer reads at a glance. Keep the kit\'s ink fill and its text colour.');
322
+ if(!app&&fadedPrimary(view.css))warnings.push('Wording: the CSS gives .sm-primary the page\'s background, so the main button blends in. Keep the kit\'s solid ink button.');
323
+ if(!app&&hiddenPick(view.css))warnings.push('Wording: the CSS restyles the picked choice (.sm-choices label:has(input:checked)), so the pick no longer reads at a glance. Keep the kit\'s ink fill and its text colour.');
288
324
  const kept=DEVICE_STATE.exec(primary.replace(/<[^<>]*>/g,' ')+'\n'+quotedText(String(view.javascript||'')));
289
- const loose=looseFields(primary);
325
+ const loose=app?0:looseFields(primary);
290
326
  if(loose>=3)warnings.push(`Wording: ${loose} settings sit loose on the page. Put what the person sets in one <div class="sm-fields"> (or .sm-settings for short ones): the kit gives the group its own surface, so the inputs, the settings and the result read apart.`);
291
327
  if(kept)warnings.push(`Wording: the screen says where a file is kept ("${kept[0]}"). Show a picture or clip that cannot show here as a quiet <span class="sm-media-off"> tile with no words; StillMade says why once, as a notification.`);
292
328
  return warnings;
293
329
  }
294
330
 
295
331
  /** The view's wording warnings as plain notes, for readiness's "Wording" limitations. */
296
- export const viewWordingNotes=view=>view?jobWordWarnings(view,String(view.html||'').replace(/<details\b[\s\S]*?<\/details>/gi,'')).map(warning=>warning.replace(/^Wording: /,'').replace(/^./,letter=>letter.toUpperCase())):[];
332
+ export const viewWordingNotes=(view,{mode='job'}={})=>view?jobWordWarnings(view,String(view.html||'').replace(/<details\b[\s\S]*?<\/details>/gi,''),mode).map(warning=>warning.replace(/^Wording: /,'').replace(/^./,letter=>letter.toUpperCase())):[];
297
333
 
298
- export function reviewViewDesign(view){
299
- if(!view)return {passed:true,issues:[],warnings:[],principles:BLOCK_INTERFACE_PRINCIPLES};
334
+ // mode: 'job' (the default) or 'app' (interfaceMode(manifest)). An app is held only to the shared checks: its own
335
+ // layout, many controls, labels above fields in an inspector and a text-first surface (a code editor) are what it is.
336
+ export function reviewViewDesign(view,{mode='job'}={}){
337
+ const principles=interfacePrinciples(mode);
338
+ if(!view)return {passed:true,issues:[],warnings:[],principles};
300
339
  const html=view.html||'',javascript=view.javascript||'',primary=html.replace(/<details\b[\s\S]*?<\/details>/gi,'');
301
340
  const issues=[],warnings=[];
341
+ if(mode==='app'){
342
+ if(ownFileInput(primary))warnings.push('The view has a file input of its own. Add files with the + tile, <button class="sm-add" data-stillmade-add="<input>" aria-label="Add photos">, which opens StillMade\'s picker.');
343
+ warnings.push(...jobWordWarnings(view,primary,mode));
344
+ warnings.push(...blockKitProblems(view,{mode}).map(problem=>problem.message));
345
+ return {passed:true,issues,warnings,principles};
346
+ }
302
347
  const largeTextarea=/<textarea\b[^>]*rows\s*=\s*["']?(?:1[2-9]|[2-9]\d)["']?[^>]*>/i.test(primary);
303
348
  const visual=/<(?:canvas|img|video|figure|table|ol|ul)\b/i.test(primary)||/\bsm-(?:stage|gallery)\b/.test(primary)||/\bStillMade\.render\s*\(/.test(javascript);
304
349
  if(largeTextarea&&!visual)issues.push('The primary workspace is dominated by serialized text. Show the thing being made first; include source or JSON only when explicitly requested or part of a source/debug task.');
@@ -324,7 +369,7 @@ export function reviewViewDesign(view){
324
369
  const narrow=[...html.matchAll(/\sdata-stillmade-layout\s*=\s*(["']?)([^"'\s>]*)\1/gi)].some(match=>match[2]==='own')?null:narrowScreen(view);
325
370
  if(narrow)warnings.push(`The screen is held to ${Math.round(narrow.px)}px wide (${narrow.where}), so a wide Step shows mostly empty space, and StillMade's zoom cannot fill it. Drop the fixed or maximum width: the Block kit lays out the controls and the result across the width.`);
326
371
  warnings.push(...blockKitProblems(view).map(problem=>problem.message));
327
- return {passed:issues.length===0,issues,warnings,principles:BLOCK_INTERFACE_PRINCIPLES};
372
+ return {passed:issues.length===0,issues,warnings,principles};
328
373
  }
329
374
 
330
375
  // October 30, 2026: Chat presses a button on a Block's own screen only when it carries data-stillmade-action
@@ -347,20 +392,23 @@ export function unmarkedButtons(view){
347
392
  // 'kit'. Installed Blocks are never refused for these; admission only warns.
348
393
  const GENERIC_ACTIONS=/^(?:run|run step|run it|submit|go|start|ok|review cost|review price)$/i;
349
394
  export const BLOCK_KIT_READINESS_RULES=Object.freeze(['action','price','heading']);
350
- export function blockKitProblems(view){
395
+ // An app (mode 'app') lays itself out, so it is never asked for the kit's page, and only its run button is held to naming
396
+ // what it does: "OK" and "Go" are fine on its dialogs and tools.
397
+ export function blockKitProblems(view,{mode='job'}={}){
351
398
  if(!view)return [];
352
- const problems=[],html=String(view.html||''),strings=quotedText(String(view.javascript||'')),original=view.appearance==='original';
399
+ const problems=[],html=String(view.html||''),strings=quotedText(String(view.javascript||'')),original=view.appearance==='original',app=mode==='app';
353
400
  const lower=html.toLowerCase();
354
401
  // Each character is read a bounded number of times: the next search starts after this button.
355
402
  for(let start=lower.indexOf('<button'),close=0;start!==-1;start=lower.indexOf('<button',close+9)){
356
403
  const open=lower.indexOf('>',start);close=open===-1?-1:lower.indexOf('</button>',open);
357
404
  if(close===-1)break;
405
+ if(app&&!runButton(lower.slice(start,open)))continue;
358
406
  const label=html.slice(open+1,close).replace(/<[^<>]*>/g,' ').replace(/\s+/g,' ').trim();
359
407
  if(GENERIC_ACTIONS.test(label)){problems.push({rule:'action',message:`A button says "${label}". Name what it does ("Write", "Generate image"), never "Run" or "Submit".`});break;}
360
408
  }
361
409
  const visible=html.replace(/<[^<>]*>/g,' ')+'\n'+strings;
362
410
  if(/\b\d+(?:\.\d+)?\s*credits?\b|\}\s*credits?\b|\$\s?\d/i.test(visible))problems.push({rule:'price',message:'The interface shows a price. Show no credit numbers; StillMade puts the price on its own run button.'});
363
411
  if(!original&&/<h1\b/i.test(html))problems.push({rule:'heading',message:'Drop the page heading; StillMade already shows the Block\'s name.'});
364
- if(!original&&view.runtime!=='frame'&&!/\bclass\s*=\s*["'][^"']*\bsm-block\b/i.test(html))problems.push({rule:'kit',message:'Build the interface from the Block kit: one <main class="sm-block"> with an sm-stage for the result and sm-actions for the button.'});
412
+ if(!original&&!app&&view.runtime!=='frame'&&!/\bclass\s*=\s*["'][^"']*\bsm-block\b/i.test(html))problems.push({rule:'kit',message:'Build the interface from the Block kit: one <main class="sm-block"> with an sm-stage for the result and sm-actions for the button.'});
365
413
  return problems;
366
414
  }
@@ -6,6 +6,20 @@ export function installViewRenderer(document,onError=()=>{},onControlsChanged=()
6
6
  const listeners=new Set();
7
7
  const actions=new WeakMap(),compositionState=new WeakMap(),compositions=new Map(),conflicts=new Map();let disposed=false,revision=0,sequence=0,dispatching=false;const roots=new Set();
8
8
  const notify=()=>{try{onControlsChanged();}catch{}};
9
+ // A button whose whole text is "Preview" shows the StillMade preview icon alone (owner, November 6, 2026), in
10
+ // StillMade.render trees and in a view's own HTML, so every Block's preview control looks the same without a new
11
+ // Block version. The label stays as aria-label for screen readers and Chat. Geometry: design-system/preview-icon.js.
12
+ const PREVIEW_PATHS=['M3 8V5a2 2 0 0 1 2-2h3M16 3h3a2 2 0 0 1 2 2v3M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3','M6.5 12s2.2-3.8 5.5-3.8 5.5 3.8 5.5 3.8-2.2 3.8-5.5 3.8S6.5 12 6.5 12Z'];
13
+ const isPreviewText=value=>typeof value==='string'&&/^\s*preview\s*$/i.test(value);
14
+ const showPreviewIcon=(node,label)=>{
15
+ const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');
16
+ for(const [name,value] of [['viewBox','0 0 24 24'],['width','20'],['height','20'],['fill','none'],['stroke','currentColor'],['stroke-width','1.5'],['stroke-linecap','round'],['stroke-linejoin','round'],['aria-hidden','true']])svg.setAttribute(name,value);
17
+ for(const d of PREVIEW_PATHS){const path=document.createElementNS(ns,'path');path.setAttribute('d',d);svg.appendChild(path);}
18
+ const pupil=document.createElementNS(ns,'circle');for(const [name,value] of [['cx','12'],['cy','12'],['r','1.8']])pupil.setAttribute(name,value);svg.appendChild(pupil);
19
+ node.textContent='';node.appendChild(svg);node.setAttribute('data-icon-action','true');node.setAttribute('aria-label',label||'Preview');node.setAttribute('title',label||'Preview');
20
+ };
21
+ const iconStaticPreviews=()=>{if(disposed)return;for(const node of document.querySelectorAll('button'))if(!node.children.length&&isPreviewText(node.textContent))showPreviewIcon(node,node.getAttribute('aria-label'));};
22
+ if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',iconStaticPreviews,{once:true});else iconStaticPreviews();
9
23
  // October 30, 2026: Chat also reads and presses a view's plain HTML controls: a button marked
10
24
  // data-stillmade-action, and a labelled input, select or textarea. Shared fields (data-stillmade-share, reached
11
25
  // as shared fields), device-only controls (data-stillmade-local) and file, password and hidden inputs are never
@@ -75,6 +89,7 @@ export function installViewRenderer(document,onError=()=>{},onControlsChanged=()
75
89
  if(item.className!==undefined)node.className=text(item.className,240);
76
90
  if(item.text!==undefined)node.textContent=text(item.text);
77
91
  if(item.label!==undefined)node.setAttribute('aria-label',text(item.label,240));
92
+ if(item.tag==='button'&&isPreviewText(item.text))showPreviewIcon(node,item.label);
78
93
  if(item.type!==undefined){check(item.tag==='input'&&['text','number','range','checkbox','radio','color'].includes(item.type),'Unsupported dynamic input type');node.type=item.type;}
79
94
  for(const key of ['checked','disabled'])if(item[key]!==undefined){check(typeof item[key]==='boolean','Control flags must be boolean');node[key]=item[key];}
80
95
  for(const key of ['min','max','step'])if(item[key]!==undefined){check(typeof item[key]==='string'||typeof item[key]==='number'&&Number.isFinite(item[key]),'Control values must be text or finite numbers');node[key]=text(String(item[key]),10000);}
@@ -133,7 +148,8 @@ export function installViewRenderer(document,onError=()=>{},onControlsChanged=()
133
148
  }
134
149
  // Preserve Block-owned media src/style properties. Only renderer-owned
135
150
  // attributes and control state are reconciled from the validated tree.
136
- for(const name of ['id','class','aria-label','type','min','max','step','placeholder']){
151
+ const iconed=fresh.hasAttribute('data-icon-action')||node.hasAttribute('data-icon-action');
152
+ for(const name of ['id','class','aria-label','type','min','max','step','placeholder',...(iconed?['data-icon-action','title']:[])]){
137
153
  const value=fresh.getAttribute(name);if(value===null)node.removeAttribute(name);else if(node.getAttribute(name)!==value)node.setAttribute(name,value);
138
154
  }
139
155
  reconcile(node,fresh);
@@ -2,7 +2,7 @@ import {parse} from 'acorn';
2
2
  import {assert,plain} from './contracts.js';
3
3
  import {moduleFileDigest} from './module-runtime.js';
4
4
 
5
- export const VIEW_LIMITS=Object.freeze({htmlBytes:65536,cssBytes:32768,javascriptBytes:65536,messageBytes:4194304,runsPerMinute:30,imagePreviewsPerMinute:60,imagePreviewMs:15000,imagePreviewEdge:512});
5
+ export const VIEW_LIMITS=Object.freeze({htmlBytes:65536,cssBytes:32768,javascriptBytes:65536,messageBytes:4194304,runsPerMinute:120,imagePreviewsPerMinute:240,voicePreviewsPerMinute:30,allowanceCredits:2000,imagePreviewMs:15000,imagePreviewEdge:512,imagePreviewMaxEdge:2048});
6
6
  const bytes=value=>new TextEncoder().encode(value).length;
7
7
  const forbidden=new Set(['eval','Function','AsyncFunction','GeneratorFunction','require','importScripts','process','globalThis','window','self','top','parent','opener','frames','location','navigation','history','navigator','localStorage','sessionStorage','indexedDB','caches','cookieStore','fetch','XMLHttpRequest','WebSocket','EventSource','Worker','SharedWorker','ServiceWorker','WebAssembly','Deno','Bun','Reflect','Proxy','DOMParser','MutationObserver','postMessage','open','close','print','alert','confirm','prompt','setInterval','setTimeout','queueMicrotask','arguments','constructor','__proto__','prototype','__lookupGetter__','__lookupSetter__','__defineGetter__','__defineSetter__','getPrototypeOf','setPrototypeOf','getOwnPropertyDescriptor','getOwnPropertyDescriptors','getOwnPropertyNames','getOwnPropertySymbols','defineProperty','defineProperties','defaultView','ownerGlobal','contentWindow','contentDocument','cookie','domain','documentURI','URL','baseURI','referrer','write','writeln','innerHTML','outerHTML','insertAdjacentHTML','createElement','createElementNS','createContextualFragment','createRange','setAttribute','setAttributeNS','attributes','attributeStyleMap','adoptNode','importNode','execCommand','replaceChildren','setHTML','setHTMLUnsafe','parseHTML','parseHTMLUnsafe']);
8
8
  for(const name of ['showSaveFilePicker','showDirectoryPicker','saveAs','FileSystemWritableFileStream','createWritable','msSaveBlob','msSaveOrOpenBlob','download'])forbidden.add(name);
@@ -135,8 +135,13 @@ export function instrumentViewJavaScript(source,guard){
135
135
  // SHA-256. The boundary is the opaque-origin frame and its policy (no network,
136
136
  // no remote or generated code, no navigation, no popups) and compiled loop
137
137
  // guards, not a reviewed subset of JavaScript.
138
- export const FRAME_VIEW_LIMITS=Object.freeze({htmlBytes:262144,cssBytes:262144,javascriptBytes:4194304,assetFiles:32,assetBytes:8388608,taskMs:250,longTaskMs:1000});
139
- export const FRAME_VIEW_ASSET_TYPES=Object.freeze(['image/png','image/jpeg','image/webp','image/gif','image/svg+xml','font/woff2','application/json','text/plain','audio/mpeg','audio/wav','video/mp4','application/octet-stream']);
138
+ // Code files (November 6, 2026): a large interface ships more classic scripts in its view files (`.js`, media type
139
+ // text/javascript) and loads each with StillMade.loadCode(path) when it needs it, so it starts with only the code its
140
+ // first screen draws. They are counted apart from the asset files. A frame view (an editor shows many clips and sounds at
141
+ // once) may hold 24 playable media previews at a time; a standard view holds two.
142
+ export const FRAME_VIEW_LIMITS=Object.freeze({htmlBytes:262144,cssBytes:262144,javascriptBytes:4194304,assetFiles:32,assetBytes:8388608,codeFiles:64,codeFileBytes:4194304,codeBytes:16777216,mediaPreviews:24,taskMs:250,longTaskMs:1000});
143
+ export const FRAME_VIEW_CODE_TYPE='text/javascript';
144
+ export const FRAME_VIEW_ASSET_TYPES=Object.freeze(['image/png','image/jpeg','image/webp','image/gif','image/svg+xml','font/woff2','application/json','text/plain','audio/mpeg','audio/wav','video/mp4','application/octet-stream',FRAME_VIEW_CODE_TYPE]);
140
145
  const FRAME_KEYS=['runtime','html','css','themedCss','javascript','appearance','files'];
141
146
  const FRAME_BLOCKED_TAGS=/<\s*(script|iframe|frame|frameset|object|embed|applet|base|meta|link|portal|noscript)\b/i;
142
147
  const ASSET_PATH=/^(?!\/)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._\/-]{1,160}$/;
@@ -154,6 +159,26 @@ export function parseFrameViewJavaScript(source){
154
159
  throw Object.assign(new Error(module?'Frame view JavaScript must be one classic script: bundle it as an IIFE (for example esbuild --format=iife).':`View JavaScript syntax: ${scriptError.message}`),{code:'VIEW'});
155
160
  }
156
161
  }
162
+ /** A frame view's file that is code (loaded with StillMade.loadCode), not an asset. */
163
+ export const isFrameViewCodeFile=file=>file?.mediaType===FRAME_VIEW_CODE_TYPE;
164
+
165
+ /**
166
+ * One code file's text, checked as a frame view's own script is: one classic
167
+ * script (bundle chunks so they register themselves, not as ES modules), with no
168
+ * import(). Returns it compiled with the task guard when a guard name is given.
169
+ */
170
+ export function compileFrameViewCode(source,path,guard){
171
+ assert(typeof source==='string'&&bytes(source)<=FRAME_VIEW_LIMITS.codeFileBytes,'VIEW',`Code file ${path} exceeds ${Math.round(FRAME_VIEW_LIMITS.codeFileBytes/1048576)} MiB`);
172
+ let ast;
173
+ try{ast=parseFrameViewJavaScript(source).ast;}catch(error){throw Object.assign(new Error(`Code file ${path}: ${error.message}`),{code:'VIEW'});}
174
+ const pending=[ast];let count=0;
175
+ while(pending.length){const node=pending.pop();assert(++count<=4000000,'VIEW',`Code file ${path} is too complex to compile`);
176
+ assert(node.type!=='ImportExpression','VIEW',`Code file ${path} uses import(); load more code with StillMade.loadCode instead`);
177
+ for(const value of Object.values(node))if(Array.isArray(value)){for(const child of value)if(child&&typeof child==='object')pending.push(child);}else if(value&&typeof value==='object'&&typeof value.type==='string')pending.push(value);
178
+ }
179
+ return guard===undefined?source:instrumentFrameViewJavaScript(source,guard);
180
+ }
181
+
157
182
  export function validateFrameView(view){
158
183
  assert(Object.keys(view).every(key=>FRAME_KEYS.includes(key)),'VIEW','A frame view contains runtime "frame", html and optional css, themedCss, javascript, appearance and files');
159
184
  assert(view.appearance===undefined || ['host','original'].includes(view.appearance),'VIEW','View appearance must be host or original');
@@ -174,13 +199,19 @@ export function validateFrameView(view){
174
199
  }
175
200
  if(view.files!==undefined){
176
201
  assert(plain(view.files)&&view.files.schemaVersion===1&&plain(view.files.files)&&Object.keys(view.files).every(key=>['schemaVersion','files'].includes(key)),'VIEW','view.files is {schemaVersion: 1, files: {path: {sha256, bytes, mediaType}}}');
177
- const entries=Object.entries(view.files.files);let total=0;
178
- assert(entries.length<=FRAME_VIEW_LIMITS.assetFiles,'VIEW',`A frame view ships at most ${FRAME_VIEW_LIMITS.assetFiles} asset files`);
202
+ const entries=Object.entries(view.files.files);let total=0,code=0,codeFiles=0;
179
203
  for(const [path,file] of entries){
180
204
  assert(ASSET_PATH.test(path)&&plain(file)&&Object.keys(file).every(key=>['sha256','bytes','mediaType'].includes(key))&&/^[a-f0-9]{64}$/.test(file.sha256||'')&&Number.isSafeInteger(file.bytes)&&file.bytes>0&&FRAME_VIEW_ASSET_TYPES.includes(file.mediaType),'VIEW',`View asset ${path} needs a safe path, its SHA-256, size and a supported media type`);
181
- total+=file.bytes;
205
+ if(isFrameViewCodeFile(file)){
206
+ assert(path.endsWith('.js'),'VIEW',`Code file ${path} needs a .js name`);
207
+ assert(file.bytes<=FRAME_VIEW_LIMITS.codeFileBytes,'VIEW',`Code file ${path} exceeds ${Math.round(FRAME_VIEW_LIMITS.codeFileBytes/1048576)} MiB`);
208
+ codeFiles++;code+=file.bytes;
209
+ }else total+=file.bytes;
182
210
  }
211
+ assert(entries.length-codeFiles<=FRAME_VIEW_LIMITS.assetFiles,'VIEW',`A frame view ships at most ${FRAME_VIEW_LIMITS.assetFiles} asset files`);
183
212
  assert(total<=FRAME_VIEW_LIMITS.assetBytes,'VIEW','Frame view assets exceed 8 MiB in total');
213
+ assert(codeFiles<=FRAME_VIEW_LIMITS.codeFiles,'VIEW',`A frame view ships at most ${FRAME_VIEW_LIMITS.codeFiles} code files`);
214
+ assert(code<=FRAME_VIEW_LIMITS.codeBytes,'VIEW',`Frame view code files exceed ${Math.round(FRAME_VIEW_LIMITS.codeBytes/1048576)} MiB in total`);
184
215
  }
185
216
  return true;
186
217
  }
@@ -208,11 +239,18 @@ export function instrumentFrameViewJavaScript(source,guard){
208
239
  // Later insertions at one position land before earlier ones: apply inner
209
240
  // openers first and outer closers first so nesting stays balanced.
210
241
  edits.sort((a,b)=>b.position-a.position||(a.open===b.open?(a.open?b.depth-a.depth:a.depth-b.depth):(a.open?-1:1)));
211
- for(const edit of edits)source=source.slice(0,edit.position)+edit.text+source.slice(edit.position);
212
- return source;
242
+ // Built in one pass (November 6, 2026: inserting one edit at a time copied the whole text per edit, 3.4 s for a
243
+ // 480 KB chunk). Edits at one position come out in reverse of the order above, as inserting them one by one did.
244
+ let out='',last=0;
245
+ for(let index=edits.length-1;index>=0;){
246
+ const position=edits[index].position;let first=index;while(first>0&&edits[first-1].position===position)first--;
247
+ out+=source.slice(last,position);for(let at=index;at>=first;at--)out+=edits[at].text;
248
+ last=position;index=first-1;
249
+ }
250
+ return out+source.slice(last);
213
251
  }
214
252
 
215
- const VIEW_ASSET_EXTENSIONS={png:'image/png',jpg:'image/jpeg',jpeg:'image/jpeg',webp:'image/webp',gif:'image/gif',svg:'image/svg+xml',woff2:'font/woff2',json:'application/json',txt:'text/plain',mp3:'audio/mpeg',wav:'audio/wav',mp4:'video/mp4'};
253
+ const VIEW_ASSET_EXTENSIONS={js:FRAME_VIEW_CODE_TYPE,png:'image/png',jpg:'image/jpeg',jpeg:'image/jpeg',webp:'image/webp',gif:'image/gif',svg:'image/svg+xml',woff2:'font/woff2',json:'application/json',txt:'text/plain',mp3:'audio/mpeg',wav:'audio/wav',mp4:'video/mp4'};
216
254
  /** Describe a frame view's asset files ({path: bytes}) by SHA-256, size and media type. */
217
255
  export async function describeViewFiles(files){
218
256
  const described={};
@@ -65,6 +65,12 @@
65
65
  :where(.sm-stage) > :where(.sm-gallery){width:100%;grid-template-columns:repeat(auto-fit,minmax(min(100%,var(--sm-tile,140px)),1fr))}
66
66
  :where(.sm-stage) > :where(.sm-gallery:not(:has(> :nth-child(5)))){grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr)}
67
67
  :where(.sm-stage) > :where(figcaption,small,.sm-caption){justify-self:center;text-align:center}
68
+ /* November 6, 2026 (owner: "why is this not centered vertically"): a stage inside a view's own result column (Generate's
69
+ .gen-results beside its settings) took only its content's height at the top of that column, leaving an empty band under it
70
+ while the settings ran on. The column holding a stage stretches to its row, so the stage fills it and what it shows sits in
71
+ the middle, as the kit's own two-column layout already does. A stage with a fixed shape (data-ratio) keeps its shape. */
72
+ :where(.sm-block *:has(> .sm-stage:not([data-ratio]))){align-self:stretch}
73
+ :where([data-stillmade-layout="own"] :has(> .sm-stage:not([data-ratio]))){align-self:auto}
68
74
  :where([data-stillmade-layout="own"]) :where(.sm-stage){align-content:normal}
69
75
  :where([data-stillmade-layout="own"]) :where(.sm-stage) > :where(.sm-gallery){width:auto;grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--sm-tile,140px)),1fr));grid-auto-flow:row}
70
76
 
@@ -322,3 +328,20 @@
322
328
  :where(.sm-status[data-state="loading"])::before,:where(.sm-glyph[data-glyph="spinner"])::before{animation:none;border-right-color:currentColor;opacity:.6}
323
329
  :where(.sm-more) > :where(summary)::after{transition:none}
324
330
  }
331
+
332
+ /* Actions on the stage (owner, November 5, 2026). A job's sm-actions and sm-status go inside its sm-stage: status top-left,
333
+ actions top-right, in the cream blocks of StillMade's player, over a band kept above the media so they never cover it.
334
+ The run button is always the rightmost, so making again is pressed in the same place it made the first time, and what
335
+ the result adds (Download) sits to its left. On a narrow stage the actions take a second row under the status. */
336
+ :where(.sm-stage:has(> .sm-actions,> .sm-status)){padding-top:calc(var(--s-3,12px) * 2 + var(--touch-target,44px))}
337
+ :where(.sm-stage) > :where(.sm-actions){position:absolute;top:var(--s-3,12px);right:var(--s-3,12px);z-index:2;flex-wrap:nowrap;justify-content:flex-end;max-width:calc(100% - var(--s-3,12px) * 2)}
338
+ :where(.sm-stage) > :where(.sm-actions) > :where(.sm-primary){flex:0 0 auto}
339
+ :where(.sm-stage) > :where(.sm-actions) > :where([data-stillmade-action="run"]){order:99}
340
+ :where(.sm-stage) > :where(.sm-actions) > :where(.sm-quiet,.sm-icon){border-color:transparent;background:var(--cream-100,#F7F0DB);color:var(--ink-600,#0A0A0A)}
341
+ :where(.sm-stage) > :where(.sm-status){position:absolute;top:var(--s-3,12px);left:var(--s-3,12px);z-index:2;display:inline-flex;align-items:center;gap:var(--s-2,8px);min-height:32px;margin:0;padding:0 var(--s-3,12px);border-radius:var(--r-3,8px);background:var(--cream-100,#F7F0DB);color:var(--ink-600,#0A0A0A);font-size:12px}
342
+ :where(.sm-stage) > :where(.sm-status:empty){display:none}
343
+ @container (max-width:420px){
344
+ :where(.sm-stage:has(> .sm-actions):has(> .sm-status:not(:empty))){padding-top:calc(var(--s-3,12px) * 2 + var(--s-2,8px) + 32px + var(--touch-target,44px))}
345
+ :where(.sm-stage:has(> .sm-status:not(:empty))) > :where(.sm-actions){top:calc(var(--s-3,12px) + var(--s-2,8px) + 32px);left:var(--s-3,12px)}
346
+ :where(.sm-stage) > :where(.sm-actions) > :where(.sm-primary){flex:0 1 auto}
347
+ }