Skip to content

[feat] context menu redesign: header, sections, icons, hints, type-to-filter - #85

Merged
AlexZ005 merged 22 commits into
fix/roadmap15-lmnofrom
fix/roadmap15-q-menu
Aug 5, 2026
Merged

[feat] context menu redesign: header, sections, icons, hints, type-to-filter#85
AlexZ005 merged 22 commits into
fix/roadmap15-lmnofrom
fix/roadmap15-q-menu

Conversation

@AlexZ005

@AlexZ005 AlexZ005 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Roadmap #15-Q — the user-approved context-menu redesign. Stacked on #84 (base = fix/roadmap15-lmno); retarget to release/next after #84 merges.

Functionality is unchanged — every action, guard and submenu survives byte-identical; only structure, metadata and styling change. The object menu, viewport menu and the "Selected" submenu now read as one family.

What changed

  • Header strip — what the menu acts on: object name + type badge, or "N objects selected"; a peer lock shows locked by X here instead of scattered tooltips.
  • Sections — quiet uppercase labels (Edit / Physics & effects / Share) over thin rules; Delete sits last after a plain divider, red, with a Del hint.
  • Icons — lucide kebab names in the item data via ui/Icon.svelte (registry extended ~26 icons); a level reserves the icon column only when a sibling has one, so small data-driven menus (packs, node cards) are untouched.
  • Shortcut hintsF, Ctrl+D, Del, Ctrl+Z/Y as a dimmed monospace right column.
  • Hover-intent submenus — 120ms open / 150ms close; a diagonal pass no longer flickers them.
  • Type-to-filter (dense menus only, ≥8 leaf actions) — autofocused row; typing flattens every leaf command-palette style with its submenu path ("Snapping ▸ Snap to surface"), Enter runs the top hit, Esc clears then closes.
  • Show/Hide removed from the object menu (user call) — hiding from the menu was a one-way door (you can't right-click an invisible object back); the object list's eye toggle owns visibility.

Verification

  • New tests/e2e/context-menu-redesign.test.cjs — 16 checks, all pass.
  • context-menu-v2, context-menu-overflow, multiselect-menu (two-peer), add-menu, add-menu-cursor, object-search, packs-explorer, mobile-hud — all green.
  • npm run build green; svelte-check 419/62 held.

🤖 Generated with Claude Code

AlexZ005 and others added 8 commits August 3, 2026 05:26
Review call (15-P2 follow-up): the prompt exists because auto-deciding was
wrong, and a ✕ that silently picks Share is the same trap smaller. Sticky
info toasts gain an optional `noClose` flag - no ✕ on the card, no Dismiss
row in the drawer - and share-or-stash uses it. The restore prompt and the
first-run notice keep their ✕ (dismissing those has a sensible meaning).

e2e: ui-fixes-15lmno asserts the fork renders no ✕ (46 checks).

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

Roadmap #15-Q (user-approved layout + type-to-filter). Functionality is
unchanged - every action, guard and submenu survives; only structure,
metadata and styling change. All three menus (object / viewport / Selected)
now read as one family.

- HEADER strip: what the menu acts on - object name + type badge, or
  "N objects selected"; a peer lock shows "locked by X" here instead of
  scattered tooltips.
- SECTIONS: quiet uppercase labels (Edit / Physics & effects / Share) over
  thin rules; Delete sits last after a plain divider, red, with a Del hint.
- ICONS: lucide kebab names in the item data, resolved via ui/Icon.svelte
  (registry extended); a level reserves the icon column only when a sibling
  has one, so small data-driven menus (packs, node cards) are untouched.
- HINTS: shortcut hints (F, Ctrl+D, Del, Ctrl+Z/Y) render as a dimmed
  monospace right column instead of hiding in tooltips.
- HOVER-INTENT: submenus open after 120ms and close after 150ms - a diagonal
  pass no longer flickers them; entering the submenu cancels the close. The
  container deliberately keeps NO role attribute (the menu suites locate
  submenus by "fixed div without a role").
- TYPE-TO-FILTER (dense menus only, >=8 leaf actions): an autofocused row -
  typing flattens every leaf command-palette style with its submenu path
  ("Snapping > Snap to surface"), Enter runs the top hit, Esc clears then
  closes. Focus is deferred one frame: the parent's use:portal moves the menu
  into <body> AFTER the child action runs, and moving a focused element blurs
  it.
- Show/Hide REMOVED from the object menu (user call): hiding from the menu
  was a one-way door - you cannot right-click an invisible object to bring it
  back; the object list's eye toggle owns visibility.
- 'Selected >' label loses its baked-in arrow (the renderer draws the
  chevron).

Verification: new tests/e2e/context-menu-redesign.test.cjs (16 checks);
context-menu-v2, context-menu-overflow, multiselect-menu (two-peer),
add-menu(+cursor), object-search, packs-explorer, mobile-hud all green
(multiselect-menu updated: header/section rows carry no label). Build green;
svelte-check 419/62 held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Roadmap #16 P6.

- LOCK LEAK: `applySelectionSet` broadcasts a `lock` message, but a deselect
  broadcast nothing - and `lock` only ever REPLACES the sender's set
  (lockGeometry ignores an empty list), so clicking empty space left the object
  highlighted and "locked by X" on every other peer until the deselecting peer
  happened to select something else. `broadcastSelectionRelease(uuids)` now
  sends one `unlock` per released uuid from all three release paths
  (deselectObject, applySelectionSet([]), and switching to a locked-VIEW).
  No new message type - `unlock`/`applyUnlock` already existed, and a peer that
  had asked us for control now gets it, which is right.
- "Selected" SUBMENU: the viewport menu gated it on `selectedObject`, which is
  STICKY by design (it keeps the last object so the open inspector has something
  to bind to), so the submenu lingered after a deselect. It now gates on the
  live selection SET, keeping the one legitimate empty-set state - VIEWING a
  peer-locked object - which retains its view-only actions.
- Configure Scene's active nav row drops the `inset 2px` accent bar (it read as
  a stray border); the tint + accent text stay.

