From 0854c3b80a52c72c02e074f1f2ea8fc4672378d6 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 27 Aug 2026 14:17:13 +0300 Subject: [PATCH 01/21] [feat] controls ride above the bottom dock - 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 --- src/components/Flow.svelte | 2 +- src/components/editors/Explorer.svelte | 2 +- src/components/editors/ShaderEditor.svelte | 3 -- src/components/menu/CameraPipWindow.svelte | 3 +- src/components/menu/ColocationBadge.svelte | 3 +- src/components/menu/Controls.svelte | 24 +++++++++-- src/components/menu/Inspector.svelte | 6 ++- src/components/menu/NotesDrawer.svelte | 5 ++- src/lib/cameraPip.js | 7 +++- src/styles/ui.css | 11 ------ tests/e2e/dock-inset.test.cjs | 46 ++++++++++++++++++++++ 11 files changed, 86 insertions(+), 26 deletions(-) diff --git a/src/components/Flow.svelte b/src/components/Flow.svelte index 7577ab89..d15782d9 100644 --- a/src/components/Flow.svelte +++ b/src/components/Flow.svelte @@ -173,7 +173,7 @@ title="Undock into a floating window" onclick={() => setDocked(false)}>⧉ -
+
diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 0d0a7e01..c671451f 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -4581,7 +4581,7 @@ onclick={() => setDocked(false)}>⧉
-
+
{@render content()}
diff --git a/src/components/editors/ShaderEditor.svelte b/src/components/editors/ShaderEditor.svelte index 753d1670..7b2c3172 100644 --- a/src/components/editors/ShaderEditor.svelte +++ b/src/components/editors/ShaderEditor.svelte @@ -664,9 +664,6 @@ flex: 1; min-height: 0; display: flex; - /* docked panels shrink by the Controls HUD footprint on folded screens, so the - canvas is never hidden behind it (the --dock-inset contract) */ - padding-bottom: var(--dock-inset, 0px); } .shader-side { flex: 0 0 148px; diff --git a/src/components/menu/CameraPipWindow.svelte b/src/components/menu/CameraPipWindow.svelte index 23d983f4..d9835b3a 100644 --- a/src/components/menu/CameraPipWindow.svelte +++ b/src/components/menu/CameraPipWindow.svelte @@ -19,6 +19,7 @@ autoPosition, clampPosition } from '$lib/cameraPip'; + import { bottomInset } from '$lib/bottomDock'; const object = $derived($pipTarget ? ($objectsGroup?.getObjectByProperty('uuid', $pipTarget) ?? null) : null); const size = $derived(object ? pipSize(object) : { w: 0, h: 0 }); @@ -39,7 +40,7 @@ const position = $derived( $pipPosition ? clampPosition($pipPosition, size, { width: vw, height: vh }) - : autoPosition(size, { width: vw, height: vh }, panelWidth) + : autoPosition(size, { width: vw, height: vh }, panelWidth, $bottomInset) ); // publish the rect the renderer draws into (null while hidden) diff --git a/src/components/menu/ColocationBadge.svelte b/src/components/menu/ColocationBadge.svelte index f3e93b95..d23e8a0b 100644 --- a/src/components/menu/ColocationBadge.svelte +++ b/src/components/menu/ColocationBadge.svelte @@ -21,7 +21,8 @@ .colocation-badge { position: fixed; left: 12px; - bottom: 12px; + /* rides above the bottom dock with the rest of the bottom chrome */ + bottom: calc(var(--bottom-inset, 0px) + 12px); z-index: var(--z-hud, 45); display: flex; align-items: center; diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index 81b6de4a..d5096d87 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -634,11 +634,21 @@ }); +

setTransformMode('translate')}>

