Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 0929af46ca SPL Meter 0.5.0: the capture tool ships prebuilt, for Apple silicon and Intel
Same move as Timers 1.7.0. The capture tool existed only where the macOS
app build had compiled it, with the module trying swiftc on the booth as a
fallback — NA has been logging "spawn spl-capture ENOENT" since its
catalog install. Under the module guide's rule it now ships built:
bin/spl-capture-macos-arm64 and bin/spl-capture-macos-x86_64, signed with
the Developer ID (hardened runtime, audio-input entitlement), from
native/spl-capture.swift via native/build.sh. captureTool() picks the
binary for this platform; where there is none — Windows, until a Windows
implementation exists — measurement reports itself off in the health line
and the device picker while the module stays mounted. The runtime build
and its "building…" state are gone, and the old top-level binary is no
longer gitignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 10:12:42 -04:00
16 changed files with 127 additions and 249 deletions
-3
View File
@@ -16,6 +16,3 @@ launcher/macos/build/
launcher/macos/.cache/
launcher/windows/build/
launcher/windows/.cache/
# Compiled per-machine, source sits next to it (see spl-capture.swift).
modules/spl-meter/spl-capture
+1 -2
View File
@@ -461,8 +461,7 @@ const menu = moduleApi.header.addMenu({
build(menuEl) {
// called on every open with an emptied menu element — fill it with
// buttons (give them your scoped classes; the container is styled and
// positioned by the shell, hosted on the body under an element carrying
// your data-module, so your scoped CSS applies and nothing clips it)
// fixed-positioned by the shell, so the tile's overflow can't clip it)
},
});
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
+3 -81
View File
@@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
applyInstanceSettings();
}
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
// icon and tooltip track the current direction — applyInstanceSettings sets both
const flowBtn = moduleApi.header.addButton({
@@ -95,7 +95,7 @@ export default function create({ root, moduleApi }) {
},
});
const latestBtn = moduleApi.header.addButton({
moduleApi.header.addButton({
icon: JUMP_SVG,
title: 'Latest',
onClick() {
@@ -112,82 +112,6 @@ export default function create({ root, moduleApi }) {
});
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() {
const s = moduleApi.instanceSettings;
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
@@ -202,7 +126,6 @@ export default function create({ root, moduleApi }) {
flowBtn.classList.toggle('active', up);
flowBtn.title = up ? 'Newest first' : 'Oldest first';
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
rebuildMoreMenu(); // its checkmarks mirror the buttons
}
applyInstanceSettings();
@@ -492,7 +415,6 @@ export default function create({ root, moduleApi }) {
},
stop() {
stopped = true;
sizeWatch.disconnect();
stream?.close();
root.innerHTML = ''; // header controls are removed by the shell
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "prodcom-transcript",
"name": "ProdCom",
"version": "1.3.3",
"version": "1.3.2",
"proddash": ">=1.1.0",
"description": "Live ProdCom transcript",
"permissions": [
+1 -5
View File
@@ -20,13 +20,9 @@
}
[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 ──
The menu container is created and positioned by the shell (.tile-menu);
it hangs off the body under a [data-module] host, so scoping still applies. */
it lives inside the tile, so [data-module] scoping still applies. */
[data-module="prodcom-transcript"] .pt-channel-item {
display: flex;
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "spl-meter",
"name": "SPL Meter",
"version": "0.4.3",
"version": "0.5.0",
"proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [
+48
View File
@@ -0,0 +1,48 @@
#!/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
@@ -0,0 +1,10 @@
<?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>
@@ -8,10 +8,11 @@
// measurement is done here and only the answers cross the pipe.
//
// Everything below uses Accelerate, which ships with macOS. No third-party
// code, no package manager, ~2 s to build:
// code, no package manager. It ships prebuilt in the module's bin/ (one
// binary per platform, committed — a booth never builds anything). To
// rebuild after a change here, on any Mac with the Xcode command line tools:
//
// swiftc -O -o modules/spl-meter/spl-capture \
// modules/spl-meter/spl-capture.swift
// modules/spl-meter/native/build.sh (both architectures)
//
// spl-capture --list
// every input-capable device: name, input channels, sample rate
+10 -20
View File
@@ -20,7 +20,7 @@
*/
const path = require('path');
const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
const { createSplListener, captureTool } = require('./spl-listener');
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94;
@@ -120,12 +120,9 @@ module.exports = {
pinger: null,
};
// Before anything else: the device picker shells out to this tool, so a
// machine that installed the module from the catalog (source only — the
// 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 });
// The capture tool ships prebuilt in the module (bin/); with none for
// this platform every reading is off and the health line says so.
state.toolMissing = captureTool(moduleDir).missing;
if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
@@ -185,9 +182,7 @@ module.exports = {
return {
stop() { teardown(); },
health() {
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
return { status: 'error', message: state.toolMissing };
}
if (state.toolMissing) return { status: 'error', message: state.toolMissing };
if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs();
@@ -311,11 +306,10 @@ module.exports = {
/** Device list for the admin page. Runs the capture tool's --list,
which is the only thing that knows what CoreAudio can see. */
'GET /devices': async (req, res) => {
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
if (missing) {
if (state) state.toolMissing = missing;
log(`device list unavailable — ${missing}`);
json(res, 200, { options: [] });
const tool = captureTool(__dirname);
if (tool.missing) {
log(`device list unavailable — ${tool.missing}`);
json(res, 200, { options: [], error: tool.missing });
return;
}
try {
@@ -447,7 +441,7 @@ function teardown() {
function listDevices() {
return new Promise((resolve, reject) => {
const { execFile } = require('child_process');
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 },
(err, stdout) => {
if (err && !stdout) { reject(err); return; }
try { resolve(JSON.parse(stdout).devices || []); }
@@ -456,10 +450,6 @@ function listDevices() {
});
}
function fsExists(p) {
try { return require('fs').existsSync(p); } catch { return false; }
}
function json(res, code, body) {
const text = JSON.stringify(body);
res.writeHead(code, {
+35 -88
View File
@@ -3,7 +3,8 @@
/**
* Sound level listener — owns the native capture child.
*
* Spawns spl-capture (compiled from spl-capture.swift beside it) for the
* Spawns spl-capture (bin/spl-capture-macos-<arch>, shipped prebuilt;
* source in native/) for 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
* plumbing and supervision, nothing more.
@@ -13,12 +14,11 @@
* { frame: {...}, meta: {...} } measuring
* { error: '<what is wrong and how to fix it>' } not measuring
*
* The capture binary is per-machine and gitignored, exactly like the Timers
* module's ltc-capture. It normally arrives already built — compiled beside
* the app, or copied from any Mac of the same architecture. When it is
* missing this will build it, but in the background and never on the event
* 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 capture tool ships prebuilt inside the module, one binary per
* platform under bin/ (module guide, "Native helpers"), exactly like the
* Timers module's ltc-capture: nothing is ever built on the machine running
* ProdDash. captureTool() picks the binary for this platform; when there is
* none, measurement reports itself off and the rest of the module carries on.
*
* 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
@@ -31,72 +31,31 @@ const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
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` '
+ 'and copy it next to spl-capture.swift';
const BUILDING = 'Building the capture tool — this takes a minute or two';
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
/** What the module ships in bin/, by platform and architecture. */
const HELPERS = {
darwin: { arm64: 'spl-capture-macos-arm64', x64: 'spl-capture-macos-x86_64' },
win32: { x64: 'spl-capture-win-x64.exe' },
};
/**
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
* everything that needs it.
* The capture tool for this machine. It is never built here and never
* looked for anywhere else: the module ships it, or measurement is off on
* this platform and says so. `bin` is the usable binary; otherwise
* `missing` is what to tell the admin page and the tiles.
*
* 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.
* @returns {{bin:string, missing:string}}
*/
let building = false;
let buildFailed = false;
function ensureCaptureBinary({ moduleDir, log, onReady }) {
const bin = path.join(moduleDir, 'spl-capture');
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
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;
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
const name = HELPERS[platform]?.[arch] || '';
const bin = name ? path.join(moduleDir, 'bin', name) : '';
if (bin && fs.existsSync(bin)) return { bin, missing: '' };
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
return {
bin: '',
missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`,
};
}
const RESTART_MS = 5000;
@@ -114,7 +73,6 @@ const STALL_MS = 3000;
*/
function createSplListener(opts) {
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
const bin = path.join(moduleDir, 'spl-capture');
let child = null;
let stopped = false;
@@ -124,13 +82,6 @@ function createSplListener(opts) {
let meta = null;
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) {
if (stopped || restartTimer) return;
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
@@ -150,15 +101,11 @@ function createSplListener(opts) {
function start() {
if (stopped) return;
const missing = ensureBinary();
if (missing) {
onUpdate({ error: missing });
// onReady is only the FAST path (a successful build restarts us at
// 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.
const tool = captureTool(moduleDir);
if (tool.missing) {
// No binary for this platform: say so and look again later — a module
// update remounts us anyway, this is only belt and braces.
onUpdate({ error: tool.missing });
scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
@@ -175,7 +122,7 @@ function createSplListener(opts) {
let stderrTail = '';
let buf = '';
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child = spawn(tool.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => {
@@ -258,4 +205,4 @@ function createSplListener(opts) {
};
}
module.exports = { createSplListener, ensureCaptureBinary };
module.exports = { createSplListener, captureTool };
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proddash",
"version": "1.10.1",
"version": "1.10.0",
"private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED",
+10 -41
View File
@@ -640,10 +640,8 @@ function buildTile(tile) {
el.addEventListener('pointermove', (ev) => {
if (!headIsOff(tile)) return;
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
// — 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.
// — neither is a moment to take the bar away.
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 shown = el.classList.contains('head-peek');
// It takes less to hold the bar open than to open it, so a bar that
@@ -654,7 +652,6 @@ function buildTile(tile) {
el.addEventListener('pointerleave', () => {
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');
});
@@ -662,7 +659,7 @@ function buildTile(tile) {
applyHeadState(tile, el);
grid.appendChild(el);
const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() };
const entry = { el, body, dot, titleEl: title, controls, instance: null };
tileEls.set(tile.id, entry);
applyRect(tile);
@@ -1429,60 +1426,34 @@ function buildModuleApi(tile, man, entry) {
menu.className = 'tile-menu';
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 = () => {
if (typeof build === 'function') {
menu.innerHTML = '';
build(menu);
}
setOpen(true);
// under the button, right-aligned, kept whole inside the viewport
menu.hidden = false;
// fixed positioning (escapes the tile's overflow): under the
// button, right-aligned, clamped to the viewport
const r = btn.getBoundingClientRect();
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
menu.style.left = '0px';
menu.style.top = '0px';
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';
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) open();
else setOpen(false);
else menu.hidden = true;
});
const onDocClick = (e) => {
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
};
document.addEventListener('click', onDocClick);
docListeners.add(onDocClick);
wrap.append(btn);
wrap.append(btn, menu);
entry.controls.appendChild(wrap);
document.body.appendChild(host);
headerEls.add(wrap);
headerEls.add(host);
return {
button: btn,
menu,
@@ -1490,7 +1461,7 @@ function buildModuleApi(tile, man, entry) {
btn.textContent = text;
},
close() {
setOpen(false);
menu.hidden = true;
},
};
},
@@ -1502,8 +1473,6 @@ function buildModuleApi(tile, man, entry) {
for (const handle of [...sseHandles]) handle.close();
for (const el of headerEls) el.remove();
headerEls.clear();
entry.openMenus.clear();
entry.el.classList.remove('menu-open');
for (const fn of docListeners) document.removeEventListener('click', fn);
docListeners.clear();
},
+2 -3
View File
@@ -445,9 +445,8 @@ body.menu-hidden #topbar {
}
.tile-dd { position: relative; display: inline-flex; }
/* a module header menu — hosted on the body (shell.js addMenu) so neither
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
.tile-menu-host { display: contents; }
/* a module header menu — fixed-positioned by the shell so the tile's
overflow:hidden can't clip it */
.tile-menu {
position: fixed;
z-index: 70;