Files
miniSCP/scripts/menu-probe.ts
T
jgamboaandClaude c4364c1051 Fix the dead win7 context menu, flatten the selection, add a tab menu
Right-click did nothing under the win7 skin, and then swallowed the next
click. 7.css declares `position: relative` on ul[role=menu] and the `skin`
layer outranks `utilities`, so Tailwind's `.fixed` lost: [style.left/top.px]
became flow offsets from the menu's static position — the last child of the
h-screen frame — and every menu rendered below the viewport, unreachable
behind overflow:hidden. Its backdrop is a plain <div>, unmatched by 7.css, so
that stayed genuinely fixed and ate the following click. Regressed in 0ae2a62,
when the menu adopted 7.css's real markup.

Restated `position: fixed` in skinfix, scoped to a new `w7-ctx` marker so
7.css's own menus keep theirs. scripts/menu-probe.ts is the regression test:
it renders the menu at declared coordinates, pixel-scans whether it landed
there, and carries a marker-less control menu (the bug verbatim) as its own
discriminator. A resting pixel diff cannot see this class of bug.

Selection band goes flat: --w7-sel-bg/-bd (#cbe8f6/#26a0da) is what Explorer
actually painted, where 7.css reused --w7-el-grad-h, its Aero element-hover
ramp. That var is left alone — buttons and tabs still want the gloss. The
unfocused pane gets the grey idle band; the band still never disappears with
focus, it only weakens. Harness diff confirms the change touches selection
rows and nothing else, in both themes.

New workspace-tab right-click menu in both skins: New Tab, Close Tab, Close
Other Tabs, Close Tabs to the Right, Disconnect. The target id travels inside
the action string because the right-clicked tab is not necessarily the active
one. Illegal entries are disabled rather than omitted, the way Win7 greyed
out — and since 7.css's [aria-disabled] handling is CSS-only, the keyboard
paths check the flag themselves.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 20:50:42 +02:00

144 lines
6.0 KiB
TypeScript

// Programmatic POSITIONING test for the win7 context menu.
//
// bun scripts/menu-probe.ts
//
// Why this exists: 7.css declares `position: relative` on ul[role=menu] (it
// needs it for the gutter stripe and submenus), and the `skin` layer outranks
// `utilities` — so Tailwind's `.fixed` on the app's own menu lost, its
// [style.left/top.px] became FLOW offsets, and every right-click menu rendered
// below the viewport of the h-screen frame. Nothing about the idle window
// changes when that breaks, so the screenshot harness (scripts/shot.ts) cannot
// see it, and hover-probe.ts tests a different axis. This script renders the
// menu markup at declared coordinates and pixel-scans whether it actually
// landed there.
//
// It carries its own control: a second menu WITHOUT the `w7-ctx` marker class,
// i.e. the bug verbatim. That one must stay invisible — if it ever shows up,
// 7.css stopped forcing `relative` and this probe's premise is stale.
//
// Inherits shot.ts's constraints: dedicated Firefox profile (a running Firefox
// holds the default profile's lock), and the page must be written INTO the
// dist folder or strict_origin_policy silently unstyles it.
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
const WORK = path.join(ROOT, ".shot");
function fail(message: string): never {
console.error(`[menu-probe] ERROR: ${message}`);
process.exit(1);
}
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
// ---- the probe page ----
// Geometry (CSS px = image px; headless Firefox applies no DPR scaling).
const MENU_X = 200;
const MENU_Y = 100;
const CONTROL_X = 560;
const MENU_W = 200;
// Mirrors ui/src/app/skins/7like/context-menu.ts EXACTLY where it matters:
// ul[role=menu] > li[role=menuitem] > button, the `w7-ctx fixed` class pair,
// and left/top as inline pixel styles. `w7-ctx` is what the skinfix rule in
// 7like.css keys on; the control deliberately omits it.
const menu = (x: number, marker: boolean) => `
<ul role="menu" tabindex="-1"
class="${marker ? "w7-ctx " : ""}fixed z-50 outline-none"
style="left: ${x}px; top: ${MENU_Y}px; width: ${MENU_W}px">
<li role="menuitem"><button type="button">Send to Other Panel</button></li>
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
<li role="menuitem"><button type="button">Copy Path</button></li>
<li role="menuitem" aria-disabled="true"><button type="button">Delete</button></li>
</ul>`;
// The frame reproduces the app's: .window.w7-frame is a 100vh flex column and
// <app-context-menu> is one of its LAST children, so a menu that falls back to
// static flow lands past the bottom edge — which is the whole bug.
// The body gets an explicit white background (inline, so no layer can touch
// it) purely so "is anything drawn here?" is an unambiguous question.
const page = `<!doctype html>
<html class="win7" lang="en">
<head><meta charset="utf-8"><link rel="stylesheet" href="${styles}">
</head>
<body>
<div class="window glass w7-frame active" style="display: flex; flex-direction: column; height: 100vh">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls"><button aria-label="Close"></button></div>
</div>
<div class="window-body" style="flex: 1; min-height: 0; background: #fff"></div>
${menu(MENU_X, true)}
${menu(CONTROL_X, false)}
</div>
</body></html>`;
const pagePath = path.join(DIST, "_menu-probe.html");
await Bun.write(pagePath, page);
// ---- shoot ----
mkdirSync(WORK, { recursive: true });
const profile = path.join(WORK, "profile");
mkdirSync(profile, { recursive: true });
rmSync(path.join(profile, "parent.lock"), { force: true });
const out = path.join(WORK, "menu-probe.png");
const proc = Bun.spawnSync(
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "800,400", `file://${pagePath}`],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image");
}
// ---- pixel scan ----
/**
* Count pixels that are visibly NOT the white window body: the menu's border
* (#0006 over white ≈ #999), its shadow and its black item text all qualify.
* A menu that rendered where it was told fills this with hundreds of them; an
* empty white region scores 0.
*/
async function inkCount(img: string, x: number, y: number, w: number, h: number): Promise<number> {
const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], {
stdout: "pipe",
stderr: "pipe",
});
let count = 0;
for (const line of p.stdout.toString().split("\n").slice(1)) {
const m = /\((\d+),(\d+),(\d+)/.exec(line);
if (!m) continue;
const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])];
if (Math.max(r, g, b) < 200) count++;
}
return count;
}
// Inset by 2px so the scan cannot straddle the region's own edge.
const BOX = { w: MENU_W - 4, h: 60 };
const placed = await inkCount(out, MENU_X + 2, MENU_Y + 2, BOX.w, BOX.h);
const control = await inkCount(out, CONTROL_X + 2, MENU_Y + 2, BOX.w, BOX.h);
console.log("[menu-probe] ink at the declared coordinates:\n");
console.log(" menu px verdict");
console.log(
` w7-ctx (position: fixed)${String(placed).padStart(6)} ${placed > 0 ? "placed" : "MISSING: menu did not render where it was told"}`,
);
console.log(
` control (no marker) ${String(control).padStart(6)} ${control > 0 ? "NOTE: 7.css no longer forces relative — premise stale" : "off-viewport, as expected"}`,
);
console.log(`\n[menu-probe] image: ${out}`);
if (placed === 0) {
console.log("[menu-probe] the context menu is positioned in FLOW again — see 7like.css .w7-ctx");
process.exit(2);
}
console.log("[menu-probe] clean");