Verification: new tests/e2e/deselect-unlock.test.cjs (two-peer, 9 checks:
lock replace, release on deselect / on applySelectionSet([]) / on locked-view,
sticky primary preserved) + viewport-selection extended with the menu gating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 P1. The 15-Q filter row was always visible on dense menus and the
menu had no keyboard model beyond Enter; both are fixed without changing any
action.

- HIDDEN UNTIL TYPING: the filter input stays MOUNTED and FOCUSED (that is what
  swallows app shortcuts while a menu is open - the input-focus guard) but
  collapses to height 0 / opacity 0 until there is a query. Not display:none or
  hidden: the input must remain focusable to own the keyboard. Revealed, it is a
  normal menu ROW - same padding and font-size as an item (verified 26px vs 26px
  in the suite) with a tint + bottom rule instead of the old inset pill.
- EVERY menu now carries it, so the >=8-leaf threshold is gone: small menus show
  no filter row (it is collapsed) but still get arrow navigation.
- KEYBOARD: Up/Down walk the current level skipping disabled rows (wrapping),
  Enter runs the highlighted row or OPENS its submenu and moves the cursor
  inside, Esc unwinds one step at a time (query -> open submenu -> close the
  menu). Left/Right stay with the textbox caret as asked; with an EMPTY box (no
  caret to move) they double as open/close submenu.
- ONE highlight for mouse and keyboard: hovering a row moves the cursor, so
  Enter always acts on the row you are looking at. The open submenu keeps its
  150ms close-intent delay while the cursor moves - that delay is what lets you
  reach a submenu diagonally, so pointer hover deliberately does NOT close it
  immediately (hence openPath for rendering vs navPath for the cursor).
- Clicking a row no longer steals focus from the filter (mousedown default
  prevented) - opening a submenu by click used to hand the keyboard back to the
  app's global shortcuts.
- Submenus now anchor to their own row element instead of a captured mouse rect,
  so a keyboard-opened submenu places itself exactly like a hovered one (and the
  stale-rect risk is gone).
- New `$lib/menuFilter.js` holds the flatten + ranking (label prefix > path
  prefix > substring > fuzzy subsequence, 40-row cap) so there is ONE matcher -
  P2 retires the node editor's private search box onto it.

