widget-box

The seam between the two SVG UI systems, so they compose instead of competing.

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.handlePointer captures: 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.