Files
8bitMyGoUI/web/main.js
T
ZacharyZhang-NY ae11fbf00c Measure the budget end to end, and fail on any console error
The Playwright suite now checks PRD section 7 and the budget on the
local build and on each preview and live URL.

Frame times count the whole frame: Go reports its share after
copying the pixels, and the page adds the canvas blit and keeps
p50 and p95 over the last 240 frames. A test slows putImageData
and expects p95 to show it.

The budget test runs on a moving component page and logs what it
measures: first frame, p50, p95 and the wasm's brotli bytes, as
Cloudflare sent them or compressed locally. Today: first frame
about 60 ms, p95 about 10 ms, wasm under 5 MB, against 300 ms,
16 ms and 8 MB.

Every page now ends free of console and page errors, whether a
test used open() or not; the two tests that fail on purpose say so.
Clicks and typing reach components on their own pages.
2026-10-12 16:21:14 -04:00

244 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Feeds browser input to the Go gallery; blits its frames.
'use strict'
const screen = document.getElementById('screen')
const ctx = screen.getContext('2d')
const ime = document.getElementById('ime')
const blocks = [...document.querySelectorAll('#bar i')]
const params = new URLSearchParams(location.search)
const mac = /Mac|iPhone|iPad/.test(navigator.platform)
const cursors = ['default', 'pointer', 'text', 'move', 'ew-resize', 'ns-resize', 'nwse-resize', 'nesw-resize',
'not-allowed', 'crosshair', 'grab', 'grabbing', 'n-resize', 'e-resize', 's-resize', 'w-resize', 'col-resize',
'row-resize', 'vertical-text', 'copy', 'alias', 'context-menu', 'none']
const SHIFT = 1, CTRL = 2, ALT = 4, SUPER = 8
const log = []
// times are recent frames' ms: Go's share plus the blit.
const times = []
const asked = params.get('zoom')
const fixed = /^[1-9]$/.test(asked) ? Number(asked) : 0
let api, zoom, scale, timer, first, active = false, skipText = ''
function note (msg) {
log.push(msg)
console.info('8bit:', msg)
}
window.__8bitCrash = (msg) => {
console.error('8bit: crash', msg)
clearTimeout(timer)
document.getElementById('why').textContent = msg
document.getElementById('over').hidden = false
}
document.getElementById('continue').onclick = () => location.reload()
if (asked !== null && !fixed) note(`ignored zoom=${asked}: use a whole number 1–9`)
// size picks device pixels per dot; returns window in dots.
function size () {
zoom = fixed || Math.max(1, Math.round(devicePixelRatio * (innerWidth >= 1000 ? 2 : 1)))
scale = zoom / devicePixelRatio
return [Math.max(1, Math.floor(innerWidth / scale)), Math.max(1, Math.floor(innerHeight / scale))]
}
// percentiles are the median and 95th of the frame times.
function percentiles (ms) {
const s = [...ms].sort((a, b) => a - b)
const at = p => s.length ? s[Math.floor(p * (s.length - 1))] : 0
return { p50: at(0.5), p95: at(0.95) }
}
function hashPath () { return decodeURIComponent(location.hash.slice(1)) }
// show blits a frame and acts on Go's requests.
function show (r) {
if (!r) return
const t = performance.now()
if (screen.width !== r.w || screen.height !== r.h) {
screen.width = r.w
screen.height = r.h
}
screen.style.width = r.w * scale + 'px'
screen.style.height = r.h * scale + 'px'
ctx.putImageData(new ImageData(r.pix, r.w, r.h), 0, 0)
times.push(r.took + performance.now() - t)
if (times.length > 240) times.shift()
if (r.path && '#' + r.path !== location.hash && !(r.path === '/' && location.hash === '')) {
history.pushState(null, '', '#' + r.path)
}
screen.style.cursor = cursors[r.cursor]
active = r.caret !== undefined
if (active) {
ime.style.left = r.caret[0] * scale + 'px'
ime.style.top = r.caret[1] * scale + 'px'
ime.style.height = r.caret[2] * scale + 'px'
}
if (r.copied !== undefined && navigator.clipboard) navigator.clipboard.writeText(r.copied).catch(e => note('clipboard: ' + e))
for (const url of r.open || []) window.open(url, '_blank', 'noopener')
clearTimeout(timer)
if (r.due !== undefined && !document.hidden) timer = setTimeout(() => show(api.frame()), Math.max(0, r.due))
}
function mods (e) {
const m = (e.shiftKey ? SHIFT : 0) | (e.altKey ? ALT : 0) | (e.ctrlKey ? CTRL : 0) | (e.metaKey ? SUPER : 0)
// wasm's ui.Cmd is Ctrl; ⌘ stays Super for line edits.
if (mac && e.metaKey && !/^(Arrow|Backspace|Delete)/.test(e.code)) return (m & ~SUPER) | CTRL
return m
}
function at (e) {
const r = screen.getBoundingClientRect()
return [(e.clientX - r.left) / scale, (e.clientY - r.top) / scale]
}
// Taps click; held fingers drag; moving fingers scroll.
let touch = null
screen.addEventListener('pointerdown', (e) => {
e.preventDefault()
if (!e.isPrimary) return
ime.focus({ preventScroll: true })
screen.setPointerCapture(e.pointerId)
const [x, y] = at(e)
if (e.pointerType === 'touch') {
const t = touch = { x, y, drag: false, scroll: false }
t.hold = setTimeout(() => { t.drag = true; show(api.pointer('down', x, y)) }, 400)
} else if (e.button === 0) show(api.pointer('down', x, y))
else if (e.button === 2) show(api.pointer('right', x, y))
})
screen.addEventListener('pointermove', (e) => {
if (!e.isPrimary) return
const [x, y] = at(e)
if (e.pointerType !== 'touch' || touch?.drag) return show(api.pointer('move', x, y))
if (!touch || (!touch.scroll && Math.hypot(x - touch.x, y - touch.y) * scale <= 8)) return
clearTimeout(touch.hold)
touch.scroll = true
show(api.wheel(x, y, touch.x - x, touch.y - y))
touch.x = x
touch.y = y
})
screen.addEventListener('pointerup', (e) => {
if (!e.isPrimary) return
const [x, y] = at(e)
if (e.pointerType !== 'touch') {
if (e.button === 0) show(api.pointer('up', x, y))
return
}
if (!touch) return
clearTimeout(touch.hold)
if (touch.drag) show(api.pointer('up', x, y))
else if (!touch.scroll) {
api.pointer('down', x, y)
show(api.pointer('up', x, y))
}
touch = null
})
// A release outside the window cancels the press.
screen.addEventListener('pointercancel', (e) => {
if (!touch || !e.isPrimary) return
clearTimeout(touch.hold)
if (touch.drag) show(api.pointer('up', -1, -1))
touch = null
})
screen.addEventListener('contextmenu', (e) => e.preventDefault())
screen.addEventListener('wheel', (e) => {
e.preventDefault()
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? innerHeight : 1
const [x, y] = at(e)
show(api.wheel(x, y, e.deltaX * unit / scale, e.deltaY * unit / scale))
}, { passive: false })
const keep = new Set(['Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End',
'Backspace', 'Delete', 'Enter', 'NumpadEnter', 'Escape'])
window.addEventListener('keydown', (e) => {
if (e.isComposing || !api) return
const m = mods(e)
const cmd = (m & CTRL) && !(m & ALT)
if (cmd && e.code === 'KeyV') return
// Like native hosts: text only to fields focused at keydown.
const typing = active
show(api.key(e.code, m))
if (keep.has(e.code) || (!typing && e.key.length === 1)) e.preventDefault()
})
ime.addEventListener('input', (e) => {
if (e.isComposing) return
const text = e.data || ''
ime.value = ''
if (!e.inputType.startsWith('insert') || e.inputType === 'insertLineBreak' || e.inputType === 'insertFromPaste') return
if (text === skipText) {
skipText = ''
return
}
if (text) show(api.text(text))
})
ime.addEventListener('compositionupdate', (e) => show(api.compose(e.data, [...e.data].length)))
ime.addEventListener('compositionend', (e) => {
api.compose('', 0)
skipText = e.data
setTimeout(() => { skipText = '' }, 0)
ime.value = ''
show(api.text(e.data))
})
ime.addEventListener('paste', (e) => {
e.preventDefault()
show(api.paste(e.clipboardData.getData('text/plain')))
})
window.addEventListener('focus', () => api && show(api.focus(true)))
window.addEventListener('blur', () => api && show(api.focus(false)))
window.addEventListener('popstate', () => api && show(api.go(hashPath() || '/')))
document.addEventListener('visibilitychange', () => api && !document.hidden && show(api.frame()))
let resizing
window.addEventListener('resize', () => {
clearTimeout(resizing)
resizing = setTimeout(() => {
if (!api) return
const [w, h] = size()
show(api.resize(w, h))
}, 50)
})
// load streams the wasm, filling the bar as bytes arrive.
async function load () {
const res = await fetch('__WASM__')
if (!res.ok) throw new Error(`${res.url}: HTTP ${res.status}`)
const total = Number('__SIZE__')
const reader = res.body.getReader()
const parts = []
let got = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
parts.push(value)
got += value.length
const lit = Math.min(blocks.length, Math.floor(got / total * blocks.length))
blocks.forEach((b, i) => b.classList.toggle('on', i < lit))
document.getElementById('loading').setAttribute('aria-valuenow', lit)
}
const bytes = new Uint8Array(got)
let off = 0
for (const p of parts) {
bytes.set(p, off)
off += p.length
}
const go = new Go()
const { instance } = await WebAssembly.instantiate(bytes, go.importObject)
const t0 = performance.now()
go.run(instance).then(() => window.__8bitCrash('The Go program exited.'))
api = window.__8bitGo
if (!api) throw new Error('Go did not export __8bitGo')
note(`wasm ${(got / 1e6).toFixed(1)} MB loaded`)
const [w, h] = size()
show(api.start(w, h, hashPath()))
first = performance.now() - t0
document.getElementById('loading').remove()
ime.focus({ preventScroll: true })
window.__8bit = {
ready: true,
stats: () => ({ ...api.stats(), ...percentiles(times), zoom, first }),
texts: () => api.texts(),
page: () => api.stats().path,
find: (label) => api.find(label)?.map(v => v * scale),
log
}
note(`ready ${w}x${h} dots, ${zoom} pixels per dot, first frame ${first.toFixed(0)} ms`)
}
load().catch((err) => window.__8bitCrash(String(err && err.stack || err)))