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.
| Theme | What it is |
|---|---|
| littleFedi | Cards on a soft background, teal accent. The default. |
| Roost | A familiar timeline for people arriving from classic microblogging apps. |
| Herd | The dense, panelled column used across much of the fediverse. |
| Console | Monospace and flat, for people who like their server in a closet. |
| Ink | A quiet serif page for people who read more than they scroll. |
| Cupertino | Vibrancy, capsule buttons and the system blue of the Mac desktop. |
| Zephyr | Crisp, dense and flat, in the style of the Plasma desktop. |
| Burrow | Boxed lists and pill buttons, in the style of the GNOME desktop. |
| Whisker | A dark panel and raised controls, in the style of the Xfce desktop. |
| Contrast | Maximum 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:
app.css, the layout and the design tokens.theme-<id>.css, if the account picked something other than the default.- 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.
| Group | Tokens | Controls |
|---|---|---|
| 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.
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
@importand web fonts are refused. Embed a font as adata:URI if you must, and mind the 64 KB budget. Remote images inurl()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-cssand 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.
- Write
internal/webui/static/theme-<id>.css. Every selector must include[data-theme="<id>"], or it leaks into other themes, and a test fails. - Add one entry to the themes slice, with the light and dark
theme-colorvalues for browser chrome. - Add a name and description to every locale file. A completeness test fails until all six have them.
- 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.
| Theme | What it is |
|---|---|
| littleFedi | The stock blog page: one column, system fonts, light and dark. |
| Marginalia | For long reads: serif text, with each post's date and tags in the margin. |
| Ledger | For technical notes: monospace metadata, code that breaks out of the column. |
| Kiosk | For short posts and links: a colour band, heavy titles, tags as pills. |
| Dusk | A 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.