Split UI render into full rebuild and passive refresh
All checks were successful
Docker / build-and-push (push) Successful in 37s

The old renderLoop rebuilt the whole DOM every tick and had to bail
out entirely while a <select> was focused to avoid closing it.
refresh() now patches only values that drift on their own
(points/rate, crashes, log lines) in place, leaving the deploy form
and buttons untouched, while render() still handles the structural
rebuilds triggered by user actions.
This commit is contained in:
Fredrik Johansson
2026-07-08 22:59:34 +02:00
parent 85c87e674e
commit 6c1f672b76
2 changed files with 269 additions and 93 deletions

View File

@@ -1,16 +1,28 @@
import './style.css'
import { load, newGame, save } from './state'
import { tick } from './engine'
import { render } from './ui'
import { render, refresh } from './ui'
import { toggleHelp } from './help'
const app = document.querySelector<HTMLDivElement>('#app')!
const state = load() ?? newGame()
// User actions (deploy/uninstall/buy/fix/prestige) change the DOM's shape,
// so they get a full rebuild for instant feedback.
function scheduleRender() {
render(app, state, scheduleRender)
}
// The passive per-tick refresh only patches values that drift on their own
// (points/rate, background crashes, new log lines) - it never recreates
// the deploy form or any button, so it can't step on an in-flight click or
// reset a select the player is mid-choice on.
function scheduleRefresh() {
if (!refresh(app, state, scheduleRender)) scheduleRender()
}
scheduleRender()
let last = performance.now()
// Passive UI refresh - user actions call scheduleRender() directly for
// instant feedback, so this only needs to be fast enough to reflect the
@@ -25,12 +37,7 @@ function loop(now: number) {
}
function renderLoop() {
// Skip the rebuild while a <select> is focused - recreating it mid-render
// forces any open native dropdown to close immediately.
const active = document.activeElement
if (!(active instanceof HTMLSelectElement)) {
scheduleRender()
}
scheduleRefresh()
setTimeout(renderLoop, RENDER_MS)
}

279
src/ui.ts
View File