{ checkPlay(); }} diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index d1632db4..0afa1325 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -224,11 +224,15 @@ // side drawers live on the --z-drawer tier (68); chat floats on its own now. // bottom rises above the docked Flow/Explorer height (105) AND the Controls pill/ // HUD footprint on narrow screens (--controls-inset) so neither covers the drawer. + // The two are SUMMED, not max()'d: the Controls pill no longer sits on the viewport + // bottom — it rides ABOVE the dock, occupying the band [bottom-inset .. +66px] — so + // the taller of the two no longer clears both. The sum is identical to the old + // max() whenever either term is 0, which is every case that existed before. // z sits just above the bottom HUD buttons (mic/chat/+ are at --z-drawer=30) so the // settings drawer is never covered by the mic on the bottom-right, but stays BELOW // the dock (--z-bottom=35) and floating windows. const drawerStyle = - 'bottom: max(var(--bottom-inset, 0px), var(--controls-inset, 0px)); z-index: calc(var(--z-bottom) - 1); height: auto'; + 'bottom: calc(var(--bottom-inset, 0px) + var(--controls-inset, 0px)); z-index: calc(var(--z-bottom) - 1); height: auto'; // Round the drawer's bottom-LEFT corner when it floats ABOVE the bottom (a docked // Flow/Explorer, or the narrow Controls inset, leave a gap below it). When it sits diff --git a/src/components/menu/NotesDrawer.svelte b/src/components/menu/NotesDrawer.svelte index 73d3c0d3..7d2a6b82 100644 --- a/src/components/menu/NotesDrawer.svelte +++ b/src/components/menu/NotesDrawer.svelte @@ -259,7 +259,10 @@ right: 0; /* default (Connect centred / not docked): below the profile icon, under the chrome */ top: 64px; - bottom: max(var(--bottom-inset, 0px), var(--controls-inset, 0px)); + /* SUM, not max(): the Controls pill rides ABOVE the dock now (the band + [bottom-inset .. +66px]), so clearing the taller of the two no longer clears + both. Identical to the old max() whenever either term is 0. */ + bottom: calc(var(--bottom-inset, 0px) + var(--controls-inset, 0px)); width: min(320px, 92vw); z-index: calc(var(--z-bottom) - 1); border-radius: 0.5rem 0 0 0.5rem; diff --git a/src/lib/cameraPip.js b/src/lib/cameraPip.js index 28c4a0c9..60718931 100644 --- a/src/lib/cameraPip.js +++ b/src/lib/cameraPip.js @@ -65,14 +65,17 @@ export function pipSize(object) { * @param {{w: number, h: number}} size * @param {{width: number, height: number}} viewport * @param {number} [panelWidth] width of an open right-side panel (0 = none) + * @param {number} [bottomClearance] height of the open bottom dock (0 = none), so the + * parked window sits ABOVE it — the Controls pill rides above the dock now, and a + * window parked on the viewport bottom would be underneath both. */ -export function autoPosition(size, viewport, panelWidth = 0) { +export function autoPosition(size, viewport, panelWidth = 0, bottomClearance = 0) { // the right edge keeps clear of the round HUD buttons (mic / chat) that live // there, so the parked window never sits under them const right = panelWidth ? MARGIN : HUD_CLEARANCE; return { x: Math.max(MARGIN, viewport.width - size.w - right - panelWidth), - y: Math.max(MARGIN, viewport.height - size.h - MARGIN) + y: Math.max(MARGIN, viewport.height - size.h - MARGIN - bottomClearance) }; } diff --git a/src/styles/ui.css b/src/styles/ui.css index 09e08dc3..555e390b 100644 --- a/src/styles/ui.css +++ b/src/styles/ui.css @@ -96,10 +96,6 @@ input.tp-check:disabled { /* how much of the bottom the Controls pill + HUD buttons occupy; side drawers inset ABOVE it on narrow screens (same idea as --bottom-inset for the dock). */ --controls-inset: 0px; - /* inset for DOCKED Flow/Explorer content — only on a truly folded screen, where the - Controls actually reach the node palette / folder tree. Above ~500px there's room, - so it stays 0 (no wasted strip). */ - --dock-inset: 0px; } /* The old flowbite z-50/z-40 → --z-modal remap is GONE: flowbite-svelte 1.x renders @@ -227,13 +223,6 @@ input.tp-check:disabled { --controls-inset: 68px; } } -/* folded screens only: the docked Flow/Explorer content insets above the Controls (which - now overlap the palette/tree). Above this width there's room, so no inset. */ -@media (max-width: 500px) { - :root { - --dock-inset: var(--controls-inset, 0px); - } -} /* Touch / limited-width: hide the "undock into a floating window" buttons (Flow, Explorer, Flow Code, Animation) UNLESS the user opted in (Settings > Allow undocking, which adds the .allow-undock root class). Floating windows have no room on a phone. */ diff --git a/tests/e2e/dock-inset.test.cjs b/tests/e2e/dock-inset.test.cjs index 5525d260..c9197c65 100644 --- a/tests/e2e/dock-inset.test.cjs +++ b/tests/e2e/dock-inset.test.cjs @@ -8,6 +8,26 @@ const inset = (page) => parseInt(getComputedStyle(document.documentElement).getPropertyValue('--bottom-inset') || '0') ); +// Phase 1 (controls dock rework): where the bottom chrome sits. The Controls pill and +// the play FAB anchor on --bottom-inset now, so they RIDE ABOVE the dock instead of +// covering its last ~60px. (The old --dock-inset model padded the DOCK's content and +// only did so at <=500px, so every wider screen had the pill over the node palette.) +const bottomChrome = (page) => + page.evaluate(() => { + const fab = document.getElementById('play-button')?.getBoundingClientRect(); + // the pill is flowbite's BottomNav OUTER div — the rounded bar carrying z-45 + const pill = document.querySelector('div.rounded-full.z-45')?.getBoundingClientRect(); + const dock = document.querySelector('#flow-list')?.getBoundingClientRect(); + return { + fabBottom: fab ? Math.round(fab.bottom) : 0, + pillBottom: pill ? Math.round(pill.bottom) : 0, + dockTop: dock ? Math.round(dock.top) : 0, + hasFab: !!fab, + hasPill: !!pill, + vh: window.innerHeight + }; + }); + h.run(async () => { const browser = await h.launch(); const A = await h.setupPage(browser, 'A'); @@ -20,6 +40,19 @@ h.run(async () => { const flowH = (await A.page.locator('#flow-list').boundingBox()).height; h.check(Math.abs((await inset(A.page)) - flowH) < 2, `inset follows the dock height (${flowH})`); + // ...and with the dock open, the bottom chrome sits ABOVE it (measured here, before + // the sidebar opens — an open sidebar shields the lower-left corner) + const open = await bottomChrome(A.page); + h.check(open.hasFab && open.hasPill && open.dockTop > 0, 'pill, FAB and dock are all measurable'); + h.check( + open.fabBottom <= open.dockTop + 2, + `play FAB rides above the dock (${open.fabBottom} <= ${open.dockTop})` + ); + h.check( + open.pillBottom <= open.dockTop + 2, + `Controls pill rides above the dock (${open.pillBottom} <= ${open.dockTop})` + ); + // 203: the sidebar now FLOATS ON TOP of the dock (z-hud) instead of ending // above it — it's a compact panel that is never covered by the dock await A.page.locator('#logo-menu').click(); @@ -81,6 +114,19 @@ h.run(async () => { await A.page.waitForTimeout(500); h.check((await inset(A.page)) === 0, 'inset returns to 0 when the dock closes'); + // ...and the bottom chrome drops back to the viewport bottom (FAB bottom:10px, + // pill bottom:16px — the +2 slack absorbs sub-pixel rounding) + await A.page.waitForTimeout(400); // the 200ms bottom transition, settled + const shut = await bottomChrome(A.page); + h.check( + shut.vh - shut.fabBottom <= 12, + `play FAB returns to the viewport bottom (${shut.vh - shut.fabBottom}px clear)` + ); + h.check( + shut.vh - shut.pillBottom <= 18, + `Controls pill returns to the viewport bottom (${shut.vh - shut.pillBottom}px clear)` + ); + // palette tabs mirror when the palette moves right await A.page.locator('p[title="Node editor (N)"]').click(); await A.page.waitForTimeout(600); From cb3c059e3f8d893a34a8328f624bd3a8dd83ee53 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 27 Aug 2026 14:48:38 +0300 Subject: [PATCH 02/21] [feat] one decision tree for the panel buttons and their keys - 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 --- src/components/Flow.svelte | 2 +- src/components/editors/Explorer.svelte | 2 +- src/components/menu/Controls.svelte | 87 ++--------- src/lib/panelToggles.js | 165 ++++++++++++++++++++ src/lib/shortcuts.js | 12 +- src/lib/windowFocus.js | 36 +++++ src/lib/windowTabs.js | 4 +- tests/e2e/flow-explorer-floating.test.cjs | 83 ++++++++-- tests/e2e/panel-toggle-keys.test.cjs | 180 ++++++++++++++++++++++ 9 files changed, 469 insertions(+), 102 deletions(-) create mode 100644 src/lib/panelToggles.js create mode 100644 tests/e2e/panel-toggle-keys.test.cjs diff --git a/src/components/Flow.svelte b/src/components/Flow.svelte index d15782d9..cabc818d 100644 --- a/src/components/Flow.svelte +++ b/src/components/Flow.svelte @@ -184,7 +184,7 @@ id="flow-window" class="ui-panel fixed flex flex-col overflow-hidden" use:dragWindow={{ key: 'flowWin', defaultRect: { left: 120, top: 90 } }} - use:focusStack + use:focusStack={'flow'} use:tabbable={{ key: 'flow', title: 'Node editor', openStore: flowGraphClose, isOpen: (v) => !v, close: () => flowGraphClose.set(true) }} use:dockable={{ key: 'flow' }} style="z-index: var(--z-window)" diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index c671451f..9b5a599d 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -4590,7 +4590,7 @@ id="explorer-window" class="ui-panel fixed flex flex-col overflow-hidden" use:dragWindow={{ key: 'explorerWin', defaultRect: { left: 160, top: 120 } }} - use:focusStack + use:focusStack={'explorer'} use:tabbable={{ key: 'explorer', title: 'Explorer', openStore: explorerClose, isOpen: (v) => !v, close: () => explorerClose.set(true) }} use:dockable={{ key: 'explorer' }} style="z-index: var(--z-window)" diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index d5096d87..10a8f803 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -2,7 +2,7 @@ import { Cog, Eye, FolderOpen, List, Maximize2, MessageSquare, Move, Pin, Play, RectangleGoggles, RotateCcw, SquarePen, Sun, Workflow } from '@lucide/svelte'; import { BottomNav, Listgroup } from 'flowbite-svelte'; import { objectsGroup, TControls, transformMode, isLocked, isVRMode, lockedObjects, globalScene, vrPassthrough, vrOverride, selectedObject, selectedObjects } from '../../stores/sceneStore'; - import { chatHidden, flowGraphClose, flowCodeClose, animationClose, uvEditorClose, explorerClose, objectListClose, objectContextMenu, renamingObject, advancedMode, showEnvInList, showLocalObjects, shaderEditorClose, hudEditorClose } from '../../stores/appStore.js'; + import { chatHidden, flowGraphClose, explorerClose, objectListClose, objectContextMenu, renamingObject, advancedMode, showEnvInList, showLocalObjects } from '../../stores/appStore.js'; import { systemGroupNames } from '$lib/moduleSDK'; import { ENV_ROOT } from '$lib/environment'; import { flyTo } from '$lib/objectActions'; @@ -16,7 +16,7 @@ import { sendPing } from '$lib/ping'; import { buildObjectMenuItems } from '$lib/objectMenu'; import * as THREE from 'three'; - import { setContext, tick } from 'svelte'; + import { setContext } from 'svelte'; import { writable } from 'svelte/store'; import { shareObject } from '$lib/objectPermissions'; import Objects from './Objects.svelte'; @@ -25,11 +25,12 @@ import MobileAddButton from './MobileAddButton.svelte'; import AiHudButton from './AiHudButton.svelte'; import SimControls from './SimControls.svelte'; - import { focusStack, raiseWindow, isTopWindow } from '$lib/windowFocus'; + import { focusStack } from '$lib/windowFocus'; import { tabbable, groupRectOf, moveGroupOf, resizeGroup } from '$lib/windowTabs'; import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { dockable } from '$lib/docking'; - import { visibleDockKey, bottomDockActive, activateDock, dockOccupants, FLOW_FAMILY } from '$lib/bottomDock'; + import { visibleDockKey, dockOccupants, FLOW_FAMILY } from '$lib/bottomDock'; + import { togglePanel } from '$lib/panelToggles'; import { VRButton, XRButton } from '@threlte/xr' // A panel is "shown" when it is open AND either the visible dock tab OR floating @@ -44,80 +45,12 @@ // touches the docked Flow Code / Animation group. // The icon is lit when a docked flow tab is the visible dock panel OR the Node editor // floating window is shown. + // WHAT a click DOES now lives in $lib/panelToggles (`togglePanel`), one decision tree + // shared with the O / N keyboard shortcuts; only the icon TINT is decided here. const flowDockVisible = $derived(FLOW_FAMILY.includes($visibleDockKey ?? '')); const flowFloatingShown = $derived(!$flowGraphClose && !$dockOccupants.flow?.present); const flowShown = $derived(flowDockVisible || flowFloatingShown); const explorerShown = $derived(!$explorerClose && ($visibleDockKey === 'explorer' || !$dockOccupants.explorer?.present)); - // remembers which flow-family views were open when the docked group was hidden - let flowDockSnapshot: any = null; - function toggleFlow() { - const open = !$flowGraphClose; - const docked = !!$dockOccupants.flow?.present; // Node editor docked AND open - if (open && !docked) { - flowGraphClose.set(true); // FLOATING Node editor is shown -> hide only its window - return; - } - if (docked) { - if (flowDockVisible) { - // docked group is on screen -> hide only the tabs that are actually DOCKED - // (leave undocked/floating Flow Code / Animation windows open) - flowDockSnapshot = { - flow: true, - flowcode: !!$dockOccupants.flowcode?.present, - animation: !!$dockOccupants.animation?.present, - uv: !!$dockOccupants.uv?.present, - shader: !!$dockOccupants.shader?.present, - // A4: without this line the HUD tab never comes back after play mode - hud: !!$dockOccupants.hud?.present - }; - flowGraphClose.set(true); - if (flowDockSnapshot.flowcode) flowCodeClose.set(true); - if (flowDockSnapshot.animation) animationClose.set(true); - if (flowDockSnapshot.uv) uvEditorClose.set(true); - if (flowDockSnapshot.shader) shaderEditorClose.set(true); - if (flowDockSnapshot.hud) hudEditorClose.set(true); - } else { - activateDock('flow'); // docked but hidden (Explorer covering) -> bring the dock back - } - return; - } - // Node editor is CLOSED -> show it in its last mode - const wasDocked = typeof localStorage === 'undefined' || localStorage.getItem('flowDocked') !== 'false'; - const snap = flowDockSnapshot; - if (snap && (snap.flow || snap.flowcode || snap.animation || snap.uv || snap.shader || snap.hud)) { - if (snap.flow) flowGraphClose.set(false); - if (snap.flowcode) flowCodeClose.set(false); - if (snap.animation) animationClose.set(false); - if (snap.uv) uvEditorClose.set(false); - if (snap.shader) shaderEditorClose.set(false); - if (snap.hud) hudEditorClose.set(false); - flowDockSnapshot = null; - activateDock('flow'); - } else { - flowGraphClose.set(false); - if (wasDocked) activateDock('flow'); // docked -> show as the dock tab; floating -> leave the dock alone - } - } - function toggleExplorer() { - if (explorerShown) explorerClose.set(true); // shown (docked or floating) -> hide - else { - explorerClose.set(false); // hidden -> show it in its last mode - bottomDockActive.set('explorer'); // if docked, make it the visible panel - } - } - // Object List is a pure floating window. Clicking its button RAISES it to the front - // (bring-to-front, as the user "called" it); clicking again while it is already at - // the front closes it. Opening a closed one raises it too. - function toggleObjectList() { - if ($objectListClose) { - objectListClose.set(false); - tick().then(() => raiseWindow('objects')); - } else if (isTopWindow('objects')) { - objectListClose.set(true); - } else { - raiseWindow('objects'); - } - } // CO4b: WHAT the play button is about to do, so it can show it. The condition // mirrors `checkPlay`'s own test below — a supported immersive session AND no @@ -679,14 +612,14 @@

