// 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 = `
PREFERENCES

Settings

${Object.entries(SETTINGS).map(([k, d]) => row(k, d)).join('')}

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)); }); }