diff --git a/docs/gotchas.md b/docs/gotchas.md
index 172848b..4b6554d 100644
--- a/docs/gotchas.md
+++ b/docs/gotchas.md
@@ -86,6 +86,25 @@ touching the adjacent code.
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.
+## 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/`)
- **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with
diff --git a/scripts/hover-probe.ts b/scripts/hover-probe.ts
new file mode 100644
index 0000000..19fb19d
--- /dev/null
+++ b/scripts/hover-probe.ts
@@ -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) => `
+
+
+
miniSCP
+
+
+
+
+
+
+
+
`;
+
+const page = `
+
+
+
+
+
${win("control", false)}
+
${win("hovered", true)}
+`;
+
+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 {
+ // 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");
diff --git a/ui/src/app/skins/7like/7like.css b/ui/src/app/skins/7like/7like.css
index 5ceb8d3..577eddc 100644
--- a/ui/src/app/skins/7like/7like.css
+++ b/ui/src/app/skins/7like/7like.css
@@ -602,6 +602,24 @@ html.win7 body {
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 —
but CSS cannot sample the DESKTOP behind a transparent native window
(there is no backdrop to read), and WebKitGTK paints the unsampleable