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:
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user