From d77d9e9b5bf64c039ed46626f96454b99503e80f Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 8 Aug 2026 20:40:29 +0530 Subject: [PATCH] fix(web): the body wears the terminal theme too (black keyboard bar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Painting the document canvas with the terminal's background fixed the band a phone keyboard reveals only in theory. The body's box is the full height of the layout viewport, and a phone keyboard shortens only the visual one, so the body's own stylesheet colour paints over the canvas across exactly the strip the keyboard uncovers — zinc-950, which iOS 26 then shows around its keyboard slab and behind the form accessory bar as a black bar under the session. Only overscroll past the body's box ever reached the canvas the earlier fix claimed. paintGround now takes both grounds, with the same ownership-checked restore on unmount the canvas already had: a replacement owner can paint before this one tears down, and a stale cleanup must surrender only what it still owns. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/terminal.test.tsx | 43 ++++++++++++++++++++++++++-- web/src/components/terminal.tsx | 29 +++++++++++++++---- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index 63230f7..9dc7602 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -111,9 +111,10 @@ afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() document.title = 'flue' - // The terminal paints the document canvas inline; a leak here would fail - // in whichever test runs next rather than the one that leaked. + // The terminal paints the document canvas and the body inline; a leak here + // would fail in whichever test runs next rather than the one that leaked. document.documentElement.style.backgroundColor = '' + document.body.style.backgroundColor = '' localStorage.clear() }) @@ -1511,6 +1512,44 @@ describe('the terminal theme', () => { view.unmount() expect(document.documentElement.style.backgroundColor).toBe('rgb(1, 2, 3)') }) + + it('paints the body with it, because the body covers the canvas', () => { + localStorage.setItem('flue:theme', 'dracula') + const { view } = mountTerminal((e) => ) + + // The canvas alone was not enough, and this is the band that proved it: + // the body is full-height of the *layout* viewport, which a phone + // keyboard does not shorten, so the body's own stylesheet colour paints + // over the canvas across exactly the strip the keyboard uncovers below + // the viewport-pinned pane. On iOS 26 that strip is what shows around + // the keyboard slab and behind the form accessory bar — zinc-950 under a + // dracula terminal read as a black bar sitting under the session. + expect(document.body.style.backgroundColor).toBe('rgb(40, 42, 54)') + + // A theme change repaints the body along with the canvas and the pane. + act(() => { + window.dispatchEvent( + new StorageEvent('storage', { key: 'flue:theme', newValue: 'nord' }), + ) + }) + expect(document.body.style.backgroundColor).toBe('rgb(46, 52, 64)') + + // And leaving hands it back, so the sessions screen wears app colours. + view.unmount() + expect(document.body.style.backgroundColor).toBe('') + }) + + it('does not claw back a body someone newer has painted', () => { + localStorage.setItem('flue:theme', 'dracula') + const { view } = mountTerminal((e) => ) + expect(document.body.style.backgroundColor).toBe('rgb(40, 42, 54)') + + // Same restraint the canvas gets, for the same teardown order: a + // replacement owner paints before this view's cleanup runs. + document.body.style.backgroundColor = 'rgb(1, 2, 3)' + view.unmount() + expect(document.body.style.backgroundColor).toBe('rgb(1, 2, 3)') + }) }) describe('the new-session link', () => { diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index 1a92299..92b2305 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -181,24 +181,40 @@ export function Terminal({ const surface = surfaceRef.current if (!pane || !inner || !surface) return - // The document's canvas, painted along with the pane wherever the theme - // lands. The pane stops at the visual viewport while a phone keyboard is - // up, and rubber-band overscroll runs past the page: both bands show the - // canvas, which otherwise wears the app scheme's colour — a dark OS under - // a light terminal theme put a black flash behind every keyboard open. + // The two grounds behind the pane, painted along with it wherever the + // theme lands. The pane stops at the visual viewport while a phone + // keyboard is up, and rubber-band overscroll runs past the page: both + // bands show what is under the pane, which otherwise wears the app + // scheme's colour — a dark OS under a light terminal theme put a black + // flash behind every keyboard open. + // + // Both, because the canvas alone leaves the keyboard band wrong in the + // other direction. The body's box is the full height of the *layout* + // viewport, and a phone keyboard shortens only the visual one, so the + // body paints its own stylesheet colour over the canvas across exactly + // the strip the keyboard uncovers — zinc-950, a black bar under a dark + // terminal theme, which is what iOS 26 shows around its keyboard slab + // and behind the form accessory bar. Only overscroll past the body's box + // ever reaches the canvas itself. + // // Restored on unmount so the rest of the app keeps its stylesheet colour. const canvas = document.documentElement + const body = document.body const priorCanvas = canvas.style.backgroundColor + const priorBody = body.style.backgroundColor // What this effect last painted, read back so the value carries the // style engine's own serialisation. The cleanup compares before it // restores, for the reason the viewport tracker's disposer does: a // replacement owner can paint before this one tears down, and a stale // cleanup must surrender only what it still owns. let paintedCanvas = '' + let paintedBody = '' const paintGround = (bg: string | undefined) => { pane.style.backgroundColor = bg ?? '' canvas.style.backgroundColor = bg ?? '' + body.style.backgroundColor = bg ?? '' paintedCanvas = canvas.style.backgroundColor + paintedBody = body.style.backgroundColor } const palette = resolveTheme(themeIdRef.current, prefersDark()) @@ -718,6 +734,9 @@ export function Terminal({ if (canvas.style.backgroundColor === paintedCanvas) { canvas.style.backgroundColor = priorCanvas } + if (body.style.backgroundColor === paintedBody) { + body.style.backgroundColor = priorBody + } emulator.dispose() } }, [client, sessionId, createEmulator])