Skip to content

fix(render): ceil canvas dimensions so 4K full-screen doesn't leak the wrap bg - #31

Open
winlp4ever wants to merge 1 commit into
mainfrom
fix/canvas-fractional-css-gap
Open

fix(render): ceil canvas dimensions so 4K full-screen doesn't leak the wrap bg#31
winlp4ever wants to merge 1 commit into
mainfrom
fix/canvas-fractional-css-gap

Conversation

@winlp4ever

Copy link
Copy Markdown
Owner

Problem

On high-DPI displays (notably 4K full-screen), `ResizeObserver` can report fractional CSS widths (e.g. `3840.4`). The previous canvas-sizing math:

```ts
surface.canvas.width = Math.max(1, Math.round(cssW * dpr)) // backing: rounded down
surface.canvas.style.width = `${cssW}px` // CSS: raw fractional
```

…rounded the backing store to integer device pixels but kept the raw fractional value for the CSS dimension. At certain widths/DPRs, the browser snaps that CSS dim down at layout time, leaving the canvas ~0.5 CSS px narrower than the wrap div. The wrap's background (`#f8fafc`, hard-coded in Canvas.tsx, not theme-aware) shows through that thin strip on the right edge.

  • Light mode: wrap bg matches the page → invisible.
  • Dark mode + 4K full-screen: faint white vertical line on the right, briefly visible during quick zoom-out (each tier-3 frame clears the static surface then repaints, momentarily exposing the strip).

Slightly resizing the window away from 4K-exact makes `cssW` integer-aligned and the gap disappears — which matches what was reported.

Fix

Use `Math.ceil` (not `Math.round`) consistently for backing store, CSS dim, and the stored `surface.cssWidth`/`cssHeight`:

```ts
const ceilCssW = Math.ceil(cssW)
const ceilCssH = Math.ceil(cssH)
surface.canvas.width = Math.max(1, Math.round(ceilCssW * dpr))
surface.canvas.height = Math.max(1, Math.round(ceilCssH * dpr))
surface.canvas.style.width = `${ceilCssW}px`
surface.canvas.style.height = `${ceilCssH}px`
surface.cssWidth = ceilCssW
surface.cssHeight = ceilCssH
```

This guarantees the canvas is always ≥ wrap width, never narrower. Sub-pixel overflow (< 1 CSS px) is clipped invisibly by the wrap's existing `overflow: hidden`.

Side fix

Updates the early-out check in `sizeSurface` to compare against the ceiled values too. Without that, a fractional ResizeObserver report that ceils to the same integer as the cached value would re-allocate the canvas backing store on every call — visible flicker on 4K.

Why the renderer's viewport math also gets the ceiled value

`surface.cssWidth` flows into `worldViewport`, `paintBackground`, and the cache-source-rect helpers. Storing the raw fractional value while displaying the ceiled value would mean the renderer thinks the viewport is one size while the canvas is actually displaying another — sub-pixel discrepancy at the right edge. Storing the ceiled value keeps both halves consistent.

No regressions for the common case

Tests run at integer `cssW` / `cssH` values. `Math.ceil(N)` of an integer is the same integer, so the existing test fixtures behave identically.

Test plan

  • `pnpm -w typecheck`
  • `pnpm -w lint`
  • `pnpm -w test` — 351 + 1 passed
  • `pnpm -C packages/core test:browser` — 61/61
  • Manual on 4K full-screen, dark theme — verify the white right-edge line is gone during quick zoom-out (user-verified).

…e wrap bg

On high-DPI screens — notably 4K full-screen — ResizeObserver can
report fractional CSS widths (e.g. `3840.4`). The previous sizing
math used `Math.round` for the backing store and the raw fractional
value for `canvas.style.width`. At certain widths and DPRs, the
browser snaps the canvas's CSS dim down at layout time, leaving the
canvas ~0.5 CSS px narrower than the wrap div. The wrap's
non-theme-aware background (`#f8fafc`) shows through as a faint
vertical strip on the right — invisible in light mode, a visible
white line in dark mode during quick zoom-out (when the static
surface is cleared+repainted per tier-3 frame, briefly exposing
the strip before the next paint covers it).

Switches to `Math.ceil` for both backing and CSS dimensions, and
stores the ceiled value as `surface.cssWidth`/`cssHeight` so the
renderer's viewport math (used in `worldViewport`,
`paintBackground`, etc.) sees the same dimensions the canvas is
actually displaying. The canvas now ends up ≤ 1 CSS px wider than
the wrap; the wrap's `overflow: hidden` clips that sub-pixel
overflow to invisibility.

Also updates the early-out comparison in `sizeSurface` to use the
ceiled values — otherwise a fractional ResizeObserver report that
ceils to the same integer as the cached value would re-allocate
the backing store every call (flicker on 4K).

No behavior change at integer cssW/cssH values (the common case).
351+1 node tests, 61 browser tests, all green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant