Interface engineering 6 November 2026 · edition 16 min read

A light theme for a dark-only operations console is a contrast audit of every page

Hostwatch was dark only until we added System, Light and Dark appearance to the website and web console. This article explains how the palette, the saved choice and the charts were built, and what an audit of every public page found in the light theme a week later.

Published 13 November 2026. Hostwatch engineering review; implementation limits are stated below.

Two identical dark server miniatures on a plinth that is pale and brightly lit on one half and dark and dimly lit on the other, with a glass lens on a stand between them
EDITORIAL IMAGEOriginal AI-generated conceptual illustration, not a product screenshot or production measurement. The same console has to be readable in a bright room and in a dark one, and every pairing of colours has to be inspected in both. Open full size ↗
01 / 10

A dark palette cannot be inverted into a light one

Hostwatch is where an operator reads traffic timelines, process lists, disk charts and release history. Until recently its website and web console had one appearance, and it was dark. Dark suits a terminal beside the browser and a long evening of reading graphs. The same console is also opened on a laptop next to a window, projected on a wall during a review and pasted into reports as screenshots, and many people keep their devices in a light appearance for those conditions. A product that ignores the device setting makes the eyes readjust on every switch between it and the rest of the screen.

The tempting shortcut is to keep every component as it is and exchange the dark background for a light one. It fails because each colour in a dark design was chosen against a dark background. Our dark accent is a bright teal, #41e6d0, which is easy to read on a near-black page. Contrast between two colours is expressed as a ratio from 1:1, for identical colours, to 21:1, for black on white. Placed on a white card, that bright teal comes to about 1.6:1, far too little for text, and the amber used for warnings in dark does no better. Shadows need a different strength, borders need a different weight, and text laid over a picture inherits a colour that was chosen for another background.

The work therefore has two halves, and this article follows both. The first is construction: a palette of named roles with an approved light and dark value for each, a saved choice that is applied before the page is drawn, charts that change colour without losing what the operator was inspecting, and artwork that is allowed to stay dark. The second is inspection. A week after the release we audited every page of the public site in both themes and found defects that the release checks had not caught, some specific to the light theme and some that had been present all along. They are described below, each with the reason it got through.

02 / 10

Colours are named by role and approved in pairs

A semantic colour token is a name for the job a colour does, with the actual value stored in one place. Ours include canvas for the page background, surface for the cards and panels that sit on the canvas, text for normal copy, secondary for supporting copy such as captions and metadata, accent for links and the main action, and on-accent for whatever is written on top of an accent fill. Status has its own set: warning, danger, success and info, each with a matching tinted surface so that a banner and the text on it are chosen together. Components refer to these names instead of carrying colours of their own, and older colour names in the stylesheets resolve through the same palette.

Each token has two approved values, one for light and one for dark, and the two are chosen as a pair. The accent is #00766a in light and #41e6d0 in dark. On-accent is white in light and a very dark green, #062b27, in dark, because a deep teal button needs white lettering and a bright teal button needs dark lettering. Worked out from those values, the lettering stands at about 5.5:1 against the fill in light and about 9.7:1 in dark. The warning colour is a dark amber on a pale amber surface in light, and a pale amber on a dark brown surface in dark. A warning remains a warning in either theme.

The benefit of pairs shows when something has to change. A component that asks for an accent fill with on-accent text is correct in both themes without knowing which one is active, and a new value is reviewed once, in the palette, with its partner beside it. The cost is discipline. A token protects contrast only when it is used with its partner, and nothing in a stylesheet stops a rule from combining two tokens that were never approved together. Several of the audit findings later in this article are of that kind: correct tokens used in a way nobody had checked.

Pairs of pale and dark glass tiles in teal, amber, red, green and blue joined by small brackets along a stone plinth, with a dark server miniature behind them
EDITORIAL IMAGEOriginal AI-generated conceptual illustration, not a product screenshot or production measurement. Each role in the palette has a light value and a dark value that are approved together. Open full size ↗
03 / 10

System is the default, and an explicit choice always wins

The Appearance control offers three choices. System follows the light or dark setting reported by the device or browser, which a page can read through the prefers-color-scheme media feature. Light and Dark hold the selected appearance even when the device changes. System is the default, so a first-time visitor sees the site the way the rest of their screen looks without touching anything. On wide public pages the control sits beside Sign in, and on narrow ones it is in the footer. In the console it is at the bottom of the navigation sidebar and under Organization, and it is available independently of administrator controls.

The stylesheet encodes that order of precedence directly. The light values are the base. The dark values apply inside a prefers-color-scheme: dark query unless the root element is marked light, and they apply unconditionally when the root element is marked dark. With no mark at all the page follows the device, which means System needs no script to work, and an explicit mark beats the device in both directions. Each block also declares the CSS color-scheme property, so the parts of the page that the browser draws itself, such as scrollbars and the default look of form controls, match the theme instead of staying light inside a dark page.

The choice is saved in the browser's local storage under a versioned key and shared with other tabs on the same origin, so changing it in one tab recolours the others. It is local to that browser. It does not follow the account to another browser, a private browsing session or a separate private installation. If the browser blocks storage, the control still changes the current visit, and the selection may not survive a reload. The same three choices exist for the iPhone and iPad app in internal TestFlight builds only, with a preference that is separate from the browser's. Changing the theme does not change server measurements, service policies or account access.

04 / 10

The saved choice is applied before the page hydrates

Our pages are exported as static HTML, so every visitor receives the same markup, and it cannot contain anyone's saved choice. The application code that knows about the choice runs later, during hydration, the step in which the JavaScript application attaches itself to that prebuilt HTML. If the theme were applied only then, a visitor whose device is dark and who chose Light would see a dark page for a moment and then a light one. The flash would repeat on every load of a new document. On a console that is opened many times a day, that is hard to ignore.

The fix is a short bootstrap script in the head of the document, where it runs before the browser draws anything. It reads the saved value, accepts only light or dark and treats anything else as System, resolves System by asking the browser whether the device prefers dark, and writes the result as an attribute on the root element. It also updates the theme-color value that some browsers use to tint their own toolbar. Reading storage is wrapped so that a browser which refuses access falls back to System instead of stopping the script. When the application hydrates, the attribute is already in place, and the framework is told to accept that one difference from the exported markup.

An inline script collides with a strict Content Security Policy, the response header that tells the browser which scripts may run. Ours allows scripts from our own origin and does not allow arbitrary inline code. A nonce would have to be different on every response, which a prebuilt file cannot provide, so the policy uses hashes: the header sent with each page lists the SHA-256 hash of every inline script that page contains, and the browser runs an inline script only when its hash is on that list. It also means the bootstrap script must be fixed code. Its text is identical for every visitor, and the value read from storage is only compared, never inserted into the page. Both releases described here were checked against the live site. After the first, the hash of the bootstrap script was confirmed to be allowed by each tested page's policy, and after the second, the tested pages loaded with no console or policy errors.

05 / 10

Charts need resolved colours and must keep zoom and legend state

Most of the interface changes theme for free once the tokens change, because the browser re-evaluates every rule that refers to them. Charts are the exception. Our timelines and breakdowns are drawn on a canvas by a charting library, and a canvas is painted with literal colour values. A reference to a CSS variable means nothing to it. The chart code therefore asks the browser for the resolved value of each token at the moment it draws: text, secondary, grid lines, tooltip surface and a fixed set of series colours. Series are assigned by role, so the series that is bright teal in dark is deep teal in light and stays recognisable as the same line.

Redrawing with new colours is the easy part. The risk is what a redraw throws away. During an investigation an operator may have zoomed a traffic timeline to a short window, hidden several series in the legend to isolate one, and drilled into a single group of callers. Rebuilding a chart from nothing resets all of that. A palette change is also not always something the operator asked for at that moment. Under System, a device that switches its appearance at sunset changes the palette in the middle of whatever is on screen, and losing the inspected window at that point would be a real cost paid for a cosmetic feature.

The chart component therefore keeps the inspection state outside the drawing. It records the zoom range and the legend selection whenever the chart reports a change, and when the palette changes it rebuilds the chart options with the new colours and hands the recorded range and selection back in. The web timeline keeps its zoom and legend selection across a palette change, and the caller breakdown stays on the selected group. The three-dimensional scene in the web interface caches its colours, so it rebuilds that cache when the theme changes. The zoom, the drill-down, reload persistence and synchronisation between tabs were exercised in a browser before the release. Our guide adds one piece of advice that holds in either theme: read the series labels as well as the colours.

Five coloured light conduits passing through a fixed glass frame on a stone plinth while a backdrop panel behind them slides from dark slate to pale stone
EDITORIAL IMAGEOriginal AI-generated conceptual illustration, not a product screenshot or production measurement. The inspected window stays where the operator put it while the palette behind the chart changes. Open full size ↗
06 / 10

Artwork that is dark by design keeps its own canvas and text colours

Some things on a page should not change with the theme. The illustrations on our site are dark scenes by design. Screenshots are records of what a screen looked like when it was captured, and recolouring a record makes it less true. For these the palette has a small separate set of tokens with a single value each: a visual canvas, #061018, and a visual text, visual secondary and visual accent colour that are meant to be read on that canvas. A headline placed over the hero illustration uses the visual text colour in both themes, and the area behind the illustration uses the visual canvas.

Without that separation the light theme breaks in the most prominent place on the site. A headline over a dark picture would take the theme's text token, which in light is a dark blue-grey, and against the visual canvas that comes to about 1.3:1. The same block of fixed values covers QR codes, which keep their own ink and surface colours so that their contrast does not depend on the theme. The page around the artwork still adapts: navigation, body copy and captions follow the theme. Our guide says plainly that illustrations and historical screenshots may keep their original backing and colours, and that they are not live measurements. How a dark picture should sit inside a light page turned out to be a separate question, which the audit raised.

07 / 10

The audit: every text element, both themes, several widths

The release had been verified with unit tests, a type check, a design review of the palette and a browser session that switched between Light, Dark and System, changed the device setting, reloaded, opened a second tab and checked a 320-pixel layout for text escaping its container. All of that passed. The audit a week later had two parts. The first is automated. A script opens every page of a local static build in a headless browser, in light and in dark, at three widths: 390 pixels for a phone, and 1320 and 1440 pixels for laptops. That was 74 pages at the time. On each page it takes every visible text element, works out the effective background behind it and computes the contrast ratio between the two. It also reports horizontal overflow.

The effective background is the colour that is really behind the glyphs once the browser has resolved everything. Finding it means looking past transparent elements to the first ancestor that paints a background, and taking account of any opacity applied on the way, because opacity changes the colour that reaches the screen. Each ratio is then compared with the WCAG level AA minimums: at least 4.5:1 for normal text and at least 3:1 for large text, where large means 18 point or more, or 14 point or more in bold, which is roughly 24 and 19 CSS pixels. After the fixes, the pass reported no text below those minimums apart from the large decorative numerals on guide cards, which are ornament, and no horizontal overflow at any of the three widths.

The second part is reading screenshots of real pages in both themes, slowly, the way a visitor would. A script finds text that is too faint. It cannot tell that a label has the wrong size, that a button asks for the wrong amount of attention or that a picture sits badly in its page, and most of what follows is of that kind. The limits are worth stating. The pass ran over the static build of the public site, so console screens that need a signed-in session and live data were not part of it. It is run by hand before a publication, and nothing forces every change through it.

08 / 10

Contrast failures that hid behind correct tokens

The plainest finding was a selected tab with an invisible label. The Protection page has a row of example scenarios, and the selected one is filled with the accent colour. Its label was also set to the accent colour, so the text and its background had the same value and a ratio of 1:1. The rule did not look wrong when read, because the fill was written with an older alias that resolves to the accent while the text named the accent token directly. The label now uses on-accent, the token that exists for this purpose. The practical rule is to write a fill token and its on-token together, and to give a second look to any rule that sets one without the other.

The second finding was text dimmed with opacity. Small step numbers in the same component used the secondary colour at 70 per cent opacity to make them recede. Opacity mixes the text with whatever is behind it, so the colour on screen is one that appears nowhere in the palette and that nobody approved. On a white surface in light the numbers came out at 3.1:1, well under the 4.5:1 that text of that size needs. By our arithmetic the same dimming applied to the dark pair lands close to 4.5:1, which would explain why it had not stood out before. The opacity was removed and the numbers now meet 4.5:1. A review that only compares token pairs never sees this kind of defect. Measuring the rendered colour does.

The third finding passed the threshold and was still wrong. Blog and guide articles set their body text in the secondary colour, which has about 5.5:1 against the light canvas. That clears AA. It also read as faint over the length of an article, because secondary was chosen for captions and metadata, short pieces of text that are meant to step back. In dark the same token works out at roughly 8.5:1 against its canvas, so the two themes were not asking the same effort of the reader. Article text now has its own token, a reading colour that is #324854 in light, 8.9:1 against the canvas, and unchanged in dark. The token exists only in the web palette.

Two teal glass blocks on a pale plinth, one carrying a teal disc that is barely visible and the other a clearly visible white disc, with a lens on a metal arm above the first
EDITORIAL IMAGEOriginal AI-generated conceptual illustration, not a product screenshot or production measurement. A label in the accent colour disappears on an accent fill, which is the case the on-accent colour exists to prevent. Open full size ↗
09 / 10

Hierarchy defects that pass every contrast check

Three kickers had lost their style. A kicker is the small, letter-spaced label above a heading that names the section, and ours is a paragraph element with its own class. Several sections also had a rule for their paragraphs, written as the section's class followed by the paragraph element, which set the size and colour of the descriptive copy. In CSS a selector made of a class and an element is more specific than a selector made of a class alone, so the section rule won regardless of the order of the rules in the file. The kickers were drawn as ordinary body paragraphs: readable, compliant and no longer introducing the heading below them.

The fix was to make those section rules skip the kicker explicitly, so that they style every paragraph in the section except one that carries the kicker class. The broader habit is to be suspicious of any rule that styles a bare element inside a container. Such a rule applies to everything of that type that is ever placed there, including components added later by someone who does not know the rule exists. A contrast check has nothing to report here, since the restyled kicker had a perfectly good ratio. Only reading the page shows that three sections have a label that looks like body copy while their neighbours have a proper one.

The sign-in page had a similar problem with emphasis. The form offers more than one sign-in method, shown as tabs, and the selected tab is highlighted. The submit button looked the same as that selected tab, so the page showed two highlighted shapes of equal weight and gave no visual answer to what happens next. A selected state says where you are. The main action says what you can do, and a form should have one. The submit button is now a solid accent fill with on-accent lettering and a heavier weight, while the method tabs and helper buttons stay quiet. Both versions of the page pass a contrast check.

10 / 10

Dark artwork and a wide header on a light page

The home page opens with a dark hero illustration with the headline over it. The hero sat on the page grid, the centred column with side margins that holds all the content. In dark that went unnoticed, because the visual canvas and the dark page canvas are almost the same colour. In light the hero became a dark rectangle inset from both edges of a pale page, with the headline flush against its left side. The artwork now spans the full width of the viewport while the copy stays on the grid, so the scene reads as a band across the page and the headline lines up with the content below it.

The Product page had a subtler error of the same family. Its illustration extended past the grid and was softened by a gradient that faded its left and right edges into the surface colour. The page behind it is the canvas colour. In light, surface is white and canvas is a pale blue-grey, so a dark picture faded through grey into a white that did not match the page, and left bands along its edges. On phones the picture also touched the link above it. A fade has to end in the colour that is really behind it, and the conversion from fixed dark values to tokens had picked the neighbouring token. The illustration is now a framed scene on the page grid, with a border, rounded corners and its own dark canvas.

The last finding had nothing to do with colour. The appearance release added the Appearance control to the header beside Sign in, and in the same change the width below which the navigation moves onto its own row was set to 1580 pixels. On most laptops that produced a two-row header about 140 pixels tall. The release checks had looked closely at a 320-pixel layout, and nothing in them examined a common laptop width. The header now stays on one row from 1320 pixels, about 80 pixels tall, with the gaps between links scaling with the viewport. Two of the three audit widths, 1320 and 1440 pixels, sit in that range. The kicker rules and the selected tab were just as wrong in dark, and it took a slower look at every page to see them.

THE TAKEAWAY

Build a light theme from role-named tokens with approved light and dark pairs, apply the saved choice before the first paint, and let charts and dark artwork follow their own rules. Then measure every text element against its effective background on every page, in both themes, at laptop widths as well as phone widths, and read the screenshots, because a passing ratio says nothing about hierarchy.

Further reading

UP NEXT / Service operationsService placement is the starting point of a trustworthy release