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
https://alber.me/assets/css/pico.min.css– Pico CSS v2, unmodified. Semantic, nearly classless, MIT.https://alber.me/assets/css/custom.css– everything described below. Each block carries a comment explaining why it looks the way it does. Where this page and that file disagree, the file is the authority.https://alber.me/assets/js/icons.js– the button icon set, around 4 kB, no dependencies.
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:
- 3.0 MB less per page load, and no compile pause before first paint.
- The app block becomes plain JavaScript, so Node can evaluate it directly instead of only the
coreseam. That is what made whole-tool testing possible. - An old rule against
async/awaitdisappeared with Babel. It had never been a style choice: preset-env rewrote them toregeneratorRuntime, which was not loaded, and the page rendered blank.
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.
stylemust 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.- Attribute names stay React names:
className,htmlFor,strokeWidth. htm passes props toReact.createElementuntouched and does no HTML-to-React mapping, soclass=andfor=fail silently. ${…}, not{…}. A forgotten$turns an interpolation into literal text, or into an attribute holding a braces string. No warning.<>is not a fragment. htm reads it as a tag with an empty name and hands""tocreateElement. The fragment is<${React.Fragment}>…<//>.- Void elements need closing.
<div>a<br>b</div>parses as adivcontaining"a"and abrthat swallows"b"and everything after it, including thediv's own closing tag. - HTML entities are not decoded. Babel decoded
×and 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\uescape inside an interpolation. - Comments are
<!-- … -->. JSX comment syntax inside a template renders as text. - A missing closing tag silently nests the following siblings inside the open element. Also no warning.
- Multiple roots return an array, not a fragment, and React then wants keys. Deliberate multi-root templates get wrapped in
React.Fragment. - Every nested template needs its own
htmltag. A bare backtick string inside${…}becomes text. - 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 standalonevm.Scriptparse 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:
- Real data, not fixtures. For a tool that fetches, the checker reads the actual JSON. Two tools compare trees of 10,572 and 15,852 lines that way; with a two-row dummy both would have proved nearly nothing.
- The injector counts
useStatecalls in render order, so positions past the root component's own hooks land in whichever child rendered first. Interesting state often lives in a child. - A wrong scenario usually shows up as an error, which is the check working. Passing a string where a multi-select wants an array throws.
- …except when it shows up as a pass. Values that land on the wrong hook positions still render a valid tree, both versions render the same wrong state, and the branch the scenario was meant to enter is never entered. One tool lost two states from its documented order, shifting everything from position 13 on, and only one of eight affected scenarios failed loudly. A green scenario does not prove its branch ran. An audit script now compares every documented order against the code.
- Equal tree sizes across different scenarios are a smell. For a while the resolver dropped the children of every component it resolved, so everything inside a
<Section>or<Card>was excluded from the comparison on both sides. The tell was that scenarios opening different panels reported identical trees.
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.
| Variable | Role | Dark |
|---|---|---|
--tool-emph | emphasized text, card titles | #e1e8f0 |
--tool-text | body text, h1 | #bcc5cf |
--tool-label | labels, secondary text | #99a4af |
--tool-note | footnotes, meta text | #778390 |
--tool-faint | disclaimers | #566472 |
--tool-border | frames, button outlines | #374656 |
--tool-hairline | separators inside a panel | #1a2a3a |
--tool-grid | chart grid lines, plot frames | #45596d |
--tool-surface | card and plot background | #111c28 |
--tool-panel | panel and popover surface | #0a1928 |
--tool-ink | dark 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
<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>
.tool-btnis the base: outline on--tool-surface,0.4rem 0.9rem,0.8rem, radius 8. It isinline-flexwithgap: 7pxso an icon needs no second rule. These values are not a design decision so much as a measurement: they are what nine independent attempts converged on.width: autoandmargin: 0are in there because Pico givesbutton[type=button]a bottom margin and stretches submit buttons to full width. Either one wrecks a row of buttons..tool-btn-primarymarks the one main action of a view. A second one makes both stop meaning anything.- It is deliberately not Pico's filled accent button. Eight tools used that, and it was the loudest object on the page: in the grey skin, where nothing else is saturated, the Generate button was the only coloured thing on screen. The info group is the quietest signal that still lifts it out of its row.
.tool-dangercarries the destructive colours;.tool-btn-smnext to it acts as a pure size modifier, so those colours are never restated. Two tools used to get there by re-declaring the three reds at a specificity that beat their own button class, which meant the colours existed twice and could drift..tool-linkbtnis a button that is really a link. The underline is permanent, because revealing it on hover means it says nothing until the pointer is already there.
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:
- An icon marks a button that performs an action on something: download, upload, copy, run, reset, remove, open elsewhere. Choices, filters, tabs and next/back stay bare. A row where every button has a picture is wallpaper, and the icons stop carrying signal.
- No emoji. An emoji is an image supplied by the operating system: a different picture on Windows, macOS, Android and Linux, in a fixed colour and a fixed stroke weight. It follows neither hover nor the disabled state, and in the deliberately colourless grey skin it is the one bright spot on the page.
- Removal is an X, never a waste bin, at every size, from a chip corner to "reset everything". A test asserts it, along with the guarantee that no icon carries a hardcoded colour.
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:
- Setting only
font-sizegives small text in a tall box. - Setting only
paddingdoes nothing to the height; the padding is redistributed inside it. It takesheight: auto. - And a bare
.tool-inputloses on specificity, because the attribute selectors inside:not()count.input.tool-inputmerely ties, which makes it a question of source order. That is how a dozen tools got their padding to apply by accident. Repeating the:not()list settles it.
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:
- A shorthand resets all three sub-properties, colour included. Eleven tools restored the seam for their gapped chip rows with width and style but no colour, so that one edge was drawn in the button's text colour and read as thicker than the other three. The comment that used to stand at the rule claimed the shorthand only killed the style. It was wrong, and it is what copied the bug into all eleven.
- An active segment in the middle recoloured its border to the accent but had no right border left to recolour, so its right-hand seam was drawn by the neighbour in grey. The selected segment looked cut off on one side.
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:
- A stack of separately bordered panels was the first attempt, for half a day in two tools, and it lost: four borders, four shadows and four gaps for what is one block of controls. It also makes the frame convention read as if each group were a thing being shown.
- The card's values are the plot frame's, so the parameter card and the chart below it agree.
- The section header is a
<p>, not an<h3>. It labels a control block, it is not document structure, and there is no meaningful heading level between the tool'sh1and nothing. As a1.05remheading it was as loud as the fields and still did not separate the group; at0.9emit works, because it competes with the field labels below it at0.85em, not with body text. - A section whose single control already names itself takes no header at all (a switch, a segmented control). Two independent switches are two sections, separated by the hairline, and neither needs a label.
.ctl-sec-hisdisplay: flexso a switch can sit in the header and turn the whole section on..ctl-group-his uppercase with letterspacing rather than bigger or bolder, so it never outweighs the header above it, and it sits on--tool-hairline: a line inside a section has to stay lighter than the line between sections.- The field grid inside stayed local to each tool. Column widths follow the content, and that does not generalise.
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:
- The selector carries the element (
input.dual-thumb). Pico's[type=range]has the same specificity as a bare class and would otherwise win. - All four track pseudo-elements are zeroed. Without that, Pico paints a second grey track over the visible one, sticking out at both ends.
- The input overhangs the track by 10 px on each side, exactly half the thumb width. Natively the thumb's centre only travels from 10 px to width minus 10 px, while the dot goes from 0% to 100%, so at the ends they stop lining up. Change the thumb width and both numbers change with it.
- Focus and active states reach the dot with
+, not~. The order is input, dot, input, dot, and~would also hit the second dot, lighting up both handles when one is grabbed.
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
- English by default, bilingual only where it earns its keep. On bilingual pages every visible string lives in one
STRINGS = { de: {…}, en: {…} }object at the top, the choice lives inlocalStorageand is honoured from?lang=, and switching happens live without a reload. Anything derived from the strings table has to be rebuilt on switch as well, which is where it silently half-worked twice. - German titles are real compounds, not hyphenated hybrids with an English second half. Length is not an argument against a compound.
- The house voice is deadpan. Dry, precise prose, absurd facts stated plainly and concretely, understatement, and honest quantification ("about 62%, depending on who you count") rather than exclamation marks. No puns, no meta-jokes. A closing sentence may name the absurdity; it does not explain it.
- Uncertainty goes into the band, not into the model. Studies contradict each other, so their precision does not get imported. A broader prior beats a more complex model, and single-study coefficients do not get carried to the decimal. Where two readings are defensible and both cheap to compute, the tool offers both and still sets a default on purpose.
- Privacy is a constraint, not a feature. No cookies, no tracking, no analytics, no external font or script requests, nothing loaded from a third party, every dependency hosted locally. Which is also why there is no cookie banner: there is nothing to consent to.
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.
- 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.
- Naming a class in a comment and never defining it. That is precisely how the seventeen happened.
- 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. - A string
stylein an htm template. Blank page, one minified error number, no other clue. - 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.
- Emoji in buttons. Four different pictures on four platforms, ignoring hover and the disabled state.
- A stack of bordered panels where one card belongs.
- 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.
- 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. - 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.