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 / ui.ts
web/src/ui.ts893 lines · 46853 bytes
1// The war room, presentation edition. A landscape screen (projector of any shape from 5:4 to 16:9, recorded video) gets
2// a 1920x1080 stage scaled to fit and letterboxed (fitStage); a phone, a portrait screen or a narrow window gets one
3// scrolling column (style.css decides with the same media query as STAGE_QUERY).
4//
5// top bar product, "Live on Ethereum Sepolia" with a small block counter, Chainlink CRE mark, controls
6// hero the airlock of the selected proposal: owner key -> outer door -> chamber (cooldown) -> inner door -> proxy
7// verdict the Chainlink CRE verdict: the pipeline, the rule in plain words, blocks and seconds after the proposal,
8// links to the proposal tx, the report tx and the evidence hash
9// strip recent proposals as cards (click, or the arrow keys, to select)
10// lab the attack picker (lab.ts, drawn by lab-ui.ts): seven cards, each replays a real Sepolia proposal of
11// that attack from its arrival; on a stage screen it shares the bottom row with the strip (two tabs)
12// op operator mode only (operator-client.ts): launch panel, status toast, token prompt
13// details developer drawer (D): every hash, gas cost, connection, event stream; hidden by default
14// help presenter shortcuts (?)
15//
16// Presenter chrome (language switch, Details, ?, key hints) fades out after 3 s without mouse movement on a stage
17// screen and is never drawn with ?clean=1 (for recordings). Touch screens never show key hints.
18//
19// Each region is rewritten only when its HTML changes. When the proposal on screen changes state (it arrives, the
20// verdict lands, the inner door opens) the affected regions carry a short "moment" class (m-arrive, m-veto, m-clear,
21// m-exec) so the CSS plays the change once; picking another proposal only fades it in. Every word goes through t().
22import { airlockView, fmtClock, fmtDur, type AirlockPhase, type AirlockView } from './airlock'
23import { blocks } from './chain'
24import { DATA, PENDING, RECORDING } from './chaindata'
25import { chainHealth } from './chainwatch'
26import { CONFIG } from './config'
27import { HELP_ROWS, type Action } from './controls'
28import { fmtEth, fmtGasPrice, plausibleBlockMs, realWorkflowId, txCost } from './derive'
29import { blockTs } from './events'
30import { cardDesc, kindText, labLine, labName, lang, LANG_LABEL, LANGS, onLangChange, ruleShortText, ruleText, setLang, t, tm, type Key } from './i18n'
31import { introSlow, isLiveNow, LAB_CARDS, LAB_PLAY, labIntro, labPlaying } from './lab'
32import { dayOf, opBarButton, renderLab, renderOperator, replayLabel, tabsHtml } from './lab-ui'
33import { opVisible, pendingLaunch, type Launch } from './operator-client'
34import { judgeProgress, replay, replayFrame } from './playback'
35import type { ReplayFrame } from './replay'
36import { CHAIN, CLEAN, FORK, OFFLINE } from './runtime'
37import { short, store, type Proposal } from './store'
38import { CARDS, ruleShort, type CardInfo } from './types'
39import { $, addrLink, esc, fullHash, hashSpan, I, num, patch, txLink } from './view'
40
41/** Stage mode: wide landscape screens. style.css uses the same query. */
42export const STAGE_QUERY = '(min-width: 960px) and (min-aspect-ratio: 5/4)'
43const STAGE_W = 1920
44const STAGE_H = 1080
45
46/** Developer drawer and help overlay (main.ts toggles them) */
47export const ui = { details: false, help: false }
48
49/** During playback the hero shows the virtual block; live, the chain head */
50let RF: ReplayFrame | null = null
51const curBlock = () => (RF ? RF.vblock : blocks.latest)
52
53/** Sourcify badge (live Sepolia only), shown after a confirmed match */
54function verifiedChip(addr: string | undefined | null): string {
55 if (!addr || !CHAIN.sourcifyApi) return ''
56 return store.verified.get(addr.toLowerCase()) === 'yes' ? ` <span class="vchip">${t('dr.verified')}</span>` : ''
57}
58
59// ---------- skeleton and stage ----------
60export function mountSkeleton() {
61 $('app').innerHTML = `
62 <div id="stage" class="stage">
63 <header class="bar">
64 <div class="brand">${I.mark}<div class="brand-txt"><div class="brand-name">Upgrade Airlock</div><div id="t-sub" class="brand-sub"></div></div></div>
65 <div id="t-status" class="status" aria-live="polite"></div>
66 <div class="bar-r"><div id="t-ctl" class="ctl"></div><div id="t-cre" class="cre"></div></div>
67 </header>
68 <section id="lab" class="lab" aria-live="polite"></section>
69 <main class="main">
70 <section class="hero" aria-live="polite">
71 <div id="h-head" class="h-head"></div>
72 <div id="h-wrap" class="lockwrap"><div id="h-lock" class="lockbox"></div><div id="h-count" class="count"></div><div id="h-stamp" class="count stampbox"></div></div>
73 <div id="h-doors" class="doors"></div>
74 <div id="h-banner" class="banner-slot"></div>
75 </section>
76 <aside class="verdict">
77 <div id="v-head" class="v-head"></div>
78 <div id="v-main" class="v-main"></div>
79 <div id="v-pipe" class="v-pipe"></div>
80 <div id="v-links" class="v-links"></div>
81 </aside>
82 </main>
83 <section id="strip" class="strip"></section>
84 <aside id="details" class="drawer" aria-hidden="true"></aside>
85 <div id="help" class="help" aria-hidden="true"></div>
86 <div id="op" class="op" aria-hidden="true"></div>
87 <div id="op-token" class="op" aria-hidden="true"></div>
88 <div id="op-toast" class="op-toast" aria-live="polite"></div>
89 </div>`
90 if (CLEAN) document.documentElement.classList?.add('clean')
91 startIdle()
92 fitStage()
93 window.addEventListener('resize', fitStage)
94 window.addEventListener('orientationchange', fitStage)
95 // language switch: handled here (not through data-act); the main loop redraws on store.emit()
96 document.addEventListener('click', (ev) => {
97 const el = (ev.target as HTMLElement | null)?.closest?.<HTMLElement>('[data-lang]')
98 const l = el?.dataset.lang
99 if (l) setLang(l as never)
100 })
101 onLangChange(() => store.emit())
102}
103
104/** Stage mode: scale the 1920x1080 stage to the window and centre it. Stack mode: plain flow (CSS) */
105function fitStage() {
106 const el = $('stage')
107 const stage = typeof window.matchMedia === 'function' && window.matchMedia(STAGE_QUERY).matches
108 if (!stage) {
109 el.style.removeProperty('transform')
110 el.style.removeProperty('left')
111 el.style.removeProperty('top')
112 return
113 }
114 const s = Math.min(window.innerWidth / STAGE_W, window.innerHeight / STAGE_H)
115 el.style.transform = `scale(${s})`
116 el.style.left = `${Math.max(0, (window.innerWidth - STAGE_W * s) / 2)}px`
117 el.style.top = `${Math.max(0, (window.innerHeight - STAGE_H * s) / 2)}px`
118}
119
120/** No mouse movement for 3 s: the presenter chrome fades out (CSS, stage screens with a mouse only) and so does the
121 * cursor. Any mouse movement brings both back; keys do not, so a presenter with a clicker keeps a clean screen. */
122const IDLE_MS = 3000
123function startIdle() {
124 const root = document.documentElement
125 if (!root?.classList || typeof window.addEventListener !== 'function') return
126 let timer: ReturnType<typeof setTimeout> | undefined
127 const wake = () => {
128 root.classList.remove('idle')
129 clearTimeout(timer)
130 timer = setTimeout(() => root.classList.add('idle'), IDLE_MS)
131 }
132 window.addEventListener('mousemove', wake, { passive: true })
133 window.addEventListener('mousedown', wake, { passive: true })
134 wake()
135}
136
137const reducedMotion = () => {
138 try {
139 return window.matchMedia('(prefers-reduced-motion: reduce)').matches
140 } catch {
141 return false
142 }
143}
144
145// ---------- what a proposal is ----------
146function cardOf(p: Proposal): CardInfo | null {
147 const id = p.card ?? store.implNames.get(p.target.toLowerCase())
148 return CARDS.find((c) => c.id === id) ?? null
149}
150
151interface Ident {
152 /** headline */
153 title: string
154 /** one line under it */
155 desc: string
156 /** short name for the capsule and the card */
157 name: string
158}
159export function identOf(p: Proposal): Ident {
160 const kind = Number(p.kind)
161 if (kind === 1) {
162 const cre = p.target.toLowerCase() === DATA.receiver.toLowerCase()
163 const on = p.value !== undefined && p.value !== '0'
164 const who = cre ? 'Chainlink CRE' : short(p.target, 4)
165 return {
166 title: t(on ? 'h.guardOn' : 'h.guardOff', { who }),
167 desc: cre && !on ? t('h.descRemoveCre') : t('h.descGuard'),
168 name: cre && !on ? t('h.nameRemoveCre') : t('h.nameGuard'),
169 }
170 }
171 if (kind === 2) return { title: t('h.delay', { n: p.value ?? '?' }), desc: t('h.descGuard'), name: t('h.nameDelay') }
172 if (kind === 3) return { title: t('h.paused', { a: short(p.target, 4) }), desc: t('h.descGuard'), name: t('h.namePaused') }
173 const c = cardOf(p)
174 if (c) {
175 // the same plain sentence as its attack card (the technical one only for a demo implementation without a card)
176 const lc = LAB_CARDS.find((x) => x.impl === c.id)
177 return { title: c.name, desc: lc ? labLine(lc.id) : cardDesc(c.id), name: c.name }
178 }
179 return { title: t('h.newImpl', { a: short(p.target, 4) }), desc: t('h.descUnknown'), name: short(p.target, 4) }
180}
181
182/** The rule a proposal was vetoed with (CRE first), if any */
183const vetoRuleOf = (p: Proposal | undefined): string | null => p?.guards.cre.rule ?? p?.guards.breakglass.rule ?? null
184
185const whoName = (by: string | undefined) => (by === 'cre' || by === undefined ? t('who.cre') : by === 'breakglass' ? t('who.human') : by === 'approver' ? t('who.approver') : t('who.someone'))
186
187/** Cooldown in blocks: the deployment, else the default */
188const delayBlocks = () => store.deploy?.delayBlocks ?? DATA.delayBlocks ?? CONFIG.delayBlocks
189const tsOf = (n: number) => blockTs.get(n)
190/** Live, a log can arrive before its block header: never draw a proposal as if it were before its own block */
191const viewOf = (p: Proposal | null, cur = curBlock()): AirlockView => airlockView(p, RF ? cur : Math.max(cur, p?.proposeBlock ?? 0), tsOf)
192/** A proposal's state now (chain head, or the end of the recording), whatever the playback shows */
193const finalView = (p: Proposal) => airlockView(p, Math.max(OFFLINE ? RECORDING.head : blocks.latest, p.proposeBlock ?? 0), tsOf)
194
195/** Seconds left on the cooldown clock: live, it ticks down between blocks (an estimate, shown with ≈) */
196function etaSec(v: AirlockView): number {
197 if (RF || OFFLINE || !blocks.lastAt) return v.leftSec
198 const since = Math.min(CONFIG.blockSec, Math.max(0, (Date.now() - blocks.lastAt) / 1000))
199 return Math.max(0, v.leftSec - since)
200}
201
202/** The picker's fast-forwarded cooldown, as a multiple of real time (12 s blocks at LAB_PLAY.fastMsPerBlock) */
203const FF_FACTOR = Math.round((CONFIG.blockSec * 1000) / (LAB_PLAY.fastMsPerBlock ?? LAB_PLAY.msPerBlock ?? 600))
204
205/** The address of this page in offline playback (the recorded real Sepolia run), keeping the language */
206function offlineHref(): string {
207 try {
208 const u = new URL(location.href)
209 for (const k of ['p', 'rpc', 'clean', 'op']) u.searchParams.delete(k)
210 u.searchParams.set('mode', 'offline')
211 return `${u.pathname}?${u.searchParams}`
212 } catch {
213 return '?mode=offline'
214 }
215}
216
217/** A replay between its arrival and its verdict: which CRE workflow step the verdict card is on (the same steps) */
218function replayStep(p: Proposal, now: number): { i: number; steps: PipeStep[] } | null {
219 if (!RF) return null
220 const f = judgeProgress(now)
221 if (f === null) return null
222 const steps = pipelineOf(p.kind, vetoRuleOf(store.proposals.get(RF.tl.id)))
223 return { i: Math.min(steps.length - 1, Math.floor(f * steps.length)), steps }
224}
225
226// ---------- the Chainlink CRE verdict of a proposal ----------
227type VKind = 'none' | 'await' | 'veto' | 'clear' | 'human' | 'approver' | 'skipped' | 'far' | 'clearFar' | 'loading' | 'intro' | 'pending'
228/** live view before the proposals' logs arrive: a missing verdict log is still on its way, not out of reach */
229const logsPending = () => !OFFLINE && !chainHealth.loadedRecent
230interface VerdictInfo {
231 kind: VKind
232 rule: string | null
233 after: { afterBlocks: number; afterSec: number; secFromTs: boolean } | null
234 tx?: string
235 evidence?: string
236 workflow?: string
237}
238function verdictOf(p: Proposal | null, v: AirlockView): VerdictInfo {
239 const none: VerdictInfo = { kind: 'none', rule: null, after: null }
240 if (!p || v.phase === 'idle') return none
241 if (v.phase === 'ejected') {
242 const x = v.veto
243 if (!x) return { ...none, kind: logsPending() ? 'loading' : 'far' }
244 if (x.by === 'cre') return { kind: 'veto', rule: x.rule, after: x, tx: x.tx, evidence: x.evidenceHash ?? p.reason?.hash, workflow: x.workflowId }
245 return { kind: 'human', rule: x.rule, after: x, tx: x.tx }
246 }
247 if (v.phase === 'chamber' || v.phase === 'waiting') return { ...none, kind: 'await' }
248 const cre = p.guards.cre
249 const c = v.clear
250 if (c?.by === 'cre' || cre.verdict === 'pass') {
251 const after = c?.by === 'cre' ? c : null
252 return { kind: 'clear', rule: null, after, tx: c?.by === 'cre' ? c.tx : cre.tx, evidence: c?.by === 'cre' ? c.evidenceHash : (cre.evidenceHash ?? c?.evidenceHash), workflow: cre.workflowId }
253 }
254 if (c?.by === 'approver') return { kind: v.phase === 'executed' ? 'skipped' : 'approver', rule: null, after: null, tx: c.tx, evidence: c.evidenceHash }
255 return { ...none, kind: v.cleared ? (logsPending() ? 'loading' : 'clearFar') : 'await' }
256}
257
258/** "1 block · 12 s after the proposal" (≈ when estimated from 12 s blocks) */
259const afterText = (a: VerdictInfo['after']) => (a ? t(a.secFromTs ? 'v.after' : 'v.afterEst', { n: a.afterBlocks, t: fmtDur(a.afterSec) }) : '')
260
261// ---------- moments: what changed on the proposal on screen ----------
262type MomentKind = 'arrive' | 'veto' | 'clear' | 'exec'
263/** How long each moment plays (style.css keyframes fit inside) */
264const MOMENT_MS: Record<MomentKind, number> = { arrive: 1300, veto: 2700, clear: 2500, exec: 2500 }
265interface Moment {
266 id: string
267 kinds: MomentKind[]
268 delays: number[]
269 until: number
270}
271let shown: { id: string | null; phase: AirlockPhase | null } = { id: null, phase: null }
272let moment: Moment | null = null
273let enterUntil = 0
274
275/** Which moments a change of phase plays (from null: the proposal was not on screen before) */
276export function momentsFor(from: AirlockPhase | null, to: AirlockPhase): MomentKind[] {
277 const fresh = from === null || from === 'idle'
278 const cooling = from === 'chamber' || from === 'waiting'
279 if (to === 'idle' || from === to) return []
280 if (to === 'ejected') return fresh ? ['arrive', 'veto'] : ['veto']
281 if (to === 'executed') return fresh ? ['arrive', 'clear', 'exec'] : cooling ? ['clear', 'exec'] : ['exec']
282 if (to === 'cleared' || to === 'ready') return fresh ? ['arrive', 'clear'] : cooling ? ['clear'] : []
283 return fresh ? ['arrive'] : []
284}
285
286function track(id: string | null, ph: AirlockPhase, now: number) {
287 if (id !== shown.id) {
288 const was = shown.id
289 shown = { id, phase: ph }
290 moment = null
291 // a proposal that just arrived live plays its arrival; a pick by the viewer only fades in
292 const a = store.arrival
293 if (!RF && id && a?.id === id && now - a.at < 30_000) begin(id, momentsFor(null, ph), now)
294 else if (was !== null || id !== null) enterUntil = now + 500
295 return
296 }
297 if (ph !== shown.phase) {
298 const kinds = momentsFor(shown.phase, ph)
299 shown.phase = ph
300 if (id && kinds.length) begin(id, kinds, now)
301 }
302}
303
304function begin(id: string, kinds: MomentKind[], now: number) {
305 if (!kinds.length || reducedMotion()) return
306 const delays: number[] = []
307 let at = 0
308 for (const k of kinds) {
309 delays.push(at)
310 at += MOMENT_MS[k]
311 }
312 moment = { id, kinds, delays, until: now + at + 300 }
313}
314
315/** The moment classes and delay variables for one region, or nothing once the moment is over */
316function mom(now: number): { cls: string; style: string; delay: (k: MomentKind) => number | null } {
317 const m = moment
318 if (!m || now > m.until || m.id !== shown.id) return { cls: '', style: '', delay: () => null }
319 return {
320 cls: m.kinds.map((k) => ` m-${k}`).join(''),
321 style: m.kinds.map((k, i) => `--d-${k}:${m.delays[i]}ms`).join(';'),
322 delay: (k) => (m.kinds.includes(k) ? m.delays[m.kinds.indexOf(k)] : null),
323 }
324}
325
326/** A door caption: its ' · ' phrases as spans (inline on the stage, one per line on a phone), a number glued to its
327 * unit and "block" to its "#n" with no-break spaces, so a narrow column never strands a unit or a separator */
328export function phrases(s: string): string {
329 const glue = (x: string) => x.replace(/(\d) (?=\S)/g, '$1\u00a0').replace(/ #/g, '\u00a0#').replace(/ ≈ /g, ' ≈\u00a0')
330 return s
331 .split(' · ')
332 .map((x) => `<span class="ph">${esc(glue(x))}</span>`)
333 .join('<span class="sep"> · </span>')
334}
335
336// ---------- top bar ----------
337function renderBar() {
338 patch('t-sub', esc(t('brand.sub')))
339 let status: string
340 const blk = (n: number) => (n ? `<span class="blk"><span class="blk-l">${t('st.block')}</span><span class="blk-n">#${num(n)}</span></span>` : '')
341 if (OFFLINE) {
342 const label = RECORDING.source === 'none' ? t('st.offlineEmpty') : t(RECORDING.source === 'sepolia' ? 'st.offline' : 'st.offlineFork')
343 status = `<span class="pill replay"><i class="dot"></i>${esc(label)}</span>${blk(RF ? RF.vblock : RECORDING.head)}`
344 } else if (RF) {
345 // a replay on the live page: the pill says so (live comes back the moment the replay ends or a live event lands)
346 status = `<span class="pill replay"><i class="dot"></i>${esc(t('st.replayOf', { id: RF.tl.id }))}</span>${blk(RF.vblock)}`
347 } else {
348 const st = !blocks.latest ? 'connecting' : blocks.link === 'down' ? 'down' : 'live'
349 const label = st === 'live' ? t(FORK ? 'st.fork' : 'st.live') : st === 'down' ? t('st.down') : t('st.connecting')
350 status = `<span class="pill ${st}${FORK ? ' fork' : ''}"><i class="dot"></i>${esc(label)}</span>${blk(blocks.latest)}`
351 }
352 patch('t-status', status)
353 patch('t-cre', `<span class="cre-by">${esc(t('cre.by'))}</span><span class="cre-logo">${I.hex}<b>Chainlink</b><span>CRE</span></span>`)
354 const cur = lang()
355 const langs = LANGS.map((l) => `<button data-lang="${l}" class="${l === cur ? 'on' : ''}" aria-pressed="${l === cur}" lang="${l}">${LANG_LABEL[l]}</button>`).join('')
356 patch(
357 't-ctl',
358 opBarButton() +
359 `<div class="seg" role="group" title="${esc(t('ctl.langTitle'))}">${langs}</div>` +
360 `<button data-act="details" class="btn${ui.details ? ' on' : ''}" title="${esc(t('ctl.detailsTitle'))}" aria-expanded="${ui.details}">${esc(t('ctl.details'))}</button>` +
361 `<button data-act="help" class="btn sq${ui.help ? ' on' : ''}" title="${esc(t('ctl.helpTitle'))}" aria-label="${esc(t('ctl.help'))}">?</button>`,
362 )
363}
364
365// ---------- hero ----------
366function renderHero(p: Proposal | null, v: AirlockView, now: number, intro: boolean, pend: Launch | null) {
367 const m = mom(now)
368 const enter = now < enterUntil ? ' enter' : ''
369 const ph = v.phase
370 const id = p ? identOf(p) : null
371 const slow = intro && introSlow(now)
372 const fastNow = !!RF && ph === 'cleared' && labPlaying(now)?.stage === 'fast'
373
374 // which proposal
375 if (pend) {
376 // the operator just launched a real attack: its transaction is on its way into a Sepolia block
377 patch(
378 'h-head',
379 `<div class="h-eyebrow"><span>${esc(t('h.pendEyebrow'))}</span><span class="tag live"><i class="dot"></i>${esc(t('h.live'))}</span></div>` +
380 `<h1 class="h-title">${esc(labName(pend.card))}</h1><p class="h-desc">${esc(t('h.pendDesc'))}</p>`,
381 )
382 } else if (intro) {
383 // first visit, before its replay starts: say a real attack is about to replay, never the newest final verdict.
384 // When the public RPCs do not answer for a while, say so and offer the recorded run instead of "in a moment".
385 const body = slow
386 ? `<h1 class="h-title">${esc(t('h.slowTitle'))}</h1><p class="h-desc">${esc(t('h.slowDesc'))} <a class="h-off" href="${esc(offlineHref())}">${esc(t('h.slowLink'))}</a></p>`
387 : `<h1 class="h-title">${esc(t('h.introTitle'))}</h1><p class="h-desc">${esc(t('h.introDesc'))}</p>`
388 patch('h-head', `<div class="h-eyebrow"><span>${esc(t('h.introEyebrow'))}</span><span class="tag replay">${I.play}${esc(t('h.replayReal'))}</span></div>` + body)
389 } else if (!p) {
390 const n = delayBlocks()
391 // ?p=<id> of a proposal the chain does not have (yet): say so, never "the airlock is empty"
392 const missing = !OFFLINE && !PENDING && !!chainHealth.pickMissing && store.activeId === chainHealth.pickMissing ? chainHealth.pickMissing : null
393 const newest = store.newestId()
394 const head = PENDING ? (OFFLINE ? t('h.noRecording') : t('h.pending')) : missing ? t('h.noSuch', { id: missing }) : OFFLINE || chainHealth.loadedRecent ? t('h.empty') : t('h.loading')
395 const desc = missing && newest ? t('h.noSuchDesc', { id: missing, n: newest }) : t('h.emptyDesc', { n, t: fmtDur(n * CONFIG.blockSec) })
396 patch('h-head', `<div class="h-eyebrow">&nbsp;</div><h1 class="h-title">${esc(head)}</h1><p class="h-desc">${esc(desc)}</p>`)
397 } else {
398 // a replay says it is one (live mode: of a real Sepolia proposal, with its date); a proposal happening now says live
399 const tag = RF
400 ? `<span class="tag replay">${RF.paused ? I.pause : I.play}${RF.paused ? esc(t('h.replayPaused')) : OFFLINE ? esc(t('h.replay')) : replayLabel(store.proposals.get(RF.tl.id))}</span>`
401 : p.dropped
402 ? `<span class="tag warn">${esc(t('h.dropped'))}</span>`
403 : isLiveNow(p)
404 ? `<span class="tag live"><i class="dot"></i>${esc(t('h.live'))}</span>`
405 : OFFLINE
406 ? ''
407 : // a past proposal's real state: dated, so it never reads as if it had just happened live
408 `<span class="tag state">${esc(dayOf(p) ? t('h.onchain', { d: dayOf(p)! }) : t('h.onchainNoDate'))}</span>`
409 patch(
410 'h-head',
411 `<div class="h-eyebrow${enter}"><span>${esc(t('h.eyebrow', { id: p.id, kind: kindText(p.kind) }))}</span>${tag}</div>` +
412 `<h1 class="h-title${enter}">${esc(id!.title)}</h1><p class="h-desc${enter}">${esc(id!.desc)}</p>`,
413 )
414 }
415
416 // the airlock picture; the capsule position rides on --p (set on the wrapper, so a new block does not redraw it)
417 const wrap = $('h-wrap')
418 const pos = ph === 'executed' ? 1 : ph === 'idle' ? 0 : v.pct / 100
419 wrap.style.setProperty('--p', pos.toFixed(4))
420 const lockOpen = ph === 'cleared' || ph === 'ready' || ph === 'executed'
421 const capIcon = ph === 'ejected' ? I.x : lockOpen ? I.check : I.hex
422 const cap = p && ph !== 'idle' ? `<div class="cap-in"><div class="cap-out"><div class="lk-cap">${capIcon}<b>${esc(id!.name)}</b></div></div></div>` : ''
423 const proxyState = ph === 'executed' ? t('g.proxyLive', { name: id!.name }) : ph === 'idle' ? t('g.proxyIdle') : t('g.proxySafe')
424 patch(
425 'h-lock',
426 `<div class="lk ph-${ph}${m.cls}${enter}" style="${m.style}">` +
427 `<div class="lk-owner">${I.key}<b>${esc(t('g.owner'))}</b><small>${esc(t('g.ownerSub'))}</small><i class="lk-path"></i></div>` +
428 `<div class="lk-door lk-outer"><i class="lf t"></i><i class="lf b"></i></div>` +
429 `<div class="lk-ch"><i class="lk-grid"></i><i class="lk-scan"></i><i class="lk-flash"></i><div class="lk-rail"><i class="lk-fill"></i><i class="lk-x">${I.x}</i></div>${cap}</div>` +
430 `<div class="lk-door lk-inner"><i class="lf t"></i><i class="lf b"></i><span class="lk-lock">${lockOpen ? I.unlock : I.lock}</span></div>` +
431 `<div class="lk-dest">${I.proxy}<b>${esc(t('g.proxy'))}</b><small>${esc(proxyState)}</small></div>` +
432 `</div>`,
433 )
434
435 // the numbers in the chamber
436 let count: string
437 let still = false
438 switch (ph) {
439 case 'chamber':
440 case 'cleared':
441 count =
442 `<div class="cn-num"><b>${v.left}</b><span>${esc(t('c.left', { n: v.left }))}</span></div>` +
443 `<div class="cn-eta">${I.clock}${esc(t('c.eta', { t: fmtClock(etaSec(v)) }))}<span class="cn-of">${esc(t('c.leftSub'))}</span></div>` +
444 // a replay's clock is compressed (it holds while the CRE judges): say so, so a still number does not look stuck
445 (RF ? `<div class="cn-rp">${esc(t('c.compressed'))}</div>` : '')
446 if (ph === 'cleared') {
447 count += fastNow
448 ? `<div class="cn-tag ff">${I.ff}${esc(t('c.ff', { n: FF_FACTOR }))}</div>`
449 : `<div class="cn-tag good">${I.check}${esc(v.clear?.by === 'cre' || !v.clear ? t('c.cleared') : t('c.clearedBy', { who: whoName(v.clear.by) }))}</div>`
450 } else {
451 // a replay walks through the workflow's steps here too (on a phone the verdict card is below the fold)
452 const rs = p ? replayStep(p, now) : null
453 count += rs
454 ? `<div class="cn-tag await step"><i class="spin"></i><span>${esc(t('c.step', { s: rs.i === rs.steps.length - 1 ? t('p.chain') : t(rs.steps[rs.i].k), i: rs.i + 1, n: rs.steps.length }))}</span></div>`
455 : `<div class="cn-tag await">${esc(t('c.awaiting'))}</div>`
456 }
457 break
458 case 'waiting':
459 count = `<div class="cn-word warn">${esc(t('c.over'))}</div><div class="cn-sub">${esc(t('c.noClear'))}</div>`
460 still = true
461 break
462 case 'ready':
463 count = `<div class="cn-word good">${esc(t('c.ready'))}</div><div class="cn-sub">${esc(t('c.readySub'))}</div>`
464 still = true
465 break
466 case 'executed':
467 count = `<div class="cn-word good">${I.check}${esc(t('c.executed'))}</div>`
468 still = true
469 break
470 case 'ejected':
471 count = `<div class="cn-stamp">${esc(t('c.ejected'))}</div><div class="cn-sub">${esc(v.veto ? t('c.vetoAt', { n: v.veto.afterBlocks }) : t(logsPending() ? 'v.loading' : 'v.far'))}</div>`
472 still = true
473 break
474 default:
475 // a replay's lead-in: the proposal is about to reach the outer door
476 count = pend
477 ? `<div class="cn-sub intro"><i class="spin"></i>${esc(t(pend.state === 'sending' ? 'op.sending' : 'c.pending'))}</div>` +
478 (pend.txHash ? `<div class="cn-tx">${txLink(pend.txHash, 6)}</div>` : '')
479 : intro
480 ? `<div class="cn-sub intro"><i class="spin"></i>${esc(t(slow ? 'c.slow' : 'c.intro'))}</div>`
481 : RF && p
482 ? `<div class="cn-sub arriving">${esc(t('c.arriving', { id: p.id }))}</div>`
483 : `<div class="cn-sub dim">${esc(t('c.empty'))}</div>`
484 }
485 // a ticking clock never carries the moment classes (it is redrawn every second)
486 patch('h-count', `<div class="cn ph-${ph}${still ? m.cls : ''}" style="${still ? m.style : ''}">${count}</div>`)
487
488 // the clearance lands: a green CLEARED stamp over the chamber for a moment, then the countdown comes back. It has
489 // its own layer (the countdown below it redraws every second, which would restart an animation)
490 const dClear = m.delay('clear')
491 const stamping = dClear !== null && (ph === 'cleared' || ph === 'ready' || ph === 'executed')
492 wrap.classList.toggle('stamping', stamping)
493 if (stamping) wrap.style.setProperty('--d-clear', `${dClear}ms`)
494 patch('h-stamp', stamping ? `<div class="cstamp"><span class="cn-stamp good">${esc(t('c.clearStamp'))}</span></div>` : '')
495
496 // the three doors in words
497 const n = p?.readyBlock && p.proposeBlock ? p.readyBlock - p.proposeBlock : delayBlocks()
498 const outer = p?.proposeBlock && ph !== 'idle' ? t('d.outerOn', { b: num(p.proposeBlock) }) : t('d.outerIdle')
499 const [inner, innerCls] =
500 ph === 'executed'
501 ? [p?.execute ? t('d.innerOpen', { b: num(p.execute.block) }) : t('c.executed'), 'good']
502 : ph === 'ejected'
503 ? [t('d.innerSealed'), 'bad']
504 : ph === 'ready'
505 ? [t('d.innerReady'), 'good']
506 : ph === 'cleared'
507 ? [t('d.innerUnlocked', { b: num(p!.readyBlock!) }), 'good']
508 : ph === 'waiting'
509 ? [t('d.innerWaiting'), 'warn']
510 : [t('d.innerLocked'), '']
511 patch(
512 'h-doors',
513 `<div class="dl"><span class="dl-k">${esc(t('d.outer'))}</span><span class="dl-v">${phrases(outer)}</span></div>` +
514 `<div class="dl c"><span class="dl-k">${esc(t('d.chamber'))}</span><span class="dl-v">${phrases(t('d.chamberSub', { n, t: fmtDur(n * CONFIG.blockSec) }))}</span></div>` +
515 `<div class="dl r"><span class="dl-k">${esc(t('d.inner'))}</span><span class="dl-v ${innerCls}">${phrases(inner)}</span></div>`,
516 )
517
518 // the sentence under it
519 let icon = I.clock
520 let text: string
521 let rule = ''
522 switch (ph) {
523 case 'ejected': {
524 icon = I.eject
525 text = v.veto ? t('b.ejected', { who: whoName(v.veto.by) }) : t('b.ejectedFar')
526 const r = v.veto?.rule
527 if (r) rule = `<span class="bn-rule"><b>${esc(ruleShort(r))}</b>${esc(ruleShortText(r))}</span>`
528 break
529 }
530 case 'executed':
531 icon = I.check
532 text = t('b.executed', { name: id!.name })
533 break
534 case 'ready':
535 icon = I.unlock
536 text = t('b.ready', { who: whoName(v.clear?.by) })
537 break
538 case 'cleared':
539 icon = I.unlock
540 text = t('b.cleared', { who: whoName(v.clear?.by) })
541 break
542 case 'waiting':
543 icon = I.lock
544 text = t('b.waiting')
545 break
546 case 'chamber':
547 text = t('b.chamber')
548 break
549 default:
550 text = pend ? t('b.pending') : intro ? t('b.intro') : RF && p ? t('b.arriving') : t('b.idle')
551 }
552 patch('h-banner', `<div class="banner ph-${ph}${m.cls}${enter}" style="${m.style}"><span class="bn-i">${icon}</span><span class="bn-t">${esc(text)}</span>${rule}</div>`)
553}
554
555// ---------- verdict card ----------
556export interface PipeStep {
557 k: Key
558 s: Key
559 /** this step decided the veto (drawn with an x) */
560 fail?: boolean
561}
562const STEP = {
563 sim: { k: 'p.sim', s: 'p.simS' },
564 agree: { k: 'p.agree', s: 'p.agreeS' },
565 cfg: { k: 'p.cfg', s: 'p.cfgS' },
566 approval: { k: 'p.static', s: 'p.staticS' },
567 undecided: { k: 'p.simFail', s: 'p.simFailS', fail: true },
568 don: { k: 'p.don', s: 'p.donS' },
569 sign: { k: 'p.sign', s: 'p.signS' },
570 chain: { k: 'p.chain', s: 'p.chainS' },
571} satisfies Record<string, PipeStep>
572
573/** The steps the workflow really runs for a proposal (README, "How it works" and the rules table): a change to the
574 * airlock itself (kinds 1-3) is judged from chain reads at the proposal block (R7); an upgrade vetoed by a static
575 * rule (R5, R8) never reaches the simulation; R6 means the simulation could not decide. Every other upgrade is
576 * attack-simulated on 4 providers whose answers must agree. Consensus, the signed report and the write on chain
577 * follow in every case. */
578export function pipelineOf(kind: string | undefined, rule: string | null | undefined): PipeStep[] {
579 const tail = [STEP.don, STEP.sign, STEP.chain]
580 if (kind !== undefined && Number(kind) !== 0) return [STEP.cfg, ...tail]
581 if (rule === 'R5_STRICT_CODEHASH' || rule === 'R8_INIT_DATA') return [STEP.approval, ...tail]
582 if (rule === 'R6_SIM_FAILURE') return [STEP.undecided, ...tail]
583 return [STEP.sim, STEP.agree, ...tail]
584}
585
586function renderVerdict(p: Proposal | null, v: AirlockView, now: number, intro: boolean, pend: Launch | null) {
587 const m = mom(now)
588 const vi: VerdictInfo = pend ? { kind: 'pending', rule: null, after: null } : intro ? { kind: 'intro', rule: null, after: null } : verdictOf(p, v)
589 // a replay says so here too (on a phone the labels at the top have scrolled away by now)
590 patch('v-head', `${I.hex}<h2>${esc(t('v.title'))}</h2>${RF && !OFFLINE ? `<span class="tag replay v-rp">${I.play}${esc(t('v.replayTag'))}</span>` : ''}`)
591
592 let chip: string
593 let body: string
594 switch (vi.kind) {
595 case 'veto':
596 chip = `<span class="v-chip veto">${I.x}${esc(t('v.veto'))}</span>${vi.rule ? `<span class="v-code">${esc(ruleShort(vi.rule))}</span>` : ''}`
597 body = `<p class="v-rule">${esc(vi.rule ? ruleText(vi.rule) : '')}</p>`
598 break
599 case 'clear':
600 chip = `<span class="v-chip clear">${I.check}${esc(t('v.clear'))}</span>`
601 // an upgrade survived the attack simulation; a change to the airlock passed the config check (no simulation)
602 body = `<p class="v-rule">${esc(t(p && Number(p.kind) !== 0 ? 'v.clearTextCfg' : 'v.clearText'))}</p>`
603 break
604 case 'human':
605 chip = `<span class="v-chip veto">${I.x}${esc(t('v.veto'))}</span>${vi.rule ? `<span class="v-code">${esc(ruleShort(vi.rule))}</span>` : ''}`
606 body = `<p class="v-rule">${esc(t('v.human'))}</p>`
607 break
608 case 'far':
609 chip = `<span class="v-chip veto">${I.x}${esc(t('v.veto'))}</span>`
610 body = `<p class="v-rule">${esc(t('v.far'))}</p>`
611 break
612 case 'clearFar':
613 chip = `<span class="v-chip clear">${I.check}${esc(t('v.clear'))}</span>`
614 body = `<p class="v-rule">${esc(t('v.clearFar'))}</p>`
615 break
616 case 'loading':
617 chip = `<span class="v-chip none"><i class="spin"></i>${esc(t('v.loadingChip'))}</span>`
618 body = `<p class="v-rule dim">${esc(t('v.loading'))}</p>`
619 break
620 case 'approver':
621 case 'skipped':
622 chip = `<span class="v-chip none">${esc(t('v.none'))}</span>`
623 body = `<p class="v-rule">${esc(t(vi.kind === 'approver' ? 'v.approver' : 'v.skipped'))}</p>`
624 break
625 case 'await':
626 chip = `<span class="v-chip await"><i class="spin"></i>${esc(t('v.awaiting'))}</span>`
627 body = `<p class="v-rule">${esc(t('v.awaitText'))}</p>`
628 break
629 case 'intro':
630 chip = `<span class="v-chip none">${I.play}${esc(t('v.introChip'))}</span>`
631 body = `<p class="v-rule dim">${esc(t('v.introText'))}</p>`
632 break
633 case 'pending':
634 chip = `<span class="v-chip none"><i class="spin"></i>${esc(t('v.pendChip'))}</span>`
635 body = `<p class="v-rule dim">${esc(t('v.pendText'))}</p>`
636 break
637 default:
638 chip = `<span class="v-chip none">${esc(t('v.none'))}</span>`
639 body = `<p class="v-rule dim">${esc(t('v.noneText'))}</p>`
640 }
641 const time = vi.after ? `<p class="v-time">${afterText(vi.after)}</p>` : ''
642 const land = vi.kind === 'veto' || vi.kind === 'clear'
643 patch('v-main', `<div class="vm k-${vi.kind}${land ? m.cls : ''}" style="${land ? m.style : ''}"><div class="v-verdict">${chip}</div>${body}${time}</div>`)
644
645 // the pipeline this proposal really went through: dim before a proposal, running while it waits, every step done
646 // once the verdict is on chain. A replay walks through the steps one by one until its verdict lands (the steps
647 // the workflow runs, not their real timing: the real verdict time is the "after the proposal" line above).
648 const st = land ? 'done' : vi.kind === 'await' ? 'run' : 'off'
649 const last = vi.kind === 'veto' ? t('p.landVeto') : vi.kind === 'clear' ? t('p.landClear') : t('p.chain')
650 // a replay's verdict decides the steps up front (an R7 change has no simulation step), so they do not change shape
651 const rule = vi.kind === 'veto' ? vi.rule : RF && st === 'run' ? (vetoRuleOf(store.proposals.get(RF.tl.id)) ?? null) : null
652 const steps = pipelineOf(p?.kind, rule)
653 const end = steps.length - 1
654 const f = RF && st === 'run' ? judgeProgress(now) : null
655 const cur = f === null ? -1 : Math.min(end, Math.floor(f * steps.length))
656 patch(
657 'v-pipe',
658 `<ol class="pipe st-${st} k-${vi.kind} n-${steps.length}${cur >= 0 ? ' seq' : ''}${land ? m.cls : ''}" style="${land ? m.style : ''}">` +
659 steps
660 .map((s, i) => {
661 const x = vi.kind === 'veto' && (i === end || (s.fail && land))
662 const sq = cur < 0 ? '' : i < cur ? 'ok' : i === cur ? 'cur' : 'next'
663 const cls = [s.fail && land ? 'fail' : '', sq].filter(Boolean).join(' ')
664 const dot = sq === 'cur' ? '<i class="spin"></i>' : x ? I.x : I.check
665 return `<li class="${cls}" style="--i:${i}"><span class="pi-dot">${dot}</span><div class="pi-t"><b>${esc(i === end ? last : t(s.k))}</b><small>${esc(t(s.s))}</small></div></li>`
666 })
667 .join('') +
668 `</ol>`,
669 )
670
671 // links
672 if (!p || v.phase === 'idle') {
673 patch('v-links', '')
674 return
675 }
676 const rows = p.proposeTx ? [`<div class="lnk"><span>${esc(t('l.proposal'))}</span>${txLink(p.proposeTx)}</div>`] : []
677 if (vi.tx) rows.push(`<div class="lnk"><span>${esc(t('l.report'))}</span>${txLink(vi.tx)}</div>`)
678 if (v.phase === 'executed' && p.execute) rows.push(`<div class="lnk"><span>${esc(t('l.execute'))}</span>${txLink(p.execute.tx)}</div>`)
679 if (vi.evidence) rows.push(`<div class="lnk wide"><span>${esc(t('l.evidence'))}</span>${hashSpan(vi.evidence, 6)}</div>`)
680 patch('v-links', rows.join(''))
681}
682
683// ---------- strip of recent proposals ----------
684function renderStrip() {
685 const showing = RF ? RF.tl.id : store.activeId
686 const title = OFFLINE ? t('s.titleOffline') : FORK ? t('s.titleFork') : t('s.title')
687 const auto = OFFLINE
688 ? `<button data-act="loop" class="toggle${replay.paused ? '' : ' on'}" title="${esc(t('help.loop'))} (P)"><i class="dot"></i>${esc(t('s.loop'))} · ${esc(t(replay.paused ? 's.off' : 's.on'))}</button>`
689 : `<button data-act="follow" class="toggle${store.follow ? ' on' : ''}" title="${esc(t('help.follow'))} (F)"><i class="dot"></i>${esc(t('s.follow'))} · ${esc(t(store.follow ? 's.on' : 's.off'))}</button>`
690 const ids = store.recentIds().slice().reverse()
691 let cards: string
692 if (!ids.length) {
693 const msg = PENDING ? '' : OFFLINE || chainHealth.loadedRecent ? t('s.none') : t('s.loading')
694 cards = `<div class="s-empty">${esc(msg)}</div>`
695 } else {
696 cards = ids
697 .map((id) => {
698 const p = store.proposals.get(id)!
699 const v = finalView(p)
700 const ph = v.phase
701 const ident = identOf(p)
702 // every card has one rhythm: what it is (rule, or what held), then the verdict with its blocks after the
703 // proposal, then where it stands
704 let line = ''
705 let foot = ''
706 const verdictAfter = (k: Key, n: number) => `<b>${esc(t(k))}</b> ${esc(t('s.after', { n }))}`
707 if (ph === 'ejected') {
708 const r = v.veto?.rule
709 line = r ? `<b class="mono">${esc(ruleShort(r))}</b> · ${esc(ruleShortText(r))}` : esc(t(logsPending() ? 'v.loading' : 'v.far'))
710 if (v.veto) foot = verdictAfter('s.veto', v.veto.afterBlocks)
711 } else if (ph === 'executed' || ph === 'ready' || ph === 'cleared') {
712 const byCre = v.clear?.by === 'cre' || (!v.clear && p.guards.cre.verdict === 'pass')
713 line = byCre ? esc(t(Number(p.kind) !== 0 ? 's.cfgOk' : 's.allHeld')) : v.clear ? esc(t('c.clearedBy', { who: whoName(v.clear.by) })) : esc(t('ph.cleared'))
714 const where = ph === 'executed' ? t('s.executed') : ph === 'cleared' ? t('s.left', { n: v.left }) : t('s.ready')
715 foot = (v.clear ? `${verdictAfter('s.clear', v.clear.afterBlocks)} · ` : '') + esc(where)
716 } else if (ph === 'chamber') {
717 line = esc(t('s.left', { n: v.left }))
718 foot = esc(t('s.awaiting'))
719 } else if (ph === 'waiting') line = esc(t('s.noClear'))
720 // a card being replayed says so in its status chip until the replay reaches its final state
721 let chipPh: AirlockPhase = ph
722 let chip = esc(t(`ph.${ph}` as Key))
723 if (RF?.tl.id === id) {
724 const rv = viewOf(RF.p)
725 if (rv.phase !== ph) {
726 chipPh = rv.phase
727 chip = `${I.play}${esc(t('s.replaying'))}`
728 }
729 }
730 const on = showing === id
731 // a long name gets a smaller size, so every card keeps a one-line title
732 const long = [...ident.name].reduce((w, c) => w + (/[\u3000-\u9fff\uff00-\uffef]/.test(c) ? 2 : 1), 0) > 16
733 return (
734 `<button class="pc ph-${chipPh}${on ? ' on' : ''}" data-act="sel:${esc(id)}" aria-pressed="${on}">` +
735 `<span class="pc-top"><span class="pc-id">#${esc(id)}</span><span class="pc-chip${chipPh !== ph ? ' rp' : ''}">${chip}</span></span>` +
736 `<span class="pc-name${long ? ' long' : ''}">${esc(ident.name)}</span><span class="pc-line">${line}</span><span class="pc-foot">${foot}</span></button>`
737 )
738 })
739 .join('')
740 }
741 patch(
742 'strip',
743 `<div class="s-head"><h3>${esc(title)}</h3>${PENDING ? '' : auto}<span class="s-hint">${esc(t('s.hint'))}</span>${tabsHtml()}</div>` +
744 `<div class="s-cards" style="--n:${Math.max(ids.length, 1)}">${cards}</div>`,
745 )
746}
747
748// ---------- details drawer (D) ----------
749const row = (k: string, v: string) => `<div class="dr-row"><dt>${esc(k)}</dt><dd>${v}</dd></div>`
750
751function costText(tx: string | undefined): string {
752 if (!tx) return '<span class="dim">—</span>'
753 const c = txCost(store.receipts.get(tx.toLowerCase()))
754 if (!c) return `<span class="dim">${esc(t('dr.reading'))}</span>`
755 return esc(t('dr.costV', { c: fmtEth(c.costWei), g: num(Number(c.gasUsed)), p: fmtGasPrice(c.priceWei) }))
756}
757
758function renderDetails(p: Proposal | null, v: AirlockView) {
759 const el = $('details')
760 el.classList.toggle('open', ui.details)
761 el.setAttribute?.('aria-hidden', String(!ui.details))
762 if (!ui.details) return
763 const out: string[] = []
764 if (p) {
765 const vi = verdictOf(p, v)
766 const wf = realWorkflowId(vi.workflow)
767 const b = (n: number | null | undefined) => (n ? ` · ${esc(t('dr.atBlock', { b: num(n) }))}` : '')
768 const vblock = v.veto?.block ?? v.clear?.block
769 const rows = [
770 row(t('dr.kind'), `#${esc(p.id)} · ${esc(kindText(p.kind))}`),
771 row(t('dr.target'), addrLink(p.target, 6) + verifiedChip(p.target)),
772 row(t('dr.codehash'), fullHash(p.codehash)),
773 row(t('dr.proposer'), addrLink(p.chain?.proposer, 6)),
774 row(t('dr.proposed'), txLink(p.proposeTx, 6) + b(p.proposeBlock)),
775 row(t('dr.cooldown'), p.proposeBlock && p.readyBlock ? esc(t('dr.cooldownV', { a: num(p.proposeBlock), b: num(p.readyBlock), n: p.readyBlock - p.proposeBlock })) : '—'),
776 row(t('dr.verdictTx'), txLink(vi.tx, 6) + b(vblock)),
777 row(t('dr.cost'), costText(vi.tx)),
778 row(t('dr.evidence'), fullHash(vi.evidence)),
779 ]
780 if (wf) rows.push(row(t('dr.workflow'), fullHash(wf)))
781 if (p.execute) rows.push(row(t('dr.executed'), txLink(p.execute.tx, 6) + b(p.execute.block)))
782 out.push(`<section><h4>${esc(t('dr.proposal'))}</h4><dl>${rows.join('')}</dl></section>`)
783 }
784 if (!PENDING) {
785 const d = store.deploy
786 out.push(
787 `<section><h4>${esc(t('dr.contracts'))}</h4><dl>` +
788 row('AirlockAdmin', addrLink(d?.airlockAdmin ?? DATA.airlockAdmin, 6) + verifiedChip(d?.airlockAdmin ?? DATA.airlockAdmin)) +
789 row('AirlockReceiver', addrLink(DATA.receiver, 6)) +
790 row(t('g.proxy'), addrLink(d?.proxy ?? DATA.proxy, 6)) +
791 row('Forwarder', addrLink(DATA.forwarder, 6)) +
792 `</dl></section>`,
793 )
794 }
795 let conn: string
796 if (OFFLINE) {
797 conn =
798 row(t('dr.heads'), esc(t('link.offline'))) +
799 (RECORDING.source === 'none'
800 ? ''
801 : row(
802 t('dr.recording'),
803 esc(t('dr.recordingV', { src: t(RECORDING.source === 'sepolia' ? 'src.sepolia' : 'src.fork'), a: num(RECORDING.deployment.startBlock), b: num(RECORDING.head), d: RECORDING.recordedOn })),
804 ))
805 } else {
806 const heads = blocks.link === 'ws' ? t('link.ws') : blocks.link === 'poll' ? t('link.poll', { s: Math.round(CHAIN.pollMs / 1000) }) : t('link.down')
807 const logs = chainHealth.logsLink === 'ws' ? t('link.logsWs') : chainHealth.logsLink === 'poll' ? t('link.logsPoll', { c: chainHealth.cursor ? num(chainHealth.cursor) : '…' }) : t('link.connecting')
808 const raw = blocks.blockTimeMs()
809 const bt = plausibleBlockMs(raw, CONFIG.minPlausibleBlockMs)
810 let host = ''
811 try {
812 host = blocks.linkUrl ? new URL(blocks.linkUrl).host : ''
813 } catch {
814 host = ''
815 }
816 conn =
817 row(t('dr.heads'), esc(heads)) +
818 row(t('dr.logs'), esc(logs)) +
819 row(t('dr.bt'), esc(bt ? t('bt.measured', { s: (bt / 1000).toFixed(1) }) : raw !== null ? t('bt.fast') : t('bt.measuring'))) +
820 (host ? row(t('dr.endpoint'), `<span class="mono">${esc(host)}</span>`) : '')
821 }
822 out.push(`<section><h4>${esc(t('dr.conn'))}</h4><dl>${conn}</dl></section>`)
823 const lines = store.stream.slice(-14).reverse()
824 out.push(
825 `<section><h4>${esc(t('dr.stream'))}</h4><div class="dr-stream">` +
826 (lines.length
827 ? lines
828 .map((l) => {
829 const d = new Date(l.ts)
830 // venue time (UTC+8) whatever the laptop's time zone is
831 const ts = d.toLocaleTimeString('en-GB', { timeZone: 'Asia/Shanghai', hour12: false })
832 return `<div class="ln ${l.level}"><span class="ln-t">${ts}</span><span class="ln-m">${esc(tm(l.msg))}</span></div>`
833 })
834 .join('')
835 : `<div class="dim">${esc(t('dr.noStream'))}</div>`) +
836 `</div></section>`,
837 )
838 out.push(`<section><h4>${esc(t('dr.about'))}</h4><p class="dr-about">${esc(t('dr.aboutBody'))}</p></section>`)
839 patch('details', `<div class="dr-head"><h3>${esc(t('dr.title'))}</h3><button data-act="close" class="btn">${esc(t('dr.close'))}</button></div><div class="dr-body">${out.join('')}</div>`)
840}
841
842// ---------- help overlay (?) ----------
843const HELP_TEXT: Record<Action, Key> = {
844 prev: 'help.prev',
845 next: 'help.next',
846 newest: 'help.newest',
847 replay: 'help.replay',
848 follow: 'help.follow',
849 loop: 'help.loop',
850 details: 'help.details',
851 lang: 'help.lang',
852 help: 'help.help',
853 lab: 'help.lab',
854 operator: 'help.operator',
855 close: 'help.esc',
856}
857function renderHelp() {
858 const el = $('help')
859 el.classList.toggle('open', ui.help)
860 el.setAttribute?.('aria-hidden', String(!ui.help))
861 if (!ui.help) return
862 // the operator key is listed only where operator mode is on for this browser
863 const rows = HELP_ROWS.filter((r) => r.action !== 'operator' || opVisible()).map((r) => {
864 const keys = r.keys.map((k) => `<kbd>${esc(k === 'Space' ? t('help.space') : k)}</kbd>`).join('')
865 const what = r.action === 'prev' ? `${t('help.prev')} / ${t('help.next')}` : t(HELP_TEXT[r.action])
866 return `<div class="hp-row"><span class="hp-k">${keys}</span><span class="hp-v">${esc(what)}</span></div>`
867 }).join('')
868 patch(
869 'help',
870 `<div class="hp-card" role="dialog" aria-label="${esc(t('help.title'))}"><h3>${esc(t('help.title'))}</h3><div class="hp-rows">${rows}</div>` +
871 `<div class="hp-url"><b>${esc(t('help.url'))}</b><span class="mono">${esc(t('help.urlBody'))}</span></div><p class="hp-foot">${esc(t('help.foot'))}</p></div>`,
872 )
873}
874
875export function render() {
876 const now = Date.now()
877 RF = replayFrame(now)
878 // first visit before its replay: a neutral intro, not the newest proposal's final state. The operator's launch on
879 // its way into a block: shown as pending (auto-follow), not the previous proposal's final state
880 const pend = !RF && store.follow ? pendingLaunch(store.proposals.values(), now) : null
881 const intro = !RF && !pend && labIntro(now)
882 const p = RF ? RF.p : intro || pend ? null : store.active
883 const v = viewOf(p)
884 track(p?.id ?? null, v.phase, now)
885 renderBar()
886 renderHero(p, v, now, intro, pend)
887 renderVerdict(p, v, now, intro, pend)
888 renderStrip()
889 renderLab(now)
890 renderDetails(p, v)
891 renderHelp()
892 renderOperator(now)
893}