diff --git a/data/asteroids.json b/data/asteroids.json
index 19fe46f..caf83bc 100644
--- a/data/asteroids.json
+++ b/data/asteroids.json
@@ -53,7 +53,7 @@
"bandLen": 46
},
"popup": {
- "width": 212,
+ "width": 248,
"padTop": 10,
"padBottom": 12,
"gap": 8,
diff --git a/dev/popup-shot.html b/dev/popup-shot.html
new file mode 100644
index 0000000..b0b53f8
--- /dev/null
+++ b/dev/popup-shot.html
@@ -0,0 +1,35 @@
+
+
+
+
+ Orbit — dev: mining popup shot
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/popup-shot.mjs b/dev/popup-shot.mjs
new file mode 100644
index 0000000..62601e9
--- /dev/null
+++ b/dev/popup-shot.mjs
@@ -0,0 +1,90 @@
+/**
+ * Dev: screenshot the mining context menu — in the REAL web font — and
+ * report whether the buttons fit inside the panel.
+ *
+ * The menu sizes itself from its buttons (js/ui/MiningPopup.js), and the
+ * buttons measure their label in whatever font is live at construction.
+ * index.html waits for the typefaces before boot (js/main.js → awaitFonts),
+ * so this probe does the same before creating the game.
+ *
+ * python3 -m http.server 8080
+ * node dev/wdshot.mjs http://localhost:8080/dev/popup-shot.html /tmp/popup.png \
+ * 'window.__POPUP_SHOT__(); return window.__POPUP_SHOT__RESULT__;' 40000 5000
+ */
+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);
+
+// The game measures its UI text in the web font (Centauri) — wait for it
+// BEFORE the scenes build any text (same contract as js/main.js).
+const __fonts = globalThis.document?.fonts;
+if (__fonts && typeof __fonts.load === 'function') {
+ const fams = ['header', 'body']
+ .map((k) => config.get(`theme.fonts.${k}.family`))
+ .filter((f) => typeof f === 'string' && f.length > 0);
+ if (fams.length > 0) {
+ await Promise.race([
+ Promise.allSettled(fams.map((f) => __fonts.load(`16px "${f}"`).catch(() => {}))),
+ new Promise((r) => setTimeout(r, 2000)),
+ ]);
+ }
+}
+
+// A deterministic galaxy (same clusters every run) + quiet audio in headless.
+globalThis.__ORBIT_DEV_SEED = 'MINING';
+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;
+
+/**
+ * Frame the first cluster, open the mining menu on it, pump until the open
+ * tween is done, and report the panel/button geometry (px).
+ */
+window.__POPUP_SHOT__ = () => {
+ const g = window.game;
+ const s = g.scene.getScene('GameScene');
+ if (!s || !Array.isArray(s.asteroidClusters) || s.asteroidClusters.length === 0) {
+ return { ready: false, note: s ? 'no clusters (yet?)' : 'no scene' };
+ }
+ const c = s.asteroidClusters[0];
+ const m = c.members[0];
+
+ // Park the ship by the rock so the camera frames it, and centre on it.
+ s.ship.stop();
+ s.ship.x = m.wx + 34;
+ s.ship.y = m.wy - 22;
+ const cam = s.cameras.main;
+ cam.scrollX = m.wx - cam.width / 2;
+ cam.scrollY = m.wy - cam.height / 2;
+
+ const rock = { cluster: c, member: m, x: m.wx, y: m.wy };
+ s.openMiningMenu(rock, { x: cam.width / 2, y: m.wy - cam.scrollY });
+
+ // Pump until the open tween (150 ms) is done — this box freezes rAF.
+ for (let i = 0; i < 300 && s.miningPopup.alpha < 0.999; i++) g.loop.step(performance.now());
+
+ const pop = s.miningPopup;
+ const r = {
+ ready: true,
+ fontStatus: __fonts?.status ?? 'n/a',
+ header: pop.headerText.text,
+ headerW: Math.round(pop.headerText.width),
+ panelW: pop.W,
+ panelH: pop.H,
+ primary: { label: pop.primaryBtn.labelText.text, labelW: Math.round(pop.primaryBtn.labelText.width), btnW: pop.primaryBtn.style.width },
+ cancel: { label: pop.cancelBtn.labelText.text, labelW: Math.round(pop.cancelBtn.labelText.width), btnW: pop.cancelBtn.style.width },
+ primaryFits: pop.primaryBtn.style.width + 24 <= pop.W,
+ cancelFits: pop.cancelBtn.style.width + 24 <= pop.W,
+ headerFits: pop.headerText.width <= pop.W - 16,
+ };
+ window.__POPUP_SHOT__RESULT__ = r;
+ return r;
+};
diff --git a/dev/wdshot.mjs b/dev/wdshot.mjs
index a0d86a8..7cf83d8 100644
--- a/dev/wdshot.mjs
+++ b/dev/wdshot.mjs
@@ -1,18 +1,23 @@
// One-shot headless-Firefox screenshot via geckodriver (W3C WebDriver).
//
-// node dev/wdshot.mjs [setupExpression] [timeoutMs=20000]
+// node dev/wdshot.mjs [setupExpression] [timeoutMs=20000] [settleMs=0]
//
// The setup expression (optional) runs right before the capture — it may
// return a Promise; the runner waits for it to settle (so scene state /
// animations can be driven first). WebDriver's /screenshot endpoint is
// used (not canvas.toDataURL — a WebGL canvas without
// preserveDrawingBuffer captures black).
+//
+// settleMs (optional): a runner-side settle wait AFTER navigation, before
+// the setup script runs. Pages with async module boot (top-level awaits —
+// e.g. dev/popup-shot.mjs waiting for the web fonts) need a beat before
+// their globals exist.
import { spawn, execFileSync } from 'node:child_process';
import { existsSync, readdirSync, writeFileSync } from 'node:fs';
-const [url, out, setup, timeoutMs = '20000'] = process.argv.slice(2);
+const [url, out, setup, timeoutMs = '20000', settleMs = '0'] = process.argv.slice(2);
if (!url || !out) {
- console.error('usage: node dev/wdshot.mjs [setupExpression] [timeoutMs]');
+ console.error('usage: node dev/wdshot.mjs [setupExpression] [timeoutMs] [settleMs]');
process.exit(2);
}
@@ -103,6 +108,11 @@ try {
await wd('POST', `/session/${sid}/url`, { url });
+ // Settle: let the page finish its async boot (module top-level awaits)
+ // before the setup script runs — a runner-side wait, since the page's
+ // own timers may be starved in headless.
+ if (Number(settleMs) > 0) await sleep(Number(settleMs));
+
if (setup) {
// Multiple scripts, each held as a pending Promise under execute/sync
// (geckodriver honours the session script timeout set above — 3 min).
diff --git a/js/ui/MiningPopup.js b/js/ui/MiningPopup.js
index 4e04966..31cd214 100644
--- a/js/ui/MiningPopup.js
+++ b/js/ui/MiningPopup.js
@@ -88,6 +88,16 @@ export class MiningPopup extends Phaser.GameObjects.Container {
this.cancelBtn.style.neon = dim;
this.cancelBtn.panel.on('pointerdown', () => this.fire('cancel'));
+ // The data width is a BASE, not a ceiling: MenuButton measures itself
+ // in the real (web) font and won't shrink below its label (width =
+ // max(requested, labelWidth + 20)). Keep the panel wide enough that the
+ // widest button still sits fully inside it, 12 px out from each edge.
+ this.W = Math.max(
+ this.W,
+ this.primaryBtn.style.width + 24,
+ this.cancelBtn.style.width + 24,
+ );
+
const h1 = this.primaryBtn.style.height;
const h2 = this.cancelBtn.style.height;
this.H = this.padTop + this.headerH + this.gap + h1 + this.gap + h2 + this.padBottom;
@@ -219,14 +229,22 @@ export class MiningPopup extends Phaser.GameObjects.Container {
return !!r && wx >= r.x && wx <= r.x + r.w && wy >= r.y && wy <= r.y + r.h;
}
- /** Lay the panel out: dir −1 = above the anchor (default), +1 = below. */
+ /**
+ * Lay the panel out: dir −1 = above the anchor (default), +1 = below.
+ * p() maps a distance from the PANEL'S TOP EDGE (0 = top edge) to a
+ * container offset: the panel's near edge sits at the anchor, so above
+ * it the whole panel hangs up (top edge at −H) and below it the panel
+ * grows down (top edge at 0). Top→bottom is always
+ * header · MINE ASTEROIDS · CANCEL, whichever side it opens on.
+ */
layout(dir) {
+ const p = (top) => (dir < 0 ? top - this.H : top);
this.panelG.setPosition(0, dir * (this.H / 2));
- this.headerText.setPosition(0, dir * (this.H - this.padTop - this.headerH / 2));
- const y1 = this.H - this.padTop - this.headerH - this.gap - this.h1 / 2;
- const y2 = y1 + this.h1 / 2 + this.gap + this.h2 / 2;
- this.primaryBtn.setPosition(0, dir * y1);
- this.cancelBtn.setPosition(0, dir * y2);
+ this.headerText.setPosition(0, p(this.padTop + this.headerH / 2));
+ const top1 = this.padTop + this.headerH + this.gap + this.h1 / 2; // primary, from top
+ const top2 = top1 + this.h1 / 2 + this.gap + this.h2 / 2; // cancel, from top
+ this.primaryBtn.setPosition(0, p(top1));
+ this.cancelBtn.setPosition(0, p(top2));
}
/** Fire an action id to the scene (its handler closes the menu). */