stillmade-sdk 0.1.39 → 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.
Files changed (37) hide show
  1. package/AGENTS.md +10 -2
  2. package/FRAME_VIEWS.md +41 -0
  3. package/INTERFACE_DESIGN.md +95 -8
  4. package/MOBILE_UI.md +3 -1
  5. package/PASTE_TO_LLM.md +14 -5
  6. package/SDK.md +172 -22
  7. package/UI_AESTHETIC.md +86 -4
  8. package/package.json +1 -1
  9. package/packages/block-cli/dev-host/assets/host.js +203 -37
  10. package/packages/block-cli/dev-host/assets/index.css +1 -1
  11. package/packages/block-platform/block-readiness.js +22 -2
  12. package/packages/block-platform/canvas.js +17 -0
  13. package/packages/block-platform/catalog.js +21 -6
  14. package/packages/block-platform/dealer-inventory-video-project-type.js +10 -5
  15. package/packages/block-platform/fixture-starters.js +13 -0
  16. package/packages/block-platform/host-release-manifests.js +95 -0
  17. package/packages/block-platform/import-eligibility.js +10 -1
  18. package/packages/block-platform/native-host-inventory.js +29 -4
  19. package/packages/block-platform/node-defaults.js +1 -0
  20. package/packages/block-platform/project-context.js +1 -1
  21. package/packages/block-platform/project-type-starters.js +13 -16
  22. package/packages/block-platform/revenue-review-project-types.js +14 -11
  23. package/packages/block-platform/revenue-review-starters.js +13 -0
  24. package/packages/block-platform/script-handoff.js +1 -1
  25. package/packages/block-platform/step-groups.js +3 -1
  26. package/packages/block-platform/type-preview.js +16 -3
  27. package/packages/block-platform/type-testing.js +3 -2
  28. package/packages/block-sdk/builder-contract.js +19 -4
  29. package/packages/block-sdk/contracts.js +3 -1
  30. package/packages/block-sdk/index.d.ts +3 -3
  31. package/packages/block-sdk/media-import-formats.js +2 -0
  32. package/packages/block-sdk/platform-checks.js +1 -1
  33. package/packages/block-sdk/view-design.js +92 -17
  34. package/packages/block-sdk/workspace-navigation.js +6 -2
  35. package/packages/design-system/block-kit.css +28 -6
  36. package/packages/design-system/block-kit.html +14 -4
  37. package/packages/design-system/theme.css +6 -5
@@ -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>
@@ -229,13 +229,14 @@
229
229
  }
230
230
 
231
231
 
232
- /* White — a truly white page and cards with hairline borders; the sidebar stays black
233
- (owner, October 20, 2026: the gray page read off, not white). */
232
+ /* White — a truly white page; the sidebar stays black (owner, October 20, 2026: the gray page read off, not white).
233
+ Panels sit one tone below the page and insets one more, so each layer differs from what it sits on, as cream does
234
+ in Light (owner, November 1, 2026: White read flat, with page, panels and covers all #FFFFFF). */
234
235
  [data-mode="white"] {
235
236
  color-scheme: light;
236
- --bg: #FFFFFF; --bg-elev-1: #FFFFFF; --bg-elev-2: #F5F5F5; --bg-inset: #FAFAFA;
237
+ --bg: #FFFFFF; --bg-elev-1: #F4F4F1; --bg-elev-2: #EAEAE6; --bg-inset: #EEEEEA;
237
238
  --fg: #0E0E0E; --fg-muted: #5C5C5C; --fg-subtle: #767676; --fg-on-accent: #FFFFFF;
238
- --border-tok: #E4E4E4; --border-strong: #C9C9C9; --divider: #EDEDED;
239
+ --border-tok: #E1E1DD; --border-strong: #C4C4BF; --divider: #E7E7E3;
239
240
  --hover-wash: rgba(0,0,0,.05); --press-wash: rgba(0,0,0,.09);
240
241
  --selection-bg: #0E0E0E; --selection-fg: #FFFFFF;
241
242
  --signal: #8E6B46; --signal-dim: rgba(142,107,70,.10); --signal-glow: rgba(142,107,70,.22);
@@ -243,7 +244,7 @@
243
244
  --scrim-top: linear-gradient(to bottom,rgba(255,255,255,.9),rgba(255,255,255,0));
244
245
  --scrim-bottom: linear-gradient(to top,rgba(255,255,255,.9),rgba(255,255,255,0));
245
246
  --bg-primary: var(--bg); --bg-secondary: var(--bg-elev-1); --bg-tertiary: var(--bg-elev-2);
246
- --bg-elevated: var(--bg-elev-1); --bg-hover: #F2F2F2;
247
+ --bg-elevated: var(--bg-elev-1); --bg-hover: #ECECE8;
247
248
  --border: var(--border-tok); --border-light: var(--border-strong);
248
249
  --accent: var(--fg); --accent-hover: #2A2A2A; --accent-dim: var(--hover-wash);
249
250
  --accent-glow: rgba(0,0,0,.12); --accent-contrast: var(--bg);