Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / web / src / lab-ui.ts
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.
14import { CONFIG } from './config'
15import { fmtDur } from './airlock'
16import { blockTs } from './events'
17import { labLine, labName, t, type Key } from './i18n'
18import { fmtDay, lab, LAB_CARDS, labCard, labPlaying, pickNow, type LabId, type LabStage } from './lab'
19import { launchBusy, launchStage, op, opVisible, type Launch, type LaunchError } from './operator-client'
20import { playbackGate } from './playback'
21import { CLEAN, OFFLINE } from './runtime'
22import { store, type Proposal } from './store'
23import { CARDS, ruleShort } from './types'
24import { $, esc, I, patch, txLink } from './view'
25
26/** How long the operator's status toast stays after the verdict landed, and after an error */
27const TOAST_DONE_MS = 30_000
28const TOAST_ERROR_MS = 20_000
29const NOTICE_MS = 10_000
30
31const 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) */
34export 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 */
41export 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) */
45export 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
50const 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 ----------
56export 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
123const labRow = (): HTMLElement | null => {
124 try {
125 return ($('lab').querySelector?.('.lab-cards') as HTMLElement | null) ?? null
126 } catch {
127 return null
128 }
129}
130function rowScroll(): number {
131 return labRow()?.scrollLeft ?? 0
132}
133function 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 */
140let revealed: string | null = null
141function 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) */
160export 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
165const whoVeto = (p: Proposal) => (p.guards.cre.verdict === 'veto' ? t('who.cre') : p.guards.breakglass.verdict === 'veto' ? t('who.human') : '')
166const vetoRule = (p: Proposal) => p.guards.cre.rule ?? p.guards.breakglass.rule ?? null
167const whoClear = (p: Proposal) => (p.clearance?.by === 'approver' ? t('who.approver') : p.clearance?.by === 'other' ? t('who.someone') : t('who.cre'))
168
169const 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) */
183export 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) */
189function 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
217const 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) */
224let shownAsk: LabId | null = null
225
226export 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}