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(). |
| 22 | import { airlockView, fmtClock, fmtDur, type AirlockPhase, type AirlockView } from './airlock' |
| 23 | import { blocks } from './chain' |
| 24 | import { DATA, PENDING, RECORDING } from './chaindata' |
| 25 | import { chainHealth } from './chainwatch' |
| 26 | import { CONFIG } from './config' |
| 27 | import { HELP_ROWS, type Action } from './controls' |
| 28 | import { fmtEth, fmtGasPrice, plausibleBlockMs, realWorkflowId, txCost } from './derive' |
| 29 | import { blockTs } from './events' |
| 30 | import { cardDesc, kindText, labLine, labName, lang, LANG_LABEL, LANGS, onLangChange, ruleShortText, ruleText, setLang, t, tm, type Key } from './i18n' |
| 31 | import { introSlow, isLiveNow, LAB_CARDS, LAB_PLAY, labIntro, labPlaying } from './lab' |
| 32 | import { dayOf, opBarButton, renderLab, renderOperator, replayLabel, tabsHtml } from './lab-ui' |
| 33 | import { opVisible, pendingLaunch, type Launch } from './operator-client' |
| 34 | import { judgeProgress, replay, replayFrame } from './playback' |
| 35 | import type { ReplayFrame } from './replay' |
| 36 | import { CHAIN, CLEAN, FORK, OFFLINE } from './runtime' |
| 37 | import { short, store, type Proposal } from './store' |
| 38 | import { CARDS, ruleShort, type CardInfo } from './types' |
| 39 | import { $, addrLink, esc, fullHash, hashSpan, I, num, patch, txLink } from './view' |
| 40 | |
| 41 | /** Stage mode: wide landscape screens. style.css uses the same query. */ |
| 42 | export const STAGE_QUERY = '(min-width: 960px) and (min-aspect-ratio: 5/4)' |
| 43 | const STAGE_W = 1920 |
| 44 | const STAGE_H = 1080 |
| 45 | |
| 46 | /** Developer drawer and help overlay (main.ts toggles them) */ |
| 47 | export const ui = { details: false, help: false } |
| 48 | |
| 49 | /** During playback the hero shows the virtual block; live, the chain head */ |
| 50 | let RF: ReplayFrame | null = null |
| 51 | const curBlock = () => (RF ? RF.vblock : blocks.latest) |
| 52 | |
| 53 | /** Sourcify badge (live Sepolia only), shown after a confirmed match */ |
| 54 | function 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 ---------- |
| 60 | export 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) */ |
| 105 | function 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. */ |
| 122 | const IDLE_MS = 3000 |
| 123 | function 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 | |
| 137 | const 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 ---------- |
| 146 | function 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 | |
| 151 | interface 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 | } |
| 159 | export 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 */ |
| 183 | const vetoRuleOf = (p: Proposal | undefined): string | null => p?.guards.cre.rule ?? p?.guards.breakglass.rule ?? null |
| 184 | |
| 185 | const 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 */ |
| 188 | const delayBlocks = () => store.deploy?.delayBlocks ?? DATA.delayBlocks ?? CONFIG.delayBlocks |
| 189 | const 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 */ |
| 191 | const 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 */ |
| 193 | const 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 ≈) */ |
| 196 | function 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) */ |
| 203 | const 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 */ |
| 206 | function 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) */ |
| 218 | function 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 ---------- |
| 227 | type 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 */ |
| 229 | const logsPending = () => !OFFLINE && !chainHealth.loadedRecent |
| 230 | interface 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 | } |
| 238 | function 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) */ |
| 259 | const 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 ---------- |
| 262 | type MomentKind = 'arrive' | 'veto' | 'clear' | 'exec' |
| 263 | /** How long each moment plays (style.css keyframes fit inside) */ |
| 264 | const MOMENT_MS: Record<MomentKind, number> = { arrive: 1300, veto: 2700, clear: 2500, exec: 2500 } |
| 265 | interface Moment { |
| 266 | id: string |
| 267 | kinds: MomentKind[] |
| 268 | delays: number[] |
| 269 | until: number |
| 270 | } |
| 271 | let shown: { id: string | null; phase: AirlockPhase | null } = { id: null, phase: null } |
| 272 | let moment: Moment | null = null |
| 273 | let enterUntil = 0 |
| 274 | |
| 275 | /** Which moments a change of phase plays (from null: the proposal was not on screen before) */ |
| 276 | export 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 | |
| 286 | function 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 | |
| 304 | function 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 */ |
| 316 | function 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 */ |
| 328 | export 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 ---------- |
| 337 | function 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 ---------- |
| 366 | function 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"> </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 ---------- |
| 556 | export interface PipeStep { |
| 557 | k: Key |
| 558 | s: Key |
| 559 | /** this step decided the veto (drawn with an x) */ |
| 560 | fail?: boolean |
| 561 | } |
| 562 | const 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. */ |
| 578 | export 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 | |
| 586 | function 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 ---------- |
| 684 | function 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) ---------- |
| 749 | const row = (k: string, v: string) => `<div class="dr-row"><dt>${esc(k)}</dt><dd>${v}</dd></div>` |
| 750 | |
| 751 | function 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 | |
| 758 | function 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 (?) ---------- |
| 843 | const 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 | } |
| 857 | function 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 | |
| 875 | export 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 | } |