// Control panel layout: groups the existing buttons (moved, never cloned, so every id, listener and
// window hook in app.js, polish.js and lib/bench.js keeps working), adds icon + text labels,
// shortcut tooltips, pressed states and measured offsets so nothing overlaps on small screens.
const $ = (id) => document.getElementById(id);
const nav = document.querySelector('nav.hud.tr');
const KEYS = {
btnHelp: '?', btnInfo: 'I', btnRead: 'R', btnRollUp: 'U', btnTear: 'T', toolTear: 'X', btnCrush: 'C',
toolBurn: 'B', toolWater: 'W', btnCrumble: 'K', play: 'Space', uiReset: 'V', uiFull: 'F', btnSettings: 'S',
};
const LAYER_KEYS = { xray: '1', ink: '2', gpu: '3' };
const LABELS = { btnSettings: 'Settings', btnHelp: 'Help', btnOpen: 'Open', btnBench: 'Ink Bench', btnInfo: 'About', btnShot: 'Picture' };
function group(name, cls) {
const g = document.createElement('section');
g.className = `ui-group ${cls}`;
g.setAttribute('aria-label', name);
g.innerHTML = `
${name}
`;
return g;
}
function mk(id, label, title, onclick) {
const b = document.createElement('button');
b.className = 'icon ui-btn'; b.id = id; b.type = 'button'; b.title = title;
b.innerHTML = `${label}`;
b.onclick = onclick;
return b;
}
function build() {
if (!nav || nav.dataset.ui) return;
nav.dataset.ui = '1';
document.body.classList.add('ui2');
nav.setAttribute('aria-label', 'Controls');
const tabs = nav.querySelector('.seg[role=tablist]');
const hint = $('layerHint'), why = $('layerWhy');
const destroy = nav.querySelector('.tear-seg');
const tools = nav.querySelector('.tools');
// View: camera, full screen, help, about, picture, open your own, bench
const gView = group('View', 'ui-view');
const reset = mk('uiReset', 'Reset view', 'Reset the camera view (V)', () => window.unrollView?.reset?.());
const full = mk('uiFull', 'Full screen', 'Full screen (F)', () => {
if (document.fullscreenElement) document.exitFullscreen?.(); else document.documentElement.requestFullscreen?.().catch(() => {});
});
addEventListener('fullscreenchange', () => full.setAttribute('aria-pressed', !!document.fullscreenElement));
full.setAttribute('aria-pressed', 'false');
const vgrid = document.createElement('div'); vgrid.className = 'ui-grid';
const gear = mk('btnSettings', 'Settings', 'Settings: quality, motion, speed, glow, background, HUD, sensitivity, units (S)', () => toggleSettings());
gear.setAttribute('aria-haspopup', 'dialog'); gear.setAttribute('aria-expanded', 'false'); gear.setAttribute('aria-controls', 'uiSettings');
const inst = mk('uiInstall', 'Install app', 'Install Unroll as an app that works offline', () => runAction('install'));
inst.hidden = !pwa.canOffer();
vgrid.append(gear, reset, full, inst);
if (tools) { for (const b of [...tools.children]) { if (LABELS[b.id]) for (const n of [...b.childNodes]) if (n.nodeType === 3) n.remove();
if (LABELS[b.id] && !b.querySelector('.ui-lbl')) b.insertAdjacentHTML('beforeend', `${LABELS[b.id]}`); b.classList.add('ui-btn'); vgrid.append(b); } tools.remove(); }
gView.append(vgrid);
// Layers
const gLayers = group('Layers', 'ui-layers');
gLayers.append(tabs); if (hint) gLayers.append(hint); if (why) gLayers.append(why);
// Destroy tools
const gDestroy = group('Destroy', 'ui-destroy');
if (destroy) gDestroy.append(destroy);
// Scrolls: the picker from the header, shown only when there is more than one
const pick = $('scrollPick');
const gScrolls = group('Scrolls', 'ui-scrolls');
if (pick) {
gScrolls.append(pick);
const sync = () => { gScrolls.hidden = pick.hidden; };
new MutationObserver(sync).observe(pick, { attributes: true, attributeFilter: ['hidden'] }); sync();
}
// mobile: a toggle that folds the panel down to the layer picker
const fold = document.createElement('button');
fold.className = 'chip ui-fold'; fold.type = 'button'; fold.id = 'uiFold';
fold.setAttribute('aria-expanded', 'false'); fold.setAttribute('aria-controls', 'uiBody');
fold.innerHTML = 'More controls';
fold.title = 'Show or hide View, Destroy and Scrolls';
const body = document.createElement('div'); body.className = 'ui-body'; body.id = 'uiBody';
body.append(gScrolls, gView, gDestroy);
fold.onclick = () => { const o = fold.getAttribute('aria-expanded') !== 'true'; fold.setAttribute('aria-expanded', o); nav.classList.toggle('ui-open', o); measure(); };
const bench = vgrid.querySelector('#btnBench');
if (bench) {
bench.classList.add('ui-bench'); bench.title = 'Ink Bench: compare and score ink detection models on a real winding (in your browser, nothing is uploaded)';
bench.setAttribute('aria-label', 'Ink Bench: compare and score ink detection models');
}
nav.replaceChildren(gLayers, ...(bench ? [bench] : []), fold, body);
// feature modules (lib/ctwl.js, lib/ctvolume.js) add their own groups to the panel body
window.unrollPanel = { body, group, mk, measure, addAction: (name, a) => { ACTIONS[name] = a; tagActions(); } };
dispatchEvent(new CustomEvent('unroll:panel', { detail: window.unrollPanel }));
// Unroll and Roll up: label the bottom bar group
const bar = document.querySelector('footer.hud.bottom');
if (bar) { bar.setAttribute('aria-label', 'Unroll and Roll up'); bar.setAttribute('role', 'group'); }
// shortcuts: aria-keyshortcuts and a kbd badge in the tooltip
for (const [id, k] of Object.entries(KEYS)) $(id)?.setAttribute('aria-keyshortcuts', k === 'Space' ? 'Space' : k);
for (const b of document.querySelectorAll('.chip[data-layer]')) b.setAttribute('aria-keyshortcuts', LAYER_KEYS[b.dataset.layer] || '');
for (const b of document.querySelectorAll('.chip[data-layer]')) b.setAttribute('aria-selected', b.classList.contains('on'));
new MutationObserver((ms) => {
for (const m of ms) { const b = m.target; if (b.matches?.('.chip[data-layer]')) b.setAttribute('aria-selected', b.classList.contains('on')); }
}).observe(nav, { subtree: true, attributes: true, attributeFilter: ['class'] });
tagActions();
buildSettings();
tips();
measure();
new ResizeObserver(measure).observe(nav);
const tl = document.querySelector('.hud.tl'); if (tl) new ResizeObserver(measure).observe(tl);
addEventListener('resize', measure);
}
// a tooltip that shows the button's help with its shortcut; native titles are parked while it shows
function tips() {
const tip = document.createElement('div');
tip.className = 'ui-tip mono'; tip.hidden = true; tip.setAttribute('role', 'tooltip'); tip.id = 'uiTip';
document.body.append(tip);
let cur = null;
const keyOf = (b) => b.getAttribute('aria-keyshortcuts') || '';
const show = (b) => {
if (matchMedia('(pointer: coarse)').matches && !b.matches(':focus-visible')) return;
const t = b.title || b.dataset.uiTitle || '';
if (!t) return;
if (b.title) { b.dataset.uiTitle = b.title; b.removeAttribute('title'); }
const k = keyOf(b);
tip.innerHTML = '';
const s = document.createElement('span'); s.textContent = t.replace(/\s*\(([A-Z?]|Space)\)\s*$/, ''); tip.append(s);
if (k) { const kb = document.createElement('kbd'); kb.textContent = k; tip.append(kb); }
tip.hidden = false; cur = b;
b.setAttribute('aria-describedby', 'uiTip');
const r = b.getBoundingClientRect(), w = tip.offsetWidth, h = tip.offsetHeight;
let x = Math.min(innerWidth - w - 8, Math.max(8, r.left + r.width / 2 - w / 2));
let y = r.top - h - 8; if (y < 8) y = r.bottom + 8;
if (nav.contains(b) && innerWidth > 720) { x = Math.max(8, r.left - w - 10); y = Math.min(innerHeight - h - 8, Math.max(8, r.top + r.height / 2 - h / 2)); }
tip.style.left = `${x}px`; tip.style.top = `${y}px`;
};
const hide = (b) => {
if (b && b.dataset.uiTitle && !b.title) b.title = b.dataset.uiTitle;
if (b) b.removeAttribute('aria-describedby');
tip.hidden = true; cur = null;
};
const sel = 'button[title], button[data-ui-title]';
document.addEventListener('pointerover', (e) => { const b = e.target.closest?.(sel); if (b && b !== cur) { if (cur) hide(cur); show(b); } });
document.addEventListener('pointerout', (e) => { const b = e.target.closest?.(sel); if (b && !b.contains(e.relatedTarget)) hide(b); });
document.addEventListener('focusin', (e) => { const b = e.target.closest?.(sel); if (b) show(b); });
document.addEventListener('focusout', (e) => { const b = e.target.closest?.(sel); if (b) hide(b); });
document.addEventListener('pointerdown', () => cur && hide(cur), true);
}
// measured offsets: the readout and credit sit above or below the panel instead of under it
function measure() {
const st = document.documentElement.style;
st.setProperty('--ui-panel-h', `${Math.ceil(nav.getBoundingClientRect().height)}px`);
const tl = document.querySelector('.hud.tl');
const cr = document.querySelector('.credit');
const hb = Math.max(tl ? tl.getBoundingClientRect().bottom : 0, cr && innerWidth <= 720 ? cr.getBoundingClientRect().bottom : 0);
if (tl) st.setProperty('--ui-head-b', `${Math.ceil(tl.getBoundingClientRect().bottom)}px`);
// the Read box sits 10 px above the docked panel on phones; the panel's max-height leaves room for it
const ro = document.getElementById('readout'), rh = ro ? ro.getBoundingClientRect().height : 0;
st.setProperty('--ui-bl-h', `${rh ? Math.ceil(rh) + 10 : 0}px`);
st.setProperty('--ui-top-b', `${Math.ceil(hb)}px`);
}
// polish.js adds the help button after load; wait for it so it lands in the View group
// ---------------------------------------------------------------- actions: one stable name per control
// Every control gets data-action, an aria-label and a function in window.unrollView.actions.
const ACTIONS = {
play: { id: 'play', label: 'Play or pause the unroll', run: (on) => { const p = isPlaying(); if (on === undefined || !!on !== p) $('play').click(); } },
rollUp: { id: 'btnRollUp', label: 'Roll it back up (again to stop)' },
seek: { label: 'Set the unroll position, 0 rolled to 1 unrolled', run: (v) => { const r = $('scrubIn'); r.value = Math.round(Math.max(0, Math.min(1, +v || 0)) * 1000); r.dispatchEvent(new Event('input', { bubbles: true })); r.dispatchEvent(new Event('change', { bubbles: true })); } },
'layer.xray': { sel: '.chip[data-layer=xray]', label: 'Layer: X-ray' },
'layer.ink': { sel: '.chip[data-layer=ink]', label: 'Layer: published ink' },
'layer.gpu': { sel: '.chip[data-layer=gpu]', label: 'Layer: ink_9um (9 µm)' },
read: { id: 'btnRead', label: 'Read the current winding close up' },
resetView: { id: 'uiReset', label: 'Reset the camera view' },
fullscreen: { id: 'uiFull', label: 'Full screen on or off' },
install: { id: 'uiInstall', label: 'Install Unroll as an app (works offline)', run: () => pwa.install() },
settings: { id: 'btnSettings', label: 'Open or close Settings', run: (on) => toggleSettings(on) },
help: { id: 'btnHelp', label: 'Keyboard shortcuts' },
about: { id: 'btnInfo', label: 'About this page' },
open: { id: 'btnOpen', label: 'Open your own scroll' },
bench: { id: 'btnBench', label: 'Ink Bench' },
picture: { id: 'btnShot', label: 'Save a picture' },
destroy: { id: 'btnTear', label: 'Destroy mode on or off', toggle: true },
'tool.tear': { id: 'toolTear', label: 'Destroy tool: Tear', needs: 'destroy' },
'tool.crush': { id: 'btnCrush', label: 'Destroy tool: Crush', needs: 'destroy' },
'tool.burn': { id: 'toolBurn', label: 'Destroy tool: Burn', needs: 'destroy' },
'tool.water': { id: 'toolWater', label: 'Destroy tool: Water', needs: 'destroy' },
'damage.reset': { id: 'btnReset', label: 'Undo all damage', needs: 'destroy' },
crumble: { id: 'btnCrumble', label: 'Crumble the whole scroll to dust', run: () => window.unrollView?.crumble?.() },
sandbox: { id: 'btnSandbox', label: 'Sandbox fracture physics on or off', needs: 'destroy', toggle: true },
shake: { id: 'btnShake', label: 'Knock the table', needs: 'sandbox' },
sound: { id: 'btnSound', label: 'Crackle sound on or off', needs: 'destroy', toggle: true },
skipIntro: { id: 'skipIntro', label: 'Skip the intro and start unrolling', run: () => window.unrollView?.skipIntro?.() ?? false },
moreControls: { id: 'uiFold', label: 'Show or hide View, Destroy and Scrolls (small screens)' },
prizeOnly: { id: 'prizeOnly', label: 'Show only prize eligible scrolls', toggle: true },
scroll: { label: 'Switch scroll by id (see window.unrollScrolls)', run: (id) => { const s = $('scrollSel'); if (!s || ![...s.options].some((o) => o.value === id)) return false; s.value = id; s.dispatchEvent(new Event('change', { bubbles: true })); return true; } },
};
const elOf = (a) => (a.id ? $(a.id) : a.sel ? document.querySelector(a.sel) : null);
const isPlaying = () => $('play')?.classList.contains('playing');
const pressed = (el) => el?.classList.contains('on') || el?.getAttribute('aria-pressed') === 'true';
function runAction(name, arg) {
const a = ACTIONS[name]; if (!a) throw new Error(`unknown action ${name}; see unrollView.actions.list()`);
if (a.needs === 'destroy' && !pressed($('btnTear'))) $('btnTear').click();
if (a.needs === 'sandbox' && !pressed($('btnSandbox'))) runAction('sandbox', true);
if (a.run) return a.run(arg) ?? true;
const el = elOf(a); if (!el || el.hidden) return false;
if (a.toggle && arg !== undefined && !!arg === pressed(el)) return true;
el.click(); return true;
}
function tagActions() {
for (const [name, a] of Object.entries(ACTIONS)) {
const el = elOf(a); if (!el) continue;
el.dataset.action = name;
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', a.label);
}
}
// ---------------------------------------------------------------- settings
const STORE = 'unroll.settings.v1';
const SETTINGS = {
quality: { label: 'Graphics quality', type: 'select', options: ['low', 'medium', 'high'], def: matchMedia('(pointer: coarse)').matches ? 'medium' : 'high' },
reducedMotion: { label: 'Reduced motion', type: 'bool', def: matchMedia('(prefers-reduced-motion: reduce)').matches },
speed: { label: 'Animation speed', type: 'range', min: 0.25, max: 4, step: 0.25, def: 1, unit: 'x' },
bloom: { label: 'Glow intensity', type: 'range', min: 0, max: 2, step: 0.1, def: 1, unit: 'x' },
background: { label: 'Background', type: 'select', options: ['dark', 'black', 'slate', 'warm'], def: 'dark' },
hud: { label: 'Show HUD (title, readout, credit)', type: 'bool', def: true },
labels: { label: 'Show button labels', type: 'bool', def: true },
sensitivity: { label: 'Mouse and touch sensitivity', type: 'range', min: 0.25, max: 3, step: 0.25, def: 1, unit: 'x' },
inkBench: { label: 'Show the Ink Bench button', type: 'bool', def: true },
units: { label: 'Units', type: 'select', options: ['metric', 'imperial'], def: 'metric' },
ctAuto: { label: 'X-ray auto contrast on load', type: 'bool', def: false },
ctAutoLow: { label: 'Auto contrast low percentile', type: 'range', min: 0, max: 20, step: 0.5, def: 1, unit: '%' },
ctAutoHigh: { label: 'Auto contrast high percentile', type: 'range', min: 80, max: 100, step: 0.5, def: 99, unit: '%' },
ctVolSize: { label: 'CT volume cube size (voxels)', type: 'select', options: ['auto', '64', '128', '192', '256'], def: 'auto' },
ctVolDetail: { label: 'CT volume detail level (0 = full 9.4 µm scan)', type: 'select', options: ['auto', '0', '1', '2', '3', '4'], def: 'auto' },
dragRes: { label: 'Lower resolution while dragging', type: 'select', options: ['off', 'light', 'strong'], def: matchMedia('(pointer: coarse)').matches ? 'strong' : 'light' },
};
const BG = { dark: 0x030303, black: 0x000000, slate: 0x10161d, warm: 0x16100a };
let S = {};
let base = null;
function coerce(k, v) {
const d = SETTINGS[k]; if (!d) throw new Error(`unknown setting ${k}; see unrollView.settings.list()`);
if (d.type === 'bool') return v === true || v === 'true' || v === '1' || v === 1 || v === 'on' || v === 'yes';
if (d.type === 'range') { const n = +v; if (!Number.isFinite(n)) throw new Error(`${k} needs a number`); return Math.min(d.max, Math.max(d.min, n)); }
if (!d.options.includes(String(v))) throw new Error(`${k} must be one of ${d.options.join(', ')}`);
return String(v);
}
function apply() {
const g = window.unrollView?.gfx;
if (g && !base) base = { rot: g.controls.rotateSpeed, zoom: g.controls.zoomSpeed, pan: g.controls.panSpeed, bloom: g.bloom.strength, damp: g.controls.enableDamping };
if (g) {
const pr = { low: 0.75, medium: Math.min(devicePixelRatio, 1.25), high: Math.min(devicePixelRatio, 2) }[S.quality];
if (g.renderer.getPixelRatio() !== pr) { g.renderer.setPixelRatio(pr); g.composer?.setPixelRatio?.(pr); g.renderer.setSize(innerWidth, innerHeight); g.composer?.setSize?.(innerWidth, innerHeight); }
g.bloom.enabled = S.quality !== 'low' && S.bloom > 0;
g.bloom.strength = base.bloom * S.bloom;
g.renderer.setClearColor(BG[S.background], 1);
g.controls.rotateSpeed = base.rot * S.sensitivity; g.controls.zoomSpeed = base.zoom * S.sensitivity; g.controls.panSpeed = base.pan * S.sensitivity;
g.controls.enableDamping = S.reducedMotion ? false : base.damp;
}
window.__uiSpeed = S.reducedMotion ? Math.min(S.speed, 1) : S.speed;
const c = document.body.classList;
c.toggle('ui-nobench', !S.inkBench);
c.toggle('ui-reduced', S.reducedMotion); c.toggle('ui-nohud', !S.hud); c.toggle('ui-nolabels', !S.labels);
document.body.dataset.bg = S.background;
units();
dispatchEvent(new CustomEvent('unroll:settings', { detail: { ...S } })); // feature modules (lib/dragres.js, lib/ct*.js) listen
for (const [k, d] of Object.entries(SETTINGS)) {
const el = $(`set-${k}`); if (!el) continue;
if (d.type === 'bool') el.checked = S[k]; else el.value = S[k];
const o = $(`set-${k}-out`); if (o) o.textContent = `${S[k]}${d.unit || ''}`;
}
}
function save() { try { localStorage.setItem(STORE, JSON.stringify(S)); } catch {} }
function load() {
let saved = {}; try { saved = JSON.parse(localStorage.getItem(STORE) || '{}'); } catch {}
for (const [k, d] of Object.entries(SETTINGS)) { try { S[k] = k in saved ? coerce(k, saved[k]) : d.def; } catch { S[k] = d.def; } }
// URL parameters win for this visit and are not saved: ?quality=low&speed=2&hud=0
const q = new URLSearchParams(location.search);
for (const k of Object.keys(SETTINGS)) if (q.has(k)) { try { S[k] = coerce(k, q.get(k)); } catch (e) { console.warn(e.message); } }
}
const settingsApi = {
list: () => Object.entries(SETTINGS).map(([key, d]) => ({ key, ...d, value: S[key] })),
get: (k) => { if (k === undefined) return { ...S }; if (!SETTINGS[k]) throw new Error(`unknown setting ${k}`); return S[k]; },
set: (k, v) => { if (typeof k === 'object') { for (const [a, b] of Object.entries(k)) S[a] = coerce(a, b); } else S[k] = coerce(k, v); apply(); save(); return { ...S }; },
reset: () => { for (const [k, d] of Object.entries(SETTINGS)) S[k] = d.def; apply(); save(); return { ...S }; },
open: () => toggleSettings(true), close: () => toggleSettings(false),
};
// metric or imperial in the readout (the page itself always measures in mm and m)
let unitBusy = false;
function units() {
for (const id of ['rRad', 'rLen']) {
const el = $(id); if (!el) continue;
if (!el.dataset.uiObs) { el.dataset.uiObs = '1'; new MutationObserver(() => { if (!unitBusy) { el.dataset.metric = el.textContent; units(); } }).observe(el, { childList: true, characterData: true, subtree: true }); el.dataset.metric = el.textContent; }
const m = el.dataset.metric || el.textContent;
const out = S.units === 'imperial'
? m.replace(/([\d.]+)(\s*\/\s*)([\d.]+)\s*m\b/, (_, a, s, b) => `${(a * 3.2808).toFixed(2)}${s}${(b * 3.2808).toFixed(2)} ft`)
.replace(/([\d.]+)\s*mm\b/, (_, a) => `${(a / 25.4).toFixed(2)} in`).replace(/([\d.]+)\s*m\b/, (_, a) => `${(a * 3.2808).toFixed(2)} ft`)
: m;
if (el.textContent !== out) { unitBusy = true; el.textContent = out; queueMicrotask(() => { unitBusy = false; }); }
}
}
let settingsOpener = null;
function toggleSettings(force) {
const p = $('uiSettings'); if (!p) return false;
const show = force ?? p.hidden;
if (show && p.hidden) { settingsOpener = document.activeElement; p.hidden = false; $('set-quality')?.focus(); }
else if (!show && !p.hidden) { p.hidden = true; settingsOpener?.focus?.(); }
$('btnSettings')?.setAttribute('aria-expanded', !p.hidden);
$('btnSettings')?.setAttribute('aria-pressed', !p.hidden);
return !p.hidden;
}
function buildSettings() {
const p = document.createElement('section');
p.id = 'uiSettings'; p.className = 'sheet about ui-settings'; p.hidden = true;
p.setAttribute('role', 'dialog'); p.setAttribute('aria-modal', 'false'); p.setAttribute('aria-labelledby', 'uiSetTitle');
const row = (k, d) => {
if (d.type === 'select') return ``;
if (d.type === 'bool') return ``;
return ``;
};
const keys = document.querySelector('#help dl.keys');
p.innerHTML = `
App
Saved in this browser. URL parameters such as ?quality=low&speed=2 override them for one visit.
Keyboard shortcuts
${keys ? keys.innerHTML : ''}- S
- Settings
`;
document.body.append(p);
$('uiSetClose').onclick = () => toggleSettings(false);
$('uiSetReset').onclick = () => settingsApi.reset();
$('uiSetInstall').onclick = () => runAction('install');
pwa.paint();
p.addEventListener('input', (e) => { const k = e.target.dataset.setting; if (k) settingsApi.set(k, e.target.type === 'checkbox' ? e.target.checked : e.target.value); });
p.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); toggleSettings(false); } });
addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest?.('textarea,select,input:not([type=range]):not([type=checkbox])')) return;
if (e.key === 's' || e.key === 'S') { e.preventDefault(); toggleSettings(); }
if (e.key === 'Escape') toggleSettings(false);
});
load(); apply();
}
// ---------------------------------------------------------------- state and URL actions
function state() {
const layer = document.querySelector('.chip[data-layer].on')?.dataset.layer || null;
const tool = document.querySelector('.chip.tool.on')?.dataset.tool || null;
const vis = (id) => !!$(id) && !$(id).hidden;
return {
scroll: $('scrollSel')?.value || null,
layer, layers: [...document.querySelectorAll('.chip[data-layer]')].filter((b) => !b.hidden).map((b) => b.dataset.layer),
position: +(($('scrubIn')?.value || 0) / 1000).toFixed(3),
playing: !!isPlaying(), rollingUp: pressed($('btnRollUp')),
winding: $('rWind')?.textContent || null,
destroy: pressed($('btnTear')), tool: pressed($('btnTear')) ? tool : null,
sandbox: pressed($('btnSandbox')), sound: pressed($('btnSound')),
panels: { settings: vis('uiSettings'), help: vis('help'), about: vis('about'), open: vis('openSheet'), closeup: vis('focus'), bench: !!document.querySelector('.bench:not([hidden]), #bench:not([hidden])') },
fullscreen: !!document.fullscreenElement,
settings: { ...S },
actions: Object.keys(ACTIONS),
};
}
const uiApi = {
settings: settingsApi,
actions: { list: () => Object.entries(ACTIONS).map(([name, a]) => ({ name, label: a.label, control: a.id || a.sel || null, arg: a.toggle ? 'optional boolean' : name === 'seek' ? 'number 0..1' : name === 'scroll' ? 'scroll id' : name === 'play' ? 'optional boolean' : null })), run: runAction },
action: runAction,
pwa: { state: () => pwa.state(), install: () => pwa.install(), version: () => pwa.version },
state, stateJSON: () => JSON.stringify(state()),
};
// window.unroll.ui: the same controls through the page API (every call returns { ok, ... })
const uiMethods = {
settings: () => ({ settings: { ...S } }),
listSettings: () => ({ settings: settingsApi.list() }),
setSetting: (k, v) => ({ settings: settingsApi.set(k, v) }),
resetSettings: () => ({ settings: settingsApi.reset() }),
settingsPanel: (on) => ({ open: toggleSettings(on) }),
action: (name, arg) => ({ action: name, done: runAction(name, arg) }),
actions: () => ({ actions: uiApi.actions.list() }),
moreControls: (on) => { const f = $('uiFold'); if (!f) return { open: false }; const o = f.getAttribute('aria-expanded') === 'true'; if (on === undefined || !!on !== o) f.click(); return { open: f.getAttribute('aria-expanded') === 'true' }; },
skipIntro: () => ({ skipped: !!(window.unrollView?.skipIntro?.() ?? false) }),
glossary: (q) => ({ opened: openGlossary(q) }),
glossarySearch: (q) => { const el = $('uiGlossQ'); if (el) { el.value = String(q ?? ''); el.dispatchEvent(new Event('input', { bubbles: true })); } return { terms: glossaryApi.search(q) }; },
install: async () => ({ outcome: await pwa.install(), state: pwa.state() }),
installState: () => ({ state: pwa.state(), version: pwa.version, offline: !!navigator.serviceWorker?.controller }),
state: () => ({ state: state() }),
};
const uiSpecs = {
settings: { summary: 'The current Settings values.' },
listSettings: { summary: 'Every setting with its type, options or range, default and value.' },
setSetting: { summary: 'Set one setting (key, value) or several ({ key: value }); saved in this browser.', params: [{ name: 'key', type: 'string|object', desc: 'quality, reducedMotion, speed, bloom, background, hud, labels, sensitivity, inkBench, units' }, { name: 'value', type: 'any', desc: 'The value', required: false }] },
resetSettings: { summary: 'Reset Settings to the defaults.' },
settingsPanel: { summary: 'Open or close the Settings panel (toggles when omitted).', params: [{ name: 'on', type: 'boolean', desc: 'Open', required: false }] },
action: { summary: 'Run a control by its stable action name (see ui.actions()).', params: [{ name: 'name', type: 'string', desc: 'Action name' }, { name: 'arg', type: 'any', desc: 'Optional argument', required: false }] },
actions: { summary: 'Every control action with its label and argument.' },
moreControls: { summary: 'Small screens: show or hide the View, Destroy and Scrolls groups.', params: [{ name: 'on', type: 'boolean', desc: 'Show', required: false }] },
skipIntro: { summary: 'Skip the opening animation.' },
glossary: { summary: 'Open the glossary under About, optionally at a term.', params: [{ name: 'term', type: 'string', desc: 'Term', required: false }] },
glossarySearch: { summary: 'Filter the glossary by text; returns the matching terms.', params: [{ name: 'query', type: 'string', desc: 'Text' }] },
install: { summary: 'Install Unroll as an app that works offline (browser prompt, or a new tab when framed); returns the outcome.' },
installState: { summary: 'Install state (installed, ready, framed, ios, unavailable), the cached version and whether the service worker controls the page.' },
state: { summary: 'The control panel state: layer, position, Destroy, panels, settings and actions.' },
};
// data-testid and data-api on every control this file adds, for tests/coverage.mjs
function tagApi() {
const T = (el, id, api) => { if (el && !el.dataset.testid) { el.dataset.testid = id; el.dataset.api = api; } };
T($('uiFold'), 'ui-more-controls', 'ui.moreControls()');
T($('btnSettings'), 'settings', 'ui.settingsPanel()');
T($('uiReset'), 'camera-reset', 'camera.reset()');
T($('uiFull'), 'fullscreen-panel', 'fullscreen()');
T($('uiSetClose'), 'settings-close', 'ui.settingsPanel(false)');
T($('uiSetReset'), 'settings-reset', 'ui.resetSettings()');
T($('skipIntro'), 'skip-intro', 'ui.skipIntro()');
T($('uiInstall'), 'install-app', 'ui.install()');
T($('uiSetInstall'), 'settings-install-app', 'ui.install()');
T($('uiGlossOpen'), 'glossary-open', 'ui.glossary()');
T(document.querySelector('#uiGloss > summary'), 'glossary-toggle', 'ui.glossary()');
T($('uiGlossQ'), 'glossary-search', 'ui.glossarySearch(query)');
T($('uiGlossQ')?.closest('label'), 'glossary-search-label', 'ui.glossarySearch(query)');
T(document.querySelector('#uiBenchAbout button'), 'bench-about', 'bench.open()');
T(document.querySelector('.ui-bench-link'), 'bench-lede', 'bench.open()');
for (const k of Object.keys(SETTINGS)) { const el = $(`set-${k}`); T(el, `setting-${k}`, `ui.setSetting('${k}', value)`); T(el?.closest('label'), `setting-${k}-label`, `ui.setSetting('${k}', value)`); }
}
let registered = false;
function expose() {
const v = window.unrollView; if (!v) return false;
Object.assign(v, uiApi);
tagApi();
const u = window.unroll;
if (!registered && u && typeof u.register === 'function') { registered = true; if (!u.ui) u.register('ui', uiMethods, uiSpecs); }
return true;
}
// URL actions after load: ?layer=gpu&destroy=1&tool=burn&t=0.5&panel=settings
function urlActions() {
const q = new URLSearchParams(location.search);
if (q.has('layer')) runAction(`layer.${q.get('layer')}`);
if (q.get('destroy') === '1' || q.has('tool')) runAction('destroy', true);
if (q.has('tool')) runAction(`tool.${q.get('tool')}`);
if (q.has('pos')) runAction('seek', q.get('pos'));
if (q.get('panel') === 'settings') toggleSettings(true);
if (q.get('play') === '1') runAction('play', true);
}
function skipButton() {
if (!window.unrollView?.introActive?.() || $('skipIntro')) return;
const b = document.createElement('button');
b.id = 'skipIntro'; b.type = 'button'; b.className = 'chip ui-skip'; b.dataset.action = 'skipIntro';
b.setAttribute('aria-label', 'Skip intro'); b.setAttribute('aria-keyshortcuts', 'Enter'); b.title = 'Skip the intro (Enter)';
b.textContent = 'Skip intro';
b.onclick = () => window.unrollView.skipIntro();
document.body.append(b); tagApi();
addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === 'Escape') && $('skipIntro') && !e.target.closest?.('input,select,textarea,button')) window.unrollView.skipIntro(); });
}
function benchIntro() {
const lede = document.querySelector('.hud.tl .lede');
if (lede && !lede.querySelector('.ui-bench-link')) lede.insertAdjacentHTML('beforeend', ' Score your own ink model in the Ink Bench.');
lede?.querySelector('.ui-bench-link')?.addEventListener('click', (e) => { e.preventDefault(); $('btnBench')?.click(); });
const ab = document.querySelector('#about .about-body');
if (ab && !$('uiBenchAbout')) ab.insertAdjacentHTML('afterbegin', 'Ink Bench: compare and score ink detection models against the published ink on a real winding.
');
let seen = false; try { seen = localStorage.getItem('unroll.benchHint') === '1'; localStorage.setItem('unroll.benchHint', '1'); } catch {}
const b = $('btnBench');
if (!seen && b && S.inkBench) {
const h = document.createElement('div'); h.className = 'ui-hint mono'; h.id = 'uiBenchHint'; h.setAttribute('role', 'status');
h.textContent = 'New: the Ink Bench scores ink detection models. Try it here.';
document.body.append(h);
const r = b.getBoundingClientRect();
h.style.top = `${Math.max(8, innerWidth > 720 ? r.top : r.top - 52)}px`;
h.style[innerWidth > 720 ? 'right' : 'left'] = innerWidth > 720 ? `${innerWidth - r.left + 10}px` : `${r.left}px`;
setTimeout(() => h.remove(), 7000);
}
}
function ready() {
if ($('btnHelp') && window.unrollView && $('btnTear') && (!$('loader') || $('loader').classList.contains('gone'))) { build(); expose(); skipButton(); benchIntro(); tagApi(); urlActions(); setInterval(expose, 2000); }
else setTimeout(ready, 60);
}
setTimeout(ready, 0); // after the whole module has run, so pwa (defined below) exists
// ---------------------------------------------------------------- glossary (glossary.json), under About
let GLOSS = [];
const glossaryApi = {
list: () => GLOSS.map((t) => ({ ...t })),
get: (q) => { const s = String(q).toLowerCase(); return GLOSS.find((t) => t.id === s || t.term.toLowerCase() === s || t.aliases.some((a) => a.toLowerCase() === s)) || null; },
search: (q) => { const s = String(q || '').toLowerCase(); return GLOSS.filter((t) => (t.term + ' ' + t.aliases.join(' ') + ' ' + t.def).toLowerCase().includes(s)); },
open: (q) => openGlossary(q),
json: () => JSON.stringify(GLOSS),
};
uiApi.glossary = glossaryApi;
(ACTIONS.glossary = { label: 'Open the glossary, optionally at a term', run: (q) => openGlossary(q) });
const escH = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
function openGlossary(q) {
const ab = $('about'); if (!ab) return false;
if (ab.hidden) $('btnInfo')?.click();
const g = $('uiGloss'); if (!g) return false;
g.open = true;
const t = q ? glossaryApi.get(q) : null;
const el = t ? $(`g-${t.id}`) : $('uiGlossQ');
el?.scrollIntoView({ block: 'center' });
if (t) { el.classList.add('ui-flash'); setTimeout(() => el.classList.remove('ui-flash'), 1600); el.focus(); } else el?.focus();
return t ? t.id : true;
}
function buildGlossary() {
const body = document.querySelector('#about .about-body'); if (!body || $('uiGloss')) return;
const d = document.createElement('details'); d.id = 'uiGloss'; d.className = 'ui-gloss';
d.innerHTML = `Glossary
${GLOSS.map((t) => `- ${escH(t.term)}
- ${escH(t.def)}
`).join('')}
Also at glossary.json and window.unrollView.glossary.
`;
body.prepend(d);
$('uiGlossQ').addEventListener('input', (e) => {
const hit = new Set(glossaryApi.search(e.target.value).map((t) => t.id));
for (const dt of d.querySelectorAll('dt')) { const on = hit.has(dt.id.slice(2)); dt.hidden = !on; dt.nextElementSibling.hidden = !on; }
});
const btn = document.createElement('p');
btn.innerHTML = '';
body.prepend(btn); $('uiGlossOpen').onclick = () => openGlossary();
tagApi();
}
// dotted underline links from jargon in the page text to its glossary entry (every occurrence)
function linkTerms(root, done = new Set()) {
if (!root || !GLOSS.length) return;
const pairs = GLOSS.flatMap((t) => [t.term, ...t.aliases].map((a) => [a, t])).filter(([a]) => a.length > 2).sort((a, b) => b[0].length - a[0].length);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: (n) => (n.parentElement.closest('a,button,select,option,kbd,code,summary,.ui-gloss,input,label') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT) });
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
for (const n of nodes) {
for (const [a, t] of pairs) {
const re = new RegExp(`(^|[^\\w])(${a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?![\\w])`, 'i');
const m = re.exec(n.data); if (!m) continue;
const at = m.index + m[1].length;
const rest = n.splitText(at); const after = rest.splitText(m[2].length);
const link = document.createElement('a');
link.className = 'ui-term'; link.href = `#g-${t.id}`; link.dataset.term = t.id; link.title = `${t.term}: ${t.def}`; link.textContent = rest.data;
rest.replaceWith(link);
return linkTerms(root, done);
}
}
}
function glossaryLinks() {
for (const sel of ['#about .about-body > :not(.ui-gloss)', '#layerWhy p', '.hud.tl .lede', '#help .help-foot']) for (const el of document.querySelectorAll(sel)) linkTerms(el);
const hint = $('layerHint');
if (hint) { let busy = false; const go = () => { if (busy) return; busy = true; linkTerms(hint); busy = false; }; new MutationObserver(() => queueMicrotask(go)).observe(hint, { childList: true }); go(); }
document.addEventListener('click', (e) => { const a = e.target.closest?.('a.ui-term'); if (a) { e.preventDefault(); openGlossary(a.dataset.term); } });
}
fetch('glossary.json?v=20261027one1').then((r) => r.json()).then((j) => {
GLOSS = j.terms.slice().sort((a, b) => a.term.localeCompare(b.term));
const go = () => { if (!$('about') || !document.body.classList.contains('ui2')) return setTimeout(go, 200); buildGlossary(); glossaryLinks(); expose(); };
go();
}).catch((e) => console.warn('glossary', e));
// ---------------------------------------------------------------- install as an app (PWA)
// sw.js is registered with the version and build sha from version.json in its URL, so each build gets its own caches.
const pwa = {
prompt: null, version: null, reg: null,
standalone: () => matchMedia('(display-mode: standalone)').matches || matchMedia('(display-mode: fullscreen)').matches || navigator.standalone === true,
framed: () => { try { return window.top !== window; } catch { return true; } },
ios: () => /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1),
state() {
if (this.standalone()) return 'installed';
if (this.prompt) return 'ready';
if (this.framed()) return 'framed';
if (this.ios()) return 'ios';
return 'unavailable';
},
canOffer() { return this.state() !== 'installed'; },
note() {
return { installed: 'Running as an installed app. The default scroll works offline.',
ready: 'Adds Unroll to your apps. The default scroll then works offline.',
framed: 'Opens the app page in a new tab, where your browser can install it.',
ios: 'In Safari: Share, then Add to Home Screen.',
unavailable: 'Use your browser menu: Install app or Add to Home Screen.' }[this.state()];
},
paint() {
const st = this.state();
for (const id of ['uiInstall', 'uiSetInstall']) { const b = $(id); if (!b) continue; b.hidden = st === 'installed' && id === 'uiInstall'; b.disabled = st === 'installed'; b.title = this.note(); }
const n = $('uiInstallNote'); if (n) n.textContent = this.note() + (this.version ? ` Version ${this.version}.` : '');
},
async install() {
const st = this.state();
if (st === 'installed') return 'installed';
if (st === 'ready') {
const p = this.prompt; this.prompt = null;
p.prompt(); const r = await p.userChoice.catch(() => ({ outcome: 'dismissed' }));
this.paint(); return r.outcome;
}
if (st === 'framed') { window.open(location.href.split('#')[0], '_blank', 'noopener'); return 'opened'; }
toggleSettings(true); this.paint(); $('uiInstallNote')?.focus?.();
return st;
},
};
addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); pwa.prompt = e; pwa.paint(); });
addEventListener('appinstalled', () => { pwa.prompt = null; pwa.paint(); });
if ('serviceWorker' in navigator && isSecureContext) {
addEventListener('load', () => {
fetch('version.json', { cache: 'no-store' }).then((r) => r.json()).then((v) => {
pwa.version = v.version; pwa.paint();
const tag = v.sha ? `${v.version}-${v.sha}` : v.version;
return navigator.serviceWorker.register(`sw.js?v=${encodeURIComponent(tag)}`, { scope: './' });
}).then((reg) => { pwa.reg = reg; }).catch((e) => console.warn('service worker', e));
});
}