Verification: context-menu-redesign extended (hidden-then-revealed row, row
height parity, Up/Down/Enter/Esc, highlight lands INSIDE the submenu);
context-menu-v2, context-menu-overflow, add-menu-cursor, object-search,
mobile-hud green. add-menu fails at its documented pre-existing flake
(right-tap does not open the menu, line 105) - identical with these changes
stashed. Build green, svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 P2. The flow pane menu carried its OWN search popup
(#node-search-box, a separate fixed box that replaced the menu at the same
coordinates, with its own ranking, its own arrow-key handling and its own
drag-to-move) and, since 15-Q, the shared filter row on top of it - visibly
duplicated. There is now ONE implementation.

- Deleted the popup and everything only it needed: openSearch, searchEntries,
  searchResults, its private subsequence ranker, pickResult, onSearchKeydown,
  scrollHighlightIntoView, savedMenu, the window keydown that jumped into
  search, and the rightDragMove/inputContextMenu imports (the box's right-drag
  move goes with it - the menu opens at the click).
- "Search nodes..." stays as the first row but is now a `revealFilter` item: it
  reveals the shared filter row WITHOUT closing the menu, so the grouped list
  stays as the browse view it always was. Typing anywhere in the menu does the
  same (the filter input is always focused), which is how it already behaved.
- Matches render as "Group > Node" - the same information the old box showed as
  "group . label" - ranked by the shared matcher, with arrow-key navigation and
  shortcut interception for free.
- Esc unwinds one step at a time: query -> revealed filter -> submenu -> close.

FIX found by the suite: a menu opened near the bottom edge (flipped up) grew
straight off the screen when filtering changed its height, because `place()` only
ran on open and on window resize. It now re-places on any size change via a
ResizeObserver, guarded against a maxHeight write loop.

Also repaired two STALE assertions in node-search (a known-failing suite): they
asserted context menus never scroll and are never height-capped, which the later
cap-and-scroll change deliberately reversed - the suite is fully green now.

Verification: node-search rewritten against the shared filter (20 checks, ALL
PASS - previously on the known-failure list); context-menu-redesign,
context-menu-v2, context-menu-overflow, multiselect-menu, object-search,
packs-explorer green. Build green, svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 P3.

CONFIGURE SCENE gains two sections (LOCAL per-device prefs, like the clip planes
and the render mode that already live there - a grid is a view aid, not scene
content, so nothing here replicates):
- GRID: show, cell size, "match snapping step" (the drawn cell tracks the
  translate snap step, so the grid you see is the grid objects land on), major
  line every N cells, cell + major colours, fade auto/fixed with radius and edge
  softness, infinite vs a finite extent, follow-camera, and origin axes. Reset
  restores the defaults. New `$lib/gridSettings.js` holds it (cameraClip idiom,
  unknown keys fall back to defaults so old payloads keep working);
  extensions/Grid.svelte reads it and keeps the I4 no-lerp auto-fade math.
  The axes helper renders in the world rig, never under objectsGroup - anything
  there would enter GLTF sync and duplicate for peers.
- SNAPPING: enable, position/rotation/scale steps as preset chips PLUS a free
  number each (custom steps were menu-only before), and surface snap.

CONTEXT MENU: new `checked` item flag renders the active choice of a group as
BOLD WHITE on a brand-tinted pill, replacing the '● ' label prefix that shifted
labels sideways as it appeared. The text itself is deliberately not accent-tinted:
the app's accent is a salmon (#fe795d), which would sit too close to the red
`danger` rows in the same menu.

SNAPPING SUBMENU is sectioned (Position / Rotation / Scale / Surface) instead of
one flat run of ten steps, the parent row's hint column shows the live setup
("0.5 · 15° · 0.1", or "off"), and a last row jumps to Configure Scene for custom
values. The View submenu gets the same treatment (Show grid as a checked toggle +
a grid-settings jump).

Verification: new tests/e2e/grid-snapping.test.cjs (15 checks: sections, checked
marks, no bullets, live hint, click-applies-and-mark-follows, panel toggles reach
the store, AxesHelper outside objectsGroup, match-snap-step, persistence across a
reload, custom step); context-menu-redesign, context-menu-overflow, vr-snap-menu
green. Build green, svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 P4.

