Skip to content

Skins

Built. This started as the design and authoring contract, written before the code so the samples and the rules were settled rather than discovered along the way. Everything below is what shipped — app/skins.py, the Skins tab under Admin, and the two seeded palettes as real, selectable skins rather than only reference material.

A skin is a palette. It replaces the colours Longbox draws itself in, and nothing else — no layout, no typography, no template changes. That narrowness is deliberate: it keeps a skin a block of values that cannot break the application, only make it look different.

This lives in docs/ rather than the README because it is authoring documentation. Someone installing Longbox to read comics does not need it — the README only says that skins exist and points here for anyone building one.


Who chooses

Skins are an administrator function and apply to the whole server. One skin is active at a time and everybody sees it. An account cannot pick its own.

Light and dark remain a personal choice. Each account still chooses system, light or dark from the header, and that follows them between devices.

Those two facts together produce the single most important rule here:

A skin must define both palettes. Not one. Both.

If a skin supplies only light values, every account using dark mode gets a half-applied palette — dark backgrounds with light-mode borders and inks. The admin who installed it would not see the problem unless they happened to be on dark themselves. A skin that does not define both must be rejected at the point it is added, not rendered and left to break for somebody else.


What a skin contains

Sixteen values per palette, thirty-two in total. The names match the CSS custom properties in app/static/app.css:

Token What it colours
bg the page behind everything
bg-2 cards, the header bar
bg-3 inset areas, buttons at rest, active nav
border ordinary dividing lines
border-2 input outlines, button edges
text body text
muted labels, secondary text, hints
accent primary buttons, active state, focus rings
accent-2 the accent's hover
accent-ink text drawn on accent
link links
good success badges, read pills
good-ink text drawn on good
bad errors, destructive buttons
warn warnings, running jobs
shadow a CSS shadow value, not a colour

The two that catch people out

accent-ink and good-ink are text colours paired with the background they sit on. They exist because the colour was once hardcoded — color: #21160a on var(--accent) — which was fine for the default amber and unreadable the moment anyone changed it.

A skin that sets accent without setting accent-ink produces a primary button nobody can read. Supplying the pair is the whole contract.


Contrast is part of the contract

Every skin has to clear WCAG AA, a ratio of 4.5, on these nine pairs, in both palettes:

text on bg          text on bg-2        muted on bg
muted on bg-2       link on bg-2        bad on bg-2
warn on bg-2        accent-ink on accent    good-ink on good

This is not advice. It should be checked when a skin is added and the skin refused with the failing pairs named, because the failure is invisible to the person who wrote it — they chose colours they can personally read, on the monitor they own, in the mode they use.

The default palette needed this itself. When accent-ink and good-ink were introduced, the first light values measured 3.64 and 4.02. Worse, --light-good at #1f8a58 could not be fixed by changing the ink at all: neither white nor black reaches 4.5 against it, so the green itself had to move to #1d8252. A checker catches that; an eye does not.


Sample skins

Both of these clear AA on all nine pairs in both palettes, and both are seeded into every database on first run (app/db.py's v5 → v6 migration) — an admin sees them on the Skins tab already, with nothing to paste in. They are also the fixtures tests/test_skins.py checks the validator itself against.

Newsprint

Warm paper and ink. Light is a broadsheet; dark is the same press at night.

              light        dark
bg            #f2ede3      #18150f
bg-2          #fbf8f1      #211d15
bg-3          #e7e0d2      #2b251b
border        #d9d0be      #3a3225
border-2      #c2b79f      #4c4231
text          #241f18      #ece4d6
muted         #5f584a      #a89b86
accent        #9c4221      #e08c5a
accent-2      #7c3315      #f0a97c
accent-ink    #fdf6ef      #241305
link          #1f5d8c      #8fc4ea
good          #1d7049      #5fc994
good-ink      #ffffff      #04240f
bad           #a52a2a      #ef6b6b
warn          #8a5a10      #dda85a
shadow        0 4px 14px rgba(0, 0, 0, .09)     0 6px 20px rgba(0, 0, 0, .35)

Worst pair: 6.04 (light), 6.15 (dark).

Midnight

Cool blue-grey. The dark palette is close to what a developer expects; the light one is deliberately plainer than the default.

              light        dark
bg            #eef1f6      #0d1117
bg-2          #ffffff      #141a22
bg-3          #e2e7f0      #1c242f
border        #d3dae6      #27303d
border-2      #b8c2d4      #36414f
text          #141a24      #e3e8f0
muted         #5a6479      #94a0b4
accent        #1f5fa8      #58a6ff
accent-2      #174a85      #79b8ff
accent-ink    #f2f7ff      #04121f
link          #0f5fae      #79b8ff
good          #1d8252      #48c78e
good-ink      #ffffff      #042418
bad           #bc2f2f      #f06a6a
warn          #8a6410      #e0a33c
shadow        0 4px 14px rgba(0, 0, 0, .09)     0 6px 20px rgba(0, 0, 0, .35)

Worst pair: 4.81 (light, good-ink on good), 6.62 (dark).

The default palette — amber on near-black — is a third option on the same list. It is not a stored skin at all: activating "Default" just clears the active_skin setting, and app/static/app.css's own :root block is what a server falls back to.


How it works

app/skins.py is the whole implementation: validate_skin (the checks above), create_skin/delete_skin/set_active_skin, and active_css(), which is the only point of contact with the rest of the app. It emits a <style> block redefining the --dark-*/--light-* raw tokens app.css already reads through — never --bg/--text/etc directly — so the system/light/dark logic from the theme toggle keeps working completely unmodified underneath whatever skin is active. app/main.py's render() attaches it to every page, signed in or not, since it is the server's look rather than an account's preference.

The Skins tab under Admin is the only way in: a list with a live swatch preview and Use this/Delete per skin, and an Add a skin form that accepts either 32 individual hex/shadow fields or a single pasted JSON document — {"name": ..., "light": {...}, "dark": {...}} — as a shortcut that fills the fields in rather than submitting blind, so what is about to be saved is still visible first.

Lifecycle

A skin persists once added. Uploading or creating one makes it permanently selectable; switching the active skin does not discard the others. Someone who spends an evening on a palette should not lose it by trying another.

Administrators can delete skins. Deleting is the only way one leaves the list. Two rules follow from skins being server-wide, both enforced in app/skins.py rather than left to the UI to remember:

  • The active skin cannot be deleted. Switch away from it first, so the server is never left rendering against something that no longer exists.
  • The built-in default cannot be deleted, because it is not a stored row at all — there is nothing in the table to delete.

What a skin cannot do

The reader is exempt. It hardcodes a dark surround — #08090d and the rgba(0, 0, 0, …) overlays — so that artwork is never framed in white. That is a deliberate choice about looking at comics, not an oversight, and a skin should not be able to override it.

Images are a separate feature. Longbox ships no image assets at all today; even the favicon is an inline SVG. Wallpaper and custom logos mean upload, storage, serving and cache invalidation, which is a different and much larger piece of work than a palette. Colour skins should not wait for it.