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