Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 41811ead1c Shell 1.10.1: header menus open where their button is, folded bar or not; ProdCom 1.3.3 keeps its filter on a narrow tile
A tile's dropdown menus (ProdCom's Channels, the Timers menu) opened but
never showed under a folded-away title bar — in fullscreen, or with
auto-hide on. The bar carries a backdrop-filter and a transform while
folded, and either one makes a fixed-positioned descendant position
itself against the bar instead of the viewport: the menu landed a
screen-height below its button, inside the tile's overflow. A tile near
the top of the screen showed a misplaced menu; anything lower showed
nothing at all.

The shell now hosts every header menu on the body, under an element
carrying the tile's data-module so a module's scoped CSS still reaches
its items; places it whole inside the viewport; holds a folded bar open
while one of its menus is open (the pointer has to travel down into the
menu); and marks the open menu's button the way the gear is marked.

ProdCom: seven controls need ~175 px of bar, and the shell clips what a
narrow tile cannot fit — the Channels button went with the rest. Below
380 px the six secondary controls fold into a ⋯ menu offering the same
actions, so the filter is the last thing to go.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 14:08:02 -04:00
16 changed files with 249 additions and 127 deletions
+3
View File
@@ -16,3 +16,6 @@ launcher/macos/build/
launcher/macos/.cache/ launcher/macos/.cache/
launcher/windows/build/ launcher/windows/build/
launcher/windows/.cache/ launcher/windows/.cache/
# Compiled per-machine, source sits next to it (see spl-capture.swift).
modules/spl-meter/spl-capture
+2 -1
View File
@@ -461,7 +461,8 @@ const menu = moduleApi.header.addMenu({
build(menuEl) { build(menuEl) {
// called on every open with an emptied menu element — fill it with // called on every open with an emptied menu element — fill it with
// buttons (give them your scoped classes; the container is styled and // buttons (give them your scoped classes; the container is styled and
// fixed-positioned by the shell, so the tile's overflow can't clip it) // positioned by the shell, hosted on the body under an element carrying
// your data-module, so your scoped CSS applies and nothing clips it)
}, },
}); });
menu.setLabel('Channels (2/4) ▾'); // update the button text any time menu.setLabel('Channels (2/4) ▾'); // update the button text any time
+81 -3
View File
@@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) }); moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
applyInstanceSettings(); applyInstanceSettings();
} }
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) }); const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) }); const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
// icon and tooltip track the current direction — applyInstanceSettings sets both // icon and tooltip track the current direction — applyInstanceSettings sets both
const flowBtn = moduleApi.header.addButton({ const flowBtn = moduleApi.header.addButton({
@@ -95,7 +95,7 @@ export default function create({ root, moduleApi }) {
}, },
}); });
moduleApi.header.addButton({ const latestBtn = moduleApi.header.addButton({
icon: JUMP_SVG, icon: JUMP_SVG,
title: 'Latest', title: 'Latest',
onClick() { onClick() {
@@ -112,6 +112,82 @@ export default function create({ root, moduleApi }) {
}); });
clearBtn.classList.add('pt-clear'); clearBtn.classList.add('pt-clear');
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
Seven controls need ~175 px, and the shell clips whatever the bar
cannot fit — on a 3-column tile among many, or one squeezed by the
fullscreen fit, that used to take the channel filter with it. Below
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
the same actions, so the filter button is the last thing to go. */
const MORE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
const COMPACT_PX = 380; // tile width below which the full bar no longer fits
let moreMenuEl = null;
const moreMenu = moduleApi.header.addMenu({
icon: MORE_SVG,
title: 'More',
build(menu) {
menu.classList.add('pt-menu');
moreMenuEl = menu;
rebuildMoreMenu();
},
});
const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd
const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn];
function addMoreItem(label, { on = null, onClick } = {}) {
const item = document.createElement('button');
item.className = 'pt-channel-item' + (on ? ' on' : '');
if (on !== null) {
item.setAttribute('role', 'menuitemcheckbox');
item.setAttribute('aria-checked', String(on));
} else {
item.setAttribute('role', 'menuitem');
}
const check = document.createElement('span');
check.className = 'check';
check.textContent = '✓';
const name = document.createElement('span');
name.className = 'name';
name.textContent = label;
item.append(check, name);
item.addEventListener('click', (e) => {
e.stopPropagation(); // the rebuild detaches this item — keep the menu open
onClick();
});
moreMenuEl.appendChild(item);
}
function rebuildMoreMenu() {
if (!moreMenuEl) return;
moreMenuEl.innerHTML = '';
const s = moduleApi.instanceSettings;
addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() });
addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() });
const divider = document.createElement('div');
divider.className = 'pt-menu-divider';
moreMenuEl.appendChild(divider);
addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) });
addMoreItem('Larger text', { onClick: () => bumpTextSize(2) });
addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } });
addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } });
}
let compact = null; // unknown until the first measurement
function applyCompact(width) {
const next = width > 0 && width < COMPACT_PX;
if (next === compact) return;
compact = next;
for (const btn of secondaryBtns) btn.hidden = compact;
moreWrap.hidden = !compact;
if (!compact) moreMenu.close();
}
// the tile's own body tells the width; onResize only knows grid cells
const sizeWatch = new ResizeObserver((entries) => {
applyCompact(entries[0]?.contentRect.width || root.clientWidth);
});
sizeWatch.observe(root);
applyCompact(root.clientWidth);
function applyInstanceSettings() { function applyInstanceSettings() {
const s = moduleApi.instanceSettings; const s = moduleApi.instanceSettings;
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
@@ -126,6 +202,7 @@ export default function create({ root, moduleApi }) {
flowBtn.classList.toggle('active', up); flowBtn.classList.toggle('active', up);
flowBtn.title = up ? 'Newest first' : 'Oldest first'; flowBtn.title = up ? 'Newest first' : 'Oldest first';
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages'; jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
rebuildMoreMenu(); // its checkmarks mirror the buttons
} }
applyInstanceSettings(); applyInstanceSettings();
@@ -415,6 +492,7 @@ export default function create({ root, moduleApi }) {
}, },
stop() { stop() {
stopped = true; stopped = true;
sizeWatch.disconnect();
stream?.close(); stream?.close();
root.innerHTML = ''; // header controls are removed by the shell root.innerHTML = ''; // header controls are removed by the shell
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "prodcom-transcript", "id": "prodcom-transcript",
"name": "ProdCom", "name": "ProdCom",
"version": "1.3.2", "version": "1.3.3",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "Live ProdCom transcript", "description": "Live ProdCom transcript",
"permissions": [ "permissions": [
+5 -1
View File
@@ -20,9 +20,13 @@
} }
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; } [data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */
[data-module="prodcom-transcript"] .tile-btn[hidden],
[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; }
/* ── channels menu ── /* ── channels menu ──
The menu container is created and positioned by the shell (.tile-menu); The menu container is created and positioned by the shell (.tile-menu);
it lives inside the tile, so [data-module] scoping still applies. */ it hangs off the body under a [data-module] host, so scoping still applies. */
[data-module="prodcom-transcript"] .pt-channel-item { [data-module="prodcom-transcript"] .pt-channel-item {
display: flex; display: flex;
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "spl-meter", "id": "spl-meter",
"name": "SPL Meter", "name": "SPL Meter",
"version": "0.5.0", "version": "0.4.3",
"proddash": ">=1.5.0", "proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [ "permissions": [
-48
View File
@@ -1,48 +0,0 @@
#!/bin/zsh
# Build this module's native helper for every macOS architecture it ships —
# ../bin/spl-capture-macos-arm64 and ../bin/spl-capture-macos-x86_64 —
# from spl-capture.swift beside this script. Run it on any Mac with the
# Xcode command line tools; both architectures cross-compile from either.
# The binaries are committed: a machine running ProdDash never builds
# anything (docs/MODULE-GUIDE.md, "Native helpers").
#
# Signed with a Developer ID Application certificate when one is installed
# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input
# entitlement, which a helper that reads a microphone needs under the
# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs.
#
# Windows is not built here: there is no Windows implementation of this
# tool yet, so on Windows the module reports measurement off.
set -e -u
cd "$(dirname "$0")"
NAME="spl-capture"
DEPLOYMENT_TARGET="13.0"
command -v swiftc >/dev/null || {
echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2
exit 1
}
IDENTITY="${CODESIGN_IDENTITY:-}"
if [[ -z "$IDENTITY" ]]; then
IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
fi
if [[ -n "$IDENTITY" ]]; then
echo "signing as $IDENTITY"
SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp)
else
echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)"
SIGN=(--force --sign - --options runtime)
fi
mkdir -p ../bin
for ARCH in arm64 x86_64; do
OUT="../bin/$NAME-macos-$ARCH"
echo " compiling $OUT"
swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift"
codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT"
codesign --verify --strict "$OUT"
echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))"
done
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The helper reads an audio input; under the hardened runtime that
needs saying. The permission itself comes from the launcher. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+20 -10
View File
@@ -20,7 +20,7 @@
*/ */
const path = require('path'); const path = require('path');
const { createSplListener, captureTool } = require('./spl-listener'); const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
/** Reference level of a sound level calibrator. Effectively always 94 dB. */ /** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94; const CALIBRATOR_DB = 94;
@@ -120,9 +120,12 @@ module.exports = {
pinger: null, pinger: null,
}; };
// The capture tool ships prebuilt in the module (bin/); with none for // Before anything else: the device picker shells out to this tool, so a
// this platform every reading is off and the health line says so. // machine that installed the module from the catalog (source only — the
state.toolMissing = captureTool(moduleDir).missing; // binary is gitignored) needs the build kicked off here. Waiting for a
// listener would never happen: no tool means no device list, and no
// device means no listener.
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
if (!config.enabled) { if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings'; state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
@@ -182,7 +185,9 @@ module.exports = {
return { return {
stop() { teardown(); }, stop() { teardown(); },
health() { health() {
if (state.toolMissing) return { status: 'error', message: state.toolMissing }; if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
return { status: 'error', message: state.toolMissing };
}
if (state.error) return { status: 'error', message: state.error }; if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' }; if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs(); const quiet = silentMs();
@@ -306,10 +311,11 @@ module.exports = {
/** Device list for the admin page. Runs the capture tool's --list, /** Device list for the admin page. Runs the capture tool's --list,
which is the only thing that knows what CoreAudio can see. */ which is the only thing that knows what CoreAudio can see. */
'GET /devices': async (req, res) => { 'GET /devices': async (req, res) => {
const tool = captureTool(__dirname); const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
if (tool.missing) { if (missing) {
log(`device list unavailable — ${tool.missing}`); if (state) state.toolMissing = missing;
json(res, 200, { options: [], error: tool.missing }); log(`device list unavailable — ${missing}`);
json(res, 200, { options: [] });
return; return;
} }
try { try {
@@ -441,7 +447,7 @@ function teardown() {
function listDevices() { function listDevices() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const { execFile } = require('child_process'); const { execFile } = require('child_process');
execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 }, execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
(err, stdout) => { (err, stdout) => {
if (err && !stdout) { reject(err); return; } if (err && !stdout) { reject(err); return; }
try { resolve(JSON.parse(stdout).devices || []); } try { resolve(JSON.parse(stdout).devices || []); }
@@ -450,6 +456,10 @@ function listDevices() {
}); });
} }
function fsExists(p) {
try { return require('fs').existsSync(p); } catch { return false; }
}
function json(res, code, body) { function json(res, code, body) {
const text = JSON.stringify(body); const text = JSON.stringify(body);
res.writeHead(code, { res.writeHead(code, {
@@ -8,11 +8,10 @@
// measurement is done here and only the answers cross the pipe. // measurement is done here and only the answers cross the pipe.
// //
// Everything below uses Accelerate, which ships with macOS. No third-party // Everything below uses Accelerate, which ships with macOS. No third-party
// code, no package manager. It ships prebuilt in the module's bin/ (one // code, no package manager, ~2 s to build:
// binary per platform, committed — a booth never builds anything). To
// rebuild after a change here, on any Mac with the Xcode command line tools:
// //
// modules/spl-meter/native/build.sh (both architectures) // swiftc -O -o modules/spl-meter/spl-capture \
// modules/spl-meter/spl-capture.swift
// //
// spl-capture --list // spl-capture --list
// every input-capable device: name, input channels, sample rate // every input-capable device: name, input channels, sample rate
+88 -35
View File
@@ -3,8 +3,7 @@
/** /**
* Sound level listener — owns the native capture child. * Sound level listener — owns the native capture child.
* *
* Spawns spl-capture (bin/spl-capture-macos-<arch>, shipped prebuilt; * Spawns spl-capture (compiled from spl-capture.swift beside it) for the
* source in native/) for the
* admin-configured device and channels, reads its JSON lines, and hands the * admin-configured device and channels, reads its JSON lines, and hands the
* latest frame upward. All measurement happens in the child; this file is * latest frame upward. All measurement happens in the child; this file is
* plumbing and supervision, nothing more. * plumbing and supervision, nothing more.
@@ -14,11 +13,12 @@
* { frame: {...}, meta: {...} } measuring * { frame: {...}, meta: {...} } measuring
* { error: '<what is wrong and how to fix it>' } not measuring * { error: '<what is wrong and how to fix it>' } not measuring
* *
* The capture tool ships prebuilt inside the module, one binary per * The capture binary is per-machine and gitignored, exactly like the Timers
* platform under bin/ (module guide, "Native helpers"), exactly like the * module's ltc-capture. It normally arrives already built — compiled beside
* Timers module's ltc-capture: nothing is ever built on the machine running * the app, or copied from any Mac of the same architecture. When it is
* ProdDash. captureTool() picks the binary for this platform; when there is * missing this will build it, but in the background and never on the event
* none, measurement reports itself off and the rest of the module carries on. * loop, and from module scope so the device picker can trigger it too — see
* ensureCaptureBinary for what both of those cost the first time round.
* *
* The same macOS gotcha applies as for LTC, and is worth repeating because * The same macOS gotcha applies as for LTC, and is worth repeating because
* it costs an afternoon every time: a ProdDash launched over SSH or from a * it costs an afternoon every time: a ProdDash launched over SSH or from a
@@ -31,31 +31,72 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */ const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
/** What the module ships in bin/, by platform and architecture. */ + 'and copy it next to spl-capture.swift';
const HELPERS = { const BUILDING = 'Building the capture tool — this takes a minute or two';
darwin: { arm64: 'spl-capture-macos-arm64', x64: 'spl-capture-macos-x86_64' },
win32: { x64: 'spl-capture-win-x64.exe' },
};
/** /**
* The capture tool for this machine. It is never built here and never * Build the capture tool if it is missing, WITHOUT blocking, and shared by
* looked for anywhere else: the module ships it, or measurement is off on * everything that needs it.
* this platform and says so. `bin` is the usable binary; otherwise
* `missing` is what to tell the admin page and the tiles.
* *
* @returns {{bin:string, missing:string}} * This lives at module scope rather than inside a listener because of a trap
* the first version walked into: the admin page's device picker runs the tool
* with --list, but the build only ran when a listener started, and a listener
* only starts once a device has been picked. On a machine that got the module
* from the catalog — source only, the binary being gitignored — the picker
* was empty, and nothing would ever fill it.
*
* It also must never block. It was a spawnSync once, copied from the LTC
* listener where the source is a third the size and builds in seconds. Here
* it took nearly two minutes on the production Mac and held the Node event
* loop for all of it, freezing every other module, then did it again on the
* next retry.
*
* The binary should normally already be there: built beside the app, or
* copied from any Mac of the same architecture. This is the fallback.
*
* @returns {string} '' when the tool is ready, otherwise what to say.
*/ */
function captureTool(moduleDir, platform = process.platform, arch = process.arch) { let building = false;
const name = HELPERS[platform]?.[arch] || ''; let buildFailed = false;
const bin = name ? path.join(moduleDir, 'bin', name) : '';
if (bin && fs.existsSync(bin)) return { bin, missing: '' }; function ensureCaptureBinary({ moduleDir, log, onReady }) {
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`; const bin = path.join(moduleDir, 'spl-capture');
return { if (fs.existsSync(bin)) return '';
bin: '', const src = bin + '.swift';
missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`, if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
}; if (building) return BUILDING;
if (buildFailed) return BUILD_HELP;
building = true;
log('spl-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (typeof onReady === 'function') onReady();
return;
}
buildFailed = true;
log(`spl-capture build failed (exit ${code})`
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return BUILDING;
} }
const RESTART_MS = 5000; const RESTART_MS = 5000;
@@ -73,6 +114,7 @@ const STALL_MS = 3000;
*/ */
function createSplListener(opts) { function createSplListener(opts) {
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts; const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
const bin = path.join(moduleDir, 'spl-capture');
let child = null; let child = null;
let stopped = false; let stopped = false;
@@ -82,6 +124,13 @@ function createSplListener(opts) {
let meta = null; let meta = null;
let lastFrameWall = 0; let lastFrameWall = 0;
/** '' when the tool is ready, otherwise what to say about it. */
function ensureBinary() {
return ensureCaptureBinary({ moduleDir, log, onReady() {
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
} });
}
function scheduleRestart(ms) { function scheduleRestart(ms) {
if (stopped || restartTimer) return; if (stopped || restartTimer) return;
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms); restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
@@ -101,11 +150,15 @@ function createSplListener(opts) {
function start() { function start() {
if (stopped) return; if (stopped) return;
const tool = captureTool(moduleDir); const missing = ensureBinary();
if (tool.missing) { if (missing) {
// No binary for this platform: say so and look again later — a module onUpdate({ error: missing });
// update remounts us anyway, this is only belt and braces. // onReady is only the FAST path (a successful build restarts us at
onUpdate({ error: tool.missing }); // once). The slow retry must stay armed regardless: a failed build
// never calls back, and a binary can also arrive from outside — scp'd
// in on a machine whose toolchain can't build (AB's broken CLT).
// Waiting on onReady alone deadlocked exactly that way in the Timers
// module: "building…" forever, with a working binary on disk.
scheduleRestart(BAD_CONFIG_RETRY_MS); scheduleRestart(BAD_CONFIG_RETRY_MS);
return; return;
} }
@@ -122,7 +175,7 @@ function createSplListener(opts) {
let stderrTail = ''; let stderrTail = '';
let buf = ''; let buf = '';
child = spawn(tool.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] }); child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8'); child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => { child.stderr.on('data', (text) => {
@@ -205,4 +258,4 @@ function createSplListener(opts) {
}; };
} }
module.exports = { createSplListener, captureTool }; module.exports = { createSplListener, ensureCaptureBinary };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.10.0", "version": "1.10.1",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+41 -10
View File
@@ -640,8 +640,10 @@ function buildTile(tile) {
el.addEventListener('pointermove', (ev) => { el.addEventListener('pointermove', (ev) => {
if (!headIsOff(tile)) return; if (!headIsOff(tile)) return;
// Mid-drag the tile moves under the pointer, and mid-resize the edges do // Mid-drag the tile moves under the pointer, and mid-resize the edges do
// — neither is a moment to take the bar away. // — neither is a moment to take the bar away. Nor is an open header
// menu, which hangs below the bar and is about to be pointed at.
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return; if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
if (el.classList.contains('menu-open')) return;
const y = ev.clientY - el.getBoundingClientRect().top; const y = ev.clientY - el.getBoundingClientRect().top;
const shown = el.classList.contains('head-peek'); const shown = el.classList.contains('head-peek');
// It takes less to hold the bar open than to open it, so a bar that // It takes less to hold the bar open than to open it, so a bar that
@@ -652,6 +654,7 @@ function buildTile(tile) {
el.addEventListener('pointerleave', () => { el.addEventListener('pointerleave', () => {
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return; if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu
el.classList.remove('head-peek'); el.classList.remove('head-peek');
}); });
@@ -659,7 +662,7 @@ function buildTile(tile) {
applyHeadState(tile, el); applyHeadState(tile, el);
grid.appendChild(el); grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null }; const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() };
tileEls.set(tile.id, entry); tileEls.set(tile.id, entry);
applyRect(tile); applyRect(tile);
@@ -1426,34 +1429,60 @@ function buildModuleApi(tile, man, entry) {
menu.className = 'tile-menu'; menu.className = 'tile-menu';
menu.hidden = true; menu.hidden = true;
/* The menu lives on the body, not in the bar. A folded-away bar
(auto-hide, fullscreen) carries a backdrop-filter and a transform,
and either one makes a fixed-positioned descendant position
itself against the bar instead of the viewport — the menu landed
a screen-height below its button, inside the tile's overflow, and
never showed. Its host carries the tile's data-module so the
module's scoped CSS still reaches the items. */
const host = document.createElement('div');
host.className = 'tile-menu-host';
if (man) host.dataset.module = man.id;
host.appendChild(menu);
const setOpen = (on) => {
if (on === !menu.hidden) return;
menu.hidden = !on;
btn.classList.toggle('is-open', on);
if (on) entry.openMenus.add(menu);
else entry.openMenus.delete(menu);
// an open menu holds a folded-away bar out; when the last one
// closes with the pointer elsewhere, the bar goes back
entry.el.classList.toggle('menu-open', entry.openMenus.size > 0);
if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek');
};
const open = () => { const open = () => {
if (typeof build === 'function') { if (typeof build === 'function') {
menu.innerHTML = ''; menu.innerHTML = '';
build(menu); build(menu);
} }
menu.hidden = false; setOpen(true);
// fixed positioning (escapes the tile's overflow): under the // under the button, right-aligned, kept whole inside the viewport
// button, right-aligned, clamped to the viewport
const r = btn.getBoundingClientRect(); const r = btn.getBoundingClientRect();
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
menu.style.left = '0px'; menu.style.left = '0px';
menu.style.top = '0px';
const w = menu.offsetWidth; const w = menu.offsetWidth;
const h = menu.offsetHeight;
menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px';
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px'; menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
}; };
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
if (menu.hidden) open(); if (menu.hidden) open();
else menu.hidden = true; else setOpen(false);
}); });
const onDocClick = (e) => { const onDocClick = (e) => {
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true; if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
}; };
document.addEventListener('click', onDocClick); document.addEventListener('click', onDocClick);
docListeners.add(onDocClick); docListeners.add(onDocClick);
wrap.append(btn, menu); wrap.append(btn);
entry.controls.appendChild(wrap); entry.controls.appendChild(wrap);
document.body.appendChild(host);
headerEls.add(wrap); headerEls.add(wrap);
headerEls.add(host);
return { return {
button: btn, button: btn,
menu, menu,
@@ -1461,7 +1490,7 @@ function buildModuleApi(tile, man, entry) {
btn.textContent = text; btn.textContent = text;
}, },
close() { close() {
menu.hidden = true; setOpen(false);
}, },
}; };
}, },
@@ -1473,6 +1502,8 @@ function buildModuleApi(tile, man, entry) {
for (const handle of [...sseHandles]) handle.close(); for (const handle of [...sseHandles]) handle.close();
for (const el of headerEls) el.remove(); for (const el of headerEls) el.remove();
headerEls.clear(); headerEls.clear();
entry.openMenus.clear();
entry.el.classList.remove('menu-open');
for (const fn of docListeners) document.removeEventListener('click', fn); for (const fn of docListeners) document.removeEventListener('click', fn);
docListeners.clear(); docListeners.clear();
}, },
+3 -2
View File
@@ -445,8 +445,9 @@ body.menu-hidden #topbar {
} }
.tile-dd { position: relative; display: inline-flex; } .tile-dd { position: relative; display: inline-flex; }
/* a module header menu — fixed-positioned by the shell so the tile's /* a module header menu — hosted on the body (shell.js addMenu) so neither
overflow:hidden can't clip it */ the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
.tile-menu-host { display: contents; }
.tile-menu { .tile-menu {
position: fixed; position: fixed;
z-index: 70; z-index: 70;