box

The flow box — the first-class SVG UI container built on flow-layout. A box paints its children (blocks + inline items + wrapped text) to an SVG <g>, gives itself a background / border, resizes (re-flowing text and inline wrap), and becomes a scroll region when its content outgrows a fixed height. It renders identically as a DOM node and — via .el.outerHTML — on a 3D texture (iconGlyph children bake explicit colours for exactly that), so one surface serves flat and VR.

This is layout + paint + scroll. The event model (one handlePointer + focus-traversal for gamepad nav — see UI-DESIGN-NOTES) and the overlay/popup layer land next; the child protocol already carries the hooks they'll use.

Children

A BoxChild is { el, kind, measure, paint? }:

Helpers build the common ones: textBlock (wraps text to the width via widgets3d-layout's real glyph measurer), inlineIcon (an iconGlyph), and blockItem / inlineItem to drop in any SVG element at a known size.

Demo — the same box in the DOM and on a 3D texture

box.el is an SVG <g>; wrap it in an <svg> to show it flat, or hand it to a b3dSvgPlane to rasterize it onto a plane. Same object, both surfaces.

import { b3d, b3dLight, b3dSvgPlane, box, textBlock, button } from 'tosijs-3d'
import { svgElements, elements } from 'tosijs'

const { svg } = svgElements
const { div } = elements
const W = 240
const H = 210

const readout = div(
  { style: 'margin:6px 16px 16px;color:#8ea;font:13px system-ui' },
  'Activate a button: click it, arrow-key + Enter, or click the 3D panel.'
)
const act = (label) => () => {
  readout.textContent = 'Activated: ' + label
}

const makePanel = () => {
  const p = box(
    { width: W, height: H, padding: 14, gap: 10, background: '#161a22', border: '#2a3140', radius: 12 },
    textBlock('Flow box', { font: { size: 18, weight: 600 }, color: '#e6e6e6' }),
    textBlock(
      'Blocks stack, text wraps, buttons flow and focus — one surface, DOM and 3D.',
      { font: { size: 13 }, color: '#9fb0c3' }
    ),
    button('Talk', { onActivate: act('Talk') }),
    button('Trade', { onActivate: act('Trade') }),
    button('Leave', { onActivate: act('Leave') })
  )
  p.focusMove(1, 0) // focus the first button so the ring shows
  return p
}

const sheet = (b) => svg({ viewBox: `0 0 ${W} ${H}`, width: W, height: H }, b.el)

// ONE panel, shown in the DOM AND textured on the plane (SvgTexture clones the
// live element each frame), so DOM and 3D stay in sync — a click or arrow-key in
// either view drives the same box.
const panel = makePanel()
const svgEl = sheet(panel)
svgEl.setAttribute('tabindex', '0')
const toBox = (e) => {
  const r = svgEl.getBoundingClientRect()
  return [((e.clientX - r.left) / r.width) * W, ((e.clientY - r.top) / r.height) * H]
}
svgEl.addEventListener('pointerdown', (e) => panel.handlePointer('down', ...toBox(e)))
svgEl.addEventListener('pointerup', (e) => panel.handlePointer('up', ...toBox(e)))
svgEl.addEventListener('keydown', (e) => {
  const m = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }
  if (m[e.key]) { panel.focusMove(...m[e.key]); e.preventDefault() }
  else if (e.key === 'Enter' || e.key === ' ') { panel.focusActivate(); e.preventDefault() }
})

// 3D side — route each pick's texture-UV → box coords → the SAME panel's
// handlePointer (the path a VR ray takes), so the 3D view is clickable and synced.
const plane = b3dSvgPlane({
  width: 2.4,
  height: (2.4 * H) / W,
  resolution: 512,
  materialChannel: 'emissive',
  pointerEvents: false,
})
plane.svgElement = svgEl

const scene = b3d(
  {
    style: 'width:300px;height:290px;display:block;border-radius:8px;overflow:hidden',
    sceneCreated(el) {
      const cam = new el.BABYLON.ArcRotateCamera(
        'cam', -Math.PI / 2, Math.PI / 2.5, 3.2, el.BABYLON.Vector3.Zero(), el.scene
      )
      el.setActiveCamera(cam)
      cam.attachControl(el.scene.getEngine().getRenderingCanvas(), true)
      el.scene.constantlyUpdateMeshUnderPointer = true
      const T = el.BABYLON.PointerEventTypes
      el.scene.onPointerObservable.add((pi) => {
        const kind =
          pi.type === T.POINTERDOWN ? 'down' : pi.type === T.POINTERUP ? 'up' : ''
        if (!kind) return
        const pk = pi.pickInfo
        if (pk && pk.hit && pk.pickedMesh === plane.mesh) {
          const uv = pk.getTextureCoordinates()
          if (uv) panel.handlePointer(kind, uv.x * W, (1 - uv.y) * H)
        }
      })
    },
  },
  b3dLight({ intensity: 1 }),
  plane
)

preview.append(
  div(
    { style: 'display:flex;gap:24px;align-items:flex-start;padding:16px 16px 4px;background:#0c0e14' },
    div({ style: 'color:#9ab;font:12px system-ui' }, 'DOM — click / arrow-key; 3D mirrors it', svgEl),
    div({ style: 'color:#9ab;font:12px system-ui' }, '3D texture — click the buttons', scene)
  ),
  readout
)

Resizable — drag to re-wrap, then scroll

Drag the corner grip: box.resize(w, h) re-flows — the paragraph re-wraps to the new width (so its height changes), and once the content is taller than the box it becomes a scroll region (spin the wheel). One box: resize, re-flow, scroll.

import { box, textBlock } from 'tosijs-3d'
import { svgElements, elements } from 'tosijs'

const { svg, rect } = svgElements
const { div } = elements

let W = 320
let H = 170
const long =
  'This paragraph re-wraps as the box gets narrower, so its height grows. Shrink the box below the content height and it turns into a scroll region — spin the wheel to scroll. One box: resize, re-flow, and scroll, all in pure SVG.'
const panel = box(
  { width: W, height: H, padding: 14, gap: 8, background: '#161a22', border: '#2a3140', radius: 10 },
  textBlock('Resizable box', { font: { size: 16, weight: 600 }, color: '#e6e6e6' }),
  textBlock(long, { font: { size: 13 }, color: '#9fb0c3' })
)

const host = svg({ width: 520, height: 340, style: 'touch-action:none' })
host.append(panel.el)
const grip = rect({ width: 16, height: 16, rx: 3, fill: '#5fb0ff', style: 'cursor:nwse-resize' })
host.append(grip)
const placeGrip = () => { grip.setAttribute('x', W - 8); grip.setAttribute('y', panel.viewportHeight - 8) }
placeGrip()

let drag = null
grip.addEventListener('pointerdown', (e) => { drag = { x: e.clientX, y: e.clientY, w: W, h: H }; grip.setPointerCapture(e.pointerId); e.preventDefault() })
grip.addEventListener('pointermove', (e) => {
  if (!drag) return
  W = Math.max(140, Math.min(500, drag.w + (e.clientX - drag.x)))
  H = Math.max(70, Math.min(320, drag.h + (e.clientY - drag.y)))
  panel.resize(W, H)
  placeGrip()
})
grip.addEventListener('pointerup', () => { drag = null })
host.addEventListener('wheel', (e) => { panel.scrollBy(e.deltaY); e.preventDefault() })

preview.append(
  div(
    { style: 'padding:16px;background:#0c0e14' },
    host,
    div({ style: 'color:#8ea;font:12px system-ui;padding-top:8px' }, 'Drag the blue corner grip; spin the wheel to scroll.')
  )
)