BOOKMARKS were five anonymous slots where a sixth save silently pushed the
oldest out, labelled by save TIME, storing only position + target.
- Unlimited NAMED entries (default "View N"), each storing the LENS it was
  saved with (FOV + near/far); recall restores the whole look, near/far through
  cameraClip so the orbit zoom clamp stays paired with the far plane.
- Management in Configure Scene: rename inline, re-shoot from the current view
  (keeps the name), reorder (order IS the Shift+N mapping), delete, plus Save
  current view. Shift+1..5 still recall the first five.
- Legacy payloads normalize on read: no name -> "View N", no lens -> recall
  leaves your lens alone. The viewport menu lists them by name with a ⇧N hint
  and a "Manage saved views…" jump.

CAMERA SETTINGS were a "Camera lens" sub-label buried inside the View section,
and the orbit feel was hardcoded in Scene.svelte's <OrbitControls>. Now one
Camera section: lens presets + FOV, near/far clip, rotate/zoom/pan speed,
damped orbiting, invert vertical orbit (the sign rides rotateSpeed - three has
no flag), Frame scene (fit everything along the current view direction), Reset
view (the mount defaults), Reset feel, and the saved-view manager. Orbit prefs
are LOCAL and re-apply whenever the controls remount (VR exit, spectator mode).

Verification: new tests/e2e/camera-bookmarks.test.cjs (14 checks: legacy
normalization, lens capture + restore, no cap, panel rename/overwrite/reorder/
delete, Shift+2, orbit prefs reach the live controls + reset); camera-clipping
and roadmap-13-viewport-camera green. Build green, svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 P5. Perspective and orthographic CAMERA objects you can place, aim,
look through and shoot from.

