Skip to content
milan.swiss Halbton v0.1.0
Concept project

Foundations

Dark mode

Pale tints cannot simply be darkened. In dark mode a hue moves to the deep end of its ramp for surfaces and the pale end for text — the mapping inverts, and the build refuses to ship if it stops doing so.

The claim

Most dark modes are built as a filter: take the light theme, push everything toward black, correct whatever breaks. It works for neutral greys and fails immediately for pastels, because a pastel's job changes between themes.

In light mode a pale tint is a surface — a panel fill you set dark text on. In dark mode that same pale tint is the only part of the ramp bright enough to be text. Darkening it destroys both roles at once: too dark to read as text, too light to sit against a dark canvas as a panel.

So Halbton does not darken. It re-points: each semantic role keeps its ramp and swaps which end of it each part uses.

Every role, both themes. Surfaces move deep; text moves pale. The ratios either side are what the pairing actually measures.
Role Ramp Surface light → dark Text light → dark Ratio light Ratio dark
informational periwinkle 100 → 800 deeper 800 → 200 paler 7.62:1 6.70:1
positive mint 100 → 800 deeper 800 → 200 paler 7.38:1 6.58:1
caution apricot 100 → 800 deeper 800 → 200 paler 7.69:1 6.75:1
critical rose 100 → 800 deeper 800 → 200 paler 7.78:1 6.85:1
neutral slate 100 → 800 deeper 800 → 200 paler 7.54:1 6.66:1

Why the ratios are not identical

A role does not measure the same in both themes, and forcing it to would mean picking worse colours in one of them. What the system guarantees is the floor — every text pairing clears 4.5:1 in both themes — not that the two numbers match. Chasing symmetry across a boundary where the canvas itself changes is optimising for a table, not for reading.

The mechanism

One attribute on the root element re-points every token at once. There are no dark: variants anywhere in this codebase — the theme is a token flip, and a component that needed to know which theme it was in would be a component that had escaped the system.

generated from tokens.json
.ht {
  --ht-informational-surface: #E9EFFC; /* periwinkle 100 */
  --ht-informational-content: #3D4A6B; /* periwinkle 800 — 7.62:1 */
}

[data-theme="dark"] .ht {
  --ht-informational-surface: #3D4A6B; /* periwinkle 800 — deep end */
  --ht-informational-content: #D7E1F9; /* periwinkle 200 — pale end, 6.70:1 */
}

Because the two blocks declare the same custom property names, every consumer — a Tailwind utility, a component class, an inline style — keeps working untouched. Nothing downstream has a theme branch in it.

No flash on first paint

A theme applied by a normal script runs after first paint, which produces a white flash before a dark page. This has to be the first thing in the document head, inline, before any stylesheet:

first in <head>
<script>
  (function () {
    try {
      var stored = localStorage.getItem("halbton-theme");
      var theme =
        stored === "light" || stored === "dark"
          ? stored
          : window.matchMedia("(prefers-color-scheme: dark)").matches
            ? "dark"
            : "light";
      document.documentElement.setAttribute("data-theme", theme);
    } catch (e) {}
  })();
</script>

Three things about it are deliberate. It is inline, so there is no request to wait on. It reads localStorage first and prefers-color-scheme second, so an explicit choice outranks the system preference. And the whole body is wrapped in try, because reading storage throws in some privacy modes — where the server-rendered light theme is already correct and complete, so there is nothing to recover.

The toggle

It is a real <button> with aria-pressed, not a checkbox styled as a switch. Pressed means dark. The label stays in the accessibility tree at every viewport — only its visual presentation is dropped on narrow screens — so the control is always named.

It follows the operating system only while no choice has been stored. Once someone has decided, the system preference changing underneath them does not override it. That is the behaviour people expect and the one most implementations get backwards.

Checking it

The verification script asserts the theme is already applied at first paint while emulating the opposite system preference — so a pass proves the stored choice won, rather than the default happening to match. It also checks the choice survives a reload, that a first visit follows the system preference, and that aria-pressed agrees with the attribute in every case.

Every ramp and ratio behind this page is on Colour, including a checker that recomputes them live in whichever theme you are reading in.