sun, 22:35 at the minemoon, waxing crescent, 36% lit - 22:35 at the mine(spicemine)()=>#!

sign inEN日本

mélangej · ABCL on Jooby and Netty

Skins

What a skin is

A skin is spicemine's word for a theme, and it is a concrete thing rather than a concept: a list of fifty-one named values. Colours, border radii, two font stacks, a shadow, a focus ring, and seven colours that belong to the animated header strip. Nothing else. A skin has no CSS in it, no markup, no images, no JavaScript and no layout — it cannot move anything or add anything, only recolour and reproportion what is already there.

There are ten. A reader can switch between them from /settings/appearance, or get one at random on a first visit, and the page is the same page in all ten.

The arrangement that makes that possible is one rule:

No stylesheet holds a colour. Every rule is written against a token;
a skin is a block of token values and nothing else.

That is what makes ten skins cost what one costs. The markup never learns which skin is on, so a page cannot be right in one skin and wrong in another for any reason except contrast — which is why contrast is the one thing measured.

The three pieces

1. The token list. Named once, in spice-skin.lisp, so that a test can check a skin against the list rather than against a hopeful reading of the CSS:

(defparameter +tokens+
  '("bg" "surface" "ink" "ink-2" "ink-3" "line" "line-soft"
    "accent" "accent-ink" "accent-text" "accent-soft" "accent-line"
    "rail" "hero-bg" "code-bg" "code-ink"
    "chip-bg" "chip-ink" "chip-line"
    "ok" "ok-soft" "ok-line"  "warn" "warn-soft" "warn-line"
    "danger" "danger-soft" "danger-line"
    "r" "r-sm" "r-pill" "r-avatar" "border-w" "pad"
    "font" "mono" "display" "display-weight" "display-tracking"
    "btn-weight" "btn-tracking" "h1"
    "shadow" "btn-shadow" "focus"
    ;; The strip has its own palette, and a skin that recolours the page and
    ;; leaves the machine in the last skin's gold looks like a bug.
    "strata" "rig" "mote" "sun" "moon" "star" "sky-ink")
  "Every token, in the order a skin block writes them.")

The groups are deliberate. ink/ink-2/ink-3 are three text weights, not three greys. accent-ink is what goes on the accent; accent-text is the accent used as text, and they are separate tokens because they have different contrast requirements — see below. ok/warn/danger each come as a triple (text, fill, border) so a status chip is one lookup rather than three decisions.

2. A skin, which is a list.

(defmacro defskin (id name mode &body pairs)
  `(list ,id ,name ,mode (list ,@pairs)))

(defparameter +skins+
 (list
  (defskin "paper" "Mine Paper" "light"
    '("bg" . "#f6f7f4") '("surface" . "#ffffff") '("ink" . "#20261f")
    '("accent" . "#2e6e4e") '("accent-ink" . "#ffffff")
    '("accent-text" . "#2e6e4e")
    ...)
  (defskin "amber" "Terminal Amber" "dark"
    '("bg" . "#15130e") '("surface" . "#1c1a13") '("ink" . "#f1d08b")
    '("accent" . "#ffb300") '("accent-ink" . "#15130e")
    ...)))

defskin is two lines and does nothing clever: a skin is (id display-name mode alist) and skin-tokens is fourth. There is no registry, no class and no inheritance between skins — a skin that wants to be a tweak of another one copies it, because fifty-one values are cheaper to read than a chain of overrides is to reason about. You can print a whole skin at the REPL and see the entire theme.

mode is "light" or "dark", and it becomes color-scheme, which is what gets form controls, scrollbars and the browser's own UI to match rather than fight the page.

3. One :root block, emitted first.

(defun skin-style (&optional (skin *skin*))
  (format out ":root{")
  (loop for (name . value) in (skin-tokens skin)
        do (format out "--~a:~a;" name value))
  (format out "color-scheme:~a}" (skin-mode skin)))

Fifty-one custom properties, before every other stylesheet, so everything after is reading values that already exist. The rest of the CSS in the project is one static block that mentions no colour at all — it is the same bytes for every reader, in every skin, which is also why it can be inline and cached as part of the page.

Where the choice lives

Four sources, in this order, and the order is the whole rule:

  ?skin=amber   beats   the cookie   beats   the account   beats   random
(defmacro with-skin ((ctx &key user-id) &body body)
  "Bind *SKIN* for this request, remembering a first visit.

A visitor with no cookie gets a random skin AND the cookie that pins it, so
the page they are looking at is the page they will get again. Without that
write the strip would change colour on every reload, which reads as a fault
rather than as a welcome."
  ...)

with-skin is one of the three wrappers def-page composes (chapter 4), and it binds skin for the dynamic extent of the request. That is the only place the current skin lives: there is no skin in the session row, no skin threaded through page functions, and no argument to pass. A page renders in whatever skin is bound, which is why a fragment can be rendered at the REPL in any of the ten by binding one special variable.

Two details in there matter more than their size:

  • The random skin is written to a cookie on the same response. Without that write the strip changes colour on every reload, which reads as a fault rather than as a welcome.
  • Signing in applies the account's skin to that browser. Without it the stored choice could never beat the random skin a new browser had already been given.

Validating a skin

A skin that forgets a token does not error. The rule referencing it falls back to inheritance, which for a colour means whatever was there — and the page goes out with ink the same colour as its background. Nothing in the build notices and the only report is someone saying the site looks broken.

So the suite walks the list and fails on:

  • a missing token;
  • a duplicate (the second value is dead, so the colour on screen is not the one being edited);
  • an invented name — a typo is not a missing token, it is a value nothing reads, which looks like a colour refusing to change;
  • a value carrying ; or }, which ends the declaration early and silently drops every token after it.

missing-tokens is four lines and is the reason that list exists as data:

(defun missing-tokens (skin)
  (loop for name in +tokens+
        unless (assoc name (skin-tokens skin) :test #'string=)
          collect name))

Contrast

The browser suite loads every page in every skin, computes the sRGB relative luminance of every leaf text node against the background actually painted behind it, and fails below 4.5:1 for body text and 3:1 for large text.

Ten skins is a hundred colour pairs; nobody checks a hundred colour pairs by looking, and the ones that fail are always the small grey text nobody was looking at.

--accent is a button fill and is not a text colour. A citrus orange reads 3.2:1 as small text on white — perfectly good as a button, illegible as a link. That is why --accent-text is a separate token; it is equal to --accent on eight skins of ten and different on the two where it had to be.

A token sits on more than one ground. --ink-3 appears on --bg, on --surface and on --rail, and has to clear 4.5:1 on all three.

Random on first visit

A visitor who has never been here is given a skin at random, pinned to a cookie on that same response. Not whimsy: a host for people who version photographs and manuscripts as readily as code usually tells them, in its first frame, that they are in the wrong building. Ten front doors say something truer.

The footer always names the skin you were given and links to the rest, because a visitor who cannot find out what happened has been played with rather than welcomed.

The strip wears it too

The animated header has its own palette — --strata, --rig, --mote, --sun, --moon, --star. A skin that recolours the page and leaves the digging machine in the last skin's gold looks like a bug, and is one.

← LocalizationDevelopment tools →