Design Conventions

How the browser tools on this site are built, why they ended up that way, and what went wrong on the road there.

This site hosts a few dozen interactive tools, grown over several months, mostly written with a coding assistant. The style below was not designed up front. It accumulated, was wrong several times, and settled into a small set of shared components once it became clear how expensive the alternative was. This page is the writeup: the stack, the colour system, the components, and the specific mistakes that each rule exists to prevent.

It is deliberately a description rather than a style guide. The reasons are the transferable part. A value copied out of context is right in one theme and wrong in the other two, which is a thing that happened here repeatedly.

The three files that carry the system

For the single-file no-build stack there are also /assets/js/react.production.min.js, /assets/js/react-dom.production.min.js and /assets/js/htm.js, all vendored locally.

They are meant to be copied into a project, not hot-linked. This is a personal site, not a CDN, and the files change.

Every page under /miscellaneous/tools/ is a worked example with readable source, since there is no build step and what is in the file is what runs.

Roughly the second half of custom.css is specific to this site: the per-section colour schemes, the breadcrumb header, the wordmark, the view-transition animations for theme and language switching, the landing page accordion. What generalises to any small web app is the rest: the colour ladder, the components, and the reasoning that decides which component a given control is.

1. The stack, and why one file

One .html file per tool. React 18 and htm from local <script> tags, no bundler, no package manager, no npm, no CDN request of any kind. The app renders into <div id="root">. Where a tool needs data, it fetches static JSON next to the page.

This started as a constraint of GitHub Pages and turned out to be the most useful property of the whole setup. A tool is one artefact that can be emailed, dropped on a network share or opened from a USB stick, and it still works years later because nothing has to resolve. On a locked-down network the same property means nothing to negotiate with a proxy, no registry, no allowlist and no supply chain. The cost is real too, and it is the subject of the next section.

The one exception to self-containment: logic a second tool needs becomes a shared module with its own test, never a copy. Copies drift, and the drift is always found later than the copy was made.

Inside the file there are two named script blocks rather than one. The app block holds the React component tree; everything above it that is real logic (the maths, the parsing, the geometry) sits in a separate block whose id ends in core, abbreviated per tool: erocore, ebookcore, lambdacore. Pure functions, no markup, no DOM access. That block is the seam the Node tests hang on: a test reads the HTML file, pulls the block out by its id and evaluates it, without a browser and without rendering anything.

The skeleton, minus this site's own chrome:

<!doctype html>
<html lang="en" data-theme="dark">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>…</title>
  <link rel="stylesheet" href="assets/pico.min.css">
  <link rel="stylesheet" href="assets/custom.css">
</head>
<body>
  <main class="container"><div id="root"></div></main>

  <script src="assets/react.production.min.js"></script>
  <script src="assets/react-dom.production.min.js"></script>
  <script src="assets/htm.js"></script>
  <script src="assets/icons.js"></script>

  <!-- Pure functions, no markup, no DOM. This block is the seam that makes the
       tool testable from Node: a test reads the file, pulls the block out by id
       and evaluates it. Renaming the id, or folding the code into the app block,
       breaks the tests with an unhelpful "no block found". -->
  <script id="xycore">
    function computeSomething(a, b) { … }
  </script>

  <script id="app">
    const html = htm.bind(React.createElement);
    const { useState, useMemo } = React;

    function App() {
      return html`<h1>…</h1>`;
    }
    ReactDOM.createRoot(document.getElementById('root')).render(html`<${App} />`);
  </script>
</body>
</html>

The core block earns its keep even without a test suite. It forces the separation that makes the maths reviewable at all: a function that needs the DOM to run is a function nobody can check.

2. htm instead of JSX: the trade, the traps, the checker

Until August 2026 every tool loaded babel.min.js (3.0 MB) and compiled its JSX in the visitor's browser on every page load, because there is no build step. htm (1.3 kB, MIT) replaced that: the same markup written as tagged template literals is valid JavaScript, so the browser runs it directly. All 36 tools were converted in one campaign and Babel was deleted.

The payoff, written down so it does not get traded away later:

