web/src/lab-ui.ts305 lines · 16511 bytes
| 1 | // What lab.ts (the attack picker) and operator-client.ts (operator mode) draw: |
| 2 | // |
| 3 | // #lab the picker: a title that says what to do, seven attack cards, and while a card plays, which real |
| 4 | // Sepolia proposal it is (#id, date, the proposal tx). Stage screens show it in the bottom row, with a |
| 5 | // tab to the strip of recent proposals; phones show it right under the top bar, as a row of cards to |
| 6 | // swipe. Never drawn with ?clean=1. |
| 7 | // #op the operator's launch panel (O, or the bolt button in the top bar): the seven cards, each with |
| 8 | // "Launch on Sepolia" and a confirm step |
| 9 | // #op-toast where the operator's last launch stands: sending, tx sent (Etherscan link), on chain, the verdict |
| 10 | // #op-token the token prompt (?op=1) |
| 11 | // Nothing operator-related is drawn unless operator-client.ts says operator mode is on for this browser. |
| 12 | // |
| 13 | // The playing card's progress bar rides on a CSS variable (--lab-f on #lab), so a frame does not rewrite the cards. |
| 14 | import { CONFIG } from './config' |
| 15 | import { fmtDur } from './airlock' |
| 16 | import { blockTs } from './events' |
| 17 | import { labLine, labName, t, type Key } from './i18n' |
| 18 | import { fmtDay, lab, LAB_CARDS, labCard, labPlaying, pickNow, type LabId, type LabStage } from './lab' |
| 19 | import { launchBusy, launchStage, op, opVisible, type Launch, type LaunchError } from './operator-client' |
| 20 | import { playbackGate } from './playback' |
| 21 | import { CLEAN, OFFLINE } from './runtime' |
| 22 | import { store, type Proposal } from './store' |
| 23 | import { CARDS, ruleShort } from './types' |
| 24 | import { $, esc, I, patch, txLink } from './view' |
| 25 | |
| 26 | /** How long the operator's status toast stays after the verdict landed, and after an error */ |
| 27 | const TOAST_DONE_MS = 30_000 |
| 28 | const TOAST_ERROR_MS = 20_000 |
| 29 | const NOTICE_MS = 10_000 |
| 30 | |
| 31 | const STAGE_ICON: Record<LabStage, string> = { arrive: I.play, judge: '<i class="spin"></i>', veto: I.x, clear: I.check, fast: I.ff, exec: I.check } |
| 32 | |
| 33 | /** The two tabs of the bottom row (stage screens only; CSS hides them on a phone, where both are shown) */ |
| 34 | export function tabsHtml(): string { |
| 35 | if (CLEAN) return '' |
| 36 | const b = (id: 'lab' | 'recent', k: Key) => `<button data-act="tab:${id}" class="${lab.tab === id ? 'on' : ''}" role="tab" aria-selected="${lab.tab === id}">${esc(t(k))}</button>` |
| 37 | return `<div class="seg tabs" role="tablist" title="${esc(t('lab.tabsTitle'))}">${b('lab', 'lab.tabLab')}${b('recent', 'lab.tabRecent')}</div>` |
| 38 | } |
| 39 | |
| 40 | /** The day a proposal was made (venue time), once its block timestamp is read */ |
| 41 | export const dayOf = (p: Proposal | null | undefined) => (p?.proposeBlock ? fmtDay(blockTs.get(p.proposeBlock)) : null) |
| 42 | |
| 43 | /** The replay label of a proposal as HTML: "Replay · a real Sepolia proposal · 2026-10-20" (the date once its block |
| 44 | * is read; kept on one line, so a narrow tag never splits it at its hyphens) */ |
| 45 | export function replayLabel(p: Proposal | null | undefined): string { |
| 46 | const d = dayOf(p) |
| 47 | return esc(t('h.replayReal')) + (d ? ` · <span class="nw">${esc(d)}</span>` : '') |
| 48 | } |
| 49 | |
| 50 | const codeOf = (id: LabId) => { |
| 51 | const c = labCard(id) |
| 52 | return c.rule ? `<span class="lc-code veto">${esc(ruleShort(c.rule))}</span>` : `<span class="lc-code clear">${I.check}${esc(t('v.clear'))}</span>` |
| 53 | } |
| 54 | |
| 55 | // ---------- the picker ---------- |
| 56 | export function renderLab(now: number) { |
| 57 | const stage = $('stage') |
| 58 | stage.classList.toggle('tab-recent', lab.tab === 'recent') |
| 59 | stage.classList.toggle('no-lab', CLEAN) |
| 60 | if (CLEAN) { |
| 61 | patch('lab', '') |
| 62 | return |
| 63 | } |
| 64 | const playing = labPlaying(now) |
| 65 | $('lab').style.setProperty('--lab-f', playing ? playing.f.toFixed(4) : '0') |
| 66 | const showOp = opVisible() |
| 67 | const busy = showOp && launchBusy(store.proposals.values(), now) |
| 68 | const cards = LAB_CARDS.map((c, i) => { |
| 69 | const pick = pickNow(c.id) |
| 70 | const live = OFFLINE ? null : pick.live |
| 71 | const p = live ?? pick.replay |
| 72 | const on = playing?.id === c.id |
| 73 | const nudge = lab.invite && lab.invite !== c.id ? ' nudge' : '' |
| 74 | const ref = live |
| 75 | ? `<span class="lc-live"><i class="dot"></i>${esc(t('lab.live'))} #${esc(live.id)}</span>` |
| 76 | : p |
| 77 | ? `<span class="lc-ref mono">#${esc(p.id)}</span>` |
| 78 | : '' |
| 79 | let foot: string |
| 80 | if (on && playing) { |
| 81 | const steps = playing.stages |
| 82 | .map((s) => `<i class="${s === playing.stage ? 'cur' : playing.stages.indexOf(s) < playing.stages.indexOf(playing.stage) ? 'done' : ''}"></i>`) |
| 83 | .join('') |
| 84 | foot = `<span class="lc-prog" aria-hidden="true"><i></i></span><span class="lc-stage st-${playing.stage}">${STAGE_ICON[playing.stage]}<span>${esc(t(`lab.st.${playing.stage}` as Key))}</span><span class="lc-steps">${steps}</span></span>` |
| 85 | } else if (live) foot = `<span class="lc-cta live">${esc(t('lab.liveFoot'))}</span>` |
| 86 | else if (p) foot = `<span class="lc-cta">${lab.played.has(c.id) ? I.again : I.play}${esc(t(lab.played.has(c.id) ? 'lab.again' : 'lab.play'))}</span>` |
| 87 | else foot = `<span class="lc-cta dim">${esc(t(playbackGate.ready || OFFLINE ? 'lab.none' : 'lab.loading'))}</span>` |
| 88 | const opBtn = |
| 89 | showOp && op.info.cards.includes(c.id) |
| 90 | ? `<button class="lc-op" data-act="opask:${c.id}"${busy ? ' disabled' : ''} title="${esc(t('op.launch'))}">${I.bolt}<span>${esc(t('op.launchShort'))}</span></button>` |
| 91 | : '' |
| 92 | return ( |
| 93 | `<div class="lc ${c.rule ? 'k-veto' : 'k-clear'}${on ? ' on' : ''}${live ? ' live' : ''}${p ? '' : ' none'}${opBtn ? ' has-op' : ''}${nudge}" style="--i:${i}" role="listitem">` + |
| 94 | `<button class="lc-main" data-act="lab:${c.id}" aria-pressed="${on}"${p ? '' : ' disabled'}>` + |
| 95 | `<span class="lc-top">${codeOf(c.id)}${ref}</span>` + |
| 96 | `<span class="lc-name">${esc(labName(c.id))}</span>` + |
| 97 | `<span class="lc-line">${esc(labLine(c.id))}</span>` + |
| 98 | `<span class="lc-foot">${foot}</span></button>${opBtn}</div>` |
| 99 | ) |
| 100 | }).join('') |
| 101 | |
| 102 | let now2 = '' |
| 103 | if (playing) { |
| 104 | const src = store.proposals.get(playing.pid) |
| 105 | const d = dayOf(src) |
| 106 | now2 = |
| 107 | `<span class="lab-now"><span class="tag replay">${I.play}${esc(t('lab.now', { id: playing.pid }))}${d ? ` · ${esc(d)}` : ''}</span>` + |
| 108 | (src?.proposeTx ? `<span class="lab-tx"><span>${esc(t('lab.proposalTx'))}</span>${txLink(src.proposeTx, 4)}</span>` : '') + |
| 109 | `</span>` |
| 110 | } |
| 111 | const title = lab.invite ? t('lab.invite', { name: labName(lab.invite) }) : t('lab.title') |
| 112 | // phone: the cards are a row to swipe; rewriting the region must not throw the row back to its first card |
| 113 | const keep = rowScroll() |
| 114 | const drawn = patch( |
| 115 | 'lab', |
| 116 | `<div class="lab-head${playing ? ' playing' : ''}"><div class="lab-t"><h3 class="${lab.invite ? 'invite' : ''}">${esc(title)}</h3><span class="lab-sub">${esc(t('lab.sub'))}</span></div>${now2}${tabsHtml()}</div>` + |
| 117 | `<div class="lab-cards" role="list">${cards}</div>`, |
| 118 | ) |
| 119 | if (drawn && keep > 0) setRowScroll(keep) |
| 120 | revealPlaying(playing ? `${playing.id}:${playing.pid}` : null) |
| 121 | } |
| 122 | |
| 123 | const labRow = (): HTMLElement | null => { |
| 124 | try { |
| 125 | return ($('lab').querySelector?.('.lab-cards') as HTMLElement | null) ?? null |
| 126 | } catch { |
| 127 | return null |
| 128 | } |
| 129 | } |
| 130 | function rowScroll(): number { |
| 131 | return labRow()?.scrollLeft ?? 0 |
| 132 | } |
| 133 | function setRowScroll(x: number) { |
| 134 | const row = labRow() |
| 135 | if (row) row.scrollLeft = x |
| 136 | } |
| 137 | |
| 138 | /** Phone: the cards are a row to swipe; when a card starts playing (a tap, a key, the first-visit replay), the row |
| 139 | * scrolls it to the middle so the viewer sees which attack is on screen */ |
| 140 | let revealed: string | null = null |
| 141 | function revealPlaying(key: string | null) { |
| 142 | if (key === revealed) return |
| 143 | revealed = key |
| 144 | if (!key) return |
| 145 | try { |
| 146 | const row = labRow() |
| 147 | const card = row?.querySelector('.lc.on') as HTMLElement | null |
| 148 | if (!row || !card || row.scrollWidth <= row.clientWidth + 4) return |
| 149 | const delta = card.getBoundingClientRect().left - row.getBoundingClientRect().left - (row.clientWidth - card.offsetWidth) / 2 |
| 150 | // instant: the row is redrawn every few frames while a card plays, and a redraw keeps the row's scroll position, |
| 151 | // which would stop a smooth scroll halfway (the playing card then stayed off screen) |
| 152 | row.scrollLeft = Math.max(0, row.scrollLeft + delta) |
| 153 | } catch { |
| 154 | /* no layout (tests) */ |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // ---------- operator mode ---------- |
| 159 | /** The bolt button in the top bar (operator only) */ |
| 160 | export function opBarButton(): string { |
| 161 | if (!opVisible()) return '' |
| 162 | return `<button data-act="operator" class="btn opb${op.panel ? ' on' : ''}" title="${esc(t('op.title'))} (O)" aria-expanded="${op.panel}">${I.bolt}<span class="opb-t">${esc(t('op.btn'))}</span></button>` |
| 163 | } |
| 164 | |
| 165 | const whoVeto = (p: Proposal) => (p.guards.cre.verdict === 'veto' ? t('who.cre') : p.guards.breakglass.verdict === 'veto' ? t('who.human') : '') |
| 166 | const vetoRule = (p: Proposal) => p.guards.cre.rule ?? p.guards.breakglass.rule ?? null |
| 167 | const whoClear = (p: Proposal) => (p.clearance?.by === 'approver' ? t('who.approver') : p.clearance?.by === 'other' ? t('who.someone') : t('who.cre')) |
| 168 | |
| 169 | const ERR_KEY: Record<LaunchError, Key> = { |
| 170 | 'unknown card': 'op.errCard', |
| 171 | unauthorized: 'op.errAuth', |
| 172 | disabled: 'op.errDisabled', |
| 173 | forbidden: 'op.errForbidden', |
| 174 | cooldown: 'op.errCooldownAny', |
| 175 | reverted: 'op.errReverted', |
| 176 | misconfigured: 'op.errMisconfigured', |
| 177 | rpc: 'op.errRpc', |
| 178 | server: 'op.errServer', |
| 179 | unclear: 'op.errUnclear', |
| 180 | network: 'op.errNet', |
| 181 | } |
| 182 | /** What a refused launch says on screen: one fixed sentence per API answer (never the server's own words) */ |
| 183 | export function launchErrorText(l: Pick<Launch, 'error' | 'retryAfterBlocks'>): string { |
| 184 | if (l.error === 'cooldown' && l.retryAfterBlocks) return t('op.errCooldown', { n: l.retryAfterBlocks, t: fmtDur(l.retryAfterBlocks * CONFIG.blockSec) }) |
| 185 | return t(ERR_KEY[l.error ?? 'network']) |
| 186 | } |
| 187 | |
| 188 | /** One line on where a launch stands (panel and toast) */ |
| 189 | function launchLine(l: Launch, now: number): { cls: string; html: string; final: boolean } { |
| 190 | const { stage, p } = launchStage(l, store.proposals.values(), now) |
| 191 | const name = `<b>${esc(labName(l.card))}</b>` |
| 192 | const tx = l.txHash ? `<span class="op-tx">${txLink(l.txHash, 4)}</span>` : '' |
| 193 | const spin = '<i class="spin"></i>' |
| 194 | switch (stage) { |
| 195 | case 'sending': |
| 196 | return { cls: 'run', html: `${spin}${name}<span>${esc(t('op.sending'))}</span>`, final: false } |
| 197 | case 'sent': |
| 198 | return { cls: 'run', html: `${spin}${name}<span>${esc(t('op.sent'))}</span>${tx}`, final: false } |
| 199 | case 'slow': |
| 200 | return { cls: 'warn', html: `${I.clock}${name}<span>${esc(t('op.slow'))}</span>${tx}`, final: true } |
| 201 | case 'onchain': |
| 202 | return { cls: 'run', html: `${spin}${name}<span>${esc(t('op.onchain', { id: p!.id }))}</span>${tx}`, final: false } |
| 203 | case 'veto': { |
| 204 | const r = vetoRule(p!) |
| 205 | const who = whoVeto(p!) |
| 206 | return { cls: 'bad', html: `${I.x}${name}<span>${esc(t('op.veto', { id: p!.id, who, rule: r ? `${who ? ' · ' : ''}${ruleShort(r)}` : '' }))}</span>${tx}`, final: true } |
| 207 | } |
| 208 | case 'clear': |
| 209 | return { cls: 'good', html: `${I.check}${name}<span>${esc(t('op.clear', { id: p!.id, who: whoClear(p!) }))}</span>${tx}`, final: true } |
| 210 | case 'exec': |
| 211 | return { cls: 'good', html: `${I.check}${name}<span>${esc(t('op.exec', { id: p!.id }))}</span>${tx}`, final: true } |
| 212 | default: |
| 213 | return { cls: 'warn', html: `${I.x}${name}<span>${esc(launchErrorText(l))}</span>`, final: true } |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | const implText = (id: LabId): string => { |
| 218 | const c = labCard(id) |
| 219 | if (c.fn === 'proposeSetGuardian') return 'proposeSetGuardian(AirlockReceiver, false)' |
| 220 | return `proposeUpgrade(${CARDS.find((x) => x.id === c.impl)?.name ?? c.impl})` |
| 221 | } |
| 222 | |
| 223 | /** The confirm step last drawn (a new one is scrolled into view) */ |
| 224 | let shownAsk: LabId | null = null |
| 225 | |
| 226 | export function renderOperator(now: number) { |
| 227 | const vis = opVisible() |
| 228 | // the panel |
| 229 | const panel = $('op') |
| 230 | const open = vis && op.panel |
| 231 | panel.classList.toggle('open', open) |
| 232 | panel.setAttribute?.('aria-hidden', String(!open)) |
| 233 | if (!open) patch('op', '') |
| 234 | else { |
| 235 | const busy = launchBusy(store.proposals.values(), now) |
| 236 | const rows = op.info.cards |
| 237 | .map((id) => { |
| 238 | const c = labCard(id) |
| 239 | const expect = c.rule ? t('op.expectVeto', { rule: ruleShort(c.rule) }) : t('op.expectClear') |
| 240 | const what = `<div class="op-what">${codeOf(id)}<b>${esc(labName(id))}</b><small class="mono">${esc(implText(id))}</small><small>${esc(expect)}</small></div>` |
| 241 | const act = |
| 242 | op.confirm === id |
| 243 | ? `<div class="op-ask"><p><b>${esc(t('op.confirmQ', { name: labName(id) }))}</b> ${esc(t('op.confirmBody', { fn: c.fn }))}</p>` + |
| 244 | `<div class="op-btns"><button data-act="opgo:${id}" class="btn danger"${busy ? ' disabled' : ''}>${I.bolt}${esc(t('op.confirm'))}</button><button data-act="opno" class="btn">${esc(t('op.cancel'))}</button></div></div>` |
| 245 | : `<button data-act="opask:${id}" class="btn go"${busy ? ` disabled title="${esc(t('op.busy'))}"` : ''}>${I.bolt}${esc(t('op.launch'))}</button>` |
| 246 | return `<div class="op-row${op.confirm === id ? ' ask' : ''}">${what}${act}</div>` |
| 247 | }) |
| 248 | .join('') |
| 249 | const st = op.launch ? launchLine(op.launch, now) : null |
| 250 | const foot = [t(op.info.auth === 'none' ? 'op.authNone' : 'op.authToken'), ...(op.info.cooldownBlocks > 0 ? [t('op.gap', { n: op.info.cooldownBlocks })] : [])].join(' · ') |
| 251 | // a phone scrolls the panel: a redraw keeps the scroll, and a new confirm step is brought into view |
| 252 | const card = () => (panel.querySelector?.('.op-card') as HTMLElement | null) ?? null |
| 253 | const keep = card()?.scrollTop ?? 0 |
| 254 | const drawn = patch( |
| 255 | 'op', |
| 256 | `<div class="op-card" role="dialog" aria-modal="true" aria-label="${esc(t('op.title'))}">` + |
| 257 | `<div class="op-head"><span class="op-badge">${I.bolt}${esc(t('op.badge'))}</span><h3>${esc(t('op.title'))}</h3><button data-act="opclose" class="btn">${esc(t('op.close'))}</button></div>` + |
| 258 | `<p class="op-note">${esc(t('op.note'))}</p>` + |
| 259 | (st ? `<div class="op-status ${st.cls}">${st.html}</div>` : '') + |
| 260 | `<div class="op-list">${rows}</div>` + |
| 261 | `<div class="op-foot"><span>${esc(foot)}</span>${op.info.auth === 'token' ? `<button data-act="opout" class="lnkbtn">${esc(t('op.signOut'))}</button>` : ''}</div>` + |
| 262 | `</div>`, |
| 263 | ) |
| 264 | if (drawn) { |
| 265 | try { |
| 266 | const c = card() |
| 267 | if (c && keep) c.scrollTop = keep |
| 268 | if (op.confirm && op.confirm !== shownAsk) (c?.querySelector('.op-row.ask') as HTMLElement | null)?.scrollIntoView({ block: 'nearest' }) |
| 269 | } catch { |
| 270 | /* no layout (tests) */ |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | shownAsk = open ? op.confirm : null |
| 275 | |
| 276 | // the status toast: the last launch while the panel is closed, or the "token rejected" notice |
| 277 | let toast = '' |
| 278 | if (op.notice) { |
| 279 | if (now - op.noticeAt > NOTICE_MS) op.notice = null |
| 280 | else toast = `<div class="op-status warn">${I.x}<span>${esc(t(op.notice === 'auth' ? 'op.errAuth' : 'op.errDisabled'))}</span></div>` |
| 281 | } else if (vis && op.launch && !op.panel && !op.toastHidden && !CLEAN) { |
| 282 | // (never over a ?clean=1 recording: the war room itself shows the launch) |
| 283 | const st = launchLine(op.launch, now) |
| 284 | if (st.final && !op.launch.doneAt) op.launch.doneAt = now |
| 285 | const gone = op.launch.state === 'error' ? now - op.launch.at > TOAST_ERROR_MS : !!op.launch.doneAt && now - op.launch.doneAt > TOAST_DONE_MS |
| 286 | if (!gone) toast = `<div class="op-status ${st.cls}">${st.html}<button data-act="optoast" class="op-x" aria-label="${esc(t('op.close'))}">${I.x}</button></div>` |
| 287 | } |
| 288 | patch('op-toast', toast) |
| 289 | |
| 290 | // the token prompt (?op=1) |
| 291 | const tok = $('op-token') |
| 292 | const ask = op.prompt && op.loaded && op.info.enabled && op.info.auth === 'token' && !op.token |
| 293 | tok.classList.toggle('open', ask) |
| 294 | tok.setAttribute?.('aria-hidden', String(!ask)) |
| 295 | const drawn = patch( |
| 296 | 'op-token', |
| 297 | ask |
| 298 | ? `<form class="op-card op-tok" data-form="optoken" autocomplete="off"><h3>${esc(t('op.tokenTitle'))}</h3><p class="op-note">${esc(t('op.tokenBody'))}</p>` + |
| 299 | `<input class="op-input" name="token" type="password" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="${esc(t('op.tokenTitle'))}">` + |
| 300 | (op.promptBad ? `<p class="op-bad">${esc(t('op.tokenBad'))}</p>` : '') + |
| 301 | `<div class="op-btns"><button type="submit" class="btn primary">${esc(t('op.tokenSave'))}</button><button type="button" data-act="optokenno" class="btn">${esc(t('op.cancel'))}</button></div></form>` |
| 302 | : '', |
| 303 | ) |
| 304 | if (drawn && ask) (tok.querySelector?.('input') as HTMLInputElement | null)?.focus?.() |
| 305 | } |