Development

Theming

How ByteMatch's colours work, so a page added today follows a visitor's theme like every other page. Read this before adding a colour, a border or a control.

The failure this prevents is not ugliness. It is a second dialect: a page that picks its own greys because nobody wrote down what the greys were for. That page then ignores the visitor's theme and looks like a different site.

Themes

A theme has two halves, chosen separately or saved together:

  • Colours: a palette of thirteen colour slots (below).
  • Style: five structural slots: layout, corners, depth, spacing and font (see Style).

Visitors pick colours and a layout from the palette button in the header, or make, import and share their own at /theme. The choice is kept in a cookie (bytematch-theme), so the server renders the right colours and layout from the first byte: no flash, and no script in <head>. Saved custom themes are larger and stay in the browser's localStorage.

Built-in colours:

ColoursSource
SlateThe original ByteMatch colours, and the default
LightThe light theme proposed in PR #1
GraphiteThe dark theme proposed in PR #1
Orange …Orange, Green, Teal, Purple, Red, Pink and Mono, after Albedo's presets

Built-in styles:

StyleLayoutCornersDepthSpacingFont
ClassicClassicRoundRaisedComfortableGeist
DirectoryDirectorySquareFlatComfortableGeist
CompactClassicSoftFlatCompactSystem
TerminalDirectorySquareFlatCompactMono

Classic is the original site and the default. Directory is the redesign proposed in PR #1; Light colours with the Directory style is that PR's whole look.

The palette is thirteen slots, and nothing else is themeable

A theme is { name, palette, style }, defined in src/lib/theme.ts. The palette is exactly these thirteen colours:

SlotCSS propertyFor
bg--bgThe page behind everything
surface--surfacePanels and cards
line--lineBorders and dividers
text--textBody text and headings
dim--text-3Labels, units, secondary text
accent--accentLinks, focus, the current item; the primary button's source
done--doneMatched code: progress bars, finished units
partial--partialUnits with some matched code
series1--series-1Code matched in charts; the Data bar
series2--series-2Functions matched in charts
good--goodGains and healthy states
bad--badRegressions, errors, destructive actions
warn--warnWarnings, and nothing else

