web/src/main.ts263 lines · 8619 bytes
| 1 | import './style.css' |
| 2 | import { startTitle } from './airlock' |
| 3 | import { blocks } from './chain' |
| 4 | import { PENDING } from './chaindata' |
| 5 | import { startChainWatch } from './chainwatch' |
| 6 | import { keyAction, labIndexOfKey, stepId, type Action } from './controls' |
| 7 | import { setManualPick } from './events' |
| 8 | import { cycleLang, initLang } from './i18n' |
| 9 | import { isLabId, lab, LAB_IDS, noteInteraction, playLab, tickLab } from './lab' |
| 10 | import { checkVerified } from './lookup' |
| 11 | import { startOffline } from './offline' |
| 12 | import { forgetToken, launchCard, loadOperator, mayOperate, op, opVisible, saveToken, takeTokenFromUrl, TOKEN_RE } from './operator-client' |
| 13 | import { historyIn, replay, replayFrame, replayProposal, tickReplay, toggleReplay } from './playback' |
| 14 | import { FORK, OFFLINE, PICK } from './runtime' |
| 15 | import { store } from './store' |
| 16 | import { mountSkeleton, render, STAGE_QUERY, ui } from './ui' |
| 17 | |
| 18 | // first of all: an operator token given as ?op=<token> leaves the address bar before anything else runs |
| 19 | takeTokenFromUrl() |
| 20 | initLang() |
| 21 | startTitle() |
| 22 | mountSkeleton() |
| 23 | |
| 24 | let dirty = true |
| 25 | const mark = () => { |
| 26 | dirty = true |
| 27 | } |
| 28 | store.onChange(mark) |
| 29 | blocks.onChange(mark) |
| 30 | let lastFrame = 0 |
| 31 | /** Space pressed before that proposal's history was read: it plays as soon as it is in */ |
| 32 | let spaceWaiting: string | null = null |
| 33 | function frame() { |
| 34 | lastFrame = performance.now() |
| 35 | if (spaceWaiting && historyIn(spaceWaiting)) { |
| 36 | const id = spaceWaiting |
| 37 | spaceWaiting = null |
| 38 | if (shownId() === id) replayProposal(id) |
| 39 | } |
| 40 | if (tickLab()) dirty = true |
| 41 | if (tickReplay()) dirty = true |
| 42 | if (dirty) { |
| 43 | dirty = false |
| 44 | if (!OFFLINE) { |
| 45 | // Sourcify badges in the details drawer (live Sepolia only; no-op on a fork) |
| 46 | checkVerified(store.deploy?.airlockAdmin) |
| 47 | const t = store.active?.target |
| 48 | if (t) checkVerified(t) |
| 49 | } |
| 50 | render() |
| 51 | } |
| 52 | } |
| 53 | function loop() { |
| 54 | frame() |
| 55 | requestAnimationFrame(loop) |
| 56 | } |
| 57 | requestAnimationFrame(loop) |
| 58 | // the cooldown clock ticks between blocks and playback moves with time: redraw a few times per second. When the |
| 59 | // browser holds back animation frames (a background tab, a headless capture), the timer draws instead. |
| 60 | setInterval(() => { |
| 61 | mark() |
| 62 | if (performance.now() - lastFrame > 400) frame() |
| 63 | }, 250) |
| 64 | |
| 65 | // ?p=<id>: open on that proposal (live: stay on it; offline: the replay loop starts with it) |
| 66 | if (PICK) { |
| 67 | if (OFFLINE) replay.startAt(PICK) |
| 68 | else { |
| 69 | store.activeId = PICK |
| 70 | setManualPick(true) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | if (OFFLINE) startOffline() |
| 75 | else if (!PENDING) { |
| 76 | blocks.start() |
| 77 | startChainWatch() |
| 78 | } else blocks.start() |
| 79 | |
| 80 | // operator mode exists only where the operator API answers (never offline, never on a local fork: that screen is not |
| 81 | // Ethereum Sepolia). The API is asked only where this browser can be the operator (a loopback host, a saved token or |
| 82 | // ?op=1): a public visitor never calls it, and a static deploy logs no 404. |
| 83 | if (!OFFLINE && !FORK && !PENDING && mayOperate(globalThis.location?.hostname)) void loadOperator().then(() => store.emit()) |
| 84 | |
| 85 | /** The proposal on screen: the one being replayed, else the selected one */ |
| 86 | const shownId = (): string | null => replayFrame()?.tl.id ?? store.activeId |
| 87 | |
| 88 | /** The viewer picked a proposal: stop playback and stay on it (a new live proposal still takes over with auto-follow) */ |
| 89 | function selectProposal(id: string | null) { |
| 90 | if (!id || !store.proposals.has(id)) return |
| 91 | replay.stop(Date.now(), id) |
| 92 | store.activeId = id |
| 93 | setManualPick(id !== store.newestId()) |
| 94 | lab.card = null |
| 95 | lab.pid = null |
| 96 | store.emit() |
| 97 | } |
| 98 | |
| 99 | /** A launch was sent: auto-follow on, any replay stops, the screen goes to the newest real proposal, and the live |
| 100 | * war room takes over as soon as the new proposal lands */ |
| 101 | function followLive() { |
| 102 | // the first-visit replay must not take the screen from the operator's real launch |
| 103 | noteInteraction() |
| 104 | store.follow = true |
| 105 | replay.stop(Date.now()) |
| 106 | setManualPick(false) |
| 107 | lab.card = null |
| 108 | lab.pid = null |
| 109 | const n = store.newestId() |
| 110 | if (n) store.activeId = n |
| 111 | op.panel = false |
| 112 | store.emit() |
| 113 | } |
| 114 | |
| 115 | /** Phone or narrow window: after a card is picked, bring the airlock into view if it is mostly off screen */ |
| 116 | function showHero() { |
| 117 | try { |
| 118 | if (window.matchMedia(STAGE_QUERY).matches) return |
| 119 | const r = document.querySelector('.hero')?.getBoundingClientRect() |
| 120 | if (r && (r.top > window.innerHeight * 0.5 || r.bottom < 120)) document.querySelector('.hero')!.scrollIntoView({ behavior: 'smooth', block: 'start' }) |
| 121 | } catch { |
| 122 | /* no layout */ |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /** Actions that pick what is on screen: the first-visit replay does not start after one of them */ |
| 127 | const PICKS = new Set(['sel', 'prev', 'next', 'newest', 'replay', 'follow', 'loop']) |
| 128 | |
| 129 | function act(a: Action | string, arg?: string) { |
| 130 | if (PICKS.has(a)) noteInteraction() |
| 131 | switch (a) { |
| 132 | case 'lab': { |
| 133 | const id = isLabId(arg) ? arg : LAB_IDS[Number(arg)] |
| 134 | if (id && playLab(id)) showHero() |
| 135 | break |
| 136 | } |
| 137 | case 'tab': |
| 138 | lab.tab = arg === 'recent' ? 'recent' : 'lab' |
| 139 | break |
| 140 | case 'operator': |
| 141 | if (opVisible()) { |
| 142 | op.panel = !op.panel |
| 143 | op.confirm = null |
| 144 | } |
| 145 | break |
| 146 | case 'opask': |
| 147 | if (opVisible() && isLabId(arg) && op.info.cards.includes(arg)) { |
| 148 | op.panel = true |
| 149 | op.confirm = arg |
| 150 | } |
| 151 | break |
| 152 | case 'opno': |
| 153 | op.confirm = null |
| 154 | break |
| 155 | case 'opgo': |
| 156 | if (isLabId(arg) && op.confirm === arg) { |
| 157 | noteInteraction() |
| 158 | void launchCard(arg, store.proposals.values(), followLive).then(() => store.emit()) |
| 159 | } |
| 160 | break |
| 161 | case 'opclose': |
| 162 | op.panel = false |
| 163 | op.confirm = null |
| 164 | break |
| 165 | case 'opout': |
| 166 | forgetToken() |
| 167 | break |
| 168 | case 'optoast': |
| 169 | op.toastHidden = true |
| 170 | break |
| 171 | case 'optokenno': |
| 172 | op.prompt = false |
| 173 | break |
| 174 | case 'sel': |
| 175 | selectProposal(arg ?? null) |
| 176 | break |
| 177 | case 'prev': |
| 178 | case 'next': |
| 179 | selectProposal(stepId(store.recentIds(), shownId(), a === 'prev' ? -1 : 1)) |
| 180 | break |
| 181 | case 'newest': |
| 182 | selectProposal(store.newestId()) |
| 183 | break |
| 184 | case 'replay': { |
| 185 | lab.card = null |
| 186 | lab.pid = null |
| 187 | const id = shownId() |
| 188 | // the page is still reading this proposal's history: play it the moment it is in |
| 189 | if (!replayProposal(id) && id && store.proposals.has(id) && !historyIn(id)) spaceWaiting = id |
| 190 | break |
| 191 | } |
| 192 | case 'follow': |
| 193 | if (OFFLINE) toggleReplay() |
| 194 | else { |
| 195 | store.follow = !store.follow |
| 196 | // turning it on jumps to the newest proposal right away |
| 197 | if (store.follow) selectProposal(store.newestId()) |
| 198 | } |
| 199 | break |
| 200 | case 'loop': |
| 201 | toggleReplay() |
| 202 | break |
| 203 | case 'details': |
| 204 | ui.details = !ui.details |
| 205 | ui.help = false |
| 206 | break |
| 207 | case 'lang': |
| 208 | cycleLang() |
| 209 | break |
| 210 | case 'help': |
| 211 | ui.help = !ui.help |
| 212 | break |
| 213 | case 'close': |
| 214 | ui.details = false |
| 215 | ui.help = false |
| 216 | // Esc steps back out of the operator panel: first the confirm step, then the panel, then the token prompt |
| 217 | if (op.confirm) op.confirm = null |
| 218 | else if (op.panel) op.panel = false |
| 219 | op.prompt = false |
| 220 | break |
| 221 | } |
| 222 | mark() |
| 223 | } |
| 224 | |
| 225 | document.addEventListener('click', (ev) => { |
| 226 | const el = (ev.target as HTMLElement | null)?.closest?.<HTMLElement>('[data-act]') |
| 227 | if (!el) { |
| 228 | // a click on the dimmed backdrop closes the help |
| 229 | if (ui.help && (ev.target as HTMLElement | null)?.id === 'help') act('close') |
| 230 | return |
| 231 | } |
| 232 | const [a, arg] = el.dataset.act!.split(':') |
| 233 | act(a, arg) |
| 234 | }) |
| 235 | |
| 236 | // the operator token prompt (?op=1): the token goes to this browser's storage only, never into the page or the URL |
| 237 | document.addEventListener('submit', (ev) => { |
| 238 | const form = ev.target as HTMLFormElement | null |
| 239 | if (form?.dataset?.form !== 'optoken') return |
| 240 | ev.preventDefault() |
| 241 | const input = form.elements.namedItem('token') as HTMLInputElement | null |
| 242 | const v = (input?.value ?? '').trim() |
| 243 | if (input) input.value = '' |
| 244 | if (TOKEN_RE.test(v) && saveToken(v)) { |
| 245 | op.prompt = false |
| 246 | op.promptBad = false |
| 247 | } else op.promptBad = true |
| 248 | mark() |
| 249 | }) |
| 250 | |
| 251 | document.addEventListener('keydown', (ev) => { |
| 252 | const t = ev.target as HTMLElement | null |
| 253 | if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return |
| 254 | const a = keyAction(ev.key, { ctrl: ev.ctrlKey, alt: ev.altKey, meta: ev.metaKey }) |
| 255 | if (!a) return |
| 256 | // Enter on a focused button or link activates it (keyboard access); Space always replays, even after a click |
| 257 | if (ev.key === 'Enter' && t && /^(BUTTON|A)$/.test(t.tagName)) return |
| 258 | ev.preventDefault() |
| 259 | if (a === 'lab') { |
| 260 | noteInteraction() |
| 261 | act('lab', String(labIndexOfKey(ev.key))) |
| 262 | } else act(a) |
| 263 | }) |