Flatten the selection and the menu highlight; kill the Aero afterglow

Three things looked wrong, three different causes.

The selected row carried a border. 7.css outlines tr.highlighted (#aaddfa) on
top of its fill, and we had been restating that outline in the new blue — so
the band had an edge, and on the cursor row the dotted focus rect made three
concentric outlines around a single row. The reserved 1px border stays (it is
what stops selection from shifting rows) but is transparent in every state;
the fill paints under it, since background-clip is border-box. --w7-sel-bd and
--w7-sel-bd-idle are gone with it.

The context menu highlighted items with 7.css's translucent gradient wash —
the same gradient the ListView selection just dropped. It now uses
--w7-sel-bg, so selection reads identically wherever it appears.

The hover-off animation was button.css bleeding into menu items, which ARE
buttons: button::before is a glow overlay that fades out over a full second,
and menu.css suppresses it with content:none on the :hover arm ONLY — so
leaving an item brought the pseudo back with opacity animating 1 -> 0, a ghost
trailing the pointer. Killing content in every state removes it. Same family,
also fixed: button:focus runs pulse-anim infinitely, and this menu moves real
focus between items for keyboard nav.

menu-probe.ts now checks the highlight too, using hover-probe's :hover ->
.simhover rewrite: it measures colour spread down a text-free strip of a
force-hovered item, so "is it flat?" is a measurement. Verified the check can
fail by injecting the old wash into the built stylesheet — spread 9, exit 2;
the flat fill reads spread 0 at exactly #cbe8f6. The harness lost its
selection outlines and nothing else, in both themes.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 21:04:09 +02:00
co-authored by Claude
parent c4364c1051
commit 5ed029a2b8
5 changed files with 167 additions and 31 deletions
+18 -9
View File
@@ -429,12 +429,17 @@ zeroed); the frame reads the state off its own child with
existing `maximized` signal rather than subscribing to `windowState` twice. existing `maximized` signal rather than subscribing to `windowState` twice.
**Selection band**: the file list's selected row is a FLAT Win7-Explorer blue **Selection band**: the file list's selected row is a FLAT Win7-Explorer blue
(`--w7-sel-bg/-bd` = `#cbe8f6`/`#26a0da`), not 7.css's `--w7-el-grad-h` — that (`--w7-sel-bg`, `#cbe8f6`) with **no border** — not 7.css's `--w7-el-grad-h`,
var is its Aero element-hover ramp and stays reserved for buttons and tabs, which is its Aero element-hover ramp and stays reserved for buttons and tabs
which still want the gloss. The unfocused pane gets `--w7-sel-bg-idle/-bd-idle`, that still want the gloss, and not its `#aaddfa` row outline, which stacked a
Explorer's grey inactive selection; the band never disappears with focus, only second edge inside the fill and, with the cursor row's dotted rect, drew three
weakens (the commander convention above). All four are dark-overridden in BOTH concentric outlines on one row. Every row still reserves `border: 1px solid
dark blocks. transparent` for geometry; the fill paints under it (`background-clip`
defaults to `border-box`). The unfocused pane gets `--w7-sel-bg-idle`,
Explorer's grey inactive selection — the band never disappears with focus,
only weakens (the commander convention above). Both are dark-overridden in
BOTH dark blocks. The context menu's item highlight uses the same
`--w7-sel-bg`, so selection reads identically wherever it appears.
**Context menu**: pure 7.css via its real menu contract — **Context menu**: pure 7.css via its real menu contract —
`ul[role=menu] > li[role=menuitem] > button` for actions, `ul[role=menu] > li[role=menuitem] > button` for actions,
@@ -446,9 +451,13 @@ one (`dist/gui/menu.css`), including the gutter stripe, disabled state and
submenu arrows. Keyboard moves REAL focus between items (7.css styles submenu arrows. Keyboard moves REAL focus between items (7.css styles
`:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only `:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only
menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus
one structural override: `.w7-ctx` restores `position: fixed`, which 7.css's own three overrides keyed on the app's own `w7-ctx` marker: `position: fixed`
`position: relative` on `ul[role=menu]` had been silently winning (see (7.css's `position: relative` on `ul[role=menu]` had been silently winning),
gotchas.md; `scripts/menu-probe.ts` is the regression test). the flat `--w7-sel-bg` highlight in place of its gradient wash, and
`content: none` on the item buttons' pseudo-elements, which kills both the
1s Aero afterglow and the infinite `pulse-anim` on a focused item (see
gotchas.md). `scripts/menu-probe.ts` regression-tests placement AND highlight
flatness, each with a control that proves the check can fail.
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans — **Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
the substitute 7.css itself names — is vendored (OFL-1.1, from the substitute 7.css itself names — is vendored (OFL-1.1, from
+14
View File
@@ -176,6 +176,20 @@ touching the adjacent code.
bug**: nothing about the idle window changes. `scripts/menu-probe.ts` renders bug**: nothing about the idle window changes. `scripts/menu-probe.ts` renders
the menu at declared coordinates and scans whether it landed there, with a the menu at declared coordinates and scans whether it landed there, with a
marker-less control menu as its own discriminator. marker-less control menu as its own discriminator.
- **7.css's button effects reach anything that IS a button — including menu
items — and one of them fades out over a FULL SECOND.** `button::before` is
an Aero glow overlay with `opacity: 0`, raised to 1 on hover, and
`button:not(:hover):before` carries `transition: opacity 1s linear`.
menu.css suppresses it with `content: none` on the **`:hover` arm only**, so
while an item is hovered nothing paints, but the moment the pointer leaves,
the pseudo returns with opacity animating 1 → 0: a blue ghost trailing the
cursor down the menu. Killing `content` in *every* state is the fix —
suppressing the transition alone leaves a glow that simply snaps. Two more
from the same family: `button:focus` runs `pulse-anim` *infinitely*
(`1s ease infinite alternate`), which matters because the context menu moves
REAL focus between items for keyboard nav, and the hover paint is 7.css's
translucent gradient wash — the same one the ListView selection dropped.
All three are overridden together, scoped to `w7-ctx`.
- **Its selectors are exact, and a near-miss looks exactly like "7.css doesn't - **Its selectors are exact, and a near-miss looks exactly like "7.css doesn't
support this".** `menu[role=tablist] button` is a DESCENDANT selector, so support this".** `menu[role=tablist] button` is a DESCENDANT selector, so
every button inside the strip becomes a tab — the `+` and the right-hand every button inside the strip becomes a tab — the `+` and the right-hand
+86 -4
View File
@@ -1,4 +1,4 @@
// Programmatic POSITIONING test for the win7 context menu. // Programmatic POSITIONING + HIGHLIGHT test for the win7 context menu.
// //
// bun scripts/menu-probe.ts // bun scripts/menu-probe.ts
// //
@@ -16,6 +16,16 @@
// i.e. the bug verbatim. That one must stay invisible — if it ever shows up, // 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. // 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 // 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 // holds the default profile's lock), and the page must be written INTO the
// dist folder or strict_origin_policy silently unstyles it. // dist folder or strict_origin_policy silently unstyles it.
@@ -35,6 +45,12 @@ if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:u
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css")); 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`); 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 ---- // ---- the probe page ----
// Geometry (CSS px = image px; headless Firefox applies no DPR scaling). // Geometry (CSS px = image px; headless Firefox applies no DPR scaling).
@@ -51,7 +67,7 @@ const menu = (x: number, marker: boolean) => `
<ul role="menu" tabindex="-1" <ul role="menu" tabindex="-1"
class="${marker ? "w7-ctx " : ""}fixed z-50 outline-none" class="${marker ? "w7-ctx " : ""}fixed z-50 outline-none"
style="left: ${x}px; top: ${MENU_Y}px; width: ${MENU_W}px"> style="left: ${x}px; top: ${MENU_Y}px; width: ${MENU_W}px">
<li role="menuitem"><button type="button">Send to Other Panel</button></li> <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" class="has-divider"><button type="button">Rename</button></li>
<li role="menuitem"><button type="button">Copy Path</button></li> <li role="menuitem"><button type="button">Copy Path</button></li>
<li role="menuitem" aria-disabled="true"><button type="button">Delete</button></li> <li role="menuitem" aria-disabled="true"><button type="button">Delete</button></li>
@@ -64,7 +80,7 @@ const menu = (x: number, marker: boolean) => `
// it) purely so "is anything drawn here?" is an unambiguous question. // it) purely so "is anything drawn here?" is an unambiguous question.
const page = `<!doctype html> const page = `<!doctype html>
<html class="win7" lang="en"> <html class="win7" lang="en">
<head><meta charset="utf-8"><link rel="stylesheet" href="${styles}"> <head><meta charset="utf-8"><link rel="stylesheet" href="${PROBE_CSS}">
</head> </head>
<body> <body>
<div class="window glass w7-frame active" style="display: flex; flex-direction: column; height: 100vh"> <div class="window glass w7-frame active" style="display: flex; flex-direction: column; height: 100vh">
@@ -121,6 +137,46 @@ async function inkCount(img: string, x: number, y: number, w: number, h: number)
return 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. // Inset by 2px so the scan cannot straddle the region's own edge.
const BOX = { w: MENU_W - 4, h: 60 }; const BOX = { w: MENU_W - 4, h: 60 };
const placed = await inkCount(out, MENU_X + 2, MENU_Y + 2, BOX.w, BOX.h); const placed = await inkCount(out, MENU_X + 2, MENU_Y + 2, BOX.w, BOX.h);
@@ -134,10 +190,36 @@ console.log(
console.log( console.log(
` control (no marker) ${String(control).padStart(6)} ${control > 0 ? "NOTE: 7.css no longer forces relative — premise stale" : "off-viewport, as expected"}`, ` 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}`); console.log(`\n[menu-probe] image: ${out}`);
let failed = false;
if (placed === 0) { if (placed === 0) {
console.log("[menu-probe] the context menu is positioned in FLOW again — see 7like.css .w7-ctx"); console.log("[menu-probe] the context menu is positioned in FLOW again — see 7like.css .w7-ctx");
process.exit(2); 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"); console.log("[menu-probe] clean");
+4 -1
View File
@@ -200,7 +200,10 @@
</figure> </figure>
<figure class="cell" style="margin:0"> <figure class="cell" style="margin:0">
<figcaption>ours — context-menu.ts output (buttons, divider, accelerator, checkables)</figcaption> <figcaption>ours — context-menu.ts output (buttons, divider, accelerator, checkables)</figcaption>
<ul role="menu" style="width:200px"> <!-- w7-ctx is the app's marker class (flat highlight, no Aero afterglow).
It also carries a position:fixed restatement, which the app wants and a
review page in normal flow does not — hence the inline static. -->
<ul role="menu" class="w7-ctx" style="width:200px;position:static">
<li role="menuitem"><button type="button">Send to Other Panel</button></li> <li role="menuitem"><button type="button">Send to Other Panel</button></li>
<li role="menuitem"><button type="button">Open</button></li> <li role="menuitem"><button type="button">Open</button></li>
<li role="menuitem"><button type="button">Edit <span>F4</span></button></li> <li role="menuitem"><button type="button">Edit <span>F4</span></button></li>
+45 -17
View File
@@ -137,9 +137,7 @@
commander convention this app already follows (see the pane-focus model commander convention this app already follows (see the pane-focus model
below): the band never disappears, only its strength changes. */ below): the band never disappears, only its strength changes. */
--w7-sel-bg: #cbe8f6; --w7-sel-bg: #cbe8f6;
--w7-sel-bd: #26a0da;
--w7-sel-bg-idle: #dedede; --w7-sel-bg-idle: #dedede;
--w7-sel-bd-idle: #c9c9c9;
/* Tabs (tabs.css). 7.css hardcodes `color: #222` on every tablist button, /* Tabs (tabs.css). 7.css hardcodes `color: #222` on every tablist button,
outside its own var system, which is unreadable on the dark surface. */ outside its own var system, which is unreadable on the dark surface. */
--w7-tab-c: #222; --w7-tab-c: #222;
@@ -201,9 +199,7 @@
--w7-lv-head-bd: var(--w7-el-bd-d); --w7-lv-head-bd: var(--w7-el-bd-d);
--w7-lv-cell-bd: var(--w7-el-bd-d); --w7-lv-cell-bd: var(--w7-el-bd-d);
--w7-sel-bg: #2a4a63; --w7-sel-bg: #2a4a63;
--w7-sel-bd: #4a8cbe;
--w7-sel-bg-idle: #3a3a3a; --w7-sel-bg-idle: #3a3a3a;
--w7-sel-bd-idle: #4c4c4c;
--w7-tab-c: var(--w7-el-c); --w7-tab-c: var(--w7-el-c);
--w7-mn-c: var(--w7-el-c); --w7-mn-c: var(--w7-el-c);
--w7-mn-bd: var(--w7-el-bd-d); --w7-mn-bd: var(--w7-el-bd-d);
@@ -262,9 +258,7 @@
--w7-lv-head-bd: var(--w7-el-bd-d); --w7-lv-head-bd: var(--w7-el-bd-d);
--w7-lv-cell-bd: var(--w7-el-bd-d); --w7-lv-cell-bd: var(--w7-el-bd-d);
--w7-sel-bg: #2a4a63; --w7-sel-bg: #2a4a63;
--w7-sel-bd: #4a8cbe;
--w7-sel-bg-idle: #3a3a3a; --w7-sel-bg-idle: #3a3a3a;
--w7-sel-bd-idle: #4c4c4c;
--w7-tab-c: var(--w7-el-c); --w7-tab-c: var(--w7-el-c);
--w7-mn-c: var(--w7-el-c); --w7-mn-c: var(--w7-el-c);
--w7-mn-bd: var(--w7-el-bd-d); --w7-mn-bd: var(--w7-el-bd-d);
@@ -582,7 +576,7 @@ html.win7 body {
} }
.win7 table > tbody > tr.highlighted { .win7 table > tbody > tr.highlighted {
background: var(--w7-sel-bg-idle); background: var(--w7-sel-bg-idle);
border-color: var(--w7-sel-bd-idle); border-color: transparent;
} }
/* ---- Window frame ---- /* ---- Window frame ----
@@ -658,15 +652,15 @@ html.win7 body {
1px at top and bottom and shoved every row below it down by 2px as the 1px at top and bottom and shoved every row below it down by 2px as the
cursor moved. Every row reserves that border instead, transparent when not cursor moved. Every row reserves that border instead, transparent when not
selected, so selecting changes colour and nothing else. selected, so selecting changes colour and nothing else.
NOTE: this sits in `skinfix`, which outranks `skin` by LAYER regardless of The border stays TRANSPARENT even when selected: it exists purely to
specificity, so it would also blank 7.css's highlight border — both reserve space. 7.css outlines the selected row (#aaddfa) on top of its
selected states therefore restate their border-color below. */ fill, which stacked a second edge inside the band and, with the cursor
row's dotted rect, drew three concentric outlines around one row. The fill
paints under the transparent border (background-clip defaults to
border-box), so the band still covers the full row. */
.win7 table > tbody > tr { .win7 table > tbody > tr {
border: 1px solid transparent; border: 1px solid transparent;
} }
.win7 table > tbody > tr.highlighted {
border-color: var(--w7-sel-bd-idle);
}
/* Pane-focus model (commander convention, matching Win7 Explorer): /* Pane-focus model (commander convention, matching Win7 Explorer):
the CURSOR ROW always carries the selection band in BOTH panels — the CURSOR ROW always carries the selection band in BOTH panels —
@@ -679,13 +673,13 @@ html.win7 body {
The dotted focus rectangle is NOT the focus signal; it is layered on top The dotted focus rectangle is NOT the focus signal; it is layered on top
of the band in the focused pane only, the way Win7 drew the keyboard of the band in the focused pane only, the way Win7 drew the keyboard
focus rect over the selected item. focus rect over the selected item.
FLAT, not a gradient: 7.css bands the row with --w7-el-grad-h, its Aero FLAT, and only a fill: 7.css bands the row with --w7-el-grad-h, its Aero
element-hover ramp, which reads as a glossy button rather than a file element-hover ramp, which reads as a glossy button rather than a file
selection. Explorer used one solid fill and a solid border. Keep the selection, and outlines it as well. One solid colour, no edge — the
1px border and 7.css's 3px radius — those ARE Explorer's selection box. */ dotted cursor rect is then the only line on the row, which is what makes
it legible as the cursor. */
.win7 table.is-active > tbody > tr.highlighted { .win7 table.is-active > tbody > tr.highlighted {
background: var(--w7-sel-bg); background: var(--w7-sel-bg);
border-color: var(--w7-sel-bd);
} }
/* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box /* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box
@@ -739,6 +733,40 @@ html.win7 body {
color: var(--w7-mn-check-c); color: var(--w7-mn-check-c);
} }
/* Menu items: flat highlight, and no Aero afterglow trailing the pointer.
Menu items ARE <button>s, so button.css reaches them with three effects
that do not belong on a menu:
(1) the highlight is 7.css's translucent wash
`linear-gradient(#fff9, #e6ecf5cc 90%, #fffc)` + an #aaddfa edge — the
same gradient the ListView selection above just dropped;
(2) `button::before` is a blue glow overlay whose opacity fades out over
a FULL SECOND (`button:not(:hover):before { transition: opacity 1s
linear }`). menu.css sets `content: none` on the :HOVER arm only, so
the pseudo reappears the instant you leave an item and then fades 1→0
— a ghost dragging behind the pointer. Killing `content` in EVERY
state is what removes it; suppressing the transition alone would not,
because the glow itself is legitimate on real push buttons;
(3) `button:focus` runs `pulse-anim` infinitely, and this menu moves REAL
focus between items for keyboard nav — so a focused item would breathe.
Scoped to the app's own menu (w7-ctx): 7.css's demo menus keep stock Aero. */
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label {
transition: none;
animation: none;
}
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::before,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::after {
content: none;
}
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:hover,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus-visible,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:hover,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:focus-visible {
background: var(--w7-sel-bg);
border-color: transparent;
}
/* ---- Flat buttons (toolbars + the w7-flat escape hatch) ---- /* ---- Flat buttons (toolbars + the w7-flat escape hatch) ----
The paint-suppression half of the rule in `skin` (see there for the split). The paint-suppression half of the rule in `skin` (see there for the split).
It lives in `skinfix` because 7.css restates border-color at (0,3,1) via It lives in `skinfix` because 7.css restates border-color at (0,3,1) via