Skip to content

Add gui.add_panel() for floating side-by-side control panels#711

Open
ArthurAllshire wants to merge 48 commits into
viser-project:mainfrom
ArthurAllshire:add-panel-api
Open

Add gui.add_panel() for floating side-by-side control panels#711
ArthurAllshire wants to merge 48 commits into
viser-project:mainfrom
ArthurAllshire:add-panel-api

Conversation

@ArthurAllshire

@ArthurAllshire ArthurAllshire commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Screen.Recording.2026-05-04.at.8.10.40.PM_compressed.mp4

Adds viser.GuiApi.add_panel(), which opens a draggable, resizable floating window as a sibling of the main control panel. Useful when the default single-panel layout gets too tall or too narrow — panels can be placed side-by-side and sized independently.

with server.gui.add_panel(
    \"Cameras\",
    initial_position=(\"center\", 20),
    initial_width_px=540,
    layout=\"row\",
) as panel:
    for name in (\"left\", \"top\", \"right\"):
        server.gui.add_image(frame, label=f\"Camera {name}\")

GuiPanelHandle subclasses GuiFolderHandle, so it works as a context manager exactly like folders do — the only difference is that the panel renders as its own floating window instead of inline in the main panel.

Features

  • Drag to move: grab the title bar.
  • Drag to resize: grab the right edge (6px strip, tinted blue on hover).
  • Persistence: per-panel position + width saved to localStorage (keyed by the panel's UUID).
  • Positioning: initial_position=(x, y) accepts int | \"center\" for either axis. Negative integers anchor to the right/bottom edge (e.g. (-20, 20) is 20px from the top-right corner).
  • layout=\"row\": renders children side-by-side with equal flex share, non-wrapping — useful for rows of camera/video feeds.
  • Flicker gate: a resizingRef suppresses the ResizeObserver's reposition pass during an active drag so width updates don't fight it.

Backward-compatible: all changes are additive. SidebarPanel, BottomPanel, and existing FloatingPanel call sites are untouched.

Demo

examples/02_gui/11_panels.py — standalone demo with animated "video" feeds. Shows the main panel alongside three user panels (top-left, centered, bottom-right), with working Zoom slider and Reset button. Run with python examples/02_gui/11_panels.py and open http://localhost:8080.

Stats

13 files changed, 528 insertions(+), 6 deletions(-). Largest changes are in UserPanels.tsx (new, 121 lines — the renderer) and FloatingPanel.tsx (+101, adds the resize handle and initialPosition / onGeometryChange props).

Test plan

  • examples/02_gui/11_panels.py runs; all three panels visible and animate.
  • Drag a panel's header — it moves, doesn't overlap neighbors if you pick them apart.
  • Drag the right edge — panel resizes; no flicker.
  • Reload the page — panel positions and widths persist.
  • Resize the browser window — \"center\" x keeps the Cameras panel horizontally centered; negative-y keeps Status anchored to the bottom.
  • Row layout: at very narrow widths, images shrink together instead of wrapping or overflowing.
  • Existing add_folder, configure_theme, sidebar layout, and bottom (mobile) layout are unchanged — verified via 02_gui/00_basic_controls.py and 02_gui/05_theming.py.

Open questions / happy to bikeshed

API shape wasn't obvious — would love maintainer input on any of these before you merge:

  1. initial_position convention. I used int | \"center\" with negative = right/bottom anchor. Clean in the common case, but the negative-sign thing is a little clever. An alternative would be an explicit anchor: Literal[\"top-left\", \"top-right\", \"top-center\", \"bottom-left\", \"bottom-right\"] = \"top-left\" field with all-positive offsets. Same expressiveness, more verbose.
  2. layout=\"row\". Currently only exists on panels. If this feels generally useful, it could move to add_folder / a new add_row container. Kept it panel-only here to minimize surface area.
  3. Persistence. Lives entirely in UserPanels.tsx and persists by panel UUID. If uuids aren't stable across Python restarts (they aren't — uuid.uuid4()), persistence only survives browser reloads, not server restarts. Happy to key on title instead if that's preferable, or make persistence opt-in via a prop.
  4. Panel-placement collision handling. None — panels are positioned independently; on narrow viewports they can overlap (the demo works around this by staggering y-offsets). Could add a simple stacking fallback in UserPanels.tsx, or leave it to the user.

Also: I ran ruff (passes), npm run typecheck (passes), and smoke-tested existing GUI examples (00_basic_controls.py, 01_callbacks.py, 05_theming.py). I couldn't run pyright locally due to an unrelated environment issue — CI should catch any type problems.


🤖 Generated with Claude Code

Adds viser.GuiApi.add_panel(), which opens a draggable, resizable
floating window as a sibling of the main control panel. Useful when
the default single-panel layout gets too tall or too narrow — panels
can be placed side-by-side and sized independently.

Features:
- Drag title bar to move, right edge to resize.
- Position and width persist to localStorage per-panel uuid.
- initial_position=(x, y); negative values anchor to right/bottom edge.
- layout="row" renders children side-by-side with equal flex share.

Example: examples/02_gui/11_panels.py.
test_docs_coverage.py was failing because GuiPanelHandle wasn't listed
in docs/source/api/handles/gui_handles.rst. Fixed by re-running
`python docs/generate_handle_docs.py`.
brentyi added 24 commits June 22, 2026 22:26
Refactor standalone panels to a dedicated GuiPanelMessage entity (not a
GuiTabGroupMessage under "root"), eliminating the isStandalonePanel inline-render
filters. Plus a batch of placement/sizing fixes and structural hardening of the
dock layout module.

API / behavior:
- add_panel is its own top-level entity (like add_modal); add_tab via a shared
  _TabContainerMixin. expand_by_default replaces minimize()/expand().
- float() is viewport/canvas-relative; negative coords are gaps from the far edge
  (x=-15 = 15px from the right), re-resolved as the canvas changes.
- visible=False hides a panel (panes removed) without destroying it.
- main_panel.float() un-docks; configure_theme(control_layout=...) deprecation.
- Disconnect freezes + dims the GUI instead of wiping it; reconnect replays.

Fixes:
- Float canvas-relative + push/clamp within canvas on dock/resize changes.
- Auto-height windows: content-aware floor + revert-to-auto (no pin-trap);
  cappedWindowHeight never inflates a small window past its pinned height.
- Resize handle sits between a minimized strip and the expanded panel.
- Re-placing a panel re-gathers tabs dragged out into other groups (no dupes).
- Narrow "Connected" header truncates cleanly; min region width 96.

Correct-by-construction (dock layout):
- Extract invariantViolations into layoutInvariants.ts; applyOp asserts it on
  every commit in dev; the fuzz suite imports the same checker.
- movePaneInPlace primitive (detach-then-insert) so a pane can't live in two
  groups; ensurePanelGroup's gather routes through it.
- Remaining union refactors tracked in design/dock-correct-by-construction.md.
Tests built FloatingWindow objects as raw {id,x,y,width,stack} literals in ~100
places (and makeLayout/floatingLayout re-declared the shape inline), coupling the
model to test literals -- which is what made every window-model change expensive.

Add floatingWindow() to testUtils as the ONE place a FloatingWindow is
constructed; route makeLayout and floatingLayout through it; migrate every raw
literal to the factory. Behavior-preserving (403 vitest, 39 e2e unchanged). A
future field change (e.g. a tagged-union for height/position) now touches the
factory, not the literals.
Replace FloatingWindow.height?: number -- which overloaded "auto-size"
(undefined) and "pinned px" (number) -- with a tagged union
{ mode: "auto" } | { mode: "pinned"; px: number }.

Makes the height pin-trap and the sentinel-undefined ambiguity unrepresentable:
"auto vs pinned" is an explicit total state, and reverting to auto is a real
named transition instead of `delete win.height`. Production reads branch on
`.mode`; the testUtils floatingWindow() factory translates the terse numeric test
opt, so call sites stay terse. The dev invariant assert validates px finiteness.

Verified: 403 vitest (incl. fuzz), 29 e2e, tsc/eslint clean; pin-trap fix
re-confirmed end-to-end (button panel resizes and reverts to content height).
Replace the FloatingWindow.requestedX?/requestedY? PAIR with one optional
`anchor: { x; y }`. Presence is the ownership tag: an anchored window re-resolves
its canvas-relative anchor (negative = gap-from-far-edge) as the canvas/size
change; absence means user-owned at its absolute x/y.

Collapsing the two coords into one object makes "half-set ownership"
unrepresentable -- the hazard where a gesture set one coord but not the other.
markWindowUserOwned is now a single `delete win.anchor`. Stored absolute x/y are
kept (hit-test/drag/render read them unchanged), so this is the lighter form of
the placement union: it captures the correctness win without rewriting the
delicate grab-offset/grip-resize code (see design/dock-correct-by-construction.md).

The Python window() helper (dock_helpers.py) is the one e2e seam that builds
floating-window layouts; updated it for the WindowHeight union too.

Verified: 403 vitest, 297 e2e (0 failed), tsc/eslint/ruff clean, 28 Python.
… aliasing

Two issues found auditing the just-landed union refactors:

- The dev invariant assert (and the shared fuzz checker) flagged any empty-paneIds
  group, but an area-backing group is LEGITIMATELY empty -- it persists as a "drop
  a panel here" affordance after its last tab is removed or torn out. This fired
  console.error on real dev flows (an empty inline tab group, or removing the last
  area tab). Rule 5 now exempts area groups from the empty check (a non-empty area
  group is still validated). The fuzz suite never exercises areas, so it missed
  this. Added regression tests (empty area via ensureArea + add/remove, plus a
  sanity check that a non-area empty group is still flagged).

- snapToWindowStack adopted the source window's height object by reference from
  the ORIGINAL layout, sharing it with the committed draft. Harmless today (height
  is always replaced wholesale, never mutated in place) but a footgun the union
  introduced; copy it instead.

Also added an op-level regression test for re-gathering panes the user scattered
into separate windows (movePaneInPlace gather).

Verified: 405 vitest (incl. fuzz), 37 e2e, tsc/eslint clean.
…anvas

Docking enough panels that the left + right regions' summed reserved width
exceeds the viewport (e.g. 3 left + 3 right at the 300px default on a 1280px
screen = 1800px) made the two regions overlap and fully cover the 3D canvas --
hiding the scene and trapping the panels' own controls underneath.

Add a render-time guard in DockManager: when the regions' summed reserved width
would leave less than MIN_CANVAS_PX of scene, scale BOTH regions down
proportionally so a usable canvas strip always remains. This is render-only --
the MODEL region widths (regionWidth) are untouched, so widths restore when the
viewport grows back. The capped insets flow consistently to hit-testing, the
rendered region divs, and float clamping (all read the same reservedWidth).

Found by an adversarial visual hunt; added an e2e regression
(test_many_docked_panels_do_not_occlude_canvas) asserting the viewport center
resolves to the CANVAS, not a docked region.

Verified: 405 vitest, 298 e2e (0 failed), tsc/eslint/ruff clean.
From an audit pass focused on remaining "valid-only-by-convention" spots and
avoidable complexity. All behavior-preserving.

- WindowHeight: add windowHeight(px?) / pinnedPxOf(h) helpers and route the 7
  open-coded construct/access sites (across types/layoutOps/DockManager/
  FloatingWindowView/testUtils) through them. The union's "no sentinel ambiguity"
  win was being undercut by `.mode === "pinned" ? .px : <fallback>` scattered
  with three different fallbacks; now there's one constructor and one accessor.

- Python tab tuples: _tab_labels / _tab_icons_html / _tab_container_ids were kept
  in lockstep by hand (separate appends, three splices on remove, .index(self)
  arithmetic in the icon setter -- the most fragile spot). Make _tab_handles the
  single source and rederive all three via one _rebuild_tab_props(). Desync in
  length/order is now unrepresentable.

- DockManager: extract a single commit(next) that does the dev invariant check +
  state update, and route BOTH applyOp and restoreLayout through it -- so "every
  committed layout is structurally checked in dev" is literally true, not "true
  except restoreLayout."

- Rename releaseRequestedCoords -> releaseAnchor and the resolve params
  requestedX/Y -> anchorX/Y, matching the `anchor` field vocabulary (the old
  requested* names were leftover drift).

Verified: 405 vitest, 298 e2e (0 failed), tsc/eslint/ruff clean, msgs in sync,
28 panel tests; tab-tuple sync re-confirmed (add/remove-middle/icon-change).
Re-placing a panel that's already floating solo (e.g. a later set_height /
set_width, which re-runs applyPanelPlacement with the coalesced placement) went
through floatGroup unconditionally: detach + mint a NEW window every time. That
churned the window id, reset its z-order to front, and re-resolved position from
the stored server anchor -- so set_height on a panel the USER had dragged yanked
it back to the anchor position.

floatAtRequested now reuses the existing window when the group is its sole
occupant: it updates width/height in place, keeps the window id + z-order, and
PRESERVES the user's position when the user owns it (anchor cleared by a drag) --
only (re)anchoring + resolving for a fresh float or a still-anchored window. A
docked->float or multi-group-stack re-placement still makes a fresh window.

Found while polish-hunting the floating-panel height behavior. Added op-level
regressions: set_height keeps the same window id + pins the height; a size-only
re-placement does not move a user-dragged panel. Verified end-to-end (drag to
(380,413) then set_height(420): height applies, position preserved).

Verified: 407 vitest (incl. fuzz), 298 e2e (0 failed), tsc/eslint clean.
A size-only re-placement on a panel already docked on the target edge (e.g.
`panel.dock_right(); panel.set_width(520)`, two statements -- exactly the
add_panel docstring example) silently did nothing: the region stayed at its
default width.

Cause: applyPanelPlacement re-ran `dockToEdge` on the already-docked group,
which detaches + recreates the leaf with a NEW node id. The width reconciler
then sees a changed column set, treats it as a new column, and resets the width
to the carried-over/default value -- discarding the requested width. (The
position-changing case worked only because the width rode that placement.)

Fix: skip the re-dock when the group is already docked on the target edge, so
the leaf's node id stays stable and the size branch + reconciler's same-columns
path apply the new width. Mirrors the floating window-reuse fix from the prior
commit. Added an op-level regression (set_width changes the docked region width;
node id unchanged).

Verified: 408 vitest (incl. fuzz + width reconciliation), 298 e2e (0 failed),
docked + floating set_width confirmed end-to-end (300->520->150 / 240->420->300).
A tab label (or single-tab panel header) longer than the tab's max width was cut
off mid-character with no "...". The tab/header Box is `display:flex` with the
title as a raw text child, and `text-overflow:ellipsis` is ignored on a flex
container -- so it clipped with no ellipsis.

Move the overflow/text-overflow/white-space onto an inner <span> (a non-flex
block) and give it minWidth:0 so it can shrink within the flex row; the icon box
gets flexShrink:0 so only the label truncates. Applies to both the tab strip and
the single-tab header's plain-title branch.

Verified end-to-end: a long label now renders "ThisIsAnExtremelyLon..." with a
proper ellipsis. tsc/eslint clean, 408 vitest, 298 e2e.
… offset)

Three related minimized-panel fixes:

- Minimized DOCKED multi-tab strip: was "{active icon} Alpha / Beta / Gamma"
  (one arbitrary icon + all labels joined, rotated) -- confusing and per-tab
  clicks were impossible. Now render ONE ROW PER TAB (upright icon + rotated
  label, active row highlighted). Each row is its own click target.

- Clicking a tab expands the panel: added DockContext.expandToTab (setActiveTab
  + expandGroup, composed in one op). The minimized strip rows AND the no-motion
  tab-click in startTabDrag both route through it, so clicking a tab to read it
  reveals its content instead of switching a hidden active tab. Keyboard tab
  nav still uses plain activateTab (arrow keys shouldn't expand).

- Tear-out grab offset: dragging a minimized panel out by grabbing the BOTTOM of
  its region-tall strip left a large cursor-to-ghost gap (grab was computed
  against the tall source, but the floated window is short). Clamp the grab
  offset into the floated window's actual rendered box so the cursor stays on it.

Verified end-to-end: 3-tab strip renders 3 clickable rows; clicking Beta expands
to Beta; bottom-grab tear-out keeps the cursor on the window. 408 vitest, e2e
regression added (test_minimized_multitab_strip_rows_expand_to_tab).
The press-to-window grab offset was open-coded in four drag starters, and only
the group-undock site clamped the offset into the floated window's box. The
column-undock site had the same latent gap bug (a region-tall column floats into
a height-capped window, so a low grab left a cursor-to-ghost gap) but no clamp.

Extract grabOffset(e, originX, originY, winId?): clamps into the floated window's
rendered box when winId is given (tear-outs whose source is bigger than the
result), else returns the raw offset (when the source IS the window). All four
sites route through it; the column-undock site now gets the same clamp.

Verified: 408 vitest, 299 e2e (0 failed); strip bottom-grab still lands on the
window through the refactored path.
The playground tests' vite dev server recursively watched the whole client dir,
including the huge .nodeenv and node_modules trees. Across many test runs this
exhausts the OS inotify watcher limit and crashes the server on startup (ENOSPC),
making the playground tests error at setup. The tests never edit source mid-run,
so ignore those trees in server.watch -- lighter servers, no watcher exhaustion.
…eError

PanelHandle.__enter__ raises a clear TypeError to catch the `with add_panel():`
mistake, but __exit__ had been removed on false reasoning. CPython looks up BOTH
dunders on the type BEFORE calling __enter__, so omitting __exit__ made
`with panel:` fail with a bare AttributeError and the helpful message never ran.
Restore __exit__; the regression test now uses a real `with panel:` (it had
masked the bug by calling __enter__() directly).
The per-tab rows in a minimized docked strip carried role=tab/aria-selected but
had no tabIndex, onKeyDown, or focus ring, and weren't wrapped in a role=tablist
-- claiming to be tabs while being mouse-only (a role-without-keyboard-support
a11y break, and role=tab outside a tablist). Wrap the rows in a
role=tablist (aria-orientation vertical), make each row tabIndex=0 + focusRing +
onKeyDown (Up/Down move focus, Enter/Space expand-to-that-tab via expandToTab),
mirroring the expanded tab strip. Verified: rows focusable, Enter expands.
dock -> minimize -> drag out -> expand produced a ~96px-wide window: floatRectFor
measured the collapsed strip's narrow DOM width and used it as the floated
window's width, persisting after expand. When tearing out a MINIMIZED docked
group, float it at the region's preserved expanded width (regionWidth survives
minimization for restore) instead of the measured strip width. Added an e2e
regression (dock->minimize->undock->expand stays wide).
Dropping a dragged panel as the leftmost tab (insert index 0) of a docked panel
failed in some configs: a region-edge band spans the whole region, so a tab strip
flush at the region's top/left edge sits inside the band, and section-2 region
edges (checked before per-panel zones) won -- docking a region span instead of
inserting at the tab. This hit EVERY panel in a stacked column (leftmost tab at
x=0, inside the 40px left band) and topmost panels in a multi-column row (strip
inside the 8px top band).

Skip the region-edge bands when the pointer is over an INSERTABLE tab strip (a
mergeable docked/floating group where tabInsertion resolves, drag itself
mergeable). A strip drop is a more specific intent than a region span. Outermost
region-edge docking is unaffected -- content area, grip bar, and screen edges
aren't strips. Added hitTest regression tests (column + topmost-row configs, plus
draggingUnmergeable still gets the region span). Verified live: drop on a stacked
panel's leftmost tab now inserts at index 0.
A minimized vertical strip is wayfinding chrome (a label/affordance), not
content, so emphasizing the active tab with the primary color + bold there just
distracts. Render all rows uniformly dimmed. aria-selected still marks the
logical active tab for assistive tech; only the visual emphasis is removed.
Dragging across the seam between two vertically stacked docked panels [A above B]
flickered: A's content bottom band drew the hint line at A.bottom, B's grip bar
drew it at B.top (7px apart, across the divider), and the divider gap itself hit
no target -> a NONE frame. Three slivers that all mean 'insert between A and B',
read as a jumpy/blinking target.

