// window.unroll: the stable public API for people and agents. The page supplies the implementations; // this file adds argument checking, uniform JSON results, events, batches and waiting. import { METHODS, EVENTS, URL_PARAMS, KEYS, API_VERSION, checkArgs } from './apispec.js'; export class ApiError extends Error { constructor(message, code = 'invalid') { super(message); this.code = code; } } const json = (v) => JSON.parse(JSON.stringify(v ?? null)); const get = (o, path) => path.split('.').reduce((a, k) => (a == null ? undefined : a[k]), o); export function installAPI(impl, { stateFn, onTick }) { const subs = new Map(); let nextId = 1; const emit = (event, payload) => { for (const [, s] of subs) if (s.event === event) { try { s.cb(json(payload)); } catch (e) { console.warn('unroll event handler', e); } } }; const api = {}; const ext = {}; // name -> { group, summary, params, fn } for add-ons such as lib/bench.js const wrap = (name) => async (...args) => { const bad = ext[name] ? null : checkArgs(name, args); if (bad) { emit('error', { method: name, error: bad }); return { ok: false, error: bad, code: 'invalid' }; } try { const out = await (ext[name] ? ext[name].fn : impl[name])(...args); return { ok: true, ...(out && typeof out === 'object' && !Array.isArray(out) ? json(out) : { result: json(out) }) }; } catch (e) { const error = e && e.message ? e.message : String(e); emit('error', { method: name, error }); return { ok: false, error, code: e.code || 'failed' }; } }; // meta methods that live here impl.schema = async () => ({ version: API_VERSION, app: window.UNROLL_VERSION || null, methods: Object.fromEntries([...Object.entries(METHODS), ...Object.entries(ext)].map(([k, m]) => [k, { group: m.group, summary: m.summary, params: m.params, ...(ext[k] ? { extension: true } : {}) }])), extensions: [...new Set(Object.values(ext).map((m) => m.group))], events: EVENTS, urlParams: URL_PARAMS, keys: KEYS, conventions: 'Every method returns a Promise of { ok: true, ... } or { ok: false, error, code }. Nothing throws. Screen coordinates are CSS pixels; scene coordinates are millimeters. Add-ons register more groups with unroll.register(group, methods, specs); they appear here with extension: true.', }); // help(): a short guide for agents; help('setLayer') or help('destroy') narrows it to a method or a group impl.help = async (topic) => { const all = { ...METHODS, ...ext }; const fmtM = (k, m) => ({ method: k, group: m.group, summary: m.summary, params: (m.params || []).map((p) => `${p.name}${p.required === false ? '?' : ''}: ${p.type}${p.desc ? ` (${p.desc})` : ''}`), call: `unroll.${k}(${(m.params || []).map((p) => p.name).join(', ')})` }); if (topic !== undefined) { if (typeof topic !== 'string') throw new ApiError('help: topic must be a method name (e.g. "setLayer") or a group (e.g. "destroy").'); if (all[topic]) return fmtM(topic, all[topic]); const g = Object.entries(all).filter(([, m]) => m.group === topic); if (g.length) return { group: topic, methods: g.map(([k, m]) => fmtM(k, m)) }; throw new ApiError(`help: no method or group "${topic}". Groups: ${[...new Set(Object.values(all).map((m) => m.group))].join(', ')}.`, 'not_found'); } return { about: 'window.unroll drives every control of the Unroll scroll viewer. Every call returns a Promise of { ok: true, ... } or { ok: false, error, code }; nothing throws.', start: ['await unroll.waitFor("ready")', 'await unroll.getState()', 'await unroll.setUnroll(0.5)', 'await unroll.setLayer("overlay")', 'await unroll.screenshot()'], groups: Object.fromEntries([...new Set(Object.values(all).map((m) => m.group))].map((g) => [g, Object.keys(all).filter((k) => all[k].group === g)])), more: 'unroll.help("destroy") for a group, unroll.help("setLayer") for one method, unroll.schema() for everything as JSON. Docs: /AGENTS.md, /llms.txt, /api.json, /docs/EMBED.md.', version: window.UNROLL_VERSION || null, }; }; impl.on = (event, cb) => { if (typeof cb !== 'function') throw new ApiError('on: callback must be a function.'); const id = nextId++; subs.set(id, { event, cb }); return { id }; }; impl.off = (id) => { if (!subs.delete(id)) throw new ApiError(`off: no subscription with id ${id}.`, 'not_found'); return {}; }; impl.run = async (steps, options = {}) => { const results = []; for (const [i, s] of steps.entries()) { const method = Array.isArray(s) ? s[0] : s && s.method; const args = Array.isArray(s) ? s.slice(1) : (s && s.args) || []; if (typeof method !== 'string' || !fns[method] || method === 'run') { results.push({ ok: false, error: `Step ${i}: unknown method ${JSON.stringify(method)}.`, code: 'invalid' }); if (!options.continueOnError) break; continue; } const r = await call(method, args); results.push({ method, ...r }); if (!r.ok && !options.continueOnError) break; } return { results, completed: results.filter((r) => r.ok).length, total: steps.length }; }; impl.waitFor = (cond, options = {}) => new Promise((resolve, reject) => { const timeout = options.timeout ?? 10000; if (typeof cond === 'string' && !['ready', 'settled', 'overlayLoaded'].includes(cond)) { reject(new ApiError(`waitFor: unknown condition "${cond}". Use "ready", "settled", "overlayLoaded" or an object.`)); return; } const test = (st) => { if (cond === 'ready') return st.ready; if (cond === 'settled') return st.settled; if (cond === 'overlayLoaded') return st.layers.overlayLoaded; return Object.entries(cond).every(([path, want]) => { const v = get(st, path); if (want && typeof want === 'object' && !Array.isArray(want)) { return Object.entries(want).every(([op, x]) => (op === 'eq' ? v === x : op === 'ne' ? v !== x : op === 'gte' ? v >= x : op === 'lte' ? v <= x : op === 'gt' ? v > x : op === 'lt' ? v < x : op === 'in' ? Array.isArray(x) && x.includes(v) : false)); } return JSON.stringify(v) === JSON.stringify(want); }); }; const t0 = performance.now(); const tick = () => { const st = stateFn(); if (test(st)) { resolve({ state: st, ms: Math.round(performance.now() - t0) }); return; } if (performance.now() - t0 > timeout) { reject(new ApiError(`waitFor timed out after ${timeout} ms.`, 'timeout')); return; } setTimeout(tick, 50); }; tick(); }); const call = (name, args) => (fns[name] ? fns[name](...args) : Promise.resolve({ ok: false, error: `Unknown method "${name}".`, code: 'invalid' })); const fns = {}; for (const name of Object.keys(METHODS)) { if (!impl[name]) throw new Error(`API method ${name} has no implementation`); fns[name] = wrap(name); const [g, m] = name.split('.'); if (m) { api[g] = api[g] || {}; api[g][m] = fns[name]; } else api[g] = fns[name]; } // state change events, checked a few times a second let last = null, lastT = null, wasSettled = false; setInterval(() => { const st = stateFn(); const cmp = { layer: st.layer, focus: st.focus, panels: st.panels, destroy: { on: st.destroy.on, tool: st.destroy.tool, sandbox: st.destroy.sandbox }, scroll: st.scroll.key }; if (last) { const changed = Object.keys(cmp).filter((k) => JSON.stringify(cmp[k]) !== JSON.stringify(last[k])); if (changed.length) emit('change', { changed, state: st }); if (changed.includes('layer')) emit('layer', { layer: st.layer }); if (changed.includes('focus')) emit('focus', { focus: st.focus }); if (changed.includes('panels')) emit('panel', { panels: st.panels }); if (changed.includes('destroy')) emit('destroy', { destroy: st.destroy }); } if (st.settled && !wasSettled) { emit('settled', { state: st }); if (lastT === null || Math.abs(st.t - lastT) > 1e-4) { emit('unroll', { t: st.t }); emit('change', { changed: ['t'], state: st }); } emit('camera', { camera: st.camera }); lastT = st.t; } wasSettled = st.settled; last = json(cmp); if (onTick) onTick(st); }, 200); // Extensions: a script registers a new group (for example window.unroll.bench) with // window.unroll.register('bench', { run: fn, ... }, { run: { summary, params } }). // Scripts that load before the API can push the same arguments onto window.unrollExtensions. const reserved = new Set(['version', 'register', ...Object.keys(api)]); const register = (group, methods, specs = {}) => { if (typeof group !== 'string' || !/^[a-z][a-zA-Z0-9]*$/.test(group)) return { ok: false, error: 'register: group must be a simple lower camel case name.', code: 'invalid' }; if (reserved.has(group)) return { ok: false, error: `register: "${group}" is already taken.`, code: 'conflict' }; if (!methods || typeof methods !== 'object' || !Object.keys(methods).length) return { ok: false, error: 'register: methods must be an object of functions.', code: 'invalid' }; const ns = {}; for (const [m, fn] of Object.entries(methods)) { if (typeof fn !== 'function') return { ok: false, error: `register: ${group}.${m} is not a function.`, code: 'invalid' }; const name = `${group}.${m}`, sp = specs[m] || {}; ext[name] = { group, summary: sp.summary || '', params: sp.params || [], fn }; fns[name] = wrap(name); ns[m] = fns[name]; } reserved.add(group); Object.defineProperty(api, group, { value: Object.freeze(ns), enumerable: true, writable: false, configurable: false }); emit('extension', { group, methods: Object.keys(ns) }); return { ok: true, group, methods: Object.keys(ns) }; }; for (const k of Object.keys(api)) { if (api[k] && typeof api[k] === 'object') Object.freeze(api[k]); Object.defineProperty(api, k, { writable: false, configurable: false }); } Object.defineProperty(api, 'register', { value: register, enumerable: true, writable: false, configurable: false }); Object.defineProperty(window, 'unroll', { value: api, configurable: false, writable: false }); const queued = Array.isArray(window.unrollExtensions) ? window.unrollExtensions : []; window.unrollExtensions = { push: (...items) => { for (const it of items) register(...it); return 0; } }; for (const it of queued) register(...it); return { api, emit, call }; }