applyTheme writes these onto <html> one for one, plus color-scheme (dark or light, from the background's luminance). It cannot write a fourteenth. So:

Anything the interface needs beyond these thirteen must be derived from them, never added as a slot.

A literal hex in a rule or a component is a colour that will not follow the theme. The exceptions are under Data colours.

The :root block of src/app/globals.css declares the slots with Slate's values, so the site works without JavaScript. src/lib/theme.test.ts fails if those values and THEMES.slate disagree.

Derived tokens

Declared under the slots in globals.css. Each is a color-mix() or a relative colour of a slot. They recompute whenever a theme is applied, so nothing at runtime needs to know they exist.

TokenValueFor
--surface-2surface 68% over lineControls, rows, raised areas
--surface-3, --tracksurface 34% over linePressed and selected states; bar tracks
--line-strongline 79% over dim, in OKLabHovered borders, tooltips
--text-2text 40% over dimSecondary prose
--idlesurface 70% over dimUnits with nothing matched
--code-bgbg 50% over surfaceCode blocks
--ringaccent at 55%Focus outlines
--accent-strongaccent, 0.9× lightness and 1.1× chroma in OKLCHPrimary buttons, checkboxes
--on-accentWhite or black, whichever reads on --accent-strongText on primary buttons
--done-deep, --series-1-deepThe slot, 80–82% over blackHatching on progress bars
--tile-done, --tile-low/highdone and partial in OKLCHThe unit grid
--seam, --seam-strongbg at 75% and 90%Seams between unit tiles and groups

The percentages were fitted so Slate's derived values land within about one RGB unit of the hand-picked colours ByteMatch shipped with. Changing a formula changes every theme, so look at Slate, Light and one accent theme before committing.

Browsers without relative colour syntax (oklch(from …)) get a fallback block: the primary button uses --accent with white text, and tiles use the plain slots.

Data colours are exempt, and live in one place

Some colours identify something rather than decorate, so they are not themeable:

  • Comparison lines: COMPARE_COLORS in src/lib/chart-colors.ts. One project is always the same hue, whatever the theme.
  • Badges: drawn on the server for READMEs, which have no theme.
  • The site icon (src/app/icon.svg): a file, not a page.
  • Theme previews: ThemeSwatch draws each theme from its own palette, not the live theme. A preview is a picture of that theme.

The rule that comes with the exemption: one source per data colour. If two places need the same colour, both import it. A literal next to a table is a second definition that will drift, and a duplicated vocabulary gets a test (as Slate's does), not a comment.

Colour meaning

  • One meaning per slot. --warn is a warning, never decoration. Something that wants amber for emphasis wants --accent.
  • State is never carried by colour alone. A low-contrast slot in the editor says "Low contrast" as well as turning amber; regressions carry a minus sign as well as red.
  • Every built-in theme keeps text, dim, accent, good, bad and warn at 4.5:1 or better against both bg and surface, and the test suite checks it. Custom themes are the visitor's own, so the editor shows each ratio and warns below 4.5:1, but does not refuse.

Style

The style is a fixed set of slots too, each a choice of a few words, and the server writes it onto <html> as data-layout, data-corners, data-depth, data-density and data-font. Everything a style changes keys off those attributes:

SlotOptionsChanges
layoutclassic, directoryMarkup and structure: header, home page, project page (below)
cornersround, soft, squareTailwind's --radius-* scale and --radius-panel / --radius-control
depthraised, flatPanel shadows and the page's glow
densitycomfortable, compact, roomyTailwind's --spacing, so every padding and gap
fontgeist, system, monoThe body font

Corners, depth, density and font are CSS only (the end of globals.css). Layout is structure, so it changes markup as well:

  • Header: CSS on the site-* classes in layout.tsx. Directory drops the sticky blurred bar for a flat surface, widens the brand and underlines the current section (SiteLinks marks it with aria-current).
  • Home page: src/app/page.tsx renders DirectoryHeading and ProjectCards (src/components/directory.tsx) instead of its classic heading and ProjectList.
  • Project page: ProjectView puts a large figure beside the title, draws taller square bars and moves the details sidebar under the report.

Server components read the layout with chosenTheme() from src/lib/theme-server.ts. When a visitor picks a different layout, the picker rewrites the cookie and refreshes the page so the server renders the new markup.

A new layout must work with every palette and every other style slot. Use the same tokens as everywhere else, and keep the classic markup unchanged unless the change is meant for Classic too.

The mark and the tab icon

The header mark is the brand's matched green and in-progress blue in Slate. Any other palette sets data-tinted on <html>, and the mark then takes the theme's accent: three units in it, the fourth part-way there. Hovering the site name fills in the fourth.

Off Slate, the tab icon is redrawn the same way in the browser (markSvg in src/components/use-theme.tsx), and theme-color follows the background, so mobile browsers tint their toolbar to match. Slate keeps the static src/app/icon.svg.

Sharing themes

A theme copies as one line of text:

bytematch-theme:2:{"name":"Dusk","palette":{"bg":"#0b0f17", … all thirteen slots …},"style":{"layout":"directory", …}}

parseTheme reads it back and refuses anything that is not #rrggbb in every colour slot. Unknown style values fall back to Classic's. It also reads version 1 (no style, so Classic) and Albedo's format (albedo-theme:1:): bg, panel, edge, fg, dim and accent become the neutrals and accent, and success, danger and warn become good, bad and warn. The data slots come from Slate. Saved themes and the cookie are checked again each time they are read, so a hand-edited value can only ever produce hex colours and known style words.

On /theme, Paste theme reads the clipboard; pressing Ctrl+V (⌘V) anywhere on the page does the same, unless a field has focus. A valid theme is saved and switched to straight away, and the note that follows offers Edit it and Undo. A name already in use gets a number, and the note says so. Anything else gets a note saying what is wrong.

Adding to the interface

  1. Is the colour one of the thirteen slots? Use it: a Tailwind colour (bg-surface-2, text-ink-3, text-warn) or var(--…).
  2. Otherwise derive one in globals.css from the slots, add it to the table above, and expose it in @theme inline if components need it as a Tailwind colour.
  3. Is it a data colour? Then it belongs in the one table for that vocabulary.
  4. Never reach for a literal hex, a rgb(255 255 255 / 0.05), or a fresh grey because a token looked slightly wrong. Those are how a second dialect starts.

When something genuinely does not fit, change this page first and the CSS second.

Credits

The slot-and-derive model, the shared text format and the accent presets follow Albedo's @albedo/ui-theme and its interface style guide. The Light and Graphite palettes and the Directory layout come from clickbate's redesign in PR #1. Its platform logos and SteamGridDB artwork are not included: the Directory cards show a platform's short code instead.