I'm a first-year undergrad in Jeju, South Korea. I build a study app alone — I'm not a professional developer.
While preparing to launch it in Japan, I hit a bug where my mascot image rendered almost pure black on the page, but looked completely fine when opened directly in a browser tab. I assumed it was Chrome's forced dark mode and "fixed" it six times based on that assumption.
When I finally measured it with Playwright, dark mode had nothing to do with it. Not a single pixel changed with the flag on or off.
Who this is for
- Anyone using transparent PNGs on a dark-themed UI
- Anyone currently suspecting Chrome's forced dark mode
- Anyone pair-programming with an AI coding agent
What you'll get
- The non-obvious reason images crush to black
- How to kill a hypothesis with measurement instead of guesswork
- A rule for when to stop guessing
What happened
I placed a cream-coloured rabbit mascot (transparent PNG) on a dark-themed landing page.
On the page, the rabbit's body merged into the background. Only the outline and the pink of the ears stayed visible. Opened directly: fine. Embedded in the page: crushed.
Six wrong fixes
1. Blaming Chrome's forced dark mode
Chrome's "show web content in dark mode" feature classifies <img> elements by rendered size. Small icons are left alone; large images are treated as photos and have their bright regions darkened.
My header logo (32px) was fine. The large mascot (160px) was black. The hypothesis fit perfectly.
Added <meta name="color-scheme" content="dark">. No change.
2. Switching to background-image
The same feature processes CSS background images through a different path, so they're not inverted. Swapped <img> for background-image. No change.
3. Wrapping in SVG
<svg><image href="..."/></svg> so it isn't recognised as an <img>. No change.
Also, my instruction to the AI agent was vague enough that it interpreted "wrap the image in SVG" as "redraw the image in SVG using the original as reference." I got an entirely different animal. Be specific with agents.
4. Blaming the file
Opened it directly and the colour really was washed out. The background removal step had altered the RGB values.
Rebuilt from the original, manipulating only the alpha channel with Pillow. Verified RGB matched pixel-for-pixel before swapping it in. Still no change.
(There was a hole in that verification. More on that below.)
5. Blaming colour space and parent CSS
Walked every ancestor up to <html> checking filter, mix-blend-mode, backdrop-filter, and opacity. All none.
6. Finally measuring
At this point I stopped guessing.
# Screenshot the full page with forced dark mode ON and OFF, then diff
browser = playwright.chromium.launch(args=[
"--force-dark-mode",
"--enable-features=WebContentsForceDark",
])
Result:
max pixel diff across whole page: 0
mean pixel diff: 0.0
differing pixels: 0 / 1,152,000
Toggling forced dark mode changed nothing. Not one pixel.
As a sanity check, I ran the same flags against a bare test page with no color-scheme declaration. A cream box went from (245,240,230) to (41,37,30) — properly inverted. So the flag was working; it just wasn't touching my page.
I had been chasing a suspect who was never at the scene.
What was actually happening
The source image was 1408×768. I was rendering it into a 32px slot in the header and a 128px slot in the hero. That's an 11× to 44× downscale.
The transparent region was also large, so with object-contain the rabbit itself rendered even smaller than 32px.
Measured results:
| Source coordinate and RGB | Rendered after downscale |
|---|---|
(254,165,135) solid region |
(231,151,123) → ~9% darker |
(245,220,200) body |
(214,190,170) → ~13% darker |
(244,218,191) near alpha edge |
(22,21,27) → near black
|
Only the edges collapsed. The image wasn't inverted — something dark bled in during the downscale.
Where I'm still guessing
Two candidates for where the dark came from:
Theory 1: the browser's resampling mixed in the background. Semi-transparent edge pixels blend with the dark background behind them during downscale. But browsers normally resample with premultiplied alpha, so I'm not convinced this alone explains a collapse this severe.
Theory 2: the transparent pixels were already black. Images that have been through a background-removal tool often contain large regions of "fully transparent, but RGB is black." Invisible on their own — but downscale and that black bleeds into the edges.
In step 4 I verified my rebuilt image matched the original pixel-for-pixel. But since the thing I compared against was the original, if the original already had black under the transparent areas, the problem survived the check untouched. I thought I had verified something. I hadn't.
I think theory 2 is more likely. Checking it is trivial — just look at the RGB of pixels where alpha is 0:
from PIL import Image
import numpy as np
a = np.array(Image.open("mascot.png").convert("RGBA"))
transparent = a[a[..., 3] == 0]
print(transparent[:, :3].mean(axis=0)) # near black → theory 2
If it is theory 2, the fix is to fill the transparent region's RGB with the colour of neighbouring opaque pixels — usually called colour bleed or edge padding. Most game texture tools have it built in.
I'll update this post when I've checked. If you've hit this before, I'd like to hear which one it was.
What I did instead
The proper fix is to prepare an image at the size you'll actually render. Resample with premultiplied alpha, then unpremultiply. Skip that and your edges go dark.
I had a launch deadline, so I put a white circle behind the mascot:
<div className="rounded-full bg-white p-2">
<Logomark />
</div>
Whatever the cause, this removes any opportunity for dark colour to bleed in. It also improved the mascot's visibility on the dark theme, so it worked out design-wise too.
But this removed the symptom. It did not prove the cause.
What I took away
The most plausible hypothesis is the most dangerous one
My first hypothesis explained the symptoms perfectly. Small logo fine, large image black — exactly matching forced dark mode's size-based classification. That's precisely why I didn't question it until attempt five.
The real mechanism was "larger render slot means a larger downscale ratio, so edge bleeding shows more strongly." Completely different cause, identical symptom.
Interrogate what "I verified it" actually means
In step 4 I wrote that I had verified RGB matched pixel-for-pixel. The numbers were correct. I was comparing against the wrong thing, so I learned nothing.
Verification code passing feels like safety. But what that verification can and cannot rule out lives outside the code.
Measuring is cheaper than you think
The Playwright measurement in step 6 took under 30 minutes. Doing it first would have saved five rounds of fixes.
Guess-and-fix is fine while it's working. But miss twice and measure. That's the right threshold.
The same rule applies with AI agents
I delegated most of this work to an AI coding agent. Agents don't question your hypothesis. They implement it, faithfully and fast. So a wrong hypothesis compounds into wrong fixes at high speed.
At one point I had three layers of stacked workarounds that were becoming a bug source of their own.
Asking the agent to "measure the cause and report back" rather than "fix this" is faster in the end. That's what finally solved it, in one pass.
Why I was building this in the first place
Before every exam, past papers appear from somewhere. From club seniors, from lab mates, from a group chat you're in.
I wasn't in any of them. Same lectures, same hours studying, different starting line.
This isn't only a Korean thing. In Japan I kept running into the same story — if you're not in a circle, past papers don't reach you. Different countries, identical structure.
So I built the thing that doesn't need a social network to get: you upload your own course materials, it analyses them per-instructor, and produces likely exam questions plus a condensed summary. Your files stay yours — nothing is shared with other users, and there is no document library.
carrotly.app — free, no signup needed.
I genuinely have no idea how well it holds up on a European or American syllabus. If it fails on yours, that's the feedback I want most.
The rabbit in my logo is the mascot that survived six rounds of fixes.
Thanks for reading. If you've hit the same symptom, I hope this saved you some hours.
Top comments (0)