widget-box
The seam between the two SVG UI systems, so they compose instead of competing.
- widgets3d has the controls —
slider3d,toggle3d,select3d,list3d— and they're what everyscenePanelhook in this library is written against. - box / surface have the container: text that re-wraps, scrolling, focus
traversal, and (via
surface) cascade menus plus persistent draggable, closable panels.
widgetBox puts the first inside the second. A Widget3d becomes a BoxChild, so
an existing panel's rows can be dropped into a floating panel — or a popup, or a
scrolling region — without rewriting them, and without box having to grow its own
slider.
Demo
import {
b3d,
b3dLight,
panelScene,
slider3d,
toggle3d,
button3d,
label3d,
ui,
} from 'tosijs-3d'
const { surface, widgetBox, box, textBlock, svgPoint } = ui
import { tosi, elements } from 'tosijs'
import { svgElements } from 'tosijs'
const { svg } = svgElements
const { div } = elements
const { wb } = tosi({ wb: { time: 14, fog: true } })
const W = 360
const H = 280
const readout = div({ style: 'margin:8px 4px;color:#8ea;font:13px system-ui' }, 'time 14.0 · fog on')
const update = () => {
readout.textContent = `time ${Number(wb.time.value).toFixed(1)} · fog ${wb.fog.value ? 'on' : 'off'}`
}
wb.time.observe(update)
wb.fog.observe(update)
const s = surface({ width: W, height: H })
s.setContent(
box(
{ width: W, height: H, padding: 12, gap: 8, background: '#12151c' },
textBlock('widgets3d inside a surface panel', { font: { size: 14, weight: 600 }, color: '#e6e6e6' }),
textBlock('The panel is draggable and closable; the controls are ordinary widgets3d.', { font: { size: 12 }, color: '#9fb0c3' })
)
)
s.openPanel(
{ x: 14, y: 78 },
widgetBox({ width: 300, padding: 10, gap: 4, background: '#0e1116' }, [
label3d({ text: 'Scene', bold: true, compact: true }),
slider3d({ label: 'time of day', value: wb.time, min: 0, max: 24, step: 0.5 }),
toggle3d({ label: 'fog', value: wb.fog }),
button3d({ label: 'Reset', handleClick: () => { wb.time.value = 14; wb.fog.value = true } }),
]),
{ title: 'Scene', draggable: true }
)
const svgEl = svg({ viewBox: `0 0 ${W} ${H}`, width: W, height: H, style: 'max-width:100%;touch-action:none' }, s.el)
const at = (e) => {
// svgPoint, not rect arithmetic: the viewBox is letterboxed when the
// container's aspect ratio differs, and a linear map drifts as it's resized.
const p = svgPoint(svgEl, e.clientX, e.clientY)
return [p.x, p.y]
}
svgEl.addEventListener('pointerdown', (e) => { s.handlePointer('down', ...at(e)); svgEl.setPointerCapture(e.pointerId) })
svgEl.addEventListener('pointermove', (e) => s.handlePointer('move', ...at(e)))
svgEl.addEventListener('pointerup', (e) => s.handlePointer('up', ...at(e)))
// 3D side — panelScene routes scene picks to the SAME surface, so the slider
// drags and toggles work identically on the plane (the path a VR ray takes).
const { plane, sceneCreated } = panelScene({ svg: svgEl, target: s })
const scene = b3d({ style: 'border-radius:8px;overflow:hidden', sceneCreated }, b3dLight({ intensity: 1 }), plane)
preview.append(
div({ style: 'display:flex;flex-direction:column;height:100%;background:#0c0e14' },
div({ style: 'display:flex;gap:20px;flex:1;min-height:0;padding:14px' },
div({ style: 'color:#9ab;font:12px system-ui;display:flex;flex-direction:column;gap:6px;flex:1;min-width:0' }, 'DOM', svgEl),
div({ style: 'color:#9ab;font:12px system-ui;display:flex;flex-direction:column;gap:6px;flex:1;min-width:0' }, '3D texture', scene)),
readout)
)
.preview { height: 100%; }
widgets3d controls living inside a surface panel — drag the title bar to move it,
× to close. The slider drags properly because BoxChild.handlePointer captures: the
gesture survives the pointer leaving the track.
Drag the sliders in either pane — same panel, same drags. A drag that leaves the track keeps working, which is the whole reason
BoxChild.handlePointercaptures: on a texture there is no pointer capture to inherit.
Why an adapter rather than a port
Porting the controls to box would mean rewriting slider3d and friends, and every
scenePanel written against them, to end up with the same widgets. The two protocols
differ only in activation: a BoxChild is activated semantically, which cannot
express a drag, while a slider needs down → move* → up with capture, so the drag
survives the pointer slipping off the track. BoxChild.handlePointer (raw, capturing)
covers that: box hands a raw child the whole gesture and keeps routing to it until
up, and widgetBox is the translation.