ADR-001: Never Use filter:invert()
Status
Accepted
Context
Many dark mode extensions and CSS snippets use filter: invert(1) on the html or body element to quickly flip all colors from light to dark. While this produces a visually dark page with minimal code, it causes severe problems in practice:
App crashes from oklch parsing failures. Complex web apps like Google Sheets read computed styles back in JavaScript. When our generic CSS injects
oklch()color values, the app's internal color parser (which expectsrgb()orrgba()format) throws errors. The specific failure observed: Google Sheets readselement.style.color, receivesoklch(0.88 0.01 260), passes it to an internal parser, and crashes withError in protected function: hg'oklch(0.88 0.01 260)'.Images, videos, and media are inverted. A blanket
filter: invert()inverts all visual content — photos appear as negatives, videos become unwatchable, favicons and logos look broken. Counter-inverting media elements (img { filter: invert(1) }) is fragile and misses canvas elements, SVGs, CSS background images, and dynamically loaded content.Inconsistent results. Sites with mixed light/dark sections, gradient backgrounds, or semi-transparent overlays produce unpredictable and ugly results with blanket inversion.
Performance cost. Applying a CSS filter to the root element forces the browser to composite the entire page through the filter, increasing GPU memory usage and reducing scroll/animation performance.
Decision
The extension will never use filter: invert(), filter: brightness(), or any blanket CSS filter on container elements. Instead, it uses a three-layer engine with explicit color remapping:
- Layer 1: Force native dark mode via
color-scheme: dark(zero side effects) - Layer 2: Generic CSS with an oklch-based dark palette applied to standard HTML elements
- Layer 3: JS MutationObserver to remap inline styles on dynamically-added elements
- Site overrides: CSS-only files that target known-safe selectors, skipping Layers 2 and 3
Alternatives Considered
filter: invert(1)with counter-inversion for media — The simplest approach. Rejected because it still breaks apps that read computed styles, misses canvas/SVG/CSS-background images, and has a performance cost. The counter-inversion list is never complete and requires constant maintenance.CSS custom properties only (no color injection) — Set CSS variables like
--bg-dark: oklch(0.15 0.01 260)and hope sites pick them up. Rejected because virtually no third-party sites read our custom properties, so this would have no effect on existing sites.Per-element color mapping (compute and replace every color) — Walk the DOM, read every element's computed colors, convert them to dark equivalents, and set them inline. Rejected because it is extremely expensive (full DOM walk + getComputedStyle on every element), fights with app JavaScript that also sets inline styles, and causes style thrashing / layout recalculation storms.
Consequences
Positive
- Images, videos, canvases, and SVGs are never affected — they display as the author intended
- No crashes in complex web apps (Google Sheets, Notion, Figma, Slack) because we do not inject colors into elements those apps manage
- Predictable color output using a consistent oklch palette
- Better performance — no root-level filter compositing
Negative
- More work per site — complex apps need hand-crafted CSS overrides targeting safe selectors
- The generic CSS (Layer 2) cannot cover every site perfectly; some sites look mediocre until they get a dedicated override
- The oklch palette may itself cause parsing failures in apps that read computed styles, which is why site overrides skip Layers 2 and 3 entirely for those apps