https://dondibogusky.com/notes/letting-readers-turn-the-knobs/Back to notes

Letting Readers Turn the Knobs

How this site follows OS text size, increased contrast, forced colors, and screen reader pacing, with screenshots and the browser tests that guard each one.

Every reader arrives with settings I never chose. Someone has text turned up to 200%. Someone asked their operating system for more contrast. Someone runs a Windows contrast theme that repaints every color on the page. Someone is listening to the page instead of looking at it.

None of them file bug reports. They squint, or they leave.

A recent round of work on this site went knob by knob: text size, contrast, forced colors, and the pace of screen reader announcements. Each fix was small. Each failure was a little sneaky. Each one now has a browser test watching it.

Knob One: Text Size

Browser zoom makes everything bigger. An OS text-size setting asks for something narrower: bigger text, same layout. Pages have mostly ignored it. One line in the <head> opts this site in:

<meta name="text-scale" content="scale" />

In supporting browsers, the root font size now follows the system setting. Everywhere else, ordinary browser zoom still works. The rest of the job is making sure everything actually follows the root: rem and em for text, including metadata and code, and no extra multiplication by env(preferred-text-scale). The browser already applied the preference once. Applying it twice turns 200% into 400%, which is generous but not helpful.

Most of the site needed only a little reflow: control groups that wrap, overflow-wrap: anywhere on the body so long words cannot push a 320-pixel screen sideways, and a container query that gives the portfolio modal’s Close button its own row when the title runs out of room. That query is written in rem, so its threshold grows with the reader’s text.

The Name That Fought Back

The homepage name was the interesting one. It fits a single line using container query units (14cqi), and supporting browsers also use text-fit: shrink to keep it from overflowing.

Container units measure the container, not the reader’s text preference. And text-fit: shrink does exactly what it says: when the enlarged name no longer fits, it shrinks it back down. The site was politely undoing the reader’s setting.

The fitting script now compares the root font size to the 16-pixel default and scales only the container-unit part of the size:

font-size: clamp(
  max(1.75rem, min(calc(8.25vw - 8px), 5.4rem)),
  calc(14cqi * var(--home-title-scale, 1)),
  6rem
);

When the scale is above 1, both native and scripted shrinking turn off and the name is allowed to wrap. A hidden 1rem probe watched by ResizeObserver catches changes made mid-visit. On wide screens, the Now panel also steps aside so the introduction gets the full reading width. The Now page is still in the navigation.

The homepage at default and 200% OS text size, captured in Chromium. The name wraps instead of shrinking back.

The test for this taught me something about trusting computed styles. text-fit can shrink the painted glyphs without changing the computed font-size, so a test that checked CSS values would pass while readers saw small text. The Playwright journey measures the first letter’s rendered box instead:

const range = document.createRange();
range.setStart(title.firstChild!, 0);
range.setEnd(title.firstChild!, 1);
return range.getBoundingClientRect().height;

Chromium can emulate the OS setting directly through Emulation.setEmulatedOSTextScale. Firefox and WebKit do not expose that, so their runs enlarge the root font size as a reflow proxy. That checks layout, not native support, and the documentation says so.

Knob Two: More Contrast

This site’s hairlines are deliberately quiet. A divider is ink at 10% opacity: enough to organize a list without drawing a grid over it.

For a reader who has asked for more contrast, quiet becomes hard to see. prefers-contrast: more is set by macOS, iOS, and Windows contrast settings, and the site now answers it by firming up the lines that carry structure:

@media (prefers-contrast: more) {
  :root,
  html[data-active-theme="dark"] {
    --color-line: color-mix(in srgb, var(--color-ink) 50%, transparent);
    --color-line-strong: color-mix(in srgb, var(--color-ink) 65%, transparent);
    --color-control-border: var(--color-faint);
  }
}

Dividers and card edges reach 3:1 against the page, and field outlines match metadata text. Mixing from ink instead of writing new hex values means one rule follows both themes and the wide-gamut palette. It also leaves the default tokens untouched for the build-time contrast checks.

The notes archive with and without prefers-contrast: more. Text is unchanged; the structure gets louder.

Nothing about the design changes for everyone else. The quiet version is still the default. It just stops being the only version.

Knob Three: Forced Colors

Windows contrast themes go further. Forced colors replace author colors with a small system palette, and they also remove box-shadow.

That second part matters because a Tailwind ring is a box-shadow. Cards on this site show a focus ring when the link stretched across them receives focus. In forced colors, that ring vanished. A keyboard user could tab onto the Now panel and see nothing change.

The focused Now panel in forced colors. Same focus, two very different amounts of help.

The fix draws a real outline in forced colors, matching the ring’s width and offset:

@media (forced-colors: active) {
  :is([class*="focus-visible]:ring"], .portfolio-gallery-card):has(:focus-visible) {
    outline: var(--focus-width) solid;
    outline-offset: 2px;
  }
}

There was a quieter trap in the modal’s Close button. Tailwind v4’s outline-none really removes the outline. outline-hidden sets a transparent outline that forced colors repaint as a visible one. One word in a class list decided whether the button had a focus indicator in high contrast mode.

The Playwright tests emulate forcedColors: "active", focus each card, panel, and modal control, and assert a solid outline. WebKit skips them because it does not implement forced colors.

Knob Four: The Pace of Announcements

The last knob is time.

Search used to mark its visual status line as a live region, and the search field pointed to it as its description. Every change was eligible for announcement. Here is roughly what a screen reader could hear while someone typed “astro” at an unhurried pace, using this site’s real results:

Type at least two characters.
Searching...
Showing 8 of 24 results
Searching...
Showing 8 of 17 results
Searching...
Showing 8 of 17 results
Searching...
Showing 8 of 17 results

Accurate, and exhausting. Now the visual status stays immediate, and a separate, visually hidden live region receives only settled outcomes after a 500-millisecond pause:

watch([query, status, isSearching], ([term, message, busy], _, cleanup) => {
  announcement.value = "";
  if (busy || term.trim().length < 2) return;
  const timer = window.setTimeout(() => {
    announcement.value = message;
  }, 500);
  cleanup(() => window.clearTimeout(timer));
});

The same typing now produces one line:

Showing 8 of 17 results

New input or leaving the page cancels a pending announcement. Clearing the message first means an identical count for a new query is still announced. Errors and empty results go through the same path.

The tests use Playwright’s fake clock to advance exactly 500 milliseconds and record everything the live region said. The change cost 128 bytes of the search page’s JavaScript budget. That seemed like a fair price for some quiet.

Try It Yourself

Most of these knobs are easy to turn without changing your own system:

  • Chromium DevTools: open the Rendering panel and emulate prefers-contrast: more or forced-colors: active.
  • Playwright: page.emulateMedia({ forcedColors: "active", contrast: "more" }), or a CDP session for OS text scale.
  • Your own settings: turn up text size on your phone and read a page you built. It is humbling in a useful way.

Emulation has limits. Chromium’s forced colors are not a real Windows theme, the Firefox and WebKit text-scale runs are proxies, and a live region test checks what was queued, not how a screen reader spoke it. These tests guard against regressions. They do not replace trying the real thing now and then.

The Site Doesn’t Own the Knobs

None of these settings are edge cases to the people who use them. They are simply how the web looks and sounds to those readers.

A design system decides the defaults. Readers decide everything after that. The job is to make sure the site still holds together when someone reaches over and turns a knob.