stillmade-sdk 0.1.47 → 0.1.49

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 (75) hide show
  1. package/AGENTS.md +6 -2
  2. package/FRAME_VIEWS.md +71 -5
  3. package/INTERFACE_DESIGN.md +65 -2
  4. package/MODULES.md +26 -3
  5. package/PASTE_TO_LLM.md +25 -10
  6. package/README.md +15 -0
  7. package/SDK.md +349 -51
  8. package/blocks/native/sm.board-editor/versions/3.0.0/package.js +921 -0
  9. package/blocks/native/sm.editor/versions/3.0.0/package.js +751 -0
  10. package/blocks/native/sm.panel-strip/versions/3.0.0/package.js +350 -0
  11. package/blocks/native/sm.photo-editor/versions/1.0.0/package.js +6693 -0
  12. package/blocks/native/sm.voiceover/versions/3.0.0/package.js +513 -0
  13. package/docs/build/block-builder-contract.md +2 -2
  14. package/docs/build/custom-interface.md +23 -3
  15. package/docs/build/frame-views.md +71 -5
  16. package/docs/build/hosted-text.md +8 -3
  17. package/docs/build/interface-design.md +65 -2
  18. package/docs/build/media-analysis.md +18 -1
  19. package/docs/build/modules.md +26 -3
  20. package/docs/build/with-your-ai.md +14 -0
  21. package/docs/build/workspaces.md +42 -0
  22. package/docs/reference/authoring-guide.md +4 -4
  23. package/docs/reference/cli.md +8 -3
  24. package/docs/reference/execution.md +13 -5
  25. package/docs/reference/first-party-blocks.md +5 -4
  26. package/docs/reference/generation-models.md +4 -0
  27. package/docs/reference/limits.md +9 -2
  28. package/docs/reference/permissions.md +4 -0
  29. package/docs/reference/view-bridge.md +6 -3
  30. package/docs/reference/view-rules.md +1 -1
  31. package/docs/tools/llm.md +25 -10
  32. package/package.json +1 -1
  33. package/packages/block-cli/cli.js +124 -12
  34. package/packages/block-cli/dev-host/assets/host.js +55 -30
  35. package/packages/block-cli/dev-host/assets/index.css +1 -1
  36. package/packages/block-cli/dev-server.js +74 -23
  37. package/packages/block-cli/format.js +20 -1
  38. package/packages/block-cli/ready-gate.js +34 -2
  39. package/packages/block-cli/templates.js +61 -0
  40. package/packages/block-cli/type-packages.js +7 -4
  41. package/packages/block-cli/workspace-dev.js +124 -0
  42. package/packages/block-cli/workspace-pack.js +62 -0
  43. package/packages/block-cli/workspace.js +304 -0
  44. package/packages/block-platform/block-readiness.js +5 -4
  45. package/packages/block-platform/canvas.js +9 -0
  46. package/packages/block-platform/catalog.js +5 -3
  47. package/packages/block-platform/listings.js +2 -2
  48. package/packages/block-platform/node-defaults.js +2 -2
  49. package/packages/block-platform/plain-descriptions.js +1 -0
  50. package/packages/block-platform/type-workspace.js +8 -0
  51. package/packages/block-sdk/admission.js +2 -2
  52. package/packages/block-sdk/builder-contract.js +3 -3
  53. package/packages/block-sdk/capabilities.js +32 -12
  54. package/packages/block-sdk/contracts.js +11 -2
  55. package/packages/block-sdk/first-party-identities.js +9 -0
  56. package/packages/block-sdk/first-party-packages.js +9 -1
  57. package/packages/block-sdk/folder-package.js +34 -2
  58. package/packages/block-sdk/generation-catalog.js +7 -0
  59. package/packages/block-sdk/index.d.ts +13 -5
  60. package/packages/block-sdk/index.js +1 -1
  61. package/packages/block-sdk/mock-host.js +7 -0
  62. package/packages/block-sdk/module-hosted.js +11 -1
  63. package/packages/block-sdk/module-media-access.js +5 -3
  64. package/packages/block-sdk/permission-summary.js +1 -1
  65. package/packages/block-sdk/portable-media-actions.d.ts +4 -1
  66. package/packages/block-sdk/portable-media-actions.js +16 -1
  67. package/packages/block-sdk/remix-policy.js +2 -2
  68. package/packages/block-sdk/staged-releases.js +5 -0
  69. package/packages/block-sdk/stillmade-pack.js +64 -0
  70. package/packages/block-sdk/testing.js +2 -2
  71. package/packages/block-sdk/view-bridge.js +6 -3
  72. package/packages/block-sdk/view-design.js +59 -12
  73. package/packages/block-sdk/view.js +47 -9
  74. package/packages/design-system/block-kit.css +6 -0
  75. package/packages/design-system/theme.css +2 -0
