unroll / lib /apispec.js
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
16.6 kB
// The public page API (window.unroll): one table that drives argument checking in the page,
// the generated api.json, and the docs. Pure data, no DOM. Version it with API_VERSION.
export const API_VERSION = '1.0.0';
const num = (name, desc, extra = {}) => ({ name, type: 'number', desc, ...extra });
const str = (name, desc, extra = {}) => ({ name, type: 'string', desc, ...extra });
const bool = (name, desc, extra = {}) => ({ name, type: 'boolean', desc, ...extra });
const obj = (name, desc, extra = {}) => ({ name, type: 'object', desc, ...extra });
const XY = [num('x', 'Screen x in CSS pixels from the left of the page', { min: 0 }), num('y', 'Screen y in CSS pixels from the top', { min: 0 })];
const HOLD = obj('options', 'Optional { hold }: how long to press, in ms (0 to 5000)', { required: false });
const DRAG = obj('options', 'Optional { dx, dy, steps, ms }: drag vector in CSS px (default 90, -200), number of pointer moves (default 24), time per move in ms (default 16)', { required: false });
// every method returns a Promise of { ok: true, ... } or { ok: false, error, code }
export const METHODS = {
version: { group: 'meta', summary: 'The app version from version.json: { version (semver), date, sha, api }. Also in api.json and Bench export bundles.', params: [] },
help: { group: 'meta', summary: 'A short guide: how calls work, a quick start and the methods by group. help("destroy") narrows to a group, help("setLayer") to one method.', params: [str('topic', 'A method or group name', { required: false })] },
schema: { group: 'meta', summary: 'This API: version, every method with its parameters, events, URL parameters and keyboard actions.', params: [] },
getState: { group: 'meta', summary: 'The whole page state as JSON: scroll, unroll position, layer, winding, close-up, panels, Destroy mode, camera, readout and a text description of the view.', params: [] },
describe: { group: 'meta', summary: 'A plain-text description of what is on screen now (the same text screen readers get).', params: [] },
links: { group: 'meta', summary: 'Every link on the page (credits, licenses, docs) with its text and URL.', params: [] },
deepLink: { group: 'meta', summary: 'A URL that restores the current state (scroll, t, layer, focus, compare, panel, Destroy mode; camera too if asked).', params: [bool('camera', 'Include the camera position (cam=...)', { required: false })] },
on: { group: 'meta', summary: 'Subscribe to an event; the callback gets a JSON payload. Returns { ok, id } for off().', params: [str('event', 'Event name', { enum: ['change', 'unroll', 'layer', 'focus', 'panel', 'destroy', 'camera', 'settled', 'scroll', 'extension', 'error'] }), { name: 'callback', type: 'function', desc: 'Called with (payload)' }] },
off: { group: 'meta', summary: 'Remove a subscription made with on().', params: [num('id', 'The id on() returned')] },
run: { group: 'meta', summary: 'Run a batch of calls in order. Each step is ["method", ...args] or { "method", "args" }; dotted names reach groups (e.g. "camera.orbit"). Stops at the first failure unless options.continueOnError.', params: [{ name: 'steps', type: 'array', desc: 'The calls' }, obj('options', '{ continueOnError: boolean }', { required: false })] },
waitFor: { group: 'meta', summary: 'Wait until the state matches. condition is "ready", "settled" (unroll and camera at rest), "overlayLoaded", or an object of dotted state paths to values or to { eq, ne, gte, lte, gt, lt, in }.', params: [{ name: 'condition', type: 'string|object', desc: 'What to wait for' }, obj('options', '{ timeout: ms (default 10000) }', { required: false })] },
reload: { group: 'meta', summary: 'Reload the page (keeps the URL state).', params: [] },
listScrolls: { group: 'scrolls', summary: 'Scrolls in the registry and scrolls opened in this browser (local).', params: [] },
openScroll: { group: 'scrolls', summary: 'Open a scroll by registry id or local key. The page reloads; call waitFor("ready") after the new page loads.', params: [str('id', 'Registry id or local key (local-...)')] },
setUnroll: { group: 'view', summary: 'Set the unroll position, 0 rolled to 1 fully unrolled. Animates unless instant.', params: [num('t', 'Position', { min: 0, max: 1 }), obj('options', '{ instant: boolean }', { required: false })] },
play: { group: 'view', summary: 'Start or stop the automatic unroll (toggles if omitted).', params: [bool('on', 'Play or pause', { required: false })] },
setLayer: { group: 'view', summary: 'Show a layer: "xray", "ink" or "overlay" (aliases "ct", "gpu", or the manifest layer id).', params: [str('id', 'Layer')] },
focus: { group: 'view', summary: 'Open the close-up of a winding (current winding if omitted), or close it with null. options.view picks the tab: "main", "overlay" or "compare".', params: [{ name: 'winding', type: 'string|number|null', desc: 'Winding id such as "w059" (or its number, 59), null to close', required: false }, obj('options', '{ view: "main" | "overlay" | "compare" }', { required: false })] },
compare: { group: 'view', summary: 'Compare swipe in the close-up: on/off and the split position 0..1 (0 shows only the overlay, 1 only the main ink). Opens the close-up on the current winding if needed.', params: [bool('on', 'Show the compare view'), num('pos', 'Split position', { min: 0, max: 1, required: false })] },
closeup: { group: 'view', summary: 'Zoom and pan the close-up image: { scale } multiplies the zoom, { x, y } pans in CSS px, { fit: true } fits it.', params: [obj('options', '{ scale, x, y, fit }')] },
panel: { group: 'view', summary: 'Open or close a panel: "about", "help", "open" (Open your own), "why" (why two ink layers), "embed" (the iframe snippet). Toggles if open is omitted.', params: [str('name', 'Panel', { enum: ['about', 'help', 'open', 'why', 'embed'] }), bool('open', 'Open or close', { required: false })] },
sound: { group: 'view', summary: 'Crackle sound for Destroy on or off (browsers need a user gesture first to play audio).', params: [bool('on', 'Sound on')] },
fullscreen: { group: 'view', summary: 'Enter or leave full screen (browsers may refuse without a user gesture).', params: [bool('on', 'Full screen', { required: false })] },
screenshot: { group: 'view', summary: 'A PNG of the view as a data URL. options.clean leaves out the title overlay; options.download also saves it like the camera button.', params: [obj('options', '{ clean: boolean, download: boolean }', { required: false })] },
key: { group: 'view', summary: 'Press a keyboard shortcut, as a person would (see schema().keys).', params: [str('key', 'Key name, e.g. "t", "ArrowRight", "?"'), obj('options', '{ shift: boolean }', { required: false })] },
reset: { group: 'view', summary: 'Back to the start: close panels, leave Destroy, put every sheet back, roll up, reset the camera and layer.', params: [] },
'camera.get': { group: 'camera', summary: 'Camera position, target, distance, azimuth and polar angle (degrees), field of view.', params: [] },
'camera.orbit': { group: 'camera', summary: 'Orbit around the target by degrees.', params: [num('dAzimuth', 'Degrees around the vertical axis'), num('dPolar', 'Degrees up or down', { required: false })] },
'camera.zoom': { group: 'camera', summary: 'Zoom by a factor (2 is twice as close, 0.5 twice as far).', params: [num('factor', 'Zoom factor', { min: 0.01, max: 100 })] },
'camera.pan': { group: 'camera', summary: 'Pan the view in millimeters along the screen (x right, y up).', params: [num('dx', 'mm right'), num('dy', 'mm up', { required: false })] },
'camera.set': { group: 'camera', summary: 'Put the camera at a position looking at a target, both [x, y, z] in scene millimeters.', params: [{ name: 'position', type: 'array', desc: '[x, y, z]' }, { name: 'target', type: 'array', desc: '[x, y, z]', required: false }] },
'camera.reset': { group: 'camera', summary: 'Fly back to the default view for the current position (the V key).', params: [] },
'destroy.mode': { group: 'destroy', summary: 'Enter or leave Destroy mode (damage the scroll as it is, rolled up or unrolled).', params: [bool('on', 'Destroy mode on', { required: false })] },
'destroy.tool': { group: 'destroy', summary: 'Pick the Destroy tool used by clicks and drags: "tear", "crush", "burn" or "water".', params: [str('name', 'Tool', { enum: ['tear', 'crush', 'burn', 'water'] })] },
'destroy.aim': { group: 'destroy', summary: 'A screen point that is on the papyrus right now, nearest the center of the view: { x, y, winding }. Use it for the tool calls.', params: [] },
'destroy.tear': { group: 'destroy', summary: 'Rip the papyrus with a drag from (x, y). Enters Destroy mode and picks Tear. Returns damage stats before and after.', params: [...XY, DRAG] },
'destroy.crush': { group: 'destroy', summary: 'Press at (x, y) to crumble the papyrus; options.hold (ms, default 700) presses harder and deeper.', params: [...XY, HOLD] },
'destroy.burn': { group: 'destroy', summary: 'Ignite the papyrus at (x, y); embers then creep along the fibers.', params: [...XY, HOLD] },
'destroy.water': { group: 'destroy', summary: 'Pour water at (x, y); options.hold (ms, default 900) widens the wet patch.', params: [...XY, HOLD] },
'destroy.reset': { group: 'destroy', summary: 'Undo all damage, clear the debris and put the Sandbox sheets back.', params: [] },
'destroy.sandbox': { group: 'destroy', summary: 'Sandbox on or off: the strip laid flat with full fracture physics (Destroy mode only).', params: [bool('on', 'Sandbox on', { required: false })] },
'destroy.grab': { group: 'destroy', summary: 'In the Sandbox, grab the papyrus at (x, y) and pull (crushes instead while the Crush tool is picked). Returns breaks before and after.', params: [...XY, DRAG] },
'destroy.crumble': { group: 'destroy', summary: 'Crumble the whole scroll: it breaks up from the edges into a heap of dust. destroy.reset() restores it.', params: [] },
prizeOnly: { group: 'scrolls', summary: 'Show only prize eligible scrolls in the scroll picker, on or off (toggles when omitted). Returns { prizeOnly, visible }.', params: [bool('on', 'Only prize eligible scrolls')] },
'destroy.shake': { group: 'destroy', summary: 'Sandbox only: knock the table, loosening random bits.', params: [] },
'destroy.stats': { group: 'destroy', summary: 'Damage numbers (masks, embers, particles, shards, puddles) and Sandbox numbers (nodes, links, breaks, debris).', params: [] },
'destroy.tools': { group: 'destroy', summary: 'The Destroy tools in this build.', params: [] },
rollUp: { group: 'view', summary: 'Roll the scroll back up with a steady rewind (the Roll up button); false stops it. Toggles if omitted.', params: [bool('on', 'Rolling', { required: false })] },
'embed.snippet': { group: 'embed', summary: 'The iframe snippet for this viewer: { url, html }. options: { state (start at the current view, default true), autoplay, controls (default true), width, height }.', params: [obj('options', '{ state, autoplay, controls, width, height }', { required: false })] },
'embed.info': { group: 'embed', summary: 'Embed mode: { embedded (?embed=1), framed (inside an iframe), active (the scene has the pointer and wheel), controls, autoplay }.', params: [] },
'embed.activate': { group: 'embed', summary: 'In embed mode, hand the pointer, touch and wheel to the scene (like clicking the "Click to interact" layer), or give them back with false.', params: [bool('on', 'Activate', { required: false })] },
'open.fromURL': { group: 'open', summary: 'Open your own: fetch a manifest.json or zip from a URL (the server must allow CORS). Nothing is uploaded. On success the page reloads into the new scroll.', params: [str('url', 'http(s) URL')] },
'open.fromFiles': { group: 'open', summary: 'Open your own from file contents: [{ name, data }] where data is an ArrayBuffer, a Uint8Array, a Blob or a base64 string. A zip, or a manifest.json plus its files. On success the page reloads.', params: [{ name: 'files', type: 'array', desc: 'Files' }] },
'open.example': { group: 'open', summary: 'Build the synthetic example package. Returns it as base64 (options.download also saves the zip).', params: [obj('options', '{ download: boolean }', { required: false })] },
'open.listLocal': { group: 'open', summary: 'Scrolls kept in this browser.', params: [] },
'open.removeLocal': { group: 'open', summary: 'Remove a scroll kept in this browser.', params: [str('key', 'Local key')] },
'open.validate': { group: 'open', summary: 'Check a manifest object with the page validator, without opening anything.', params: [obj('manifest', 'The manifest')] },
};
export const EVENTS = {
change: 'Any state change: { changed: [paths], state }',
unroll: 'The unroll position settled: { t }',
layer: 'The layer changed: { layer }',
focus: 'The close-up opened, closed or changed tab: { focus }',
panel: 'A panel opened or closed: { panels }',
destroy: 'Destroy mode, crush or a tool was used: { destroy }',
camera: 'The camera came to rest somewhere new: { camera }',
settled: 'Unroll and camera at rest: { state }',
scroll: 'A scroll is about to open (the page reloads): { id }',
extension: 'An add-on registered a method group, for example bench: { group, methods }',
error: 'A call failed: { method, error }',
};
export const URL_PARAMS = {
scroll: 'Registry id or local key (alias s)',
t: 'Unroll position 0..1',
layer: 'xray, ink or overlay (alias l; gpu and ct also work)',
focus: 'Winding id: open its close-up',
view: 'Close-up tab: main, overlay or compare',
compare: 'Open the compare swipe at this split position 0..1 (on the focus winding, or the current one)',
panel: 'about, help, open, why or embed',
embed: '1 for the compact embed layout with click to interact (query string only)',
autoplay: '1 to play the unroll on load (embed mode)',
controls: '0 to hide the controls (embed mode)',
destroy: '1 to start in Destroy mode',
tool: 'Destroy tool: tear, crush, burn or water (implies destroy=1)',
crush: '1: the same as tool=crush',
sandbox: '1 to start in the Destroy Sandbox',
cam: 'Camera px,py,pz,tx,ty,tz in scene millimeters',
shot: 'In the query string: a clean frame for share images',
};
export const KEYS = {
Space: 'play', ArrowRight: 'setUnroll(t + 0.01)', ArrowLeft: 'setUnroll(t - 0.01)', 'Shift+Arrow': 'setUnroll(t +/- 0.05)',
Home: 'setUnroll(0)', End: 'setUnroll(1)', 1: 'setLayer("xray")', 2: 'setLayer("ink")', 3: 'setLayer("overlay")',
r: 'focus()', v: 'camera.reset()', t: 'destroy.mode()', x: 'destroy.tool("tear")', c: 'destroy.tool("crush")', b: 'destroy.tool("burn")', w: 'destroy.tool("water")', u: 'rollUp()', f: 'fullscreen()', i: 'panel("about")', '?': 'panel("help")', Escape: 'close panels',
'canvas focused: arrows': 'camera.orbit', 'canvas focused: + and -': 'camera.zoom', 'canvas focused: Shift+arrows': 'camera.pan', 'canvas focused: Enter': 'focus()',
'close-up focused: + - arrows 0': 'closeup()',
};
// argument checking, shared by every method
export function checkArgs(name, args) {
const spec = METHODS[name];
if (!spec) return `Unknown method "${name}". Call unroll.schema() for the list.`;
const ps = spec.params;
if (args.length > ps.length) return `${name} takes at most ${ps.length} argument${ps.length === 1 ? '' : 's'} (${ps.map((p) => p.name).join(', ') || 'none'}); got ${args.length}.`;
for (const [i, p] of ps.entries()) {
const v = args[i];
if (v === undefined) { if (p.required !== false) return `${name}: "${p.name}" is required (${p.type}: ${p.desc}).`; continue; }
const types = p.type.split('|');
const ok = types.some((t) => (t === 'null' ? v === null : t === 'array' ? Array.isArray(v) : t === 'object' ? v && typeof v === 'object' && !Array.isArray(v) : typeof v === t));
if (!ok) return `${name}: "${p.name}" must be ${p.type}; got ${v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v}.`;
if (p.type === 'number' || (types.includes('number') && typeof v === 'number')) {
if (!Number.isFinite(v)) return `${name}: "${p.name}" must be a finite number.`;
if (p.min !== undefined && v < p.min) return `${name}: "${p.name}" must be at least ${p.min}; got ${v}.`;
if (p.max !== undefined && v > p.max) return `${name}: "${p.name}" must be at most ${p.max}; got ${v}.`;
}
if (p.enum && !p.enum.includes(v)) return `${name}: "${p.name}" must be one of ${p.enum.map((x) => JSON.stringify(x)).join(', ')}; got ${JSON.stringify(v)}.`;
}
return null;
}