Skip to content

Colorblind mode breaks position:fixed site-wide (filter on <body> creates a containing block) #305

Description

@TortoiseWolfe

Turning on colorblind mode breaks position: fixed everywhere on the site

Found 2026-07-17 by an adversarial review while planning #301. Confirmed live, not theorised.

The bug

src/hooks/useColorblindMode.ts:37:

body.style.filter = filterValue;   // 'url(#protanopia)' etc.

A filter other than none makes that element a containing block for position: fixed
descendants
. The CSS Filter Effects spec exempts only the root element — and <body> is
not the root, <html> is.

So when a colorblind user enables the accessibility feature, every fixed element on the page
stops being fixed and starts scrolling with the document.

Measured live on /chatt/ (dev, 1200x630, page scrolled to y=150)

A position:fixed; top:64; bottom:0 probe:

body.filter = none              ->  top   64, bottom 630    (viewport-anchored, correct)
body.filter = url(#protanopia)  ->  top  -86, bottom 721    (anchored to body, scrolls!)

body is 871px tall on that route, so the probe is displaced by exactly the scroll offset.

What is already broken by this

  • The tour captionAtlasViewer.client.tsx:733, fixed bottom-28 z-[55]. PR feat(#292): the atlas is the default — wide bake, flip, honest tests, tour + card #297 moved it
    to fixed specifically to rescue it from hiding behind the cookie banner. With colorblind
    mode on, it scrolls away again. The comment at :719-732 concludes "viewport-relative
    positioning does"
    — which silently isn't true for these users.
  • The diorama shellTwinCanvas.client.tsx:472,731, fixed top:64.
  • The cookie consent bannerfixed inset-x-0 z-[60].
  • /messagespage.tsx:78, fixed inset-x-0 top-16 bottom-[calc(7rem+…)].
  • PWA installPWAInstall.tsx:226,253, fixed top-20 right-4.

It hides wherever body height happens to equal the viewport (body's padding box then is the
viewport, so there's no offset to diverge). That coincidence is exactly what makes it dangerous:
it holds on short pages and breaks on scrolling ones.

Why the tests never caught it

All 8 assertions in src/hooks/useColorblindMode.test.ts are of the form:

expect(document.body.style.filter).toBe('url(#tritanopia)');

They assert the property is set. They cannot observe a containing-block change — that needs
layout, and jsdom has none. A hook whose entire job is a visual side effect is tested only for
having assigned a string. Fourth sighting of this repo's recorded trap: the assertion passes
while the user's screen is wrong.

The fix, proven before writing code

Move the filter to the root element. Measured on the same page, same scroll:

no filter          ->  top  64, bottom 630
filter on <html>   ->  top  64, bottom 630    <- IDENTICAL, and computed style != none
filter on <body>   ->  top -86, bottom 721    <- broken

The root element is spec-exempt from creating the containing block, and the filter still
applies to the whole rendered tree.

Also: dead code next door

useColorblindMode.ts:36 sets --colorblind-filter on the root. Nothing consumes it — the
only other references in the repo are two assertions in the hook's own test file. It should go,
or be the mechanism.

Acceptance

  • Filter moves to document.documentElement; the simulation still visibly paints (screenshot
    with colorblind mode on — computed style is not proof).
  • A regression test that jsdom cannot fake: a Playwright check that a fixed element stays
    viewport-anchored on a scrolled page with colorblind mode enabled.
  • Dead --colorblind-filter removed.

Blocks #301 in spirit: the twin-route chrome model rests entirely on position: fixed working.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions