Skip to content

[feat] controls and bottom-dock rework: ride-above dock, unified toggles, explorer tab, play mode menu, roster pill, rebindable shortcuts - #183

Open
AlexZ005 wants to merge 23 commits into
release/nextfrom
feat/controls-dock-rework
Open

[feat] controls and bottom-dock rework: ride-above dock, unified toggles, explorer tab, play mode menu, roster pill, rebindable shortcuts#183
AlexZ005 wants to merge 23 commits into
release/nextfrom
feat/controls-dock-rework

Conversation

@AlexZ005

@AlexZ005 AlexZ005 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

The Controls / bottom-dock rework: five design phases, then two rounds of fixes from the user's on-device passes. Designed against DCC standards (VS Code's bottom panel, Unity's Shortcut Manager, Blender/Unity's editor-area layout, the mobile-FPS touch convention).

Round 1 — the rework

  1. 0854c3b Controls above the dock — pill + play FAB anchor on --bottom-inset; the <=500px --dock-inset padding hack deleted.
  2. cb3c059 one decision tree for the panel buttons — $lib/panelToggles shared by the buttons AND the O/N keys (open in last mode → raise a buried window → hide only when on top); windowFocus.isTopVisibleWindow fixes the stale-top-of-stack misfire.
  3. 739f9df the Explorer is a dock tab — DOCK_FAMILY/dockTabs, the force-close exclusivity deleted, one shared dock height, one shared "+" builder.
  4. ee786fc play mode menu — right-click the play button for Play / VR / AR, pick-and-enter in one gesture; $lib/playMode owns the probes and requestPlay(); both hidden XR buttons mount permanently (kills the remount race and the threlte label sniff).
  5. 55f8b5c the pill is a persisted, customizable roster.
  6. 74398be rebindable shortcuts — stable ids + overrides, Alt-aware comboOf, click-to-rebind with conflict Swap, Ctrl+Enter = play.

