{
  "name": "Unroll",
  "app": {
    "version": "1.5.0",
    "date": "2026-10-16",
    "sha": "bf9ab42"
  },
  "apiVersion": "1.0.0",
  "docs": {
    "llms": "/llms.txt",
    "agents": "/AGENTS.md",
    "addingAScroll": "/docs/ADDING_A_SCROLL.md",
    "changelog": "/CHANGELOG.md",
    "embed": "/docs/EMBED.md",
    "oembed": "/oembed.json"
  },
  "global": "window.unroll",
  "conventions": [
    "Every method returns a Promise of { ok: true, ... } or { ok: false, error, code }. Nothing throws.",
    "Error codes: invalid (bad arguments), not_found, unavailable (feature missing in this build or scroll), timeout, conflict, failed, load_failed.",
    "Screen coordinates are CSS pixels of the viewport; scene coordinates are millimetres.",
    "Add-ons register more groups with unroll.register(group, methods, specs), for example unroll.bench. They appear in unroll.schema() with extension: true.",
    "Every interactive element has data-testid and data-api naming its API equivalent."
  ],
  "embed": {
    "url": "https://sharpmeow-unroll.static.hf.space/index.html?embed=1",
    "params": {
      "embed": "1: compact layout, click to interact",
      "autoplay": "1: play on load",
      "controls": "0: hide the controls"
    },
    "iframeAllow": "fullscreen; clipboard-write"
  },
  "postMessage": {
    "description": "Host pages drive an embedded viewer with postMessage. Every method above is callable by name; on, off and register are replaced by subscribe and unsubscribe.",
    "toViewer": {
      "call": {
        "unroll": 1,
        "id": "number|string",
        "method": "string (an API method name)",
        "args": "array"
      },
      "subscribe": {
        "unroll": 1,
        "id": "number|string",
        "method": "subscribe",
        "args": [
          "eventName"
        ]
      },
      "unsubscribe": {
        "unroll": 1,
        "id": "number|string",
        "method": "unsubscribe",
        "args": [
          "eventName"
        ]
      }
    },
    "fromViewer": {
      "ready": {
        "unroll": 1,
        "type": "ready",
        "version": "{ version, date, sha, api }",
        "methods": "array of method names"
      },
      "result": {
        "unroll": 1,
        "type": "result",
        "id": "the id you sent",
        "result": "{ ok: true, ... } | { ok: false, error, code }"
      },
      "event": {
        "unroll": 1,
        "type": "event",
        "event": "string",
        "payload": "object"
      }
    },
    "forbidden": [
      "open.listLocal",
      "open.removeLocal"
    ]
  },
  "methods": {
    "version": {
      "group": "meta",
      "summary": "The app version from version.json: { version (semver), date, sha, api }. Also in api.json and Bench export bundles.",
      "call": "window.unroll.version()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.help(topic?)",
      "params": [
        {
          "name": "topic",
          "type": [
            "string"
          ],
          "required": false,
          "description": "A method or group name"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "schema": {
      "group": "meta",
      "summary": "This API: version, every method with its parameters, events, URL parameters and keyboard actions.",
      "call": "window.unroll.schema()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.getState()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "describe": {
      "group": "meta",
      "summary": "A plain-text description of what is on screen now (the same text screen readers get).",
      "call": "window.unroll.describe()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "links": {
      "group": "meta",
      "summary": "Every link on the page (credits, licences, docs) with its text and URL.",
      "call": "window.unroll.links()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "deepLink": {
      "group": "meta",
      "summary": "A URL that restores the current state (scroll, t, layer, focus, compare, panel, Destroy mode; camera too if asked).",
      "call": "window.unroll.deepLink(camera?)",
      "params": [
        {
          "name": "camera",
          "type": [
            "boolean"
          ],
          "required": false,
          "description": "Include the camera position (cam=...)"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "on": {
      "group": "meta",
      "summary": "Subscribe to an event; the callback gets a JSON payload. Returns { ok, id } for off().",
      "call": "window.unroll.on(event, callback)",
      "params": [
        {
          "name": "event",
          "type": [
            "string"
          ],
          "required": true,
          "description": "Event name",
          "enum": [
            "change",
            "unroll",
            "layer",
            "focus",
            "panel",
            "destroy",
            "camera",
            "settled",
            "scroll",
            "extension",
            "error"
          ]
        },
        {
          "name": "callback",
          "type": [
            "function"
          ],
          "required": true,
          "description": "Called with (payload)"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "off": {
      "group": "meta",
      "summary": "Remove a subscription made with on().",
      "call": "window.unroll.off(id)",
      "params": [
        {
          "name": "id",
          "type": [
            "number"
          ],
          "required": true,
          "description": "The id on() returned"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.run(steps, options?)",
      "params": [
        {
          "name": "steps",
          "type": [
            "array"
          ],
          "required": true,
          "description": "The calls"
        },
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "{ continueOnError: boolean }"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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 }.",
      "call": "window.unroll.waitFor(condition, options?)",
      "params": [
        {
          "name": "condition",
          "type": [
            "string",
            "object"
          ],
          "required": true,
          "description": "What to wait for"
        },
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "{ timeout: ms (default 10000) }"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "reload": {
      "group": "meta",
      "summary": "Reload the page (keeps the URL state).",
      "call": "window.unroll.reload()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "listScrolls": {
      "group": "scrolls",
      "summary": "Scrolls in the registry and scrolls opened in this browser (local).",
      "call": "window.unroll.listScrolls()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "openScroll": {
      "group": "scrolls",
      "summary": "Open a scroll by registry id or local key. The page reloads; call waitFor(\"ready\") after the new page loads.",
      "call": "window.unroll.openScroll(id)",
      "params": [
        {
          "name": "id",
          "type": [
            "string"
          ],
          "required": true,
          "description": "Registry id or local key (local-...)"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "setUnroll": {
      "group": "view",
      "summary": "Set the unroll position, 0 rolled to 1 fully unrolled. Animates unless instant.",
      "call": "window.unroll.setUnroll(t, options?)",
      "params": [
        {
          "name": "t",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Position",
          "minimum": 0,
          "maximum": 1
        },
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "{ instant: boolean }"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "play": {
      "group": "view",
      "summary": "Start or stop the automatic unroll (toggles if omitted).",
      "call": "window.unroll.play(on?)",
      "params": [
        {
          "name": "on",
          "type": [
            "boolean"
          ],
          "required": false,
          "description": "Play or pause"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "setLayer": {
      "group": "view",
      "summary": "Show a layer: \"xray\", \"ink\" or \"overlay\" (aliases \"ct\", \"gpu\", or the manifest layer id).",
      "call": "window.unroll.setLayer(id)",
      "params": [
        {
          "name": "id",
          "type": [
            "string"
          ],
          "required": true,
          "description": "Layer"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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\".",
      "call": "window.unroll.focus(winding?, options?)",
      "params": [
        {
          "name": "winding",
          "type": [
            "string",
            "number",
            "null"
          ],
          "required": false,
          "description": "Winding id such as \"w059\" (or its number, 59), null to close"
        },
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "{ view: \"main\" | \"overlay\" | \"compare\" }"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.compare(on, pos?)",
      "params": [
        {
          "name": "on",
          "type": [
            "boolean"
          ],
          "required": true,
          "description": "Show the compare view"
        },
        {
          "name": "pos",
          "type": [
            "number"
          ],
          "required": false,
          "description": "Split position",
          "minimum": 0,
          "maximum": 1
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.closeup(options)",
      "params": [
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": true,
          "description": "{ scale, x, y, fit }"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.panel(name, open?)",
      "params": [
        {
          "name": "name",
          "type": [
            "string"
          ],
          "required": true,
          "description": "Panel",
          "enum": [
            "about",
            "help",
            "open",
            "why",
            "embed"
          ]
        },
        {
          "name": "open",
          "type": [
            "boolean"
          ],
          "required": false,
          "description": "Open or close"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "sound": {
      "group": "view",
      "summary": "Crackle sound for Destroy on or off (browsers need a user gesture first to play audio).",
      "call": "window.unroll.sound(on)",
      "params": [
        {
          "name": "on",
          "type": [
            "boolean"
          ],
          "required": true,
          "description": "Sound on"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "fullscreen": {
      "group": "view",
      "summary": "Enter or leave full screen (browsers may refuse without a user gesture).",
      "call": "window.unroll.fullscreen(on?)",
      "params": [
        {
          "name": "on",
          "type": [
            "boolean"
          ],
          "required": false,
          "description": "Full screen"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.screenshot(options?)",
      "params": [
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "{ clean: boolean, download: boolean }"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "key": {
      "group": "view",
      "summary": "Press a keyboard shortcut, as a person would (see schema().keys).",
      "call": "window.unroll.key(key, options?)",
      "params": [
        {
          "name": "key",
          "type": [
            "string"
          ],
          "required": true,
          "description": "Key name, e.g. \"t\", \"ArrowRight\", \"?\""
        },
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "{ shift: boolean }"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "reset": {
      "group": "view",
      "summary": "Back to the start: close panels, leave Destroy, put every sheet back, roll up, reset the camera and layer.",
      "call": "window.unroll.reset()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "camera.get": {
      "group": "camera",
      "summary": "Camera position, target, distance, azimuth and polar angle (degrees), field of view.",
      "call": "window.unroll.camera.get()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "camera.orbit": {
      "group": "camera",
      "summary": "Orbit around the target by degrees.",
      "call": "window.unroll.camera.orbit(dAzimuth, dPolar?)",
      "params": [
        {
          "name": "dAzimuth",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Degrees around the vertical axis"
        },
        {
          "name": "dPolar",
          "type": [
            "number"
          ],
          "required": false,
          "description": "Degrees up or down"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "camera.zoom": {
      "group": "camera",
      "summary": "Zoom by a factor (2 is twice as close, 0.5 twice as far).",
      "call": "window.unroll.camera.zoom(factor)",
      "params": [
        {
          "name": "factor",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Zoom factor",
          "minimum": 0.01,
          "maximum": 100
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "camera.pan": {
      "group": "camera",
      "summary": "Pan the view in millimetres along the screen (x right, y up).",
      "call": "window.unroll.camera.pan(dx, dy?)",
      "params": [
        {
          "name": "dx",
          "type": [
            "number"
          ],
          "required": true,
          "description": "mm right"
        },
        {
          "name": "dy",
          "type": [
            "number"
          ],
          "required": false,
          "description": "mm up"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "camera.set": {
      "group": "camera",
      "summary": "Put the camera at a position looking at a target, both [x, y, z] in scene millimetres.",
      "call": "window.unroll.camera.set(position, target?)",
      "params": [
        {
          "name": "position",
          "type": [
            "array"
          ],
          "required": true,
          "description": "[x, y, z]"
        },
        {
          "name": "target",
          "type": [
            "array"
          ],
          "required": false,
          "description": "[x, y, z]"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "camera.reset": {
      "group": "camera",
      "summary": "Fly back to the default view for the current position (the V key).",
      "call": "window.unroll.camera.reset()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.mode": {
      "group": "destroy",
      "summary": "Enter or leave Destroy mode (damage the scroll as it is, rolled up or unrolled).",
      "call": "window.unroll.destroy.mode(on?)",
      "params": [
        {
          "name": "on",
          "type": [
            "boolean"
          ],
          "required": false,
          "description": "Destroy mode on"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.tool": {
      "group": "destroy",
      "summary": "Pick the Destroy tool used by clicks and drags: \"tear\", \"crush\", \"burn\" or \"water\".",
      "call": "window.unroll.destroy.tool(name)",
      "params": [
        {
          "name": "name",
          "type": [
            "string"
          ],
          "required": true,
          "description": "Tool",
          "enum": [
            "tear",
            "crush",
            "burn",
            "water"
          ]
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.aim": {
      "group": "destroy",
      "summary": "A screen point that is on the papyrus right now, nearest the centre of the view: { x, y, winding }. Use it for the tool calls.",
      "call": "window.unroll.destroy.aim()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.destroy.tear(x, y, options?)",
      "params": [
        {
          "name": "x",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Screen x in CSS pixels from the left of the page",
          "minimum": 0
        },
        {
          "name": "y",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Screen y in CSS pixels from the top",
          "minimum": 0
        },
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "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)"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.crush": {
      "group": "destroy",
      "summary": "Press at (x, y) to crumble the papyrus; options.hold (ms, default 700) presses harder and deeper.",
      "call": "window.unroll.destroy.crush(x, y, options?)",
      "params": [
        {
          "name": "x",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Screen x in CSS pixels from the left of the page",
          "minimum": 0
        },
        {
          "name": "y",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Screen y in CSS pixels from the top",
          "minimum": 0
        },
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "Optional { hold }: how long to press, in ms (0 to 5000)"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.burn": {
      "group": "destroy",
      "summary": "Ignite the papyrus at (x, y); embers then creep along the fibres.",
      "call": "window.unroll.destroy.burn(x, y, options?)",
      "params": [
        {
          "name": "x",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Screen x in CSS pixels from the left of the page",
          "minimum": 0
        },
        {
          "name": "y",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Screen y in CSS pixels from the top",
          "minimum": 0
        },
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "Optional { hold }: how long to press, in ms (0 to 5000)"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.water": {
      "group": "destroy",
      "summary": "Pour water at (x, y); options.hold (ms, default 900) widens the wet patch.",
      "call": "window.unroll.destroy.water(x, y, options?)",
      "params": [
        {
          "name": "x",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Screen x in CSS pixels from the left of the page",
          "minimum": 0
        },
        {
          "name": "y",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Screen y in CSS pixels from the top",
          "minimum": 0
        },
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "Optional { hold }: how long to press, in ms (0 to 5000)"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.reset": {
      "group": "destroy",
      "summary": "Undo all damage, clear the debris and put the Sandbox sheets back.",
      "call": "window.unroll.destroy.reset()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.sandbox": {
      "group": "destroy",
      "summary": "Sandbox on or off: the strip laid flat with full fracture physics (Destroy mode only).",
      "call": "window.unroll.destroy.sandbox(on?)",
      "params": [
        {
          "name": "on",
          "type": [
            "boolean"
          ],
          "required": false,
          "description": "Sandbox on"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.destroy.grab(x, y, options?)",
      "params": [
        {
          "name": "x",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Screen x in CSS pixels from the left of the page",
          "minimum": 0
        },
        {
          "name": "y",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Screen y in CSS pixels from the top",
          "minimum": 0
        },
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "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)"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.crumble": {
      "group": "destroy",
      "summary": "Crumble the whole scroll: it breaks up from the edges into a heap of dust. destroy.reset() restores it.",
      "call": "window.unroll.destroy.crumble()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "prizeOnly": {
      "group": "scrolls",
      "summary": "Show only prize eligible scrolls in the scroll picker, on or off (toggles when omitted). Returns { prizeOnly, visible }.",
      "call": "window.unroll.prizeOnly(on)",
      "params": [
        {
          "name": "on",
          "type": [
            "boolean"
          ],
          "required": true,
          "description": "Only prize eligible scrolls"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.shake": {
      "group": "destroy",
      "summary": "Sandbox only: knock the table, loosening random bits.",
      "call": "window.unroll.destroy.shake()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.stats": {
      "group": "destroy",
      "summary": "Damage numbers (masks, embers, particles, shards, puddles) and Sandbox numbers (nodes, links, breaks, debris).",
      "call": "window.unroll.destroy.stats()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "destroy.tools": {
      "group": "destroy",
      "summary": "The Destroy tools in this build.",
      "call": "window.unroll.destroy.tools()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "rollUp": {
      "group": "view",
      "summary": "Roll the scroll back up with a steady rewind (the Roll up button); false stops it. Toggles if omitted.",
      "call": "window.unroll.rollUp(on?)",
      "params": [
        {
          "name": "on",
          "type": [
            "boolean"
          ],
          "required": false,
          "description": "Rolling"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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 }.",
      "call": "window.unroll.embed.snippet(options?)",
      "params": [
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "{ state, autoplay, controls, width, height }"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "embed.info": {
      "group": "embed",
      "summary": "Embed mode: { embedded (?embed=1), framed (inside an iframe), active (the scene has the pointer and wheel), controls, autoplay }.",
      "call": "window.unroll.embed.info()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.embed.activate(on?)",
      "params": [
        {
          "name": "on",
          "type": [
            "boolean"
          ],
          "required": false,
          "description": "Activate"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.open.fromURL(url)",
      "params": [
        {
          "name": "url",
          "type": [
            "string"
          ],
          "required": true,
          "description": "http(s) URL"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "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.",
      "call": "window.unroll.open.fromFiles(files)",
      "params": [
        {
          "name": "files",
          "type": [
            "array"
          ],
          "required": true,
          "description": "Files"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "open.example": {
      "group": "open",
      "summary": "Build the synthetic example package. Returns it as base64 (options.download also saves the zip).",
      "call": "window.unroll.open.example(options?)",
      "params": [
        {
          "name": "options",
          "type": [
            "object"
          ],
          "required": false,
          "description": "{ download: boolean }"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "open.listLocal": {
      "group": "open",
      "summary": "Scrolls kept in this browser.",
      "call": "window.unroll.open.listLocal()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "open.removeLocal": {
      "group": "open",
      "summary": "Remove a scroll kept in this browser.",
      "call": "window.unroll.open.removeLocal(key)",
      "params": [
        {
          "name": "key",
          "type": [
            "string"
          ],
          "required": true,
          "description": "Local key"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "open.validate": {
      "group": "open",
      "summary": "Check a manifest object with the page validator, without opening anything.",
      "call": "window.unroll.open.validate(manifest)",
      "params": [
        {
          "name": "manifest",
          "type": [
            "object"
          ],
          "required": true,
          "description": "The manifest"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.dragRes": {
      "group": "ct",
      "summary": "Lower render resolution while the camera moves: \"off\", \"light\" (0.75x) or \"strong\" (0.5x). Without an argument, returns the state.",
      "call": "window.unroll.ct.dragRes(mode?)",
      "params": [
        {
          "name": "mode",
          "type": [
            "string"
          ],
          "required": false,
          "description": "off, light or strong",
          "enum": [
            "off",
            "light",
            "strong"
          ]
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.setWindow": {
      "group": "ct",
      "summary": "X-ray window and level on the 3D scroll, both 0..1 (low = level - window / 2).",
      "call": "window.unroll.ct.setWindow(window, level)",
      "params": [
        {
          "name": "window",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Width, 0.005..1"
        },
        {
          "name": "level",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Centre, 0..1"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.autoContrast": {
      "group": "ct",
      "summary": "X-ray auto contrast from the CT atlas percentiles (default from Settings, 1 and 99).",
      "call": "window.unroll.ct.autoContrast(pLow?, pHigh?)",
      "params": [
        {
          "name": "pLow",
          "type": [
            "number"
          ],
          "required": false,
          "description": "Low percentile"
        },
        {
          "name": "pHigh",
          "type": [
            "number"
          ],
          "required": false,
          "description": "High percentile"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.fullRange": {
      "group": "ct",
      "summary": "Show the full X-ray brightness range.",
      "call": "window.unroll.ct.fullRange()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.contrast": {
      "group": "ct",
      "summary": "X-ray window/level state.",
      "call": "window.unroll.ct.contrast()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.open": {
      "group": "ct",
      "summary": "Open the CT volume (MIP in a slab) around a papyrus point: { winding, u, v } or { voxel: [x, y, z] } or { x, y } screen point, with level? (0..5) and size? (64, 128, 192, 256). Streams OME-Zarr chunks from the public S3 bucket.",
      "call": "window.unroll.ct.open(where)",
      "params": [
        {
          "name": "where",
          "type": [
            "object"
          ],
          "required": true,
          "description": "Point"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.close": {
      "group": "ct",
      "summary": "Close the CT volume panel.",
      "call": "window.unroll.ct.close()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.state": {
      "group": "ct",
      "summary": "CT volume state: point, level, size, voxel size, source URL, slab, window, load stats, labels.",
      "call": "window.unroll.ct.state()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.setSlab": {
      "group": "ct",
      "summary": "Slab clip of the CT volume: { on, thickness (voxels), offset (voxels), tiltX, tiltY (degrees), face (point the camera down the normal) }.",
      "call": "window.unroll.ct.setSlab(slab)",
      "params": [
        {
          "name": "slab",
          "type": [
            "object"
          ],
          "required": true,
          "description": "Slab fields to change"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.volumeWindow": {
      "group": "ct",
      "summary": "Window and level of the CT volume MIP, both 0..1.",
      "call": "window.unroll.ct.volumeWindow(window, level)",
      "params": [
        {
          "name": "window",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Width"
        },
        {
          "name": "level",
          "type": [
            "number"
          ],
          "required": true,
          "description": "Centre"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.volumeAuto": {
      "group": "ct",
      "summary": "Auto contrast of the CT volume from its own histogram.",
      "call": "window.unroll.ct.volumeAuto()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.setDetail": {
      "group": "ct",
      "summary": "Detail level of the CT volume: 0 is the full 9.4 µm scan, each step halves the resolution. Reloads.",
      "call": "window.unroll.ct.setDetail(level)",
      "params": [
        {
          "name": "level",
          "type": [
            "number"
          ],
          "required": true,
          "description": "0..5"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.setSize": {
      "group": "ct",
      "summary": "Cube size of the CT volume in voxels. Reloads.",
      "call": "window.unroll.ct.setSize(size)",
      "params": [
        {
          "name": "size",
          "type": [
            "number"
          ],
          "required": true,
          "description": "64, 128, 192 or 256"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.pick": {
      "group": "ct",
      "summary": "Turn pick mode on or off: the next click on the papyrus opens the CT volume there.",
      "call": "window.unroll.ct.pick(on?)",
      "params": [
        {
          "name": "on",
          "type": [
            "boolean"
          ],
          "required": false,
          "description": "On"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.image": {
      "group": "ct",
      "summary": "The CT volume view as a PNG data URL.",
      "call": "window.unroll.ct.image()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.labels": {
      "group": "ct",
      "summary": "Labeled segments: colour every traced segment inside the CT volume cube. Returns the segments found with their colours and voxel counts.",
      "call": "window.unroll.ct.labels(on?)",
      "params": [
        {
          "name": "on",
          "type": [
            "boolean"
          ],
          "required": false,
          "description": "On"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.highlight": {
      "group": "ct",
      "summary": "Highlight one labeled segment by winding id (null clears).",
      "call": "window.unroll.ct.highlight(id)",
      "params": [
        {
          "name": "id",
          "type": [
            "string",
            "null"
          ],
          "required": true,
          "description": "Winding id, for example w035"
        }
      ],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    },
    "ct.source": {
      "group": "ct",
      "summary": "Where the CT volume of the current scroll streams from (OME-Zarr on s3://vesuvius-challenge-open-data) and the levels it has.",
      "call": "window.unroll.ct.source()",
      "params": [],
      "returns": "Promise<{ ok: true, ... } | { ok: false, error: string, code: string }>"
    }
  },
  "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 }"
  },
  "urlParams": {
    "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 millimetres",
    "shot": "In the query string: a clean frame for share images"
  },
  "keys": {
    "1": "setLayer(\"xray\")",
    "2": "setLayer(\"ink\")",
    "3": "setLayer(\"overlay\")",
    "Space": "play",
    "ArrowRight": "setUnroll(t + 0.01)",
    "ArrowLeft": "setUnroll(t - 0.01)",
    "Shift+Arrow": "setUnroll(t +/- 0.05)",
    "Home": "setUnroll(0)",
    "End": "setUnroll(1)",
    "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()"
  },
  "manifestSchema": {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "title": "Unroll scroll manifest",
    "type": "object",
    "required": [
      "schema",
      "id",
      "name",
      "voxelMM",
      "geometry",
      "windings",
      "layers"
    ],
    "properties": {
      "schema": {
        "const": "unroll-scroll/1"
      },
      "id": {
        "type": "string",
        "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
      },
      "name": {
        "type": "string",
        "maxLength": 120
      },
      "voxelMM": {
        "type": "number",
        "exclusiveMinimum": 0,
        "exclusiveMaximum": 10
      },
      "base": {
        "type": "string",
        "description": "Relative folder that paths start from"
      },
      "geometry": {
        "type": "object",
        "required": [
          "format"
        ],
        "properties": {
          "format": {
            "enum": [
              "grid-int16",
              "mesh"
            ],
            "description": "grid-int16 is for registry scrolls only"
          },
          "units": {
            "enum": [
              "voxels",
              "mm"
            ]
          },
          "axis": {
            "enum": [
              "z",
              "y"
            ]
          },
          "index": {
            "type": "string",
            "description": "Relative path inside the package: no URL, no leading slash, no \"..\""
          },
          "bin": {
            "type": "string",
            "description": "Relative path inside the package: no URL, no leading slash, no \"..\""
          }
        }
      },
      "windings": {
        "description": "A list of { id, mesh } or { range: [first, last], mesh: pattern with {id}, outermost }",
        "oneOf": [
          {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "mesh": {
                  "type": "string",
                  "description": ".obj, .ply"
                }
              }
            }
          },
          {
            "type": "object",
            "required": [
              "range"
            ]
          }
        ]
      },
      "outermost": {
        "enum": [
          "first",
          "last"
        ]
      },
      "layers": {
        "type": "array",
        "minItems": 1,
        "items": {
          "type": "object",
          "required": [
            "id",
            "role",
            "label"
          ],
          "properties": {
            "id": {
              "type": "string"
            },
            "role": {
              "enum": [
                "ct",
                "ink",
                "overlay"
              ]
            },
            "label": {
              "type": "string"
            },
            "shortLabel": {
              "type": "string",
              "description": "Label where space is tight (layer chip, compare swipe)"
            },
            "resolutionUM": {
              "type": "number",
              "exclusiveMinimum": 0,
              "description": "Resolution in micrometres"
            },
            "windings": {
              "description": "\"all\", a list of ids, or { range: [first, last] }"
            },
            "texture": {
              "type": "string",
              "description": "Per-winding image path pattern containing {id}"
            },
            "files": {
              "type": "object",
              "additionalProperties": {
                "type": "string",
                "description": "Relative path inside the package: no URL, no leading slash, no \"..\""
              },
              "description": "Winding id to image path, overriding texture"
            },
            "atlas": {
              "type": "string",
              "description": "Relative path inside the package: no URL, no leading slash, no \"..\""
            },
            "atlasFallback": {
              "type": "string",
              "description": "Relative path inside the package: no URL, no leading slash, no \"..\""
            },
            "hi": {
              "type": "string",
              "description": "Close-up image pattern containing {id}"
            },
            "stats": {
              "type": "string",
              "description": "Relative path inside the package: no URL, no leading slash, no \"..\""
            },
            "source": {
              "type": "string",
              "description": "Where the layer comes from"
            },
            "aboutHtml": {
              "type": "string",
              "description": "Registry scrolls only"
            }
          }
        },
        "description": "One layer per role; at least one ct or ink layer"
      },
      "description": {
        "type": "string"
      },
      "credits": {
        "type": "object",
        "properties": {
          "text": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "citation": {
            "type": "string"
          },
          "holder": {
            "type": "string"
          }
        }
      },
      "dataTerms": {
        "type": "object",
        "properties": {
          "license": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "notes": {
            "type": "string"
          }
        }
      },
      "display": {
        "type": "object",
        "description": "Registry scrolls only"
      }
    },
    "x-imageExtensions": [
      "png",
      "jpg",
      "jpeg",
      "webp",
      "tif",
      "tiff"
    ],
    "x-meshExtensions": [
      "obj",
      "ply"
    ],
    "x-limits": {
      "openYourOwn": {
        "maxFileBytes": 67108864,
        "maxTotalBytes": 268435456,
        "maxZipBytes": 134217728,
        "maxWindings": 64,
        "maxVertices": 2000000,
        "maxTextureSide": 8192
      },
      "registry": {
        "maxFileBytes": 209715200,
        "maxTotalBytes": 2000000000,
        "maxZipBytes": 134217728,
        "maxWindings": 400,
        "maxVertices": 2000000,
        "maxTextureSide": 8192
      }
    }
  }
}
