import { PRESETS, PLANETS_ORDER } from './hud-data.js'
import { PLANETS } from '../world/planet.js'
import { TIMES, systemTimeOfDay } from '../world/sky.js'
import { STATUS_LABEL } from '../game/colony.js'
import { FACE, FRAME_COLS, FRAME_ROWS } from '../agents/faces.js'
import { PLOT_PALETTE, hashString } from '../world/plots.js'
/**
* The whole HUD, in plain DOM.
*
* Deliberately not a framework: this sits on top of a render loop that must not miss a
* frame, so the UI only ever touches the DOM when something it shows has actually changed —
* every setter compares against the last value it wrote and returns early otherwise.
*
* The one hard rule is that all of this is optional. Pressing H hides every panel, and the
* game stays fully readable because status lives above the astronauts' heads in the scene,
* not in here.
*/
/**
* The page only ever runs on the machine the server is on — it answers nothing else — so the
* browser's OS is the server's OS, and the name of the thing that shows a folder can be read
* here rather than asked for.
*/
const IS_MAC = /Mac/.test(navigator.platform)
const IS_WIN = /Win/.test(navigator.platform)
const FILE_MANAGER = IS_MAC ? 'Finder' : IS_WIN ? 'Explorer' : 'Files'
const ICON = {
settings: ``,
eye: ``,
eyeOff: ``,
home: ``,
next: ``,
sun: ``,
globe: ``,
camera: ``,
help: ``,
open: ``,
archive: ``,
close: ``,
back: ``,
plus: ``,
folder: ``,
copy: ``,
locate: ``,
orbit: ``,
sound: ``,
soundOff: ``,
}
const STAT_DEFS = [
{ key: 'working', label: 'building', cls: 'working' },
{ key: 'waiting', label: 'need you', cls: 'waiting' },
{ key: 'blocked', label: 'blocked', cls: 'blocked' },
{ key: 'celebrating', label: 'shipped', cls: 'done' },
{ key: 'agents', label: 'bots', cls: 'idle' },
]
export class Hud {
constructor(root, settings, actions) {
this.settings = settings
this.actions = actions
this.visible = true
this._last = {}
this.hiddenOpen = false
this.el = document.createElement('div')
this.el.className = 'hud'
this.el.innerHTML = TEMPLATE
root.appendChild(this.el)
this.$ = (sel) => this.el.querySelector(sel)
this._buildStats()
this._buildSettings()
this._buildAvatar()
this._wire()
this.syncSettings()
// Read layout when panels resize/change, never in the animation loop.
this._layoutObserver = new ResizeObserver(() => this._syncLayout())
this._layoutObserver.observe(this.el)
this._layoutObserver.observe(this.$('.side'))
this._syncLayout()
}
// ── construction ────────────────────────────────────────────────────────────────────
_buildStats() {
const wrap = this.$('.stats')
this.statEls = {}
for (const def of STAT_DEFS) {
const b = document.createElement('button')
b.className = `stat ${def.cls}`
b.type = 'button'
b.dataset.key = def.key
b.title = `Jump to the next ${def.label} bot`
b.innerHTML = `0${def.label}`
b.type = 'button'
b.addEventListener('click', () => this.actions.focusStatus?.(def.key))
wrap.appendChild(b)
this.statEls[def.key] = b
}
}
_buildSettings() {
const body = this.$('.settings .body')
const s = this.settings
this.controls = []
// Quality presets.
body.appendChild(
group(
'Quality preset',
chips(
Object.entries(PRESETS).map(([id, p]) => ({ id, label: p.label, title: p.hint })),
() => s.get('preset'),
(id) => s.applyPreset(id),
this.controls
)
)
)
// Performance.
const perf = group('Performance')
perf.append(
this._toggle('HDR + bloom', 'bloom', 'Glowing eyes, lamps and windows. The first thing to drop.'),
this._toggle('Tilt-shift', 'tiltShift', 'A shallow depth of field, which is what makes the colony read as a model.'),
this._slider(
'Tilt-shift blur',
'tiltShiftStrength',
0,
1,
0.05,
(v) => `${Math.round(v * 100)}%`,
'Aperture: how shallow the focus is, and how far out of it things go.'
),
this._slider(
'Tilt-shift angle',
'tiltShiftAngle',
-90,
90,
1,
(v) => `${v}°`,
'Swings the plane of focus, the way tilting a real lens does.'
),
this._select('Shadows', 'shadows', [
['off', 'Off'],
['low', 'Low'],
['high', 'High'],
['ultra', 'Ultra'],
]),
this._select('Particles', 'particles', [
['off', 'Off'],
['low', 'Low'],
['full', 'Full'],
]),
this._select('Textures', 'textureQuality', [
['low', 'Low'],
['medium', 'Medium'],
['high', 'High'],
['ultra', 'Ultra'],
]),
this._select('Ground detail', 'groundDetail', [
['low', 'Low'],
['medium', 'Medium'],
['high', 'High'],
]),
this._toggle('Anti-aliasing', 'antialias', 'SMAA pass. Cheap, but not free.'),
this._slider(
'Render scale',
'renderScale',
0.35,
2,
0.05,
(v) => `${Math.round(v * 100)}%`,
'100% is your display’s own resolution, retina included.'
),
this._toggle('Adaptive quality', 'autoQuality', 'Quietly drops render scale if frames get expensive.'),
this._slider('Scatter', 'scatterDensity', 0, 1, 0.05, (v) => `${Math.round(v * 100)}%`),
this._slider('Max bots', 'maxAgents', 10, 200, 10, (v) => String(v)),
this._toggle('Stars', 'stars')
)
body.appendChild(perf)
// World.
const world = group('Planet')
const planets = document.createElement('div')
planets.className = 'planets'
for (const id of PLANETS_ORDER) {
const planet = PLANETS[id]
const b = document.createElement('button')
b.type = 'button'
b.className = 'planet'
b.title = planet.blurb
const c1 = hex(planet.ground.high)
const c2 = hex(planet.ground.low)
b.innerHTML = `${planet.name}`
b.addEventListener('click', () => this.settings.set('planet', id))
planets.appendChild(b)
this.controls.push({ el: b, sync: () => b.setAttribute('aria-pressed', String(this.settings.get('planet') === id)) })
}
world.appendChild(planets)
body.appendChild(world)
// Lighting.
const light = group('Lighting')
light.append(
chips(
// `Live` is a time of day like the others from where you are standing, so it belongs
// in the same row rather than in a toggle further down.
[...TIMES.map((t) => ({ id: t.id, label: t.label })), { id: 'live', label: 'Live' }],
() => (this.settings.get('clockTime') ? 'live' : nearestTime(this.settings.get('timeOfDay'))),
(id) => {
this.settings.set('autoTime', false)
this.settings.set('clockTime', id === 'live')
if (id === 'live') this.settings.set('timeOfDay', systemTimeOfDay())
else this.settings.set('timeOfDay', TIMES.find((t) => t.id === id).value)
},
this.controls
),
this._slider('Time of day', 'timeOfDay', 0, 1, 0.005, clockLabel, undefined, () => {
// Reaching for the slider is a request for a particular light, so stop following the
// clock — otherwise the next frame would drag the thumb straight back.
this.settings.set('clockTime', false)
}),
this._toggle(
'Cycle day/night',
'autoTime',
'Runs the clock forward on its own. Ignored while the sky is following this machine’s clock.'
),
this._slider('Cycle length', 'dayLength', 30, 900, 30, (v) => `${Math.round(v / 60)}m`),
this._toggle(
'Environment light',
'ibl',
'Image-based lighting taken from this planet’s own sky. Metals get something to reflect.'
),
this._slider('Environment', 'iblIntensity', 0, 2, 0.05, (v) => v.toFixed(2)),
this._slider('Exposure', 'exposure', 0.4, 2, 0.05, (v) => v.toFixed(2)),
this._slider('Bloom', 'bloomStrength', 0, 1.6, 0.02, (v) => v.toFixed(2))
)
body.appendChild(light)
// View.
const view = group('View')
// The server refuses terminals on Windows, so a choice there would only ever toast an error.
if (!IS_WIN) {
view.append(
this._select(
'Open threads in',
'openIn',
[
['app', 'Desktop app'],
['terminal', 'Terminal'],
],
'Terminal runs the harness’s own CLI in a new window, so the CLI has to be installed. ' +
'BOT_CROSSING_TERMINAL or $TERMINAL picks the emulator.'
)
)
}
view.append(
this._toggle(
'Hide dormant repos',
'hideDormant',
'Takes a repo off the map when every thread in it has been quiet for three days. Its threads are untouched, and it comes back to the same ground the moment one wakes up.'
)
)
view.append(
this._toggle('Follow selected bot', 'followSelected', 'Tracks the selected bot until you deselect. Drag to pan, right-drag to orbit, and scroll to zoom.'),
this._toggle('Return to isometric', 'autoFrame', 'Eases the angle back when you stop dragging.'),
this._slider('Field of view', 'fov', 20, 60, 1, (v) => `${v}°`),
this._toggle('Project labels', 'showLabels'),
this._toggle('Reduced motion', 'reducedMotion', 'Calms the bobbing and the camera easing.'),
this._toggle('Show FPS', 'showFps')
)
body.appendChild(view)
// Look.
const look = group('Look')
look.append(
this._slider(
'Ambient occlusion',
'ambientOcclusion',
0,
1,
0.05,
(v) => v === 0 ? 'Off' : `${Math.round(v * 100)}%`,
'Soft shading in creases and where surfaces meet. Try 20–35% for a subtle effect; 0 turns it off.'
),
this._slider(
'World curve',
'worldCurve',
0,
1,
0.05,
(v) => `${Math.round(v * 100)}%`,
'How far the ground bends away toward the horizon. The point under the cursor never moves.'
),
this._toggle('Colour grade', 'colorGrade', 'Saturation, warmth, lifted shadows and a soft vignette on the finished frame.'),
this._slider('Saturation', 'saturation', 0.6, 1.5, 0.05, (v) => v.toFixed(2)),
this._slider('Vignette', 'vignette', 0, 1, 0.05, (v) => `${Math.round(v * 100)}%`),
this._toggle('Clouds', 'clouds', 'Cumulus drifting over worlds that have weather.'),
this._select('Wildlife', 'fauna', [
['off', 'Off'],
['low', 'Some'],
['full', 'Full'],
])
)
body.appendChild(look)
// Sound.
const sound = group('Sound')
sound.append(
this._toggle(
'Ambient sound',
'sound',
'A bed for each world, things calling out on their own clocks, and work you can hear where it is happening. Louder as you lean in.'
),
this._slider('Master', 'masterVolume', 0, 1, 0.05, (v) => `${Math.round(v * 100)}%`),
this._slider('Ambience', 'ambienceVolume', 0, 1, 0.05, (v) => `${Math.round(v * 100)}%`, 'Beds and the sounds of the world.'),
this._slider('Effects', 'effectsVolume', 0, 1, 0.05, (v) => `${Math.round(v * 100)}%`, 'Hammering, drones, splashes, the chime when somebody needs you.')
)
body.appendChild(sound)
}
_row(label, hint) {
const row = document.createElement('div')
row.className = 'row'
const l = document.createElement('div')
l.className = 'label'
l.innerHTML = `${label}${hint ? `${hint}` : ''}`
row.appendChild(l)
return row
}
_toggle(label, key, hint) {
const row = this._row(label, hint)
const b = document.createElement('button')
b.type = 'button'
b.className = 'toggle'
b.setAttribute('role', 'switch')
b.setAttribute('aria-label', label)
b.addEventListener('click', () => this.settings.set(key, !this.settings.get(key)))
row.appendChild(b)
this.controls.push({
el: row,
sync: () => {
b.setAttribute('aria-checked', String(Boolean(this.settings.get(key))))
row.classList.toggle('overridden', this.settings.isOverridden(key))
},
})
return row
}
_select(label, key, options, hint) {
const row = this._row(label, hint)
const sel = document.createElement('select')
sel.className = 'select'
for (const [value, text] of options) {
const o = document.createElement('option')
o.value = value
o.textContent = text
sel.appendChild(o)
}
sel.addEventListener('change', () => this.settings.set(key, sel.value))
row.appendChild(sel)
this.controls.push({
el: row,
sync: () => {
sel.value = String(this.settings.get(key))
row.classList.toggle('overridden', this.settings.isOverridden(key))
},
})
return row
}
_slider(label, key, min, max, step, format, hint, onInput) {
const row = this._row(label, hint)
const wrap = document.createElement('div')
wrap.style.cssText = 'display:flex;align-items:center;gap:8px'
const input = document.createElement('input')
input.type = 'range'
input.setAttribute('aria-label', label)
input.className = 'slider'
input.min = min
input.max = max
input.step = step
const out = document.createElement('span')
out.className = 'value'
input.addEventListener('input', () => {
onInput?.()
this.settings.set(key, Number(input.value))
})
wrap.append(input, out)
row.appendChild(wrap)
this.controls.push({
el: row,
sync: () => {
const v = Number(this.settings.get(key))
// Never fight the thumb the user is dragging.
if (document.activeElement !== input) input.value = String(v)
out.textContent = format(v)
row.classList.toggle('overridden', this.settings.isOverridden(key))
},
})
return row
}
/** The little face on the agent card, drawn from the same atlas the astronauts use. */
_buildAvatar() {
const canvas = this.$('.thread-pop .avatar canvas')
canvas.width = 108
canvas.height = 108
this.avatarCtx = canvas.getContext('2d')
this.avatarTmp = document.createElement('canvas')
this.avatarTmp.width = 108
this.avatarTmp.height = 108
this.avatarTmpCtx = this.avatarTmp.getContext('2d')
this._avatarState = { frame: -1, color: '' }
}
_wire() {
const on = (sel, ev, fn) => this.$(sel).addEventListener(ev, fn)
on('#btn-settings', 'click', () => this.toggleSettings())
// On a phone the sidebar is a sheet: a tap on its brand row (not on its buttons) pulls
// it up or lets it drop, and a drag on the row does the same by direction.
const brandbar = this.$('.side .brandbar')
const grab = this.$('.side .grab')
let dragY = null
const startDrag = (e) => {
if (!this.isPhone() || e.target.closest('.btn')) return
dragY = e.clientY
}
const endDrag = (e) => {
if (dragY === null) return
const dy = e.clientY - dragY
dragY = null
if (Math.abs(dy) > 24) this.toggleSheet(dy < 0)
else if (!e.target.closest('.btn')) this.toggleSheet()
}
for (const el of [brandbar, grab]) {
el.addEventListener('pointerdown', startDrag)
el.addEventListener('pointerup', endDrag)
}
on('#btn-close-settings', 'click', () => this.toggleSettings(false))
on('#btn-hide', 'click', () => this.toggleUi())
on('#btn-help', 'click', () => this.toggleHelp())
on('#btn-shot', 'click', () => this.actions.screenshot?.())
on('#btn-home', 'click', () => this.actions.resetView?.())
on('#btn-next', 'click', () => this.actions.focusStatus?.('waiting'))
on('#btn-orbit', 'click', () => this.setOrbit(this.actions.toggleOrbit?.()))
on('#btn-planet', 'click', () => this.actions.cyclePlanet?.())
on('#btn-time', 'click', () => this.actions.cycleTime?.())
on('#btn-sound', 'click', () => this.settings.set('sound', !this.settings.get('sound')))
on('#btn-open', 'click', () => this.actions.openThread?.())
on('#btn-viewed', 'click', () => this.actions.markViewed?.())
on('#btn-archive', 'click', () => this.actions.archiveThread?.())
on('#btn-deselect', 'click', () => this.actions.select?.(null))
on('#btn-follow', 'click', () => this.settings.set('followSelected', !this.settings.get('followSelected')))
on('#btn-new-session', 'click', () => this.actions.newConversation?.())
on('#btn-reveal', 'click', () => this.actions.revealProject?.())
on('#btn-copy-path', 'click', () => this.actions.copyProjectPath?.())
on('#btn-hide-project', 'click', () => this.actions.hideProject?.())
on('#btn-hidden-toggle', 'click', () => this.toggleHiddenList())
on('#btn-locate', 'click', () => this.actions.focusProject?.(this.project?.name))
on('#btn-close-project', 'click', () => this.actions.closeProject?.())
on('.help', 'click', (e) => {
if (e.target === this.$('.help')) this.toggleHelp(false)
})
this.$('.help .sheet').addEventListener('click', (e) => e.stopPropagation())
on('#btn-help-close', 'click', () => this.toggleHelp(false))
this.settings.onChange(() => this.syncSettings())
}
// ── state in ────────────────────────────────────────────────────────────────────────
syncSettings() {
const follow = Boolean(this.settings.get('followSelected'))
this.$('#btn-follow').setAttribute('aria-pressed', String(follow))
this.$('#btn-follow').title = follow ? 'Stop following selected bot' : 'Follow selected bot'
for (const c of this.controls) c.sync()
this.$('.fps').classList.toggle('on', Boolean(this.settings.get('showFps')))
const sound = Boolean(this.settings.get('sound'))
const btn = this.$('#btn-sound')
btn.innerHTML = sound ? ICON.sound : ICON.soundOff
btn.setAttribute('aria-pressed', String(sound))
btn.title = sound ? 'Mute (M)' : 'Unmute (M)'
}
setStats(stats) {
for (const def of STAT_DEFS) {
const n = stats[def.key] ?? 0
const el = this.statEls[def.key]
if (this._last['stat:' + def.key] === n) continue
this._last['stat:' + def.key] = n
el.querySelector('.n').textContent = String(n)
el.dataset.empty = String(n === 0)
}
}
/**
* Every repo, in the sidebar. This was a strip of chips along the bottom of the screen;
* it is a list now because the sidebar is where all the chrome lives, and because a list
* can carry a count and an alarm without running out of room at eleven repos.
*/
setLegend(projects, activeName = null, hidden = [], folded = []) {
const signature =
projects.map((p) => `${p.name}:${p.count}:${p.accent}:${p.urgent ? 1 : 0}`).join('|') +
`~${activeName}~` +
hidden.map((p) => `${p.name}:${p.count}`).join('|') +
`~${folded.length}`
if (this._last.legend === signature) return
this._last.legend = signature
const wrap = this.$('.projects')
wrap.innerHTML = ''
for (const p of projects) {
const b = document.createElement('button')
b.type = 'button'
b.className = 'repo'
b.title = `${p.count} thread${p.count === 1 ? '' : 's'} in ${p.name}`
b.setAttribute('aria-pressed', String(p.name === activeName))
b.innerHTML =
`` +
`${escapeHtml(p.name)}` +
(p.urgent ? '' : '') +
`${p.count}`
b.addEventListener('click', () => this.actions.pickProject?.(p.name))
wrap.appendChild(b)
}
this.$('.sec-head span').textContent = `${projects.length} repo${projects.length === 1 ? '' : 's'}`
// The hidden list is its own block at the foot of the sidebar: collapsed by default, because
// the whole point of hiding a repo is not to look at it.
const block = this.$('.hidden-block')
block.hidden = hidden.length === 0 && folded.length === 0
const hiddenWrap = this.$('.hidden-projects')
hiddenWrap.innerHTML = ''
for (const p of hidden) {
const accent = PLOT_PALETTE[hashString(p.name) % PLOT_PALETTE.length]
const row = document.createElement('div')
row.className = 'repo hidden-repo'
row.innerHTML =
`` +
`${escapeHtml(p.name)}` +
`${p.count}`
const show = document.createElement('button')
show.type = 'button'
show.className = 'btn ghost show-repo'
show.title = `Show ${p.name} on the map again`
show.textContent = 'Show'
show.addEventListener('click', () => this.actions.unhideProject?.(p.name))
row.appendChild(show)
hiddenWrap.appendChild(row)
}
// The dormant fold gets one line rather than a row each: it is a setting, not a list of
// decisions, and the thing worth offering is the way back rather than per-repo control.
if (folded.length) {
const n = folded.reduce((sum, p) => sum + p.count, 0)
const row = document.createElement('div')
row.className = 'repo hidden-repo folded-note'
row.innerHTML =
`${folded.length} quiet repo${folded.length === 1 ? '' : 's'}` +
`, ${n} thread${n === 1 ? '' : 's'}`
const show = document.createElement('button')
show.type = 'button'
show.className = 'btn ghost show-repo'
show.title = 'Put dormant repos back on the map'
show.textContent = 'Show'
show.addEventListener('click', () => this.settings.set('hideDormant', false))
row.appendChild(show)
hiddenWrap.appendChild(row)
}
const total = hidden.length + folded.length
this.$('#btn-hidden-toggle .label').textContent = `${total} off the map`
this._syncHiddenList()
}
toggleHiddenList() {
this.hiddenOpen = !this.hiddenOpen
this._syncHiddenList()
}
_syncHiddenList() {
this.$('#btn-hidden-toggle').setAttribute('aria-expanded', String(this.hiddenOpen))
this.$('.hidden-projects').hidden = !this.hiddenOpen
}
/**
* The project sidebar: what a zone is, and the things you can do to the *repo* rather
* than to one thread in it. Opened by clicking a zone, its name plate, its legend chip,
* or any astronaut standing on it.
*/
setProject(project) {
const panel = this.$('.side')
if (!project) {
this.project = null
if (this._last.project === null) return
this._last.project = null
panel.classList.remove('drilled')
panel.classList.remove('open')
this._syncLayout()
return
}
this.project = project
// On a phone, opening a repo pulls the sheet up so its threads are in view — unless an
// astronaut was just picked, whose card wants the room above the sheet's peek.
if (this.isPhone() && !this.selected && !panel.classList.contains('open')) {
panel.classList.add('open')
this._syncLayout()
}
// The minute is part of the signature because `ago()` is: without it a repo where
// nothing is happening keeps whatever "4m ago" it was first drawn with, for as long as
// you leave the panel open.
const signature =
`${project.name}~${project.path}~${project.accent}~${project.selectedId}~${Math.floor(Date.now() / 60000)}~` +
project.threads.map((t) => `${t.id}:${t.status}:${t.title}:${t.lastActivityAt}`).join('|')
panel.classList.add('drilled')
if (this._last.project === signature) return
this._last.project = signature
const swatch = this.$('.side .who .swatch')
swatch.style.background = hex(project.accent)
swatch.style.color = hex(project.accent) // the halo is `currentColor`
this.$('.side .name').textContent = project.name
const path = this.$('.side .path')
path.textContent = project.path ? shortPath(project.path) : 'folder unknown'
path.title = project.path || ''
// Nothing to open a new thread in, and nothing to reveal, without a folder on disk.
this.$('#btn-new-session').disabled = !project.path
this.$('#btn-reveal').disabled = !project.path
this.$('#btn-copy-path').disabled = !project.path
const n = project.threads.length
const waiting = project.threads.filter((t) => t.status === 'waiting' || t.status === 'blocked').length
this.$('.side .threads-head').innerHTML =
`${n} thread${n === 1 ? '' : 's'}` + (waiting ? `${waiting} need you` : '')
const list = this.$('.side .threads')
// A poll rewrites these rows every time a live thread's timestamp moves. Losing your
// place in a forty-thread repo every fifteen seconds would make the list unusable.
const scroll = list.scrollTop
list.innerHTML = ''
for (const t of project.threads) {
const b = document.createElement('button')
b.type = 'button'
b.className = `thread ${statusClass(t.status)}`
b.setAttribute('aria-pressed', String(t.id === project.selectedId))
b.title = STATUS_LABEL[t.status] || t.status
b.innerHTML =
'' +
`${escapeHtml(t.title || 'Untitled thread')}` +
`${ago(t.lastActivityAt)}` +
(t.worktree ? `⑂ ${escapeHtml(t.worktree)}` : '')
b.addEventListener('click', () => this.actions.focusThread?.(t.id))
list.appendChild(b)
// A long repo can hide the astronaut you just clicked in the world. Scrolled by hand
// rather than with `scrollIntoView`, which walks up the ancestors and will happily
// scroll the *page* — and a page that can scroll at all is one keystroke away from
// the whole HUD sitting sideways with nothing to put it back.
if (t.id === project.selectedId && this._scrolledTo !== t.id) {
this._scrolledTo = t.id
const row = b
requestAnimationFrame(() => {
const top = row.offsetTop
const bottom = top + row.offsetHeight
if (top < list.scrollTop) list.scrollTop = top
else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight
})
}
}
list.scrollTop = scroll
if (!project.selectedId) this._scrolledTo = null
}
/**
* The selected thread, shown inside the zone sidebar rather than in a panel of its own —
* one thread and its repo are the same context, and splitting them across the screen made
* you look in two places to act on one astronaut.
*/
setSelection(agent, thread) {
const card = this.$('.thread-pop')
// Only ever one accent button in the panel: whichever action is the immediate one.
this.$('#btn-new-session').classList.toggle('primary', !agent || !thread)
if (!agent || !thread) {
card.classList.remove('on')
this.selected = null
return
}
this.selected = { agent, thread }
card.classList.add('on')
// On a phone the card docks above the sheet's peek, so the sheet drops to make room.
if (this.isPhone()) this.toggleSheet(false)
this.$('.thread-pop .title').textContent = thread.title || 'Untitled thread'
const status = STATUS_LABEL[agent.status] || agent.status
const meta = this.$('.thread-pop .meta')
const bits = [
`${escapeHtml(status)}`,
]
// The repo is the panel's own heading now, so the card says what the *thread* is —
// starting with whose it is, since that decides what Open can do.
if (thread.harnessName) bits.push(`${escapeHtml(thread.harnessName)}`)
if (thread.worktree) bits.push(`⑂ ${escapeHtml(thread.worktree)}`)
if (thread.gitBranch) bits.push(`${escapeHtml(thread.gitBranch)}`)
if (thread.model) bits.push(`${escapeHtml(shortModel(thread.model))}`)
bits.push(`${ago(thread.lastActivityAt)}`)
meta.innerHTML = bits.join('')
const pct = Math.round((this.actions.progressFor?.(thread.id) ?? 0) * 100)
this.$('.thread-pop .progress > i').style.width = `${pct}%`
this.$('.thread-pop .progress > i').style.background = hex(agent.trim.getHex())
// Measured once per selection rather than per frame: placing the card beside its
// astronaut needs its size sixty times a second, and asking the layout for it that
// often is how a HUD starts costing frames.
this._cardSize = { w: card.offsetWidth, h: card.offsetHeight }
this.$('#btn-open').disabled = thread.canOpen === false
// Only offered when there is something to dismiss. A third button on every card would
// crowd the two that are always worth having, and "Viewed" on a thread that is not asking
// for anything is a control with no effect.
this.$('#btn-viewed').hidden = !thread.unread
}
/**
* Put the thread card beside its own astronaut, in screen space, every frame.
*
* `screen` is where the astronaut is right now, in CSS pixels, or null when it is behind
* the camera. The card prefers the astronaut's right, flips to its left rather than slide
* under the sidebar, and never leaves the window — so it stays reachable at any zoom
* without ever covering the thing it is describing.
*/
placeCard(screen) {
const el = this.$('.thread-pop')
if (!screen || !this.selected) {
if (this._cardOn) {
this._cardOn = false
el.classList.remove('on')
}
return
}
// On a phone the card is docked above the sheet by the stylesheet; nothing to place.
if (this.isPhone()) {
if (!this._cardOn) {
this._cardOn = true
el.classList.add('on')
}
return
}
const size = this._cardSize || { w: 280, h: 150 }
const margin = 12
const gap = 26
const rightWall = window.innerWidth - margin - (this._sideWidth || 0)
let flip = false
let left = screen.x + gap
if (left + size.w > rightWall) {
left = screen.x - gap - size.w
flip = true
// Nowhere to go on either side — sit over the middle rather than off the edge.
if (left < margin) left = Math.min(Math.max(margin, screen.x - size.w / 2), rightWall - size.w)
}
const top = Math.min(Math.max(margin, screen.y - size.h / 2), window.innerHeight - margin - size.h)
if (!this._cardOn) {
this._cardOn = true
el.classList.add('on')
}
// Whole pixels, and only when it actually moved: a transform written every frame with a
// fractional delta is a repaint the compositor cannot skip.
const x = Math.round(left)
const y = Math.round(top)
if (x !== this._cardX || y !== this._cardY) {
this._cardX = x
this._cardY = y
el.style.transform = `translate3d(${x}px, ${y}px, 0)`
}
// The nib points back at the astronaut, so it changes sides with the card.
if (flip !== this._cardFlip) {
this._cardFlip = flip
el.classList.toggle('flip', flip)
}
// And it tracks the astronaut vertically when the card has been pushed off-centre.
const nib = Math.min(Math.max(14, screen.y - y), size.h - 14)
if (nib !== this._cardNib) {
this._cardNib = nib
el.style.setProperty('--nib-y', `${Math.round(nib)}px`)
}
}
/** Share one measured safe area between camera framing, cards, and the legend. */
_syncLayout() {
const width = this.el.clientWidth
const height = this.el.clientHeight
const side = this.$('.side')
let right = 0
let bottom = 0
if (this.visible) {
if (this.isPhone()) {
// Use the sheet's destination, not an intermediate animation transform.
const peek = parseFloat(getComputedStyle(this.el).getPropertyValue('--peek')) || 0
const top = side.offsetTop + (side.classList.contains('open') ? 0 : side.offsetHeight - peek)
bottom = Math.max(0, height - top)
} else {
right = Math.max(0, width - side.offsetLeft)
}
}
this._sideWidth = right
this.el.style.setProperty('--side', `${right}px`)
this.actions.viewportChanged?.({ width, height, right, bottom })
}
/** Redraw the card's face so it blinks in step with the astronaut it belongs to. */
updateAvatar(faceAtlasCanvas) {
if (!this.selected || !faceAtlasCanvas) return
const agent = this.selected.agent
const frame = agent.faceFrame ?? FACE.idle
const color = agent.eye
const css = cssFromGlow(color)
if (this._avatarState.frame === frame && this._avatarState.color === css) return
this._avatarState = { frame, color: css }
const size = 108
const cell = faceAtlasCanvas.width / FRAME_COLS
const sx = (frame % FRAME_COLS) * cell
const sy = Math.floor(frame / FRAME_COLS) * (faceAtlasCanvas.height / FRAME_ROWS)
// The atlas is an opaque white-on-black mask, so the tint is a `multiply`, not a
// `source-in`: black stays black and the white features take the eye colour. Keying on
// alpha instead would flood the whole cell, because every pixel in it is opaque.
const t = this.avatarTmpCtx
t.globalCompositeOperation = 'source-over'
t.clearRect(0, 0, size, size)
t.drawImage(faceAtlasCanvas, sx, sy, cell, cell, 0, 0, size, size)
t.globalCompositeOperation = 'multiply'
t.fillStyle = css
t.fillRect(0, 0, size, size)
t.globalCompositeOperation = 'source-over'
const c = this.avatarCtx
c.fillStyle = '#06070c'
c.fillRect(0, 0, size, size)
c.drawImage(this.avatarTmp, 0, 0)
// Scanlines, so the card's face reads as the same little screen as the one in the world.
c.globalAlpha = 0.2
c.fillStyle = '#000'
for (let y = 0; y < size; y += 3) c.fillRect(0, y, size, 1)
c.globalAlpha = 1
}
setFps(perf, viewport, extra) {
if (!this.settings.get('showFps')) return
const el = this.$('.fps')
const fps = Math.round(perf.fps)
if (this._last.fps === fps && this._last.calls === perf.drawCalls) return
this._last.fps = fps
this._last.calls = perf.drawCalls
el.innerHTML =
`${fps} fps · ${perf.frameMs.toFixed(1)} ms
` +
`${perf.drawCalls} draws · ${(perf.triangles / 1000).toFixed(0)}k tris
` +
// The setting is a share of the display, so the readout is too — otherwise a retina
// machine sitting exactly on the 100% slider reads back "200%".
`${viewport.bw}×${viewport.bh} (${Math.round((viewport.scale / (window.devicePixelRatio || 1)) * 100)}%)` +
(extra ? `
${extra}` : '')
}
hint(text, ms = 3200) {
const el = this.$('.hint-pill')
el.textContent = text
el.classList.add('on')
clearTimeout(this._hintTimer)
this._hintTimer = setTimeout(() => el.classList.remove('on'), ms)
}
toast(message, kind = '') {
const el = document.createElement('div')
el.className = `toast panel ${kind}`
el.textContent = message
this.$('.toasts').appendChild(el)
setTimeout(() => {
el.classList.add('leaving')
setTimeout(() => el.remove(), 260)
}, 3600)
}
// ── visibility ──────────────────────────────────────────────────────────────────────
/** Reflect orbit mode on the rail button. */
setOrbit(on) {
this.$('#btn-orbit').setAttribute('aria-pressed', String(Boolean(on)))
}
/** Whether the layout is the phone one: the sheet, the docked card, the top rail. */
isPhone() {
return window.matchMedia('(max-width: 600px)').matches
}
/** Pull the sidebar sheet up over the colony, or let it drop to its peek. Phone only. */
toggleSheet(force) {
const side = this.$('.side')
const open = force ?? !side.classList.contains('open')
side.classList.toggle('open', open)
this._syncLayout()
return open
}
toggleSettings(force) {
const panel = this.$('.settings')
const open = force ?? panel.classList.contains('closed')
panel.classList.toggle('closed', !open)
this.$('#btn-settings').setAttribute('aria-pressed', String(open))
// Settings replaces the sidebar in its existing slot, including for keyboard users.
const side = this.$('.side')
side.classList.toggle('covered', open)
side.inert = open
panel.inert = !open
if (open) this.$('#btn-close-settings').focus({ preventScroll: true })
else if (panel.contains(document.activeElement)) this.$('#btn-settings').focus({ preventScroll: true })
}
toggleHelp(force) {
const el = this.$('.help')
const open = force ?? !el.classList.contains('open')
el.classList.toggle('open', open)
}
/**
* Dismiss everything. This is the mode the game is really meant to be left in — the
* colony carries its own state above the astronauts' heads, so the panels are for
* setting things up, not for playing.
*/
toggleUi(force) {
this.visible = force ?? !this.visible
this.el.classList.toggle('hidden', !this.visible)
this.$('#btn-hide').innerHTML = this.visible ? ICON.eye : ICON.eyeOff
this.actions.uiVisibility?.(this.visible)
this._syncLayout()
if (!this.visible) this.toggleHelp(false)
return this.visible
}
removeBoot() {
const boot = document.querySelector('.boot')
if (!boot) return
boot.classList.add('gone')
setTimeout(() => boot.remove(), 550)
}
}
// ── helpers ───────────────────────────────────────────────────────────────────────────
function group(title, child) {
const el = document.createElement('div')
el.className = 'group'
el.innerHTML = `
Every coding-agent thread on this machine is a bot. They walk out of the ship, claim a plot for their repo, and build. Click one to open its thread; click a zone — its deck or its name — for the repo itself, and start a new conversation there. Hide a repo from that panel if you would rather not see it — its threads stay in your harness, and you can show it again from the list. Navigation works like Google Earth — drag the ground itself, right-drag to tilt, scroll to zoom in on whatever is under the cursor.