Hint-only fix (drop OUTCOMES unchanged): snap both bands' line to the divider
gap CENTER when the split lands on a seam shared with an adjacent stacked sibling
(dockedSeamSibling: same edge, horizontally overlapping, gap <= divider+slack),
and map the divider gap itself to that same seam split so there's no dead frame.
Single-panel / region-edge splits (no sibling) keep the panel-boundary line +
on-screen clamp. Added hitTest seam tests (bands + divider + 1px sweep: one
stable line, no NONE; single-leaf still anchors to its own edge).

Verified live: seam sweep went from 403->NONE->410 to a stable 406, 0 dead
frames; drop still inserts between A and B.
The main panel's header toggled minimize on a no-motion tap, but ordinary panel
grip bars deliberately did not -- minimize was only on the small +/- button -- an
inconsistency between the main panel and every other panel. Make a motionless tap
ANYWHERE on the grip handle toggle minimize/expand too, matching the header. The
+/- button stays as an explicit, redundant cue.

Safe because armPress already splits click-vs-drag at a 3px motion threshold: a
real drag (move the panel) exceeds it and never fires the tap toggle. Verified
live: tap grip -> minimize, tap strip -> expand, drag grip -> moves (no
accidental minimize). Updated the e2e (test_handle_tap_does_not_minimize ->
test_handle_tap_toggles_minimize) to assert the new behavior.
A docked region's max width was derived from MAX_PANEL_WIDTH_PX (600) via
maxRegionWidth, which SUMS across a row but takes the MAX across a column. So a
single panel capped at 600 alone but ~1207 when stacked above a two-panel row --
structure-dependent and inconsistent.

