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