diff --git a/dev/clickout-test.html b/dev/clickout-test.html
new file mode 100644
index 0000000..2db2f87
--- /dev/null
+++ b/dev/clickout-test.html
@@ -0,0 +1,29 @@
+
+
+
+
+ Orbit — dev click-outside sub-menu test
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/clickout-test.mjs b/dev/clickout-test.mjs
new file mode 100644
index 0000000..eab0419
--- /dev/null
+++ b/dev/clickout-test.mjs
@@ -0,0 +1,190 @@
+/**
+ * Click-outside the sub-menu (headless browser — NOT a Node test).
+ *
+ * Drives the REAL game's input pipeline (mousedown on the canvas) and
+ * checks the dismiss rule in GameScene's pointerdown handler:
+ *
+ * sub-bar OPEN + click OUTSIDE it (world) → closes, no fly-here
+ * sub-bar OPEN + click on its own panel → stays open
+ * sub-bar OPEN + a sub-bar button (Save Game) → the button still fires
+ * sub-bar OPEN + click on the MENU deck button → closes, no re-open
+ * sub-bar OPEN + compass autopilot seam → closes, no navigation
+ * after any of those, MENU still re-opens it
+ *
+ * Served by dev/clickout-test.html; the results land in
+ * `window.__CLICKOUT__` for the CDP runner (dev/cdp-firefox.mjs):
+ *
+ * python3 -m http.server 8080
+ * node dev/cdp-firefox.mjs http://localhost:8080/dev/clickout-test.html
+ */
+import Phaser from '../js/vendor/phaser.js';
+import { config } from '../js/config/Config.js';
+import { ConfigLoader } from '../js/config/ConfigLoader.js';
+import { createGameConfig } from '../js/config/GameConfig.js';
+import { GameScene } from '../js/scenes/GameScene.js';
+
+const data = await ConfigLoader.load();
+config.init(data);
+
+// A deterministic galaxy (same system every run) + a quiet run (no audio
+// files to fetch in headless).
+globalThis.__ORBIT_DEV_SEED = 'CLICKOUT';
+const gameConfig = createGameConfig();
+gameConfig.scene = [GameScene]; // GameScene boots first
+if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
+
+const game = new Phaser.Game(gameConfig);
+window.game = game;
+
+const results = [];
+const check = (label, cond) => {
+ const pass = !!cond;
+ results.push({ label, pass });
+ console.log(`${pass ? '✔' : '✘ FAIL'} ${label}`);
+};
+
+// WAIT — throttling-proof. This headless box starves setTimeout and
+// stretches its compositor clock, so the only honest barrier is the
+// GAME'S OWN CLOCK: Phaser v4 sets scene time.now from the engine loop
+// time (monotonic across scene transitions). Poll it on rAF.
+const gameClock = () => {
+ try {
+ const s = window.game.scene.getScenes(true)[0];
+ if (s && typeof s.time.now === 'number') return s.time.now;
+ } catch {}
+ return null;
+};
+const wait = (ms) => new Promise((resolve) => {
+ const base = gameClock();
+ if (base === null) {
+ const start = performance.now();
+ setTimeout(() => resolve(), ms);
+ return;
+ }
+ const poll = () => {
+ const now = gameClock();
+ if (now !== null && now - base >= ms) return resolve();
+ requestAnimationFrame(poll);
+ };
+ requestAnimationFrame(poll);
+});
+
+// A real mouse press at CANVAS screen coords (x, y). Phaser 4.2.1 binds
+// mousedown/mouseup/mousemove on the canvas element, so a dispatched
+// MouseEvent drives the full input pipeline (pointer → scene handler →
+// interactive objects). FIT mode scales the canvas, so map canvas →
+// client space with the rendered rect (not 1:1 pixels).
+const press = (x, y) => {
+ const canvas = game.canvas;
+ const r = canvas.getBoundingClientRect();
+ const zoom = r.width / canvas.width;
+ const mk = (type, buttons) => new MouseEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ view: window,
+ button: 0,
+ buttons,
+ clientX: r.left + x * zoom,
+ clientY: r.top + y * zoom,
+ });
+ canvas.dispatchEvent(mk('mouseover', 0));
+ canvas.dispatchEvent(mk('mousedown', 1));
+ canvas.dispatchEvent(mk('mouseup', 0));
+};
+
+const OPEN_MS = 450; // openMs (340) + button decode margin
+const CLOSE_MS = 350; // closeMs (200) + margin
+
+const run = async () => {
+ await wait(900); // boot: galaxy, system, deck, sub-bar
+ const scene = game.scene.getScene('GameScene');
+ check('boots into GameScene', game.scene.isActive('GameScene'));
+ check('the sub-bar starts closed', scene.menuSubBar.isOpen === false);
+
+ // ---- 1) open with MENU, dismiss with a click in open space -----------
+ scene.menuAction();
+ await wait(OPEN_MS);
+ check('MENU folds the sub-bar up', scene.menuSubBar.isOpen);
+ const targetBefore = scene.ship.target;
+ press(scene.scale.width / 2, scene.scale.height * 0.4); // open space
+ await wait(CLOSE_MS);
+ check('a click OUTSIDE the sub-bar closes it',
+ scene.menuSubBar.isOpen === false && scene.menuSubBar.state === 'closed');
+ check('the dismissing click does NOT fly the ship',
+ scene.ship.target === targetBefore);
+
+ // ---- 2) a click on the sub-bar's own panel keeps it open -------------
+ scene.menuAction();
+ await wait(OPEN_MS);
+ check('the sub-bar re-opens', scene.menuSubBar.isOpen);
+ const bar = scene.menuSubBar.rect;
+ press(bar.x + 5, bar.y + bar.h / 2); // left padding — no button there
+ await wait(250);
+ check('a click on the sub-bar panel (not a button) keeps it open',
+ scene.menuSubBar.isOpen === true);
+
+ // ---- 3) a sub-bar button still fires while the bar is open -----------
+ const saveBtn = scene.menuSubBar.buttons.find((b) => b.id === 'save');
+ check('the Save Game button is inside the open bar',
+ scene.menuSubBar.contains(
+ scene.menuSubBar.x + saveBtn.btn.x,
+ scene.menuSubBar.y + saveBtn.btn.y));
+ press(scene.menuSubBar.x + saveBtn.btn.x, scene.menuSubBar.y + saveBtn.btn.y);
+ await wait(500);
+ check('Save Game still opens the 10-slot pop-up',
+ scene.savePanel.isOpen && scene.savePanel.mode === 'save');
+ scene.savePanel.close(); // the sub-bar stays open behind it (existing)
+ await wait(500);
+
+ // ---- 4) MENU toggles it closed again (toggle path intact) ------------
+ check('the sub-bar is still open under the closed pop-up',
+ scene.menuSubBar.isOpen === true);
+ scene.menuAction();
+ await wait(CLOSE_MS);
+ check('MENU folds the sub-bar back down', scene.menuSubBar.state === 'closed');
+
+ // ---- 5) clicking the MENU deck button closes it — no re-open ---------
+ scene.menuAction();
+ await wait(OPEN_MS);
+ check('the sub-bar re-opens (deck-button test)', scene.menuSubBar.isOpen);
+ const menuSlot = scene.actionBar.slots.find((s) => s.id === 'menu');
+ press(menuSlot.slot.x, menuSlot.slot.y);
+ await wait(CLOSE_MS);
+ check('clicking the MENU button closes the sub-bar (no re-open)',
+ scene.menuSubBar.isOpen === false && scene.menuSubBar.state === 'closed');
+
+ // ---- 6) the compass seam can't navigate while the bar is open --------
+ scene.menuAction();
+ await wait(OPEN_MS);
+ check('the sub-bar re-opens (compass test)', scene.menuSubBar.isOpen);
+ const targetBefore2 = scene.ship.target;
+ scene.autopilotTo('home'); // the chip's seam, with the bar open
+ await wait(CLOSE_MS);
+ check('a compass autopilot while open: bar closes, ship NOT targeted',
+ scene.menuSubBar.state === 'closed' && scene.ship.target === targetBefore2);
+
+ // ---- 7) all dismiss paths left the bar re-openable -------------------
+ scene.menuAction();
+ await wait(OPEN_MS);
+ check('the sub-bar re-opens after every dismiss path', scene.menuSubBar.isOpen);
+};
+
+let done = false;
+game.events.once('ready', async () => {
+ try {
+ await run();
+ } catch (err) {
+ results.push({ label: `THREW: ${String(err && err.message || err)}`, pass: false });
+ }
+ const pass = results.length > 0 && results.every((r) => r.pass);
+ window.__CLICKOUT__ = { pass, results, errors: window.__CAPTURED_ERRORS__ || [] };
+ done = true;
+ console.log(pass ? 'CLICKOUT PASS' : 'CLICKOUT FAIL');
+});
+
+// Hard timeout: a hung flow must not hang the driver forever.
+setTimeout(() => {
+ if (!done) {
+ window.__CLICKOUT__ = { pass: false, results: [...results, { label: 'TIMED OUT (300s)', pass: false }] };
+ }
+}, 300000);
diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js
index 01e2147..aab4add 100644
--- a/js/scenes/GameScene.js
+++ b/js/scenes/GameScene.js
@@ -326,8 +326,18 @@ export class GameScene extends Phaser.Scene {
// The save pop-up is MODAL — while it's up it owns all input
// (its scrim / cards / dialog eat the click; the world stays put).
if (this.savePanel && this.savePanel.isOpen) return;
- // A click ON the sub-bar is the sub-bar's (its buttons live there).
- if (this.menuSubBar && this.menuSubBar.isOpen && this.menuSubBar.contains(pointer.x, pointer.y)) return;
+ // Sub-bar OPEN: a click INSIDE it is its own (the panel or one of
+ // its buttons — the buttons fire on their own pointerdown). ANY
+ // click OUTSIDE — world, deck, HUD, compass — folds it back down
+ // and that click is done (no fly-here, no dossier toggle, no
+ // autopilot). Deck buttons still get their own press from the
+ // deck's handler; the Menu button's toggle is safe either order
+ // because open() is a no-op while the bar is still 'closing'.
+ if (this.menuSubBar && this.menuSubBar.isOpen) {
+ if (this.menuSubBar.contains(pointer.x, pointer.y)) return;
+ this.menuSubBar.close();
+ return;
+ }
if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return;
if (this.compass.contains(pointer.x, pointer.y)) return;
if (this.hudTitleContains(pointer.x, pointer.y)) {
@@ -875,6 +885,14 @@ export class GameScene extends Phaser.Scene {
* click wins).
*/
autopilotTo(id) {
+ // While the sub-bar is open (or this very click just closed it — the
+ // scene's handler and the chip's own listener race on event order),
+ // the chip's click is the menu-dismiss, not a navigation command.
+ const bar = this.menuSubBar;
+ if (bar && (bar.isOpen || bar.closing)) {
+ if (bar.isOpen) bar.close();
+ return;
+ }
const o = this.discoverableObjects().find((v) => v.id === id);
if (!o) return;
// The solid body behind this discovery entry (a world or a cluster).
diff --git a/js/ui/MenuSubBar.js b/js/ui/MenuSubBar.js
index 7cea124..8748732 100644
--- a/js/ui/MenuSubBar.js
+++ b/js/ui/MenuSubBar.js
@@ -199,6 +199,13 @@ export class MenuSubBar extends Phaser.GameObjects.Container {
return this.state === 'open' || this.state === 'opening';
}
+ /** True while the close animation is running (the bar is already dead
+ * as far as input goes — GameScene uses this so a click that dismissed
+ * it can't re-trigger its seams regardless of event order). */
+ get closing() {
+ return this.state === 'closing';
+ }
+
open() {
if (this.state !== 'closed' || this.dead) return;
this.state = 'opening';