Documentation

Theming

Ten themes for the web interface, four for the generated blog, and a stylesheet of your own on top of whichever you picked. Everything visual is driven by design tokens, which is why most themes are under 150 lines.

The ten themes

Four of these dress littleFedi as an application on a specific desktop. Above 1050 pixels they lay the page out as a real application window, with a navigation panel, a content pane and a side pane; below that they hand the shell back to the top bar and the bottom navigation. All of them cover light, dark and true black.

Built-in web interface themes
ThemeWhat it is
littleFediCards on a soft background, teal accent. The default.
RoostA familiar timeline for people arriving from classic microblogging apps.
HerdThe dense, panelled column used across much of the fediverse.
ConsoleMonospace and flat, for people who like their server in a closet.
InkA quiet serif page for people who read more than they scroll.
CupertinoVibrancy, capsule buttons and the system blue of the Mac desktop.
ZephyrCrisp, dense and flat, in the style of the Plasma desktop.
BurrowBoxed lists and pill buttons, in the style of the GNOME desktop.
WhiskerA dark panel and raised controls, in the style of the Xfce desktop.
ContrastMaximum legibility: pure black and white, heavy borders, underlined links.

Skin and mode

Two independent axes, both set on <html> from the account preference:

<html data-theme="console" data-appearance="dark">

data-theme picks the skin. data-appearance picks the mode: auto, light, dark or black.

auto is the only value that defers to the operating system, so it is the only one carrying a prefers-color-scheme query. That means a theme covering every mode declares its dark tokens three times: once inside the media query for auto, once for dark, once for black. Vanilla CSS cannot reuse a media block from a plain selector and this project has no build step, so the repetition is deliberate rather than sloppy. If you only care about the mode you personally use, write one block and ignore the rest.

Three stylesheets load, in this order, and later ones win:

  1. app.css, the layout and the design tokens.
  2. theme-<id>.css, if the account picked something other than the default.
  3. Your custom CSS, inlined into the page.

The tokens

Everything visual is a CSS custom property declared on :root. Redefine one and every component that uses it follows. That is the whole trick.

Design tokens by group
GroupTokensControls
Typography --font-body, --font-features, --font-mono Interface strings, font feature settings, code and monospace themes
Surfaces --bg, --bg-grad, --surface, --surface-2, --surface-3 Page background and its decorative gradient, cards and header, hover states and inputs, pressed states and code backgrounds
Text --text, --text-soft, --muted Primary text, post bodies, timestamps and help text
Lines --line, --line-strong Card borders and separators, focus and hover borders
Accent --accent, --accent-strong, --accent-soft, --accent-grad, --accent-shadow Links, active navigation, primary buttons, tinted backgrounds, the glow accent surfaces cast
Interaction --like, --boost, --mention, --danger and their soft variants Favourite, boost and mention colours, destructive actions and errors
Shape and depth --shadow*, --radius*, --header-height, --mobile-nav-height, --status-clamp Elevation, corner rounding, the sticky bars, and how tall a post grows before it folds

Writing your own CSS

Open the custom CSS section of your settings, paste, save. A starting point:

:root{
    --accent:#b4531f;
    --accent-strong:#8d3f16;
    --accent-soft:rgba(180,83,31,.10);
    --accent-shadow:transparent;
    --radius:4px;
    --bg-grad:none;
    --shadow:none;
}

To follow the viewer's light and dark modes, cover the states you use:

