Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions web/src/components/terminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down Expand Up @@ -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) => <Terminal sessionId="s1" createEmulator={e.create} />)

// 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) => <Terminal sessionId="s1" createEmulator={e.create} />)
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', () => {
Expand Down
29 changes: 24 additions & 5 deletions web/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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])
Expand Down
Loading