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