Nadir
Nadir is a small on-device model that gives light websites a tasteful, readable dark mode, with the care of a real dark theme rather than a filter. It runs entirely in your browser (onnxruntime-web, WASM) and ships with a ready-to-load Chromium extension. The extension is open source: github.com/SSHdotCodes/nadir.
Left: the page as published. Right: the same page with Nadir. These are first visits in a fresh profile, rendered by training/ext_render.py.
GeoGebra. The purple brand color and the illustration keep their colors; surfaces and text go dark.
python.org. The brand's blue and yellow stay, and the code sample keeps its syntax colors.
Wolfram MathWorld. Formula images are recolored so they read on the dark page; the rendered sphere keeps its colors.
Hacker News. The orange header stays; the beige page becomes a warm dark grey.
AddToAny. The light sunburst texture behind the hero is removed, the dark wordmark turns light, and the share buttons keep their brand colors.
PyPI. Badges, code blocks and the blue header keep their colors and hierarchy.
Wikipedia has its own dark theme, so Nadir switches that on instead of designing one.
Nadir follows three rules:
Already dark? Leave it alone. Pages that are already dark are detected and never touched.
Has its own dark mode? Use it. Nadir switches on the site's own dark theme:
prefers-color-schememedia rules, ordark/theme-darkclass and attribute toggles it detects in the page's CSS.No dark mode? The model designs one. Nadir reads the page's colors and images and designs a dark theme for that specific site:
- brand colors stay recognizable and surfaces keep their hierarchy;
- text stays readable;
- every image, CSS background, sprite and icon is shown the way a designer would choose.
Photos and videos are never recolored.
v2 (2026-10-15) adds an element head: the model now decides for itself how to show every image, CSS
background, sprite icon and ::before icon, including ones that load after the page. On 23,532 held-out elements
its choice is acceptable to the teacher 94.4% of the time, against 89.3% for v1's hand-written rules. On 190 held-out live pages, a blind
Claude Haiku 5.5 judge prefers v2 to Dark Reader 126 to 56 and to v1 91 to 59. Weights, quants and the extension in
this repository are v2; the extension and runtime are at 2.0.1, which fixes the pages where v1 still did better
(same weights).
What's new in v2
2.0.1: fixes for the pages where 2.0.0 did worse than v1. The judge preferred v1 on 59 of the 190 evaluation pages. Each one was re-rendered and inspected; the losses that reproduced came from a few runtime causes, all fixed in the extension (same model weights):
- Dark text in logos on headers that turn dark. A wordmark or tagline drawn in dark ink on transparency was kept as is when the element head preferred keeping it by a small margin, and vanished on the dark header. It is now adapted (dark ink turns light, brand colors stay) whenever the head gives adapting a real chance.
- Light icons drawn for a dark surface. A light icon on a surface that is dark in the original and stays dark is shown as it is; inverting or adapting it made it disappear into that surface.
- Adapting transparent logos. Thin white outlines and knockouts in a transparent logo stay white; white text on a dark box still flips together with its box.
- Elements drawn over a picture shown as it is (a title graphic over a hero illustration) stay as designed.
- Large light pictures under text. A pale hero image or decorative supergraphic kept as it is under text that the dark palette turns light goes dark too: removed when it is a CSS background the head was open to removing, otherwise inverted with hues preserved.
- Backgrounds of
::before/::afterare measured in the pseudo-element's own box. A 32-pixel checkmark was measured as its 700-pixel list item and removed as a backdrop. - Pages painted dark by a gradient on
<html>/<body>with no background color are recognized as already dark and left alone. - Large light gradients that the palette kept light go dark outside media (Haiku darkens 94-98% of light gradient stops that cover a tenth of the screen or more), and the stops of one gradient are checked together.
An element head. v1 decided how to show the first 32 images of a page with a 5-way image head; everything else (CSS backgrounds, sprites, icons, images that load later) fell to hand-written rules, and the rules got logos, icons and light textures wrong often enough to notice. v2 removes those rules. Every visual element is classified by the model itself:
- how to show it:
keep,invert,adaptorremove; - what it is: one of 9 roles;
- with a calibrated confidence.
Three fixed rules remain: photos and people keep their colors, a background is only removed when the head is at least 70% sure, and an element whose surface stays light in the dark design is shown as designed. The element head is a separate 1.2M-parameter graph that reuses the page's design context, so the extension can decide on an image that appears minutes after the page loaded (Gmail icons, lazy-loaded logos, CSS sprites,
::beforeicons) in about a millisecond.- how to show it:
Element supervision from Claude Haiku 5.5. About 80,000 contact sheets were rendered and judged by Haiku 5.5. Each sheet shows elements on their page's real light surface and on the dark surface Haiku designed for that spot, each in every rendering the runtime can produce (kept, inverted, adapted, removed). Haiku picked the best option and marked every acceptable one, which gave 500,785 element judgments. Each option is rendered with the same pixel transforms the extension uses, so what Haiku judged matches what the extension shows.
More pages, and inner pages. 14,196 new Haiku 5.5 designs, mostly of inner pages: docs, dashboards, inboxes, articles, stores and settings, not just home pages. Together with v1's data that makes 33,615 designs and 6,434 native dark-theme pairs.
Runtime fixes found by end-to-end testing.
- Images, backgrounds and icons that appear after the first pass are measured against the page's original light styles.
- Long inline (
data:) images are now measured and classified. - Hotlink-protected images are fetched with the page's origin as the Referer.
- A "keep" decision is never overridden by the engine's solid-color heuristic, which misreads SVGs without a fixed size.
- SVG logos are recolored shape by shape.
- See-through light panels (frosted cards, glass buttons) turn dark.
- Opaque surfaces stay opaque. A see-through promo bar would show a hidden header stacked beneath it.
- Icons from an external SVG sprite that already follow the page's text color are not inverted a second time.
- Logos drawn as CSS masks (the shape painted with a background color) are recolored as ink, like mask icons.
- Tabs opened together right after the browser starts no longer miss the model while it is still loading.
How it works
Nadir doesn't paint pixels. It redesigns the page's color system, and makes a decision for every visual element.
Extraction (
runtime/extract.js). One synchronous pass over the DOM collects every distinct color in its role: background, text, border, shadow, gradient, SVG fill and SVG stroke.- Each color token gets 49 features: lightness, chroma, hue, area, text size and weight, where it appears (header, nav, link, form, code, footer), what it sits on, and the original contrast.
- It also records four relations between tokens: text-on-surface, surface-on-surface, border-on-surface and same-element.
- Every visual element gets 41 features plus a 32×32 thumbnail. Visual elements are
<img>, CSS backgrounds, one icon cut out of a sprite sheet,::before/::aftercontent icons, and SVG images. - Ad slots are excluded.
Model (
NadirNet, 16.0M parameters). A relation-biased transformer over[page] + colors + images. Each attention head gets a learned bias from 11 relation channels, so the model knows which text sits on which surface. It has three heads:Palette head. For every color token it predicts an OKLab color as a parameterized edit of the input: lightness, chroma scale, hue rotation, tint and alpha. This keeps brand hues stable and makes impossible colors rare.
Page context head. A 32-number summary of the page's design.
Element head (new in v2). For any visual element it predicts:
- how to show it:
keep,invert(hue-preserving),adapt(dark text and lines turn light, brand colors stay, a white box is removed) orremove(a light texture behind content); - what it is: photo, illustration, diagram, logo, icon, text/formula, backdrop, avatar or UI decoration.
It reads the element's thumbnail and features, the page context, the light surface under the element, and the dark surface the palette head designed for that spot. It is exported as its own 1.2M-parameter graph, so the extension can classify elements that appear later (lazy-loaded images, icons in web apps, sprites) without re-running the transformer. Its confidence is calibrated with a fitted temperature.
- how to show it:
Design guards (
runtime/nadir-infer.js,runtime/nadir-map.js). Deterministic rules on top of the colors:- The page canvas is always dark.
- Every text and icon color keeps WCAG contrast on every surface it actually sits on. It never needs more contrast than the original design had.
- A light surface whose text no longer reads is darkened within its own hue.
- Brand colors keep their hue (±45°).
- Things that were invisible in the light design stay invisible (text fades, borders and SVG shapes that matched their surface).
- Colors over video and photos are left alone when they already read as dark mode.
- A large light gradient stop that the model kept light goes dark, except over media.
For images there are three fixed rules:
- Anything the element head itself recognizes as a photo or a person keeps its colors.
- A background is only removed when the head is at least 70% sure; otherwise its next choice is used, because taking an image away is the costliest mistake.
- An element on a surface that is light in the original and stays light in the dark design (a white carousel button, a kept light card) is shown as designed; recolored, its dark ink would vanish.
2.0.1 adds guards where the head's choice and the rendered page disagree (see What's new): light icons on surfaces that stay dark keep their colors, dark wordmarks on transparency are adapted when the head gives that a real chance, elements drawn over a picture shown as it is stay as designed, and a large light decorative picture under text the palette turns light goes dark. Every other image decision is the model's.
Engine. The palette is applied through a patched Dark Reader 4.9.133 dynamic engine (MIT). The engine handles stylesheets, inline styles, shadow DOM and dynamically inserted content, and asks Nadir for every color and every background image it rewrites. Colors the model didn't see are mapped by nearest-neighbor lookup in the predicted palette. Background images it didn't see are measured and sent to the element head.
Model
| Parameters | 16.0M total, of which the element graph is 1.15M (shared image encoder + element head) |
| Width / depth / heads | 384 / 8 layers / 6 heads |
| Inputs | ≤256 color tokens, ≤32 images in the page pass, any number of elements in the element pass |
| Outputs | OKLab color + alpha per token; page context; per element: 4-way treatment + 9-way role (calibrated) |
| Runtime | onnxruntime-web 1.29 (WASM SIMD, multi-threaded) |
| Speed | 32 ms per page for the 8-bit page graph (4 WASM threads, Apple M4 Max; 114 ms on one thread), 1.2 s to load it once per browser session; about 0.7 ms per element for the element graph. Revisits use the cached palette. |
Files
| File | Size | What |
|---|---|---|
onnx/nadir-int8.onnx |
20.6 MB | Page graph, 8-bit. Shipped in the extension. Mean color difference to fp32: OKLab ΔE 0.0002 (max 0.024). Its element decisions match fp32 on all 400 validation pages. 32 ms per page in onnxruntime-web (4 threads, M4 Max); loads in 1.2 s. |
onnx/nadir-int4.onnx |
13.3 MB | Page graph, 4-bit (MatMulNBits, blocks of 32; input/output layers stay 8-bit, relation layers fp32). Mean ΔE to fp32: 0.0004 (max 0.11). Element decisions: 99.96% identical. 37 ms per page; loads in 0.9 s. |
onnx/nadir-fp32.onnx |
63.5 MB | Page graph, full precision. Outputs lab, alpha_logit, ctx. 32 ms per page; loads in 5.6 s. |
onnx/nadir-elem-fp32.onnx |
4.6 MB | Element graph (1.15M parameters). Inputs e_f, e_thumb, under_light, under_dark, ctx; outputs treat_logits (keep/invert/adapt/remove) and role_logits (9 roles). About 0.7 ms per call on a desktop CPU. |
model.safetensors, config.json, model.py |
64 MB | PyTorch weights, configuration (including the element-head temperature) and the architecture. |
runtime/ |
The browser runtime: extraction (extract.js), decoding and design guards (nadir-infer.js), color mapping and image adaptation (nadir-map.js), the patched page engine (darkreader-nadir.js, MIT). |
|
training/ |
Crawl, teacher, contact-sheet, labelling, dataset, training, export, quantization and evaluation scripts. | |
reports/ |
Training log, export and quantization reports, browser timing (ortweb_timing_m4max.json), the held-out element evaluation and the pairwise judge results (eval_judge.json for v2, eval_judge_v201.json for the 2.0.1 runtime). |
|
nadir-extension.zip |
The browser extension, ready to load. | |
demos/ |
The light vs Nadir comparisons shown above. |
Both quantizations are calibrated on 256 validation pages: per-block scales and zero points are fitted to the pages' activations, and the layers that are most sensitive at 4 bits stay at 8 bits or fp32.
Training data
All teacher data comes from Claude Haiku 5.5 (Anthropic Batch API, high effort). The total cost for v1 and v2 together was about $143.
- Pages.
- v1: a crawl of 47,569 home pages from the Tranco top list.
- v2 adds 20,375 inner pages (docs, dashboards, inboxes, articles, stores, settings) reached from those sites: 16,789 light pages, 2,299 with their own dark theme and 271 already dark.
- Every page was captured at 1280×800: in light mode, in emulated dark mode, and with dark-class toggles applied.
- Palette designs. Haiku 5.5 redesigned 33,759 light pages (19,563 in v1 and 14,196 new; 33,615 passed QC) as the site's own product designer. It saw the screenshot, a contact sheet of the images, and the full color inventory with roles and locations, and returned a dark palette in a strict JSON schema.
- Native pairs. 6,434 pages with a real built-in dark theme give human-designed light→dark pairs, aligned token by token.
- Element judgments (new). 79,736 contact sheets were rendered by
training/elem_sheets.py. Each sheet shows elements on their page's real light surface and on the dark surface Haiku designed for that spot. Each element is rendered every way the extension can show it (kept, inverted, adapted, removed), using the extension's own pixel transforms. Haiku 5.5 picked the best rendering, marked every acceptable one and named the element's role: 500,785 judgments. On sheets shown to Haiku twice, its two answers agreed 95.5% of the time. - Quality control.
- Designs that missed ids, kept a light canvas, or left too much text below 3:1 contrast were dropped.
- The rest were passed through the same contrast rule as the runtime (25,404 of 40,049 adjusted).
- The split is by site (hash of the hostname): 38,076 training pages and 1,973 validation pages. Validation sites hold 23,532 judged elements.
- Augmentation. Random token dropout (it also simulates ad blockers and partial loads) and whole-design hue rotation.
Evaluation
All numbers are on held-out sites that the model never saw in training.
End to end: pairwise judge
Each held-out page was rendered four ways in the same minute, all in a fresh browser profile: as published, with Nadir v1, with Nadir v2, and with Dark Reader 4.9 (dynamic mode). Claude Haiku 5.5 then saw the light page plus two dark versions in random order, as A and B. It judged readability first, then breakage (glaring light patches, lost logos and icons, distorted photos), then design quality.
| Nadir v2 vs … (190 held-out pages) | Nadir v2 preferred | Other preferred | Ties |
|---|---|---|---|
| Dark Reader 4.9 (dynamic mode) | 126 | 56 | 8 |
| Nadir v1 | 91 | 59 | 39 |
v2 is preferred on 61% of the pages with a winner against v1 (91 vs 59) and on 69% against Dark Reader (126 vs 56). Counting only clear preferences (a score gap of at least 2 out of 10), v2 leads v1 39 to 23 and Dark Reader 64 to 27. The pages are live, so re-rendering them changes the counts by about ±5 (carousels, ads and cookie banners differ between renders). Four runtime changes came from reading the judge's reasons on these same pages: opaque surfaces, external SVG sprites, CSS-mask logos and the light-surface rule. Before them, a render batch had v2 and v1 even (77 to 77), so part of that gain may be specific to these pages. Pages that failed to load in any version are left out (189 and 190 of 200).
2.0.1
The 2.0.1 runtime fixes came from re-rendering the 59 pages where the judge preferred v1 and reading why. To check them beyond those pages, 420 pages were rendered again in one batch with 2.0.0, 2.0.1 and Dark Reader: the 200 evaluation pages above and 220 more held-out pages (validation sites from both crawls) that were never inspected.
| Nadir 2.0.1 vs … | 2.0.1 preferred | Other preferred | Ties |
|---|---|---|---|
| 2.0.0, all 384 judged pages | 102 | 93 | 189 |
| 2.0.0, the 200 uninspected pages | 55 | 53 | 92 |
| 2.0.0, the 56 judged pages where v1 had won | 19 | 10 | 27 |
| Dark Reader 4.9, 382 pages | 243 | 127 | 12 |
| Dark Reader 4.9, the 199 uninspected pages | 133 | 64 | 2 |
The fixes touch specific elements, so most pages render the same with both versions, and most of the remaining differences come from the live pages themselves (another slide, another ad). Overall 2.0.1 and 2.0.0 are even within that noise; where the fixes apply they help: on the pages v1 had won, 2.0.1 is preferred 19 to 10. One guard was added after this run, from reading the judge's reasons: a dark badge with white text in it (a black oval logo) is no longer adapted.
Element decisions
For each of 23,532 held-out elements, Haiku 5.5 marked which renderings are acceptable and which one is best. The table counts how often the extension's decision is one of the acceptable ones. For v2 that is the element head plus the runtime's removal rule: a background is only removed at ≥ 70% confidence. The light-surface rule depends on the rendered page, so it is not part of this number. For v1 it is v1's image head plus its hand-written rules.
| Elements | n | v1 | v2 |
|---|---|---|---|
| Logos | 3,485 | 80.1% | 93.0% |
| Transparent images (icons, line art, formulas) | 7,507 | 87.5% | 93.0% |
| Backdrops and textures | 2,111 | 77.7% | 89.6% |
| Flat images | 10,181 | 95.9% | 96.8% |
| Photos | 148 | 100% | 100% |
| CSS backgrounds (any type) | 3,952 | 81.7% | 90.3% |
| All | 23,532 | 89.3% | 94.4% |
- Agreement with the teacher. v2 picks Haiku's first choice 88.6% of the time; v1 did 78.6%.
- Calibration. Confidence is calibrated with a temperature of 1.30 (expected calibration error 0.029). 58% of decisions are made with ≥ 90% confidence, and those match Haiku's first choice 98.3% of the time.
- Roles. The element's role (9 classes) is right 78.4% of the time.
Colors
Held-out color error against the targets (OKLab ΔE, lower is better):
| Haiku-designed pages | Native dark themes | |
|---|---|---|
| v2 | 0.0365 | 0.0966 |
| v1, on its own validation split | 0.0402 | 0.1031 |
Size. A 34M-parameter variant was no better in v1's evaluation, so v2 keeps 16.0M parameters. A from-scratch v2 run (30 epochs) reached 0.0370 / 0.0994 and 94.3% acceptable elements. The released model is warm-started from v1 and trained for 18 epochs.
Use it
Browser extension (Chrome, Edge, Brave, Arc, Aside and other Chromium browsers)
- Download
nadir-extension.zip(here or from the GitHub releases) and unzip it. - Open
chrome://extensions, enable Developer mode, click Load unpacked, and choose the folder. - Browse. The toolbar badge shows what Nadir did on each site:
AI: model-designed dark mode.ON: the site's own dark mode.- No badge: already dark.
The popup can turn Nadir off for a site. The extension works alongside content blockers such as uBlock Origin Lite.
In your own code
import * as ort from 'onnxruntime-web';
// runtime/extract.js, runtime/nadir-infer.js and runtime/nadir-map.js define the globals used below
const page = await __nadirExtract({describe: false, thumbs: false, maxImages: 120}); // in the page
// pixel stats + thumbnails for the images (any context that can fetch them, e.g. an extension page)
const st = await __nadirImageStats(page.images, {dpr: devicePixelRatio});
page.images.forEach((im, i) => { Object.assign(im, st[i]); im.f = __nadirImageFeatures(im, page.vw, page.vh, page.docH); });
const tensor = (data, dims) => new ort.Tensor(data instanceof BigInt64Array ? 'int64' : data instanceof Uint8Array ? 'bool' : 'float32', data, dims);
const feedsOf = (built) => Object.fromEntries(Object.entries(built.feeds).map(([k, [d, dims]]) => [k, tensor(d, dims)]));
// 1. page graph: the palette and the page context
const pageSess = await ort.InferenceSession.create('onnx/nadir-int8.onnx', {executionProviders: ['wasm']});
const built = NadirInfer.buildInputs(page);
const out = await pageSess.run(feedsOf(built));
const {colors, keep} = NadirInfer.decode(page, built, {lab: out.lab.data, alpha_logit: out.alpha_logit.data});
// colors[i] is the dark replacement for page.tokens[i] (null = keep as is)
// 2. element graph: how to show each image (any time, for any element)
const elemSess = await ort.InferenceSession.create('onnx/nadir-elem-fp32.onnx', {executionProviders: ['wasm']});
const {items, idx} = NadirInfer.pageElementItems(page, {lab: out.lab.data});
const eb = NadirInfer.buildElemInputs(items, out.ctx.data);
const eo = await elemSess.run(feedsOf(eb));
const decisions = NadirInfer.decodeElements(eo.treat_logits.data, eo.role_logits.data, eb.K, 1.30); // temperature from config.json
// decisions[k] = {t: 'keep'|'invert'|'adapt'|'remove', p, role, rp} for page.images[idx[k]]
// the extension then applies the image rules above (decide() in the extension's content.js)
Limitations
- Nadir recolors CSS and decides per image; it never repaints pixels inside an image except when adapting one (dark ink → light). Text baked into photos, canvas drawings and cross-origin iframes keep their colors; the iframes get their own Nadir pass in the extension.
- Colors are mapped per (color, role). If one exact color is used both as a light card and as a light button, both get the same dark color.
- The page pass reads at most 256 colors. On very colorful pages the rarest colors are mapped by nearest neighbor.
- Glyphs drawn with CSS boxes and borders instead of images (hamburger bars made of thin boxes, chevrons made of borders) follow the per-color mapping, so a dark bar can stay dark on the dark page. White glyphs inside colored SVG icon buttons can turn dark; the brand color stays, so they still read, except on black buttons.
- A transparent logo whose artwork touches the edge of its image is not adapted (the runtime cannot tell it from a photo there), so a dark tagline in it can stay dark on a dark header.
- Text over a light picture is only handled when the picture is decorative and kept as is; text baked into a light hero photo, or over a light gradient that also covers images, can stay low-contrast.
- A sprite sheet drawn as a CSS background gets one decision for all its icons (the majority of the icons the element head saw), because the engine rewrites the CSS rule, not each element.
- Teacher and judge are both LLMs (Claude Haiku 5.5). Their taste is good but not perfect, the model inherits it, and the evaluation is automated, not a human study.
License
Apache-2.0 for the model, weights and Nadir code. The bundled page engine is a modified
Dark Reader (MIT License, © Dark Reader Ltd.); its license is included in
the extension (lib/DARKREADER-LICENSE).
- Downloads last month
- 36






