Remove the Windows 7 (7.css) skin

The win7 skin is dropped. Everything else the branch built stays: Linux
support and the HTML context menus, the workspace-tab menu, the continuous
interface scale (rem everywhere, stepper, Ctrl/Cmd +/-/0) and the core/
service layer. The modern tree already carried all of them, and nothing
outside skins/7like/ imported the win7 tree except main.ts and styles.css.

Removed: the skins/7like/ tree (5.1k lines, fonts), the 7.css dependency,
the skin setting and its options row + strings in all 7 locales, the
frameless-window plumbing (window-control RPCs, windowState push,
trackMaximizeState, the synthesized resize grip and its postinstall
watchdog), the session-skin pinning in buildPreload/chromeOptions, the
skin/skinfix cascade layers, and the win7-only probes and screenshot
harness (the harness cannot render the modern tree: plain Firefox has no
RPC bridge). chromeOptions() stays as the one shared frame decision.

Fixed on the way out:
- index.html's per-theme pre-paint background is restored. The branch had
  replaced it with a win7-only transparent rule, so modern dark windows had
  silently lost one of the three flash guards.
- editor-windows.ts wrote its window-key separator as a literal NUL byte,
  which made git and grep treat the file as binary; it is `\0` now (same
  string at runtime).

No settings migration: a stale "skin" key is carried harmlessly by the
store's spread-merge and dropped on the next save.

UI bundle: 616.7 kB -> 405.4 kB, back under the 500 kB budget.