togglePanel('objects')} >

togglePanel('flow')} >

@@ -694,7 +627,7 @@ class={classActive + ' rounded-r-full'} id="explorer-slot" title="Explorer" - on:click={toggleExplorer} + on:click={() => togglePanel('explorer')} >
+
@@ -4581,7 +4590,7 @@ onclick={() => setDocked(false)}>⧉
-
+
{@render content()}
diff --git a/src/lib/bottomDock.js b/src/lib/bottomDock.js index 9248a41b..29ee0930 100644 --- a/src/lib/bottomDock.js +++ b/src/lib/bottomDock.js @@ -1,15 +1,20 @@ -import { writable, derived, get } from 'svelte/store'; -import { explorerClose } from '../stores/appStore'; +import { writable, derived } from 'svelte/store'; -// Bottom dock (roadmap #9 tail rework): the dock shows exactly ONE panel at a time. -// The Flow-family — Node editor (flow) / Flow Code (flowcode) / Animation (animation) -// — are notebook TABS in the dock (DockTabs.svelte); the Explorer is a SEPARATE panel -// that is MUTUALLY EXCLUSIVE with them: activating any Flow tab closes the Explorer, -// and the Explorer itself shows no tabs. Each panel reports present(docked+open)+height -// via setDockOccupant; only the visible one renders (the rest hide). The visible -// panel's height publishes as --bottom-inset so drawers/edge-docked windows sit above it. +// Bottom dock: the dock shows exactly ONE panel at a time, and every panel that is +// docked+open is a notebook TAB in it — the Flow family (Node editor / Flow Code / +// Animation / UV editor / Shader editor / HUD editor) AND the Explorer alike, one +// strip rendered by whichever panel is showing (DockTabs.svelte). Nothing here +// force-closes anything: switching tabs changes only WHICH panel renders, so an +// Explorer covered by the Node editor stays open as a hidden tab (it used to be +// closed outright — the dock's two systems collapsed into one in the controls +// rework). Each panel reports present(docked+open)+height via setDockOccupant; only +// the visible one renders (the rest hide) and its height publishes as --bottom-inset +// so drawers/edge-docked windows sit above it. +// This module imports NO app stores — it is dock bookkeeping and nothing else. export const FLOW_FAMILY = ['flow', 'flowcode', 'animation', 'uv', 'shader', 'hud']; +/** every panel that can be a dock tab, in strip order (Node editor first) */ +export const DOCK_FAMILY = [...FLOW_FAMILY, 'explorer']; /** @type {Record} */ export const DOCK_TITLES = { flow: 'Node editor', flowcode: 'Flow Code', animation: 'Animation', uv: 'UV editor', shader: 'Shader editor', hud: 'HUD editor', explorer: 'Explorer' }; @@ -28,7 +33,7 @@ function clampH(h) { const max = typeof window !== 'undefined' ? Math.round(window.innerHeight * 0.8) : 800; return Math.min(Math.max(h || 320, 160), max); } -/** shared height of the Flow-family dock (the Explorer keeps its own height) */ +/** shared height of the dock — ONE value for every tab, Explorer included */ export const dockHeight = writable(clampH(parseInt(ls?.getItem('flowDockHeight') ?? '320'))); dockHeight.subscribe((value) => { try { @@ -50,11 +55,17 @@ export function setDockOccupant(key, present, height = 0) { }); } -/** the Flow-family panels currently open+docked, as tabs (Node editor first) */ +/** the Flow-family panels currently open+docked (the Node editor button owns this + * group, and the flow-dock suites read it — it is NOT the tab strip) */ export const flowTabs = derived(dockOccupants, ($o) => FLOW_FAMILY.filter((k) => $o[k]?.present).map((k) => ({ key: k, title: DOCK_TITLES[k] })) ); +/** every panel currently open+docked, as the dock's tabs (what DockTabs renders) */ +export const dockTabs = derived(dockOccupants, ($o) => + DOCK_FAMILY.filter((k) => $o[k]?.present).map((k) => ({ key: k, title: DOCK_TITLES[k] })) +); + /** the single panel that is actually VISIBLE in the dock (null if the dock is empty) */ export const visibleDockKey = derived([dockOccupants, bottomDockActive], ([$o, $a]) => { if ($o[$a]?.present) return $a; @@ -69,24 +80,15 @@ export const bottomInset = derived([dockOccupants, visibleDockKey], ([$o, $key]) ); /** - * Make `key` the visible dock panel. The Flow-family and the Explorer are mutually - * exclusive ONLY in the dock — the actual closing of the Explorer happens reactively - * (see below) when a Flow-family panel becomes the VISIBLE dock panel, so a FLOATING - * Node editor / Flow Code never closes a docked Explorer. + * Make `key` the visible dock panel. Purely a selection: the tab that was showing + * stays open and simply stops rendering, so no panel is ever closed by another one + * arriving. * @param {string} key */ export function activateDock(key) { bottomDockActive.set(key); } -// Exclusivity: the dock has ONE slot. Close the Explorer only when a DOCKED Flow-family -// panel actually becomes the visible dock panel — a floating Node editor never makes a -// Flow-family key the visible key, so a docked Explorer is left alone (they collide only -// when BOTH are docked). -visibleDockKey.subscribe((key) => { - if (key && FLOW_FAMILY.includes(key) && get(dockOccupants).explorer?.present) explorerClose.set(true); -}); - // publish the visible dock height as a CSS var so drawers/docked windows adjust (105) if (typeof document !== 'undefined') { bottomInset.subscribe((inset) => { diff --git a/src/lib/dockMenu.js b/src/lib/dockMenu.js new file mode 100644 index 00000000..0d5f7738 --- /dev/null +++ b/src/lib/dockMenu.js @@ -0,0 +1,28 @@ +import { + flowCodeClose, + animationClose, + uvEditorClose, + shaderEditorClose, + hudEditorClose, + explorerClose +} from '../stores/appStore'; +import { activateDock } from './bottomDock'; + +// The dock's "+" add-a-view menu, in ONE place. The docked tab strip +// (DockTabs.svelte) and the FLOATING Node editor's header "+" (Flow.svelte) each +// kept their own copy of the same list, so a view added to one silently went +// missing from the other. Every entry opens its panel — they all start docked — +// and makes it the visible tab; the Explorer is one of them now that it is an +// ordinary dock tab rather than the dock's separate occupant. + +/** @returns {{label: string, tooltip: string, action: () => void}[]} */ +export function dockAddItems() { + return [ + { label: '+ Flow Code', tooltip: 'Edit the graph as JSON', action: () => { flowCodeClose.set(false); activateDock('flowcode'); } }, + { label: '+ Animation', tooltip: 'Animate the selected object', action: () => { animationClose.set(false); activateDock('animation'); } }, + { label: '+ UV editor', tooltip: 'Edit the selected mesh’s UV map and textures', action: () => { uvEditorClose.set(false); activateDock('uv'); } }, + { label: '+ Shader editor', tooltip: 'Drive this material from a node graph', action: () => { shaderEditorClose.set(false); activateDock('shader'); } }, + { label: '+ HUD editor', tooltip: 'Lay out the on-screen HUD its nodes drive', action: () => { hudEditorClose.set(false); activateDock('hud'); } }, + { label: '+ Explorer', tooltip: 'Browse the asset library', action: () => { explorerClose.set(false); activateDock('explorer'); } } + ]; +} diff --git a/tests/e2e/dock-float-exclusivity.test.cjs b/tests/e2e/dock-float-exclusivity.test.cjs index 506cc328..d4882d89 100644 --- a/tests/e2e/dock-float-exclusivity.test.cjs +++ b/tests/e2e/dock-float-exclusivity.test.cjs @@ -1,7 +1,9 @@ -// Dock exclusivity refinement: the Explorer and the Flow-family share ONE dock slot, -// so activating a DOCKED Node editor closes the Explorer. But when the Node editor is -// FLOATING (undocked) it does not compete for the dock, so clicking it must NOT close -// a docked Explorer. Closing only happens when BOTH are docked. +// Docked vs floating, phase 3: the dock has ONE slot but no exclusivity — panels +// coexist as tabs. A FLOATING Node editor does not compete for the dock at all, so +// clicking it leaves a docked Explorer alone; and DOCKING it does not close the +// Explorer either any more (it just becomes the visible tab, with the Explorer one +// tab over). This suite pins both halves, since the second one used to be the +// force-close. const h = require('./helpers.cjs'); h.run(async () => { @@ -44,7 +46,7 @@ h.run(async () => { h.check(before.flowOn && before.explOn, 'the floating Node editor AND the docked Explorer icons are both highlighted'); // click Node editor -> the floating flow HIDES (show/hide), the docked Explorer is - // left alone (the key fix: a floating flow never closes the Explorer) + // left alone (a floating flow never touches the dock) await A.page.evaluate(() => document.querySelector('p[title="Node editor (N)"]').click()); await A.page.waitForTimeout(300); const afterClick = await A.page.evaluate(() => { @@ -57,19 +59,47 @@ h.run(async () => { h.check(afterClick.explorerClosed === false, 'clicking Node editor with a FLOATING flow does NOT close the docked Explorer'); h.check(afterClick.flowClosed === true, 'clicking a shown floating Node editor hides it (show/hide)'); - // contrast: re-open the Node editor and DOCK it -> now both compete, Explorer closes + // re-open the Node editor and DOCK it -> both are docked now, and they COEXIST as + // tabs: the flow tab shows, the Explorer stays open one tab over await A.page.evaluate(() => window.__stores.flowGraphClose.set(false)); await A.page.waitForTimeout(250); await A.page.evaluate(() => document.getElementById('flow-dock')?.click()); await A.page.waitForTimeout(500); const afterDock = await A.page.evaluate(() => { const s = window.__stores; - let ec, k; + let ec, k, occ; s.explorerClose.subscribe((v) => (ec = v))(); s.bottomDock.visibleDockKey.subscribe((v) => (k = v))(); - return { explorerClosed: ec, visible: k }; + s.bottomDock.dockOccupants.subscribe((v) => (occ = v))(); + const box = [...document.querySelectorAll('#flow-list, #explorer-list')].find((el) => !el.classList.contains('hidden')); + return { + explorerClosed: ec, + visible: k, + present: Object.keys(occ).filter((x) => occ[x]?.present).sort(), + strip: box ? [...box.querySelectorAll('.tab-note')].map((b) => b.textContent.trim()).filter(Boolean) : [] + }; + }); + h.check(afterDock.visible === 'flow', `docking the Node editor makes it the visible dock tab (visible=${afterDock.visible})`); + h.check(afterDock.explorerClosed === false, 'docking the Node editor does NOT close the Explorer any more'); + h.check( + afterDock.present.join(',') === 'explorer,flow' && afterDock.strip.includes('Explorer'), + `both are dock tabs and the strip says so (${afterDock.strip.join('|')})` + ); + + // and the Explorer tab brings it straight back, with the flow still open + await A.page.evaluate(() => { + const box = [...document.querySelectorAll('#flow-list, #explorer-list')].find((el) => !el.classList.contains('hidden')); + [...box.querySelectorAll('.tab-note')].find((b) => b.textContent.trim() === 'Explorer').click(); + }); + await A.page.waitForTimeout(300); + const back = await A.page.evaluate(() => { + const s = window.__stores; + let k, fc; + s.bottomDock.visibleDockKey.subscribe((v) => (k = v))(); + s.flowGraphClose.subscribe((v) => (fc = v))(); + return { visible: k, flowClosed: fc }; }); - h.check(afterDock.explorerClosed === true && afterDock.visible === 'flow', `docking the Node editor (both docked) closes the Explorer (visible=${afterDock.visible})`); + h.check(back.visible === 'explorer' && back.flowClosed === false, `the Explorer tab shows it again, Node editor still open (visible=${back.visible})`); await h.finish(browser); }); diff --git a/tests/e2e/dock-tabs-coexist.test.cjs b/tests/e2e/dock-tabs-coexist.test.cjs new file mode 100644 index 00000000..9287d223 --- /dev/null +++ b/tests/e2e/dock-tabs-coexist.test.cjs @@ -0,0 +1,116 @@ +// Phase 3: the Explorer is an ordinary bottom-dock TAB. One dock, seven possible +// tabs, no exclusivity — the strip lists everything docked+open, switching tabs +// closes nothing, and there is ONE height for the whole dock (the Explorer's private +// 'explorerHeight' pref migrates into it on first load and the key is dropped). +const h = require('./helpers.cjs'); + +const snap = (page) => + page.evaluate(() => { + const s = window.__stores; + let tabs, occ, visible, fc, ec, dh; + s.bottomDock.dockTabs.subscribe((v) => (tabs = v))(); + s.bottomDock.dockOccupants.subscribe((v) => (occ = v))(); + s.bottomDock.visibleDockKey.subscribe((v) => (visible = v))(); + s.bottomDock.dockHeight.subscribe((v) => (dh = v))(); + s.flowGraphClose.subscribe((v) => (fc = v))(); + s.explorerClose.subscribe((v) => (ec = v))(); + const box = [...document.querySelectorAll('#flow-list, #explorer-list')].find( + (el) => !el.classList.contains('hidden') + ); + return { + tabs: tabs.map((t) => t.key), + strip: box ? [...box.querySelectorAll('.tab-note')].map((b) => b.textContent.trim()).filter(Boolean) : [], + shown: box ? box.id : null, + shownH: box ? Math.round(box.getBoundingClientRect().height) : 0, + present: Object.keys(occ).filter((k) => occ[k]?.present).sort(), + visible, + flowClosed: fc, + explClosed: ec, + dockHeight: dh, + inset: getComputedStyle(document.documentElement).getPropertyValue('--bottom-inset').trim() + }; + }); + +const clickTab = (page, title) => + page.evaluate((t) => { + const box = [...document.querySelectorAll('#flow-list, #explorer-list')].find((el) => !el.classList.contains('hidden')); + const btn = [...box.querySelectorAll('.tab-note')].find((b) => b.textContent.trim() === t); + btn.click(); + }, title); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + + await A.page.evaluate(() => { + localStorage.setItem('flowDocked', 'true'); + localStorage.setItem('explorerDocked', 'true'); + localStorage.removeItem('explorerHeight'); + }); + await A.page.reload({ waitUntil: 'domcontentloaded' }); + await A.page.waitForFunction(() => window.__stores && !!window.__stores.bottomDock, { timeout: 30000 }); + await A.page.evaluate(() => { + const s = window.__stores; + s.flowGraphClose.set(false); + s.explorerClose.set(false); + s.bottomDock.activateDock('flow'); + }); + await A.page.waitForTimeout(600); + + // --- 1. one strip, both tabs --- + let s = await snap(A.page); + h.check(s.tabs.join(',') === 'flow,explorer', `1.1 dockTabs lists the Node editor AND the Explorer (${s.tabs.join(',')})`); + h.check(s.strip.includes('Node editor') && s.strip.includes('Explorer'), `1.2 the rendered strip shows both (${s.strip.join('|')})`); + h.check(s.shown === 'flow-list' && s.visible === 'flow', `1.3 the Node editor is the one on screen (shown=${s.shown})`); + // flowTabs stays flow-family-only: it is what the Node editor BUTTON owns + const ft = await A.page.evaluate(() => { + let t; + window.__stores.bottomDock.flowTabs.subscribe((v) => (t = v))(); + return t.map((x) => x.key); + }); + h.check(ft.join(',') === 'flow', `1.4 flowTabs stays the flow FAMILY only (${ft.join(',') || 'none'})`); + + // --- 2. switching tabs closes neither --- + await clickTab(A.page, 'Explorer'); + await A.page.waitForTimeout(350); + s = await snap(A.page); + h.check(s.visible === 'explorer' && s.shown === 'explorer-list', `2.1 the Explorer tab shows the Explorer (shown=${s.shown})`); + h.check(s.flowClosed === false && s.explClosed === false, '2.2 neither panel was closed by the switch'); + h.check(s.present.join(',') === 'explorer,flow', `2.3 both are still dock occupants (${s.present.join(',')})`); + + await clickTab(A.page, 'Node editor'); + await A.page.waitForTimeout(350); + s = await snap(A.page); + h.check(s.visible === 'flow' && s.flowClosed === false && s.explClosed === false, '2.4 switching back is just as harmless'); + + // --- 3. ONE height for the whole dock --- + await clickTab(A.page, 'Explorer'); + await A.page.waitForTimeout(300); + await A.page.evaluate(() => window.__stores.bottomDock.dockHeight.set(430)); + await A.page.waitForTimeout(350); + s = await snap(A.page); + h.check(s.shown === 'explorer-list' && Math.abs(s.shownH - 430) < 3, `3.1 the docked Explorer takes the shared height (${s.shownH}px)`); + h.check(s.inset === '430px', `3.2 --bottom-inset follows it (${s.inset})`); + + await clickTab(A.page, 'Node editor'); + await A.page.waitForTimeout(350); + s = await snap(A.page); + h.check(s.shown === 'flow-list' && Math.abs(s.shownH - 430) < 3, `3.3 the height survives the tab switch — one dock, one height (${s.shownH}px)`); + h.check(s.inset === '430px' && s.dockHeight === 430, `3.4 and so does the inset (${s.inset})`); + + // --- 4. the explorerHeight -> dockHeight migration --- + await A.page.evaluate(() => localStorage.setItem('explorerHeight', '365')); + await A.page.reload({ waitUntil: 'domcontentloaded' }); + await A.page.waitForFunction(() => window.__stores && !!window.__stores.bottomDock, { timeout: 30000 }); + await A.page.waitForTimeout(600); + const migrated = await A.page.evaluate(() => { + let dh; + window.__stores.bottomDock.dockHeight.subscribe((v) => (dh = v))(); + return { dh, legacy: localStorage.getItem('explorerHeight'), shared: localStorage.getItem('flowDockHeight') }; + }); + h.check(migrated.dh === 365, `4.1 the old explorerHeight is adopted as the shared dock height (${migrated.dh}, was 430)`); + h.check(migrated.legacy === null, `4.2 and the private key is dropped (explorerHeight=${migrated.legacy})`); + h.check(migrated.shared === '365', `4.3 it persists as the dock's own key (flowDockHeight=${migrated.shared})`); + + await h.finish(browser); +}); diff --git a/tests/e2e/flow-explorer-dock.test.cjs b/tests/e2e/flow-explorer-dock.test.cjs index ba745631..82c0c4fb 100644 --- a/tests/e2e/flow-explorer-dock.test.cjs +++ b/tests/e2e/flow-explorer-dock.test.cjs @@ -1,7 +1,10 @@ -// Docking rework: the bottom dock shows ONE panel. The Node editor (Flow-family) and -// the Explorer are MUTUALLY EXCLUSIVE — clicking Explorer shows it (Flow un-highlights, -// stays open+hidden); clicking the Node editor shows the Flow dock AND closes the -// Explorer (single docked panel). Clicking a shown panel again closes it. +// Docking rework, phase 3: the bottom dock shows ONE panel, and every docked+open +// panel is a notebook TAB in it — the Explorer included. Switching tabs closes +// NOTHING: clicking Explorer shows it (the Node editor un-highlights but stays open +// as a hidden tab) and clicking the Node editor shows the flow tab again with the +// Explorer still open behind it. Clicking a panel's own toolbar button while that +// panel is the one on screen hides it. (Before this phase a flow tab becoming +// visible force-CLOSED the Explorer.) const h = require('./helpers.cjs'); const state = (page) => @@ -9,14 +12,22 @@ const state = (page) => const flowI = document.querySelector('p[title="Node editor (N)"] svg'); const explI = document.querySelector('#explorer-slot svg'); const ON = 'text-primary-500'; - let active, flowClosed, explClosed; + let active, flowClosed, explClosed, occ; window.__stores.bottomDock.bottomDockActive.subscribe((v) => (active = v))(); + window.__stores.bottomDock.dockOccupants.subscribe((v) => (occ = v))(); window.__stores.flowGraphClose.subscribe((v) => (flowClosed = v))(); window.__stores.explorerClose.subscribe((v) => (explClosed = v))(); + // the strip of the dock panel actually on screen (both containers stay + // mounted; the covered one carries a `hidden` class) + const box = [...document.querySelectorAll('#flow-list, #explorer-list')].find( + (el) => !el.classList.contains('hidden') + ); return { active, flowClosed, explClosed, + present: Object.keys(occ).filter((k) => occ[k]?.present).sort(), + strip: box ? [...box.querySelectorAll('.tab-note')].map((b) => b.textContent.trim()).filter(Boolean) : [], flowOn: (flowI.getAttribute('class') ?? '').includes(ON), explOn: (explI.getAttribute('class') ?? '').includes(ON) }; @@ -41,26 +52,45 @@ h.run(async () => { await A.page.waitForTimeout(500); let s = await state(A.page); h.check(s.flowOn && !s.explOn, `both docked: Flow highlighted, Explorer not (active=${s.active})`); + h.check( + s.present.join(',') === 'explorer,flow' && s.strip.includes('Explorer') && s.strip.includes('Node editor'), + `the strip lists BOTH panels as tabs (${s.strip.join('|')})` + ); - // click Explorer -> it becomes the shown dock occupant, Flow un-highlights + // click Explorer -> it becomes the shown dock occupant, Flow un-highlights but + // stays OPEN (a covered tab, not a closed panel) await A.page.evaluate(() => document.querySelector('#explorer-slot').click()); await A.page.waitForTimeout(300); s = await state(A.page); - h.check(s.active === 'explorer' && s.explOn && !s.flowOn, `click Explorer: Explorer shown+highlighted, Flow un-highlighted`); + h.check(s.active === 'explorer' && s.explOn && !s.flowOn, 'click Explorer: Explorer shown+highlighted, Flow un-highlighted'); + h.check(s.flowClosed === false && s.present.includes('flow'), 'the covered Node editor stays open as a dock tab'); - // click Node editor -> Flow shown again AND the Explorer is CLOSED (single docked panel) + // click Node editor -> Flow shown again and the Explorer is STILL OPEN (this is + // the exclusivity that phase 3 deleted) await A.page.evaluate(() => document.querySelector('p[title="Node editor (N)"]').click()); await A.page.waitForTimeout(300); s = await state(A.page); - h.check(s.active === 'flow' && s.flowOn && !s.explOn && s.explClosed, `click Node editor: Flow shown, Explorer closed (exclusive)`); + h.check(s.active === 'flow' && s.flowOn && !s.explOn, 'click Node editor: Flow shown, Explorer un-highlighted'); + h.check( + s.explClosed === false && s.present.join(',') === 'explorer,flow' && s.strip.includes('Explorer'), + `showing the flow tab does NOT close the Explorer — it stays a tab (${s.strip.join('|')})` + ); - // click Explorer twice: show it, then close it (highlight removed) - await A.page.evaluate(() => document.querySelector('#explorer-slot').click()); - await A.page.waitForTimeout(250); + // the strip's own Explorer tab brings it back + await A.page.evaluate(() => { + const box = [...document.querySelectorAll('#flow-list, #explorer-list')].find((el) => !el.classList.contains('hidden')); + [...box.querySelectorAll('.tab-note')].find((b) => b.textContent.trim() === 'Explorer').click(); + }); + await A.page.waitForTimeout(300); + s = await state(A.page); + h.check(s.active === 'explorer' && s.explOn && s.flowClosed === false, 'clicking the Explorer TAB shows it again, Node editor still open'); + + // clicking the toolbar button of the panel that IS on screen hides it await A.page.evaluate(() => document.querySelector('#explorer-slot').click()); await A.page.waitForTimeout(300); s = await state(A.page); h.check(s.explClosed && !s.explOn, 'click Explorer again (shown) closes it + removes highlight'); + h.check(s.active === 'explorer' && s.flowClosed === false, 'and the Node editor is still open, so the dock falls back to it'); await h.finish(browser); }); diff --git a/tests/e2e/panel-toggle-keys.test.cjs b/tests/e2e/panel-toggle-keys.test.cjs index 7d37b345..1fb5a2b8 100644 --- a/tests/e2e/panel-toggle-keys.test.cjs +++ b/tests/e2e/panel-toggle-keys.test.cjs @@ -74,13 +74,15 @@ h.run(async () => { d = await dockState(A.page); h.check(d.visible === 'flow', `1.1 N brings the docked Node editor tab back (visible=${d.visible})`); h.check(d.flowClosed === false, '1.2 N did NOT close it (the old bare store flip would have)'); - h.check(d.explClosed === true, '1.3 the dock stays exclusive: showing the flow tab closes the docked Explorer'); + h.check(d.explClosed === false, '1.3 the Explorer stays open behind it — the dock is tabs now, not one exclusive slot'); // pressing N again, now that the flow dock IS the visible panel, hides it (the - // button semantics the dock suites already pin) + // button semantics the dock suites already pin). The Explorer is still a docked + // tab, so the dock falls back to it rather than going empty. await press(A.page, 'n'); d = await dockState(A.page); - h.check(d.flowClosed === true && d.visible === null, `1.4 a second N hides the docked Node editor (visible=${d.visible})`); + h.check(d.flowClosed === true, '1.4 a second N hides the docked Node editor'); + h.check(d.visible === 'explorer', `1.5 the dock falls back to the Explorer tab (visible=${d.visible})`); // --- 2. N on a FLOATING Node editor buried under the floating Explorer --- await reload(A.page, 'false', 'false'); From ee786fc2b2e4f6887043d64f3e89421f0726a96d Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 27 Aug 2026 15:53:33 +0300 Subject: [PATCH 04/21] [feat] play mode menu: right-click the play button for desktop/VR/AR - 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 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 --- src/components/menu/Controls.svelte | 191 ++++++++++++++++------------ src/lib/playMode.js | 143 +++++++++++++++++++++ tests/e2e/play-mode-menu.test.cjs | 150 ++++++++++++++++++++++ tests/e2e/vr-passthrough.test.cjs | 33 +++-- 4 files changed, 427 insertions(+), 90 deletions(-) create mode 100644 src/lib/playMode.js create mode 100644 tests/e2e/play-mode-menu.test.cjs diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index 10a8f803..1fabec0f 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -1,7 +1,7 @@ - {#if willEnterAR} + {#if $willEnterAR} - {:else if willEnterXR} + {:else if $willEnterXR}