@@ -69,11 +69,12 @@ function hostLabel(state: GameState, hardwareId: string): string {
return `${hw.nickname} (${def.name})`
}
// Full structural rebuild - used for the initial mount and after any user
// action (deploy/uninstall/buy/fix/prestige), since those already change
// the shape of the DOM (new rows, new buttons) and are driven by a click
// that has already completed, so there's nothing left mid-interaction to
// wipe out.
export function render(root: HTMLElement, state: GameState, onChange: () => void) {
const cap = capacity(state)
const rate = totalRate(state)
const inOutage = Date.now() < state.outageUntil
if (!selectedHardwareId || !state.hardware.find((h) => h.id === selectedHardwareId)) {
selectedHardwareId = state.hardware[0]?.id ?? null
}
@@ -81,12 +82,12 @@ export function render(root: HTMLElement, state: GameState, onChange: () => void
const prevLogScroll = root.querySelector('.log-list')?.scrollTop
root.innerHTML = ''
root.appendChild(buildHeader(state, rate, inOutage))
root.appendChild(buildHeader(state))
root.appendChild(buildRackArt(state))
const grid = document.createElement('div')
grid.className = 'grid'
grid.appendChild(buildLeftColumn(state, cap, onChange))
grid.appendChild(buildLeftColumn(state, onChange))
grid.appendChild(buildRightColumn(state, onChange))
root.appendChild(grid)
@@ -102,34 +103,66 @@ export function render(root: HTMLElement, state: GameState, onChange: () => void
save(state)
}
function buildHeader(state: GameState, rate: number, inOutage: boolean): HTMLElement {
// Passive per-tick refresh - patches the values that drift on their own
// between user actions (points/rate ticking up, background crashes, new
// log lines) in place, instead of tearing the whole panel down. Never
// touches the deploy form or recreates any button, so it can't step on an
// in-flight click or reset a select the player is mid-choice on.
export function refresh(root: HTMLElement, state: GameState, onChange: () => void): boolean {
const header = root.querySelector('.topbar')
const grid = root.querySelector('.grid')
if (!header || !grid) return false // nothing built yet - caller should do a full render
updateHeader(root, state)
updateRackArt(root, state)
updateHardwareList(root, state)
updateBuyButtons(root, state)
updateUpgradeList(root, state)
updateServiceList(root, state, onChange)
updateDeployOptions(root, state)
updateLog(root, state)
const hasBanner = !!root.querySelector('.prestige-banner')
if (canPrestige(state) && !hasBanner) {
root.appendChild(buildPrestigeBanner(state, onChange))
} else if (!canPrestige(state) && hasBanner) {
root.querySelector('.prestige-banner')!.remove()
}
save(state)
return true
}
function buildHeader(state: GameState): HTMLElement {
const header = document.createElement('header')
header.className = 'topbar'
header.innerHTML = `
<div class="brand">rack<span class="cursor">_</span></div>
<div class="stat">
<span class="arrow">${inOutage ? '✗' : '▲'}</span>
<span class="rate">${inOutage ? 'OUTAGE' : `${fmt(rate)} pts/sec`}</span>
<span class="points">${fmt(state.points)} pts</span>
<span class="arrow"></span>
<span class="rate"></span>
<span class="points"></span>
<button class="help-btn" title="Help & changelog (?)">?</button>
</div>
`
header.querySelector('.help-btn')!.addEventListener('click', () => toggleHelp())
updateHeader(header, state)
return header
}
function buildRackArt(state: GameState): HTMLElement {
const section = document.createElement('section')
section.className = 'panel rack-art-panel'
section.innerHTML = `<h2>RACK</h2>`
const pre = document.createElement('pre')
pre.className = 'rack-art'
function updateHeader(root: HTMLElement, state: GameState) {
const rate = totalRate(state)
const inOutage = Date.now() < state.outageUntil
const arrow = root.querySelector('.arrow')
const rateEl = root.querySelector('.rate')
const pointsEl = root.querySelector('.points')
if (arrow) arrow.textContent = inOutage ? '✗' : '▲'
if (rateEl) rateEl.textContent = inOutage ? 'OUTAGE' : `${fmt(rate)} pts/sec`
if (pointsEl) pointsEl.textContent = `${fmt(state.points)} pts`
}
for (const hw of state.hardware) {
function rackArtLineHtml(state: GameState, hw: GameState['hardware'][number]): string {
const hostCap = hostCapacity(state, hw.id)
const line = document.createElement('div')
line.className = 'rack-art-line'
const badges = state.services
.filter((s) => s.hardwareId === hw.id)
.map((s) => {
@@ -143,28 +176,52 @@ function buildRackArt(state: GameState): HTMLElement {
const freeSlots = Math.max(0, hostCap.ram - hostCap.ramUsed)
const dots = '·'.repeat(Math.min(freeSlots, 20))
line.innerHTML = `<span class="rack-host-name">${hw.nickname.padEnd(16, ' ')}</span>${badges}<span class="dim">${dots}</span>`
return `<span class="rack-host-name">${hw.nickname.padEnd(16, ' ')}</span>${badges}<span class="dim">${dots}</span>`
}
function buildRackArt(state: GameState): HTMLElement {
const section = document.createElement('section')
section.className = 'panel rack-art-panel'
section.innerHTML = `<h2>RACK</h2>`
const pre = document.createElement('pre')
pre.className = 'rack-art'
for (const hw of state.hardware) {
const line = document.createElement('div')
line.className = 'rack-art-line'
line.dataset.hw = hw.id
line.innerHTML = rackArtLineHtml(state, hw)
pre.appendChild(line)
}
section.appendChild(pre)
return section
}
function buildLeftColumn(state: GameState, cap: ReturnType<typeof capacity>, onChange: () => void): HTMLElement {
// Same set of hosts still shown (hardware count only changes via buyHardware,
// which triggers a full render) - just patch each line's badges/free-slot
// dots, since crashes/fixes flip badge state every tick.
function updateRackArt(root: HTMLElement, state: GameState) {
for (const hw of state.hardware) {
const line = root.querySelector<HTMLElement>(`.rack-art-line[data-hw="${hw.id}"]`)
if (line) line.innerHTML = rackArtLineHtml(state, hw)
}
}
function buildLeftColumn(state: GameState, onChange: () => void): HTMLElement {
const col = document.createElement('div')
col.className = 'col left'
const hwSection = document.createElement('section')
hwSection.className = 'panel'
hwSection.innerHTML = `<h2>HARDWARE <span class="dim">Σ ram ${cap.ramUsed}/${cap.ram} · pwr ${cap.powerUsed}/${cap.power}</span></h2>`
hwSection.innerHTML = `<h2>HARDWARE <span class="dim cap-summary"></span></h2>`
const hwList = document.createElement('div')
hwList.className = 'list'
hwList.dataset.role = 'hw-list'
for (const hw of state.hardware) {
const def = HARDWARE.find((h) => h.id === hw.defId)!
const hostCap = hostCapacity(state, hw.id)
const item = document.createElement('div')
item.className = 'item hardware' + (hw.id === selectedHardwareId ? ' selected' : '')
item.innerHTML = `<span class="name">${hostLabel(state, hw.id)}</span><span class="dim tiny">ram ${hostCap.ramUsed}/${hostCap.ram} · pwr ${hostCap.powerUsed}/${hostCap.power}</span>`
item.dataset.hw = hw.id
item.title = `${def.flavour} (click to select as the deploy target)`
item.onclick = () => {
selectedHardwareId = hw.id
@@ -174,21 +231,19 @@ function buildLeftColumn(state: GameState, cap: ReturnType<typeof capacity>, onC
}
hwSection.appendChild(hwList)
// Highest-tier hardware the player already owns - buying another unit of
// it is a horizontal bridge (more capacity now, at a rising price) for
// the often-long grind between one tier's cost and the next tier's.
const topOwnedHw = [...state.hardware]
.map((h) => HARDWARE.find((d) => d.id === h.defId)!)
.sort((a, b) => HARDWARE.indexOf(b) - HARDWARE.indexOf(a))[0]
if (topOwnedHw && topOwnedHw.cost > 0) {
const repeatCost = hardwareCost(state, topOwnedHw.id)
// Every tier the player already owns - buying another unit of any of them
// is a horizontal bridge (more capacity now, at a rising price) for the
// often-long grind between one tier's cost and the next tier's.
const ownedTiers = HARDWARE.filter(
(def) => def.cost > 0 && state.hardware.some((h) => h.defId === def.id),
)
for (const tier of ownedTiers) {
const repeatBtn = document.createElement('button')
repeatBtn.className = 'buy-btn'
repeatBtn.textContent = `+ buy another ${topOwnedHw.name} (${fmt(repeatCost)})`
repeatBtn.disabled = state.points < repeatCost
repeatBtn.title = `Add a second ${topOwnedHw.name} as its own host, at a rising price per extra unit.`
repeatBtn.dataset.tier = tier.id
repeatBtn.title = `Add another ${tier.name} as its own host, at a rising price per extra unit.`
repeatBtn.onclick = () => {
if (buyHardware(state, topOwnedHw.id)) onChange()
if (buyHardware(state, tier.id)) onChange()
}
hwSection.appendChild(repeatBtn)
}
@@ -197,6 +252,7 @@ function buildLeftColumn(state: GameState, cap: ReturnType<typeof capacity>, onC
if (nextHw) {
const buyBtn = document.createElement('button')
buyBtn.className = 'buy-btn'
buyBtn.dataset.tier = 'next'
buyBtn.textContent = `+ buy ${nextHw.name} (${fmt(nextHw.cost)})`
buyBtn.disabled = state.points < nextHw.cost
buyBtn.title = nextHw.flavour
@@ -217,6 +273,7 @@ function buildLeftColumn(state: GameState, cap: ReturnType<typeof capacity>, onC
const lockedByReq = up.requires ? !state.upgrades.includes(up.requires) : false
const row = document.createElement('div')
row.className = 'item upgrade' + (owned ? ' owned' : '') + (lockedByReq ? ' locked' : '')
row.dataset.up = up.id
row.title = up.flavour
row.innerHTML = `<span class="name">${up.name}</span><span class="upgrade-status">${
owned ? '✓' : lockedByReq ? '🔒' : fmt(up.cost)
@@ -232,30 +289,59 @@ function buildLeftColumn(state: GameState, cap: ReturnType<typeof capacity>, onC
upSection.appendChild(upList)
col.appendChild(upSection)
updateHardwareList(col, state)
updateBuyButtons(col, state)
updateUpgradeList(col, state)
return col
}
function buildRightColumn(state: GameState, onChange: () => void): HTMLElement {
const col = document.createElement('div')
col.className = 'col right'
function updateHardwareList(root: HTMLElement, state: GameState) {
const cap = capacity(state)
const summary = root.querySelector('.cap-summary')
if (summary) summary.textContent = `Σ ram ${cap.ramUsed}/${cap.ram} · pwr ${cap.powerUsed}/${cap.power}`
const svcSection = document.createElement('section')
svcSection.className = 'panel'
svcSection.innerHTML = `<h2>SERVICES</h2>`
const svcList = document.createElement('div')
svcList.className = 'list'
for (const hw of state.hardware) {
const item = root.querySelector<HTMLElement>(`[data-role="hw-list"] .item[data-hw="${hw.id}"]`)
if (!item) continue
const hostCap = hostCapacity(state, hw.id)
item.classList.toggle('selected', hw.id === selectedHardwareId)
item.innerHTML = `<span class="name">${hostLabel(state, hw.id)}</span><span class="dim tiny">ram ${hostCap.ramUsed}/${hostCap.ram} · pwr ${hostCap.powerUsed}/${hostCap.power}</span>`
}
}
const showRates = state.upgrades.includes('monitoring')
function updateBuyButtons(root: HTMLElement, state: GameState) {
for (const tier of HARDWARE) {
const btn = root.querySelector<HTMLButtonElement>(`.buy-btn[data-tier="${tier.id}"]`)
if (!btn) continue
const cost = hardwareCost(state, tier.id)
btn.textContent = `+ buy another ${tier.name} (${fmt(cost)})`
btn.disabled = state.points < cost
}
const nextBtn = root.querySelector<HTMLButtonElement>('.buy-btn[data-tier="next"]')
if (nextBtn) {
const nextHw = HARDWARE.find((h) => !state.hardware.some((owned) => owned.defId === h.id))
if (nextHw) nextBtn.disabled = state.points < nextHw.cost
}
}
for (const svc of state.services) {
function updateUpgradeList(root: HTMLElement, state: GameState) {
for (const up of UPGRADES) {
const row = root.querySelector<HTMLElement>(`.item.upgrade[data-up="${up.id}"]`)
if (!row) continue
const owned = state.upgrades.includes(up.id)
if (!owned) row.classList.toggle('affordable', state.points >= up.cost)
}
}
function serviceRowHtml(state: GameState, svc: GameState['services'][number]): string {
const def = SERVICES.find((s) => s.id === svc.defId)!
const row = document.createElement('div')
row.className = 'item service ' + svc.status
const showRates = state.upgrades.includes('monitoring')
const rate = ratePerSec(state, svc)
const badge = svc.redundant ? ' <span class="redundant-badge">×2</span>' : ''
const quirk = quirkDef(svc.quirkId)
const quirkBadge = quirk ? ` <span class="quirk-badge" title="${quirk.flavour}">${quirk.name}</span>` : ''
row.innerHTML = `
return `
<span class="status-icon">${svc.status === 'running' ? '✓' : '✗'}</span>
<span class="name">${def.name}${badge}${quirkBadge}</span>
<span class="dim tiny">${hostLabel(state, svc.hardwareId)}</span>
@@ -263,6 +349,14 @@ function buildRightColumn(state: GameState, onChange: () => void): HTMLElement {
<span class="value">${svc.status === 'running' ? `${rate.toFixed(1)}/s` : 'CRASHED'}</span>
${showRates && svc.status === 'running' ? `<span class="dim tiny">risk ${(def.baseCrashPerSec * 3600).toFixed(2)}/hr</span>` : ''}
`
}
function buildServiceRow(state: GameState, svc: GameState['services'][number], onChange: () => void): HTMLElement {
const row = document.createElement('div')
row.dataset.svc = svc.id
row.className = 'item service ' + svc.status
row.innerHTML = serviceRowHtml(state, svc)
if (svc.status === 'crashed') {
const fixBtn = document.createElement('button')
fixBtn.className = 'fix-btn'
@@ -275,6 +369,7 @@ function buildRightColumn(state: GameState, onChange: () => void): HTMLElement {
}
row.appendChild(fixBtn)
}
const def = SERVICES.find((s) => s.id === svc.defId)!
const uninstallBtn = document.createElement('button')
uninstallBtn.className = 'uninstall-btn'
uninstallBtn.textContent = 'rm'
@@ -285,7 +380,65 @@ function buildRightColumn(state: GameState, onChange: () => void): HTMLElement {
onChange()
}
row.appendChild(uninstallBtn)
svcList.appendChild(row)
return row
}
// Services are only added/removed via deploy/uninstall, which already go
// through a full render - so on a passive tick the set of rows is stable
// and only their status/rate/fix-button needs patching (crash/fix happen
// in the background tick loop).
function updateServiceList(root: HTMLElement, state: GameState, onChange: () => void) {
for (const svc of state.services) {
const row = root.querySelector<HTMLElement>(`.item.service[data-svc="${svc.id}"]`)
if (!row) continue
row.className = 'item service ' + svc.status
const fixBtn = row.querySelector<HTMLButtonElement>('.fix-btn')
const uninstallBtn = row.querySelector('.uninstall-btn')
row.innerHTML = serviceRowHtml(state, svc)
if (svc.status === 'crashed') {
const btn = fixBtn ?? document.createElement('button')
btn.className = 'fix-btn'
btn.textContent = canFix(svc) ? 'fix' : '...'
btn.disabled = !canFix(svc)
if (!fixBtn) {
btn.onclick = (e) => {
e.stopPropagation()
fixService(state, svc.id)
onChange()
}
}
row.appendChild(btn)
}
if (uninstallBtn) row.appendChild(uninstallBtn)
}
}
function updateDeployOptions(root: HTMLElement, state: GameState) {
const select = root.querySelector<HTMLSelectElement>('.svc-select')
if (!select) return
const targetCap = selectedHardwareId ? hostCapacity(state, selectedHardwareId) : null
for (const opt of Array.from(select.options)) {
const def = SERVICES.find((s) => s.id === opt.value)!
const noRoom = !!(
targetCap &&
(targetCap.ramUsed + def.ram > targetCap.ram || targetCap.powerUsed + def.power > targetCap.power)
)
opt.disabled = noRoom
}
}
function buildRightColumn(state: GameState, onChange: () => void): HTMLElement {
const col = document.createElement('div')
col.className = 'col right'
const svcSection = document.createElement('section')
svcSection.className = 'panel'
svcSection.innerHTML = `<h2>SERVICES</h2>`
const svcList = document.createElement('div')
svcList.className = 'list'
for (const svc of state.services) {
svcList.appendChild(buildServiceRow(state, svc, onChange))
}
svcSection.appendChild(svcList)
@@ -323,10 +476,7 @@ function buildRightColumn(state: GameState, onChange: () => void): HTMLElement {
const logList = document.createElement('div')
logList.className = 'log-list'
for (const entry of state.log) {
const line = document.createElement('div')
line.className = 'log-line'
line.innerHTML = `<span class="log-time">${timeAgo(entry.t)}</span> ${entry.text}`
logList.appendChild(line)
logList.appendChild(buildLogLine(entry))
}
logSection.appendChild(logList)
col.appendChild(logSection)
@@ -334,6 +484,25 @@ function buildRightColumn(state: GameState, onChange: () => void): HTMLElement {
return col
}
function buildLogLine(entry: GameState['log'][number]): HTMLElement {
const line = document.createElement('div')
line.className = 'log-line'
line.innerHTML = `<span class="log-time">${timeAgo(entry.t)}</span> ${entry.text}`
return line
}
// The log only ever grows (new entries pushed on crash/fix/purchase/etc) -
// append what's new rather than rebuilding the whole scrollable list, which
// would also reset the user's scroll position.
function updateLog(root: HTMLElement, state: GameState) {
const logList = root.querySelector('.log-list')
if (!logList) return
const rendered = logList.children.length
for (let i = rendered; i < state.log.length; i++) {
logList.appendChild(buildLogLine(state.log[i]))
}
}
function buildPrestigeBanner(state: GameState, onChange: () => void): HTMLElement {
const banner = document.createElement('div')
banner.className = 'prestige-banner'