stillmade-sdk 0.1.40 → 0.1.41

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.
@@ -352,7 +352,8 @@ export const NATIVE_HOST_INVENTORY=[
352
352
  "id": "sm.panel-strip",
353
353
  "versions": [
354
354
  "1.0.0",
355
- "1.1.0"
355
+ "1.1.0",
356
+ "1.2.0"
356
357
  ],
357
358
  "phase": "shotMap",
358
359
  "kind": "image-panels",
@@ -381,7 +382,19 @@ export const NATIVE_HOST_INVENTORY=[
381
382
  "priority": 10
382
383
  }
383
384
  ],
384
- "host": {}
385
+ "host": {},
386
+ "releaseHosts": {
387
+ "1.2.0": {
388
+ "canvasNode": "workspaceHostNode",
389
+ "ports": {
390
+ "narrationAudio": "in:narrationAudio",
391
+ "narrationTiming": "in:narrationTiming",
392
+ "narrationScript": "in:narrationScript",
393
+ "panels": "out:panels",
394
+ "images": "out:images"
395
+ }
396
+ }
397
+ }
385
398
  },
386
399
  {
387
400
  "schemaVersion": 1,
@@ -609,11 +622,23 @@ export const NATIVE_HOST_INVENTORY=[
609
622
  "id": "sm.voiceover",
610
623
  "versions": [
611
624
  "1.0.0",
612
- "1.1.0"
625
+ "1.1.0",
626
+ "1.2.0"
613
627
  ],
614
628
  "phase": "voiceover",
615
629
  "kind": "voiceover",
616
- "host": {}
630
+ "host": {},
631
+ "releaseHosts": {
632
+ "1.2.0": {
633
+ "canvasNode": "workspaceHostNode",
634
+ "ports": {
635
+ "input": "in:input",
636
+ "output": "out:output",
637
+ "timing": "out:timing",
638
+ "spokenScript": "out:spokenScript"
639
+ }
640
+ }
641
+ }
617
642
  },
618
643
  {
619
644
  "schemaVersion": 1,
@@ -1,5 +1,6 @@
1
1
  export function defaultNodeData(type, request = {}, sceneNumber = 1, {llmModel, voiceId} = {}) {
2
2
  if (type === 'productionWorkspaceNode')return {title:request.name || 'Production workspace',prompt:'',messages:[],versions:[],status:'pending'};
3
+ if (type === 'workspaceHostNode')return {title:request.name || 'Workspace',workspaceOutputs:{}};
3
4
  if (type === 'bibleNode') return { title: request.name || 'Manual Blueprint Node', description: request.description || 'Manual node.' };
4
5
  if (type === 'textPromptNode') {
5
6
  return {
@@ -22,7 +22,7 @@ const sceneFields=['title','name','sceneNumber','location','locationId','timeOfD
22
22
  const shotFields=['shotId','sceneId','sceneNumber','shotNumber','title','name','description','shotDescription','voiceoverText','text','dialogue','hasDialogue','characterSpeaking','characterId','characterIds','charactersPresent','location','locationId','startTime','endTime','startMs','durationMs','estimatedScreenTime','shotSize','shotType','cameraSize','cameraMotion','fulfillment','vsmId','panelId','type'];
23
23
  // URL-only histories (Panels and imported editor media) have no persisted version
24
24
  // IDs. A content-derived identifier survives history trimming and reordering.
25
- function urlVersion(url) {
25
+ export function urlVersion(url) {
26
26
  let a=2166136261,b=5381;
27
27
  for(let i=0;i<url.length;i++){a=Math.imul(a^url.charCodeAt(i),16777619);b=Math.imul(b,33)^url.charCodeAt(i);}
28
28
  return `url-${(a>>>0).toString(16)}${(b>>>0).toString(16)}-${url.length}`;
@@ -3,7 +3,7 @@ import {isScriptWriterHandoff} from './script-writer.js';
3
3
  import {packageForStage} from './stage-package.js';
4
4
 
5
5
  const SDK_SCRIPT_RUNTIMES = new Set(['recipe', 'javascript', 'capability']);
6
- const VOICEOVER_VERSIONS = new Set(['1.0.0', '1.1.0']);
6
+ const VOICEOVER_VERSIONS = new Set(['1.0.0', '1.1.0', '1.2.0']);
7
7
 
8
8
  // This identifies an explicit document handoff, not a Canvas wire or permission
9
9
  // to replace narration. The host must separately verify the pinned source,
@@ -3,7 +3,9 @@
3
3
  // Steps in `stages`. Each part stays a real Step, so its permissions, approvals,
4
4
  // credits, memory and runs are unchanged; groups only change what people see.
5
5
  import {assert,plain} from '../block-sdk/contracts.js';
6
- import {BLOCK_NAME_MAX_LENGTH} from '../block-sdk/builder-contract.js';
6
+ // The Block name limit, mirrored from builder-contract.js (the rule lives there; scripts/step-groups-name-length.test.js
7
+ // pins the two): importing it pulled 36 KB of builder prompt text into the studio's startup graph for one number.
8
+ export const BLOCK_NAME_MAX_LENGTH=40;
7
9
 
8
10
  export const MAX_GROUP_PARTS=12;
9
11
  const GROUP_KEYS=['id','label','stages','settings','result'];
@@ -9,7 +9,7 @@
9
9
  import {HOSTED_OPERATION_NAMES} from './contracts.js';
10
10
 
11
11
  export const BUILDER_CONTRACT_ID='stillmade.block-builder';
12
- export const BUILDER_CONTRACT_VERSION='1.12.0';
12
+ export const BUILDER_CONTRACT_VERSION='1.16.0';
13
13
  export const BUILDER_CONTRACT_STAMP=`${BUILDER_CONTRACT_ID}@${BUILDER_CONTRACT_VERSION}`;
14
14
  export const BUILDER_CONTRACT_DOCS_PATH='/docs/build/block-builder-contract';
15
15
  // Block names appear in step bars, cards and pickers; new releases keep them short.
@@ -72,9 +72,20 @@ const SECTIONS=Object.freeze([
72
72
  rule('input-words','block',
73
73
  'Name each input in the job\'s words (`sermonVideo`, not `video`): StillMade words its drop area from the name.',
74
74
  'Name each input for what the person brings, in the job\'s words: `sermonVideo` for a sermon clip Block, `listingPhotos` for a listing, `lessonSlides` for a lesson podcast. StillMade titles the input from that name ("Sermon video") and words its drop area from it ("Drop your sermon video here", the formats, "Choose a video"), in StillMade and the SDK preview, so never draw your own upload box for an input. Keep a bare type name (`video`, `file`) only when any file of that kind does the job. The `description` says what makes a good one.'),
75
+ // November 2, 2026 (owner): every screen speaks the job, as a product made only for it would, and stays quiet. The same
76
+ // day (owner, of four tiles each saying "Only on the device it was added from"): "if it's a notification make it a
77
+ // notification": state is one StillMade notification or nothing, never a sentence on the page or one per item.
78
+ rule('job-words','block',
79
+ 'Speak the job unless the person or creator asks otherwise: `manifest.action` names the button ("Plan the tour"; AI pictures, video or sound start with Generate, never "Make it"), one solid `sm-primary`, progress in the job\'s units, one line per problem, no notes on how the Block works or its state (one StillMade notification at most, never one per item), `date` inputs for days and times, plain input names so "Use a reference" fills them, posting as its own connector Step. Clear every Wording note `gate` lists.',
80
+ 'These are StillMade\'s defaults: unless the person or the creator asked for something else, every screen speaks the job in its own words and adds nothing else. `manifest.action` names the Step\'s button, {label:"Plan the tour", running:"Planning the tour…"}, and StillMade adds the price ("Plan the tour · 1 credit"). When the Block makes a picture, video or sound with AI the label starts with Generate and says what ("Generate tour", "Generate voiceover"); one that cuts or renders the person\'s own media says Make ("Make 6 clips"); never "Make it", "Run", "Submit", "Process" or a bare "Generate". The main button stands out: one solid `sm-primary` in the kit\'s ink, never restyled to the background; everything else is `sm-quiet` or an `sm-icon`, and close is an X icon. Report progress in the job\'s units with `stillmade.progress(fraction, label)` ("Moving through the kitchen, 4 of 9 rooms"), never "Processing". Head the result in the job\'s words ("9 rooms", "Your tour · 44 s"), never "Output". Say a problem in one line with its fix beside it ("No bathroom photo. Guests look for one." and Add photo). Stay quiet: no welcome text, tips under fields, tours or banners, and at most one line of guidance on a screen, only where the next move is not obvious; anything more goes in the input\'s `description`, which StillMade shows behind an (i). Say nothing about how the Block works (where it runs, saving, syncing, recovery, accounts, credits) or where a file is kept: StillMade tells the person what they need as one short notification, or nothing; never a sentence on the page, and never the same line under each item. Every drop area offers "Use a reference", which fills the Step from the person\'s saved photos, videos and notes: media inputs fill by kind, so take `video` or `asset` where a clip works as well as a photo, and text inputs fill by plain names and labels (`productName`, `website`, `brandColors`, `fonts`, `voice`, and `details` or `notes` for the reference\'s note); never build your own reference picker. Days and times are picked, never typed: use the `date` type, which StillMade shows as a calendar, with a list of times when the input is about a time (`postAt`, `startsAt`); a custom view uses `<input type="date">` or `type="datetime-local"`. Posting is a connector Step of its own, and StillMade words its button from the app and action ("Post to Instagram"). Before handing the Block back, clear every Wording note that `gate` and readiness list, unless the person asked for those words.'),
81
+ // November 2, 2026 (owner): StillMade centers and zooms a screen; an app that lays itself out (a CAD tool) opts out with
82
+ // data-stillmade-layout="own" and keeps its own layout and Ctrl/Cmd +/- keys. The same day (owner: "implement A absolutely
83
+ // universally, like in app, platform, sdk, just everywhere"): every <video controls> gets StillMade's player and result
84
+ // pictures its full-screen button; a video without controls stays the Block's own. Also that day (owner: "there is no
85
+ // separation and I cannot tell what is going on"): a pick is the kit's ink fill, and media that cannot show is a quiet tile.
75
86
  rule('ui','block',
76
- 'Prefer `manifest.ui`; custom views use the Block kit (`sm-*`) unless a look is named. Pictures over words: preview each option before choosing; few words, fold the rest; buttons carry `data-stillmade-action`; the start button names the action (never "Run") and shows no price (StillMade adds it). A job fits one screen unscrolled and a 320px phone; an app (editor, configurator, CAD tool) works like that app: full canvas, panels, tools, undo, never a form.',
77
- 'If the capability needs UI, use the Block UI SDK: generated `manifest.ui` controls first; a sandboxed custom view (`view.html`/`view.css`/`view.js`) only when the core interaction needs one. Build custom views from the Block kit the host injects (`block-kit.css`): the result in `sm-stage`, picks as pictures in `sm-choices`, one `sm-primary` naming the action (Write, Generate; never "Run") and no price (StillMade\'s run button adds it), extras in a closed `sm-more`; a look the person names wins. Fit one laptop screen without scrolling: label beside control, status in a few words, files as tiles, rare tools as icons, (i) on unclear controls. These compact rules are for a job or an ingredient. Size the view to the Block\'s tier (block-tier.js): an app, a workspace a person works in for a while (video editor, 3D configurator, CAD or drawing tool), looks and works like that kind of app instead of a compact page: its canvas or stage fills the view, with the panels, toolbars, inspector or timeline such an app has, direct manipulation (select, drag, zoom), keyboard shortcuts and undo; it keeps the host appearance and Block kit controls, its panels may scroll, and on a phone its tools fold into sheets while the canvas stays usable. Never shrink an app into a form of fields and one button. Drop words nobody needs; put helpful ones in a closed `sm-hint`. Mark each button `data-stillmade-action="what it does"` and label each field: Chat presses and fills them. Expose the capability, not the implementation: sensible defaults, previews, progress, clear errors, visible outputs. Show no raw JSON, internal IDs, filesystem details, provider terms or developer settings unless the Block is a developer tool. When a person chooses among options they need to hear or see (voices, music, fonts, looks, templates, images), let them preview each option before choosing: a sample or thumbnail from the Block\'s own files or the host\'s free samples, never a paid generation. Skip previews only when there is nothing to preview. Choices StillMade owns (voices, models in the run review) already show samples. Follow the host appearance (Light, Dark and White) and one elastic layout: stacked on a 320px phone with no sideways scroll (result first, action pinned), and from 860px the controls left (≤380px), the result filling the right. Short settings sit side by side, each label beside its control (`sm-field`, `sm-settings`; three or four short options as one `data-segmented` row); many items are one line each (`sm-list`) that open in place; input files are small tiles (`sm-gallery data-size="small"`); on a phone chip rows scroll sideways and long lists show three. Compacting moves controls, never deletes them.'),
87
+ 'Prefer `manifest.ui`; custom views use the Block kit (`sm-*`) unless a look is named. Pictures over words: preview each option before choosing; few words, fold the rest; buttons carry `data-stillmade-action`; the start button names the action (never "Run" or "Make it") and shows no price (StillMade adds it). A job fits one screen unscrolled and a 320px phone; an app (editor, configurator, CAD tool) works like that app: full canvas, panels, tools, undo, never a form. An app puts `data-stillmade-layout="own"` on its root: StillMade never centers or zooms it. `<video controls>` gets StillMade\'s player; leave out `controls` when the Block drives playback. A pick keeps the kit\'s ink fill; media that cannot show is a wordless `sm-media-off` tile.',
88
+ 'If the capability needs UI, use the Block UI SDK: generated `manifest.ui` controls first; a sandboxed custom view (`view.html`/`view.css`/`view.js`) only when the core interaction needs one. Build custom views from the Block kit the host injects (`block-kit.css`): the result in `sm-stage`, picks as pictures in `sm-choices`, the picked one in the kit\'s ink fill (never restyled to an outline, a tint or the page\'s background) so it reads at a glance, one solid `sm-primary` naming the action in the job\'s words ("Plan the tour", "Generate voiceover"; never "Run" or "Make it") and no price (StillMade\'s run button adds it), extras in a closed `sm-more`; a look the person names wins. Fit one laptop screen without scrolling: label beside control, status in a few words, files as tiles, rare tools as icons, (i) on unclear controls. These compact rules are for a job or an ingredient. Size the view to the Block\'s tier (block-tier.js): an app, a workspace a person works in for a while (video editor, 3D configurator, CAD or drawing tool), looks and works like that kind of app instead of a compact page: its canvas or stage fills the view, with the panels, toolbars, inspector or timeline such an app has, direct manipulation (select, drag, zoom), keyboard shortcuts and undo; it keeps the host appearance and Block kit controls, its panels may scroll, and on a phone its tools fold into sheets while the canvas stays usable. Never shrink an app into a form of fields and one button. StillMade centers a standard view narrower than its Step and zooms a view to fill its space; an app that lays itself out (full-size canvas or WebGL, its own scroll, pan or zoom, its own Ctrl/Cmd +/- keys) puts `data-stillmade-layout="own"` on its outermost element, and StillMade then never centers, zooms or takes those keys. StillMade draws one player for every `<video controls>` (play, timeline, time, sound and full screen, shown while the pointer is on the video, with keys and touch) and a full-screen button on result pictures in `sm-stage` or `sm-gallery` and on any element marked `data-stillmade-expand` (the same value makes one set of pictures, `off` removes it); `data-stillmade-player="native"` keeps the browser\'s controls, and a `<video>` without `controls` is the Block\'s own and is never touched. A picture or clip that cannot show here (kept on another device, or it did not open) keeps its place as a quiet `sm-media-off` tile in its own shape with the `image-off` glyph and the reason in its label, never words in its place. Drop words nobody needs; put helpful ones in a closed `sm-hint`. Mark each button `data-stillmade-action="what it does"` and label each field: Chat presses and fills them. Expose the capability, not the implementation: sensible defaults, previews, progress, clear errors, visible outputs. Show no raw JSON, internal IDs, filesystem details, provider terms or developer settings unless the Block is a developer tool. When a person chooses among options they need to hear or see (voices, music, fonts, looks, templates, images), let them preview each option before choosing: a sample or thumbnail from the Block\'s own files or the host\'s free samples, never a paid generation. Skip previews only when there is nothing to preview. Choices StillMade owns (voices, models in the run review) already show samples. Follow the host appearance (Light, Dark and White) and one elastic layout: stacked on a 320px phone with no sideways scroll (result first, action pinned), and from 860px the controls left (≤380px), the result filling the right. Short settings sit side by side, each label beside its control (`sm-field`, `sm-settings`; three or four short options as one `data-segmented` row); many items are one line each (`sm-list`) that open in place; input files are small tiles (`sm-gallery data-size="small"`); on a phone chip rows scroll sideways and long lists show three. Compacting moves controls, never deletes them.'),
78
89
  rule('multiplayer','block',
79
90
  'Custom views share edits: `StillMade.onShared`, publish every editable field (`data-stillmade-share`, `bindShared`, `updateShared`), respect `canEdit`, never re-run on remote updates.',
80
91
  'Projects are multiplayer. A custom view subscribes through `StillMade.onShared(callback)` and publishes every editable field: give static controls a unique `id` and `data-stillmade-share="field"` (the host owns their synchronization), use `StillMade.bindShared(field, elementId)` for controls created dynamically, and `StillMade.updateShared(values, before, options)` for structured values; private variables and DOM values are not shared. Mark a truly device-only control (playback, hover, device selection) with `data-stillmade-local`. Observe shared outputs as well as state, honor `canEdit` in every mutating callback (read-only participants cannot write), never run, click or call a paid integration in response to a remote update, and preserve conflicting drafts. Shared background work uses `sharedWork` with the `work.shared` permission.'),
@@ -147,7 +158,7 @@ export const BUILDER_CONTRACT=Object.freeze({
147
158
  gapClasses:GAP_CLASSES,
148
159
  });
149
160
 
150
- const SECTION_TITLES=Object.freeze({'runtime-choice':'Choose the runtime','grounded-text':'Only supplied facts','source-of-truth':'Source of truth','reuse-first':'Reuse first','block-tier':'Three sizes of Block','block-scope':'One job per Step','capability-contract':'Define the capability first','feature-map':'Feature map','compose':'Blocks compose','platform-primitives':'Use platform primitives','no-workarounds':'No hidden workarounds','naming':'Name it briefly','input-words':'Name inputs in the job\'s words','ui':'Interface','multiplayer':'Shared editing','external-services':'External services','dependencies':'Dependencies','security':'Security','lifecycle':'Execution lifecycle','errors':'Useful failures','adapt-to-platform':'Adapt to the platform','verification':'What StillMade verifies','project-types':'Project Types','readiness-report':'Readiness report'});
161
+ const SECTION_TITLES=Object.freeze({'runtime-choice':'Choose the runtime','grounded-text':'Only supplied facts','source-of-truth':'Source of truth','reuse-first':'Reuse first','block-tier':'Three sizes of Block','block-scope':'One job per Step','capability-contract':'Define the capability first','feature-map':'Feature map','compose':'Blocks compose','platform-primitives':'Use platform primitives','no-workarounds':'No hidden workarounds','naming':'Name it briefly','input-words':'Name inputs in the job\'s words','job-words':'Speak the job','ui':'Interface','multiplayer':'Shared editing','external-services':'External services','dependencies':'Dependencies','security':'Security','lifecycle':'Execution lifecycle','errors':'Useful failures','adapt-to-platform':'Adapt to the platform','verification':'What StillMade verifies','project-types':'Project Types','readiness-report':'Readiness report'});
151
162
  const inScope=(scope,wanted)=>wanted==='all'||scope==='both'||scope===wanted;
152
163
 
153
164
  /** Render the contract for a model prompt ('compact'), a guide ('full') or an SDK folder ('agents-md'). */
@@ -128,7 +128,7 @@ export function validateManifest(manifest, { builtIn = false } = {}) {
128
128
  const check = (ok, path, message) => { if (!ok) errors.push({ path, message }); };
129
129
  try { safeDocument(manifest); } catch (e) { return { valid: false, errors: [{ path: '$', message: e.message }] }; }
130
130
  if (!plain(manifest)) return { valid: false, errors: [{ path: '$', message: 'Manifest must be an object' }] };
131
- const allowed = ['schemaVersion', 'id', 'name', 'version', 'sdkVersion', 'description', 'kind', 'inputs', 'outputs', 'permissions', 'runtime', 'jobs', 'ui', 'uiLayout', 'entry', 'license', 'provenance', 'summary', 'category', 'platforms', 'state', 'storage', 'semantics', 'desktopTools', 'operations', 'sharedWork', 'triggers', 'features'];
131
+ const allowed = ['schemaVersion', 'id', 'name', 'version', 'sdkVersion', 'description', 'kind', 'inputs', 'outputs', 'permissions', 'runtime', 'jobs', 'ui', 'uiLayout', 'action', 'entry', 'license', 'provenance', 'summary', 'category', 'platforms', 'state', 'storage', 'semantics', 'desktopTools', 'operations', 'sharedWork', 'triggers', 'features'];
132
132
  for (const key of Object.keys(manifest)) check(allowed.includes(key), key, 'Unknown manifest field');
133
133
  for (const message of featureMapErrors(manifest)) check(false, 'features', message);
134
134
  if(manifest.sharedWork!==undefined){try{validateSharedWorkContract(manifest.sharedWork);}catch(error){check(false,'sharedWork',error.message);}check(manifest.permissions?.project?.includes('work.shared'),'sharedWork','Declare the work.shared permission');}
@@ -158,6 +158,8 @@ export function validateManifest(manifest, { builtIn = false } = {}) {
158
158
  check(typeof manifest.name === 'string' && manifest.name.trim().length > 0 && manifest.name.length <= 120, 'name', 'Name must be 1–120 characters');
159
159
  check(typeof manifest.description === 'string' && manifest.description.trim().length > 0 && manifest.description.length <= 1000, 'description', 'Description must be 1–1000 characters');
160
160
  check(KINDS.includes(manifest.kind), 'kind', 'Unsupported block kind');
161
+ // The Step's button in the job's words ("Plan the tour"), November 2, 2026; StillMade adds the price.
162
+ if(manifest.action!==undefined){const action=manifest.action,text=(value,max)=>typeof value==='string'&&value.trim().length>0&&value.length<=max&&!/[\r\n]/.test(value);check(plain(action)&&Object.keys(action).every(key=>['label','running'].includes(key))&&text(action.label,40)&&(action.running===undefined||text(action.running,48)),'action','Name the button as {label:"Plan the tour", running:"Planning the tour…"}: a label up to 40 characters, and optional running words up to 48');}
161
163
  check(['recipe', 'javascript', 'module', 'comfyui', 'capability', ...(builtIn ? ['host'] : [])].includes(manifest.runtime), 'runtime', 'Installable runtimes are recipe, isolated JavaScript, sandboxed JavaScript modules with WebAssembly, the reviewed ComfyUI adapter, and approved host capabilities');
162
164
  if(manifest.state!==undefined){
163
165
  const state=manifest.state;
@@ -14,7 +14,7 @@ export interface BlockViewBridge { connectDocument(options:import('./document-st
14
14
  export interface SdkCompatibility { status:'supported'|'unsupported'|'invalid'; declared:unknown; runtimeVersion:string; message:string; }
15
15
  export function sdkCompatibility(declared:unknown,runtimeVersion?:string):SdkCompatibility;
16
16
  export type Port = { type: string; imageMode?: 'pixels'|'reference'; role?: string; context?: keyof typeof CONTEXT_FIELDS; primary?: boolean; required?: boolean; default?: unknown; min?: number; max?: number; description?: string } & Partial<import('./semantic-contracts.js').SemanticContract>;
17
- export interface Manifest { schemaVersion: 1; id: string; name: string; version: string; sdkVersion: SdkVersionRange; description: string; kind: 'task'|'workspace'|'editor-extension'|'composite'; inputs: Record<string, Port>; outputs: Record<string, Port>; runtime: 'recipe'|'javascript'|'module'|'comfyui'|'capability'; jobs?: {kind:'cooperative';version:1}; state?: {scope:'step';version:number;initial:Record<string,unknown>;migrations?:StateMigration[]}; entry?: string; permissions: { project: string[]; desktop?: DesktopPermission[]; capabilities?: ['comfyui.execute']|['text.generate']|['audio.speech']|['audio.transcribe']|['image.generate']|['video.generate']|['image.describe']|['timeline.propose']|['workspace.propose']|['connection.execute']; network?: never[]; filesystem?: never[]; secrets?: never[] }; desktopTools?:DesktopToolDeclaration[]; uiLayout?: {kind:'tabs'|'sections';groups:{id:string;label:string;inputs:string[];outputs:string[];collapsed?:boolean}[]}; ui?: ({ control: 'text'|'number'|'slider'|'checkbox'|'asset'; port: string; label?: string; when?: {port:string;equals:string|boolean} } | {control:'select'|'multiselect';port:string;options:string[];label?:string;when?:{port:string;equals:string|boolean}} | {control:'gallery';port:string;label?:string} | {control:'before-after';port:string;before:string;label?:string})[]; }
17
+ export interface Manifest { schemaVersion: 1; id: string; name: string; version: string; sdkVersion: SdkVersionRange; description: string; action?: { label: string; running?: string }; kind: 'task'|'workspace'|'editor-extension'|'composite'; inputs: Record<string, Port>; outputs: Record<string, Port>; runtime: 'recipe'|'javascript'|'module'|'comfyui'|'capability'; jobs?: {kind:'cooperative';version:1}; state?: {scope:'step';version:number;initial:Record<string,unknown>;migrations?:StateMigration[]}; entry?: string; permissions: { project: string[]; desktop?: DesktopPermission[]; capabilities?: ['comfyui.execute']|['text.generate']|['audio.speech']|['audio.transcribe']|['image.generate']|['video.generate']|['image.describe']|['timeline.propose']|['workspace.propose']|['connection.execute']; network?: never[]; filesystem?: never[]; secrets?: never[] }; desktopTools?:DesktopToolDeclaration[]; uiLayout?: {kind:'tabs'|'sections';groups:{id:string;label:string;inputs:string[];outputs:string[];collapsed?:boolean}[]}; ui?: ({ control: 'text'|'number'|'slider'|'checkbox'|'asset'; port: string; label?: string; when?: {port:string;equals:string|boolean} } | {control:'select'|'multiselect';port:string;options:string[];label?:string;when?:{port:string;equals:string|boolean}} | {control:'gallery';port:string;label?:string} | {control:'before-after';port:string;before:string;label?:string})[]; }
18
18
  export interface BlockView { html: string; css?: string; themedCss?: string; javascript?: string; appearance?: 'host'|'original'; }
19
19
  /** A frame view: any interface code (frameworks, SVG, canvas, drag) with assets named by SHA-256. */
20
20
  export interface FrameView { runtime: 'frame'; html: string; css?: string; themedCss?: string; javascript?: string; appearance?: 'host'|'original'; files?: {schemaVersion: 1; files: Record<string, {sha256: string; bytes: number; mediaType: string}>}; }
@@ -414,7 +414,7 @@ export * from './native-host-providers.js';
414
414
 
415
415
  export {blockRemixPolicy,assertRemixReady,REMIX_AUTHORING_REQUIREMENT} from './remix-policy.js';
416
416
  export interface SdkCompatibility {status:'supported'|'unsupported'|'invalid';declared:unknown;runtimeVersion:string;message:string}
417
- export interface Manifest { schemaVersion: 1; id: string; name: string; version: string; sdkVersion: SdkVersionRange; description: string; kind: 'task'|'workspace'|'editor-extension'|'composite'; inputs: Record<string, Port>; outputs: Record<string, Port>; runtime: 'recipe'|'javascript'|'module'|'comfyui'|'capability'; jobs?: {kind:'cooperative';version:1}; state?: {scope:'step';version:number;initial:Record<string,unknown>;migrations?:StateMigration[]}; entry?: string; permissions: { project: string[]; desktop?: DesktopPermission[]; capabilities?: ['comfyui.execute']|['text.generate']|['audio.speech']|['audio.transcribe']|['image.generate']|['video.generate']|['image.describe']|['timeline.propose']|['workspace.propose']|['connection.execute']; network?: never[]; filesystem?: never[]; secrets?: never[] }; uiLayout?: {kind:'tabs'|'sections';groups:{id:string;label:string;inputs:string[];outputs:string[];collapsed?:boolean}[]}; ui?: ({ control: 'text'|'number'|'slider'|'checkbox'|'asset'; port: string; label?: string; when?: {port:string;equals:string|boolean} } | {control:'select'|'multiselect';port:string;options:string[];label?:string;when?:{port:string;equals:string|boolean}} | {control:'gallery';port:string;label?:string} | {control:'before-after';port:string;before:string;label?:string})[]; }
417
+ export interface Manifest { schemaVersion: 1; id: string; name: string; version: string; sdkVersion: SdkVersionRange; description: string; action?: { label: string; running?: string }; kind: 'task'|'workspace'|'editor-extension'|'composite'; inputs: Record<string, Port>; outputs: Record<string, Port>; runtime: 'recipe'|'javascript'|'module'|'comfyui'|'capability'; jobs?: {kind:'cooperative';version:1}; state?: {scope:'step';version:number;initial:Record<string,unknown>;migrations?:StateMigration[]}; entry?: string; permissions: { project: string[]; desktop?: DesktopPermission[]; capabilities?: ['comfyui.execute']|['text.generate']|['audio.speech']|['audio.transcribe']|['image.generate']|['video.generate']|['image.describe']|['timeline.propose']|['workspace.propose']|['connection.execute']; network?: never[]; filesystem?: never[]; secrets?: never[] }; uiLayout?: {kind:'tabs'|'sections';groups:{id:string;label:string;inputs:string[];outputs:string[];collapsed?:boolean}[]}; ui?: ({ control: 'text'|'number'|'slider'|'checkbox'|'asset'; port: string; label?: string; when?: {port:string;equals:string|boolean} } | {control:'select'|'multiselect';port:string;options:string[];label?:string;when?:{port:string;equals:string|boolean}} | {control:'gallery';port:string;label?:string} | {control:'before-after';port:string;before:string;label?:string})[]; }
418
418
  export interface BlockPackage { portable?: PortableMetadata; manifest: Manifest; view?: BlockView|FrameView; code?: string; module?: ModuleDescriptor; comfyui?: ComfyWorkflow; capability?: ConnectedCapability|TextGenerationCapability|SpeechCapability|TranscriptionCapability|ImageGenerationCapability|VideoGenerationCapability|ImageDescriptionCapability|TimelineProposalCapability; recipe?: { schemaVersion: 1; steps: { id: string; op: string; args: Record<string, unknown> }[]; outputs: Record<string, unknown> }; tests: { name: string; input: Record<string, unknown>; expected?: Record<string, unknown>; state?: BlockState; expectedState?: BlockState; expectations?: Record<string, ConnectedResultExpectation|ComfyImageExpectation|CapabilityTextExpectation|CapabilityAudioExpectation|CapabilityTranscriptExpectation|CapabilityImageExpectation|CapabilityVideoExpectation|CapabilityTimelineExpectation> }[]; }
419
419
  export type CapabilityInvocation = TextGenerationInvocation|SpeechInvocation|TranscriptionInvocation|ImageGenerationInvocation|VideoGenerationInvocation|ImageDescriptionInvocation|TimelineProposalInvocation;
420
420
  export interface CapabilityResult { outputs: Record<string, unknown>; metadata?: Record<string, unknown>; mediaReceipts?: Record<string, SpeechMediaReceipt|GeneratedAudioMediaReceipt|GeneratedImageMediaReceipt|GeneratedVideoMediaReceipt>; }
@@ -11,7 +11,7 @@ export const BLOCK_KIT_CLASSES=Object.freeze({
11
11
  '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
12
  'sm-stage':'The thing being made: preview, media or result. The biggest area; data-ratio="16:9|9:16|1:1".',
13
13
  'sm-empty':'What an empty stage shows: an .sm-glyph, at most five words and one action.',
14
- '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). On a phone chips and segments scroll sideways.',
14
+ '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
15
  '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
16
  '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
17
  '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.',
@@ -21,16 +21,19 @@ export const BLOCK_KIT_CLASSES=Object.freeze({
21
21
  'sm-more':'A <details> for secondary settings; starts closed.',
22
22
  'sm-hint':'A <details> info toggle for an explanation that might help; starts closed.',
23
23
  '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.',
24
- 'sm-glyph':'A small icon drawn in currentColor: data-glyph="play|pause|plus|close|check|chevron|upload|image|video|audio|text|spark|info|warning|spinner".',
24
+ 'sm-media-off':'A picture or clip that cannot show here (on another device, or it did not open): a quiet tile in its own shape (--sm-photo-ratio, the tile\'s shape in a gallery) holding the image-off glyph, with the reason in aria-label and title and no words inside.',
25
+ 'sm-glyph':'A small icon drawn in currentColor: data-glyph="play|pause|plus|close|check|chevron|upload|image|image-off|video|audio|text|spark|info|warning|spinner".',
25
26
  });
26
27
 
27
28
  // The design principles for every Block interface, in priority order.
28
29
  export const BLOCK_INTERFACE_PRINCIPLES=Object.freeze([
29
30
  '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.',
30
- 'Show the thing being made: the preview, media or result gets the largest area (`sm-stage`). 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. Never a JSON dump, source editor, manifest or diagnostic panel unless the task is about source.',
31
- 'One obvious next action (`sm-primary`); peers are quiet buttons or named icons. Seven or fewer choices in view.',
32
- 'The action button names what it does ("Write", "Generate image", "Make video"), never "Run", "Run step", "Submit" or "Review cost". 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.',
31
+ 'Show the thing being made: the preview, media or result gets the largest area (`sm-stage`). 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.',
32
+ '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.',
33
+ '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.',
33
34
  '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.',
35
+ '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.',
36
+ 'Media that cannot show here (on another device, or it did not open) keeps its place as a quiet `sm-media-off` tile in its own shape, never words in its place.',
34
37
  'Words only when needed. Drop what nobody needs to finish the task (intros, "how it works", restating the Block name). Words that might help start collapsed (`sm-hint`, `sm-more`). Errors, spending approvals, stale inputs and destructive steps stay visible, in few words.',
35
38
  'Strong defaults; secondary settings go in `sm-more`, closed.',
36
39
  'Compact: the whole Block fits one laptop screen without scrolling. A short setting is one row, its label beside the control; no card around a single control.',
@@ -42,12 +45,16 @@ export const BLOCK_INTERFACE_PRINCIPLES=Object.freeze([
42
45
  'Status sits beside the action it describes (`sm-status`), in a few words.',
43
46
  'Keep the page in normal scroll flow; no sticky media or control strips unless the editing task needs them.',
44
47
  'Use the width: from 860px wide the kit puts the controls on the left at a reading width (300–380px) and the result (`sm-stage`) fills the right, in its real shape; under that everything stacks with the result first and the action within reach. Extra width goes to the result, never to wider text fields. Before there is a result, the stage previews what will be made from the inputs. `data-layout="stack"` opts out.',
45
- 'Size with the kit, never fixed sizes: no fixed pixel widths or heights for the workspace or stage, no 100vh, no position:fixed panels. StillMade zooms a Block\'s screen to fill the space it has (up to 150%, never down to a phone layout on a computer), so a Block laid out with the kit\'s spacing fills its Step without empty space.',
48
+ '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. 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.',
46
49
  '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.',
47
50
  '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.',
48
51
  'On a phone chip and segment rows scroll sideways instead of wrapping; long lists show their first three with "N more" (`data-peek`) and long text five lines until tapped (`sm-clamp`).',
49
52
  'Nothing is lost when a Block gets smaller: every control stays on the screen, behind an (i), in `sm-more`, or inside the item it belongs to. Compacting moves controls; it never deletes them.',
50
53
  'Text scene descriptions are planning notes, never a storyboard or an approved frame.',
54
+ 'Say nothing about how the Block works: no notes on where it runs (this device, StillMade Desktop, the cloud), saving, syncing, recovery, accounts, credits or where a file is kept. StillMade tells the person what they need as one short notification, or nothing; never a sentence on the page, and never the same line under each item.',
55
+ 'References fill your inputs: every drop area in StillMade offers "Use a reference", and the person\'s saved photos, videos and notes fill the Step with no typing. Media inputs fill by kind, so take `video` or `asset` wherever a clip works as well as a photo; text inputs fill by plain names and labels (`productName`, `website`, `brandColors`, `fonts`, `voice`, and `details` or `notes` for the reference\'s note). Never build your own reference picker or library upload.',
56
+ '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.',
57
+ '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.',
51
58
  ]);
52
59
  // The compact layout rules (October 18–19, 2026), for every Block interface,
53
60
  // generated or custom. StillMade's in-app builders and the SDK guide both read them.
@@ -188,6 +195,79 @@ function narrowScreen(view){
188
195
  return narrowest;
189
196
  }
190
197
 
198
+ // Speak the job (owner, November 2, 2026): the defaults a new Block's screen follows unless the person or creator asks
199
+ // otherwise. Warnings only, here and as readiness "Wording" limitations; they never refuse a release.
200
+ const GENERIC_JOB_ACTIONS=/^(?:make it|make|create|create it|generate|generate it|process|process it|do it|build it|start it)$/i;
201
+ const VAGUE_PROGRESS=/\bprogress\s*\(\s*[^,()]*,\s*['"`](?:processing|loading|working|please wait|running)\b/i;
202
+ const HOW_IT_WORKS=/\b(?:runs? (?:on|in) (?:your|this) (?:device|browser|computer)|while stillmade is open|stillmade desktop|saved automatically|auto-?saved?|kept in recovery|in recovery|syncs? (?:with|to) (?:stillmade|the cloud))\b/i;
203
+ const DATE_WORD=/(?:^|[\s_-])(?:date|day|time|when|deadline|schedule|starts?|yyyy-mm-dd|mm\/dd\/yyyy|dd\/mm\/yyyy)(?:$|[\s_-])|[a-z](?:Date|Time|At)$/;
204
+ function typedDate(html){
205
+ const lower=html.toLowerCase();
206
+ for(let at=lower.indexOf('<input');at!==-1;at=lower.indexOf('<input',at+6)){
207
+ const end=lower.indexOf('>',at);if(end===-1)return false;
208
+ const tag=html.slice(at,end),type=/\btype\s*=\s*["']?([a-z-]+)/i.exec(tag)?.[1]?.toLowerCase()||'text';
209
+ if(type!=='text'&&type!=='search')continue;
210
+ const values=[...tag.matchAll(/\b(?:id|name|placeholder|aria-label|data-stillmade-input)\s*=\s*["']([^"']{1,80})["']/gi)].map(match=>match[1]);
211
+ if(values.some(value=>DATE_WORD.test(value)))return true;
212
+ }
213
+ return false;
214
+ }
215
+ function fadedPrimary(css){
216
+ for(const rule of String(css||'').split('}')){
217
+ const open=rule.indexOf('{');if(open===-1)continue;
218
+ if(/\.sm-primary\b/.test(rule.slice(0,open))&&/background(?:-color)?\s*:\s*(?:transparent|none|inherit|initial|unset|var\(--bg\b)/i.test(rule.slice(open)))return true;
219
+ }
220
+ return false;
221
+ }
222
+ // November 2, 2026 (owner, of a Step's Kind and Voice rows: "there is no separation and I cannot tell what is going on"):
223
+ // a pick the Block's CSS takes back to a transparent or page-coloured fill, or gives the kit's ink as its text colour (on
224
+ // the kit's ink fill), no longer reads. Only a rule whose subject is the picked label itself (`…:has(input:checked)`)
225
+ // counts: a span inside it may be transparent over the label's fill. The subject is read with string searches: a regular
226
+ // expression over `:has(` and many `:checked` with no `)` took seconds on a 224 KB view CSS.
227
+ const picksChecked=selector=>{const subject=selector.trim(),at=subject.lastIndexOf(':has(');if(at===-1||!subject.endsWith(')'))return false;const inner=subject.slice(at+5,-1);return !/[()]/.test(inner)&&inner.includes(':checked');};
228
+ function hiddenPick(css){
229
+ for(const rule of String(css||'').split('}')){
230
+ const open=rule.indexOf('{');if(open===-1)continue;
231
+ if(!rule.slice(0,open).split(',').some(selector=>/\.sm-choices\b/.test(selector)&&picksChecked(selector)))continue;
232
+ const body=rule.slice(open),fill=/(?:^|[;{\s])background(?:-color)?\s*:\s*([^;]*)/i.exec(body)?.[1];
233
+ if(fill!==undefined?/^\s*(?:transparent|none|inherit|initial|unset|var\(--bg\b)/i.test(fill):/(?:^|[;{\s])color\s*:\s*var\(--fg\)/i.test(body))return true;
234
+ }
235
+ return false;
236
+ }
237
+ // The same day (owner, of four tiles each saying "Only on the device it was added from": "if it's a notification make it a
238
+ // notification"): where a file is kept is StillMade's to say, once. Read in the page's words and the strings its code shows.
239
+ const DEVICE_STATE=/\b(?:only|kept|stored|saved) on (?:the|this|that|another|your|a different) (?:device|computer|browser)\b|\bnot (?:available )?on this (?:device|computer|browser)\b|\bthe device it was added (?:on|from)\b/i;
240
+ // The text inside quotes, in one linear pass (regular expressions here could retry at every position on unterminated quotes).
241
+ function quotedText(javascript){
242
+ let strings='',quote=null;
243
+ for(let index=0;index<javascript.length;index++){
244
+ const char=javascript[index];
245
+ if(!quote){if(char==='"'||char==="'"||char==='`'){quote=char;strings+='\n';}continue;}
246
+ if(char==='\\'){index++;continue;}
247
+ if(char===quote)quote=null;else strings+=char;
248
+ }
249
+ return strings;
250
+ }
251
+ function jobWordWarnings(view,primary){
252
+ const warnings=[],lower=primary.toLowerCase();
253
+ for(let start=lower.indexOf('<button'),close=0;start!==-1;start=lower.indexOf('<button',close+9)){
254
+ const open=lower.indexOf('>',start);close=open===-1?-1:lower.indexOf('</button>',open);if(close===-1)break;
255
+ const label=primary.slice(open+1,close).replace(/<[^<>]*>/g,' ').replace(/\s+/g,' ').trim();
256
+ 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;}
257
+ }
258
+ 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").');
259
+ 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.');
260
+ 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.');
261
+ 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.');
262
+ 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.');
263
+ const kept=DEVICE_STATE.exec(primary.replace(/<[^<>]*>/g,' ')+'\n'+quotedText(String(view.javascript||'')));
264
+ 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.`);
265
+ return warnings;
266
+ }
267
+
268
+ /** The view's wording warnings as plain notes, for readiness's "Wording" limitations. */
269
+ 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())):[];
270
+
191
271
  export function reviewViewDesign(view){
192
272
  if(!view)return {passed:true,issues:[],warnings:[],principles:BLOCK_INTERFACE_PRINCIPLES};
193
273
  const html=view.html||'',javascript=view.javascript||'',primary=html.replace(/<details\b[\s\S]*?<\/details>/gi,'');
@@ -210,7 +290,11 @@ export function reviewViewDesign(view){
210
290
  if(rareTool)warnings.push(`"${rareTool.slice(0,40)}" is its own section. Rare tools (update, schedule, history, reload) are named icon buttons beside the action (.sm-icon in .sm-actions), never sections under the work.`);
211
291
  const rows=topLevelRows(primary);
212
292
  if(rows>COMPACT_ROW_LIMIT)warnings.push(`The workspace has ${rows} visible rows besides the stage and may not fit one laptop screen (an estimate: this check counts rows and cannot measure height). Put short settings side by side in .sm-settings and secondary ones in a closed .sm-more.`);
213
- const narrow=narrowScreen(view);
293
+ warnings.push(...jobWordWarnings(view,primary));
294
+ // November 2, 2026: an app that lays itself out (data-stillmade-layout="own", exactly as the frame's selector reads it)
295
+ // chose its own width. Whether StillMade centers a narrow screen is measured in the Step (never html or body, never over
296
+ // a margin the view sets), so the warning promises nothing about it.
297
+ const narrow=[...html.matchAll(/\sdata-stillmade-layout\s*=\s*(["']?)([^"'\s>]*)\1/gi)].some(match=>match[2]==='own')?null:narrowScreen(view);
214
298
  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.`);
215
299
  warnings.push(...blockKitProblems(view).map(problem=>problem.message));
216
300
  return {passed:issues.length===0,issues,warnings,principles:BLOCK_INTERFACE_PRINCIPLES};
@@ -236,16 +320,7 @@ const GENERIC_ACTIONS=/^(?:run|run step|run it|submit|go|start|ok|review cost|re
236
320
  export const BLOCK_KIT_READINESS_RULES=Object.freeze(['action','price','heading']);
237
321
  export function blockKitProblems(view){
238
322
  if(!view)return [];
239
- const problems=[],html=String(view.html||''),javascript=String(view.javascript||''),original=view.appearance==='original';
240
- // One linear pass over the code: the text inside quotes (regular expressions here
241
- // could retry at every position on unterminated quotes).
242
- let strings='',quote=null;
243
- for(let index=0;index<javascript.length;index++){
244
- const char=javascript[index];
245
- if(!quote){if(char==='"'||char==="'"||char==='`'){quote=char;strings+='\n';}continue;}
246
- if(char==='\\'){index++;continue;}
247
- if(char===quote)quote=null;else strings+=char;
248
- }
323
+ const problems=[],html=String(view.html||''),strings=quotedText(String(view.javascript||'')),original=view.appearance==='original';
249
324
  const lower=html.toLowerCase();
250
325
  // Each character is read a bounded number of times: the next search starts after this button.
251
326
  for(let start=lower.indexOf('<button'),close=0;start!==-1;start=lower.indexOf('<button',close+9)){
@@ -4,9 +4,13 @@ export function createWorkspaceNavigationRegistry(discovered){
4
4
  for(const namespace of Object.values(discovered)){
5
5
  const source=namespace.workspaceNavigation;safeDocument(source);const d=structuredClone(source);
6
6
  assert(plain(d)&&d.schemaVersion===1&&typeof d.id==='string'&&ID.test(d.id)&&Array.isArray(d.versions)&&d.versions.length>0&&d.versions.every(v=>typeof v==='string'&&VERSION.test(v))&&typeof d.phase==='string'&&typeof d.kind==='string','WORKSPACE_NAVIGATION','Invalid Block navigation declaration');
7
- if(d.host!==undefined)assert(plain(d.host)&&Object.keys(d.host).every(key=>['canvasNode','ports','editorTab','exportVideo','exportKind','inspectClip'].includes(key))&&['canvasNode','editorTab','exportKind'].every(key=>d.host[key]===undefined||(typeof d.host[key]==='string'&&d.host[key].length>0&&d.host[key].length<=100))&&['exportVideo','inspectClip'].every(key=>d.host[key]===undefined||typeof d.host[key]==='boolean')&&(d.host.ports===undefined||(plain(d.host.ports)&&Object.values(d.host.ports).every(value=>typeof value==='string'&&value.length>0&&value.length<=100))),'WORKSPACE_NAVIGATION','Invalid trusted host mapping');
7
+ const validHost=host=>plain(host)&&Object.keys(host).every(key=>['canvasNode','ports','editorTab','exportVideo','exportKind','inspectClip'].includes(key))&&['canvasNode','editorTab','exportKind'].every(key=>host[key]===undefined||(typeof host[key]==='string'&&host[key].length>0&&host[key].length<=100))&&['exportVideo','inspectClip'].every(key=>host[key]===undefined||typeof host[key]==='boolean')&&(host.ports===undefined||(plain(host.ports)&&Object.values(host.ports).every(value=>typeof value==='string'&&value.length>0&&value.length<=100)));
8
+ if(d.host!==undefined)assert(validHost(d.host),'WORKSPACE_NAVIGATION','Invalid trusted host mapping');
9
+ // A later release can carry its own host mapping (November 2, 2026: Voiceover and Image Panels 1.2.0
10
+ // take wires through a Canvas port node) while earlier pins keep the declaration they shipped with.
11
+ if(d.releaseHosts!==undefined)assert(plain(d.releaseHosts)&&Object.entries(d.releaseHosts).every(([version,host])=>d.versions.includes(version)&&validHost(host)),'WORKSPACE_NAVIGATION','Invalid release host mapping');
8
12
  assert(!definitions.has(d.id),'WORKSPACE_NAVIGATION','Duplicate Block navigation declaration');definitions.set(d.id,d);
9
- for(const version of d.versions){const key=d.id+'@'+version;assert(!entries.has(key),'WORKSPACE_NAVIGATION','Duplicate Block navigation pin');entries.set(key,d);}
13
+ for(const version of d.versions){const key=d.id+'@'+version;assert(!entries.has(key),'WORKSPACE_NAVIGATION','Duplicate Block navigation pin');const release=d.releaseHosts?.[version];entries.set(key,release?Object.freeze({...d,host:release}):d);}
10
14
  all.push(d);
11
15
  }
12
16
  const resolve=step=>typeof step?.blockId==='string'&&typeof step?.version==='string'?entries.get(step.blockId+'@'+step.version)||null:null;
@@ -9,7 +9,7 @@
9
9
  .sm-stage the thing being made (preview, media, result); biggest area
10
10
  .sm-settings settings side by side; .sm-choices[data-segmented] for short ones
11
11
  .sm-list many items, one line each; an item opens in place
12
- .sm-choices picks shown as pictures or icons, not word lists
12
+ .sm-choices picks shown as pictures or icons, not word lists; the pick is ink
13
13
  .sm-shape a frame shape at its real ratio (style="--r:9/16")
14
14
  .sm-field the few inputs a person actually needs
15
15
  .sm-actions one .sm-primary, optional .sm-quiet / .sm-icon
@@ -18,6 +18,7 @@
18
18
  .sm-hint an info toggle for words that might help, collapsed
19
19
  .sm-empty what an empty stage shows: a glyph, a few words, one action
20
20
  .sm-glyph icons drawn in CSS with currentColor (no SVG or url needed)
21
+ .sm-media-off a picture or clip that cannot show here: a quiet tile, no words
21
22
 
22
23
  The reference page is packages/design-system/block-kit.html. Keep this file
23
24
  free of url(), @import and @font-face: Block views refuse to load resources. */
@@ -68,8 +69,9 @@
68
69
  :where(.sm-actions) > :where(.sm-status){margin-inline-start:var(--s-2,8px)}
69
70
  :where(.sm-primary,.sm-quiet){display:inline-flex;align-items:center;justify-content:center;gap:var(--s-2,8px);min-height:var(--touch-target,44px);padding:10px 20px;border-radius:var(--r-pill,999px);font:inherit;font-weight:500;cursor:pointer;max-width:100%;text-align:center;overflow-wrap:anywhere}
70
71
  :where(.sm-primary){border:1px solid var(--fg);background:var(--fg);color:var(--bg)}
71
- :where(.sm-quiet){border:1px solid var(--border-tok);background:transparent;color:var(--fg)}
72
- :where(.sm-quiet):hover{background:var(--hover-wash)}
72
+ /* Every button you act on stands out from the page: white with a strong edge (November 2, 2026). */
73
+ :where(.sm-quiet){border:1px solid var(--border-strong);background:var(--bg-elev-1);color:var(--fg)}
74
+ :where(.sm-quiet):hover{background:var(--bg-elev-2)}
73
75
  :where(.sm-icon){display:inline-grid;place-items:center;min-width:var(--touch-target,44px);min-height:var(--touch-target,44px);padding:0;border:0;border-radius:var(--r-2,4px);background:transparent;color:var(--fg-muted);cursor:pointer}
74
76
  :where(.sm-icon):hover{color:var(--fg);background:var(--hover-wash)}
75
77
  :where(.sm-icon[aria-pressed="true"]){color:var(--fg);background:var(--press-wash)}
@@ -107,10 +109,20 @@
107
109
  :where(.sm-choices[data-size="wide"]){--sm-tile:160px}
108
110
  :where(.sm-choices) > :where(legend){padding:0;margin-bottom:6px;font-size:var(--fs-body-s,.875rem);color:var(--fg-muted)}
109
111
  :where(.sm-choices) > :where(label){position:relative;display:grid;justify-items:center;align-content:start;gap:6px;padding:var(--s-2,8px);border:1px solid var(--border-tok);border-radius:var(--r-3,8px);background:var(--bg-elev-1);color:var(--fg-muted);font-size:max(12px,var(--fs-caption,.8125rem));text-align:center;cursor:pointer}
110
- :where(.sm-choices) > :where(label):hover{border-color:var(--border-strong);color:var(--fg)}
112
+ :where(.sm-choices) > :where(label:not(:has(input:checked,input:disabled))):hover{border-color:var(--border-strong);color:var(--fg)}
111
113
  :where(.sm-choices) :where(input[type="radio"],input[type="checkbox"]){position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
112
- :where(.sm-choices) > :where(label:has(input:checked)){border-color:var(--fg);color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)}
114
+ /* The pick is the app's ink inversion, as in every segmented control in StillMade (owner, November 2, 2026, of a Step's
115
+ Kind and Voice rows: "there is like no separation and I cannot tell what is going on"). An outline alone read as a
116
+ slightly brighter border in Dark. Glyphs and shapes draw in currentColor, so they invert with the words. The muted
117
+ tokens invert too: in Light and Dark they are the ink at partial alpha, so a word inside the pick drawn in them (the
118
+ released Narration views' `#voice small{color:var(--fg-subtle)}`, "Onyx deep") vanished on the ink fill whatever its
119
+ selector's weight. The --text-* aliases were resolved at :root, so they are restated here. */
120
+ :where(.sm-choices) > :where(label:has(input:checked)){border-color:var(--fg);background:var(--fg);color:var(--bg);box-shadow:none;--fg-muted:color-mix(in srgb,var(--bg) 78%,transparent);--fg-subtle:color-mix(in srgb,var(--bg) 64%,transparent);--text-secondary:var(--fg-muted);--text-muted:var(--fg-subtle)}
113
121
  :where(.sm-choices) > :where(label:has(input:focus-visible)){outline:2px solid var(--signal);outline-offset:2px}
122
+ /* A locked row (a disabled fieldset, a view-only person) keeps its pick readable and fades the rest; nothing in it
123
+ reacts to the pointer. Inputs in a disabled fieldset are :disabled themselves, so one rule covers both. */
124
+ :where(.sm-choices) > :where(label:has(input:disabled)),:where(.sm-choices) :where(input:disabled){cursor:default}
125
+ :where(.sm-choices) > :where(label:has(input:disabled):not(:has(input:checked))){opacity:.45}
114
126
  :where(.sm-choices) :where(img,video,canvas,.sm-swatch){display:block;width:100%;aspect-ratio:var(--sm-tile-ratio,1);object-fit:cover;border-radius:var(--r-2,4px);background:var(--bg-inset)}
115
127
  :where(.sm-choices) :where(.sm-glyph){--sm-glyph-size:28px;margin:10px 0 4px}
116
128
  :where(.sm-choices[data-ratio="16:9"]){--sm-tile-ratio:16/9}
@@ -130,7 +142,8 @@
130
142
  :where(.sm-choices[data-segmented]) > :where(label + label){margin-inline-start:-1px}
131
143
  :where(.sm-choices[data-segmented]) > :where(label:first-of-type){border-start-start-radius:var(--r-pill,999px);border-end-start-radius:var(--r-pill,999px)}
132
144
  :where(.sm-choices[data-segmented]) > :where(label:last-of-type){border-start-end-radius:var(--r-pill,999px);border-end-end-radius:var(--r-pill,999px)}
133
- :where(.sm-choices[data-segmented]) > :where(label:has(input:checked)){position:relative;z-index:1;background:var(--bg-elev-2)}
145
+ /* The picked segment keeps the ink fill above it; z-index keeps its edge over the next segment's -1px overlap. */
146
+ :where(.sm-choices[data-segmented]) > :where(label:has(input:checked)){position:relative;z-index:1}
134
147
  :where(.sm-choices[data-segmented]) :where(.sm-glyph){--sm-glyph-size:16px;margin:0}
135
148
  /* Settings side by side: a grid of short settings, each label beside its control. A cell
136
149
  too narrow for both wraps the control under its label; long inputs keep the label above. */
@@ -174,6 +187,13 @@
174
187
  :where(.sm-gallery) :where([aria-selected="true"],.is-selected){box-shadow:inset 0 0 0 2px var(--fg)}
175
188
  /* Small tiles: more per row as the screen widens, never bigger tiles. */
176
189
  :where(.sm-gallery[data-size="small"]){--sm-tile:92px;--sm-tile-ratio:3/2}
190
+ /* Media that cannot show here (kept on another device, or it did not open) keeps its place and its shape: a quiet tile
191
+ with the image-off glyph and no words (owner, November 2, 2026: four tiles each saying "Only on the device it was added
192
+ from"). The reason goes in its aria-label and title; StillMade says it once, as a notification.
193
+ <span class="sm-media-off" role="img" aria-label="On another device" title="On another device" style="--sm-photo-ratio:3/2"><span class="sm-glyph" data-glyph="image-off" aria-hidden="true"></span></span>
194
+ Its edge separates it from a stage or gallery tile of the same colour; in a gallery it takes the tile's shape. */
195
+ :where(.sm-media-off){display:inline-grid;place-items:center;box-sizing:border-box;width:min(100%,320px);aspect-ratio:var(--sm-photo-ratio,4/3);border:1px solid var(--border-tok);border-radius:var(--r-2,4px);background:var(--bg-inset);color:var(--fg-subtle,var(--fg-muted));vertical-align:top}
196
+ :where(.sm-gallery) :where(.sm-media-off){display:grid;width:100%;aspect-ratio:var(--sm-tile-ratio,1);border-radius:inherit}
177
197
 
178
198
  /* ---------- Status: a glyph and a few words, next to the action ---------- */
179
199
  :where(.sm-status){display:inline-flex;align-items:center;gap:var(--s-2,8px);min-height:24px;font-size:var(--fs-body-s,.875rem);color:var(--fg-muted)}
@@ -229,6 +249,8 @@
229
249
  :where(.sm-glyph[data-glyph="upload"])::after{left:25%;right:25%;top:4%;height:62%;background:currentColor;clip-path:polygon(50% 0,100% 45%,60% 45%,60% 100%,40% 100%,40% 45%,0 45%)}
230
250
  :where(.sm-glyph[data-glyph="image"])::before{inset:12%;border:2px solid currentColor;border-radius:3px}
231
251
  :where(.sm-glyph[data-glyph="image"])::after{left:22%;right:22%;bottom:24%;height:34%;background:currentColor;clip-path:polygon(0 100%,35% 20%,60% 65%,75% 45%,100% 100%)}
252
+ :where(.sm-glyph[data-glyph="image-off"])::before{inset:12%;border:2px solid currentColor;border-radius:3px;background:radial-gradient(circle calc(var(--g) * .09) at 35% 35%,currentColor 96%,transparent 100%)}
253
+ :where(.sm-glyph[data-glyph="image-off"])::after{left:50%;top:-10%;bottom:-10%;width:2px;margin-left:-1px;background:currentColor;transform:rotate(-45deg)}
232
254
  :where(.sm-glyph[data-glyph="video"])::before{left:8%;right:28%;top:22%;bottom:22%;border:2px solid currentColor;border-radius:3px}
233
255
  :where(.sm-glyph[data-glyph="video"])::after{right:8%;top:30%;bottom:30%;width:20%;background:currentColor;clip-path:polygon(0 35%,100% 0,100% 100%,0 65%)}
234
256
  :where(.sm-glyph[data-glyph="audio"])::before{inset:15% 12%;background:linear-gradient(currentColor,currentColor) 0 60%/2px 40% no-repeat,linear-gradient(currentColor,currentColor) 33% 50%/2px 80% no-repeat,linear-gradient(currentColor,currentColor) 66% 50%/2px 100% no-repeat,linear-gradient(currentColor,currentColor) 100% 60%/2px 50% no-repeat}
@@ -84,7 +84,7 @@
84
84
  </div>
85
85
 
86
86
  <div class="sm-actions">
87
- <button type="button" class="sm-primary" id="run">Make it</button>
87
+ <button type="button" class="sm-primary" id="run">Make the clip</button>
88
88
  <p class="sm-status" id="status" role="status"></p>
89
89
  </div>
90
90
 
@@ -162,7 +162,8 @@
162
162
  </div>
163
163
  </section>
164
164
 
165
- <!-- ===== 4. Choices as pictures instead of word lists. ===== -->
165
+ <!-- ===== 4. Choices as pictures instead of word lists. The pick is the ink inversion, so it reads at a
166
+ glance; a locked row (disabled, or a view-only person) keeps its pick and fades the rest. ===== -->
166
167
  <section>
167
168
  <h2>Choices as pictures</h2>
168
169
  <div class="ref-frame" style="display:grid;gap:24px">
@@ -179,6 +180,13 @@
179
180
  <label><input type="radio" name="shape"><span>9:16</span></label>
180
181
  <label><input type="radio" name="shape"><span>1:1</span></label>
181
182
  </fieldset>
183
+ <!-- A quiet word in a tile may use the muted tokens: inside the pick they invert with the fill. -->
184
+ <fieldset class="sm-choices" data-compact disabled>
185
+ <legend>Voice (view only)</legend>
186
+ <label><input type="radio" name="voice"><span>Nova</span><small style="color:var(--fg-subtle)">bright</small></label>
187
+ <label><input type="radio" name="voice" checked><span>Onyx</span><small style="color:var(--fg-subtle)">deep</small></label>
188
+ <label><input type="radio" name="voice"><span>Sage</span><small style="color:var(--fg-subtle)">calm</small></label>
189
+ </fieldset>
182
190
  </div>
183
191
  </section>
184
192
 
@@ -194,6 +202,7 @@
194
202
  <span class="sm-glyph" data-glyph="chevron" title="chevron"></span>
195
203
  <span class="sm-glyph" data-glyph="upload" title="upload"></span>
196
204
  <span class="sm-glyph" data-glyph="image" title="image"></span>
205
+ <span class="sm-glyph" data-glyph="image-off" title="image-off"></span>
197
206
  <span class="sm-glyph" data-glyph="video" title="video"></span>
198
207
  <span class="sm-glyph" data-glyph="audio" title="audio"></span>
199
208
  <span class="sm-glyph" data-glyph="text" title="text"></span>
@@ -204,7 +213,8 @@
204
213
  </div>
205
214
  </section>
206
215
 
207
- <!-- ===== 6. Results: show the thing, not a description of it. ===== -->
216
+ <!-- ===== 6. Results: show the thing, not a description of it. A picture that cannot show here is a
217
+ quiet tile with no words; StillMade says why once, as a notification. ===== -->
208
218
  <section>
209
219
  <h2>Gallery</h2>
210
220
  <div class="ref-frame">
@@ -212,7 +222,7 @@
212
222
  <li><span class="sm-swatch swatch-a" style="display:block;aspect-ratio:1"></span></li>
213
223
  <li class="is-selected"><span class="sm-swatch swatch-b" style="display:block;aspect-ratio:1"></span></li>
214
224
  <li><span class="sm-swatch swatch-c" style="display:block;aspect-ratio:1"></span></li>
215
- <li><span class="sm-swatch swatch-d" style="display:block;aspect-ratio:1"></span></li>
225
+ <li><span class="sm-media-off" role="img" aria-label="On another device" title="On another device"><span class="sm-glyph" data-glyph="image-off" aria-hidden="true"></span></span></li>
216
226
  </ul>
217
227
  </div>
218
228
  </section>