DATA: a camera is a normal replicated marker MESH (`/create Camera` /
`/create CameraOrtho`, body from customGeometries - a box with a lens cone down
-Z, three's camera convention) whose settings live on `userData.camera`. That is
the userData.physics trick, so replication, undo, sessions, autosave, prefabs
and GLTF extras all work with ZERO new plumbing, and peers agree because the
same /create stamps the same deterministic defaults. Markers are never physics
bodies and never cast shadows. Add > Camera > Perspective / Orthographic.

WRITES go through `setCameraFor(uuid, patch)` - one props history entry, one
`objectParameters` message, one poke that rebuilds viz + preview (the
setPhysicsFor precedent); the 'props' history kind and the objectParameters
applier both learned `camera`.

FRUSTUM VIZ (`cameraHelpers.js`, the colliderHelpers pattern): a wireframe
frustum per camera, built from the spec, living at the SCENE ROOT so it stays
out of GLTF sync, following its marker per frame, hidden in wireframe view mode
and for the camera you are currently inside. On by default with a local
`showCameraFrustums` toggle.

PREVIEW is a TRUE camera swap: `CameraPreview.svelte` mounts a real
Perspective/OrthographicCamera as threlte's default, so an ortho camera renders
genuinely orthographic and fov/near/far are exact. Two things this needed:
- Outline.svelte re-points its EffectComposer (setMainCamera + N8AO's own camera
  reference) whenever the active camera changes - every pass baked
  `camera.current` at construction, so without this you keep seeing the editor
  view. It tracks `$camera` (the STORE): threlte's CurrentWritable exposes
  `.current` as a plain property, so reading it inside an $effect registers no
  dependency and the effect runs exactly once.
- the previewed marker hides itself locally (you stand inside its body mesh
  otherwise), the way spectator mode hides the avatar you watch from.

CONTROL (banner button): hands the camera to the normal viewport navigation -
WASD + mouse, because the preview camera owns the OrbitControls and Scene's
existing per-frame nav call drives whatever is active. Every controlled frame
writes the pose back onto the marker (throttled ~10Hz `move` broadcasts), and
ending control leaves exactly ONE undo entry for the whole ride (the possess
"one undo per ride" precedent).

Also: framing guide (letterbox bars for 16:9 / 4:3 / 1:1 / 2.39:1 while
previewing), Capture (renders one offscreen frame at the framing aspect and
downloads it), Set from current view / Align view to camera, an Inspector Camera
section, the two headline actions in the object context menu, and REPLICATED
preview presence (`campreview`) - the Users popover shows "previewing <camera>"
per peer with a click to join, cleared on disconnect and shared on handshake.

Verification: new tests/e2e/camera-objects.test.cjs (27 checks, two-peer:
defaults, replication, write+undo replication, scene-root frustums that follow,
ortho preview really orthographic, banner, own-frustum suppression, peers seeing
the preview, flying writes the marker + replicates + one undo entry, exit
restores your camera, Capture at 640x360). collider-viz, inspector,
object-properties, viewport-selection, roadmap-13-viewport-camera green;
view-mode fails a pre-existing shadow-catcher check (identical with these
changes stashed). Build green, svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AlexZ005 and others added 14 commits August 4, 2026 12:45
Two bugs in 16-P5's Control mode, both reported.

1. Pressing Control SNAPPED the view. A fresh OrbitControls targets the world
   origin and its update() ends with camera.lookAt(target), so mounting it
   rotated the preview camera the moment Control was pressed. The controls are
   now seated behind the camera first - the target goes on the camera's own
   forward axis, making that lookAt a no-op - and because OrbitControls already
   ran one update() during construction (before any effect could run), the camera
   is re-synced from the MARKER first, so the mount rotation cannot get baked into
   the pose. Measured drift is now 0.0000.

2. After exiting a preview, dragging the transform gizmo also ORBITED the camera.
   Both sets of controls were bound to the same `orbitControls` store, and
   threlte clears a bound ref on unmount - so the preview's teardown could land
   after the editor's controls remounted and leave the store EMPTY. Every
   suppression site (`$orbitControls.enabled = false` on gizmo/marquee drags) then
   wrote to nothing, for the rest of the session. The preview camera now publishes
   its own `previewOrbit` store, a derived `activeOrbit` says which controls are
   actually steering, and Scene routes its six suppression sites plus the
   per-frame navigation call through it.

Verification: new tests/e2e/camera-preview-control.test.cjs (9 checks: no drift
on Control, target seated ahead, ownership handover, editor controls restored
live and attached to the active camera, suppression reaches them, two cycles stay
clean). svelte-check 419/62 held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 second drop, batch Q1 — three reported annoyances in the menu.

- SUBMENU CURSOR is remembered per level: stepping out of a submenu lands back on
  the row you came from instead of jumping to the top. Pointer hovers record the
  same memory, so mouse and keyboard agree about where you were.
- SEARCH IS STICKY. Deleting the query used to snap you back to the grouped menu
  mid-thought; now the box stays and the list simply widens to EVERY action
  (capped at 200 rows, scrolling) - the browse list the retired node-search box
  used to be. Only Esc leaves search, so Esc now unwinds query -> search ->
  submenu -> menu, one step per press.
- THE MENU KEEPS ITS ANCHOR. It picked the roomier side of the click on EVERY
  re-place, so a long match list re-decided and teleported the menu to the top of
  the screen. The side is chosen once, the anchored edge stays on the click, and
  anything that doesn't fit is capped and scrolls (verified: top 140 -> 140 while
  the list grows from 2 rows to 59).
- "Search nodes…" no longer appears among its own search results (`revealFilter`
  rows are excluded from the leaf collection).

Verification: context-menu-redesign +9 checks (sticky search, anchor stability,
internal scroll, three-step Esc, cursor memory into and out of a mid-list
submenu); node-search updated for the new Esc semantics +1 check for the
exclusion; context-menu-v2 and context-menu-overflow green. svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ckboxes

Roadmap #16 second drop, batch Q2 — five reported items.

- DEEP LINKS: "More snapping settings…", "Grid & axes settings…" and "Manage
  saved views…" now open Configure Scene, EXPAND the named section (even one you
  had collapsed) and scroll to it. They went through `showSidebar('scene')`,
  which TOGGLES - so clicking one while the panel was already open closed it. New
  `openSceneSection(label)` only ever opens, and Section.svelte watches an
  `inspectorScrollTo` store, expands, scrolls and clears it.
- GRID FOLLOW is a three-way choice: Off / Look-at / Camera. "Follow the camera"
  tracked your POSITION, which is the wrong thing while you are looking somewhere
  else; Look-at centres the grid under the orbit target. Both modes stay
  HORIZONTAL (y = 0 - it is the ground plane, not a flying sheet) and snap the
  centre to whole cells so the lines keep agreeing with world coordinates instead
  of sliding under objects. The old boolean migrates to Look-at.