Remove the absolute cap entirely. Region width and floating-window width are now
bounded only by the container: the render-time MIN_CANVAS_PX guard keeps a canvas
sliver visible for docked regions, and float resize is bounded by container width
minus a canvas sliver. The width reconciler's clamps now enforce only the
grab-min floor (no ceiling). maxRegionWidth is kept solely as a relative
structural-extent comparator for widthColumns (renamed leaf unit to
LEAF_WIDTH_UNIT_PX; it caps nothing). Dropped MAX_PANEL_WIDTH_PX.

Updated width tests (no upper bound; over-wide set_width is now preserved).
Verified live: set_width(850) gives 850 (was clamped to 600); set_width(5000) is
held to 1280 by the canvas guard, leaving a 120px canvas sliver.
Cleanups from a /simplify pass over the recent dock work (behavior-preserving):

- Finish the max-width removal. maxRegionWidth still fed resizeRegionColumns as a
  per-column cap, so interactive region-edge DRAG was still capped at 600 while
  SplitView/reconciler/set_width were not -- an inconsistency the cap-removal
  commit missed. Replace the vestigial pixel-based maxRegionWidth (and
  LEAF_WIDTH_UNIT_PX) with a pure ordinal columnExtent (leaf=1, row=sum,
  col=max) used only by widthColumns; region-edge resize now passes Infinity for
  maxs. The misleading 'max width' concept is gone, not renamed.
