Clip the close button's hover glow at the frame edge; add a hover probe

Hovering the win7 X painted a red mark on the window border's far edge.
Diagnosed programmatically, per request, with a new scripts/hover-probe.ts:
Firefox headless has no pointer, so the probe copies the built stylesheet
with every `:hover` rewritten to a `.simhover` class, renders the app's exact
frame markup (w7-frame + direct-child title-bar + aria-label'd controls) with
the X force-hovered, and pixel-scans four regions for red-dominant pixels,
printing a verdict table and exiting non-zero on artifacts.

Verdict from the first run: the right border and the region beside it were
CLEAN — the reported mark is the glow escaping over the TOP edge. 7.css's
hover ::before carries box-shadow 0 0 7px 3px #e68e75, a child's box-shadow
is not clipped by the parent's border or radius, and the caption buttons sit
flush against the window's top edge (border-top: 0) — so 100 red pixels
landed above the frame: a tint across the top border and a halo floating on
the desktop past it.

Fix: clip-path inset(0 0 -1rem 0) on `.w7-frame > .title-bar` in skinfix. The
glow now dies at the frame border — where the real Win7 DWM stopped it — and
the bottom stays open so the authentic bleed toward the caption survives.
clip-path clips hit-testing too, but only outside the region; the drag area
is the title-bar's own box, entirely inside. Scoped to w7-frame so harness
and demo windows keep stock 7.css behaviour.

Verified: probe verdict now clean (escape regions 0, the intended in-frame
glow still measures 30 px — the effect survived, only the escape is gone);
the resting-state harness diffs exactly zero, so nothing but hover pixels
changed; tsc + build clean. The probe stays in scripts/ as the regression
test; gotchas.md records both the clipping rule and the :hover -> class
technique.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:29:21 +02:00
co-authored by Claude
parent 9c54a441a9
commit 89af52e1c0
3 changed files with 208 additions and 0 deletions
+19
View File
@@ -86,6 +86,25 @@ touching the adjacent code.
and per call, since the scale changes mid-session. The editor's canvas-based and per call, since the scale changes mid-session. The editor's canvas-based
`charWidth` is cached keyed on the computed font string for the same reason. `charWidth` is cached keyed on the computed font string for the same reason.
## Hover states and the frame edge
- **A child's box-shadow is NOT clipped by its parent's border or radius.** The
close button's hover glow (7.css: `0 0 7px 3px #e68e75` on a `::before`)
painted across the window's top border and onto the desktop beyond it — the
caption buttons sit flush against the top edge (`border-top: 0`), and the
frame's rounded transparent corners clip nothing. Fixed with
`clip-path: inset(0 0 -1rem 0)` on `.w7-frame > .title-bar`: glow stops at
the frame like the real DWM, bottom bleed (authentic) stays open. Note
clip-path also clips hit-testing — safe here because the drag region is the
title-bar's own box, entirely inside the clip.
- **Hover states CAN be tested headlessly**: `scripts/hover-probe.ts` copies
the built stylesheet with `:hover` rewritten to `.simhover`, renders the
frame markup with the state forced, and pixel-scans artifact regions,
exiting non-zero on hover-introduced red outside the frame. Firefox
`--screenshot` has no pointer and `import -window` captures the real
pointer's state, so the class rewrite is the only programmatic hover on this
machine. Re-run it after touching title-bar or hover styling.
## The two skin trees (`ui/src/app/skins/`) ## The two skin trees (`ui/src/app/skins/`)
- **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with - **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with
+171
View File
@@ -0,0 +1,171 @@
// Programmatic HOVER test for the win7 title-bar close button.
//
// bun scripts/hover-probe.ts
//
// Why this exists: hovering the X paints 7.css's red glow ::before
// (box-shadow: 0 0 7px 3px #e68e75), and a stray red mark was reported on the
// window border's far edge. Hover cannot be captured by any tool on this
// machine — Firefox --screenshot has no pointer, `import -window` grabs
// whatever the real pointer is (not) doing — so this script makes hover a
// CLASS: it copies the built stylesheet with every `:hover` rewritten to
// `.simhover`, renders the app's exact frame markup with the X force-hovered,
// and then PIXEL-SCANS the regions where the artifact could live, printing a
// verdict table. Re-run after any change to the title-bar/hover styling; the
// artifact regions must stay at zero.
//
// 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. Gecko vs WebKitGTK
// is fine here — box-shadow/border-radius compositing is engine-agnostic.
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(`[hover-probe] ERROR: ${message}`);
process.exit(1);
}
// ---- stylesheet with :hover rewritten to .simhover ----
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`);
const css = await Bun.file(path.join(DIST, styles)).text();
// Global on purpose: EVERY hover rule becomes assertable markup. `:hover` has
// no partial-token hazard (no `:hoverish` exists in CSS).
await Bun.write(path.join(DIST, "_probe.css"), css.replaceAll(":hover", ".simhover"));
// ---- the probe page: the app's frame markup, X force-hovered ----
// Mirrors ui/src/app/skins/7like/app.html + title-bar.ts EXACTLY where it
// matters: .window.glass.w7-frame.active with .title-bar as a DIRECT child
// (7.css couples the glass with `.window.glass > .title-bar`), controls keyed
// by aria-label. Fixed-size windows instead of h-screen so two fit one shot.
const win = (id: string, hover: boolean) => `
<div id="${id}" class="window glass w7-frame active" style="width: 420px; height: 120px; position: relative;">
<div class="title-bar electrobun-webkit-app-region-drag">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"${hover ? ' class="simhover"' : ""}></button>
</div>
</div>
<div class="window-body" style="height: 60px"></div>
</div>`;
const page = `<!doctype html>
<html class="win7" lang="en">
<head><meta charset="utf-8"><link rel="stylesheet" href="_probe.css">
<style>
/* checkerboard ground so semi-transparent escapes are visible over ANY tone */
body { margin: 0; padding: 40px; background: #808080; }
.slot { margin-bottom: 40px; }
</style></head>
<body>
<div class="slot">${win("control", false)}</div>
<div class="slot">${win("hovered", true)}</div>
</body></html>`;
const pagePath = path.join(DIST, "_hover-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, "hover-probe.png");
const proc = Bun.spawnSync(
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "560,420", `file://${pagePath}`],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image");
}
// ---- pixel scan ----
// Page geometry (CSS px = image px; no DPR scaling in headless Firefox):
// body padding 40, window at x:40..460 (width 420), first window y:40..160,
// second y:200..320. The window's own 1px border is AT x=40 and x=459.
const WIN_X = 40;
const WIN_W = 420;
const CONTROL_Y = 40;
const HOVER_Y = 200;
const WIN_H = 120;
interface Region {
name: string;
x: number;
y: number;
w: number;
h: number;
artifact: boolean; // true = red here is a DEFECT; false = red here is the intended glow
}
// The close button: last control, min-width 48px (3rem), controls end 6px
// (--w7-w-space padding) from the window's right edge — so the button spans
// roughly x = right-6-48 .. right-6. The glow reaches up to blur+spread = 10px
// beyond any edge of it.
const regions = (top: number): Region[] => [
// the window's right border column (2px wide: border + inset ring), full title-bar height
{ name: "right border (A)", x: WIN_X + WIN_W - 2, y: top + 2, w: 2, h: 30, artifact: true },
// OUTSIDE the window entirely, right of the border, title-bar height
{ name: "outside right (B)", x: WIN_X + WIN_W, y: top, w: 12, h: 34, artifact: true },
// OUTSIDE above the top edge / rounded corner
{ name: "outside top (B)", x: WIN_X + WIN_W - 80, y: top - 12, w: 92, h: 12, artifact: true },
// the gutter between the button and the border — glow HERE is the intended look
{ name: "gutter (glow ok)", x: WIN_X + WIN_W - 7, y: top + 2, w: 5, h: 30, artifact: false },
];
async function redCount(img: string, r: Region): Promise<number> {
// txt: gives one "x,y: (r,g,b[,a])" line per pixel
const p = Bun.spawnSync(
["magick", img, "-crop", `${r.w}x${r.h}+${r.x}+${r.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 [red, green, blue] = [Number(m[1]), Number(m[2]), Number(m[3])];
// "red" = the red channel dominating both others decisively. The Aero
// chrome is blue/white, so any genuine red here is the glow.
if (red > 120 && red > green + 30 && red > blue + 30) count++;
}
return count;
}
console.log("[hover-probe] region scan (red-dominant pixels):\n");
console.log(" region control hovered verdict");
let defects = 0;
for (const [i, region] of regions(CONTROL_Y).entries()) {
const hoverRegion = regions(HOVER_Y)[i];
const inControl = await redCount(out, region);
const inHover = await redCount(out, hoverRegion);
const introduced = inHover - inControl;
let verdict: string;
if (!region.artifact) verdict = introduced > 0 ? "glow present (intended)" : "no glow";
else if (introduced > 0) {
verdict = "DEFECT: red introduced by hover";
defects++;
} else verdict = "clean";
console.log(
` ${region.name.padEnd(20)}${String(inControl).padStart(7)}${String(inHover).padStart(9)} ${verdict}`,
);
}
console.log(`\n[hover-probe] image: ${out}`);
if (defects > 0) {
console.log(`[hover-probe] ${defects} artifact region(s) show hover-introduced red`);
process.exit(2);
}
console.log("[hover-probe] clean");
+18
View File
@@ -602,6 +602,24 @@ html.win7 body {
border-radius: 0; border-radius: 0;
} }
/* Hovering the close button paints 7.css's red glow ::before
(box-shadow: 0 0 7px 3px #e68e75), and box-shadows of CHILDREN are not
clipped by the parent's border or radius — the caption buttons sit flush
against the window's top edge (border-top: 0), so the glow spilled ABOVE
the frame: a red tint across the top border and a red halo floating on the
desktop past it (scripts/hover-probe.ts renders the forced-hover state and
pixel-scans for exactly this; it caught 100 red pixels above the edge).
Clip the title bar's children to its own box on top/left/right — the glow
now dies at the frame border, which is where the real Win7 DWM stopped it
too — while the bottom stays open (-1rem) so the glow's bleed toward the
caption below survives; that part is authentic.
clip-path also clips HIT-TESTING, but only for content outside the region:
the title-bar's own box (the drag region) is entirely inside it.
Scoped to w7-frame: demo/harness windows keep stock 7.css behaviour. */
.win7 .window.w7-frame > .title-bar {
clip-path: inset(0 0 -1rem 0);
}
/* 7.css's glass layer is `backdrop-filter: blur(4px)` on .window::after — /* 7.css's glass layer is `backdrop-filter: blur(4px)` on .window::after —
but CSS cannot sample the DESKTOP behind a transparent native window but CSS cannot sample the DESKTOP behind a transparent native window
(there is no backdrop to read), and WebKitGTK paints the unsampleable (there is no backdrop to read), and WebKitGTK paints the unsampleable