- SNAPPING STEPS: Scale gained the missing 0.25 (and Rotation 90°), and a value
  typed in Configure Scene now joins the presets in the menu, sorted and marked
  as active - custom steps used to be invisible and unreachable there.
- PHYSICS CHECKBOXES: the Sensor, freeze-axis and show-collider boxes were raw
  <input type="checkbox"> rendering as native controls; they use the themed
  component now, like everything around them.
- ADD opens the new object's properties, even when another panel was showing.

Verification: new tests/e2e/panel-deeplinks.test.cjs (16 checks: open from
closed, expand a collapsed section, no-close on a second link, look-at follow
snapped to cells with y pinned, Off recentres, 0.25 + custom step present and
marked, themed checkbox, Add switches the panel); grid-snapping,
add-menu-cursor, physics-colliders, collider-viz, inspector green. Build green,
svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 second drop, batch Q3.

The transform rows had a drag-to-scrub control; every other number was a plain
<input type="number"> that only committed on Enter or blur - so its arrows looked
like they did nothing, and the two behaved differently. DragRow is now THE numeric
field and the sliders' boxes, the snapping steps, the clip planes, render order
and the particle offsets all use it:

  drag horizontally   scrub (Shift = fine, Ctrl = snap)
  click / type        applies LIVE, no Enter needed
  ArrowUp / Down      one MINOR unit (0.01 at 2 decimals), Ctrl x10, Shift x100;
                      integer fields step by 1 / 10 / 100
  Esc                 back to the value you focused it with

It is a real <input> the whole time instead of swapping a button for a box: the
caret is always there, ids keep working, and touch gets the numeric keypad
(inputmode). type="text" on purpose - a native number spinner would fight our own
arrow steps.

TWO DELEGATION TRAPS, both found by the suites: Svelte's `onkeydown`/`onpointerdown`
attributes are DELEGATED, running only once the event reaches the app root, and
the panels this field lives in swallow both on the way up (the drawer's drag/resize
wiring eats pointerdown, the flowbite dialog eats Escape). Esc-to-revert did
nothing, and a drag whose pointerdown never arrived jumped the value by the
pointer's absolute X (+22 instead of +2). Both are direct element listeners now.
The pointer trio rides the WRAPPER so a scrub can start on the axis label too,
and a click there focuses the input for typing.

Verification: new tests/e2e/number-fields.test.cjs (13 checks: live typing, the
three arrow steps, integer stepping, Esc revert, drag scrub, the same rules in a
slider box); inspector updated for the always-input field (its drag check now
lands on 2.0 instead of 22.9); camera-clipping, grid-snapping, camera-bookmarks,
object-properties, panel-deeplinks, geometry-params, particles(+impact),
environment(+v2) green. Build green, svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Roadmap #16 second drop, batch Q4.

Selecting a camera object now shows a small LIVE preview bottom-right. It is not
a second WebGL context (that duplicates every texture and geometry on the GPU):
Outline.svelte draws one extra SCISSORED viewport of the same renderer after the
composer pass, into the rect the window publishes - so the DOM part is pure chrome
and the cost is one viewport draw, only while a camera is selected. gl clears
respect the scissor box, so the inset clears just itself.

- Right-drag moves it (left stays free for the viewport), touch-HOLD then drag;
  it clamps fully on screen and parks bottom-right, LEFT of an open side panel so
  the two never overlap.
- Inside the frame the camera's own marker and the frustum lines are hidden for
  that draw - you would otherwise be looking at the inside of its body.
- A ⤢ button jumps to the full-screen preview; ✕ turns the window off for that
  camera. On by default, with "Preview window while selected" in Camera
  properties.
- CAPTURE moved onto its own row below Preview / Set from view / Align view, with
  a camera icon and a note that it saves a PNG at the framing aspect.

Two familiar traps, both caught by the suite: `pipTarget` reads the selection SET
(the sticky `selectedObject` kept the window open after a deselect) and takes
`objectsGroup` as a dependency (the `pip` flag lives on userData, and THREE trees
are not reactive - the post-write poke is the only signal a derived store gets).