@@ -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,6 +3,17 @@
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';
@@ -28,6 +39,8 @@ 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.',
@@ -60,6 +73,24 @@ export const BLOCK_INTERFACE_PRINCIPLES=Object.freeze([
60
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.',
61
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.',
62
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;
63
94
  // The compact layout rules (October 18–19, 2026), for every Block interface,
64
95
  // generated or custom. StillMade's in-app builders and the SDK guide both read them.
65
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)));
@@ -274,32 +305,45 @@ export function looseFields(html){
274
305
  }
275
306
  return loose;
276
307
  }
277
- function jobWordWarnings(view,primary){
278
- 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';
279
313
  for(let start=lower.indexOf('<button'),close=0;start!==-1;start=lower.indexOf('<button',close+9)){
280
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;
281
316
  const label=primary.slice(open+1,close).replace(/<[^<>]*>/g,' ').replace(/\s+/g,' ').trim();
282
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;}
283
318
  }
284
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").');
285
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.');
286
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.');
287
- 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.');
288
- 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.');
289
324
  const kept=DEVICE_STATE.exec(primary.replace(/<[^<>]*>/g,' ')+'\n'+quotedText(String(view.javascript||'')));
290
- const loose=looseFields(primary);
325
+ const loose=app?0:looseFields(primary);
291
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.`);
292
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.`);
293
328
  return warnings;
294
329
  }
295
330
 
296
331
  /** The view's wording warnings as plain notes, for readiness's "Wording" limitations. */
297
- 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())):[];
298
333
 
299
- export function reviewViewDesign(view){
300
- 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};
301
339
  const html=view.html||'',javascript=view.javascript||'',primary=html.replace(/<details\b[\s\S]*?<\/details>/gi,'');
302
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
+ }
303
347
  const largeTextarea=/<textarea\b[^>]*rows\s*=\s*["']?(?:1[2-9]|[2-9]\d)["']?[^>]*>/i.test(primary);
304
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);
305
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.');
@@ -325,7 +369,7 @@ export function reviewViewDesign(view){
325
369
  const narrow=[...html.matchAll(/\sdata-stillmade-layout\s*=\s*(["']?)([^"'\s>]*)\1/gi)].some(match=>match[2]==='own')?null:narrowScreen(view);
326
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.`);
327
371
  warnings.push(...blockKitProblems(view).map(problem=>problem.message));
328
- return {passed:issues.length===0,issues,warnings,principles:BLOCK_INTERFACE_PRINCIPLES};
372
+ return {passed:issues.length===0,issues,warnings,principles};
329
373
  }
330
374
 
331
375
  // October 30, 2026: Chat presses a button on a Block's own screen only when it carries data-stillmade-action
@@ -348,20 +392,23 @@ export function unmarkedButtons(view){
348
392
  // 'kit'. Installed Blocks are never refused for these; admission only warns.
349
393
  const GENERIC_ACTIONS=/^(?:run|run step|run it|submit|go|start|ok|review cost|review price)$/i;
350
394
  export const BLOCK_KIT_READINESS_RULES=Object.freeze(['action','price','heading']);
351
- 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'}={}){
352
398
  if(!view)return [];
353
- 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';
354
400
  const lower=html.toLowerCase();
355
401
  // Each character is read a bounded number of times: the next search starts after this button.
356
402
  for(let start=lower.indexOf('<button'),close=0;start!==-1;start=lower.indexOf('<button',close+9)){
357
403
  const open=lower.indexOf('>',start);close=open===-1?-1:lower.indexOf('</button>',open);
358
404
  if(close===-1)break;
405
+ if(app&&!runButton(lower.slice(start,open)))continue;
359
406
  const label=html.slice(open+1,close).replace(/<[^<>]*>/g,' ').replace(/\s+/g,' ').trim();
360
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;}
361
408
  }
362
409
  const visible=html.replace(/<[^<>]*>/g,' ')+'\n'+strings;
363
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.'});
364
411
  if(!original&&/<h1\b/i.test(html))problems.push({rule:'heading',message:'Drop the page heading; StillMade already shows the Block\'s name.'});
365
- 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.'});
366
413
  return problems;
367
414
  }
@@ -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
 
@@ -269,3 +269,5 @@ button[data-icon-action="true"]:disabled,a[data-icon-action="true"]:disabled {op
269
269
  button[data-icon-action="true"][aria-pressed="true"],a[data-icon-action="true"][aria-pressed="true"] {color:var(--text-primary, var(--fg)) !important;text-decoration:underline;text-underline-offset:5px;}
270
270
  a[data-icon-action="true"] {flex:0 0 auto !important;align-self:center;} /* an icon link never stretches into a bar */
271
271
  @media (pointer:coarse) {button[data-icon-action="true"],a[data-icon-action="true"] {min-width:44px !important;min-height:44px !important;}}
272
+ /* What an icon-only control does, after a short hover (icon-tips.js). */
273
+ .sm-icon-tip {position:fixed;z-index:2147483647;max-width:260px;padding:6px 10px;border-radius:var(--r-2,4px);background:var(--fg,#0a0a0a);color:var(--bg,#f6f0e0);font:400 12px/1.35 var(--font-ui,inherit);box-shadow:0 6px 20px #0002;pointer-events:none;white-space:normal}