The single line at the top of every app block: const html = htm.bind(React.createElement);

The traps, all verified against the vendored copy

htm never throws. A mistake produces a different tree, silently. This is the expensive half of the trade, and it is worse with an assistant in the loop, because models are trained mostly on JSX and reach for JSX spellings by reflex. Every item below is a bug that shipped here at least once.

  1. style must be an object. style=${{ marginBottom: 14 }}. A string throws React error #62 at render time, and the result is a blank page with a minified error number and no other clue. This one costs an hour every time.
  2. Attribute names stay React names: className, htmlFor, strokeWidth. htm passes props to React.createElement untouched and does no HTML-to-React mapping, so class= and for= fail silently.
  3. ${…}, not {…}. A forgotten $ turns an interpolation into literal text, or into an attribute holding a braces string. No warning.
  4. <> is not a fragment. htm reads it as a tag with an empty name and hands "" to createElement. The fragment is <${React.Fragment}>…<//>.
  5. Void elements need closing. <div>a<br>b</div> parses as a div containing "a" and a br that swallows "b" and everything after it, including the div's own closing tag.
  6. HTML entities are not decoded. Babel decoded &times; and &nbsp; in JSX text; htm passes the literal characters through and the page shows the entity. What replaced them here is the character itself, or a \u escape inside an interpolation.
  7. Comments are <!-- … -->. JSX comment syntax inside a template renders as text.
  8. A missing closing tag silently nests the following siblings inside the open element. Also no warning.
  9. Multiple roots return an array, not a fragment, and React then wants keys. Deliberate multi-root templates get wrapped in React.Fragment.
  10. Every nested template needs its own html tag. A bare backtick string inside ${…} becomes text.
  11. The one typo that is caught, but misreported: <//>} instead of <//>`}, dropping the backtick that closes a nested template before the brace that closes the interpolation. Two agents converting different tools made it independently. It is a syntax error, but V8 reports "Missing } in template expression" pointing at the outer template's line. Bisecting with a standalone vm.Script parse finds the real spot.

The conversion table used during the migration:

JSX                              htm
return ( <div…> )                return html`<div…>`
{value}                          ${value}
attr={value}                     attr=${value}
attr="literal"                   attr="literal"   (unchanged)
{items.map(x => ( <li…> ))}      ${items.map(x => html`<li…>`)}
{cond && <p/>}                   ${cond && html`<p/>`}
{/* note */}                     <!-- note -->
<>…</>                           <${React.Fragment}>…<//>
<Comp x={1}/>                    <${Comp} x=${1}/>
{' '}                            ${' '}
{...props}                       ...${props}

key and ref are ordinary props to htm and get picked out by createElement exactly as with JSX.

The checker, which is the part worth stealing

Silent failure needs a mechanical check, so the migration was done against one. The tool had to be unconverted in git HEAD with the new version unstaged: the checker pulls the old JSX out of git, compiles it with Babel, runs the new htm source against the same stub React, and diffs the two element trees. They have to be byte-identical. The order is convert, check, commit, one tool per commit, so a regression is one git revert.

A tree comparison at initial state proves almost nothing, because every conditional branch, tooltip and modal stays unrendered. So each tool has a scenario table with injected useState values. Four things about that table were learned the hard way:

Two rules around it that mattered more than the code. First, the checker is not weakened to reach a green result: a diff is a fault in the tool, never in the checker, and scenarios get added but not defanged. Second, no reformatting or drive-by improvements inside a conversion, and a pre-existing bug found on the way gets reported and fixed afterwards in its own commit. The equivalence check only proves the tree is unchanged; it cannot tell an intentional improvement from a mistake, and a diff that mixes both is unreviewable.

Node proves the tree, not the rendering, so every conversion was followed by a browser pass: page renders at all (blank means broken), console free of React warnings, every interactive control, the live language switch, the live theme switch, and any canvas or SVG redraw on the theme event.