Verification: new tests/e2e/camera-pip.test.cjs (18 checks: targeting, published
rect matches the frame, framing aspect, parking clear of the panel, right-drag +
clamping, hidden during a full preview and after deselect, the per-camera switch,
and the DOM->gl y-flip); camera-objects (banner check hardened against its fly
transition), camera-preview-control, viewport-selection, collider-viz green.
view-mode still fails its pre-existing shadow-catcher check. Build green,
svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… PiP polish

Roadmap #16 second drop, batch Q5.

THE BUG, properly this time. Nothing in the app ever disabled orbiting during a
gizmo drag: threlte's TransformControls disables whatever controls sit in ITS OWN
context slot, and a camera preview unmounts + remounts the editor's OrbitControls,
after which that slot points at an instance which no longer drives the view. So
dragging an object also spun the camera. The `dragging-changed` hook now does the
suppression itself, first thing and for every gizmo target, writing through
`activeOrbit` - instance-proof. The preview's own controls are disposed when
Control ends (three keeps DOM listeners on a merely-dropped instance).
Reproduced end to end first: the SAME real drag on the REAL gizmo arrow rotated
the view 0.21 after a preview cycle and 0.00 before it; now both read 0.00000 and
the object still moves the identical 1.04 units.

MENU placement, rewritten to the rules you gave: open AT the cursor preferring
downward; when the content does not fit below, shift the WHOLE menu up just far
enough that its bottom stays inside (no flipping to the other side of the
pointer); a scrollbar appears only when the content is taller than the window.
SEARCHING no longer moves anything - the menu keeps the top it opened with, the
list gets a sensible height instead of unfolding to the floor, and a corner grip
resizes it while searching.

GRID follow: 'camera' now uses threlte's own followCamera, which keeps the shader
drawing lines at WORLD positions - smooth while you pan. Only 'look-at' snaps the
centre to whole cells, which is what you want when it is locked to an object.

DEEP LINKS scroll the container with the sticky header's height as an offset, so
the section label lands BELOW "Filter properties" instead of under it, and
"Manage saved views…" targets the SAVED VIEWS sub-heading (`data-anchor`) rather
than the top of the Camera section.

SNAP STEPS are clean numbers: the shared numeric field quantizes a drag to its own
precision (a scrub used to produce 0.7999999999999999), the position/scale snap
fields are 2-decimal, and every menu label and hint prints through one formatter.

CAMERA PiP: the title bar drags with the LEFT button too (the body keeps
right-drag so a stray left-click cannot move it), the transform gizmo is hidden
for the inset draw - it sat on the lens as a giant coloured blob, and hiding the
controls was not enough because three keeps the gizmo's visuals in a separate
helper object - and the parked window keeps clear of the round HUD buttons.

Verification: new tests/e2e/gizmo-orbit-leak.test.cjs (9 checks, A/B real-mouse
gizmo drag before and after a preview cycle, so normal left-drag orbiting can
never be mistaken for the bug); context-menu-redesign +5 (cursor-anchored open,
shift-up with no scrollbar, sticky top while searching, bounded height, grip);
panel-deeplinks +4 (three headers clear the sticky header, clean snap hint);
camera-pip +4 (left-drag on the bar, body left-drag inert, gizmo restored after
each draw). camera-objects, camera-preview-control, node-search, grid-snapping,
number-fields, inspector all green. Build green, svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ght, PiP layer

Roadmap #16 second drop, batch Q6 — the six follow-ups.

- LOOK-AT POINT no longer jumps to the world origin when you exit a preview. The
  editor's OrbitControls unmount while the preview owns the view, and the pair that
  mounts on exit starts from the default target (0, 1.5, 0). The target is now
  snapshotted when the preview takes over and copied back onto the FRESH instance
  once it appears (the store still holds the disposed one for a beat). Verified:
  (6, 2, -3) in, (6, 2, -3) out.
- SNAPPING ROWS align: label | chips | field in one grid, so the three numeric
  boxes share a right edge no matter how many preset chips a row has (4 / 4 / 3),
  and the chips are tightened enough to stay on ONE line in the 320px panel.
