A Firefox addon that applies a soft reader mode. Where the built-in reader mode throws the page away and rebuilds it from the article text, this one leaves the page exactly where it is and only raises a few typographic floors — and only on pages that need them. Every page is measured first, and one that already reads well is left completely untouched.
The floors:
- Minimum font size — text is never rendered below your threshold.
- Minimum contrast — text that fails a WCAG ratio against its real background is nudged toward black or white until it passes, keeping its hue.
- Serif body text — with code, icon fonts, form controls and sites that already use a serif left alone.
- Minimum line spacing — a small bonus, since cramped leading undoes most of the benefit of a larger font.
Nothing is moved, hidden or reparented. Every change is an !important inline
style that is recorded and put back exactly as it was when you switch the addon
off, so toggling is instant and lossless — no reload.
It is unsigned, so the permanent route needs Developer Edition, Nightly, or ESR
(xpinstall.signatures.required=false in about:config). For everyday use on
release Firefox, load it temporarily:
- Open
about:debugging#/runtime/this-firefox - Load Temporary Add-on…
- Pick
manifest.jsonfrom this folder - Grant site access. Under Manifest V3 Firefox does not hand out
<all_urls>at install — until you allow it, the addon can see nothing. Open the popup and click Grant access to all sites, or set it fromabout:addons→ Soft Reader → Permissions → Access your data for all websites. - Reload any tab you want it to act on
Temporary addons are unloaded when Firefox restarts. To make it permanent, get it signed — see below.
Firefox will not permanently install an unsigned addon on release builds, but Mozilla will sign one for you without listing it publicly. Get an API key and secret from Manage API Keys, then:
WEB_EXT_API_KEY=user:12345:67 WEB_EXT_API_SECRET=your-secret npm run signweb-ext reads any option from a matching WEB_EXT_* environment variable, so
no credentials go on the command line or into a file. Automated review takes a
few minutes and the signed .xpi lands in web-ext-artifacts/.
Install it with about:addons → the gear icon → Install Add-on From File.
It survives restarts, and site permissions still have to be granted once.
Each upload needs a version AMO has not seen before, so bump version in
manifest.json before re-signing.
Check the packaging before submitting:
npx web-ext lint --source-dir . --ignore-files "node_modules/**" "test/**" "package*.json" --self-hostedPackage it with:
zip -r -FS ../soft-reader.zip manifest.json icons src- The toolbar button opens a popup with a per-site on/off switch, the sliders you will actually reach for, and what the addon decided about this page — either why it stepped in, or the measurements that persuaded it not to.
Alt+Shift+Rtoggles the current site without opening anything.- Full settings, including the serif stack and your list of per-site exceptions, live in the addon's options page (Settings, from the popup).
The button's badge reads:
| Badge | Meaning |
|---|---|
| (none) | Running, and it changed this page |
ok (green) |
Running, but this page already reads well — nothing changed |
off (grey) |
Disabled on this site |
If Firefox tucked the button into the » overflow or the puzzle-piece
extensions menu, right-click it there and choose Pin to Toolbar.
The read pass has to happen anyway, so measuring the page before touching it is
free. What it buys is restraint: on a well-set page the addon does nothing at
all, and the toolbar button shows a green ok.
A page is judged on the text you actually read — runs of at least 40 characters, weighted by length, so a cramped navigation bar cannot condemn a well-set article and one faint caption cannot either. It needs help if:
- its body text (the length-weighted median size) is below your minimum, or
- more than 20% of its text, by character, fails your contrast ratio, or
- its lines are set tighter than 1.25.
Judging a page is deliberately stricter than fixing one. A floor exists to be applied wherever it is breached, but tipping a whole page into reader treatment should take a real problem rather than a near miss — which is why the leading bar for judging is 1.25 while the one for fixing defaults to 1.5. Pages with under 250 characters are not judged at all; there is nothing to go on.
Once a page is judged to need help, the full treatment applies to it, not just the failing part. That keeps the result coherent — a page half in one typeface and half in another reads worse than either.
Late-arriving content is re-judged. A page that starts as an empty shell and renders its article a second later is treated when the article shows up, so client-rendered sites are not permanently written off on the strength of an empty first pass.
Turn all of this off with Only step in when a page needs it in Settings, which is what you want if you simply prefer serif text everywhere. To keep the assessment but override it for one site, use Apply here anyway in the popup.
src/content/soft-reader.js walks the document — including shadow roots and
iframes — and picks out elements that directly hold text. It reads every
decision for the whole page before writing anything, so a parent's new font
size cannot leak into a child's em-relative size and get scaled twice.
Sizing. If body text is under the minimum, the whole page is scaled by the same factor (capped, default 1.5×) so headings keep their relative weight, with the minimum applied afterwards as a hard floor. Turn off Scale the page proportionally to clamp small text only and leave everything else untouched.
Contrast. For each text element, effectiveBackground() walks outwards
compositing translucent background layers until it hits something opaque, or the
canvas. If it meets a background image or gradient on the way it returns nothing
and the text is left alone by default, because the real backdrop cannot be
measured and guessing at it looks worse than the original. Translucent text is
composited over its backdrop before being measured. The correction itself
bisects toward black or white for the smallest shift that clears the ratio, so
colour survives: a burnt orange stays a burnt orange, just a darker one. Large
text is held to a lower ratio, as WCAG allows.
Typeface. The serif is skipped when the computed stack is already a serif or
looks like an icon font, and never applied under <code>, <pre>, <kbd>,
<samp>, <var> or form controls.
A MutationObserver handles content that arrives later. New nodes are measured
inside an already-restyled page, so they are clamped to the minimum rather than
scaled again.
<html> carries data-soft-reader="on" when the page was treated and
"standby" when it was judged fine and left alone, if you want to hang your own
userstyles off either.
| Path | Purpose |
|---|---|
manifest.json |
MV3, Firefox 140+ (142+ on Android). |
src/lib/settings.js |
Defaults, storage helpers, per-site resolution. Shared by every context. |
src/lib/color.js |
Parsing, alpha compositing, WCAG luminance, contrast correction. |
src/content/soft-reader.js |
The traversal, measurement and styling passes. |
src/background/background.js |
Toolbar badge and keyboard shortcut. An MV3 event page, so it keeps no state between events. |
src/popup/ |
Quick controls. |
src/options/ |
Full settings, with a live preview. |
test/ |
Node tests. Nothing here ships in the addon. |
npm install && npm testcolor.test.js checks the contrast maths directly: that corrections land on the
target ratio without overshooting, that translucent text is composited first,
that hue survives, and that an unreachable target degrades to the better of
black or white instead of looping.
settings.test.js pins the per-site resolution rules — the master switch, the
default, and per-site exceptions — including that an exception matching the
default is discarded so the list stays meaningful.
content.test.js runs the real content script inside jsdom against a stubbed
browser.* API, covering traversal (shadow roots, skipped tags, icon fonts),
the per-site rules, and — the one that matters most — that disabling the addon
restores every element's original inline style exactly. It also drives the
assessment from both sides: comfortable, slightly-loose and one-faint-caption
pages must come out untouched, while small, faint and cramped ones must be
treated, and an empty shell that renders small text a second later must flip.
jsdom's cascade is thin: it reports font-size: medium for elements with no
explicit size, and the content script correctly declines to act on those, so
test fixtures set sizes explicitly. jsdom is pinned because older versions
silently drop !important on some properties.
- Text drawn on background images or gradients is skipped by default. You can turn that off in Settings, which falls back to measuring against white.
::before/::aftercontent cannot be given inline styles, so generated text keeps the site's own sizing.- An ancestor
opacityorfilterchanges the rendered colour in ways the contrast pass does not model. - Resolved backgrounds are cached for the life of a pass, and class changes are not watched, so a site's own light/dark toggle will not re-trigger the contrast pass. Toggle the addon off and on to re-measure.
- Sites with heavy client-side rendering may briefly show their own styling before the pass lands.