/* Light, and the default for everything below. */
:root{--bg:#faf6f0;--surface:#fffdf9;--text:#241f1a;}

/* "auto", when the OS asks for dark. */
@media(prefers-color-scheme:dark){
    :root[data-appearance="auto"],:root:not([data-appearance]){
        --bg:#14110d;--surface:#1d1913;--text:#f0e9df;
    }
}

/* Explicitly chosen dark and black. */
:root[data-appearance="dark"],:root[data-appearance="black"]{
    --bg:#14110d;--surface:#1d1913;--text:#f0e9df;
}
:root[data-appearance="black"]{--bg:#000;--surface:#0a0806;}

Your CSS is not scoped to a [data-theme] selector, unlike a built-in theme. It is yours alone and it applies whichever theme you have selected. Pick a built-in theme as your base first, then correct what you dislike about it.

Every theme file is public, so you can start from one: curl -O https://your.instance/static/theme-console.css. If you paste it in unchanged, strip the [data-theme="console"] part of each selector, or select that theme in the picker as well.

Prefer tokens over selectors

Class names are not a stable API. A stylesheet that redefines tokens keeps working across upgrades. One built on .status-card__footer > span:nth-child(2) breaks the first time that markup is touched. Reach for a selector only when no token covers what you want, and expect to revisit it.

Aim for at least 4.5:1 between text and the surface behind it, in every mode you support. That is not enforced for personal CSS, but it is enforced for the built-in themes by a test that checks text and muted text against every background they can land on.

Limits and rules

64 KB
Your stylesheet is inlined into every page you load, so it is page weight on every request. The largest built-in theme is well under a quarter of that.
No markup
The sequences </ and <!-- are rejected, because the stylesheet is inlined into a <style> element and those are the only things that can close it early. This is the one hard security boundary here.
Signed in, and only you
Custom CSS applies to your own session. Signed out you see the instance default, and nobody else ever sees your stylesheet.
No external resources
The Content-Security-Policy blocks anything not served by the instance: cross-origin @import and web fonts are refused. Embed a font as a data: URI if you must, and mind the 64 KB budget. Remote images in url() do work, but they tell that server your IP address on every page load.
Bad CSS is your problem
Nothing is parsed or corrected, and browsers silently ignore what they cannot understand, so a typo simply does nothing. If you lock yourself out visually, the settings page still works: go to /settings#custom-css and tick "Remove my custom CSS".

Your stylesheet travels in your account export under preferences.custom_css, and is validated again on import.

Contributing a theme

Built-in themes come in two sizes, and the smaller one is usually right.

  • A palette theme redefines tokens, plus at most a handful of selectors that follow from the palette. It inherits the layout wholesale, so it cannot break a page it was never opened on. 60 to 120 lines, and a smoke render is enough review.
  • A layout theme reflows the shell. 200 to 450 lines, and it puts every template in scope for manual verification.

Themes are CSS-only skins: a theme may change how the interface looks, never what markup is rendered. Status cards are cached with no theme axis, so where two themes need different glyphs the markup renders both and CSS hides one.

  1. Write internal/webui/static/theme-<id>.css. Every selector must include [data-theme="<id>"], or it leaks into other themes, and a test fails.
  2. Add one entry to the themes slice, with the light and dark theme-color values for browser chrome.
  3. Add a name and description to every locale file. A completeness test fails until all six have them.
  4. Run make verify. The settings picker and the service worker precache list both derive from the same slice, so there is nothing else to wire up.

Blog themes

The static blog has its own themes and its own stylesheet, because it is a different thing: a public page, generated once at publish time, read by people who are not signed in. None of the interface tokens or data-theme attributes exist there.

Blog themes
ThemeWhat it is
littleFediThe stock blog page: one column, system fonts, light and dark.
MarginaliaFor long reads: serif text, with each post's date and tags in the margin.
LedgerFor technical notes: monospace metadata, code that breaks out of the column.
KioskFor short posts and links: a colour band, heavy titles, tags as pills.
DuskA dark journal: no rules, wide leading, one amber mark per post.

One stylesheet is served, written at build time by concatenating the stock sheet, the chosen theme, and your own CSS last. The token names match the interface equivalents, with a dark set inside a prefers-color-scheme query. There is no appearance setting: a visitor gets what their system asks for.

What is different from the interface:

  • Everyone sees it. Your blog CSS is published, not personal. Test it.
  • Changes need a rebuild. The stylesheet is a file on disk, so saving the form queues one, but only when the theme or the CSS actually changed.
  • No CSP to fight, and no JavaScript to lean on. Blog pages are static files. Keep them working with images blocked and scripting off. Every shipped theme does.
  • Same 64 KB limit, same rejection of markup, re-validated when the site is generated and not only when you save.