stillmade-sdk 0.1.36 → 0.1.38
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.
- package/AGENTS.md +8 -8
- package/IMAGE_DESCRIPTION.md +2 -0
- package/MODULES.md +68 -10
- package/NATIVE_MCP.md +18 -7
- package/PASTE_TO_LLM.md +121 -50
- package/PORTABLE.md +7 -3
- package/README.md +19 -6
- package/REMIX.md +6 -0
- package/SDK.md +226 -103
- package/TRANSCRIPTION.md +4 -2
- package/UI_AESTHETIC.md +2 -0
- package/api-index.json +1 -1
- package/blocks/narration/package.js +1 -1
- package/blocks/narration/versions/1.0.1/package.js +221 -0
- package/blocks/native/sm.animated-scene/versions/2.3.9/package.js +769 -0
- package/blocks/native/sm.board-editor/versions/2.2.11/package.js +551 -0
- package/blocks/native/sm.describe-media/versions/2.1.0/package.js +140 -0
- package/blocks/native/sm.panel-strip/versions/2.4.2/package.js +1316 -0
- package/blocks/native/sm.photo-notes/versions/1.1.0/package.js +170 -0
- package/blocks/native/sm.pipeline/versions/2.2.6/package.js +1095 -0
- package/blocks/screen-recorder/package.js +1 -1
- package/blocks/screen-recorder/versions/1.0.1/package.js +223 -0
- package/package.json +1 -1
- package/packages/block-cli/cli.js +91 -35
- package/packages/block-cli/dev-host/assets/host.js +35 -32
- package/packages/block-cli/dev-host/assets/index.css +1 -1
- package/packages/block-cli/dev-server.js +4 -0
- package/packages/block-cli/ready-gate.js +19 -4
- package/packages/block-cli/templates.js +33 -21
- package/packages/block-platform/block-readiness.js +10 -1
- package/packages/block-platform/catalog.js +14 -3
- package/packages/block-platform/chat-packages.js +6 -0
- package/packages/block-platform/flow-inspection.js +3 -1
- package/packages/block-platform/project-target.js +57 -0
- package/packages/block-platform/project-type-composer.js +4 -3
- package/packages/block-platform/project-types.js +5 -2
- package/packages/block-sdk/admission.js +4 -1
- package/packages/block-sdk/block-tier.js +23 -0
- package/packages/block-sdk/builder-contract.js +12 -10
- package/packages/block-sdk/capabilities.js +23 -10
- package/packages/block-sdk/collaboration-admission.js +3 -0
- package/packages/block-sdk/contracts.js +13 -1
- package/packages/block-sdk/example-media-data.js +16 -0
- package/packages/block-sdk/example-media.js +42 -0
- package/packages/block-sdk/first-party-identities.js +8 -0
- package/packages/block-sdk/first-party-packages.js +11 -1
- package/packages/block-sdk/index.d.ts +6 -2
- package/packages/block-sdk/local-admission.js +6 -5
- package/packages/block-sdk/media-transform.js +59 -3
- package/packages/block-sdk/mock-host.js +23 -2
- package/packages/block-sdk/mocks.js +4 -1
- package/packages/block-sdk/module-hosted.js +12 -2
- package/packages/block-sdk/port-types.d.ts +6 -0
- package/packages/block-sdk/port-types.js +32 -10
- package/packages/block-sdk/shared-agent-controls.js +25 -2
- package/packages/block-sdk/shared-controls.js +17 -3
- package/packages/block-sdk/testing.js +46 -3
- package/packages/block-sdk/view-design.js +67 -0
- package/packages/block-sdk/view-renderer.js +50 -8
- package/packages/design-system/block-kit.css +6 -4
|
@@ -4,13 +4,21 @@ export function installSharedControls(document,api,onError,textControls=null,loc
|
|
|
4
4
|
const bindings=new Map(),TEXT_TYPES=['text','search','email','tel','url'];
|
|
5
5
|
const lockedInputs=new Set(lockedInputKeys);
|
|
6
6
|
const deferred=new Map(),running=new Set(),deferredErrors=new Map();let closed=false;
|
|
7
|
+
// November 1, 2026 (live controller check): Chat set Narration's voice input to nova, and the screen showed Onyx, the
|
|
8
|
+
// fieldset's HTML default, because shared state had no voice yet. A shared field with the name of a Step input starts from
|
|
9
|
+
// that input's value until someone picks one; a value the field cannot show is ignored.
|
|
10
|
+
let inputDefaults={};
|
|
11
|
+
const shows=(radios,node,value)=>radios?radios.some(radio=>radio.value===String(value)):node.tagName!=='SELECT'||[...node.options].some(option=>option.value===String(value));
|
|
7
12
|
const runDeferred=(key,entry)=>{
|
|
8
13
|
clearTimeout(entry.timer);deferred.delete(key);if(closed)return;
|
|
9
14
|
let task;task=Promise.resolve().then(()=>{if(!closed)return entry.callback();}).catch(error=>{deferredErrors.set(key,error);onError(error.message);}).finally(()=>running.delete(task));
|
|
10
15
|
running.add(task);return task;
|
|
11
16
|
};
|
|
12
17
|
const scheduleSharedEdit=(key,callback)=>{
|
|
13
|
-
|
|
18
|
+
// Which part is wrong (November 2026: a Block author read this as being about the callback when its key had a +).
|
|
19
|
+
if(closed)throw Error('This interface has closed; shared edits stopped.');
|
|
20
|
+
if(typeof key!=='string'||!/^[a-zA-Z][a-zA-Z0-9_-]{0,99}$/.test(key))throw Error('A shared edit key starts with a letter and holds only letters, digits, - and _ (up to 100 characters).');
|
|
21
|
+
if(typeof callback!=='function')throw Error('Give scheduleSharedEdit a function to run.');
|
|
14
22
|
if(!deferred.has(key)&&deferred.size+running.size>=32||!deferredErrors.has(key)&&deferredErrors.size>=32)throw Error('Too many scheduled shared edits');
|
|
15
23
|
deferredErrors.delete(key);
|
|
16
24
|
const previous=deferred.get(key),entry={callback,started:previous?.started??Date.now(),timer:null};
|
|
@@ -53,7 +61,7 @@ export function installSharedControls(document,api,onError,textControls=null,loc
|
|
|
53
61
|
const picked=()=>radios.find(radio=>radio.checked)?.value??'';
|
|
54
62
|
const write=value=>{const before=radios?picked():node.type==='checkbox'?node.checked:node.value;if(radios)for(const radio of radios)radio.checked=radio.value===String(value??'');else if(node.type==='checkbox')node.checked=!!value;else if(node.value!==String(value??''))node.value=String(value??'');if((radios?picked():node.type==='checkbox'?node.checked:node.value)!==before)queueMicrotask(()=>{if(!disposed)api.announce?.();});};
|
|
55
63
|
const read=()=>radios?picked():node.type==='checkbox'?node.checked:['number','range'].includes(node.type)?node.valueAsNumber:node.value;
|
|
56
|
-
let fallback=read();
|
|
64
|
+
let fallback=Object.hasOwn(inputDefaults,key)&&shows(radios,node,inputDefaults[key])?inputDefaults[key]:read();
|
|
57
65
|
const flush=()=>{
|
|
58
66
|
clearTimeout(timer);timer=null;if(saving)return inFlight;if(disposed||composing||!dirty||!canEdit||problem)return;
|
|
59
67
|
const value=draft;dirty=false;dirtySince=null;saving=true;let failed=false;
|
|
@@ -115,7 +123,7 @@ export function installSharedControls(document,api,onError,textControls=null,loc
|
|
|
115
123
|
node.addEventListener('input',change);node.addEventListener('change',change);node.addEventListener(leave,flush);
|
|
116
124
|
node.addEventListener('compositionstart',compositionStart);node.addEventListener('compositionend',compositionEnd);
|
|
117
125
|
const dispose=()=>{disposed=true;composing=false;clearTimeout(timer);unsubscribe();node.removeEventListener('input',change);node.removeEventListener('change',change);node.removeEventListener(leave,flush);node.removeEventListener('compositionstart',compositionStart);node.removeEventListener('compositionend',compositionEnd);node.disabled=originallyDisabled;bindings.delete(key);};
|
|
118
|
-
bindings.set(key,{dispose,flush,status(){return {dirty,saving,problem,canEdit,composing,interrupted:retainedComposition!==undefined};},setDefault(value){fallback=value;receive(api.getShared());}});return dispose;
|
|
126
|
+
bindings.set(key,{dispose,flush,status(){return {dirty,saving,problem,canEdit,composing,interrupted:retainedComposition!==undefined};},setDefault(value){fallback=value;receive(api.getShared());},setInputDefault(value){if(shows(radios,node,value))this.setDefault(value);}});return dispose;
|
|
119
127
|
};
|
|
120
128
|
const bindAnnotated=()=>{
|
|
121
129
|
const nodes=[...document.querySelectorAll('[data-stillmade-share]')];
|
|
@@ -155,5 +163,11 @@ export function installSharedControls(document,api,onError,textControls=null,loc
|
|
|
155
163
|
return {bindShared,bindAnnotated,scheduleSharedEdit,flushAll,assertNoComposition,getBindingStatus:key=>bindings.get(key)?.status(),setSharedDefaults(values){
|
|
156
164
|
if(!values||typeof values!=='object'||Array.isArray(values)||Object.keys(values).length>100||JSON.stringify(values).length>65536)throw Error('Use bounded shared control defaults');
|
|
157
165
|
for(const [key,value] of Object.entries(values))bindings.get(key)?.setDefault(value);
|
|
166
|
+
},useInputDefaults(input){
|
|
167
|
+
// Only a default that changed is applied again: the host sends the input often, and re-writing every field from shared
|
|
168
|
+
// state each time hid a view that overwrote other people's edits from the two-participant check.
|
|
169
|
+
const previous=inputDefaults;inputDefaults={};
|
|
170
|
+
if(input&&typeof input==='object'&&!Array.isArray(input))for(const [key,value] of Object.entries(input))if(['string','number','boolean'].includes(typeof value))inputDefaults[key]=value;
|
|
171
|
+
for(const [key,binding] of bindings)if(Object.hasOwn(inputDefaults,key)&&!(Object.hasOwn(previous,key)&&previous[key]===inputDefaults[key]))binding.setInputDefault(inputDefaults[key]);
|
|
158
172
|
},dispose(){closed=true;for(const entry of deferred.values())clearTimeout(entry.timer);deferred.clear();for(const binding of [...bindings.values()])binding.dispose();}};
|
|
159
173
|
}
|
|
@@ -3,6 +3,7 @@ import {requiresDesktopExecution,validateDesktopExecution} from './desktop-execu
|
|
|
3
3
|
import {validatePortableMetadata} from './portable.js';
|
|
4
4
|
import {initialBlockState,validateBlockState} from './state.js';
|
|
5
5
|
import { assert, digest, plain, safeDocument, stableStringify, validateManifest, validateValues } from './contracts.js';
|
|
6
|
+
import {FILE_MEDIA_TYPES,DOCUMENT_FORMATS} from './data-values.js';
|
|
6
7
|
import { validateRecipe } from './recipe.js';
|
|
7
8
|
import { validateJavaScript } from './javascript.js';
|
|
8
9
|
import { runPackageAsync } from './runtime.js';
|
|
@@ -12,6 +13,46 @@ import {CAPABILITY_LIMITS,validateCapability,prepareCapabilityInvocation,validat
|
|
|
12
13
|
import {validateNativeApplicationPackage,NATIVE_APPLICATION_RUNTIME_REQUIREMENT} from './native-application-package.js';
|
|
13
14
|
import {validateModuleDescriptor,MODULE_LIMITS} from './module-runtime.js';
|
|
14
15
|
import {validateMocks} from './mocks.js';
|
|
16
|
+
// October 27, 2026: in-app builds of module Blocks failed three repairs on "output document must be text": the
|
|
17
|
+
// message named neither the fixture nor where the bad value was, so each repair changed the code and kept it. A
|
|
18
|
+
// module fixture whose expected value is the wrong shape now says which fixture and port, and what that type holds.
|
|
19
|
+
const reference=kind=>`{kind:'${kind}', assetId:'…', versionId:'…', url:'…'}, the saved reference exactly as the host returned it`;
|
|
20
|
+
const VALUE_SHAPES=Object.freeze({
|
|
21
|
+
text:'a string',
|
|
22
|
+
file:`{kind:'file', assetId:'…', versionId:'…', mimeType:${FILE_MEDIA_TYPES.map(type=>`'${type}'`).join('|')}, url?, name?, bytes?}`,
|
|
23
|
+
document:`{schemaVersion:1, format:${DOCUMENT_FORMATS.map(format=>`'${format}'`).join('|')}, text:'…', title?:'…'} with no other keys`,
|
|
24
|
+
table:`{columns:['name', …], rows:[{name: text, number, true/false or null}], schemaVersion?:1} with no other keys`,
|
|
25
|
+
image:reference('image'),video:reference('video'),audio:reference('audio'),
|
|
26
|
+
asset:`{assetId:'…', versionId:'…', kind?, url?}, a saved reference exactly as the host returned it`,
|
|
27
|
+
});
|
|
28
|
+
const valueShape=type=>type.endsWith('[]')?VALUE_SHAPES[type.slice(0,-2)]&&`a plain array of ${type.slice(0,-2)} values, each ${VALUE_SHAPES[type.slice(0,-2)]}, with no wrapper objects`:VALUE_SHAPES[type];
|
|
29
|
+
function fixtureExpectedMessage(outputs,fixture,error){
|
|
30
|
+
const name=/^output (.+?) must be /.exec(error.message)?.[1],type=Object.hasOwn(outputs,name??'')?String(outputs[name].type):null;
|
|
31
|
+
if(!type)return `Fixture "${fixture.name}": its expected outputs are wrong, not the code (${error.message}). expected holds exactly the declared output ports.`;
|
|
32
|
+
const shape=valueShape(type);
|
|
33
|
+
return `Fixture "${fixture.name}": expected.${name} is not a valid ${type} value, so the bad value is in this fixture's expected outputs, not the code (${error.message}).${shape?` ${/^[aeiou]/.test(type)?'An':'A'} ${type} value is ${shape}.`:''}`;
|
|
34
|
+
}
|
|
35
|
+
// October 27, 2026: a reference the stand-in host wrote (files.write in a module test) carries fields the host makes
|
|
36
|
+
// (assetId local-written-file-N, versionId, url, bytes) that no model can predict. A module fixture compares it by
|
|
37
|
+
// shape: an expected {kind, mimeType, name}, with those fields left out or given, matches when kind, mimeType and name
|
|
38
|
+
// match. Every other value is compared exactly.
|
|
39
|
+
const WRITTEN_KINDS=['file','image','video','audio'],HOST_MADE=['assetId','versionId','url','bytes'];
|
|
40
|
+
const standInWritten=value=>plain(value)&&/^local-written-(?:file|image|video|audio)-\d+$/.test(String(value.assetId));
|
|
41
|
+
const writtenShape=value=>plain(value)&&WRITTEN_KINDS.includes(value.kind)&&typeof value.mimeType==='string'&&Object.keys(value).every(key=>['kind','mimeType','name',...HOST_MADE].includes(key));
|
|
42
|
+
// expected, with each reference that matches a stand-in-written output by shape replaced by that output.
|
|
43
|
+
function matchWritten(actual,expected){
|
|
44
|
+
if(standInWritten(actual))return writtenShape(expected)&&['kind','mimeType','name'].every(key=>expected[key]===actual[key])?actual:expected;
|
|
45
|
+
if(Array.isArray(actual)&&Array.isArray(expected))return expected.map((value,index)=>matchWritten(actual[index],value));
|
|
46
|
+
if(plain(actual)&&plain(expected))return Object.fromEntries(Object.entries(expected).map(([key,value])=>[key,matchWritten(actual[key],value)]));
|
|
47
|
+
return expected;
|
|
48
|
+
}
|
|
49
|
+
// A module fixture's expected outputs for the type check: a file or media reference given by shape gets stand-in values
|
|
50
|
+
// for the host-made fields it leaves out.
|
|
51
|
+
function withStandInHostFields(outputs,expected){
|
|
52
|
+
if(!plain(outputs)||!plain(expected))return expected;
|
|
53
|
+
const fill=(value,type)=>type.endsWith('[]')?(Array.isArray(value)?value.map(item=>fill(item,type.slice(0,-2))):value):WRITTEN_KINDS.includes(type)&&value?.kind===type&&writtenShape(value)?{assetId:`local-written-${type}-1`,versionId:'v1',...value}:value;
|
|
54
|
+
return Object.fromEntries(Object.entries(expected).map(([name,value])=>[name,Object.hasOwn(outputs,name)?fill(value,String(outputs[name]?.type)):value]));
|
|
55
|
+
}
|
|
15
56
|
export function validatePackage(pkg) {
|
|
16
57
|
safeDocument(pkg);
|
|
17
58
|
if(pkg?.manifest?.runtime==='native-application')return validateNativeApplicationPackage(pkg);
|
|
@@ -54,7 +95,8 @@ export function validatePackage(pkg) {
|
|
|
54
95
|
// hosted calls, actions and transforms it makes then cost nothing and give the same result each run.
|
|
55
96
|
if(pkg.manifest.runtime === 'module')for(const fixture of pkg.tests){
|
|
56
97
|
assert(fixture&&typeof fixture.name==='string'&&fixture.name.trim().length>0&&fixture.name.length<=200&&Object.keys(fixture).every(key=>['name','input','expected','mocks'].includes(key)),'TEST','Module fixtures need a name, input and the exact expected outputs, and may record mocks');
|
|
57
|
-
validateValues(pkg.manifest.inputs,fixture.input);
|
|
98
|
+
validateValues(pkg.manifest.inputs,fixture.input);
|
|
99
|
+
try{validateValues(pkg.manifest.outputs,withStandInHostFields(pkg.manifest.outputs,fixture.expected),'output');}catch(error){assert(false,error.code||'TYPE',fixtureExpectedMessage(pkg.manifest.outputs,fixture,error));}
|
|
58
100
|
if(fixture.mocks!==undefined)validateMocks(fixture.mocks);
|
|
59
101
|
}
|
|
60
102
|
if(pkg.manifest.runtime === 'capability'){
|
|
@@ -97,10 +139,11 @@ export async function testPackage(pkg, { timeoutMs = 10000, wasmLocation, comfyu
|
|
|
97
139
|
if(pkg.manifest.runtime === 'comfyui')testComfyOutputs(pkg.manifest,outputs,test.expectations,{metadata});
|
|
98
140
|
else if(pkg.manifest.runtime === 'capability')testCapabilityOutputs(pkg.manifest,outputs,test.expectations);
|
|
99
141
|
else {
|
|
100
|
-
const
|
|
142
|
+
const wanted=pkg.manifest.runtime==='module'?matchWritten(outputs,test.expected):test.expected;
|
|
143
|
+
const actual=stableStringify(outputs),expected=stableStringify(wanted);
|
|
101
144
|
// Each output that differs, with both values, so a long output (a transcript) never hides the
|
|
102
145
|
// one that differs; a module's failed host calls (in its log) say why (October 22, 2026).
|
|
103
|
-
const differing=[...new Set([...Object.keys(test.expected||{}),...Object.keys(outputs||{})])].filter(key=>stableStringify(outputs?.[key])!==stableStringify(
|
|
146
|
+
const differing=[...new Set([...Object.keys(test.expected||{}),...Object.keys(outputs||{})])].filter(key=>stableStringify(outputs?.[key])!==stableStringify(wanted?.[key]));
|
|
104
147
|
const failedCalls=(logs||[]).filter(line=>/ failed: /.test(line)).slice(0,3);
|
|
105
148
|
assert(actual===expected,'BEHAVIOR',`Output differs from expected fixture${differing.length?` in ${differing.join(', ')}`:''}. ${differing.length?differing.slice(0,4).map(key=>`${key}: expected ${String(stableStringify(test.expected?.[key])).slice(0,600)}; received ${String(stableStringify(outputs?.[key])).slice(0,600)}`).join('. '):`Expected ${expected.slice(0,1200)}; received ${actual.slice(0,1200)}`}${failedCalls.length?`. Host calls that failed: ${failedCalls.join('; ')}`:''}`);
|
|
106
149
|
}
|
|
@@ -42,6 +42,7 @@ export const BLOCK_INTERFACE_PRINCIPLES=Object.freeze([
|
|
|
42
42
|
'Status sits beside the action it describes (`sm-status`), in a few words.',
|
|
43
43
|
'Keep the page in normal scroll flow; no sticky media or control strips unless the editing task needs them.',
|
|
44
44
|
'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.',
|
|
45
46
|
'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.',
|
|
46
47
|
'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.',
|
|
47
48
|
'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`).',
|
|
@@ -135,6 +136,58 @@ function topLevelRows(html){
|
|
|
135
136
|
return rows;
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
// November 1, 2026 (owner, zoom plan): a screen held to a narrow width leaves most of a wide Step empty, and StillMade's
|
|
140
|
+
// zoom cannot fill it (it stops before the kit's two-column layout from 860px would change). The narrowest fixed or
|
|
141
|
+
// maximum width on what holds the whole screen (html, body, the outermost element, a lone wrapper inside it), from its
|
|
142
|
+
// inline style or a stylesheet rule outside a small-screen @media block; null when nothing holds it.
|
|
143
|
+
const KIT_TWO_COLUMNS=860;
|
|
144
|
+
function narrowScreen(view){
|
|
145
|
+
const html=String(view?.html||''),lower=html.toLowerCase(),holders=[{tag:'html',classes:[],id:''},{tag:'body',classes:[],id:''}];
|
|
146
|
+
const width=declarations=>{let narrowest=null;for(const match of String(declarations).matchAll(/(?:^|[;{\s])(?:max-width|width)\s*:\s*(\d{1,5}(?:\.\d+)?)(px|rem|em)\b/gi)){const px=Number(match[1])*(match[2].toLowerCase()==='px'?1:16);if(px>=200&&px<KIT_TWO_COLUMNS)narrowest=Math.min(narrowest??Infinity,px);}return narrowest;};
|
|
147
|
+
// The outermost elements, and the children of a lone outermost one; a lone child is a wrapper too.
|
|
148
|
+
// The last '>' found is reused while it is still ahead, and attributes are read from a bounded slice, so broken tags
|
|
149
|
+
// ("<a<a<a…>") stay linear.
|
|
150
|
+
const top=[],inner=[];let depth=0,end=-1;
|
|
151
|
+
for(let at=lower.indexOf('<');at!==-1;at=lower.indexOf('<',at+1)){
|
|
152
|
+
if(end<at)end=lower.indexOf('>',at);if(end===-1)break;
|
|
153
|
+
const tag=/^<(\/?)([a-z][a-z0-9-]*)/.exec(lower.slice(at,Math.min(end+1,at+40)));
|
|
154
|
+
if(!tag)continue;
|
|
155
|
+
const [,closing,name]=tag;
|
|
156
|
+
if(closing){depth=Math.max(0,depth-1);if(depth===0&&top.length>1)break;continue;}
|
|
157
|
+
if(['script','style','template'].includes(name)){const close=lower.indexOf(`</${name}`,end);if(close===-1)break;at=close;continue;}
|
|
158
|
+
const attributes=html.slice(at,Math.min(end,at+2000)),element={tag:name,classes:(/\sclass\s*=\s*["']([^"']*)["']/i.exec(attributes)?.[1]||'').split(/\s+/).filter(Boolean),id:/\sid\s*=\s*["']([^"']*)["']/i.exec(attributes)?.[1]||'',style:/\sstyle\s*=\s*["']([^"']*)["']/i.exec(attributes)?.[1]||''};
|
|
159
|
+
if(depth===0)top.push(element);else if(depth===1&&top.length===1)inner.push(element);
|
|
160
|
+
if(!VOID_TAGS.has(name)&&lower[end-1]!=='/')depth++;
|
|
161
|
+
}
|
|
162
|
+
holders.push(...top.filter(()=>top.length===1),...(inner.length===1?inner:[]));
|
|
163
|
+
for(const element of holders.slice(2)){const px=width(element.style);if(px)return {px,where:`the style on <${element.tag}>`};}
|
|
164
|
+
const holds=selector=>{
|
|
165
|
+
const last=selector.trim().split(/[\s>+~]+/).pop().replace(/^:root$/i,'html'),parts=/^([a-z][a-z0-9-]*|\*)?((?:[.#][\w-]+)*)$/i.exec(last);
|
|
166
|
+
if(!parts||!parts[1]&&!parts[2])return false;
|
|
167
|
+
const classes=[...parts[2].matchAll(/\.([\w-]+)/g)].map(match=>match[1]),id=/#([\w-]+)/.exec(parts[2])?.[1]||'';
|
|
168
|
+
return holders.some(element=>(!parts[1]||parts[1]==='*'||parts[1].toLowerCase()===element.tag)&&classes.every(name=>element.classes.includes(name))&&(!id||id===element.id));
|
|
169
|
+
};
|
|
170
|
+
// One pass over the stylesheet, comments skipped: each rule with the @media blocks around it.
|
|
171
|
+
const css=String(view?.css||''),around=[];let prelude='',narrowest=null;
|
|
172
|
+
for(let index=0;index<css.length;index++){
|
|
173
|
+
const char=css[index];
|
|
174
|
+
if(char==='/'&&css[index+1]==='*'){const close=css.indexOf('*/',index+2);if(close===-1)break;index=close+1;continue;}
|
|
175
|
+
if(char==='{'){
|
|
176
|
+
const head=prelude.trim();prelude='';
|
|
177
|
+
if(head.startsWith('@')){around.push(head);continue;}
|
|
178
|
+
const close=css.indexOf('}',index);if(close===-1)break;
|
|
179
|
+
const smallOnly=around.some(rule=>/^@media\b/i.test(rule)&&/max-width/i.test(rule));
|
|
180
|
+
const px=!smallOnly&&head.split(',').some(holds)?width(css.slice(index+1,close)):null;
|
|
181
|
+
if(px&&(!narrowest||px<narrowest.px))narrowest={px,where:head.slice(0,60)};
|
|
182
|
+
index=close;continue;
|
|
183
|
+
}
|
|
184
|
+
if(char==='}'){around.pop();prelude='';continue;}
|
|
185
|
+
if(char===';'&&!around.length){prelude='';continue;}
|
|
186
|
+
prelude+=char;
|
|
187
|
+
}
|
|
188
|
+
return narrowest;
|
|
189
|
+
}
|
|
190
|
+
|
|
138
191
|
export function reviewViewDesign(view){
|
|
139
192
|
if(!view)return {passed:true,issues:[],warnings:[],principles:BLOCK_INTERFACE_PRINCIPLES};
|
|
140
193
|
const html=view.html||'',javascript=view.javascript||'',primary=html.replace(/<details\b[\s\S]*?<\/details>/gi,'');
|
|
@@ -157,10 +210,24 @@ export function reviewViewDesign(view){
|
|
|
157
210
|
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.`);
|
|
158
211
|
const rows=topLevelRows(primary);
|
|
159
212
|
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);
|
|
214
|
+
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.`);
|
|
160
215
|
warnings.push(...blockKitProblems(view).map(problem=>problem.message));
|
|
161
216
|
return {passed:issues.length===0,issues,warnings,principles:BLOCK_INTERFACE_PRINCIPLES};
|
|
162
217
|
}
|
|
163
218
|
|
|
219
|
+
// October 30, 2026: Chat presses a button on a Block's own screen only when it carries data-stillmade-action
|
|
220
|
+
// (naming what it does); an unmarked button is out of Chat's reach. The number of buttons in view.html without one.
|
|
221
|
+
export function unmarkedButtons(view){
|
|
222
|
+
const html=String(view?.html||''),lower=html.toLowerCase();let count=0;
|
|
223
|
+
for(let start=lower.indexOf('<button');start!==-1;start=lower.indexOf('<button',start+7)){
|
|
224
|
+
if(!/[\s>/]/.test(lower[start+7]||''))continue;
|
|
225
|
+
const end=lower.indexOf('>',start);if(end===-1)break;
|
|
226
|
+
if(!/\sdata-stillmade-action\s*=/.test(lower.slice(start,end)))count++;
|
|
227
|
+
}
|
|
228
|
+
return count;
|
|
229
|
+
}
|
|
230
|
+
|
|
164
231
|
// What a NEW Block's interface must get right. Each problem is {rule, message}.
|
|
165
232
|
// The builder contract's DOD-15 (the SDK's `ready`, StillMade's builder) fails
|
|
166
233
|
// on 'action', 'price' and 'heading'; StillMade's own builder also repairs
|
|
@@ -6,6 +6,38 @@ export function installViewRenderer(document,onError=()=>{},onControlsChanged=()
|
|
|
6
6
|
const listeners=new Set();
|
|
7
7
|
const actions=new WeakMap(),compositionState=new WeakMap(),compositions=new Map(),conflicts=new Map();let disposed=false,revision=0,sequence=0,dispatching=false;const roots=new Set();
|
|
8
8
|
const notify=()=>{try{onControlsChanged();}catch{}};
|
|
9
|
+
// October 30, 2026: Chat also reads and presses a view's plain HTML controls: a button marked
|
|
10
|
+
// data-stillmade-action, and a labelled input, select or textarea. Shared fields (data-stillmade-share, reached
|
|
11
|
+
// as shared fields), device-only controls (data-stillmade-local) and file, password and hidden inputs are never
|
|
12
|
+
// offered. Pressing one clicks it, and setting one sends its input and change events, as a person's use does;
|
|
13
|
+
// whatever that starts keeps its own approval (an assisted run still opens its cost review).
|
|
14
|
+
const later=globalThis.setTimeout?.bind(globalThis),cancel=globalThis.clearTimeout?.bind(globalThis),Observer=later&&cancel?document.defaultView?.MutationObserver:null;
|
|
15
|
+
const SKIPPED_TYPES=new Set(['file','password','hidden','submit','reset','image','button']);
|
|
16
|
+
const ownText=node=>{let value='';for(const child of node.childNodes)if(child.nodeType===3)value+=child.nodeValue;else if(child.nodeType===1&&!child.matches('input,select,textarea,button'))value+=' '+child.textContent;return value;};
|
|
17
|
+
const plainLabel=node=>{
|
|
18
|
+
let value=node.getAttribute('aria-label')||'';const by=node.getAttribute('aria-labelledby');
|
|
19
|
+
if(!value&&by)value=by.split(/\s+/).map(id=>document.getElementById(id)?.textContent||'').join(' ');
|
|
20
|
+
if(!value&&node.labels?.length)value=ownText(node.labels[0]);
|
|
21
|
+
if(!value.trim()&&node.tagName==='BUTTON')value=node.textContent||'';
|
|
22
|
+
return String(value.trim()?value:node.getAttribute('title')||'').replace(/\s+/g,' ').trim().slice(0,240);
|
|
23
|
+
};
|
|
24
|
+
const plainNode=node=>{
|
|
25
|
+
const meta=actions.get(node);if(meta&&!meta.plain)return false;
|
|
26
|
+
if(node.closest('[data-stillmade-share],[data-stillmade-local]'))return false;
|
|
27
|
+
if(node.tagName==='BUTTON')return node.hasAttribute('data-stillmade-action');
|
|
28
|
+
if(node.tagName==='INPUT'&&SKIPPED_TYPES.has(node.type))return false;
|
|
29
|
+
return node.hasAttribute('data-stillmade-action')||!!plainLabel(node);
|
|
30
|
+
};
|
|
31
|
+
const plainControls=()=>{
|
|
32
|
+
const out=[];
|
|
33
|
+
for(const node of document.querySelectorAll('button,input,select,textarea')){
|
|
34
|
+
if(out.length>=200)break;if(!plainNode(node))continue;
|
|
35
|
+
if(!actions.has(node))actions.set(node,{plain:true,event:node.tagName==='BUTTON'?'click':node.tagName==='SELECT'||['checkbox','radio'].includes(node.type)?'change':'input',root:document.documentElement,handle:'view-control-'+String(++sequence)});
|
|
36
|
+
out.push(node);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
const actionOf=node=>{const meta=actions.get(node);return meta.plain?String(node.getAttribute('data-stillmade-action')||node.id||node.getAttribute('name')||plainLabel(node)).slice(0,120):meta.action;};
|
|
9
41
|
const check=(ok,message)=>{if(!ok)throw Error(message);};
|
|
10
42
|
const text=(value,max=10000)=>{check(typeof value==='string'&&value.length<=max,'Dynamic control text exceeds its limit');return value;};
|
|
11
43
|
const textControl=node=>node.tagName==='TEXTAREA'||node.tagName==='INPUT'&&node.type==='text';
|
|
@@ -129,28 +161,38 @@ export function installViewRenderer(document,onError=()=>{},onControlsChanged=()
|
|
|
129
161
|
if(conflict)throw Error(compositionState.get(conflict[0]).problem);
|
|
130
162
|
check(![...compositions].some(([node,root])=>root.contains(node)&&node.isConnected&&!node.disabled),'Finish composing text before continuing.');
|
|
131
163
|
};
|
|
132
|
-
const currentControls=()=>{const seen=new Set(),out=[];for(const root of roots)if(root.isConnected)for(const node of root.querySelectorAll('button,input,textarea,select')){const meta=actions.get(node);if(meta?.action&&meta.root===root&&!seen.has(node)){seen.add(node);out.push(node);}}return out;};
|
|
164
|
+
const currentControls=()=>{const seen=new Set(),out=[];for(const root of roots)if(root.isConnected)for(const node of root.querySelectorAll('button,input,textarea,select')){const meta=actions.get(node);if(meta?.action&&meta.root===root&&!seen.has(node)){seen.add(node);out.push(node);}}for(const node of plainControls())if(!seen.has(node)){seen.add(node);out.push(node);}return out;};
|
|
133
165
|
const hidden=node=>{for(let current=node;current;current=current.parentElement){if(current.hidden)return true;const style=document.defaultView?.getComputedStyle(current);if(style?.display==='none'||['hidden','collapse'].includes(style?.visibility)||style?.opacity==='0')return true;}return false;};
|
|
134
|
-
const label=node=>String(node.getAttribute('aria-label')||node.textContent||node.placeholder||'Control').slice(0,240);
|
|
166
|
+
const label=node=>actions.get(node)?.plain?plainLabel(node)||actionOf(node)||'Control':String(node.getAttribute('aria-label')||node.textContent||node.placeholder||'Control').slice(0,240);
|
|
135
167
|
const choices=node=>node.tagName==='SELECT'?Array.from(node.options).map(option=>({value:option.value,label:option.textContent})):null;
|
|
136
|
-
const identity=node=>{const meta=actions.get(node);return JSON.stringify({action:
|
|
137
|
-
const lockControls=locked=>{for(const node of currentControls())node.disabled=locked||actions.get(node).declaredDisabled;};
|
|
138
|
-
|
|
168
|
+
const identity=node=>{const meta=actions.get(node);return JSON.stringify({action:actionOf(node),event:meta.event,root:meta.root.id,tag:node.tagName,type:node.type,value:node.value,checked:node.checked,disabled:node.disabled,hidden:hidden(node),label:label(node),choices:choices(node)});};
|
|
169
|
+
const lockControls=locked=>{for(const node of currentControls())if(!actions.get(node).plain)node.disabled=locked||actions.get(node).declaredDisabled;};
|
|
170
|
+
// A plain control appearing, going or changing its state is a new control list; checked at most every 250 ms.
|
|
171
|
+
let plainSignature=null,plainTimer=null;
|
|
172
|
+
const plainState=()=>plainControls().map(node=>actions.get(node).handle+(node.disabled?':off':'')+(hidden(node)?':hidden':'')).join(',');
|
|
173
|
+
const plainChanged=()=>{plainTimer=null;if(disposed||plainSignature===null)return;let next;try{next=plainState();}catch{return;}if(next!==plainSignature){plainSignature=next;revision++;notify();}};
|
|
174
|
+
const observer=Observer?new Observer(()=>{if(plainTimer===null&&!disposed)plainTimer=later(plainChanged,250);}):null;
|
|
175
|
+
observer?.observe(document.documentElement,{subtree:true,childList:true,attributes:true,attributeFilter:['disabled','hidden','class','data-stillmade-action','aria-label']});
|
|
176
|
+
const inspectControls=({offset=0,limit=20}={})=>{check(!disposed,'Dynamic controls are closed');check(Number.isSafeInteger(offset)&&offset>=0&&Number.isSafeInteger(limit)&&limit>=1&&limit<=20,'Choose a bounded control page');const nodes=currentControls();plainSignature=plainState();check(offset<=nodes.length,'Choose a current control page');return {revision,total:nodes.length,offset,nextOffset:offset+limit<nodes.length?offset+limit:null,controls:nodes.slice(offset,offset+limit).map(node=>{const meta=actions.get(node),value=String(node.value??'');return {id:meta.handle,action:actionOf(node),label:label(node),tag:node.tagName.toLowerCase(),type:node.type,event:meta.event,disabled:node.disabled,hidden:hidden(node),value:value.slice(0,240),valuePartial:value.length>240,...(typeof node.checked==='boolean'?{checked:node.checked}:{}),...(node.tagName==='SELECT'?{choices:choices(node).slice(0,40),choicesPartial:node.options.length>40}:{}),identity:identity(node)};})};};
|
|
139
177
|
const dispatchControl=async(args,settle=async()=>{})=>{
|
|
140
178
|
check(!dispatching,'A declared control is already running');assertCommitted();
|
|
141
179
|
check(args&&typeof args==='object'&&!Array.isArray(args)&&Object.keys(args).every(key=>['id','revision','identity','value','checked'].includes(key))&&typeof args.id==='string'&&args.revision===revision&&typeof args.identity==='string','The declared control changed');
|
|
142
180
|
const node=currentControls().find(node=>actions.get(node).handle===args.id),meta=node&&actions.get(node);
|
|
143
|
-
check(node&&identity(node)===args.identity,'The declared control changed');check(!node.disabled&&!hidden(node),'This declared control is unavailable');check(listeners.size>0,'This declared control has no native handler');
|
|
181
|
+
check(node&&identity(node)===args.identity,'The declared control changed');check(!node.disabled&&!hidden(node),'This declared control is unavailable');check(meta.plain||listeners.size>0,'This declared control has no native handler');
|
|
144
182
|
if(node.tagName==='BUTTON')check(!Object.hasOwn(args,'value')&&!Object.hasOwn(args,'checked'),'Buttons take no input value');
|
|
145
183
|
else if(['checkbox','radio'].includes(node.type)){check(typeof args.checked==='boolean'&&!Object.hasOwn(args,'value'),'Choose a checkbox value');node.checked=args.checked;}
|
|
146
184
|
else{check((typeof args.value==='string'&&args.value.length<=10000||typeof args.value==='number'&&Number.isFinite(args.value))&&!Object.hasOwn(args,'checked'),'Choose a bounded control value');if(node.tagName==='SELECT')check(Array.from(node.options).some(option=>option.value===String(args.value)),'Choose a declared option');node.value=String(args.value);}
|
|
147
185
|
const payload={action:meta.action,value:node.value,checked:node.checked,event:meta.event};
|
|
148
186
|
dispatching=true;lockControls(true);
|
|
187
|
+
if(meta.plain)try{if(node.tagName==='BUTTON')node.click();else for(const type of ['input','change'])node.dispatchEvent(new document.defaultView.Event(type,{bubbles:true}));check(!disposed,'Dynamic controls are closed');await settle();check(!disposed,'Dynamic controls are closed');return {settled:true};}
|
|
188
|
+
finally{dispatching=false;lockControls(false);notify();}
|
|
149
189
|
try{const pending=[];for(const listener of listeners){try{pending.push(Promise.resolve(listener({...payload})));}catch(error){pending.push(Promise.reject(error));}}const results=await Promise.allSettled(pending);check(!disposed,'Dynamic controls are closed');await settle();check(!disposed,'Dynamic controls are closed');const failure=results.find(result=>result.status==='rejected');if(failure)throw failure.reason;return {settled:true};}
|
|
150
190
|
finally{dispatching=false;lockControls(false);notify();}
|
|
151
191
|
};
|
|
152
|
-
|
|
192
|
+
// Where a declared control sits in this document, for a host that points at it.
|
|
193
|
+
const locateControl=id=>{check(!disposed,'Dynamic controls are closed');const node=currentControls().find(node=>actions.get(node).handle===id);if(!node||hidden(node))return null;const rect=node.getBoundingClientRect();return {x:rect.left,y:rect.top,width:rect.width,height:rect.height};};
|
|
194
|
+
return Object.freeze({render,inspectControls,dispatchControl,locateControl,onAction(listener){check(typeof listener==='function'&&listeners.size<16,'Register at most 16 action listeners');listeners.add(listener);return()=>listeners.delete(listener);},assertCommitted,async flushAll(){
|
|
153
195
|
assertCommitted();
|
|
154
196
|
return {queued:true};
|
|
155
|
-
},dispose(){disposed=true;roots.clear();for(const node of compositions.keys())compositionState.get(node).cancelled=true;compositions.clear();conflicts.clear();listeners.clear();}});
|
|
197
|
+
},dispose(){disposed=true;observer?.disconnect();if(plainTimer!==null)cancel?.(plainTimer);roots.clear();for(const node of compositions.keys())compositionState.get(node).cancelled=true;compositions.clear();conflicts.clear();listeners.clear();}});
|
|
156
198
|
}
|
|
@@ -101,10 +101,12 @@
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/* ---------- Choices as pictures: replaces word dropdowns and radio lists ---------- */
|
|
104
|
+
/* Captions never go under 12 px, the readable size the platform's phone check asks of every Block (October 27, 2026: a
|
|
105
|
+
generated choice read 11.4 px under a 14 px root and failed every Block that has one). */
|
|
104
106
|
:where(.sm-choices){display:grid;gap:var(--s-2,8px);grid-template-columns:repeat(auto-fill,minmax(var(--sm-tile,96px),1fr));margin:0;padding:0;border:0;min-width:0}
|
|
105
107
|
:where(.sm-choices[data-size="wide"]){--sm-tile:160px}
|
|
106
108
|
:where(.sm-choices) > :where(legend){padding:0;margin-bottom:6px;font-size:var(--fs-body-s,.875rem);color:var(--fg-muted)}
|
|
107
|
-
: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:var(--fs-caption,.8125rem);text-align:center;cursor:pointer}
|
|
109
|
+
: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}
|
|
108
110
|
:where(.sm-choices) > :where(label):hover{border-color:var(--border-strong);color:var(--fg)}
|
|
109
111
|
:where(.sm-choices) :where(input[type="radio"],input[type="checkbox"]){position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
|
|
110
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)}
|
|
@@ -135,7 +137,7 @@
|
|
|
135
137
|
:where(.sm-settings){display:grid;gap:var(--s-3,12px) var(--s-4,16px);grid-template-columns:repeat(auto-fill,minmax(min(100%,11rem),1fr));align-items:start;min-width:0}
|
|
136
138
|
:where(.sm-settings) > :where(.sm-field){grid-template-columns:minmax(0,1fr);gap:4px}
|
|
137
139
|
:where(.sm-settings) > :where(.sm-field:not(:has(textarea,.sm-choices,.sm-gallery,.sm-list))){--sm-control:6rem;display:flex;flex-wrap:wrap;align-items:center;gap:4px var(--s-2,8px)}
|
|
138
|
-
:where(.sm-settings) > :where(.sm-choices) > :where(legend),:where(.sm-settings) :where(.sm-label){font-size:var(--fs-caption,.8125rem)}
|
|
140
|
+
:where(.sm-settings) > :where(.sm-choices) > :where(legend),:where(.sm-settings) :where(.sm-label){font-size:max(12px,var(--fs-caption,.8125rem))}
|
|
139
141
|
/* On a phone, a row of chips or segments scrolls sideways instead of wrapping. */
|
|
140
142
|
@container (max-width:519px){
|
|
141
143
|
:where(.sm-choices[data-compact],.sm-choices[data-segmented]){display:flex;flex-wrap:nowrap;width:auto;overflow-x:auto;scrollbar-width:none}
|
|
@@ -157,7 +159,7 @@
|
|
|
157
159
|
:where(.sm-list) > :where(li) > :where(details) > :where(:not(summary)){padding:var(--s-2,8px) var(--s-3,12px) var(--s-3,12px)}
|
|
158
160
|
:where(.sm-list) :where(img,video,canvas,.sm-swatch){flex:none;width:40px;height:26px;object-fit:cover;border-radius:var(--r-2,4px);background:var(--bg-inset)}
|
|
159
161
|
:where(.sm-list) :where(.sm-line){flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
160
|
-
:where(.sm-list) :where(small){flex:none;color:var(--fg-subtle);font-size:var(--fs-caption,.8125rem);white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
162
|
+
:where(.sm-list) :where(small){flex:none;color:var(--fg-subtle);font-size:max(12px,var(--fs-caption,.8125rem));white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
161
163
|
:where(.sm-list) > :where(.sm-list-more){display:none}
|
|
162
164
|
@container (max-width:519px){
|
|
163
165
|
:where(.sm-list[data-peek]:not([data-open])) > :where(li:nth-child(n+4):not(.sm-list-more)){display:none}
|
|
@@ -168,7 +170,7 @@
|
|
|
168
170
|
:where(.sm-gallery){display:grid;gap:var(--s-2,8px);grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--sm-tile,140px)),1fr));margin:0;padding:0;list-style:none}
|
|
169
171
|
:where(.sm-gallery) > :where(*){position:relative;margin:0;border-radius:var(--r-3,8px);overflow:hidden;background:var(--bg-inset)}
|
|
170
172
|
:where(.sm-gallery) :where(img,video,canvas){display:block;width:100%;aspect-ratio:var(--sm-tile-ratio,1);object-fit:cover}
|
|
171
|
-
:where(.sm-gallery) :where(figcaption){padding:6px 8px;font-size:var(--fs-caption,.8125rem);color:var(--fg-muted)}
|
|
173
|
+
:where(.sm-gallery) :where(figcaption){padding:6px 8px;font-size:max(12px,var(--fs-caption,.8125rem));color:var(--fg-muted)}
|
|
172
174
|
:where(.sm-gallery) :where([aria-selected="true"],.is-selected){box-shadow:inset 0 0 0 2px var(--fg)}
|
|
173
175
|
/* Small tiles: more per row as the screen widens, never bigger tiles. */
|
|
174
176
|
:where(.sm-gallery[data-size="small"]){--sm-tile:92px;--sm-tile-ratio:3/2}
|