Round 2 — first on-device pass

  1. eed6221 ContextMenu keepOpen + rowActions (the Customize checklist survives clicks); Move left/right acts on the VISUAL row, one slot per press; collapsed = the well alone with Expand in the menu; viewport Tools ▸ Node editor. Also root-caused the standing reset-windows red (the suite clicked the wrong accordion).
  2. c5d23d8 floating toolbar made opt-in; dock minimize + close-tab chrome.
  3. 65212eb instant play re-entry (the flat 2s wall replaced by a pointer-lock retry where the refusal actually lives) + XR-permission-deny recovery (isVRMode had one writer and no failure reset; the wedge was Scene's viewport-menu guard, the ONE writer of both context menus).
  4. b8e9a84 right-pinned dock chrome, filtered "+" menu, per-tab Undock/Close via a generalized arm seam, and G = grab in viewport / UV / timeline (a new external registry kind: listed and rebindable in Settings, executed by the owning editor).
  5. 87ca571 mobile play mode — left half walks, right half looks, a still tap interacts, X overlay and Android Back both exit. Found two real bugs: play mode left the canvas scrollable (Chromium pointercanceled every drag after one frame) and a redundant 250ms tap threshold ate tap-to-interact.

Round 3 — second on-device pass (this round)

  1. 54a1e64 ONE root cause behind two reports: the round-2 lucide icon buttons dropped text-xs, so their 24px line-height flex-stretched the tab band 22px → 34px — which both fattened the tabs and pushed the band's bottom edge 10px inside the panel, burying the resize cue. Tabs restored to 97.89 x 22px exactly; the cue raised above the strip in all seven panels, including ShaderEditor which never had one (so the shared dock height was frozen whenever it was showing). Dock X removed; the Explorer's close button turned out to exist but sit outside its own window at narrow widths; minimize glyph → PanelBottom, picked by rendering ten candidates at 14px.
  2. 253c497 the dock resizes the viewport instead of covering it (default on, revertible in Settings ▸ Interface ▸ Viewport). Rendering was free — threlte sizes from a ResizeObserver on the canvas's parent — so the work was the eight client→NDC sites that read window.innerHeight and were right only by accident (Explorer drop, api.pointerRay, the mesh knife, vertex-handle sizing, pong, the framing guide, the PiP, mobile add), all now unconditional through one canvasRect.js leaf. The same latent bug was in the test harness. Measured: a hand-paced resize drag with AO at dpr 2 = 61 buffer reallocations, zero frames over 20ms, so the drag stays live.
  3. f0514cd the toolbar floats by default with the slide removed; always-on-top split from the floating geometry (the combination worth knowing: floating off + on-top on = the bar stays on the floor and the dock passes behind it); the bar drags horizontally with a clamp measured against whichever HUD buttons share its row. Found a real modalGrab bug: its capture guard blocks a fresh gesture but not the browser's following click, so committing a move over a cell opened the dock and shifted the placement 21px.
  4. 0cd8238 the Customize "mess" was three defects, including that chevron-left/chevron-right were in no icon map — Icon.svelte falls back to MAP[name] ?? Box, so both reorder controls had been drawing the same square since they shipped. The list is now the bar itself (top-to-bottom = left-to-right, the play well as its own row); the five other dock views are optional roster buttons (defaults byte-identical); "Swap with" shares one data source with the "+" list; togglePanel went 3 panels → 8.
  5. 18f188e dock tabs reorder by drag or menu with a persisted order, drag out to undock, drag a floating window in to dock; precedence header-merge > bottom band > left/right edges. Two bugs caught by its own tests: a header-merge was being docked on top of and dissolved, and the new band stole the object list's edge drop.
  6. 44cece7 / 642fd44 the two lanes merged and verified as a composition, not just as a text merge — one real defect found (the default-on floating toolbar covering the header a dock-drag test parked at screen centre under a 560px dock, measured at z45 over 43) and fixed in that suite, since both behaviours are correct.

Gates

  • svelte-check 384 errors / 47 warnings on the merged head. Pristine base was 385/62; every delta is accounted (warnings −15 from the roster {#each} consolidation; errors −1 net from the applyLook fold plus exitPlay). release.yml's hardcoded gate must move to 384/47 with this branch.
  • npm run build green at every commit and on the merge.
  • 21 suites green on the merged tree, including a purpose-written cross-lane probe. New this branch: play-mode-menu, controls-roster (102), shortcut-rebind, dock-tabs-coexist, panel-toggle-keys, dock-chrome (64), viewport-tools-menu, play-reentry, grab-key, touch-play, toolbar-position (57), dock-tab-drag (52), viewport-scale (34). Every guard proven by breaking the code; every red A/B-proven pre-existing against BOTH merge parents.

Owed on-device (headless cannot judge)

  • Whether the Customize list's up/down arrows read naturally for a horizontal bar (the tooltips say it; the feel is a judgement call).
  • The toolbar drag on a real trackpad; the Android long-press cell menu (the whole drag discrimination exists to protect it); the new Settings rows and dock chrome in non-dark themes.
  • Real-headset VR/AR entry from the play menu; Cardboard on a real Android; mobile stick/look feel and hardware Back under gesture-nav.
  • The viewport-scaling default against a real workflow — and whether the corner HUD buttons should also lift with the dock (they deliberately stay window-anchored today).

🤖 Generated with Claude Code

Round 4 — third on-device pass

  1. a87529f floating show/hide fixed for every panel, and click-to-front fixed app-wide. Two root causes: four windows (Flow Code, Animation, UV, HUD) registered use:focusStack with no key, so raiseWindow/isTopVisibleWindow could not address them and their roster buttons silently no-opped; and windowFocus.apply() ranked the z band from the bottom (40 + min(index,4)), tying every window past the fifth at 44 — measured five windows at z=44 at once, so a click changed no number and DOM order decided who painted. Ranking from the top makes the raised window uniquely 44. "Add a view" also left the Controls cell menu (the strip's + keeps it).
  2. 0a5563f a shortcut for every toolbar tool, and the Shader editor's floating window. Bare T = dock show/hide; Alt+E/F/A/U/S/H = Explorer / Flow Code / Animation / UV / Shader / HUD; O/N unchanged; every binding names the TOOL, not a toolbar slot, and all stay rebindable. conflictOf reported clear for all seven before they were added. Only E, F and S are bare mesh-session keys, but all six take Alt+ anyway — a scheme with memorised exceptions is worse than one that is always true (proven by A/B on a live mesh session: bare S is swallowed, Alt+S opens). The Shader editor gained #shader-window mirrored from the UV editor, and both exceptions coded around its absence are gone (dockOnly in the toggle tree, the guard withholding "Undock" from its tab menu).
  3. 3ce32cf merge verification. No behavioural composition defect, but three things worth naming. The shader-editor suite's red was ours, not pre-existing — an earlier A/B compared against this branch's own HEAD, which already contained the change; origin/release/next passes with the same selector. Three suites were rotted by the +-to-lucide change, not one. And the flowCode/flowcode key mismatch was worse than reported: three callers address windowTabs by the dock key, so besides the tab-group branch it broke headerTargetAt's self-exclusion — during a header drag the pointer is always on FlowCode's own header, so Flow Code could never be dragged into the dock at all. Renamed with a windowTabGroups migration (dragWindow deliberately keeps its key — it names a persisted rect). Also corrected a z assertion that only held for five windows, a coverage gap blind to the eighth window, and a uv-editor check that was vacuous (it matched the strip's own tab, so it read green with the click doing nothing).

New suites this round: floating-window-focus (52), panel-shortcuts (126), shader-window (40). Gates still 384 errors / 47 warnings, build green.

AlexZ005 and others added 23 commits August 27, 2026 14:17
- the Controls pill + play FAB anchor on --bottom-inset (the visible docked
  panel's height, already JS-published by bottomDock) so they sit in the band
  just above an open Flow/Explorer dock instead of covering its last ~60px
  of thumbnails/palette on desktop; 200ms bottom transition matches the
  dock's own fly
- the old --dock-inset model is deleted (declaration, the <=500px media rule,
  and its three consumers in Flow/Explorer/ShaderEditor) - it padded the
  dock's CONTENT and only on folded screens, leaving every wider screen with
  the pill permanently over the dock
- Inspector/NotesDrawer bottom offset becomes bottom-inset + controls-inset
  (sum, not max: the pill no longer sits on the viewport bottom, so the
  taller-of-the-two no longer clears both; identical whenever either is 0)
- cameraPip autoPosition gains bottomClearance and the PiP window passes
  $bottomInset, so a parked PiP is no longer covered by the dock;
  ColocationBadge rides the same var
- the play FAB's transition moves inline (transform 100ms, bottom 200ms) -
  an inline transition shorthand replaces the class-set one, so extending
  the tailwind class would have silently killed the hover-scale
- dock-inset suite: 5 new checks proving the ride-up (FAB/pill above the
  dock top while open, back at their 10px/16px offsets when closed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- new $lib/panelToggles: togglePanel('objects'|'flow'|'explorer') is the ONE
  tree behind the Controls toolbar buttons AND the O/N shortcuts, which used
  to bypass the buttons with bare store flips - closed opens in the last
  mode (dock tab or floating, revealed + raised), a buried floating window
  is RAISED instead of closed (the object-list semantics, generalized), a
  window in a tab group activates its tab, a docked panel covered by another
  tab is brought back, and only a panel already on top hides
- the Node editor keeps its flowDockSnapshot behavior verbatim (hide/restore
  exactly the flow-family tabs that were docked, incl. the load-bearing A4
  hud line); a floating Explorer no longer repoints bottomDockActive at a
  panel that is not in the dock
- windowFocus gains isTopVisibleWindow: closed windows stay MOUNTED (hidden
  class / display:none) and the focus order only drops a node on destroy, so
  a window closed while on top read as top forever and the plain isTopWindow
  check misfired on the next press
- flow/explorer floating windows register focusStack KEYS at last, so
  raiseWindow can address them; windowTabs exports groupOfKey
- flow-explorer-floating rewritten for raise-then-hide (10 checks); new
  panel-toggle-keys suite (17 checks) pins keyboard parity; three guards
  proven by breaking the code (always-close, isTopWindow swap, bare flips)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- one panel, seven tabs (VS Code bottom-panel model): DOCK_FAMILY =
  FLOW_FAMILY + explorer, a dockTabs derived the strip renders, and the
  exclusivity subscription DELETED - activating a flow tab used to force-
  CLOSE the explorer while the flow family merely hid, so switching tabs
  now changes only which panel renders and nothing ever closes anything
  (flowTabs stays exported and flow-family-only: the node-editor button
  owns that group and the flow-dock suites read it)
- new $lib/dockMenu dockAddItems(): the dock's "+" menu in ONE place -
  DockTabs and the floating node editor's "+" kept separate copies of the
  same list (the documented drift), and the explorer is an entry now
- the explorer's docked branch renders the DockTabs strip and adopts the
  SHARED dockHeight (one value for every tab; resize writes through the
  store; one-shot migration adopts a legacy explorerHeight then drops the
  key); floating explorerWinW/H untouched; bottomDock now imports zero
  app stores
- suites: flow-explorer-dock + dock-float-exclusivity rewritten for
  coexistence, panel-toggle-keys updated (a covered explorer tab is a
  reachable state now), new dock-tabs-coexist (15 checks incl. the height
  migration); the counterfactual is proven - re-adding the exclusivity
  subscription turns 6 checks red across four suites

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- new $lib/playMode owns the play state machine the FAB, its new menu and
  the coming keyboard shortcut all share: the isSessionSupported probes,
  willEnterXR/willEnterAR, requestPlay() and the 2s exit cooldown with the
  21-F3 queued-press replay (an isLocked.subscribe that hops out through
  setTimeout - never a store write inside its own subscriber)
- the decision no longer sniffs threlte's private button label: the probes
  decide, the label survives only as a belt-and-braces guard against
  clicking an in-session Exit button
- BOTH hidden XR buttons mount permanently (#vrButtonVr/#vrButtonAr,
  data-aim echoes the preference): the old {#if $vrPassthrough} swap was a
  reactive REMOUNT, fine while the preference only changed in Settings and
  a race the moment a menu pick flips it and enters in the SAME gesture -
  with both mounted the aim is a query and requestSession stays
  user-gesture-synchronous
- right-click (a direct listener, not the deprecated on:contextmenu
  directive) on #play-button opens the shared ContextMenu: Play (desktop) /
  Enter VR / Enter AR passthrough as a checked radio group, unsupported
  modes disabled with the reason, each pick writing the preference (store +
  localStorage, the Settings shapes) and entering immediately; iOS Safari
  fires no contextmenu on long-press, so Settings keeps the toggles
- the pre-existing isLocked Writable<null> TS error moved here with the
  code it belongs to - baseline stays exactly 385/62, an in-file comment
  stops anyone tidying it into a silent baseline shift
- new play-mode-menu suite (24 checks); vr-passthrough retargeted onto the
  dual mount + data-aim; counterfactual proven (reverting to the {#if}
  swap turns 6 checks red)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- the pill is a plain nav (#controls-pill) rendering a persisted layout
  {order, hidden, spacerIndex, collapsed} through ONE {#each} template -
  flowbite's BottomNav could not take a dynamic inner grid (its classes
  are JIT literals and restProps land on the outer div), so the nav bakes
  in flowbite's own resolved classes, measured pixel-identical to HEAD
- the play FAB moves INSIDE the spacer well (top -5px against the 38px
  row reproduces the old 4px/6px overhang), so it inherits the pill's
  ride above the dock and tracks the well wherever customization puts it;
  the right hover-paint half is addressed as nth-child(2) because the FAB
  became the well's third child
- right-click any cell: Move left/right (a button swaps with its
  neighbour, skipping the well; the FAB's own menu walks the well),
  Hide button (never offered on the FAB - you cannot hide the way out),
  Collapse toolbar (well + expand chevron only), Customize toolbar
  (checked visibility rows + a danger Reset restoring the default and
  clearing the pref); the node-editor cell also offers the shared dock
  "+" list and the explorer cell Open-as-dock-tab / Open-as-floating
- explorer dock-mode rows go through a new explorerDockArm store seam
  (the explorerSceneSaveArm shape): explorerDocked is read ONCE at mount
  into component-local state, so a toolbar localStorage write is inert at
  a live panel - the toolbar asks, setDocked (which owns the flag, the
  render branch and the occupancy together) acts
- svelte-check 385 errors / 47 WARNINGS: the six hand-written cells each
  carried the same three a11y/directive warnings and one template reports
  them once - a legitimate ratchet from 62 (the release.yml gate must
  move to 47 when this lands)
- new controls-roster suite (31 checks incl. reload persistence, proven
  by neutering saveLayout); dock-inset's pill selector retargeted to
  #controls-pill

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- the shortcut registry gains stable ids and defaultKeys; user overrides
  live in localStorage shortcutOverrides {id -> keys} and applyOverrides
  rewrites each entry's keys in place, so the matcher, the Settings
  renderer and editorNavigation's Shift probe never learn overrides exist;
  the empty map stores nothing
- comboOf can express Alt now (canonical Ctrl+Alt+Shift+K; no shipped
  default uses it, asserted in-suite, so every existing combo is
  byte-identical); registerShortcut dedupes by id, not keys - keys-dedupe
  breaks the moment an override frees a default combo
- rebindShortcut refuses rather than clobbers (fixed rows, bare
  modifiers, the Escape deny-list, registry collisions returned as a
  conflict the UI offers to Swap); per-row reset + Reset all; display-only
  rows are fixed with the reason (movement keys, PTT hold, mesh-edit
  session); conflictOf also warns when a combo sits on the MESH_EDIT_KEYS
  stand-down list, which tests literal combos and so follows a rebind
- Settings > Shortcuts: click a key to rebind (window capture-phase
  listener, Esc cancels, closing Settings tears it down), inline conflict
  row with Swap, lock glyph + reason on fixed rows - the FILE IS LEGACY
  MODE (a $state would flip it to runes and break the build), so the
  redraw signal is a plain let + {#key}
- new scene.play entry: Ctrl+Enter -> requestPlay() (Ctrl+P is the
  browser's print dialog and bare P is physics; Alt+P is now expressible
  as a rebind); the modal exemption follows help.shortcuts by id instead
  of its literal combo, so it survives a rebind
- shortcuts.js gains a capturing stand-down (setShortcutCapture) so the
  registry never executes a combo the rebind UI is recording - proven by
  neutering it and watching the suite go red
- new shortcut-rebind suite (43 checks incl. reload persistence, swap,
  refusals, the standdown, and Ctrl+Enter entering play); 13 regression
  suites green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ContextMenu items gain keepOpen (opt-in; every other menu byte-
  unchanged): the Customize list is a CHECKLIST, and toggling one button
  used to dismiss it - the rows are a $derived of controlsLayout now, so
  a keepOpen action re-renders them in place with position and scroll
  kept (the node is never replaced)
- ContextMenuItems gains rowActions - small trailing controls inside a
  row (stopPropagation, never close): the Customize list reorders each
  button with an inline pair; a row carrying them takes an explicit
  aria-label or the buttons' names leak into the row's accessible name
- Move left/right acts on the VISUAL row (buttons + the play well
  spliced in) and moves exactly ONE slot per press - swapping with the
  well crosses play and shifts the well by one, instead of the reported
  two-cell swap around the play button; order + spacerIndex are read
  back off the mutated sequence, one source of truth
- collapsed toolbar renders the well alone: the expand chevron is gone,
  the FAB's right-click menu swaps Collapse for "Expand toolbar", and
  controlsLayout joined resetWindowLayout's wipe list + a live resetter
  (the app's existing put-my-chrome-back button is the iOS hatch, since
  Safari fires no contextmenu)
- viewport right-click > Tools gains "Node editor" through the same
  togglePanel tree the N key and the toolbar button call
- reset-windows suite FIXED, not skipped: it clicked the Scene accordion
  while #reset-windows lives under Interface, and flowbite renders no
  body for a collapsed item - the button was never in the DOM (its
  standing red had this one cause)
- controls-roster grown 34 -> 60 checks; new viewport-tools-menu (11);
  keepOpen proven by removing the flag and watching the stays-open check
  go red

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- new LOCAL pref floatingToolbar (Settings > Interface, default OFF after
  the on-device pass): ON = the pill + play FAB ride above the dock on
  --bottom-inset (the previous behavior); OFF = the pill is an ordinary
  member of the z-30 bottom-HUD tier, pinned 16px off the viewport floor,
  and an open dock covers it exactly like the chat/AI/sim buttons beside
  it - one derived class/style pair, no second markup branch (the FAB's
  inline z-hud cannot escape the pill's stacking context, asserted by
  elementFromPoint in dock-inset)
- the tab strip carries the dock's own chrome now: "-" minimizes the
  whole dock (tabs stay open, nothing renders, bottomInset reads 0 -
  deliberately NOT persisted: a minimized dock leaves no trace on screen,
  so a reload brings it back rather than handing someone a lost panel)
  and "x" closes the ACTIVE tab (visibleDockKey, not bottomDockActive -
  the strip is drawn by whichever panel actually renders), letting the
  existing fallback promote the next tab
- activateDock clears the minimize - every restore path (toolbar
  buttons, O/N keys, the "+" menu, a panel opening itself) already
  funnels through it; panelToggles' isVisibleInDock answers false while
  minimized, or step 4 would CLOSE the tab the user was asking to see
- DOCK_CLOSERS/closeStoreFor live in dockMenu beside the add list
  (bottomDock keeps importing zero app stores)
- dock-inset reworked: default asserts the pill stays put and is covered
  while the dock is open, then flips the real Settings toggle and asserts
  the ride-up; new dock-chrome suite (31 checks incl. the N-key restore,
  proven by removing the activateDock clear)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- the flat 2s post-exit wall in playMode is GONE: the ~1s browser refusal
  after a user Esc belongs to the pointer lock, so PointerLockControls
  copes with it now - attempt immediately, retry on refusal (~275ms beat,
  2.5s window, both failure signals: the modern promise rejection AND the
  legacy pointerlockerror event), stop the instant the lock lands or play
  is left; every non-Esc exit path stops paying 2s for a rule only Esc is
  subject to. lockRetryUntil is the ownership fence (the held-flag rule:
  only act on an error inside a window our own request opened); store
  reads go through get() so the isLocked effect cannot auto-subscribe to
  playPointerFree and fight the menu substate
- 21-F3 survives as the sub-macrotask transient replay only (a press
  landing on isLocked===false is deferred to the settle, never eaten)
- denying the VR/AR permission no longer wedges the app: isVRMode had ONE
  writer (the optimistic set before the click) and ONE reset (sessionend)
  - a rejected requestSession fires no session events and threlte's
  onerror prop was wired nowhere, so the flag stuck true and
  Scene.svelte's viewport-menu guard (the ONE writer of the viewport and
  object context menus) went silently dead along with click-select, the
  gizmo, WASD and focus. Both hidden buttons pass onerror now
  (xrSessionFailed, toast + reset), a 6s watchdog covers threlte's
  silent not-supported return, and Scene's sessionstart ASSERTS the flag
  (cancelling the watchdog), so a permission accepted late still comes
  up in VR - the optimistic set stays because Scene arms the VR
  configuration off it before the first XR frame
- new play-reentry suite (32 checks; the timing check fires the press
  from the store subscription at the settle, because timing Playwright's
  .click() includes an actionability wait that let a 1500ms wall pass -
  the counterfactual re-added one and read 1342ms, red); game-presence
  section 2 rewritten to the user-facing property (leave mid-round,
  press play, back in the same round) since "inside the cooldown" is no
  longer a reachable premise; play-exits drops its 2200ms wait

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- the dock's own chrome is a right-pinned cluster at the browser edge on
  the tab band (lucide Minus + X; the tab group gets right-24 +
  overflow-x-auto so a full strip scrolls under the cluster instead of
  running beneath it); the "+" becomes lucide Plus; icon-only buttons
  carry aria-labels beside their titles
- the "+" menu lists only views NOT already docked (built per open off
  dockOccupants; all-docked degrades to one disabled row); the Node
  editor stays out of the base list on purpose - the same list is the
  floating Node editor's own header "+", where offering itself is
  nonsense
- right-click (Android long-press) a TAB for Undock / Close on THAT tab,
  visible or not: the panels' docked flags are component-local, so the
  Explorer-only arm seam generalised into bottomDock's dockModeArm /
  armDockMode(key, docked) with a consume-effect in each of the six
  panels that own a flag (explorerDockArm deleted from appStore, its one
  consumer migrated); ShaderEditor genuinely has NO floating mode (no
  docked flag, no window, occupant report has no docked term) so its tab
  offers Close alone - it IS mounted while hidden, that was not the
  reason
- G = grab, in three surfaces and rebindable: transform.grab (registry,
  Move mode, stands down during mesh sessions via the existing
  MESH_EDIT_KEYS list), uv.grab and animation.grab as a new EXTERNAL
  registry kind - listed and rebindable in Settings but owned by the
  editor's capture handler, which asks bindingOf(id) for the effective
  combo; the matcher skips external rows explicitly (proven by
  reordering the array, not by luck), and conflictOf collides only
  same-scope rows so three G defaults coexist while a real double-bind
  inside one scope is still caught
- dock-chrome grown 30 -> 50 checks; new grab-key suite (36); three
  counterfactuals proven and restored byte-identical (the + filter, the
  external matcher guard, the conflict scope)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k exit

- new TouchPlayControls overlay + touchControls leaf: a touch starting on
  the LEFT half of the canvas is a virtual move stick (64px radius,
  folded into the SAME moveX/moveY numbers the E5 gamepad mapping feeds,
  so walk mode, claims and the E3 menu substate gate it for free), the
  RIGHT half is look through the extracted applyLook (was three
  near-identical copies of the yaw/pitch clamp in PLC; touch is the
  fourth caller); gesture claim decided by TARGET (canvas only,
  pointerType touch only) so HUD buttons and hybrid mice are untouched
- tap-to-interact survives: a still tap on the look half falls through
  to playInteract (travel alone discriminates - a redundant 250ms
  threshold measured 258.7ms on a real two-event tap and silently ate
  the interaction); touch presses never beginGrab (a swipe near a crate
  used to fling it); playInteract honors defaultPrevented from a claimed
  drag
- REAL BUG: play mode left the canvas scrollable - OrbitControls is what
  sets touch-action none and it stands down in play, so Chromium
  pointercancel'd every drag after one frame (measured: a 110px look
  drag a third short, a stick applied 0.047 units once) - a
  touch-play-on root class pins the canvas while the overlay is up
- exitPlay() in playMode is the canonical programmatic exit (release the
  lock when one is held, else the store write - the Esc pair); the X
  overlay (coarse pointers, isLocked, never during XR) and the Android
  Back button both call it - entering play pushes ONE history marker,
  popstate spends it on an exit instead of leaving the app, and every
  other way out consumes it so the next real Back is the user's
  (universal: desktop Back/back-swipe had the same surprise)
- the W3 lock retry stands down on coarse pointers (no pointer to lock;
  it asked 9 times per entry); the gamepad enabled switch moved onto the
  pad snapshot so disabling the gamepad no longer disables the only
  control a phone has
- svelte-check 384/47: the applyLook fold removed two baseline errors,
  exitPlay's isLocked.set(false) adds one of the documented
  writable(null) family - net minus one, the branch baseline restates
  to 384/47
- new touch-play suite (39 checks, REAL CDP touch - synthetic
  PointerEvents carry ids no browser issued and OrbitControls throws on
  setPointerCapture; explicit 390x844 viewport pins CDP input space to
  clientX); marker consumption proven by skipping history.back and
  watching the stale-marker checks go red

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ck X

ONE root cause behind two reports: W5's lucide icon buttons dropped
text-xs and gained pt-1.5 pb-1, so their 24px line-height made them 34px
- and the strip is a flex row, so every text tab inherited it (measured
22 -> 34). That both fattened the tabs and, at -top-6, pushed the band's
bottom edge 10px INSIDE the panel, burying the top-edge resize cue.

- every strip button carries an explicit h-5.5 (22px) so one member can
  never set the band's height again; a Node editor tab measures 97.89 x
  22 again, the pre-branch metric exactly (pinned in the suite)
- the resize cue is raised to z-30, above the strip, with a hover tint
  beside its existing cursor/title affordance - a real-mouse drag at the
  middle of the top edge moved dockHeight 320 -> 320 before and
  320 -> 380 now; dock-inset's own "inset tracks the resize" check had
  been failing 3/3 at HEAD and passes again
- ShaderEditor gains the top-edge resize it NEVER had, so the shared
  dock height stopped being frozen whenever it was the visible tab; all
  seven panels behave identically now
- the strip's X is gone (user testing: it read as "close the dock"); a
  view is closed by its own header X when floating or the tab's
  right-click menu when docked, and visibleDockKey's fallback still
  promotes the next tab
- the Explorer's close button was never missing, it was UNREACHABLE:
  every header item is shrink-0, so the row's ~730px minimum overflowed
  the window's own 420px minimum and the trailing buttons sat outside an
  overflow-hidden window (measured x=743 against a right edge of 580) -
  the search+chips now live in a clipping min-w-0 flex-1 middle, and the
  button gained #explorer-close + an aria-label
- minimize glyph -> lucide PanelBottom, picked by RENDERING ten
  candidates at 14px in the dark theme: it is the square-with-a-dash-at-
  the-bottom shape, while PanelBottomClose/Dashed smudge at that size,
  PanelBottomOpen reads as the opposite action, and SquareMinus's
  centred dash reads as a generic tree-collapse
- dock-chrome 50 -> 64 checks; two counterfactuals prove the halves are
  independently load-bearing (the band height is the visual fix, z-30 is
  real defence in depth)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- floatingToolbar flips back to default ON and the bottom transition is
  DELETED from both modes (the FAB keeps transform 100ms for its hover
  scale). The default flip is a change to the STORED SHAPE, not just the
  seed: the reader becomes !== 'false', because every existing user has
  a literal 'false' on disk from the moment the pref shipped default-off
  (the subscriber writes on the first flush) and would otherwise be
  pinned off with no way to tell that from never having chosen
- new toolbarAlwaysOnTop (default ON) splits Z-ORDER from GEOMETRY:
  floatingToolbar answers where the bar SITS (does it lift onto
  --bottom-inset), this answers who wins the pixel (z-45 over the dock
  and floating windows, or z-30 under them). They compose - the
  combination worth naming is floating OFF + on-top ON, where the bar
  stays on the viewport floor and an opening dock passes BEHIND it; only
  with both off does the dock cover it. The Settings copy no longer
  promises coverage the floating row no longer controls
- the bar MOVES along the bottom: a pointerdown becomes a drag once it
  travels 6px (movement, never a timer - a finger held still has to stay
  the browser's long press, which is what raises the contextmenu every
  toolbar menu lives on), the play FAB is excluded from starting one,
  and "Move toolbar" / "Reset toolbar position" ride the shared tail.
  modalGrab owns the armed move (origin, snapshot, listeners,
  commit-or-revert) with arrows nudging through refresh()
- the clamp is MEASURED, never coded: only a neighbour whose vertical
  band overlaps the bar's can be in the way, which is also why the
  <=600px HUD lift needs no breakpoint here (at that width chat/AI/mic
  move to a higher row and stop clamping). Recomputed on mount, resize,
  a ResizeObserver over the bar and each neighbour, and when the visible
  set changes; the position persists as a FRACTION of the track on
  controlsLayout, so it already rides resetWindowLayout
- FOUND AND FIXED: modalGrab's capture-phase pointerdown stops a fresh
  gesture but not the browser's following CLICK, so committing an armed
  move over the Node editor cell opened the dock, which lifted the bar
  and widened the track - the position committed 21px from where it was
  placed. One window-level swallowNextClick replaces the per-handler
  flag guards
- new toolbar-position suite (57 checks incl. the too-narrow fallback
  and drag-vs-click on the same cell both orders); dock-inset reworked
  for the new default and asserts "no animation" by DEMONSTRATION (a
  computed transition-property of `all` with 0s duration reads like
  everything animates and means the opposite)
- side-edge (vertical) docking deliberately deferred, not half-landed:
  the well's nth-child hover halves, the rounded ends, the FAB's tuned
  overhang, a vertical track measured against different chrome, and
  --controls-inset's bottom-band contract are a phase of their own

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The DCC behaviour: the canvas ends where the panel begins, rather than a
full-window canvas with a panel drawn on top of it.

- App.svelte wraps the Canvas in a .viewport region whose bottom is the
  open dock's height. That is the WHOLE rendering mechanism: threlte
  sizes the renderer from a ResizeObserver on the Canvas's PARENT, so
  the drawing buffer, camera aspect, composer and N8AO all follow from
  its own resize task, once per frame. No z-index (a stacking context
  would trap the PiP frame and the framing guide) and no transition
  (each step reallocates the composer's targets, so animating the inset
  turns one realloc into one per frame - which is also what the user
  asked for)
- new dockPushesViewport pref in viewPrefs (default ON, Settings >
  Interface > Viewport); OFF restores the overlay behaviour exactly
- the eight sites that mapped client coords to NDC through
  window.innerWidth/Height are fixed UNCONDITIONALLY through one
  canvasRect.js leaf (an un-inset canvas measures the window, so there
  is no mode to branch on): explorer drop, the module SDK's pointerRay,
  the mesh knife, adaptive vertex-handle sizing, pong, the framing
  guide, the camera PiP and the mobile add button. Two follow-ons:
  moduleSDK stores CLIENT coords and converts at ray time (the viewport
  can change while the pointer sits still), and cameraPip.autoPosition
  loses the bottomClearance argument added earlier this branch - once
  the caller passes the canvas, a second way to say one thing is a
  second way to get it wrong
- bottomDock gains a viewportInset derived: the store twin of the CSS
  rule, so the canvas and the chrome measured against it cannot
  disagree
- helpers.cjs projectPoint (SHARED by ~15 suites) had the same latent
  bug in the harness - it aimed clicks with window.innerHeight, so any
  suite opening the dock would have mis-aimed by its height. Now
  canvas-relative, arithmetically identical whenever the canvas fills
  the window
- MEASURED, not assumed: a hand-paced top-edge drag with AO on at dpr 2
  produced 61 distinct buffer sizes and ZERO frames over 20ms, matching
  the no-realloc control - so the drag stays live and unthrottled
- new viewport-scale suite (34 checks); two counterfactuals, the second
  of which earned its keep: a VERTICAL knife cut passed with the fix
  reverted, because canvas and window share a width and the whole error
  lives in y - rewritten horizontally it goes decisively red (the same
  world point projects 174.9px apart), and the drop counterfactual
  misses by 100px at the tested pixel
- the HUD frame deliberately stays window-relative: element offsets are
  PERSISTED against it, so moving it is a data migration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
THE ARROW "MESS" WAS THREE DEFECTS, reproduced before anything changed:
- the list was ordered by the STORED order with hidden entries
  interleaved, while a move only ever swapped SHOWN buttons - so one
  press jumped the row over a hidden one, the moved row travelled TWO
  rows and the ticks leapfrogged the unticked one. The visibility bits
  were in fact preserved; it just read exactly like they were not
- the rows re-sorted under a stationary pointer, so a second press
  grabbed whichever button had taken the slot and undid the first - the
  two ping-ponged and nothing could be walked anywhere
- chevron-left/chevron-right were in NO icon map, and Icon.svelte ends
  `MAP[name] ?? Box`, so BOTH reorder controls had been drawing the same
  square since they shipped. There were never any arrows on screen
- (a fourth, found by reading: an arrow whose neighbour was the play
  well rewrote only spacerIndex, so the press looked dead)

FIX: the Customize list IS the bar, top-to-bottom = left-to-right, with
the play well as its own row - one press is always one row, in the
direction pressed, and it cannot touch a visibility bit. Rows carry a
key so svelte RELOCATES the row's node instead of relabelling in place,
and the control re-focuses by its accessible name afterwards (a
relocation reads as a blur in Chromium). Arrows are chevron-up/down now,
greyed with a reason on hidden rows; the horizontal bar's own cell menu
keeps Move left/right.

- the five remaining dock views (Flow Code, Animation, UV, Shader, HUD)
  are OPTIONAL roster buttons: absent from the bar until enabled in
  Customize, and the DEFAULT layout is byte-identical (an optional
  button is simply one in neither order nor hidden; loadLayout now
  filters against BUTTONS, where the old filter silently dropped every
  optional view on the next reload)
- "Swap with" replaces a cell in place from the same DOCK_VIEWS data the
  "+" list is built from, so a button and its tab can never drift
- togglePanel generalized from 3 entries to 8: one tree for every panel,
  so an Animation button behaves exactly as the Node editor button does,
  raise-a-buried-window and mode memory included. Shader is dockOnly -
  it has no docked flag, no dragWindow and no chrome, so without the
  flag the tree reads it as floating-only and raises a window that does
  not exist
- icons picked from a RENDERED 18px sheet: Code / Clapperboard /
  Grid2x2 / Palette / Monitor. The sheet earned its keep - the obvious
  HUD glyph (layout-dashboard) and the obvious UV one (grid-2x2) are
  both a quartered square and indistinguishable at 18px; reading names
  would never have caught it
- controls-roster 60 -> 102 checks; two counterfactuals (drop the row
  keying, restore the old row source) both proven and reverted
  byte-identical

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- tab ORDER is user data now: a persisted dockTabOrder that dockTabs
  sorts by, with resolveOrder splicing an unknown key in beside its
  DOCK_FAMILY siblings (a view added in a later release appears where it
  belongs instead of vanishing or jumping to the front); reordering
  re-fills the PRESENT slots, so a closed tab never migrates
- a tab drag promotes on 6px of TRAVEL, never a timer - a still press is
  what Android turns into contextmenu, so a timer would race the
  long-press menu; below the threshold it stays a plain click that
  activates the tab. Reorder geometry is frozen at gesture start so the
  live preview cannot move the boundaries deciding the next reorder
- released outside the strip inflated by 44px (docking.js's own reach -
  the band is 22px tall, so a bare outside-the-rect test would undock on
  a wobble) the view UNDOCKS through the armDockMode seam
- dragging a floating dock-family window into the bottom band docks it,
  in a sibling module rather than docking.js: use:dockable is wired on
  only three windows, two of which are dock tabs, so a bottom edge there
  would miss Flow Code / Animation / UV / HUD entirely, and wiring
  dockable onto them would hand them left/right edge docking nobody
  asked for - and the two docks are different models (an edge dock
  re-poses the node, the bottom dock asks the panel to re-render)
- PRECEDENCE is explicit and asserted: header-merge (the smallest, most
  deliberate target) > bottom band > left/right edges
- the tab menu gains Move left/right at the top, disabled at the ends -
  the drag is the fast way, these are the discoverable one and the only
  way to reorder without a pointer (touch stands down for both drags,
  as docking.js already does)
- TWO REAL BUGS the tests caught: tabbable registers its pointerup
  first, merges, and hides the target, so re-asking headerTargetAt at
  the drop answered "no header" and docked ON TOP of a merge, dissolving
  it (the verdict is snapshotted on the last move now); and the band
  stole docking.js's bottom 44px for EVERY window including the object
  list, which can never be a dock tab, so its low edge-drop did nothing
- new dock-tab-drag suite (47 checks). DRAG_PX=0 initially passed
  everything because mouse.click() emits no pointermove - a 3px-wobble
  check is what makes that counterfactual bite

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lane B (toolbar round 2) into Lane A (dock round 2): the toolbar floats by
default and moves, the customize list is the bar and every dock view can be
a button - joining the dock's slim tabs, working resize, viewport scaling
and tab drag/dock/undock.
…olbar

The merge composed two correct behaviours into one collision: lane A's
section 8 parks the floating Node editor's header at the screen centre
under a 560px dock, and lane B's default-ON floating toolbar lifts the
pill onto --bottom-inset, which at that height puts it at y=104..144,
x=499..781 - exactly on that header, at z45 over the window's 43. The
grab hit a lucide path instead of the move handle. Neither lane alone
produces it (measured: floatingToolbar OFF puts the pill back at y=664
and the grab lands).

The source is right, so the fix is the suite's stale assumption: park at
x=400, which the 282px centred pill clears at every dock height, and
read the blocker with getAttribute('class') - .className on an SVG is an
SVGAnimatedString and printed [object SVGAnimatedString], naming nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reproduced at 1280x720: park the bar hard right with a 320px dock open,
minimize the dock. --bottom-inset drops to 0, the bar falls from the
band above the dock onto the chat/AI row - a row with different
neighbours and a narrower track - with no re-measure, so it held
right=1272 while #chat-button starts at 1220. It sat 52px over the
button until an unrelated store change forced a measure and it snapped
back to 1212.

The measuring effect now voids bottomInset instead of visibleDockKey.
That is the honest dependency: the pill's bottom resolves from
--bottom-inset, which is what bottomInset publishes, so one line closes
the whole class - minimize, restore, closing the last tab, undocking it,
and switching to a tab of a different height. dockMinimized would have
closed only the reported path, and visibleDockKey is subsumed
(bottomInset derives from it plus dockMinimized plus the occupant
heights); its icon-tint uses in the file are untouched.

No re-clamp of the stored posX, deliberately: it is a FRACTION of the
current track, so it is legal on any track and re-maps on its own
(measured: stayed 1.000 across the row change and the bar re-centred
itself). Rewriting it would forget the bar's place at the wide track, so
restoring the dock would not put it back.

9 new checks in toolbar-position, proven red without the fix with the
premises still passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TWO ROOT CAUSES behind "show/hide only works for the Explorer and the
node editor", both measured before anything changed.

- Of eleven use:focusStack sites only Flow, Explorer, the object list
  and two others pass a KEY. Flow Code, Animation, UV and HUD - exactly
  the four that gained roster buttons last round - registered with NO
  key, so windowFocus's byKey never held them and raiseWindow /
  isTopVisibleWindow could not address them: togglePanel's open-and-
  raise and hide-when-top steps silently no-opped for all four. Each now
  registers its PANELS key.
- Click-to-front was never a missing listener (focusStack has had a
  capture-phase pointerdown raise since phase 82) - it was arithmetic.
  apply() ranked from the BOTTOM as `40 + min(index, 4)`, which clamps
  every window from the fifth onwards onto 44. Measured with seven
  panels open: flowcode/animation/uv/hud/explorer ALL read z=44, so
  raising one moved it in `order`, changed no number, and DOM order
  decided who painted. Ranking from the TOP makes the raised window the
  only 44 (measured 40/41/42/43/44 after), which is exactly the
  guarantee click-to-front needs; the band stays under --z-hud so the
  tier contract is untouched, and the collapse moves to the back of the
  stack where nobody is looking
- the "Add a view" section leaves the Controls cell menu (the strip's +
  and the floating node editor's + keep dockAddItems, both verified
  still wired); Swap with and the toolbar tail are untouched
- new floating-window-focus suite (37 checks). Its counterfactual is
  instructive: with the old clamp restored, the elementFromPoint check
  still PASSED because DOM order won the tie - the z assertions are what
  carry the weight

Reported, not fixed: FlowCode's tabbable key is 'flowCode' while
togglePanel looks for 'flowcode', so the tab-group branch cannot find
it. Those keys are persisted in windowTabGroups, so renaming is a
migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- seven new Panels rows, all rebindable like any other: bare T for the
  dock (show/hide, with an empty dock opening the last-active view - a
  restore to an empty dock is indistinguishable from the key doing
  nothing) and Alt+initial for the six views E/F/A/U/S/H. Bindings name
  the TOOL, so Alt+A opens Animation wherever it sits or even when it is
  not on the bar. conflictOf reported null for all seven before they
  were added, and asserts it again in-suite
- the Alt prefix is load-bearing for THREE of the six, not all six: only
  E, F and S are bare mesh-session keys (measured - my first draft
  claimed all of them and the suite proved it false). All six take Alt
  anyway, because a scheme whose exceptions you must memorise is worse
  than one that is always true; proven by A/B on a live mesh session
  where bare S is swallowed and Alt+S opens
- Alt+E/Alt+F are Chrome menu accelerators on Windows: measured that the
  keydown arrives and handleKeydown's preventDefault covers it (the bare
  Alt keydown stays unprevented, which is correct - Firefox only
  activates on an Alt keyup with no intervening key)
- toggleDock asks through armDockMode rather than writing the panel's
  localStorage flag, which is inert at a live panel - so T genuinely
  docks a view whose remembered mode is floating
- THE SHADER EDITOR HAS A FLOATING WINDOW at last, mirrored from
  UvEditor: docked flag, setDocked, the coarse-pointer guard, the
  dockModeArm consumer, #shader-window with dragWindow / focusStack
  KEYED 'shader' / tabbable / bottomDockable / corner grip, body and
  chrome as shared snippets so the two headers cannot drift. Both
  exceptions coded around its absence are gone: dockOnly in
  panelToggles (with its typedef field and two dead branches) and the
  guard withholding Undock in dockMenu; CLAUDE.md records that they were
  removed. W7's drag-out and drag-in work over DOCK_FAMILY, verified
- new panel-shortcuts (117) and shader-window (40) suites; two suites
  RECORDED facts this batch changes and were flipped in-commit rather
  than worked around (shortcut-rebind's "no shipped default uses Alt",
  dock-chrome's "shader offers no Undock")
- a third guard earned its keep unplanned: a premise check caught
  querySelector returning the HIDDEN Flow panel's copy of the strip
  (0x0 at the origin), so a synthesized drag was landing on the canvas
  and reading as a dead feature

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…indow

Lane B (T + Alt+initial shortcuts for every panel, the shader editor's
floating mode and the removal of both exceptions coded around its
absence) into lane A (the focus-key and z-order fixes for floating
windows).
…checks

Merge verification of 0a5563f: no behavioural composition defect - the
two lanes touch disjoint source files and all four risky interactions
hold (Alt+S drives the shader window's full raise/hide cycle, T leaves
floating z byte-identical across a minimize round trip, no orphan
dockOnly reference survives, and the top window is uniquely 44 with
eight panels open). What it found instead:

- THE SHADER-EDITOR RED IS OURS, not pre-existing: origin/release/next
  renders the strip's + as text and passes with the same selector; the
  commits that made it a lucide icon are not ancestors of that branch.
  The earlier A/B compared against this branch's own HEAD, which already
  contained the change. Three suites were rotted the same way, not one
  (shader-editor, hud-editor, uv-editor); all select #dock-add-view now,
  and the tree was swept for the other old glyphs (-, x) with no further
  hits. shader-editor 0 -> 66
- THE flowCode/flowcode MISMATCH WAS WORSE THAN REPORTED. Three callers
  address windowTabs by the DOCK key, so the mismatch disabled the
  tab-group branch of togglePanel, the drags-as-one guard, AND
  headerTargetAt's self-exclusion - and that last one is user-facing:
  during a header drag the pointer is always on FlowCode's own header,
  so the drop test was false on every move and FLOW CODE COULD NEVER BE
  DRAGGED INTO THE DOCK. bottomDockDrop had documented the divergence as
  a quirk; it records it as the defect now. The tabbable key is renamed
  with a KEY_ALIASES migration where windowTabGroups is restored
  (members and active), while dragWindow deliberately KEEPS flowCode -
  it names a persisted rect and renaming it would strand saved positions
- lane A's z assertion did not generalise: "every z distinct" held only
  because that suite opened five windows, which is the whole 40..44
  band - a sixth correctly meets the documented 40 floor. Replaced with
  the guarantee the design actually makes (the top is uniquely 44,
  the stack stays under --z-hud), plus pixel ownership
- coverage gap closed: lane A's focus suite predates lane B's shader
  window and was blind to it (37 -> 52); controls-roster still asserted
  the dock-only design lane B deleted, staying green only because the
  shader defaults to docked
- uv-editor had a check asserting the opposite of this branch's own
  covered behaviour (the Explorer became a dock TAB and activateDock
  stopped closing anything), and its + check was VACUOUS - it searched
  every button and matched the strip's own tab, so it read green with
  the click doing nothing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant