// Programmatic POSITIONING + HIGHLIGHT 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. // // It also checks the item HIGHLIGHT, using hover-probe.ts's trick (`:hover` // rewritten to a `.simhover` class, since headless Firefox has no pointer): // 7.css paints hovered items with a translucent gradient wash, and this app // wants one flat colour. A gradient is detectable as vertical colour spread // down a text-free strip of the item, so "is it flat?" is a measurement, not // an opinion. What canNOT be measured here is the 1s afterglow fade — a // temporal effect — but its cause is structural (`button::before` still has // `content`), and this probe would show the glow's colour if the pseudo ever // came back. // // 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`); // Hover as a CLASS: same rewrite hover-probe.ts uses, for the same reason — // `--screenshot` has no pointer. `:hover` has no partial-token hazard. const css = await Bun.file(path.join(DIST, styles)).text(); const PROBE_CSS = "_menu-probe.css"; await Bun.write(path.join(DIST, PROBE_CSS), css.replaceAll(":hover", ".simhover")); // ---- 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) => ` `; // The frame reproduces the app's: .window.w7-frame is a 100vh flex column and // 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 = `
miniSCP
${menu(MENU_X, true)} ${menu(CONTROL_X, false)}
`; 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 { 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; } /** * Colour spread down a text-free strip of the first (force-hovered) item: the * ul's own 2px padding + the item's 1px border put the item's background at * x >= menu+3, and its 32px left padding keeps label text out to menu+32. So a * column at menu+5..menu+26 is pure fill — except for the gutter stripe, which * 7.css puts at left: 28px. A flat colour reads 0 spread; a gradient does not. */ async function fillSpread( img: string, x: number, y: number, w: number, h: number, ): Promise<{ spread: number; mean: [number, number, number] }> { const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], { stdout: "pipe", stderr: "pipe", }); const lo: number[] = [255, 255, 255]; const hi: number[] = [0, 0, 0]; const sum = [0, 0, 0]; let n = 0; for (const line of p.stdout.toString().split("\n").slice(1)) { const m = /\((\d+),(\d+),(\d+)/.exec(line); if (!m) continue; for (let c = 0; c < 3; c++) { const v = Number(m[c + 1]); lo[c] = Math.min(lo[c], v); hi[c] = Math.max(hi[c], v); sum[c] += v; } n++; } if (!n) return { spread: -1, mean: [0, 0, 0] }; return { spread: Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]), mean: [Math.round(sum[0] / n), Math.round(sum[1] / n), Math.round(sum[2] / n)], }; } // 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"}`, ); // The force-hovered first item: fill strip, and the same strip on the item // below it as a not-hovered reference (which must stay the menu surface). const FILL = { x: MENU_X + 5, w: 21, h: 14 }; const hovered = await fillSpread(out, FILL.x, MENU_Y + 7, FILL.w, FILL.h); const resting = await fillSpread(out, FILL.x, MENU_Y + 34, FILL.w, FILL.h); const FLAT_MAX = 6; // antialiasing tolerance; 7.css's wash spreads far wider console.log("\n[menu-probe] item highlight (force-hovered vs resting):\n"); console.log(" item mean rgb spread verdict"); console.log( ` hovered (.simhover) ${hovered.mean.join(",").padEnd(15)} ${String(hovered.spread).padStart(6)} ${ hovered.spread <= FLAT_MAX ? "flat" : "GRADIENT: highlight is not a flat colour" }`, ); console.log(` resting ${resting.mean.join(",").padEnd(15)} ${String(resting.spread).padStart(6)} reference`); console.log(`\n[menu-probe] image: ${out}`); let failed = false; if (placed === 0) { console.log("[menu-probe] the context menu is positioned in FLOW again — see 7like.css .w7-ctx"); failed = true; } if (hovered.spread > FLAT_MAX) { console.log("[menu-probe] the item highlight regained a gradient — see the menu block in 7like.css"); failed = true; } if (hovered.mean.join(",") === resting.mean.join(",")) { console.log("[menu-probe] hovered and resting items are identical — the highlight is not painting at all"); failed = true; } if (failed) process.exit(2); console.log("[menu-probe] clean");