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/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')}>
@@ -716,7 +726,13 @@
instead of fighting). clip-path circles the HIT AREA too: the 50px square box
used to intercept clicks/hovers meant for the Scale / Object-list cells it
overlaps. fill=currentColor keeps the play triangle SOLID (lucide is
- stroke-only by default); the 2px nudge is the classic optical centering. -->
+ stroke-only by default); the 2px nudge is the classic optical centering.
+ The transition is ONE inline declaration covering BOTH properties rather than
+ the tailwind `transition-transform duration-100` this used to carry: an inline
+ `transition:` shorthand REPLACES whatever the class set, so adding `bottom` there
+ would have silently wiped the transform transition and killed the hover-scale.
+ Inline owns both — transform keeps its 100ms, bottom rides the dock's 200ms. -->
+
{
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 @@
diff --git a/src/lib/panelToggles.js b/src/lib/panelToggles.js
new file mode 100644
index 00000000..22c99db3
--- /dev/null
+++ b/src/lib/panelToggles.js
@@ -0,0 +1,165 @@
+import { tick } from 'svelte';
+import { get } from 'svelte/store';
+import {
+ flowGraphClose,
+ flowCodeClose,
+ animationClose,
+ uvEditorClose,
+ shaderEditorClose,
+ hudEditorClose,
+ explorerClose,
+ objectListClose
+} from '../stores/appStore';
+import { activateDock, dockOccupants, visibleDockKey, FLOW_FAMILY } from './bottomDock';
+import { raiseWindow, isTopVisibleWindow } from './windowFocus';
+import { groupOfKey, activateTab } from './windowTabs';
+import { revealWindow } from './dragWindow';
+
+// ONE decision tree for the Controls panel buttons AND their keyboard shortcuts
+// (O / N). Before this module the Object list button had taskbar semantics
+// (open -> raise -> close) while the Node editor and Explorer buttons plain-toggled,
+// and the O/N keys bypassed the buttons entirely with a bare store flip — three
+// behaviours for one gesture. The tree, in order:
+//
+// 1. CLOSED -> open in its last mode (docked tab / floating window).
+// 2. OPEN, tab group -> not the active tab? activate it and raise.
+// 3. OPEN, floating -> already the top VISIBLE window? close it. Otherwise
+// raise it: a buried window is "called", not dismissed.
+// 4. OPEN, docked -> the visible dock panel? hide it. Otherwise make it
+// the visible dock tab.
+//
+// Only step 3's raise-before-close is new; everything else is the behaviour the
+// three old handlers already had, moved here so the keys inherit it.
+//
+// Steps 2 and 4 can never both apply: a bottom-docked panel renders a DIFFERENT
+// markup branch with no floating window at all, so its `tabbable` action is
+// destroyed and `removeFromGroup` has already run (Flow.svelte / Explorer.svelte).
+
+/** @typedef {{ key: string, openStore: any, dragKey: string | null, dockedLs: string | null }} PanelConfig */
+
+/**
+ * The three panels the Controls toolbar owns. `openStore` is inverted (true =
+ * closed) throughout the app. `dragKey` is the dragWindow key of the FLOATING
+ * window (null for the object list, which uses Controls' own `dragMe` and
+ * therefore has no dragWindow revealer). `dockedLs` is the localStorage flag
+ * remembering the panel's last mode (null = floating-only).
+ * @type {Record}
+ */
+const PANELS = {
+ flow: { key: 'flow', openStore: flowGraphClose, dragKey: 'flowWin', dockedLs: 'flowDocked' },
+ explorer: { key: 'explorer', openStore: explorerClose, dragKey: 'explorerWin', dockedLs: 'explorerDocked' },
+ objects: { key: 'objects', openStore: objectListClose, dragKey: null, dockedLs: null }
+};
+
+// remembers which flow-family views were open when the docked group was hidden
+/** @type {any} */
+let flowDockSnapshot = null;
+
+/** docked AND open (that is what dockOccupants.present means) @param {string} key */
+function isDockedPresent(key) {
+ return !!get(dockOccupants)[key]?.present;
+}
+
+/** Would opening this panel put it in the dock? @param {PanelConfig} cfg */
+function opensDocked(cfg) {
+ if (!cfg.dockedLs) return false; // floating-only panel
+ return typeof localStorage === 'undefined' || localStorage.getItem(cfg.dockedLs) !== 'false';
+}
+
+/** Is this panel the one the dock is actually SHOWING? @param {PanelConfig} cfg */
+function isVisibleInDock(cfg) {
+ const visible = get(visibleDockKey) ?? '';
+ // the Node editor button owns the whole docked flow GROUP, so any flow-family
+ // tab being visible counts as "the Node editor's dock is on screen"
+ return cfg.key === 'flow' ? FLOW_FAMILY.includes(visible) : visible === cfg.key;
+}
+
+/** Step 1 for the Node editor: bring back the docked group we hid. @returns {boolean} did it restore */
+function restoreFlowSnapshot() {
+ const snap = flowDockSnapshot;
+ if (!snap || !(snap.flow || snap.flowcode || snap.animation || snap.uv || snap.shader || snap.hud)) return false;
+ 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');
+ return true;
+}
+
+/** Step 4 for the Node editor: hide only the tabs that are actually DOCKED
+ * (leave undocked/floating Flow Code / Animation windows open). */
+function hideDockedFlowFamily() {
+ const occupants = get(dockOccupants);
+ flowDockSnapshot = {
+ flow: true,
+ flowcode: !!occupants.flowcode?.present,
+ animation: !!occupants.animation?.present,
+ uv: !!occupants.uv?.present,
+ shader: !!occupants.shader?.present,
+ // A4: without this line the HUD tab never comes back after play mode
+ hud: !!occupants.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);
+}
+
+/** Step 1: open a closed panel in the mode it was last in. @param {PanelConfig} cfg */
+function openInLastMode(cfg) {
+ if (cfg.key === 'flow' && restoreFlowSnapshot()) return;
+ cfg.openStore.set(false);
+ if (opensDocked(cfg)) {
+ activateDock(cfg.key); // docked -> show as the dock tab
+ return;
+ }
+ // floating: the window only mounts once the store says open, so both the
+ // reveal (pull it fully back on screen) and the raise wait for that flush
+ tick().then(() => {
+ if (cfg.dragKey) revealWindow(cfg.dragKey);
+ raiseWindow(cfg.key);
+ });
+}
+
+/**
+ * The Controls toolbar button / keyboard shortcut for one panel.
+ * @param {string} key 'flow' | 'explorer' | 'objects'
+ */
+export function togglePanel(key) {
+ const cfg = PANELS[key];
+ if (!cfg) return;
+
+ // 1. closed -> open in its last mode
+ if (get(cfg.openStore)) {
+ openInLastMode(cfg);
+ return;
+ }
+
+ if (!isDockedPresent(key)) {
+ // 2. open + a member of a tab group that is showing a SIBLING tab
+ const group = groupOfKey(key);
+ if (group && group.active !== key) {
+ activateTab(group.id, key);
+ raiseWindow(key);
+ return;
+ }
+ // 3. open + floating: close it only when it is already on top; a buried
+ // window is raised instead (closing what the user cannot see is a trap)
+ if (isTopVisibleWindow(key)) cfg.openStore.set(true);
+ else raiseWindow(key);
+ return;
+ }
+
+ // 4. open + docked
+ if (!isVisibleInDock(cfg)) {
+ activateDock(key); // docked but hidden (another tab covering) -> bring it back
+ return;
+ }
+ if (cfg.key === 'flow') hideDockedFlowFamily();
+ else cfg.openStore.set(true);
+}
diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js
index 2671df5f..d8f2d2d2 100644
--- a/src/lib/shortcuts.js
+++ b/src/lib/shortcuts.js
@@ -2,7 +2,6 @@ import { get } from 'svelte/store';
import { TControls, isLocked } from '../stores/sceneStore';
import {
flowGraphClose,
- objectListClose,
chatHidden,
settingsOpen,
anyModalOpen,
@@ -27,6 +26,7 @@ import { editingObject, enterEditMode, exitEditMode } from './meshEdit';
import { faceEditObject, meshEditHotkeys } from './faceEdit';
import { recallBookmark } from './cameraBookmarks';
import { snapTargets } from './snapping';
+import { togglePanel } from './panelToggles';
import { selectedObject } from '../stores/sceneStore';
// Single source of truth for keyboard shortcuts: the same registry binds the keys
@@ -145,14 +145,16 @@ export const shortcuts = [
{
keys: 'O',
group: 'Panels',
- label: 'Toggle object list',
- action: () => objectListClose.update((value) => !value)
+ // the key IS the toolbar button now (one tree in panelToggles): a buried
+ // window is raised first and only closes on the next press
+ label: 'Object list: show / bring to front / hide',
+ action: () => togglePanel('objects')
},
{
keys: 'N',
group: 'Panels',
- label: 'Toggle node editor',
- action: () => flowGraphClose.update((value) => !value)
+ label: 'Node editor: show / bring to front / hide',
+ action: () => togglePanel('flow')
},
{
keys: 'C',
diff --git a/src/lib/windowFocus.js b/src/lib/windowFocus.js
index a391e18a..58c5906d 100644
--- a/src/lib/windowFocus.js
+++ b/src/lib/windowFocus.js
@@ -67,3 +67,39 @@ export function isTopWindow(key) {
const node = byKey.get(key);
return !!node && order.length > 0 && order[order.length - 1] === node;
}
+
+/**
+ * Is a node currently on screen? The same test windowTabs' merge hit-test uses
+ * (`targetAt`): these windows are `position: fixed`, so `offsetParent` is null
+ * even when they are perfectly visible — the offsetParent clause only rules out
+ * NON-fixed ones. A closed window is hidden either by an inline `display:none`
+ * (a tab group left it behind) or by a `hidden` CLASS, which shows up as a
+ * computed `display:none` and a zero-width rect.
+ * @param {any} node
+ */
+function isVisible(node) {
+ if (!node?.isConnected) return false;
+ if (node.style.display === 'none') return false;
+ const style = getComputedStyle(node);
+ if (style.display === 'none') return false;
+ if (node.offsetParent === null && style.position !== 'fixed') return false;
+ return node.getBoundingClientRect().width > 0;
+}
+
+/**
+ * Is the keyed window the top-most VISIBLE one? `isTopWindow` cannot answer this:
+ * a closed window usually stays MOUNTED (a `hidden` class / display:none) and
+ * windowFocus only drops a node when its action is destroyed, so a window closed
+ * while it was on top sits at the top of `order` for ever — after which the
+ * top-most window that is actually on screen never reads as top.
+ * @param {string} key
+ */
+export function isTopVisibleWindow(key) {
+ const node = byKey.get(key);
+ if (!node) return false;
+ for (let index = order.length - 1; index >= 0; index--) {
+ if (!isVisible(order[index])) continue;
+ return order[index] === node;
+ }
+ return false;
+}
diff --git a/src/lib/windowTabs.js b/src/lib/windowTabs.js
index 3c1b4e60..be0094db 100644
--- a/src/lib/windowTabs.js
+++ b/src/lib/windowTabs.js
@@ -56,8 +56,8 @@ function applyGroups() {
persist();
}
-/** @param {string} key */
-function groupOfKey(key) {
+/** The tab group a window belongs to, or null. @param {string} key */
+export function groupOfKey(key) {
return get(tabGroups).find((group) => group.members.includes(key)) ?? null;
}
diff --git a/tests/e2e/flow-explorer-floating.test.cjs b/tests/e2e/flow-explorer-floating.test.cjs
index fcae69d6..1fc2277e 100644
--- a/tests/e2e/flow-explorer-floating.test.cjs
+++ b/tests/e2e/flow-explorer-floating.test.cjs
@@ -1,8 +1,40 @@
// When BOTH the Node editor and the Explorer are floating windows (neither docked),
// toggling the Node editor from Controls must only show/hide itself — it must never
// close the floating Explorer (they don't compete for the dock).
+//
+// Phase 2 (panelToggles): the button is a TASKBAR button now. A floating panel that
+// is open but BURIED is raised on the first press and only closes on the next one —
+// closing a window the user cannot see is the trap this replaces. The Object list
+// button already worked this way; the Node editor and the Explorer now share its tree.
const h = require('./helpers.cjs');
+// z-index + a hit test at a point inside BOTH windows: which one would take a click
+const stack = (page) =>
+ page.evaluate(() => {
+ const s = window.__stores;
+ let fc, ec;
+ s.flowGraphClose.subscribe((v) => (fc = v))();
+ s.explorerClose.subscribe((v) => (ec = v))();
+ const f = document.getElementById('flow-window');
+ const e = document.getElementById('explorer-window');
+ if (!f || !e) return { fc, ec, flowZ: null, explZ: null, at: null };
+ const fr = f.getBoundingClientRect();
+ const er = e.getBoundingClientRect();
+ const x = Math.max(fr.left, er.left) + 20;
+ const y = Math.max(fr.top, er.top) + 8;
+ const hit = document.elementFromPoint(x, y);
+ return {
+ fc,
+ ec,
+ flowZ: parseInt(getComputedStyle(f).zIndex),
+ explZ: parseInt(getComputedStyle(e).zIndex),
+ at: hit ? (hit.closest('#flow-window') ? 'flow' : hit.closest('#explorer-window') ? 'explorer' : 'other') : null
+ };
+ });
+
+const clickFlowButton = (page) =>
+ page.evaluate(() => document.querySelector('p[title="Node editor (N)"]').click());
+
h.run(async () => {
const browser = await h.launch();
const A = await h.setupPage(browser, 'A');
@@ -24,29 +56,48 @@ h.run(async () => {
}));
h.check(setup.flowWin && setup.explWin, 'both the Node editor and the Explorer are floating windows');
- // click Node editor (shown) -> it hides; the floating Explorer stays open
- await A.page.evaluate(() => document.querySelector('p[title="Node editor (N)"]').click());
- await A.page.waitForTimeout(250);
+ // PREMISE: the Explorer mounts after the Node editor, so the flow window starts
+ // BEHIND it — that is the state the raise-first rule exists for.
+ const before = await stack(A.page);
+ h.check(
+ before.flowZ < before.explZ && before.at === 'explorer',
+ `premise: the floating Node editor starts BEHIND the Explorer (flow z=${before.flowZ}, explorer z=${before.explZ}, click hits ${before.at})`
+ );
+
+ // press 1: buried -> RAISE, do not close
+ await clickFlowButton(A.page);
+ await A.page.waitForTimeout(300);
+ const raised = await stack(A.page);
+ h.check(raised.fc === false, 'pressing the button on a BURIED floating Node editor does not close it');
+ h.check(
+ raised.flowZ > raised.explZ && raised.at === 'flow',
+ `it comes to the front instead (flow z=${raised.flowZ}, explorer z=${raised.explZ}, click hits ${raised.at})`
+ );
+ h.check(raised.ec === false, 'raising the Node editor leaves the floating Explorer open');
+
+ // press 2: already on top -> hide, and the floating Explorer is left alone
+ await clickFlowButton(A.page);
+ await A.page.waitForTimeout(300);
const afterHide = await A.page.evaluate(() => {
const s = window.__stores;
let fc, ec;
s.flowGraphClose.subscribe((v) => (fc = v))();
s.explorerClose.subscribe((v) => (ec = v))();
- return { fc, ec };
+ return { fc, ec, flowWin: !!document.getElementById('flow-window') };
});
- h.check(afterHide.fc === true && afterHide.ec === false, 'hiding the floating Node editor leaves the floating Explorer open');
+ h.check(afterHide.fc === true && !afterHide.flowWin, 'pressing it again (now on top) hides the floating Node editor');
+ h.check(afterHide.ec === false, 'hiding the floating Node editor leaves the floating Explorer open');
- // click Node editor again (show) -> Explorer STILL open
- await A.page.evaluate(() => document.querySelector('p[title="Node editor (N)"]').click());
- await A.page.waitForTimeout(250);
- const afterShow = await A.page.evaluate(() => {
- const s = window.__stores;
- let fc, ec;
- s.flowGraphClose.subscribe((v) => (fc = v))();
- s.explorerClose.subscribe((v) => (ec = v))();
- return { fc, ec };
- });
- h.check(afterShow.fc === false && afterShow.ec === false, 'showing the floating Node editor again leaves the floating Explorer open');
+ // press 3: closed -> reopens (and the Explorer is STILL untouched)
+ await clickFlowButton(A.page);
+ await A.page.waitForTimeout(400);
+ const afterShow = await stack(A.page);
+ h.check(afterShow.fc === false, 'a third press reopens the floating Node editor');
+ h.check(afterShow.ec === false, 'showing the floating Node editor again leaves the floating Explorer open');
+ h.check(
+ afterShow.flowZ > afterShow.explZ && afterShow.at === 'flow',
+ `the reopened window comes back in FRONT (flow z=${afterShow.flowZ}, explorer z=${afterShow.explZ}, click hits ${afterShow.at})`
+ );
await h.finish(browser);
});
diff --git a/tests/e2e/panel-toggle-keys.test.cjs b/tests/e2e/panel-toggle-keys.test.cjs
new file mode 100644
index 00000000..7d37b345
--- /dev/null
+++ b/tests/e2e/panel-toggle-keys.test.cjs
@@ -0,0 +1,180 @@
+// Phase 2: the O / N keyboard shortcuts and the Controls toolbar buttons run ONE
+// decision tree ($lib/panelToggles). Before this, both keys were a bare store flip:
+// N on a docked-but-COVERED Node editor closed it instead of bringing its dock tab
+// back, and N/O on a buried floating window closed something the user could not see.
+const h = require('./helpers.cjs');
+
+const reload = async (page, flowDocked, explorerDocked) => {
+ await page.evaluate(
+ ([f, e]) => {
+ localStorage.setItem('flowDocked', f);
+ localStorage.setItem('explorerDocked', e);
+ },
+ [flowDocked, explorerDocked]
+ );
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.__stores && !!window.__stores.bottomDock, { timeout: 30000 });
+};
+
+const dockState = (page) =>
+ page.evaluate(() => {
+ const s = window.__stores;
+ let visible, occupants, fc, ec;
+ s.bottomDock.visibleDockKey.subscribe((v) => (visible = v))();
+ s.bottomDock.dockOccupants.subscribe((v) => (occupants = v))();
+ s.flowGraphClose.subscribe((v) => (fc = v))();
+ s.explorerClose.subscribe((v) => (ec = v))();
+ return { visible, flowDocked: !!occupants.flow?.present, flowClosed: fc, explClosed: ec };
+ });
+
+const zOf = (page) =>
+ page.evaluate(() => {
+ const z = (id) => {
+ const n = document.getElementById(id);
+ if (!n || getComputedStyle(n).display === 'none') return null;
+ return parseInt(getComputedStyle(n).zIndex);
+ };
+ const s = window.__stores;
+ let fc, ec, oc;
+ s.flowGraphClose.subscribe((v) => (fc = v))();
+ s.explorerClose.subscribe((v) => (ec = v))();
+ s.objectListClose.subscribe((v) => (oc = v))();
+ return { flow: z('flow-window'), expl: z('explorer-window'), objects: z('object-list'), fc, ec, oc };
+ });
+
+// a REAL key press, with focus on the document body (never a text field)
+const press = async (page, key) => {
+ await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur());
+ await page.keyboard.press(key);
+ await page.waitForTimeout(350);
+};
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+
+ // --- 1. N on a DOCKED Node editor whose dock slot is showing the Explorer ---
+ // The dock has one slot, so a docked Node editor can be open and yet invisible.
+ // The button brings its tab back; the key used to CLOSE it (the bare flip).
+ await reload(A.page, 'true', 'true');
+ await A.page.evaluate(() => {
+ const s = window.__stores;
+ s.flowGraphClose.set(false);
+ s.explorerClose.set(false);
+ s.bottomDock.activateDock('explorer');
+ });
+ await A.page.waitForTimeout(700);
+ let d = await dockState(A.page);
+ h.check(
+ d.visible === 'explorer' && d.flowDocked && d.flowClosed === false,
+ `1.0 premise: the Node editor is docked+open but the dock is showing the Explorer (visible=${d.visible})`
+ );
+
+ await press(A.page, 'n');
+ 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');
+
+ // pressing N again, now that the flow dock IS the visible panel, hides it (the
+ // button semantics the dock suites already pin)
+ 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})`);
+
+ // --- 2. N on a FLOATING Node editor buried under the floating Explorer ---
+ await reload(A.page, 'false', 'false');
+ await A.page.evaluate(() => {
+ window.__stores.flowGraphClose.set(false);
+ window.__stores.explorerClose.set(false);
+ });
+ await A.page.waitForTimeout(800);
+ let z = await zOf(A.page);
+ h.check(
+ z.fc === false && z.ec === false && z.flow < z.expl,
+ `2.0 premise: both float and the Node editor is BEHIND the Explorer (flow z=${z.flow}, explorer z=${z.expl})`
+ );
+
+ await press(A.page, 'n');
+ z = await zOf(A.page);
+ h.check(z.fc === false, '2.1 N on a buried floating Node editor does not close it');
+ h.check(z.flow > z.expl, `2.2 it is raised to the front instead (flow z=${z.flow}, explorer z=${z.expl})`);
+
+ await press(A.page, 'n');
+ z = await zOf(A.page);
+ h.check(z.fc === true, '2.3 a second N hides it, now that it is the top window');
+ h.check(z.ec === false, '2.4 the floating Explorer is untouched throughout');
+
+ // --- 3. O on a buried Object list (the same tree, the panel it came from) ---
+ await A.page.evaluate(() => window.__stores.objectListClose.set(false));
+ await A.page.waitForTimeout(500);
+ z = await zOf(A.page);
+ h.check(
+ z.oc === false && z.objects !== null && z.objects < z.expl,
+ `3.0 premise: the Object list is open but BEHIND the floating Explorer (objects z=${z.objects}, explorer z=${z.expl})`
+ );
+
+ await press(A.page, 'o');
+ z = await zOf(A.page);
+ h.check(z.oc === false, '3.1 O on a buried Object list does not close it');
+ h.check(z.objects > z.expl, `3.2 it is raised to the front instead (objects z=${z.objects}, explorer z=${z.expl})`);
+
+ await press(A.page, 'o');
+ z = await zOf(A.page);
+ h.check(z.oc === true, '3.3 a second O closes the Object list, now that it is on top');
+
+ // --- 4. the key and the BUTTON agree: O reopens exactly like a button press ---
+ await press(A.page, 'o');
+ const reopened = await A.page.evaluate(() => {
+ let oc;
+ window.__stores.objectListClose.subscribe((v) => (oc = v))();
+ const n = document.getElementById('object-list');
+ return { oc, z: parseInt(getComputedStyle(n).zIndex), expl: parseInt(getComputedStyle(document.getElementById('explorer-window')).zIndex) };
+ });
+ h.check(reopened.oc === false && reopened.z > reopened.expl, `4.1 O reopens the Object list in FRONT (objects z=${reopened.z}, explorer z=${reopened.expl})`);
+
+ // --- 5. a window CLOSED while it was on top must not shield the next one ---
+ // This is what `isTopVisibleWindow` is for: the Object list stays MOUNTED when it
+ // closes (a `hidden` class) and windowFocus only drops a node when its action is
+ // destroyed, so it sits at the top of `order` for ever afterwards. Plain
+ // `isTopWindow` then says "not on top" about the only window still on screen, and
+ // the key/button silently RAISES it instead of closing it — a control that does
+ // nothing at all.
+ await reload(A.page, 'false', 'false');
+ await A.page.evaluate(() => {
+ const s = window.__stores;
+ s.flowGraphClose.set(false); // the floating Node editor
+ s.explorerClose.set(true);
+ s.objectListClose.set(false);
+ });
+ await A.page.waitForTimeout(700);
+ await press(A.page, 'o'); // raise the Object list to the very top...
+ await press(A.page, 'o'); // ...and close it there, leaving a stale top-of-stack node
+ const stale = await A.page.evaluate(() => {
+ const s = window.__stores;
+ let oc, fc;
+ s.objectListClose.subscribe((v) => (oc = v))();
+ s.flowGraphClose.subscribe((v) => (fc = v))();
+ const visible = [...document.querySelectorAll('#object-list, #flow-window, #explorer-window')].filter(
+ (el) => getComputedStyle(el).display !== 'none' && el.getBoundingClientRect().width > 0
+ ).map((el) => el.id);
+ return { oc, fc, visible };
+ });
+ h.check(
+ stale.oc === true && stale.fc === false && stale.visible.join(',') === 'flow-window',
+ `5.0 premise: the Object list closed while on top; the Node editor is the only panel on screen (visible=${stale.visible.join(',') || 'none'})`
+ );
+
+ await press(A.page, 'n');
+ const closedAlone = await A.page.evaluate(() => {
+ let fc;
+ window.__stores.flowGraphClose.subscribe((v) => (fc = v))();
+ return { fc, win: !!document.getElementById('flow-window') };
+ });
+ h.check(
+ closedAlone.fc === true && !closedAlone.win,
+ '5.1 N still closes it — a window hidden behind nothing is on top, whatever the raw stack order says'
+ );
+
+ await h.finish(browser);
+});
From 739f9dfffc2c1e753d40095db37c25840f77035a Mon Sep 17 00:00:00 2001
From: AlexZ005
Date: Thu, 27 Aug 2026 15:28:21 +0300
Subject: [PATCH 03/21] [feat] the explorer is a bottom-dock tab
- 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
---
src/components/DockTabs.svelte | 27 ++---
src/components/Flow.svelte | 14 +--
src/components/editors/Explorer.svelte | 39 +++++---
src/lib/bottomDock.js | 48 ++++-----
src/lib/dockMenu.js | 28 ++++++
tests/e2e/dock-float-exclusivity.test.cjs | 48 +++++++--
tests/e2e/dock-tabs-coexist.test.cjs | 116 ++++++++++++++++++++++
tests/e2e/flow-explorer-dock.test.cjs | 54 +++++++---
tests/e2e/panel-toggle-keys.test.cjs | 8 +-
9 files changed, 295 insertions(+), 87 deletions(-)
create mode 100644 src/lib/dockMenu.js
create mode 100644 tests/e2e/dock-tabs-coexist.test.cjs
diff --git a/src/components/DockTabs.svelte b/src/components/DockTabs.svelte
index aa110165..28ae8bc4 100644
--- a/src/components/DockTabs.svelte
+++ b/src/components/DockTabs.svelte
@@ -1,21 +1,16 @@
- {#each $flowTabs as tab (tab.key)}
+ {#each $dockTabs as tab (tab.key)}
diff --git a/src/components/Flow.svelte b/src/components/Flow.svelte
index cabc818d..967c979f 100644
--- a/src/components/Flow.svelte
+++ b/src/components/Flow.svelte
@@ -2,7 +2,7 @@
// Flow host: the Node editor. DOCKED mode is a Flow-family TAB in the shared bottom
// dock (DockTabs strip; shares dockHeight with Flow Code + Animation; only the
// visible tab renders). UNDOCKED mode is a floating, resizable window. Both persist.
- import { flowGraphClose, flowCodeClose, animationClose, uvEditorClose, mobileUndockAllowed, shaderEditorClose, hudEditorClose } from '../stores/appStore.js';
+ import { flowGraphClose, mobileUndockAllowed } from '../stores/appStore.js';
import { get } from 'svelte/store';
import { onMount } from 'svelte';
import { SvelteFlowProvider } from '@xyflow/svelte';
@@ -17,6 +17,7 @@
import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize';
import { dockable } from '$lib/docking';
import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock';
+ import { dockAddItems } from '$lib/dockMenu';
import { fly } from 'svelte/transition';
const clampH = (h: number) => Math.min(Math.max(h || 320, 200), Math.round(window.innerHeight * 0.8));
@@ -73,15 +74,10 @@
const effH = $derived(myGroup ? myGroup.rect.height : winH);
// Flow "+" (floating window only — docked mode uses the DockTabs strip): open
- // another Flow-family view. They start docked, so they appear as dock tabs.
+ // another dock view. They start docked, so they appear as dock tabs. Same list the
+ // strip's "+" renders ($lib/dockMenu) — they used to be two copies that drifted.
let addMenu: { x: number; y: number } | null = $state(null);
- const addItems = [
- { 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'); } }
- ];
+ const addItems = dockAddItems();
function openAddMenu(e: MouseEvent) {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
addMenu = { x: r.left, y: r.bottom + 4 };
diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte
index 9b5a599d..7e9484fd 100644
--- a/src/components/editors/Explorer.svelte
+++ b/src/components/editors/Explorer.svelte
@@ -4,8 +4,9 @@
// Explorer (95, tree v2 in 106): dockable asset browser — real file-manager
// tree on the left (inline create/rename, expand/collapse, drag re-parent,
// cascade delete, resizable), thumbnail grid on the right (subfolder cards
- // + items), drag files in to import. Shares the bottom dock with the Flow
- // editor as notebook tabs (bottomDock.js); undocks into a floating window.
+ // + items), drag files in to import. It is an ordinary bottom-dock TAB beside
+ // the Flow-family views (bottomDock.js), sharing their strip and their height;
+ // undocks into a floating window.
import { get } from 'svelte/store';
import { tick, untrack } from 'svelte';
import { explorerClose, mobileUndockAllowed, explorerSceneSaveArm, peers } from '../../stores/appStore.js';
@@ -156,12 +157,13 @@
import { sceneAssets } from '$lib/sceneAssets';
import { setNodeData } from '$lib/nodesHandler';
import { findNodeAnyGraph } from '../../stores/flowStore';
- import { bottomDockActive, visibleDockKey, setDockOccupant } from '$lib/bottomDock';
+ import { bottomDockActive, visibleDockKey, setDockOccupant, dockHeight } from '$lib/bottomDock';
import { dragWindow } from '$lib/dragWindow';
import { focusStack } from '$lib/windowFocus';
import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs';
import { dockable } from '$lib/docking';
import ContextMenu from '../ContextMenu.svelte';
+ import DockTabs from '../DockTabs.svelte';
import WindowShell from '../shared/WindowShell.svelte';
import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize';
import { fly } from 'svelte/transition';
@@ -173,7 +175,6 @@
const WIN_MIN = { minW: 420, minH: 280 };
const WIN_DEFAULT = { w: 720, h: 440 };
- let height = $state(300);
let inlineStats: any = $state(null); // N4: poly stats for the Properties inline preview
let docked = $state(true);
let winW = $state(720);
@@ -187,7 +188,16 @@
let shell = $state(null);
let selected = $state(null);
if (typeof localStorage !== 'undefined') {
- height = clampH(parseInt(localStorage.getItem('explorerHeight') ?? '300'));
+ // one-shot migration: the Explorer used to keep a docked height of its own
+ // ('explorerHeight'). It is a dock TAB now, so the dock's shared height owns
+ // it — adopt the old value once, then drop the key.
+ try {
+ const legacyH = localStorage.getItem('explorerHeight');
+ if (legacyH) {
+ dockHeight.set(clampH(parseInt(legacyH) || 300));
+ localStorage.removeItem('explorerHeight');
+ }
+ } catch {}
docked = localStorage.getItem('explorerDocked') !== 'false';
// 18-B: a size saved on a bigger screen must not come back oversized —
// that is the state whose resize grip sits off-screen. Fitted BEFORE the
@@ -219,12 +229,11 @@
if (v) bottomDockActive.set('explorer'); // re-docking makes it the visible panel
}
- // The Explorer is the dock's separate (exclusive) panel — it reports docked+open
- // (+height for the --bottom-inset) and is visible only when it owns the dock. It is
- // mutually exclusive with the Flow-family tabs (activating a Flow tab closes it), so
- // it shows NO tab strip of its own.
+ // A dock tab like any other: report docked+open (+ the SHARED dock height, which
+ // feeds --bottom-inset) so the strip lists it, and render only while it is the
+ // visible tab. Being covered by another tab closes nothing — this stays open.
$effect(() => {
- setDockOccupant('explorer', !$explorerClose && docked, height);
+ setDockOccupant('explorer', !$explorerClose && docked, $dockHeight);
return () => setDockOccupant('explorer', false);
});
const dockVisible = $derived($visibleDockKey === 'explorer');
@@ -235,7 +244,7 @@
const effW = $derived(myGroup ? myGroup.rect.width : winW);
const effH = $derived(myGroup ? myGroup.rect.height : winH);
- // --- docked: top-edge resize (Flow pattern) ---
+ // --- docked: top-edge resize (shared dock height, persisted by the store) ---
let resizing = $state(false);
function startResize(e: any) {
resizing = true;
@@ -244,13 +253,12 @@
}
function doResize(e: any) {
if (!resizing) return;
- height = clampH(height - e.movementY);
+ dockHeight.update((h) => clampH(h - e.movementY));
}
function endResize(e: any) {
if (!resizing) return;
resizing = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
- localStorage.setItem('explorerHeight', String(height));
}
// --- undocked: corner resize ---
@@ -4543,7 +4551,7 @@
id="explorer-list"
transition:fly={{ y: 300, duration: 200 }}
class="fixed inset-x-0 bottom-0 bg-white p-2 dark:bg-gray-800 {dockVisible ? '' : 'hidden'}"
- style="z-index: var(--z-bottom); height: {height}px; border-top: 1px solid rgb(55 65 81 / 0.6)"
+ style="z-index: var(--z-bottom); height: {$dockHeight}px; border-top: 1px solid rgb(55 65 81 / 0.6)"
ondragover={(e) => {
if (canAccept(e)) return;
e.preventDefault();
@@ -4561,6 +4569,7 @@
onpointermove={doResize}
onpointerup={endResize}
>
+
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}
R
- {:else if willEnterXR}
+ {:else if $willEnterXR}
{:else}
{/if}
!v, close: () => objectListClose.set(true) }}
use:dockable={{ key: 'objects' }}
diff --git a/src/lib/playMode.js b/src/lib/playMode.js
new file mode 100644
index 00000000..55dbcaed
--- /dev/null
+++ b/src/lib/playMode.js
@@ -0,0 +1,143 @@
+import { derived, get, writable } from 'svelte/store';
+import { isLocked, isVRMode, vrOverride, vrPassthrough } from '../stores/sceneStore';
+
+// THE PLAY STATE MACHINE, lifted out of Controls.svelte so the play FAB, the FAB's
+// right-click mode menu and (next) a keyboard shortcut all press the same button.
+// It used to live as three private pieces of that component — the XR support probes,
+// `checkPlay` and the exit-cooldown effect — which is why the FAB's GLYPH and its
+// ACTION read two different sources: the glyph derived from
+// `navigator.xr.isSessionSupported`, while the click SNIFFED threlte's private button
+// label and localStorage. One decision lives here now; the label survives only as a
+// safety guard (see `requestPlay`).
+//
+// The imports are deliberately sceneStore + svelte/store ONLY. shortcuts.js sits in
+// history.js's import subtree, so anything reachable from here would have to be
+// cycle-safe from there too — and this module is about to be imported from it.
+
+/* ------------------------------------------------------------------ support ---- */
+
+/** immersive-vr is available on this device (probe answer, false until it lands) */
+export const vrSupported = writable(false);
+/** immersive-ar — passthrough — is available on this device */
+export const arSupported = writable(false);
+
+// try/catch and not just .catch(): a runtime whose isSessionSupported THROWS
+// synchronously would otherwise throw during this module's evaluation, and this
+// module owns the play button — the one control the app cannot lose.
+try {
+ const xr = typeof navigator === 'undefined' ? null : /** @type {any} */ (navigator).xr;
+ xr?.isSessionSupported?.('immersive-vr')
+ ?.then((/** @type {boolean} */ ok) => vrSupported.set(!!ok))
+ ?.catch(() => vrSupported.set(false));
+ xr?.isSessionSupported?.('immersive-ar')
+ ?.then((/** @type {boolean} */ ok) => arSupported.set(!!ok))
+ ?.catch(() => arSupported.set(false));
+} catch {
+ vrSupported.set(false);
+ arSupported.set(false);
+}
+
+/**
+ * WHAT a play press is about to do: a supported immersive session AND no desktop
+ * override. `vrPassthrough` picks WHICH session kind is asked for, so it also picks
+ * which support answer matters.
+ *
+ * `vrOverride` is a STRING ('true') or null in practice — Scene.svelte seeds it
+ * straight from localStorage — so this is a TRUTHINESS test, never `=== true`.
+ */
+export const willEnterXR = derived(
+ [vrPassthrough, vrSupported, arSupported, vrOverride],
+ ([$passthrough, $vr, $ar, $override]) => (($passthrough ? $ar : $vr) && !$override)
+);
+
+/** ...and that session is the passthrough (immersive-ar) one */
+export const willEnterAR = derived(
+ [willEnterXR, vrPassthrough],
+ ([$xr, $passthrough]) => $xr && !!$passthrough
+);
+
+/* ------------------------------------------------------------------ cooldown --- */
+
+// Both flags are declared ABOVE the `isLocked` subscription at the foot of this file,
+// which runs its callback SYNCHRONOUSLY at module evaluation (the module-level
+// subscribe rule — a `let` read from below would TDZ-crash the SSR prerender).
+
+/** false while the post-exit re-entry lockout is running */
+let allowPlay = true;
+// 21-F3 REJOIN: a play press that landed inside the exit cooldown, replayed when it
+// expires. The cooldown itself has to stay — it exists because the browser refuses a
+// pointer-lock request for about a second after a user-initiated Esc — but DROPPING
+// the press was never part of that: the button simply did nothing, with no feedback,
+// which is precisely the "I left play and could not get back in" report. Deferring is
+// the whole fix, and it costs one flag.
+let playQueued = false;
+
+/* ------------------------------------------------------------------ the press -- */
+
+/**
+ * The one entry point. Enters an immersive session when that is what this press
+ * means, and desktop play otherwise.
+ *
+ * It must stay SYNCHRONOUS on the XR path: `requestSession` is only granted inside
+ * the user gesture that asked for it, so nothing here may await.
+ */
+export function requestPlay() {
+ if (get(willEnterXR) && typeof document !== 'undefined') {
+ // Both hidden XR buttons are mounted permanently (Controls.svelte), so the aimed
+ // one is already in the DOM even when the preference was flipped by the menu item
+ // that is calling us — there is no remount to wait for.
+ const aimed = /** @type {HTMLElement | null} */ (
+ document.querySelector(get(vrPassthrough) ? '#vrButtonAr button' : '#vrButtonVr button')
+ );
+ // The label test is BELT AND BRACES, not the decision. threlte writes
+ // 'Enter VR' / 'Enter AR' only while a session can be STARTED, and flips it to
+ // 'Exit …' once one runs — clicking THAT would leave the session we were asked to
+ // enter — and to a not-supported text when its own probe says no. The probes
+ // above decide; this refuses a click that would do the wrong thing.
+ if (aimed && (aimed.textContent ?? '').trim().startsWith('Enter')) {
+ isVRMode.set(true);
+ aimed.click();
+ return;
+ }
+ }
+ // already in play — a second press is not a re-entry
+ if (get(isLocked) === true) return;
+ // 21-F3: inside the exit cooldown, REMEMBER the press instead of eating it.
+ // `isLocked === false` is the transient the exit path writes (the subscription below
+ // settles it to null), so both non-null values land here.
+ if (allowPlay !== true || get(isLocked) === false) {
+ playQueued = true;
+ return;
+ }
+ // NOTE for whoever tidies this: `isLocked` is `writable(null)`, so TS infers
+ // Writable and every `.set(true)` in the app reads as an error — twelve of
+ // them sit in PointerLockControls alone. This one MOVED here with the code and is
+ // part of the 385 baseline. Casting it away locally would drop the count to 384;
+ // the real fix is a JSDoc annotation on the store, which moves the baseline by a
+ // dozen and belongs in its own change.
+ if (get(isLocked) === null) isLocked.set(true);
+}
+
+// The exit debounce, formerly Controls' `$effect`. `isLocked === false` is what the
+// exit path writes; it settles to null here and locks re-entry out for two seconds.
+if (typeof window !== 'undefined') {
+ isLocked.subscribe((v) => {
+ if (v !== false) return;
+ allowPlay = false;
+ // NEVER write a store from inside its own subscriber (flush loop) — hop out of
+ // the notification first. It costs one macrotask and no semantics: every reader
+ // of the transient `false` already treats it as "not playing, not ready yet".
+ setTimeout(() => {
+ if (get(isLocked) === false) isLocked.set(null);
+ }, 0);
+ setTimeout(() => {
+ allowPlay = true;
+ // 21-F3: honour a press made during the cooldown. Through `requestPlay`, not a
+ // bare store write, so the XR branch and the guards above still decide.
+ if (playQueued) {
+ playQueued = false;
+ requestPlay();
+ }
+ }, 2000);
+ });
+}
diff --git a/tests/e2e/play-mode-menu.test.cjs b/tests/e2e/play-mode-menu.test.cjs
new file mode 100644
index 00000000..b595ab40
--- /dev/null
+++ b/tests/e2e/play-mode-menu.test.cjs
@@ -0,0 +1,150 @@
+// Phase 4a: the play FAB's right-click MODE MENU. VR / AR / desktop used to be
+// reachable only through two buried Settings toggles, and the FAB decided what a
+// press meant by sniffing threlte's private button label while its own glyph read
+// the WebXR support probes — two sources for one decision. $lib/playMode is the
+// single truth now, and this menu is a second caller of it.
+//
+// Headless Chromium has no `navigator.xr`, so both probes stay false: that IS the
+// desktop case, and it is what makes the disabled rows assertable here.
+const h = require('./helpers.cjs');
+
+const lockedState = (page) =>
+ page.evaluate(() => new Promise((r) => window.__stores.isLocked.subscribe((v) => r(v))()));
+
+/** every row of the open menu, with the two things that decide how it reads */
+const menuRows = (page) =>
+ page.evaluate(() =>
+ [...document.querySelectorAll('[role="menuitem"]')].map((el) => {
+ const style = getComputedStyle(el);
+ return {
+ label: el.textContent.trim(),
+ cls: el.className,
+ title: el.getAttribute('title') ?? '',
+ weight: style.fontWeight,
+ color: style.color
+ };
+ })
+ );
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+
+ // ---- premise: no WebXR here, so this is the desktop case ----------------------
+ // `navigator.xr` EXISTS in headless Chromium — it simply answers "unsupported" —
+ // so the premise has to ask the same question $lib/playMode asks, not test for the
+ // object's presence.
+ const probes = await A.page.evaluate(async () => ({
+ vr: await navigator.xr?.isSessionSupported('immersive-vr').catch(() => false),
+ ar: await navigator.xr?.isSessionSupported('immersive-ar').catch(() => false),
+ override: localStorage.getItem('vrOverride')
+ }));
+ h.check(
+ !probes.vr && !probes.ar,
+ `premise: this runtime supports neither immersive mode (vr=${probes.vr} ar=${probes.ar})`
+ );
+ h.check(probes.override === null, 'premise: no desktop override is set yet');
+ const fabTitle = () =>
+ A.page.evaluate(() => document.getElementById('play-button')?.getAttribute('title') ?? '');
+ h.check((await fabTitle()) === 'Play', 'the FAB says Play with no immersive support');
+ h.check(
+ (await lockedState(A.page)) === null,
+ 'premise: the app starts in the editor, not in play'
+ );
+
+ // both hidden XR buttons are mounted permanently — that is what lets a menu pick
+ // flip the mode and enter inside ONE user gesture (no remount to wait for)
+ const mounts = await A.page.evaluate(() => ({
+ vr: !!document.querySelector('#vrButtonVr button'),
+ ar: !!document.querySelector('#vrButtonAr button'),
+ aim: document.getElementById('vrButton')?.dataset.aim ?? ''
+ }));
+ h.check(mounts.vr && mounts.ar, 'both hidden XR buttons are in the DOM at once');
+ h.check(mounts.aim === 'vr', 'the aim echoes the preference (vr by default)');
+
+ // ---- the menu opens on a REAL right-click ------------------------------------
+ h.check((await menuRows(A.page)).length === 0, 'premise: no menu is open before the press');
+ await A.page.locator('#play-button').click({ button: 'right' });
+ await A.page.waitForTimeout(350);
+ let rows = await menuRows(A.page);
+ const labels = rows.map((r) => r.label);
+ h.check(rows.length === 3, `the mode menu opens with three entries (${rows.length})`);
+ h.check(
+ labels.includes('Play (desktop)') &&
+ labels.includes('Enter VR') &&
+ labels.includes('Enter AR passthrough'),
+ `the three modes are offered (${labels.join(' | ')})`
+ );
+ const section = await A.page.evaluate(
+ () => [...document.querySelectorAll('.ctx-section')].map((el) => el.textContent.trim())
+ );
+ h.check(section.includes('Play as'), `the group is headed "Play as" (${section.join(',')})`);
+ // stopPropagation on the FAB's own direct listener: the viewport menu must NOT
+ // also open behind it
+ const menus = await A.page.evaluate(() => document.querySelectorAll('[role="menu"]').length);
+ h.check(menus === 1, `exactly one menu opened (${menus})`);
+
+ // ---- the checked row is the one a press would take ---------------------------
+ // The repo's rule: `checked` renders as BOLD + a tinted pill, never a glyph — so
+ // the assertion is the computed weight, with the class as corroboration.
+ const desktop = rows.find((r) => r.label === 'Play (desktop)');
+ const vr = rows.find((r) => r.label === 'Enter VR');
+ const ar = rows.find((r) => r.label === 'Enter AR passthrough');
+ h.check(
+ Number(desktop.weight) >= 600 && desktop.cls.includes('ctx-checked'),
+ `desktop is the checked mode (weight ${desktop.weight})`
+ );
+ h.check(
+ Number(vr.weight) < 600 && Number(ar.weight) < 600,
+ `the two immersive rows are not checked (${vr.weight}/${ar.weight})`
+ );
+
+ // ---- unsupported modes are offered but refused, WITH the reason ---------------
+ h.check(
+ vr.cls.includes('cursor-default') && ar.cls.includes('cursor-default'),
+ 'VR and AR render disabled with no support'
+ );
+ h.check(
+ vr.title === 'No immersive-vr support detected' &&
+ ar.title === 'No immersive-ar (passthrough) support detected',
+ `both say why they are refused (vr:"${vr.title}")`
+ );
+ h.check(
+ !desktop.cls.includes('cursor-default'),
+ 'the desktop row stays live — there is always a way to play'
+ );
+
+ // a disabled row must do NOTHING, not silently pick a mode
+ await A.page.getByRole('menuitem', { name: 'Enter VR' }).click();
+ await A.page.waitForTimeout(250);
+ const afterDisabled = await A.page.evaluate(() => ({
+ rows: document.querySelectorAll('[role="menuitem"]').length,
+ override: localStorage.getItem('vrOverride'),
+ passthrough: localStorage.getItem('vrPassthrough')
+ }));
+ h.check(
+ afterDisabled.rows === 3 && afterDisabled.override === null,
+ 'clicking a disabled mode neither closes the menu nor writes a preference'
+ );
+
+ // ---- PICK AND ENTER: the action writes the preference and starts play ---------
+ await A.page.getByRole('menuitem', { name: 'Play (desktop)' }).click();
+ await h.eventually(() => lockedState(A.page), (v) => v === true, 'the pick enters play mode');
+ const stored = await A.page.evaluate(() => localStorage.getItem('vrOverride'));
+ h.check(stored === 'true', `the desktop pick persists the override (${stored})`);
+ const storeSide = await A.page.evaluate(
+ () => new Promise((r) => window.__stores.vrOverride.subscribe((v) => r(v))())
+ );
+ h.check(!!storeSide, 'the store half of the override moved with the localStorage half');
+ const closed = await menuRows(A.page);
+ h.check(closed.length === 0, 'the menu closed behind the pick');
+
+ // ---- and back out ------------------------------------------------------------
+ await A.page.keyboard.press('Escape');
+ await h.eventually(() => lockedState(A.page), (v) => v !== true, 'Escape leaves play mode');
+ // the extracted cooldown still settles the transient `false` back to null
+ await h.eventually(() => lockedState(A.page), (v) => v === null, 'the exit transient settles to null');
+ h.check((await fabTitle()) === 'Play', 'the FAB reads Play again after the round trip');
+
+ await h.finish(browser);
+});
diff --git a/tests/e2e/vr-passthrough.test.cjs b/tests/e2e/vr-passthrough.test.cjs
index 1af835fb..9c2e2428 100644
--- a/tests/e2e/vr-passthrough.test.cjs
+++ b/tests/e2e/vr-passthrough.test.cjs
@@ -9,11 +9,22 @@ h.run(async () => {
const browser = await h.launch();
const A = await h.setupPage(browser, 'A');
- const buttonLabel = () =>
- A.page.evaluate(() => document.querySelector('#vrButton button')?.textContent?.trim() ?? '');
+ // 4a: BOTH hidden XR buttons mount permanently (they used to swap on the
+ // preference, a remount that races a same-gesture mode pick), so a label no longer
+ // says which mode is aimed — `#vrButton[data-aim]` does. The labels still prove
+ // each button asks for the session KIND it is supposed to ask for.
+ const labelOf = (sel) =>
+ A.page.evaluate((s) => document.querySelector(s)?.textContent?.trim() ?? '', sel);
+ const aim = () =>
+ A.page.evaluate(() => document.getElementById('vrButton')?.dataset.aim ?? '');
- let label = await buttonLabel();
- h.check(label.includes('VR'), `hidden button requests immersive-vr by default (${label})`);
+ const vrLabel = await labelOf('#vrButtonVr button');
+ const arLabel = await labelOf('#vrButtonAr button');
+ h.check(
+ vrLabel.includes('VR') && arLabel.includes('AR'),
+ `both hidden buttons are mounted, one per mode (vr:"${vrLabel}" ar:"${arLabel}")`
+ );
+ h.check((await aim()) === 'vr', 'immersive-vr is aimed by default');
// the quick-menu tile flips the preference (real VR user path)
await A.page.evaluate(() => window.__stores.vrControls.executeVRMenuAction('settings:passthrough')); // 187: moved into the VR settings panel
@@ -22,8 +33,11 @@ h.run(async () => {
h.check(toastShown, 'tile toggle explains it applies next session');
const persisted = await A.page.evaluate(() => localStorage.getItem('vrPassthrough'));
h.check(persisted === 'true', 'preference persisted');
- label = await buttonLabel();
- h.check(label.includes('AR'), `button now requests immersive-ar (${label})`);
+ h.check((await aim()) === 'ar', 'the aim moves to immersive-ar');
+ h.check(
+ (await labelOf('#vrButtonAr button')).includes('AR'),
+ 'the aimed button is the immersive-ar one'
+ );
// passthroughActive is a LOCAL view mode: sky+fog lift, env state untouched
const envBefore = await A.page.evaluate(
@@ -91,14 +105,17 @@ h.run(async () => {
// survives a reload (localStorage-backed store + button swap)
await A.page.reload();
await A.page.waitForTimeout(2500);
- label = await buttonLabel();
+ const aimAfterReload = await aim();
const still = await A.page.evaluate(
() =>
new Promise((resolve) => {
window.__stores.vrPassthrough.subscribe((v) => resolve(v))();
})
);
- h.check(still === true && label.includes('AR'), `passthrough preference survives reload (${label})`);
+ h.check(
+ still === true && aimAfterReload === 'ar',
+ `passthrough preference survives reload (aim=${aimAfterReload})`
+ );
await h.finish(browser);
});
From 55f8b5c15ada71912e689708742c5c1826ddaa35 Mon Sep 17 00:00:00 2001
From: AlexZ005
Date: Thu, 27 Aug 2026 16:39:35 +0300
Subject: [PATCH 05/21] [feat] the controls pill is a customizable roster
- 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
---
src/components/editors/Explorer.svelte | 18 +-
src/components/menu/Controls.svelte | 643 ++++++++++++++++++++-----
src/stores/appStore.js | 18 +
tests/e2e/controls-roster.test.cjs | 238 +++++++++
tests/e2e/dock-inset.test.cjs | 6 +-
tests/e2e/play-mode-menu.test.cjs | 16 +-
6 files changed, 803 insertions(+), 136 deletions(-)
create mode 100644 tests/e2e/controls-roster.test.cjs
diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte
index 7e9484fd..7185688a 100644
--- a/src/components/editors/Explorer.svelte
+++ b/src/components/editors/Explorer.svelte
@@ -9,7 +9,7 @@
// undocks into a floating window.
import { get } from 'svelte/store';
import { tick, untrack } from 'svelte';
- import { explorerClose, mobileUndockAllowed, explorerSceneSaveArm, peers } from '../../stores/appStore.js';
+ import { explorerClose, mobileUndockAllowed, explorerSceneSaveArm, explorerDockArm, peers } from '../../stores/appStore.js';
import { showToast, enable3dPreview, stackOnDrop, confirmPrefabUpdate } from '../../stores/appStore.js';
import {
explorerFolders,
@@ -229,6 +229,22 @@
if (v) bottomDockActive.set('explorer'); // re-docking makes it the visible panel
}
+ // 4b: CONSUME the dock arm. The Controls toolbar's Explorer menu offers "Open as
+ // dock tab" / "Open as floating window", and `docked` above is read from
+ // localStorage exactly ONCE, at mount — so the toolbar writing that flag would be
+ // inert at a live panel and the row would read as a dead button. It asks through
+ // the store instead and `setDocked` (which owns the flag, this branch and the dock
+ // occupancy together) is what acts. Same write-once shape as `explorerSceneSaveArm`.
+ $effect(() => {
+ const arm = $explorerDockArm;
+ if (!arm) return;
+ explorerDockArm.set(null);
+ untrack(() => {
+ if (arm.docked !== docked) setDocked(arm.docked);
+ explorerClose.set(false); // the rows say "Open as …", so open it
+ });
+ });
+
// A dock tab like any other: report docked+open (+ the SHARED dock height, which
// feeds --bottom-inset) so the strip lists it, and render only while it is the
// visible tab. Being covered by another tab closes nothing — this stays open.
diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte
index 1fabec0f..7a2b3a5c 100644
--- a/src/components/menu/Controls.svelte
+++ b/src/components/menu/Controls.svelte
@@ -1,8 +1,8 @@
- over the `visibleCells` roster, no longer flowbite's
+ `BottomNav` (whose inner grid column count must be a JIT literal, which a
+ customizable cell count cannot be). The class list is flowbite's own RESOLVED
+ output for `position="absolute" navType="application"` plus this component's
+ overrides, read off the rendered DOM — same border, surface, radius and 40px
+ height. `bottom-4` stays in it purely as the class the inline style overrides,
+ exactly as before. The cells are `w-10` literals now: the grid's content-sized
+ `fr` columns used to take their width from the spacer, and a flex row has to say
+ it out loud. -->
+