- NO CARET during a scrub: pointerdown's default is blocked so a drag can never
  place or drag the caret through the digits (you could watch it skate about),
  selection is suppressed while scrubbing, and focus is handed over on RELEASE
  instead — which is also what makes click-to-type work.
- THE SEARCH LIST REMEMBERS the height you drag it to, per menu kind: the viewport
  menu, the node editor and the object menu each keep their own (`sizeKey` prop →
  `ctx:searchHeight:<kind>` locally).
- DEEP LINKS are deterministic: measure → scroll → re-measure → correct, with the
  scroller found by real SCROLLABILITY rather than class names, an instant scroll
  (a `smooth` one was being cancelled by the reflow of the section it had just
  expanded) and retries while the just-expanded content lays out. All three land
  8-20px under the sticky filter header even when the panel starts scrolled to the
  bottom. NOTE: the previous check passed vacuously — it only asked for "somewhere
  below the header", which is true without any scrolling at all.
- CAMERA PiP frame drops to z-index 2: it is a viewport overlay, not chrome, so
  panels and HUD now cover it (verified pip z 2 vs panel z 10).

Verification: camera-preview-control +2 (look-at round trip), panel-deeplinks +3
(tight landing band from a bottom-scrolled panel), context-menu-redesign +3
(height persisted, restored on reopen, per-kind keys), number-fields +4 (scrub
reports itself, no focus, no selection, click still focuses), camera-pip +2
(layer below the panel). camera-objects, gizmo-orbit-leak, grid-snapping,
node-search, inspector, object-properties green — 11 suites in one run. Build
green, svelte-check 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e2e-verify gains an ASSERTION DISCIPLINE section, because the expensive failures in
this roadmap were not broken code but checks that could not fail:
- position/layout asserts need a tight band AND a start state that forces the
  behaviour (the deep-link check passed while nothing scrolled, because most
  sections were collapsed and the label was trivially "below the header");
- isolate a regression with an A/B of the same gesture, and assert the gesture did
  its job, so a no-op cannot read as a pass;
- match the metric to the gesture (OrbitControls: LEFT rotates, RIGHT pans - a
  quaternion check on a right-drag reads 0.0000 forever);
- when a check says pass and the user says fail, re-read the check first.

Also in e2e-verify: HMR churn makes runs LIE (a page loaded while vite
re-transforms edited modules is half-mounted - three runs "proved" a working
feature dead); component-side debug hooks as the cure (__outlineDebug,
__cameraPreviewDebug, cameraHelpersDebug, pipDebug); the real-mouse GIZMO drag
recipe (find the picker via getHelper().traverse + project, never guess pixels, and
remember three keeps the gizmo visuals in that helper); panels scroll so
scrollIntoViewIfNeeded before boundingBox; section headers are buttons with a -/+
glyph so match with startsWith; the new numeric-field (.dn-wrap/.dn-input) and
context-menu (.ctx-filter-input/.ctx-match/.ctx-grip/[data-ctx-active]) selectors;
the new __stores keys; and node-search is OFF the known-failing list (two of its
assertions were asserting the old no-scroll contract).

peer-feature gains "the cheapest replicated feature: put it on userData" - the
full recipe behind userData.camera/physics/particles (deterministic defaults at
creation, ONE write path with props history + objectParameters + poke, the applier
case that a two-peer suite must cover, scene-root visuals), plus PRESENCE-style
state (campreview: one message on change, per-peer map, cleanup in BOTH teardown
paths, late joiners piggyback an existing handshake reply) and the lock-RELEASE
trap (a `lock` message only replaces the sender's set, so letting go needs explicit
`unlock` per uuid).

CLAUDE.md: the Configure-Scene deep-link seam (openSceneSection/inspectorScrollTo/
data-anchor and why smooth scrolling and class-name scroller lookups fail), the
remembered per-kind menu search height (sizeKey), revealFilter rows, and the PiP
frame's deliberate z-index 2 (a viewport overlay, so chrome covers it).

svelte-check 419/62 unchanged (docs only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
[feat] roadmap #16: menus, grid & scene cameras (P1-P6)
@AlexZ005
AlexZ005 merged commit e8d038a into fix/roadmap15-lmno Aug 5, 2026
@AlexZ005
AlexZ005 deleted the fix/roadmap15-q-menu branch August 5, 2026 20:27
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