You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 caption — AtlasViewer.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 fixedspecifically 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 shell — TwinCanvas.client.tsx:472,731, fixed top:64.
The cookie consent banner — fixed inset-x-0 z-[60].
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:
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.
Turning on colorblind mode breaks
position: fixedeverywhere on the siteFound 2026-07-17 by an adversarial review while planning #301. Confirmed live, not theorised.
The bug
src/hooks/useColorblindMode.ts:37:A
filterother thannonemakes that element a containing block forposition: fixeddescendants. The CSS Filter Effects spec exempts only the root element — and
<body>isnot 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:0probe:body is 871px tall on that route, so the probe is displaced by exactly the scroll offset.
What is already broken by this
AtlasViewer.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 itto
fixedspecifically to rescue it from hiding behind the cookie banner. With colorblindmode on, it scrolls away again. The comment at
:719-732concludes "viewport-relativepositioning does" — which silently isn't true for these users.
TwinCanvas.client.tsx:472,731,fixed top:64.fixed inset-x-0 z-[60]./messages—page.tsx:78,fixed inset-x-0 top-16 bottom-[calc(7rem+…)].PWAInstall.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.tsare of the form: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:
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:36sets--colorblind-filteron the root. Nothing consumes it — theonly other references in the repo are two assertions in the hook's own test file. It should go,
or be the mechanism.
Acceptance
document.documentElement; the simulation still visibly paints (screenshotwith colorblind mode on — computed style is not proof).
fixedelement staysviewport-anchored on a scrolled page with colorblind mode enabled.
--colorblind-filterremoved.Blocks #301 in spirit: the twin-route chrome model rests entirely on
position: fixedworking.