This material is kept in the repo as a Claude Code skill, .claude/skills/jsx-to-htm/SKILL.md, so that a session that hits an htm bug finds it without being told. The equivalent place for GitHub Copilot is .github/copilot-instructions.md, or a scoped .github/instructions/htm.instructions.md. The trap list above is the part that pays for itself; the rest is bookkeeping specific to this repo.

One honest caveat about the trade: htm earns its place exactly where the single-file property is worth more than tooling. What it costs is editor support inside the markup, so no highlighting, no type checking, no linting and no rename-across-the-file. Where a build step is allowed and that support is worth more than shipping one artefact, JSX and Vite are the better answer.

Size, notably, is not the deciding factor, which was a surprise. The largest tool here is just over 10,000 lines in one file and the next two are 8,500 and 7,400, all of them htm without a build step, and none of them was the thing that hurt. The pressure came from the trap list above and from the absence of module boundaries, not from the line count.

3. Colour: a ladder of roles, three skins

There are three skins, not two: light, dark and grey. The choice lives in data-skin on <html>, its polarity in data-theme, so grey is a dark skin and inherits the dark rules before overriding them. Grey tells elements apart by outline rather than by fill: page and panels are the same grey, no shadows, one single accent. It turned out to be the useful skin to have, because it exposes every place where colour was doing work that structure should have done.

No tool writes a grey or a border colour as a hex value. Each uses a rung of a ladder, named by role, and gets all three skins for free. The dark values are constructed in OKLab (hue 250°, even lightness steps, chroma rising toward the dark end); the light and grey sets keep the roles and change the numbers. The values are generated rather than picked by eye, which is why the table below gives only the dark rungs: the light and grey counterparts live in custom.css under the same names, and reproducing them here would only create a second copy to keep in sync.

VariableRoleDark
--tool-emphemphasized text, card titles#e1e8f0
--tool-textbody text, h1#bcc5cf
--tool-labellabels, secondary text#99a4af
--tool-notefootnotes, meta text#778390
--tool-faintdisclaimers#566472
--tool-borderframes, button outlines#374656
--tool-hairlineseparators inside a panel#1a2a3a
--tool-gridchart grid lines, plot frames#45596d
--tool-surfacecard and plot background#111c28
--tool-panelpanel and popover surface#0a1928
--tool-inkdark text on a light chip#0a1928

Two rungs that look identical in dark, --tool-ink and --tool-panel, are separate on purpose: in the grey skin the panel wants the page colour while chip text still wants to be dark. Meanings that coincide in one theme are not the same meaning, and they pull apart at the worst possible moment.

On top of the ladder sit four callout groups, each with -bg, -border and -text (--tool-ok-*, --tool-warn-*, --tool-info-*, --tool-danger-*), four signal colours for readouts and plot marks (--tool-sig-good, -warn, -bad, -info) and --tool-shield-* for the privacy badge. The light set is not the dark set lightened: a Tailwind-400 green on white is a pastel nobody can read, so the light signals drop two steps.

Canvas and WebGL have to be told

CSS variables reach the DOM, not a canvas. Tools that bake palette values into JavaScript read them once through a readPalette() into mutable bindings, then re-read and redraw when the theme changes:

function readPalette() {
  const cs = getComputedStyle(document.documentElement);
  return { text: cs.getPropertyValue('--tool-text').trim(), … };
}
let PAL = readPalette();
window.addEventListener('themechange', () => { PAL = readPalette(); redraw(); });

themechange is a custom event fired by this site's own switcher; elsewhere a MutationObserver on data-theme does the same job. Eight tools here need this, and forgetting it looks like a rendering bug that only appears after a switch.

On light fills that are computed rather than themed, such as heatmap cells, the text colour is picked by luminance: dark #1e293b above 145, light #f1f5f9 below. Those two are inputs to a calculation and stay literals.

Colormaps

Two families, and which one a tool takes is decided by the data rather than by taste. A diverging map is for a signed quantity with a meaningful zero, where the question is which side of the middle a value falls on. A sequential map is for a quantity that only goes up, where the middle means nothing and spending the strongest colour contrast on it would be a lie about the data.

