selection
The selection-state glyph: circle / checkCircle for single-select (radio),
square / checkSquare for multi-select (checkbox). Used by lists and
tables, and by anything else that needs to show chosen separately
from hovered.
Why an icon rather than a highlight
Hover, focus, pressed and disabled all naturally want the same encoding — intensity (contrast, opacity, brightness). Piling selection onto that axis means tuning five states against each other, and the result is fragile: subtly different greys that read as "slightly off" rather than as distinct states, and that stop being distinguishable the moment a theme, a background or a viewing angle changes.
An icon is a different channel, so it's orthogonal — a row can be hovered and selected and focused at once and all three still read, with nothing finessed against anything else.
That matters more here than in a flat DOM UI, because these surfaces are also rasterized onto a 3D texture and viewed through headset optics: at texture resolution, off-axis, small intensity differences are the first thing to vanish. A glyph has shape, not just value, so it survives.
See UI-DESIGN-NOTES.md → "Show selection with an icon, not with intensity".
Demo
The same four rows under each mode. Click to select — and note that hover and selection never fight: hover is background intensity, selection is the glyph.
import { selectionIcon, applySelection, iconGlyph, svgPoint } from 'tosijs-3d'
import { svgElements, elements } from 'tosijs'
const { svg, g, rect, text } = svgElements
const { div } = elements
const ITEMS = ['rifle', 'medkit', 'ration', 'flare']
const readout = div(
{ style: 'margin:10px 4px;color:#8ea;font:13px system-ui' },
'Nothing selected.'
)
const makeList = (mode, x, title) => {
let selected = new Set(mode === 'single' ? ['medkit'] : ['medkit', 'flare'])
let hover = -1
const host = g({ transform: `translate(${x} 0)` })
const paint = () => {
host.replaceChildren(
text({ x: 0, y: 14, 'font-size': 12, 'font-family': 'system-ui', fill: '#9fb0c3' }, title)
)
ITEMS.forEach((name, i) => {
const y = 26 + i * 32
const isSel = selected.has(name)
host.append(
g({},
rect({ x: 0, y, width: 170, height: 30, rx: 6, fill: i === hover ? 'rgba(255,255,255,0.13)' : 'transparent' }),
iconGlyph(selectionIcon(mode, isSel), { size: 17, color: isSel ? '#39c5ff' : '#9aa0a6', x: 7, y: y + 7 }),
text({ x: 34, y: y + 20, 'font-size': 14, 'font-family': 'system-ui', fill: '#f0f0f0' }, name)
)
)
})
}
paint()
return {
host,
hit: (lx, ly) => {
const i = Math.floor((ly - 26) / 32)
return lx >= 0 && lx <= 170 && i >= 0 && i < ITEMS.length ? i : -1
},
move: (i) => { if (i !== hover) { hover = i; paint() } },
click: (i) => {
if (i < 0) return
selected = applySelection(selected, ITEMS[i], mode)
paint()
readout.textContent = `${title}: ${[...selected].join(', ') || '(none)'}`
},
}
}
const single = makeList('single', 0, 'single-select — circle / checkCircle')
const multi = makeList('multi', 200, 'multi-select — square / checkSquare')
const sheet = svg({ width: 380, height: 170, viewBox: '0 0 380 170', style: 'max-width:100%' },
single.host, multi.host)
const local = (e) => {
// svgPoint, not rect arithmetic — a letterboxed viewBox makes a linear map drift.
const p = svgPoint(sheet, e.clientX, e.clientY)
return [p.x, p.y]
}
sheet.addEventListener('pointermove', (e) => {
const [x, y] = local(e)
single.move(single.hit(x, y))
multi.move(multi.hit(x - 200, y))
})
sheet.addEventListener('pointerdown', (e) => {
const [x, y] = local(e)
single.click(single.hit(x, y))
multi.click(multi.hit(x - 200, y))
})
preview.append(div({ style: 'padding:16px;background:#0c0e14' }, sheet, readout))
Deliberately name-only, no drawing: emitting a glyph here would mean importing the icon layer, which imports tosijs, which needs a DOM — and then this module could no longer be unit-tested without one. The names are the part worth pinning; the caller already has an icon helper.