@mobilewright/inspector 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,572 @@
1
+ // Mobilewright Inspector frontend. No framework, no build step.
2
+
3
+ // ---- Pure locator utilities ----
4
+
5
+ const DEFAULT_HIDDEN_TESTIDS = new Set(['android:id/content'])
6
+
7
+ function locatorKey(locator) {
8
+ if (locator.kind === 'role') return `role:${locator.value}:${locator.name ?? ''}`
9
+ return `${locator.kind}:${locator.value}`
10
+ }
11
+
12
+ function escQ(s) {
13
+ return s
14
+ .replace(/\\/g, '\\\\')
15
+ .replace(/'/g, "\\'")
16
+ .replace(/\r/g, '\\r')
17
+ .replace(/\n/g, '\\n')
18
+ .replace(/\u2028/g, '\\u2028')
19
+ .replace(/\u2029/g, '\\u2029')
20
+ }
21
+
22
+ function locatorLabel(locator) {
23
+ if (locator.kind === 'testId') return `getByTestId('${escQ(locator.value)}')`
24
+ if (locator.kind === 'role') {
25
+ return locator.name
26
+ ? `getByRole('${escQ(locator.value)}', { name: '${escQ(locator.name)}' })`
27
+ : `getByRole('${escQ(locator.value)}')`
28
+ }
29
+ if (locator.kind === 'label') return `getByLabel('${escQ(locator.value)}')`
30
+ if (locator.kind === 'text') return `getByText('${escQ(locator.value)}')`
31
+ return ''
32
+ }
33
+
34
+ function buildDuplicateSet(elements) {
35
+ const counts = new Map()
36
+ for (const el of elements) {
37
+ if (!el.locator) continue
38
+ const key = locatorKey(el.locator)
39
+ counts.set(key, (counts.get(key) ?? 0) + 1)
40
+ }
41
+ const dupes = new Set()
42
+ for (const [k, n] of counts) if (n > 1) dupes.add(k)
43
+ return dupes
44
+ }
45
+
46
+ // ---- ScreenshotPane ----
47
+ // Owns the screenshot image, SVG highlight overlay, and placeholder state.
48
+
49
+ class ScreenshotPane {
50
+ #img
51
+ #overlay
52
+ #placeholder
53
+ #placeholderTitle
54
+ #placeholderSub
55
+ #logicalWidth = 0
56
+ #logicalHeight = 0
57
+ #elements = []
58
+ #hiddenIndices = new Set()
59
+ #selectedIndex = null
60
+ #onClickCb = null
61
+ #screenshotPane // cached pane element for #constrainSize
62
+ // O(1) lookup from element index to its SVG rect; rebuilt on each renderHighlights call.
63
+ #rectByIndex = new Map()
64
+
65
+ constructor() {
66
+ this.#img = document.getElementById('screenshot-img')
67
+ this.#overlay = document.getElementById('highlight-overlay')
68
+ this.#placeholder = document.getElementById('no-device-msg')
69
+ this.#placeholderTitle = document.getElementById('placeholder-title')
70
+ this.#placeholderSub = document.getElementById('placeholder-sub')
71
+ this.#screenshotPane = document.getElementById('screenshot-pane')
72
+ window.addEventListener('resize', () => this.#constrainSize())
73
+ }
74
+
75
+ onElementClick(cb) { this.#onClickCb = cb }
76
+
77
+ get isScreenshotHidden() { return this.#img.hidden }
78
+
79
+ showPlaceholder(title, sub = '', loading = false) {
80
+ this.#placeholder.hidden = false
81
+ this.#img.hidden = true
82
+ this.#overlay.setAttribute('hidden', '')
83
+ this.#placeholderTitle.textContent = title
84
+ this.#placeholderSub.textContent = sub
85
+ this.#placeholder.classList.toggle('loading', loading)
86
+ }
87
+
88
+ showScreenshot(dataUrl, logicalWidth = 0, logicalHeight = 0) {
89
+ this.#logicalWidth = logicalWidth
90
+ this.#logicalHeight = logicalHeight
91
+ this.#placeholder.hidden = true
92
+ this.#placeholder.classList.remove('loading')
93
+ this.#img.hidden = false
94
+ this.#overlay.removeAttribute('hidden')
95
+ this.#img.src = dataUrl
96
+ this.#img.onload = () => this.#constrainSize()
97
+ }
98
+
99
+ renderHighlights(elements, hiddenIndices, selectedIndex) {
100
+ this.#elements = elements
101
+ this.#hiddenIndices = hiddenIndices
102
+ this.#selectedIndex = selectedIndex
103
+ this.#buildRects()
104
+ }
105
+
106
+ setSelectedIndex(index) {
107
+ // O(1): deselect old rect, select new one directly via index map.
108
+ this.#rectByIndex.get(this.#selectedIndex)?.classList.remove('selected')
109
+ this.#selectedIndex = index
110
+ this.#rectByIndex.get(index)?.classList.add('selected')
111
+ }
112
+
113
+ #buildRects() {
114
+ this.#overlay.innerHTML = ''
115
+ this.#rectByIndex.clear()
116
+ for (const el of this.#visibleElements()) {
117
+ const { x, y, width, height } = el.bounds
118
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
119
+ rect.setAttribute('x', x)
120
+ rect.setAttribute('y', y)
121
+ rect.setAttribute('width', width)
122
+ rect.setAttribute('height', height)
123
+ rect.classList.add('highlight-rect')
124
+ if (el.index === this.#selectedIndex) rect.classList.add('selected')
125
+ rect.addEventListener('click', () => this.#onClickCb?.(el.index))
126
+ rect.addEventListener('mouseenter', () => rect.classList.add('hovered'))
127
+ rect.addEventListener('mouseleave', () => rect.classList.remove('hovered'))
128
+ this.#rectByIndex.set(el.index, rect)
129
+ this.#overlay.appendChild(rect)
130
+ }
131
+ }
132
+
133
+ #visibleElements() {
134
+ return this.#elements.filter(el =>
135
+ el.bounds &&
136
+ el.isVisible &&
137
+ !this.#hiddenIndices.has(el.index) &&
138
+ el.bounds.width > 0 &&
139
+ el.bounds.height > 0
140
+ )
141
+ }
142
+
143
+ #constrainSize() {
144
+ if (this.#img.hidden) return
145
+ const cs = getComputedStyle(this.#screenshotPane)
146
+ const availH = this.#screenshotPane.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
147
+ this.#img.style.maxHeight = availH + 'px'
148
+ const vw = this.#logicalWidth || this.#img.naturalWidth
149
+ const vh = this.#logicalHeight || this.#img.naturalHeight
150
+ this.#overlay.setAttribute('width', this.#img.offsetWidth)
151
+ this.#overlay.setAttribute('height', this.#img.offsetHeight)
152
+ this.#overlay.setAttribute('viewBox', `0 0 ${vw} ${vh}`)
153
+ }
154
+ }
155
+
156
+ // ---- ElementsPane ----
157
+ // Owns the element list: rendering rows, selection state, visibility toggles.
158
+
159
+ class ElementsPane {
160
+ #list
161
+ #onClickCb = null
162
+ #onToggleCb = null
163
+ // O(1) lookup from element index to its row element; rebuilt on each render call.
164
+ #rowByIndex = new Map()
165
+ #selectedRow = null
166
+
167
+ constructor() {
168
+ this.#list = document.getElementById('elements-list')
169
+ this.#list.setAttribute('role', 'list')
170
+ }
171
+
172
+ onElementClick(cb) { this.#onClickCb = cb }
173
+ onToggleHidden(cb) { this.#onToggleCb = cb }
174
+
175
+ render(elements, hiddenIndices) {
176
+ this.#list.innerHTML = ''
177
+ this.#rowByIndex.clear()
178
+ this.#selectedRow = null
179
+ if (elements.length === 0) {
180
+ const msg = document.createElement('div')
181
+ msg.className = 'pane-message'
182
+ msg.textContent = 'No elements'
183
+ this.#list.appendChild(msg)
184
+ return
185
+ }
186
+ const dupes = buildDuplicateSet(elements)
187
+ for (const el of elements) {
188
+ const row = this.#buildRow(el, hiddenIndices, dupes)
189
+ this.#rowByIndex.set(el.index, row)
190
+ this.#list.appendChild(row)
191
+ }
192
+ }
193
+
194
+ setSelectedIndex(index) {
195
+ // O(1): deselect previous row, select new one directly via index map.
196
+ this.#selectedRow?.classList.remove('selected')
197
+ this.#selectedRow = this.#rowByIndex.get(index) ?? null
198
+ if (this.#selectedRow) {
199
+ this.#selectedRow.classList.add('selected')
200
+ this.#selectedRow.scrollIntoView({ block: 'nearest' })
201
+ }
202
+ }
203
+
204
+ updateRowVisibility(index, hidden) {
205
+ const row = this.#rowByIndex.get(index)
206
+ if (!row) return
207
+ row.classList.toggle('element-hidden', hidden)
208
+ const btn = row.querySelector('.vis-btn')
209
+ if (btn) {
210
+ btn.textContent = hidden ? '○' : '◉'
211
+ btn.title = hidden ? 'Show on screenshot' : 'Hide from screenshot'
212
+ btn.setAttribute('aria-label', hidden ? 'Show on screenshot' : 'Hide from screenshot')
213
+ btn.setAttribute('aria-pressed', String(!hidden))
214
+ }
215
+ }
216
+
217
+ #buildRow(el, hiddenIndices, dupes) {
218
+ const row = document.createElement('div')
219
+ row.className = 'element-row'
220
+ row.setAttribute('role', 'listitem')
221
+ row.tabIndex = 0
222
+ row.dataset.index = el.index
223
+ if (!el.locator) row.classList.add('no-locator')
224
+ if (hiddenIndices.has(el.index)) row.classList.add('element-hidden')
225
+
226
+ const locLabel = el.locator ? locatorLabel(el.locator) : null
227
+ row.setAttribute('aria-label', locLabel ?? `${el.type ?? 'unknown'} (no locator)`)
228
+
229
+ const badge = document.createElement('span')
230
+ badge.className = `locator-badge badge-${el.locator?.kind ?? 'none'}`
231
+ badge.textContent = el.locator?.kind ?? 'none'
232
+ row.appendChild(badge)
233
+
234
+ const value = document.createElement('span')
235
+ value.className = 'locator-value'
236
+ value.textContent = locLabel ?? '(no locator)'
237
+ if (locLabel) value.title = locLabel
238
+ row.appendChild(value)
239
+
240
+ if (el.locator && dupes.has(locatorKey(el.locator))) {
241
+ const warn = document.createElement('span')
242
+ warn.className = 'duplicate-warning'
243
+ warn.textContent = 'dup'
244
+ warn.title = 'Multiple elements share this locator'
245
+ row.appendChild(warn)
246
+ }
247
+
248
+ const type = document.createElement('span')
249
+ type.className = 'element-type'
250
+ type.textContent = el.type ?? ''
251
+ row.appendChild(type)
252
+
253
+ const isHidden = hiddenIndices.has(el.index)
254
+ const visBtn = document.createElement('button')
255
+ visBtn.className = 'vis-btn'
256
+ visBtn.textContent = isHidden ? '○' : '◉'
257
+ visBtn.title = isHidden ? 'Show on screenshot' : 'Hide from screenshot'
258
+ visBtn.setAttribute('aria-label', isHidden ? 'Show on screenshot' : 'Hide from screenshot')
259
+ visBtn.setAttribute('aria-pressed', String(!isHidden))
260
+ visBtn.addEventListener('click', e => { e.stopPropagation(); this.#onToggleCb?.(el.index) })
261
+ row.appendChild(visBtn)
262
+
263
+ row.addEventListener('click', () => this.#onClickCb?.(el.index))
264
+ row.addEventListener('keydown', e => {
265
+ if (e.target !== row) return
266
+ if (e.key === 'Enter' || e.key === ' ') {
267
+ e.preventDefault()
268
+ this.#onClickCb?.(el.index)
269
+ }
270
+ })
271
+ return row
272
+ }
273
+ }
274
+
275
+ // ---- Inspector ----
276
+ // Orchestrates device management, the refresh cycle, and shared selection/visibility state.
277
+
278
+ class Inspector {
279
+ #state = {
280
+ devices: [],
281
+ activeId: null,
282
+ elements: [],
283
+ selectedIndex: null,
284
+ logicalWidth: 0,
285
+ logicalHeight: 0,
286
+ hiddenIndices: new Set(),
287
+ }
288
+ // Persists user visibility overrides across refreshes: locatorKey -> 'hidden' | 'visible'.
289
+ // Pruned on each refresh to keys present in the new element list.
290
+ #userOverrides = new Map()
291
+ #autoRefreshTimer = null
292
+ #tickInFlight = false
293
+ #refreshInFlight = false
294
+ #connectInFlight = false
295
+ #consecutiveErrors = 0
296
+ static #MAX_CONSECUTIVE_ERRORS = 3
297
+
298
+ #screenshotPane = new ScreenshotPane()
299
+ #elementsPane = new ElementsPane()
300
+ #deviceSelect = document.getElementById('device-select')
301
+ #refreshBtn = document.getElementById('refresh-btn')
302
+ #autoRefreshToggle = document.getElementById('auto-refresh-toggle')
303
+ #autoRefreshInterval = document.getElementById('auto-refresh-interval')
304
+ #statusBar = document.getElementById('status-bar')
305
+
306
+ constructor() {
307
+ this.#screenshotPane.onElementClick(i => this.#selectElement(i))
308
+ this.#elementsPane.onElementClick(i => this.#selectElement(i))
309
+ this.#elementsPane.onToggleHidden(i => this.#toggleHidden(i))
310
+
311
+ this.#refreshBtn.addEventListener('click', () => this.refresh())
312
+ this.#deviceSelect.addEventListener('change', () => {
313
+ const opt = this.#deviceSelect.selectedOptions[0]
314
+ if (!opt?.value) return
315
+ const device = this.#state.devices.find(d => d.id === opt.value)
316
+ if (device) this.#connectDevice(device)
317
+ })
318
+ this.#autoRefreshToggle.addEventListener('change', () => this.#applyAutoRefresh())
319
+ this.#autoRefreshInterval.addEventListener('change', () => this.#applyAutoRefresh())
320
+
321
+ this.#loadDevices()
322
+ }
323
+
324
+ async refresh() {
325
+ if (!this.#state.activeId) return
326
+ if (this.#refreshInFlight) return
327
+ this.#refreshInFlight = true
328
+ this.#refreshBtn.disabled = true
329
+
330
+ try {
331
+ const res = await fetch('/api/inspect')
332
+ if (res.status === 503) return // another inspect in flight, skip this tick — no state changed yet
333
+ this.#setStatus('Loading...', 'loading')
334
+ if (this.#screenshotPane.isScreenshotHidden) {
335
+ this.#screenshotPane.showPlaceholder('Loading screenshot...', '', true)
336
+ }
337
+ if (res.status === 409) {
338
+ this.#state.activeId = null
339
+ this.#state.elements = []
340
+ this.#state.hiddenIndices = new Set()
341
+ this.#state.selectedIndex = null
342
+ this.#screenshotPane.showPlaceholder('Device disconnected', 'Select a device to continue')
343
+ this.#elementsPane.render([], new Set())
344
+ this.#screenshotPane.renderHighlights([], new Set(), null)
345
+ this.#setStatus('Device disconnected', 'error')
346
+ this.#renderDevicePicker()
347
+ return
348
+ }
349
+ if (!res.ok) {
350
+ const err = await res.json()
351
+ throw new Error(err.error ?? res.statusText)
352
+ }
353
+ const data = await res.json()
354
+ this.#state.elements = data.elements ?? []
355
+ this.#state.selectedIndex = null
356
+ this.#state.logicalWidth = data.screen?.width ?? 0
357
+ this.#state.logicalHeight = data.screen?.height ?? 0
358
+
359
+ // Prune overrides for locator keys no longer present in the new element list.
360
+ const liveKeys = new Set(
361
+ this.#state.elements.filter(el => el.locator).map(el => locatorKey(el.locator))
362
+ )
363
+ for (const k of this.#userOverrides.keys()) {
364
+ if (!liveKeys.has(k)) this.#userOverrides.delete(k)
365
+ }
366
+
367
+ this.#state.hiddenIndices = this.#computeHiddenIndices()
368
+
369
+ this.#screenshotPane.showScreenshot(data.screenshot, this.#state.logicalWidth, this.#state.logicalHeight)
370
+ this.#elementsPane.render(this.#state.elements, this.#state.hiddenIndices)
371
+ this.#screenshotPane.renderHighlights(this.#state.elements, this.#state.hiddenIndices, null)
372
+ this.#setStatus(`${this.#state.elements.length} elements`)
373
+ this.#consecutiveErrors = 0
374
+ } catch (err) {
375
+ this.#consecutiveErrors++
376
+ this.#setStatus('Refresh failed: ' + err.message, 'error')
377
+ if (this.#screenshotPane.isScreenshotHidden) {
378
+ this.#screenshotPane.showPlaceholder('Could not load screenshot', err.message)
379
+ }
380
+ if (this.#consecutiveErrors >= Inspector.#MAX_CONSECUTIVE_ERRORS) {
381
+ this.#stopAutoRefresh()
382
+ this.#setStatus(`Auto-refresh stopped after ${this.#consecutiveErrors} consecutive failures`, 'error')
383
+ }
384
+ } finally {
385
+ this.#refreshInFlight = false
386
+ this.#refreshBtn.disabled = false
387
+ }
388
+ }
389
+
390
+ async #loadDevices() {
391
+ try {
392
+ await this.#fetchDevices()
393
+ if (this.#state.activeId) {
394
+ await this.refresh()
395
+ }
396
+ } catch (err) {
397
+ this.#setStatus('Could not load devices: ' + err.message, 'error')
398
+ }
399
+ }
400
+
401
+ async #fetchDevices() {
402
+ const res = await fetch('/api/devices')
403
+ if (!res.ok) throw new Error(`Device list failed: ${res.status}`)
404
+ const data = await res.json()
405
+ const prevActiveId = this.#state.activeId
406
+ this.#state.devices = data.devices ?? []
407
+ this.#state.activeId = data.activeId ?? null
408
+ if (this.#state.activeId !== prevActiveId) this.#userOverrides.clear()
409
+ this.#renderDevicePicker()
410
+ if (prevActiveId && !this.#state.activeId) {
411
+ this.#state.elements = []
412
+ this.#state.selectedIndex = null
413
+ this.#state.hiddenIndices = new Set()
414
+ this.#elementsPane.render([], new Set())
415
+ this.#screenshotPane.showPlaceholder('Device disconnected', 'Select a device to continue')
416
+ this.#setStatus('Device disconnected', 'error')
417
+ }
418
+ }
419
+
420
+ #renderDevicePicker() {
421
+ const currentIds = [...this.#deviceSelect.options].filter(o => o.value).map(o => o.value)
422
+ const newIds = this.#state.devices.map(d => d.id)
423
+ const sameList = currentIds.length === newIds.length && currentIds.every((id, i) => id === newIds[i])
424
+ if (sameList && this.#deviceSelect.value === (this.#state.activeId ?? '')) return
425
+
426
+ this.#deviceSelect.innerHTML = ''
427
+ if (this.#state.devices.length === 0) {
428
+ const opt = document.createElement('option')
429
+ opt.value = ''
430
+ opt.textContent = 'No devices connected'
431
+ this.#deviceSelect.appendChild(opt)
432
+ return
433
+ }
434
+ if (!this.#state.activeId) {
435
+ const placeholder = document.createElement('option')
436
+ placeholder.value = ''
437
+ placeholder.textContent = 'Select a device'
438
+ placeholder.disabled = true
439
+ placeholder.selected = true
440
+ this.#deviceSelect.appendChild(placeholder)
441
+ }
442
+ for (const d of this.#state.devices) {
443
+ const opt = document.createElement('option')
444
+ opt.value = d.id
445
+ opt.dataset.platform = d.platform
446
+ opt.textContent = `${d.name} (${d.platform}, ${d.type})`
447
+ if (d.id === this.#state.activeId) opt.selected = true
448
+ this.#deviceSelect.appendChild(opt)
449
+ }
450
+ }
451
+
452
+ async #connectDevice(device) {
453
+ if (this.#connectInFlight) return
454
+ this.#connectInFlight = true
455
+ this.#setStatus('Connecting...', 'loading')
456
+ this.#screenshotPane.showPlaceholder('Connecting...', device.name ?? device.id, true)
457
+ this.#refreshBtn.disabled = true
458
+ this.#deviceSelect.disabled = true
459
+ try {
460
+ const res = await fetch(`/api/devices/${encodeURIComponent(device.id)}/select`, {
461
+ method: 'POST',
462
+ headers: { 'Content-Type': 'application/json' },
463
+ body: JSON.stringify({ platform: device.platform }),
464
+ })
465
+ if (!res.ok) {
466
+ const err = await res.json()
467
+ throw new Error(err.error ?? res.statusText)
468
+ }
469
+ this.#state.activeId = device.id
470
+ this.#userOverrides.clear()
471
+ this.#renderDevicePicker()
472
+ await this.refresh()
473
+ } catch (err) {
474
+ this.#setStatus('Connect failed: ' + err.message, 'error')
475
+ this.#screenshotPane.showPlaceholder('Connect failed', err.message)
476
+ this.#renderDevicePicker()
477
+ } finally {
478
+ this.#connectInFlight = false
479
+ this.#deviceSelect.disabled = false
480
+ this.#refreshBtn.disabled = false
481
+ }
482
+ }
483
+
484
+ #selectElement(index) {
485
+ this.#state.selectedIndex = index
486
+ this.#screenshotPane.setSelectedIndex(index)
487
+ this.#elementsPane.setSelectedIndex(index)
488
+ }
489
+
490
+ #toggleHidden(index) {
491
+ const el = this.#state.elements.find(el => el.index === index)
492
+ const key = el?.locator ? locatorKey(el.locator) : null
493
+ const nowHidden = this.#state.hiddenIndices.has(index)
494
+ // Only persist overrides for unique locator keys — shared keys would affect all duplicates.
495
+ const isDupe = key ? buildDuplicateSet(this.#state.elements).has(key) : false
496
+
497
+ if (nowHidden) {
498
+ this.#state.hiddenIndices.delete(index)
499
+ if (key && !isDupe) this.#userOverrides.set(key, 'visible')
500
+ } else {
501
+ this.#state.hiddenIndices.add(index)
502
+ if (key && !isDupe) this.#userOverrides.set(key, 'hidden')
503
+ }
504
+
505
+ this.#screenshotPane.renderHighlights(this.#state.elements, this.#state.hiddenIndices, this.#state.selectedIndex)
506
+ this.#elementsPane.updateRowVisibility(index, this.#state.hiddenIndices.has(index))
507
+ }
508
+
509
+ #computeHiddenIndices() {
510
+ const dupes = buildDuplicateSet(this.#state.elements)
511
+ return new Set(
512
+ this.#state.elements
513
+ .filter(el => {
514
+ const key = el.locator ? locatorKey(el.locator) : null
515
+ const override = key && !dupes.has(key) ? this.#userOverrides.get(key) : undefined
516
+ if (override === 'visible') return false
517
+ if (override === 'hidden') return true
518
+ return el.locator?.kind === 'testId' && DEFAULT_HIDDEN_TESTIDS.has(el.locator.value)
519
+ })
520
+ .map(el => el.index)
521
+ )
522
+ }
523
+
524
+ #applyAutoRefresh() {
525
+ clearInterval(this.#autoRefreshTimer)
526
+ this.#autoRefreshTimer = null
527
+ this.#autoRefreshInterval.disabled = !this.#autoRefreshToggle.checked
528
+ this.#consecutiveErrors = 0 // reset when user manually reconfigures auto-refresh
529
+ if (this.#autoRefreshToggle.checked) {
530
+ const ms = Number(this.#autoRefreshInterval.value)
531
+ this.#autoRefreshTimer = setInterval(async () => {
532
+ if (this.#tickInFlight) return
533
+ this.#tickInFlight = true
534
+ try {
535
+ await this.#fetchDevices().catch(() => {})
536
+ await this.refresh()
537
+ } finally {
538
+ this.#tickInFlight = false
539
+ }
540
+ }, ms)
541
+ }
542
+ }
543
+
544
+ // Called from the error path only — unregisters the timer and unchecks the toggle.
545
+ #stopAutoRefresh() {
546
+ clearInterval(this.#autoRefreshTimer)
547
+ this.#autoRefreshTimer = null
548
+ this.#autoRefreshToggle.checked = false
549
+ this.#autoRefreshInterval.disabled = true
550
+ }
551
+
552
+ #setStatus(msg, type = '') {
553
+ this.#statusBar.textContent = msg
554
+ this.#statusBar.className = type
555
+ }
556
+ }
557
+
558
+ // ---- Theme ----
559
+
560
+ function applyTheme(name) {
561
+ document.documentElement.setAttribute('data-theme', name)
562
+ localStorage.setItem('mobilewright-inspector-theme', name)
563
+ const sel = document.getElementById('theme-select')
564
+ if (sel) sel.value = name
565
+ }
566
+
567
+ document.getElementById('theme-select')?.addEventListener('change', e => applyTheme(e.target.value))
568
+
569
+ // ---- Bootstrap ----
570
+
571
+ applyTheme(localStorage.getItem('mobilewright-inspector-theme') || 'void')
572
+ new Inspector()