Themes
Arclight ships with one look, Tron (dark glass, cyan light, a grid horizon). Theme packs let anyone re-skin the whole web app with a stylesheet: new colours, corners, shadows, fonts and background. This page covers choosing a theme, how packs reach your server, and how to make and publish your own.
What a pack is
Section titled “What a pack is”A pack is a small folder: a manifest, one stylesheet and optional fonts. It changes the app by
overriding the --al-* CSS variables (and, if it wants, any .al-* rule), always under
[data-theme="<id>"], so it only applies while it is the active theme. A pack contains no
JavaScript and cannot load anything from the internet.
Packs live in the repository under themes/<id>/. A copy is bundled in the Docker image
(/app/themes) and newer versions are synced into <config>/themes (see
How sync works). Tron is built in and is not a pack.
Choosing a theme
Section titled “Choosing a theme”Users: Settings → General → UI → Theme. Pick a card; it saves to your account and follows you to every device you sign in on. Default means “whatever the admin set”. On the request portal, the palette button in the top bar opens the same cards. The button is hidden when there is only Tron, or when the admin turned user choice off.
Without an account (API key, signed out) the choice is remembered on that device only. The sign-in page uses the theme this device used last, then the server default.
Admins: Settings → General → Themes.
| Setting | What it does |
|---|---|
| Default theme | Shown to everyone who has not picked one, and on the sign-in page |
| Let users pick a theme | Off: everyone sees the default theme and the picker is read-only. Admins can still pick their own |
| Sync theme packs | Download new and updated packs from the source (on by default) |
| Check for packs | Hours between syncs: 6, 12, 24 (default), 48 or 168 |
| Source | The raw-content URL packs are fetched from (see below) |
| Installed / Sync now | Lists installed packs and versions, shows when the last sync ran, and syncs immediately |
If a user’s pick, or the default, names a pack that is no longer installed, Arclight falls back to the default, then to Tron.
How sync works
Section titled “How sync works”- The server downloads
index.jsonfrom the Source URL. It lists every pack with the size and SHA-256 of each file. - For each pack that is new, or has a higher
versionthan the installed copy (bundled or synced), it downloads the files into a temporary folder, checks the size and checksum of every file, then runs the same validation as for a local pack (see What the validator checks). - Only if everything passes is the folder swapped into
<config>/themes/<id>in one step. A pack that fails is skipped (the reason is shown after Sync now and logged) and the old copy stays.
When a pack exists both bundled and synced, the newer version wins; on equal versions the synced copy wins.
Syncs run on a schedule (job ThemesSync, also listed as “Theme packs” under System → Feeds),
and once about 90 seconds after start when the last sync is older than the interval or never
happened. Sync now runs one on demand.
To turn it off, switch off Sync theme packs. The server then makes no requests for
themes and uses only the packs in the image plus anything already synced. To remove a synced
pack, delete its folder from <config>/themes and restart.
Self-hosted fork: set Source to the raw URL of your fork’s themes/ folder, with a
trailing slash, so <Source>index.json and <Source><id>/theme.css resolve.
Making a pack
Section titled “Making a pack”Folder layout
Section titled “Folder layout”themes/ my-theme/ theme.json theme.css fonts/ optional MyFont.woff2 images/ optional (backgrounds) background.webpThe folder name is the theme id: lowercase letters, digits and dashes, starting with a letter or
digit, at most 32 characters. tron is reserved.
theme.json
Section titled “theme.json”{ "id": "my-theme", "name": "My Theme", "version": "1.0.0", "description": "One line shown on the picker card.", "author": "Your name or handle", "colorScheme": "light", "backdrop": "shapes", "swatches": ["#f6efe6", "#e8743b"]}| Field | Rules |
|---|---|
id |
Same as the folder name |
name |
1 to 40 characters |
version |
major.minor.patch digits, e.g. 1.2.0. Bump it whenever any file changes |
description |
Up to 200 characters, optional |
author |
Up to 80 characters, optional |
colorScheme |
dark or light. Sets the browser’s color-scheme (form controls, scrollbars) |
backdrop |
tron, shapes or none (default none), see Backdrops |
swatches |
2 to 6 colours as #rrggbb. The picker previews them; the first is the background and also becomes the browser theme colour, the second is the accent |
theme.css
Section titled “theme.css”Every rule must be scoped to your theme:
[data-theme="my-theme"] { --al-void: #f6efe6; --al-cyan: #e8743b; --al-radius-card: 14px;}
[data-theme="my-theme"] .al-pane { backdrop-filter: none; }The scope may appear anywhere in a selector (html[data-theme="my-theme"] .x), and every
selector in a comma list must include it. Plain :root { ... } or body { ... } is rejected.
@media, @supports and @container blocks are fine, with scoped rules inside.
@font-face and @keyframes are the only rules allowed unscoped.
Which tokens to override. The full list is the :root block of
apps/web/src/styles/arclight/arclight.css, whose values are the Tron look. It is still
growing, so read the file rather than this page for the exact names. The groups:
- Surfaces and text:
--al-void,--al-deep,--al-horizon, the glass and surface tokens,--al-text-1and the lower text levels. - Accent and signals:
--al-cyan*(the accent, despite the name),--al-warn*,--al-danger*, and their alpha tints (--al-cyan-a20is the accent at 20%). Tron’s tints are all cyan, so a pack with a different accent must override the tints too. - Lines:
--al-line-1to-4and the--al-line-aNNdividers. - Light effects: glow, focus rings, sheen, scrim, scanline and vignette.
- Type:
--al-font(body),--al-display(headings, defaults to--al-font),--al-mono. - Shape: corners and elevation, below.
Check contrast: text on the glass surfaces and the accent button’s ink (--al-cyan-ink) must
stay readable. A light pack usually has to flip the surfaces, text, lines, scrim and the alpha
tints together.
Shape tokens. Tron is square and flat. Give a pack rounded, soft corners with:
| Token | Used for |
|---|---|
--al-radius-card |
Panes and cards |
--al-radius-inner |
Things inside cards |
--al-radius-control |
Buttons, segmented controls, toggles |
--al-radius-field |
Inputs and selects |
--al-radius-check |
Checkboxes |
--al-shadow-card, --al-shadow-pop |
Elevation on cards and popovers |
--al-blur |
Glass blur radius |
Tron cuts corners (“chamfers”) with clip-path, which would clip rounded corners and shadows. A
pack that drops them sets --al-chamfer: 0px; and --al-chamfer-clip: none;. The modal and
float-bar cuts have their own sizes, --al-chamfer-modal and --al-chamfer-bar.
Backdrops
Section titled “Backdrops”backdrop in theme.json picks what is drawn behind the app:
| Kind | You get |
|---|---|
tron |
The animated grid floor and spire, tinted by your tokens |
none |
A plain .al-backdrop layer (background, scanline and vignette from your tokens) |
shapes |
The plain layer plus three decorative elements you style yourself |
For shapes the app renders:
<div class="al-backdrop al-backdrop--shapes" aria-hidden="true"> <i class="al-backdrop__shape" data-shape="1"></i> <i class="al-backdrop__shape" data-shape="2"></i> <i class="al-backdrop__shape" data-shape="3"></i></div>The shapes are empty and unstyled, so give them size, position, colour and (optionally) motion:
[data-theme="my-theme"] .al-backdrop__shape { position: absolute; border-radius: 50%; filter: blur(40px); }[data-theme="my-theme"] .al-backdrop__shape[data-shape="1"] { width: 40vmax; height: 40vmax; top: -10%; left: -8%; background: var(--al-cyan-a20); }[data-theme="my-theme"] .al-backdrop__shape[data-shape="2"] { width: 30vmax; height: 30vmax; right: -6%; bottom: 5%; background: var(--al-warn-a35); }[data-theme="my-theme"] .al-backdrop__shape[data-shape="3"] { width: 18vmax; height: 18vmax; top: 40%; left: 45%; background: var(--al-cyan-a10); }The backdrop is fixed, behind everything and ignores the pointer. If you animate the shapes, respect reduced motion:
@media (prefers-reduced-motion: reduce) { [data-theme="my-theme"] .al-backdrop__shape { animation: none; }}Fonts must be self-hosted .woff2 files in fonts/, referenced relatively. Remote fonts
(Google Fonts and similar) are not allowed.
@font-face { font-family: "My Font"; src: url(fonts/MyFont.woff2) format("woff2"); font-weight: 100 900; font-display: swap;}
[data-theme="my-theme"] { --al-font: "My Font", system-ui, sans-serif; }File names use letters, digits, ., _ and -. Subset fonts to the characters you need to
stay under the size limits. Only ship fonts whose licence allows redistribution (SIL OFL,
Apache 2.0 and similar), keep the licence text alongside your pack if the licence asks for it,
and credit the font in description.
What the validator checks
Section titled “What the validator checks”A pack that fails any rule is not offered, whether it is local, bundled or synced.
| Rule | Detail |
|---|---|
theme.json |
Valid per the table above; id matches the folder; not tron |
| Scoping | Every style rule names [data-theme="<id>"] |
| Allowed at-rules | @font-face, @keyframes, and @media / @supports / @container groups (checked inside) |
| Not allowed | @import, @namespace, @charset, any other at-rule |
| URLs | url() only to the pack’s own fonts and images (url(fonts/x.woff2), url(images/bg.webp)); nothing remote, no image-set() or src() |
| Images | images/*.webp, .png, .jpg, .jpeg or .avif, and the file must really be that type (checked by its first bytes). No SVG, which can carry script |
| Hooks and escapes | No expression(), javascript:, behavior:, -moz-binding, and no backslash anywhere in the CSS |
| Files | Only theme.json, theme.css, fonts/*.woff2 and images/* are used |
| Limit | Value |
|---|---|
theme.css |
256 KB |
| One font | 512 KB |
| One image | 1 MB |
| Whole pack (CSS, fonts and images) | 2 MB |
| Files per pack in the index | 2 to 40 |
| Packs in an index | 100 |
For a picture behind the app, ship it in images/ and put it on a backdrop shape (with
backdrop: "shapes"), with a gradient wash over it so text stays readable:
[data-theme="my-theme"] .al-backdrop__shape[data-shape="1"] { inset: 0; width: auto; height: auto; border-radius: 0; animation: none; background: linear-gradient(rgba(0,0,0,.25), rgba(0,0,0,.55)), url(images/background.webp) 30% 100% / cover no-repeat;}Keep images small (WebP at about 2000 px wide is usually under 200 KB). Otherwise draw
backgrounds with gradients, box-shadow and the backdrop shapes.
Testing locally
Section titled “Testing locally”With a dev server. Put the folder in the repository’s themes/ and start pnpm dev from
the repository root. The API loads packs from themes/ (or from the folder named by the
ARCLIGHT_THEMES_DIR environment variable). Packs are read when the API starts and after a
sync, so restart the API after editing. The stylesheet is cached for a year per version, so
bump version (or hard-refresh) to see CSS changes. Then pick the theme in Settings → General →
UI.
On a running install. Copy the folder to <config>/themes/<id>/ (the config volume in the
install guides) and restart. This is the same place synced packs go; a pack with the same id and
an equal or higher version than the bundled one is used.
To find out why a pack is missing, look in the log for theme pack skipped: it carries the
first problem found. If a theme’s stylesheet fails to load in the browser, the app falls back
to Tron.
Private packs
Section titled “Private packs”A pack does not have to be published. Copy its folder into <config>/themes/<id>/ and restart:
it is offered like any other pack, to every user of that server, and never leaves it. Sync only
replaces packs that are listed in the source’s index.json, so give a private pack an id the
public catalogue does not use (a prefix of your own, for example home-winter). Back up the
folder with the rest of your config.
Publishing
Section titled “Publishing”-
Bump
versionintheme.json(every change to any file needs a new version, or instances keep their old copy). -
Regenerate the index:
node scripts/build-themes.mjsThis writes
themes/index.jsonwith each pack’s manifest and every file’s size and SHA-256. CI runsnode scripts/build-themes.mjs --checkand fails when the index is out of date. -
Commit the pack and
themes/index.json, and push tomain.
Running instances pick the pack up on their next sync, or when an admin presses Sync now.
Releases also bundle whatever is in themes/ at that point.
For a fork, do the same in your own repository and point each instance’s Source at
the raw URL of your fork’s themes/ folder.