Docs: architecture, gotchas and platform-notes lose the win7 material but
keep what stays true (construction-time preloads/frames, drag regions
ignoring preventDefault, electrobun's missing resize/maximize primitives);
flash-prevention, dev-URL and Bun-panic notes move back under Electrobun;
stale context-menu/tab-bar links fixed; README documents the scale keys.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:17:38 +02:00
co-authored by Claude
parent 0a0c708140
commit 2bac7678ca
64 changed files with 144 additions and 7194 deletions
-140
View File
@@ -1,140 +0,0 @@
// Watches for electrobun growing a real window-resize gesture, so the
// synthesized one can be deleted the moment it becomes dead weight.
//
// bun scripts/check-electrobun-resize.ts (also runs from `postinstall`)
//
// WHY THIS EXISTS
//
// The win7 skin is frameless, which on GTK means undecorated, which means the
// window manager supplies no resize borders. electrobun 1.18.1 exposes no
// native resize gesture at all, so the app synthesizes one: handles in the
// webview drag the frame via RPC. That is a genuine stopgap — jerkier than a
// compositor-side grab and always will be — and the thing most likely to
// happen to a stopgap is that it outlives its reason and nobody notices.
//
// So the trigger is automated rather than a comment somebody has to re-read: an
// electrobun bump runs `postinstall`, which runs this, which prints the removal
// checklist the moment upstream ships the primitive we are waiting for.
//
// It NEVER fails the install (always exit 0) and is silent when there is
// nothing to say — a check that cries wolf gets muted, and a muted check is
// worse than none.
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
/** The version the synthesized resize was written against and verified on. If
* the installed version differs, this script says so once — that is the cue to
* re-check upstream's changelog and then bump this constant. */
const VALIDATED_VERSION = "1.18.1";
/**
* Symbols that would mean "upstream can resize now". Verified ABSENT in 1.18.1:
* the shipped library exports `startWindowMove`/`stopWindowMove` with no resize
* counterpart, and imports `gtk_window_begin_move_drag` but not the resize
* twin. Any of these appearing is the signal.
*
* Searched as raw ASCII in the binary rather than via `nm`/`strings`: only the
* host platform's library is installed (`dist-linux-x64/…so`, `.dylib`, `.dll`
* elsewhere), and `nm` does not exist on Windows. Symbol names live in the
* binary as plain text either way.
*/
const NATIVE_NEEDLES = [
"gtk_window_begin_resize_drag", // the GTK primitive, if they ever link it
"gtk_window_set_resizable",
"startWindowResize", // the obvious sibling of startWindowMove
"setWindowResizable",
"beginWindowResize",
];
/** Source-level signals in the shipped TS API. */
const SOURCE_NEEDLES: { file: string; needle: string; means: string }[] = [
{
file: "dist/api/bun/core/BrowserWindow.ts",
needle: "setResizable",
means: "BrowserWindow gained a resizability setter",
},
{
file: "dist/api/bun/core/BrowserWindow.ts",
needle: "minWidth",
means: "the window constructor gained min-size options (our JS clamp may be redundant)",
},
{
file: "dist/api/bun/preload/dragRegions.ts",
needle: "resize",
means: "the drag-region preload now knows about resizing",
},
];
const CHECKLIST = `
┌────────────────────────────────────────────────────────────────────────┐
│ electrobun may now support window resizing natively. │
│ The synthesized resize is a STOPGAP and should be deleted. │
│ Full checklist: │
│ ui/src/app/skins/7like/window-resize/window-resize.ts │
└────────────────────────────────────────────────────────────────────────┘`;
function main(): void {
const pkgDir = path.join(import.meta.dir, "..", "node_modules", "electrobun");
if (!existsSync(pkgDir)) return; // not installed yet (fresh clone mid-install)
const hits: string[] = [];
// --- the shipped native library, whichever platform's is installed ---
for (const entry of readdirSync(pkgDir)) {
if (!entry.startsWith("dist-")) continue;
const dir = path.join(pkgDir, entry);
for (const file of readdirSync(dir)) {
if (!/^libNativeWrapper.*\.(so|dylib|dll)$/.test(file)) continue;
// latin1: we are searching for ASCII symbol names inside a binary, and
// utf-8 decoding would mangle bytes >0x7f (and can drop invalid
// sequences), which could hide a needle sitting next to one.
const text = readFileSync(path.join(dir, file), "latin1");
for (const needle of NATIVE_NEEDLES) {
if (text.includes(needle)) hits.push(`${entry}/${file} contains ${needle}`);
}
}
}
// --- the shipped TypeScript API ---
for (const { file, needle, means } of SOURCE_NEEDLES) {
const full = path.join(pkgDir, file);
if (!existsSync(full)) continue;
if (readFileSync(full, "utf8").includes(needle)) hits.push(`${file} mentions ${needle} — ${means}`);
}
const version = installedVersion(pkgDir);
if (hits.length) {
console.log(CHECKLIST);
console.log(` electrobun ${version ?? "?"} — what changed:`);
for (const hit of hits) console.log(` · ${hit}`);
console.log("");
return;
}
// No resize support. Stay quiet unless the version moved out from under the
// hack, in which case one line is the reminder to look.
if (version && version !== VALIDATED_VERSION) {
console.log(
`[electrobun] ${VALIDATED_VERSION} → ${version}: no window-resize API found, so the ` +
`synthesized resize stays. Re-check, then bump VALIDATED_VERSION in ` +
`scripts/check-electrobun-resize.ts to silence this.`,
);
}
}
function installedVersion(pkgDir: string): string | null {
try {
return JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8")).version ?? null;
} catch {
return null;
}
}
// A broken check must never break an install: swallow everything, exit 0.
try {
main();
} catch (error) {
// Deliberately quiet — this is advisory. Surface it only when run by hand.
if (process.env["DEBUG"]) console.error("[electrobun] resize check failed:", error);
}
-171
View File
@@ -1,171 +0,0 @@
// 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");
-225
View File
@@ -1,225 +0,0 @@
// 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");
-126
View File
@@ -1,126 +0,0 @@
// Programmatic test for the synthesized window resize's frame arithmetic.
//
// bun scripts/resize-math-probe.ts
//
// Why this exists: the win7 skin resizes itself by dragging handles and moving
// the native frame (see ui/src/app/skins/7like/window-resize/window-resize.ts —
// a STOPGAP with a removal checklist). Pointer drags cannot be synthesized
// headlessly on this machine, so the interaction itself needs a human; but the
// part most likely to be subtly wrong is pure arithmetic — eight edges, four of
// which move the window's ORIGIN as well as its size, plus a minimum-size clamp
// that must not let the anchored edge drift. That is all in `resize-math.ts`,
// which is deliberately dependency-free so this script can drive it directly.
//
// The failure this guards against is not "the window does not resize" (obvious
// on sight) but "dragging the west edge inward past the minimum walks the whole
// window across the screen" — correct-looking until you hit the limit.
//
// This covers MORE than the UI currently mounts, deliberately: only the
// bottom-right grip is wired up (an invisible handle over a caption button
// steals its hover — see the component), but the math stays general so that
// restoring a side later is one line against already-proven arithmetic.
import {
MIN_HEIGHT,
MIN_WIDTH,
RESIZE_EDGES,
type Frame,
type ResizeEdge,
resizeFrame,
} from "../ui/src/app/skins/7like/window-resize/resize-math";
const START: Frame = { x: 100, y: 200, width: 1000, height: 800 };
interface Case {
name: string;
edge: ResizeEdge;
dx: number;
dy: number;
want: Frame;
}
// Every expectation is written out in full rather than computed, so a bug in
// resizeFrame cannot be mirrored into the expectation by sharing its algebra.
const CASES: Case[] = [
// --- edges: each moves only what it owns ---
{ name: "east grows width only", edge: "e", dx: 50, dy: 999, want: { x: 100, y: 200, width: 1050, height: 800 } },
{ name: "south grows height only", edge: "s", dx: 999, dy: 40, want: { x: 100, y: 200, width: 1000, height: 840 } },
{ name: "west moves x AND width", edge: "w", dx: -30, dy: 0, want: { x: 70, y: 200, width: 1030, height: 800 } },
{ name: "north moves y AND height", edge: "n", dx: 0, dy: -25, want: { x: 100, y: 175, width: 1000, height: 825 } },
// --- corners: two axes at once ---
{ name: "south-east", edge: "se", dx: 10, dy: 20, want: { x: 100, y: 200, width: 1010, height: 820 } },
{ name: "north-west", edge: "nw", dx: -10, dy: -20, want: { x: 90, y: 180, width: 1010, height: 820 } },
{ name: "north-east", edge: "ne", dx: 10, dy: -20, want: { x: 100, y: 180, width: 1010, height: 820 } },
{ name: "south-west", edge: "sw", dx: -10, dy: 20, want: { x: 90, y: 200, width: 1010, height: 820 } },
// --- the anchored edge must not drift once clamped ---
// west dragged far east: width parks at MIN, and x parks where the EAST edge
// (100 + 1000 = 1100) minus MIN puts it. If x kept tracking dx, the window
// would slide away while appearing frozen.
{
name: "west clamps, east edge holds",
edge: "w",
dx: 5000,
dy: 0,
want: { x: 1100 - MIN_WIDTH, y: 200, width: MIN_WIDTH, height: 800 },
},
{
name: "north clamps, south edge holds",
edge: "n",
dx: 0,
dy: 5000,
want: { x: 100, y: 1000 - MIN_HEIGHT, width: 1000, height: MIN_HEIGHT },
},
// east/south clamp the size but never touch the origin
{ name: "east clamps, origin holds", edge: "e", dx: -5000, dy: 0, want: { x: 100, y: 200, width: MIN_WIDTH, height: 800 } },
{ name: "south clamps, origin holds", edge: "s", dx: 0, dy: -5000, want: { x: 100, y: 200, width: 1000, height: MIN_HEIGHT } },
// a corner clamping on BOTH axes at once
{
name: "north-west clamps both",
edge: "nw",
dx: 5000,
dy: 5000,
want: { x: 1100 - MIN_WIDTH, y: 1000 - MIN_HEIGHT, width: MIN_WIDTH, height: MIN_HEIGHT },
},
// a zero drag must be a no-op, or a click on a handle would nudge the window
{ name: "zero drag is identity", edge: "se", dx: 0, dy: 0, want: START },
];
const fmt = (f: Frame) => `${f.x},${f.y} ${f.width}x${f.height}`;
console.log("[resize-math] frame arithmetic:\n");
console.log(" case edge got verdict");
let failed = 0;
for (const c of CASES) {
const got = resizeFrame(c.edge, START, c.dx, c.dy);
const ok = fmt(got) === fmt(c.want);
if (!ok) failed++;
console.log(
` ${c.name.padEnd(32)} ${c.edge.padEnd(5)} ${fmt(got).padEnd(22)} ${ok ? "ok" : `FAIL: want ${fmt(c.want)}`}`,
);
}
// Structural check: every edge must be exercised above, so adding a ninth
// handle without a case cannot slip through.
const covered = new Set(CASES.map((c) => c.edge));
const missing = RESIZE_EDGES.filter((e) => !covered.has(e));
if (missing.length) {
console.log(`\n[resize-math] no case covers: ${missing.join(", ")}`);
failed++;
}
// The clamp must never produce a frame smaller than the minimum, whatever the
// drag — a cheap sweep over every edge and a range of deltas.
for (const edge of RESIZE_EDGES) {
for (const d of [-9999, -1000, -1, 0, 1, 1000, 9999]) {
const got = resizeFrame(edge, START, d, d);
if (got.width < MIN_WIDTH || got.height < MIN_HEIGHT) {
console.log(`\n[resize-math] ${edge} at delta ${d} produced ${fmt(got)} — under the minimum`);
failed++;
}
}
}
if (failed) {
console.log(`\n[resize-math] ${failed} failure(s)`);
process.exit(2);
}
console.log("\n[resize-math] clean");
-106
View File
@@ -1,106 +0,0 @@
// Headless screenshot of the skin harness, so the Win7 look can be reviewed
// visually during development rather than inferred from a clean build log.
//
// bun scripts/shot.ts [--dark] [--width 1200] [--height 1400] [--out path.png]
// bun scripts/shot.ts --url http://localhost:4200 (any page, not just the harness)
//
// Firefox is used because it ships on this machine and has a built-in
// --screenshot mode; no Chromium/puppeteer download is needed. Two constraints
// it imposes:
// - a DEDICATED PROFILE is mandatory. A running Firefox holds a lock on the
// default profile and the headless run aborts with "Firefox is already
// running, but is not responding".
// - it renders with Gecko, NOT the WebKitGTK engine the app actually uses.
// Good enough for skin fidelity (layout, colour, gradients, borders);
// useless for engine-specific behaviour or native window transparency.
// Real confirmation is still `bun run start`.
//
// The harness links the REAL hashed stylesheet from ui/dist (produced by the
// normal `bun run build:ui`), so there is no second build pipeline to keep in
// sync.
//
// The composed page is written INTO that same dist folder, and this is not
// incidental: Firefox's security.fileuri.strict_origin_policy stops a file://
// document from loading a stylesheet that lives in a different directory. Host
// the page elsewhere and it renders completely unstyled — which looks exactly
// like "the skin is broken" rather than "the CSS never loaded". Keeping them
// co-located also lets media/ fonts resolve.
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 HARNESS = path.join(ROOT, "ui", "dev", "harness.html");
const WORK = path.join(ROOT, ".shot");
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}
const flag = (name: string) => process.argv.includes(`--${name}`);
function fail(message: string): never {
console.error(`[shot] ERROR: ${message}`);
process.exit(1);
}
// ---- resolve the built stylesheet ----
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`);
// ---- compose the page ----
mkdirSync(WORK, { recursive: true });
const profile = path.join(WORK, "profile");
mkdirSync(profile, { recursive: true });
const url = arg("url");
let target: string;
if (url) {
target = url;
} else {
const dark = flag("dark");
let html = await Bun.file(HARNESS).text();
// replaceAll, not replace: the harness documents its own {{STYLES}} token in
// a header comment that precedes the <link>, so a first-match replace
// substitutes the comment and silently leaves the stylesheet unlinked.
html = html.replaceAll("{{STYLES}}", styles); // relative: page sits next to it
// The preload bakes these attributes in the real app; do it by hand here.
if (dark) html = html.replace('<html class="win7"', '<html class="win7" data-theme="dark"');
const page = path.join(DIST, dark ? "_harness.dark.html" : "_harness.light.html");
await Bun.write(page, html);
target = `file://${page}`;
}
// ---- shoot ----
const out = path.resolve(arg("out") ?? path.join(WORK, flag("dark") ? "dark.png" : "light.png"));
const width = arg("width") ?? "1200";
const height = arg("height") ?? "1500";
// A stale lock from a killed run makes every later shot fail confusingly.
rmSync(path.join(profile, "parent.lock"), { force: true });
const proc = Bun.spawnSync(
[
"firefox",
"--headless",
"--profile",
profile,
"--screenshot",
out,
"--window-size",
`${width},${height}`,
target,
],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image (see stderr above)");
}
console.log(`[shot] ${out}`);