- widthReconciliation: clamp(x, min, Infinity) -> Math.max(x, min) at all sites
  (a floor, not a two-sided clamp); drop the now-unused clamp import and trim the
  gravestone comment describing removed behavior.
- Extract tabListKeyDown (gestures.ts) for the roving-focus tablist keyboard
  pattern; TabGroupFrame's tabKeyDown and VerticalMinimizedColumn's per-row
  handler were near-duplicates (arrow-rove + Enter/Space activate).
- hitTest overInsertableStrip: reuse the existing inside() helper for the
  strip-bounds check instead of an open-coded 4-way compare.

Removed the obsolete maxRegionWidth test block (covered by widthColumns tests).
Verified: 412 vitest, 46 e2e (width/keyboard/dropzones/panels), tsc/eslint clean.

NOT done (noted, higher-risk redesigns of the just-tuned hit-test, left for a
deliberate change): canonicalizing the seam DropResult by tree nodeId instead of
geometric sibling detection; replacing the leftmost-tab skip-guard with an
explicit 3-tier zone precedence.
brentyi added 20 commits June 24, 2026 13:18
resizeWindow (the op behind a floating panel's server set_width) wrote the width
unclamped, so set_width(0)/negative produced a zero- or ungrabbable-width window
(and the interactive drag path's floor didn't apply to server-driven sizing).
Floor at MIN_REGION_GRAB_PX, matching the drag path and docked regions. No max --
float width stays otherwise uncapped, consistent with the max-width removal.

Pre-existing gap (resizeWindow never clamped); surfaced by the adversarial sweep
of this session's changes. + op regression test.
set_width/set_height re-send the whole COALESCED placement, so a size-only change
still carries the prior position (e.g. dock_right's edge). applyPanelPlacement's
edge/split branches then unconditionally re-docked -- so a panel the user had
locally torn out to a float got yanked back to the server's edge on any
set_width. (The float branch already guarded this via the window's anchor flag;
edge/split had no equivalent.) With two clients this is a contract violation:
one set_width correctly resizes the docked client but re-docks the client that
had locally floated the shared panel.

Fix: thread the last-applied position (prevPosition) into applyPanelPlacement;
the edge/split branches relocate only on a genuine position CHANGE. A size-only
re-placement (position unchanged) leaves the panel where it is and just resizes
it. A real dock_left-after-float still relocates (positions differ).

Verified end-to-end + multi-client: tear out on A -> set_width keeps A floating
while B resizes docked; dock_left then moves both. + op regression tests
(size-only keeps a floated panel floating; position change relocates).
Found by the multi-client adversarial loop.
Removing all of a DOCKED panel's tabs (down to zero) then adding a new tab left
the panel permanently invisible (and orphaned its group -- the dev invariant
checker fired). When tabIds went empty, ready flipped false so the membership
effect bailed and the hide effect (gated on !visible) didn't run, so the dead
group lingered in the layout; on revive the placement effect was deduped
(appliedPlacementKey unchanged) and the membership effect created a NEW group,
orphaning the stale one. Floating panels were immune (different path).

Add an effect for the emptied-while-visible transition: remove the panel's
last-known panes from the layout (collapsing the dead group/leaf) and clear the
applied-placement refs so a revive re-places cleanly. Verified across docked,
dock_below (stacked), and floating; 0 invariant violations. + e2e regression.
Found by the general correctness loop.
A minimized vertical strip with many tabs / long titles in a short viewport
clipped its lower rows unreachably: the Paper was overflow:hidden, cells/rows
flex-shrank and squashed, and a vertical-rl label has no main-axis bound so a
long title stretched the whole strip.

- Cap each rotated label at maxHeight:14em so it ellipsizes into a tidy fixed
  spine (instead of growing the row to the title's length).
- Cells + rows are flexShrink:0 (keep content height); the Paper is
  overflowY:auto -- so when rows don't all fit, the strip SCROLLS and every tab
  stays reachable, rather than clipping. flexGrow:1 still balances a few cells.

Verified: 8-tab strip in a 360px viewport is scrollable (all reachable); a long
title ellipsizes. 368 vitest.
dock_above/dock_below split against a DOCKED neighbor; if the anchor is floating
or unplaced they fall back to a right-edge dock (with a warning). The main_panel
docstring advertised it as a dock anchor 'from any scope' without noting the
control panel floats by default, so a split against it falls back unless it's
docked first. Clarified all three docstrings.
Two pure-column render paths (expanded SplitNode vs fully-minimized
VerticalMinimizedColumn) duplicated the same chrome: a data-dock-column Box + the
float-the-column handle + a flex-grow body wrapper. Extract a ColumnShell
component taking the body as children. Behavior-preserving render refactor;
verified the stacked-column path still renders (column + handle + both leaves).
From a deep robustness review (no live bug; these make it bulletproof):
- Move the lastTabIds bookkeeping out of the render body into an effect (keyed on
  orderKey, declared before the empty effect). A ref write during render is a
  concurrent-mode footgun; only-update-when-non-empty preserves the panes the
  empty effect needs. Documented the B/D declaration-order coupling.
- Add four structural invariants to layoutInvariants.ts (so the dev assert + the
  fuzzer now catch them): floating stackWeights must be finite/positive and keyed
  only by groups in the window's stack; collapsedByParent must be boolean; each
  area must be keyed by its own id. These harden the persistence/restore boundary
  (only reachable from a corrupt serialized layout, never from an op).
Added an area-containing fuzz fixture (the fuzzer previously NEVER exercised
dockable areas -- a standing blind spot, since the area-aware invariants are
load-bearing). It immediately caught a real conservation bug: tearOutPane wrapped
its paneId in a fresh floating group UNCONDITIONALLY, so tearing out a pane the
group doesn't hold (e.g. an emptied area group -> undefined paneId) CONJURED a
phantom panel (a [null] group + window), violating panel conservation.

Fix at the chokepoint: tearOutPane returns the layout unchanged (windowId/
floatingGroupId null) when the pane isn't actually in the group -- there's
nothing to tear out. Return type widened; the DockManager caller bails on the
null window id. Regression tests in area.test.ts pin the exact shape.

Verified: fuzz suite green (area fixture across 500x80 + randomized start),
371 dock vitest, 37 e2e.
floatColumn (float a whole pure column as one stacked window) is the only
conservation-preserving op that produces stackWeights, and it wasn't fuzzed. Add
it to the op driver so the new stackWeights invariant (finite/positive, keyed
only by in-stack groups) is verified across random sequences, not just by unit
tests. (removePane deliberately NOT added -- it destroys a pane, which would
break the fuzzer's panel-conservation check; its removal logic is covered by op
tests + the empty-revive e2e.) Fuzz suite green across all 7 fixtures.
In a docked column [Top, Mid, minimized Bottom], dragging the Top/Mid divider
rewrites the expanded cells' weights to a PX scale, but the minimized Bottom's
weight (its restore size) was left at its old flex-unit value. On unminimize it
then rendered flexGrow:tiny next to px-magnitude siblings and collapsed to ~1px
-- off the bottom of the viewport.

Fix at the resize chokepoint: when committing resized weights, also rescale each
collapsed cell's preserved weight onto the same px basis (keeping its
proportion), so all of a column's weights stay in one consistent unit and an
expand always restores a proportional height. + e2e regression.
The scene visibly lagged the panel divider on a fast docked-width drag: R3F
sizes its renderer from a ResizeObserver on the canvas wrapper, which fires
async AFTER layout, so the panel edge (CSS inset) moved this frame but the GL
backbuffer trailed by one drag-step every frame (measured -6px at 6px/step, -30px
at 30px/step; snapped on release).

Drive the GL resize on the same tick as the commit instead:
- ViewerMutable.syncCanvasSize: a nullable hook that calls R3F setSize to the
  canvas's current CSS box (updates drawing buffer + camera aspect/projection;
  no-ops if unchanged). Registered by SceneContextSetter once the renderer exists.
- DockManager: a host-supplied onRegionResizeFrame callback (dock stays
  Three-agnostic); the per-frame width commit is wrapped in flushSync so the new
  inset is in the DOM, then the callback fires. flushSync is safe here -- the
  commit runs in the gesture's own rAF, not in render.
- ControlPanelDock wires the two together.

R3F's own observer fires moments later and finds the size already current, so
the two never fight. Verified: 0px backbuffer lag at full drag speed (dpr 1 + 2);
window resize after a drag still follows (setSize doesn't pin CSS px); 55 e2e +
371 dock unit tests pass.
…n's path

Two fixes to how a docked-region width drag behaves:

1. Canvas no longer trails the divider. R3F sizes its renderer from an async
   ResizeObserver, so during a width drag the panel edge moved this frame but the
   GL backbuffer (and rendered scene) trailed. The resize handler now hands the
   3D canvas its authoritative new size (containerWidth minus the freshly
   committed insets) and the renderer resizes + repaints synchronously on the
   same tick (ViewerMutable.syncCanvasSize). Computed, not re-measured from
   canvas.clientWidth, which isn't reliably reflowed mid-drag.

2. Floating panels no longer get shoved around by a resize. Previously every
   inset change re-clamped user floats inward (so you couldn't resize a docked
   panel under a float without it jumping). Now a float is pushed ONLY when the
   growing region's edge actually sweeps past it AND it was fully on the canvas;
   it's kept flush with the seam (stays fully on canvas) until it can't, then the
   region slides over it. A float already overlapping, or a shrinking region,
   moves nothing. The decision is local to the drag frame (before/after seam) --
   no history. Server-anchored floats still re-resolve against the new bounds.

Verified: scene tracks the divider with 0 lag; float pushed flush / left alone
per the rule. 39 panel+float e2e + 371 dock unit tests pass. Replaces the
discrete-state float_clamp playground tests (the behavior is now drag-only) with
a real-server push test.
The unmergeable panel header (e.g. the main control panel's connection-status
header) drew a 1px bottom rule via the headerRule class unconditionally. That rule
separates the header from the content below -- but a minimized panel is
header-only, so the rule read as a stray gray border on its bottom edge. Apply
headerRule only when expanded (!collapsed).
- Remove the orphaned comment block that still described the old
  resolveFloatingPositions/clampDragged behavior (since replaced by
  pushFloatsAheadOfSeam + reanchorFloats); it referenced a parameter that no
  longer exists.
- Drop containerHeightRef: a width drag never changes the container height, so
  the resize closure's captured containerHeight is already current. Keep
  containerWidthRef (matches the reservedWidthRef convention; width is the axis
  being dragged).

No behavior change; 371 dock unit tests pass.
Behavior-preserving cleanup of the panel/dock client logic:
- DockManager: merge two byte-identical reanchorFloats effects into one (combined
  deps -- React fires on any dep change either way); inline restoreLayout (a
  one-line useCallback wrapper around commit used at a single drag-cancel site),
  moving its rationale comment to the call site.
- layoutOps: drop the unused PanelPlacementPosition type export.
- ControlPanelDock: fold a duplicate DockContext import line.

Net ~-13 lines of source. The load-bearing machines (StandalonePanelPlacement's
effect/ref set, the gesture stable-wrapper) were reviewed and deliberately kept:
no consolidation is provably behavior-preserving across their transitions
(per-client/shared contract, set_width-no-redock, empty-then-revive). tsc +
eslint clean; 372 dock unit tests pass.
The dropOnDockedLeaf seam-insert equivalence (splitting right-of-b == left-of-a
in a side-by-side region both yield column order [b,c,a]) was pinned only by an
e2e test that opened TWO full browser sessions to read rendered geometry. It's a
pure layout-algebra property -- add it as a deterministic vitest test against
dropOnDockedLeaf (runs in ms). The e2e test can be retired once the pytest
harness is available for a coverage-parity run.

Part of a test-efficiency pass: the dock suite is already well-factored (algebra
exhaustively unit-tested; e2e deliberately covers wiring/render). 372 dock unit
tests pass.
Regression from 4c3facf: it replaced the inset-change effect (which re-clamped
unanchored floats inward when a docked region's width changed) with reanchorFloats
(server-anchored floats only). The control panel is UNANCHORED, so after
dock_right then minimize, it was never pulled back -- its right edge and its
zIndex-12 resize handle overhung the revealed minimized strip and intercepted the
strip's pointerdown, so tearing out a minimized panel did nothing.

Restore the clamp for unanchored floats, but ONLY on DISCRETE inset changes
(dock/minimize/undock); a region-resize DRAG is excluded via
regionResizeDraggingRef (the drag moves floats itself via pushFloatsAheadOfSeam).
Canvas-resize-lag fix and float-collision-push untouched.

Verified: full dock e2e 111 passed (incl. the 2 previously-failing tear-out
tests); tsc/eslint clean; 372 dock unit tests pass.
When the control panel was narrow, TWO stacked horizontal scrollbars appeared:
one for the scene tree table (its own overflowX:auto ScrollArea) and one for the
whole panel body (dockBodyScroll). The scene tree's wide fixed-width rows
overflowed inside their own scroller, on top of the panel's.

Render the scene tree as a plain Box (drop its ScrollArea) and remove
tableWrapper's overflowX, so its wide rows overflow into the panel body's single
scrollbar instead of stacking a second one. The scene tree still renders and its
controls still work; the panel now shows exactly one horizontal bar when narrow.
These asserted pure layout-algebra (collapsed-state / column-order) through
synthetic clicks -- no real geometry or rendering -- and pytest.skip'd when their
gesture-built setup didn't land (flaky), so they cost a browser session for zero
unique coverage:
- test_stack_minimize_all_and_expand_all
- test_stack_minimize_restores_previous_mix
  (both subsumed by layoutOps.test.ts "minimizeStack / expandStack", 5 cases)
- test_right_of_A_and_left_of_B_are_the_same_seam_insert (opened TWO browser
  sessions; ported to layoutOps.test.ts "dropOnDockedLeaf seam equivalence")

Also removed the now-dead _make_stack helper + unused pytest/collapsed imports.
Verified: vitest equivalents pass; trimmed handles+dropzones e2e pass (8 tests).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants