From 1ea441211d94267fb0deed9740c5dd9eba284ddd Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:06:11 +0200 Subject: [PATCH] fix(terminal): the panel paints the ground, so no strip shows under the last row (#6) xterm sizes itself in whole rows (measured: 604px in a 611px box), so the pixels under the last row were a transparent element and showed the native window background: a grey bar under a black theme. The panel now paints --p-bg and xterm's canvas is clear, with cursorAccent named so the character under a block cursor is not a hole. One coat per pixel, which is also what acrylic needs. The e2e theme scenario measures the strip and fails on the old code. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 12 ++++- src/renderer/src/components/TerminalPanel.tsx | 54 ++++++++++--------- tools/e2e/run.mjs | 41 ++++++++++++++ 3 files changed, 79 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2526a8b..244c511 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,10 +70,18 @@ terminal theme, anything that reads or shows files. - **The title bar has a settings cog and NO menu** (owner, same day: a menu of Settings + Quit was cut to the cog). Nothing in the UI needs to quit the app any more; the X does. - **The theme drives the chrome** through the real `--p-*` tokens (`lib/chromeTheme.ts`). Every ink is - moved to a contrast floor, and light/dark is MEASURED from the ground, never read off a name. No - container behind `TerminalPanel` may paint `--p-bg`: on acrylic that is a second translucent coat. + moved to a contrast floor, and light/dark is MEASURED from the ground, never read off a name. The `:root` fallbacks in `index.css` are `chromeTokens` output for the `prism` preset; recompute them if either changes. +- **The terminal panel paints the ground; xterm's canvas is CLEAR** (2026-09-19, #6, owner + screenshot: a grey bar under a black terminal). xterm sizes itself in whole rows (MEASURED: 604px + in a 611px box), so the strip under the last row is never its to paint; with the ground on the + canvas and a transparent box round it, that strip showed the native window background. So + `TerminalPanel`'s box is `bg-[var(--p-bg)]` and `currentTermTheme()` hands xterm + `background: #00000000` plus a named `cursorAccent` (it defaults to the background, which would + make the character under a block cursor a hole). Exactly ONE coat per pixel, which also matters + on acrylic: two translucent coats are a visibly darker panel, so App's own container behind the + panel stays unpainted. The e2e `theme` scenario measures the strip, and fails on the old code. - **Opacity is a number read defensively** (`termOpacity`): `Number(null)` is 0, and never-set must read as opaque. - **Explorer verbs**: HKCU, `reg.exe` with argv only, on `Directory` and `Directory\Background`, no diff --git a/src/renderer/src/components/TerminalPanel.tsx b/src/renderer/src/components/TerminalPanel.tsx index 5bd8081..cabea20 100644 --- a/src/renderer/src/components/TerminalPanel.tsx +++ b/src/renderer/src/components/TerminalPanel.tsx @@ -9,13 +9,10 @@ import { registerPaste, reportCwd, reportTitle } from '../lib/termBus' import { parseOsc9 } from '@shared/termCwd' import { resolveTermTheme } from '../lib/termTheme' import { normalizeColor } from '../lib/termAnsi' -import { alphaHex } from '../lib/chromeTheme' import { onTermLookChange, - termAcrylic, termBaseFontPx, termFontStack, - termOpacity, termThemeId } from '../lib/termLook' import { @@ -47,21 +44,30 @@ interface Session { const sessions = new Map() -/** The theme as painted. Acrylic works with ANY theme here: the theme's own - * background is kept and only its alpha changes (#rrggbbaa at termOpacity()), - * so the window's material shows through a ground that is still the theme's - * colour. It is the same colour at the same alpha the chrome paints (--p-bg, - * lib/chromeTheme), which is what makes the window one sheet. NOTHING behind - * the canvas may paint that colour a second time: two translucent coats are a - * visibly darker panel than the rest of the window. */ -function currentTermTheme(): ReturnType { - const theme = { ...resolveTermTheme(termThemeId()) } - if (termAcrylic() && termOpacity() < 100) { - // Flattened first: a custom theme may hold #rgb or an rgba(), and an alpha - // pair appended to either is not a colour. - theme.background = normalizeColor(theme.background, '#0b0b0f') + alphaHex(termOpacity()) - } - return theme +/** + * The theme as painted: the theme's colours on a TRANSPARENT canvas. + * + * THE PANEL PAINTS THE GROUND, NOT XTERM (2026-09-19, owner screenshot: a grey + * bar along the bottom of a black terminal). xterm sizes itself in whole rows, + * MEASURED at 604px in a 611px box, so a few pixels under the last row are + * never xterm's to paint. While the canvas carried the ground and the box + * around it was transparent, that strip showed the native window background + * instead of the theme. So the ground is `--p-bg` on the panel (the chrome's + * own colour, at the window's alpha when it is acrylic, see lib/chromeTheme) + * and the canvas over it is clear: every pixel of the panel, rows, frame and + * leftover strip alike, gets exactly ONE coat. One and not two is the other + * half of it: two translucent coats are a visibly darker panel than the rest + * of an acrylic window. + * + * The block cursor draws the character under it in `cursorAccent`, which + * defaults to the background; on a clear background that would be a hole, so + * it is named. + */ +function currentTermTheme(): ReturnType & { cursorAccent: string } { + const theme = resolveTermTheme(termThemeId()) + // Flattened first: a custom theme may hold #rgb or an rgba(). + const solid = normalizeColor(theme.background, '#0b0b0f') + return { ...theme, background: '#00000000', cursorAccent: solid } } /** @@ -529,18 +535,14 @@ export default function TerminalPanel({ } }, [sessionId, root, shellId]) - // The 4px frame round the canvas is a BORDER in --p-bg, not padding over a - // background. The canvas paints the theme's ground itself, at the window's - // alpha when the window is acrylic, so a background here would be a second - // translucent coat under it and a visibly darker panel than the rest of the - // window (Prism's acrylic terminal made that obvious); padding alone would - // leave a frame of bare desktop. A border is painted once and the canvas - // sits inside it, so every pixel of the panel gets exactly one coat. + // The panel paints the ground and the canvas over it is clear (see + // currentTermTheme): the 4px frame, the rows and the strip under the last + // row are one surface in one coat. return (
) } diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 86ad8ea..58ad479 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -207,6 +207,44 @@ const scenarios = { await until(async () => (await tabLabels(page)).length === 1) const mode = () => page.evaluate(() => document.documentElement.dataset.mode) ok((await mode()) === 'dark', 'the default theme is dark') + // The panel is ONE surface in the theme's ground, down to its last pixel. + // xterm sizes itself in whole rows, so a strip under the last row is never + // its to paint; left transparent, that strip showed the native window + // background (owner screenshot: a grey bar under a black terminal). + const ground = () => + page.evaluate(() => { + const rgb = (c) => (c.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number).join(',') + const probe = document.createElement('span') + probe.style.color = getComputedStyle(document.documentElement).getPropertyValue('--p-bg-solid') + document.body.appendChild(probe) + const want = rgb(getComputedStyle(probe).color) + probe.remove() + const box = document.querySelector('[data-term-region]').getBoundingClientRect() + const rows = document.querySelector('.xterm-screen').getBoundingClientRect() + // The first painted ground under a point, walking up from what is there. + const under = (x, y) => { + let el = document.elementFromPoint(x, y) + while (el) { + const c = getComputedStyle(el).backgroundColor + const alpha = Number((c.match(/[\d.]+/g) ?? [])[3] ?? 1) + if (c.startsWith('rgb') && alpha > 0) return rgb(c) + el = el.parentElement + } + return 'none' + } + const x = box.left + box.width / 2 + return { + want, + strip: Math.round(box.bottom - rows.bottom), + underLastRow: under(x, box.bottom - 2), + frame: under(box.left + 1, box.top + box.height / 2), + rows: under(x, rows.top + rows.height / 2) + } + }) + const g = await ground() + ok(g.strip > 0, `there IS a strip under the last row to get wrong (${g.strip}px)`) + ok(g.underLastRow === g.want, `and it is the theme's ground (${g.underLastRow} vs ${g.want})`) + ok(g.frame === g.want && g.rows === g.want, 'as are the frame round the rows and the rows themselves') await page.keyboard.press('Control+,') await page.locator('[data-settings-tab="appearance"]').click() await page.locator('[data-term-card="github"]').first().click() @@ -218,6 +256,9 @@ const scenarios = { return 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255) }) ok(lum > 0.4, `the ground really is light (luminance ${lum.toFixed(2)})`) + await page.locator('[data-tab]').first().click() + const g2 = await ground() + ok(g2.underLastRow === g2.want && g2.want !== g.want, `the strip follows the theme (${g2.underLastRow})`) const bg = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].getBackgroundColor()) ok(/^#f/i.test(bg), `main was told the window's ground (${bg})`) await app.close().catch(() => {})