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.