Three things looked wrong, three different causes. The selected row carried a border. 7.css outlines tr.highlighted (#aaddfa) on top of its fill, and we had been restating that outline in the new blue — so the band had an edge, and on the cursor row the dotted focus rect made three concentric outlines around a single row. The reserved 1px border stays (it is what stops selection from shifting rows) but is transparent in every state; the fill paints under it, since background-clip is border-box. --w7-sel-bd and --w7-sel-bd-idle are gone with it. The context menu highlighted items with 7.css's translucent gradient wash — the same gradient the ListView selection just dropped. It now uses --w7-sel-bg, so selection reads identically wherever it appears. The hover-off animation was button.css bleeding into menu items, which ARE buttons: button::before is a glow overlay that fades out over a full second, and menu.css suppresses it with content:none on the :hover arm ONLY — so leaving an item brought the pseudo back with opacity animating 1 -> 0, a ghost trailing the pointer. Killing content in every state removes it. Same family, also fixed: button:focus runs pulse-anim infinitely, and this menu moves real focus between items for keyboard nav. menu-probe.ts now checks the highlight too, using hover-probe's :hover -> .simhover rewrite: it measures colour spread down a text-free strip of a force-hovered item, so "is it flat?" is a measurement. Verified the check can fail by injecting the old wash into the built stylesheet — spread 9, exit 2; the flat fill reads spread 0 at exactly #cbe8f6. The harness lost its selection outlines and nothing else, in both themes. Co-Authored-By: Claude <noreply@anthropic.com>
226 lines
9.6 KiB
TypeScript
226 lines
9.6 KiB
TypeScript
// 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) => `
|
|
<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"${marker ? ' class="simhover"' : ""}>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="${PROBE_CSS}">
|
|
</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;
|
|
}
|
|
|
|
/**
|
|
* 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");
|