The diverging ones are the shared convention: three RGB triples, interpolated linearly. hollywood is the default, named after the orange-and-teal grade it comes from, and the rest exist because this is exactly the kind of choice where one default cannot serve everyone: greyscale for printing, tritanopia for blue-yellow colour blindness, and the ColorBrewer scales for readers who already know what they mean.

hollywood:  { neg: [255,140,66],  mid: [245,245,245], pos: [0,139,139]  }   // default
greyscale:  { neg: [0,0,0],       mid: [128,128,128], pos: [255,255,255] }
tritanopia: { neg: [200,85,61],   mid: [245,245,245], pos: [74,172,143] }
rdbu:       { neg: [33,102,172],  mid: [247,247,247], pos: [178,24,43]  }
rdylgn:     { neg: [215,48,39],   mid: [255,255,191], pos: [26,152,80]  }
spectral:   { neg: [213,62,79],   mid: [255,255,191], pos: [50,136,189] }
puor:       { neg: [230,97,1],    mid: [247,247,247], pos: [94,60,153]  }
brbg:       { neg: [140,81,10],   mid: [245,245,245], pos: [1,102,94]   }

The sequential ones are not shared, because they need more resolution than three stops and because only two tools have data shaped that way. The sun-hours map offers viridis, plasma, inferno, magma and four older matplotlib ramps, each sampled at nine evenly spaced points and interpolated in between, next to one hand-built ramp of its own that puts its yellow late, because a fully sunlit courtyard is the exception and not the middle of the range. The wave tool samples at eleven points instead, and for two of its maps that is exactly the published anchor list, so nothing is approximated at all.

The one thing that made those ramps trustworthy was refusing to write them from memory. The numbers were read out of matplotlib 3.11. A ramp typed from the head is afterwards not the ramp it claims to be, and nobody can tell by looking.

Credit where it is due: most of this is other people's work. rdbu, rdylgn, spectral, puor and brbg are ColorBrewer diverging schemes, the cartographic work of Cynthia Brewer at Penn State, taken here in the form matplotlib ships them under those same names. Their colour specifications are published for reuse under an Apache-style licence, so this is an attribution question rather than a licensing one.

On the sequential side, viridis, plasma, inferno and magma were designed for matplotlib by Nathaniel Smith and Stéfan van der Walt and released into the public domain; the older ramps beside them are matplotlib's, with heritage going back further than that. The wave tool additionally uses Managua from Fabio Crameri's scientific colour maps, which is dark in the middle, so still water reads dark and both crests and troughs read light.

Local, and therefore the only ones nobody else is responsible for: hollywood, greyscale, tritanopia, and the hand-built sun ramp.

Worth saying out loud, because the table above hides it: a ColorBrewer scheme is specified at more stops than three, and sampling it down to a negative, a middle and a positive end and then interpolating linearly throws away most of the perceptual work that went into it. What survives is the hue pairing and the neutral midpoint, which is the part these tools actually use. Anything that needs a genuinely uniform ramp wants the full stop list, or a perceptual map such as viridis, rather than these three-point versions.

They are the one part of the colour system that does not come from the ladder and does not switch with the skin, because they encode data rather than interface. A scale that changed meaning when someone pressed the theme button would be a different chart.

This is also the one place where the drive to centralise was tried and abandoned. The original intention was a single map set for the whole site, on the same reasoning that produced one button and one segmented control. It did not survive contact with the tools: what looks good depends on what is being drawn. A sauce comparison table and a moving wave field and a map of annual sunlight hours want genuinely different ramps, and forcing one set on all three made every one of them slightly worse in exchange for a consistency nobody was going to notice. Shared code is worth insisting on. Shared taste, past a point, is not.

4. The components

Everything in this section is defined once, centrally. The most expensive habit this site had was letting each tool invent its own version of a control that already existed. An audit in August 2026 found roughly eighteen distinct size sets for the ordinary button, and nine files carrying a byte-identical rule body under nine different names. Consolidating them was a week of work that a shared class from the start would have avoided entirely.

Action buttons

Live, and restyled by the theme switcher above

Rain intensity
<button type="button" class="tool-btn">Download</button>
<button type="button" class="tool-btn tool-btn-sm">Copy</button>
<button type="button" class="tool-btn tool-btn-primary">Generate</button>
<button type="button" class="tool-danger">Reset everything</button>
<button type="button" class="tool-danger tool-btn-sm">Remove</button>
<button type="button" class="tool-linkbtn">show 40 more</button>

Icons

ToolIcons.el(name, props) returns a React element, ToolIcons.html(name, size) the same icon as a string. Inline SVG, 14 px, fill: none, stroke: currentColor, width 2, round caps. Available: download upload copy reset remove play pause check plus external link search tune.

currentColor is the entire point: the icon is the button's text colour in every skin and in the hover and disabled states, without one extra CSS rule.

Three conventions around them, all about restraint rather than about SVG:

Text fields, and Pico's specificity trap

Live

Pico's rule is input:not([type=checkbox],[type=radio],[type=range]) and it puts a fixed height on the element, computed from 1rem rather than from the element's own font size. With Pico's defaults and its root scaling above 1280 px that is 62 px, against 41 px for the button beside it. A field half again as tall as its neighbour is what reads as wrong on the page, and four tools fell into the two traps that follow:

The shared classes are .tool-input, .tool-input-sm and .tool-input-inline (the last one unlocks width from Pico's 100%, for a field in a chip row rather than in a label grid). Their geometry is .tool-btn's exactly, so a field and the button next to it match to the pixel. Background, focus ring and validity states stay Pico's, so a field still reads as enterable rather than as a button.

Tabs choose a VIEW, segmented controls choose a SETTING

This distinction is the reason both exist. As a segmented control, the view switch looked exactly like the half dozen settings underneath it, so the one choice that decides what the page even shows read as one option among many. Underlined tabs say "these are the sections" instead.

Live: tabstrip, joined seg-row with a disabled segment, gapped chip row

<div class="tabstrip" role="tablist">
  <button role="tab" class="active">Chart</button>
  <button role="tab">Table</button>
</div>

<div class="seg-row">
  <button class="lang-seg active">Before</button><button class="lang-seg">After</button>
</div>

Tabs take no class per button, only role="tab" and active. Margins stay with the page, because the strip sits at a different distance from its neighbours everywhere.

Segments are .lang-seg; the name is historical, it is not only the language switcher. Corners follow DOM adjacency, keyed on :has(+ .lang-seg) and + .lang-seg. The earlier version keyed on :first-child and :last-child, which only held when the segments were the sole flush children of their wrapper: a lone button came out rounded on the right only, and half a dozen tools carried a local patch for exactly that. With adjacency, a solo button needs no patch and a group still joins seamlessly.

Every segment keeps all four of its own borders and the row overlaps them by margin-left: -1px, with active, hover and focus raised by z-index. Before that, the left neighbour handed over its seam with border-right: none, and that broke twice, both times for over a year:

The cost of the new geometry is that squaring and overlap are the default for adjacent segments, and adjacency cannot see a gap. A row of gapped segments that are separate actions rather than one control opts out with two declarations on its own row class:

.qr-chips > .lang-seg { border-radius: 4px; margin-left: 0; }

Deliberately not a shared .chip-row, since each such row needs a class of its own for gap and sizing anyway. The failure mode behind that decision is worth repeating: .seg-row was referenced in comments for two weeks and never actually defined, and seventeen tools hand-rolled it in three mutually incompatible geometries. Naming a class and not defining it turned out to be worse than not naming it.

Info folds

Live

How this is calculated

The body is indented behind a hairline and set slightly smaller and muted. No border, no background, no box: a fold holds prose a reader may skip, so it must not look like an object.

<details class="fold">
  <summary>How this is calculated</summary>
  <div class="fold-body"><p>…</p></div>
</details>

The fold-body wrapper is mandatory, because the hairline hangs on it. No local chevron or marker CSS, and no inline colours or font sizes on the paragraphs inside; fold-body already styles them. fold-sm is the compact variant for dense result areas.

It is for prose only. Functional disclosures kept their own designs: control sections, edit panels with forms, advanced-option panels, popovers, show-more links, list accordions. And the native <details> replaced every React "show notes" toggle on the site, because it prints, it is findable with the browser's own search, and it needs no state.

The control card

For tools whose parameters form groups. One card holds all of them, and the groups inside are separated by hairlines rather than by boxes of their own.

Live

Rainfallper iteration

Advanced

Sediment

<div class="ctl-card">
  <div class="ctl-sec">…fields…</div>
  <div class="ctl-sec">
    <p class="ctl-sec-h">Group<span class="ctl-sec-h-sub">aside</span></p>
    <div class="ctl-group"><p class="ctl-group-h">Subgroup</p> … </div>
  </div>
  <div class="ctl-sec"><details class="ctl-adv"><summary>Advanced</summary> … </details></div>
</div>

The things that went wrong while arriving at this:

The dual slider

Two handles on one track, for the two ends of one quantity: a daily minimum and maximum, a rank from and to, a year range. Two [type=range] inputs lie on top of each other with their own rendering zeroed out; what is visible is .dual-track, .dual-fill and two .dual-dot spans.

Live

Range 25 to 70

<div class="dual-range">
  <div class="dual-track"><div class="dual-fill"></div></div>
  <input class="dual-thumb" type="range" …><span class="dual-dot"></span>
  <input class="dual-thumb" type="range" …><span class="dual-dot"></span>
</div>

It takes Pico's --pico-range-* variables rather than values of its own, because in all three tools that use it, it stands directly next to ordinary single sliders and has to look like them in every skin. Before it was shared it existed three times with three different hardcoded accent colours.

Four details are load-bearing, and each was a bug once:

What stays with the tool: its own margins, --dual-ring if the slider sits on a card rather than on the page surface, and the z-index swap that brings the lower handle forward once the two are close together (otherwise the one at the stop cannot be grabbed any more). That last one depends on the tool's step size.

Frames, and the opposite of a frame

Anything that shows something (an SVG plot, a canvas, an image preview, a parameter map) sits in the same frame, and the frame is border, radius and shadow:

border: 1px solid var(--tool-border);
border-radius: 8px;
box-shadow: var(--tool-panel-shadow);

The background is not part of it. Where the surface already has one it wants (a checkerboard for transparency, a gradient, a 3D scene), that stays. Rounded corners over canvas content need overflow: hidden on the container. And the frame belongs on the element inside a scroll wrapper rather than on the wrapper, otherwise the border scrolls out of view when the content is panned sideways.

A drop zone is the opposite affordance and deliberately looks unlike a frame: 2px dashed var(--pico-muted-border-color), border-radius: 12px. It is an invitation, not an object. One tool switches from the dashed zone to the plot frame at the moment an image is loaded, which is the distinction working as intended.

The privacy badge

Live

Everything stays in your browser. Nothing is uploaded.

<p class="tool-shield">
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
       stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0" aria-hidden="true">
    <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
  </svg>
  Everything stays in your browser. Nothing is uploaded.
</p>

The shield glyph is part of the badge, not decoration on top of it: the class is an inline-flex with a gap and expects the icon as its first child. It is the one icon that is not in icons.js and sits inline in every tool that carries the badge, which is a small inconsistency in this repo rather than a rule.

Placement is fixed: no inline margin, directly below the description text and above everything else. One position everywhere, after an earlier phase in which it sat next to whatever triggered it: over the drop zone in one tool, over the salary fields in another, over the microphone button in a third. Each placement was defensible on its own and the set of them was incoherent.

It is reserved for genuinely sensitive personal data: a due date, medication, a voice recording, a salary. It had spread to five tools that merely accepted an image file, was called inflationary, and was removed from all of them on the same day. A reassurance that appears everywhere reassures nobody.

The category accordion

The landing page groups its entries in native <details> elements, with no JavaScript at all: the browser's own disclosure, restyled. Each group is a card, the native marker and Pico's own chevron are both removed, and a custom chevron sits on the left of the summary, because a marker at the far right of a full-width card loses its connection to the label. The group id doubles as the deep-link anchor.

<details class="tool-group" id="3d-modelling">
  <summary>
    <span class="tool-group-icon" style="--icon:url('/assets/img/icons/box.svg')" aria-hidden="true"></span>
    <span class="tool-group-label">3D Modelling <span class="tool-group-count">(3)</span></span>
  </summary>
  <ul class="tool-list">
    <li><span class="tool-icon" style="--icon:url('icons/mountain.svg')"></span>
      <span class="tool-text"><a href="…">Heightmap to STL</a>
        <span class="tool-desc">…</span></span></li>
  </ul>
</details>

That is the shape, abridged: the real summary also carries a copy-the-permalink button that appears on hover, and the whole list is filtered live by a search field above it.

The icons are SVG masks rather than <img>, tinted through an --icon variable, so they follow the theme the way text does. Same idea as currentColor in the button icons.

5. Layout and width

Wordmark, content and footer stand on one vertical line, left and right. That is not automatic; it is a condition each page has to keep, and eleven pages here were wrong about it until it was checked mechanically.

Pico steps .container at 510 / 700 / 950 / 1200 / 1450 px, switching at 576 / 768 / 1024 / 1280 / 1536 px of window width. A page that renders into a plain .container gets the same step for header, content and footer for free. That is what most pages here do, and the ones that do it have never had an alignment problem.

A page that sets its own width has to cap the header and footer with it, or the wordmark ends up inside the content edge at some window widths and outside it at others. Two shapes exist here:

/* wrapper with padding:16px around an inner box of max-width:N */
header.container, body > footer.container { max-width: calc(N + 32px); padding: 0 16px; }

/* root IS a .container with an inline maxWidth:N */
header.container, body > footer.container { max-width: Npx; }
@media (min-width: 576px) and (max-width: calc(N + 55px)) {
  .container { padding-left: 16px; padding-right: 16px; }  /* Pico drops it, assuming narrower steps */
}

Prose pages like this one are a single 70 ch column. Because a one-line navigation does not fit next to the wordmark inside 70 ch, their header is two rows at every width, deliberately rather than as a fallback for narrow windows.

Scoping rules that touch the header

custom.css serves three page families that share a header and shrink differently, so every header or nav rule hangs on a family hook rather than on a bare header nav: ul.site-crumbs for the tool pages, ul.site-links for the content pages, body.prose-page for everything that is not a tool. An unhooked rule inevitably hits the other two. A single header nav a { white-space: nowrap } took the breadcrumbs' ability to shorten and made every tool page overflow sideways.

6. Copy, language and privacy

A tool's own source note is small and quiet (11 px, --tool-note), and where a brand is named, a disclaimer sits below it, quieter still (10 px, --tool-faint).

7. The mistakes that cost the most time

Each of these was a real bug or a real rewrite. They are collected here because they are the failure modes that recur, especially when work goes fast.

  1. Rebuilding a component that already exists. Eighteen button sizes, nine identical rule bodies under nine names, seventeen hand-rolled segmented rows. Every one of them started as a reasonable local decision.
  2. Naming a class in a comment and never defining it. That is precisely how the seventeen happened.
  3. Joining segments with border-right: none. The shorthand resets the colour too, and eleven tools drew one edge in the wrong colour for a year.
  4. A string style in an htm template. Blank page, one minified error number, no other clue.
  5. Hex literals for greys. They look right in the theme they were written in and are wrong in the other two, and nobody notices until someone switches.
  6. Emoji in buttons. Four different pictures on four platforms, ignoring hover and the disabled state.
  7. A stack of bordered panels where one card belongs.
  8. Per-page copies of a switcher, a footer or a header script. Each copy is a place a later fix will not reach. Centralising them was a day's work; finding the ones that had already drifted was longer.
  9. Winning a specificity fight by luck. A rule that only applies because the page's own <style> comes later in the document has not really won, and the day someone reorders the files it stops applying.
  10. Trusting a green test that never ran the branch. The htm scenario table above: a passing comparison of the wrong state is indistinguishable from a passing comparison of the right one.

This page describes the state of the site in August 2026. The authority for any value is custom.css, which carries the reasoning next to the code.