Generated from
cat-harness/skills/ui/theming/theme-artefacts.md— do not edit here.
Per-artefact specialisation
This is level 3 of the axis, and it is deliberately the narrowest. The
general rule lives in its stage — intake, declaration, generation,
contrast — and an artefact carries only its difference. Going artefact-first
would duplicate intake and contrast into every surface, which is the shape that
let the scrim rule be rediscovered rather than read.
The avatar crop boxes are the worked example: the rule that a crop is
declared data belongs to theme-declaration; the boxes themselves are
navbar-avatar specialisation, and they are below.
The surfaces
| artefact | what differs | where |
|---|---|---|
| sticky | the container sizes to its CONTENT, so a text region would reimpose the geometry the sticky removes | schemas/landing-sticky.ts, .fa-sticky |
| landing board | text is composited INTO the art, so it needs textRegion per layout and a crop chosen by content shape | docs/_includes/landing.html |
| docs background | the page ground, not a card — a different CSS surface and probably a different crop set | not built; bean yj32 |
| navbar avatar | the art CLIPPED to the subject in a square frame — avatarRegion | <name>.json, bun run cat avatar:crops |
| the mark | ONE geometry, TWO inks — a scheme pair, because one ink cannot clear 3:1 on both grounds | <name>.json mark + mark-dark, docs/assets/img/icons/ |
Sticky: art backs it, text does not sit IN it
Most sticky-backing art carries no textRegion on purpose. A sticky is a
container that sizes to its content; a region would fit the text into a cloud
interior, which is the geometry the sticky exists to remove. The three
landing-*.webp entries keep theirs because the composited landing path still
uses them.
A floating sticky keeps its theme. .fa-sticky-floating’s opaque override
is scoped to :not([data-fa-sticky-theme]), because the theme is a property of
the sticky and not of the pinned state — bean ivfw. That scoping is sound
only while every theme surface is opaque, which is why theme-declaration
carries the rule and themes.test.ts gates it.
Landing board: the crop is chosen by CONTENT, then overridden by viewport
The default crop is the one the sticky’s content wants — computed from how much
text and how many links the card carries. The narrow-viewport <source>s stay
on top of it: content decides the default and the viewport still overrides,
which is the right precedence, because the viewport is a hard constraint and
the content shape is a preference.
<picture> swaps the FILE at 30rem and 48rem, so the card’s ratio and the
cloud’s position must change at exactly those widths or the words land in the
wrong part of the art. A media query cannot read which <source> won, so both
sides are keyed to the same two numbers.
Navbar avatar: the box is per-image and measured
The avatar is the card art clipped to the cat’s head and torso, not the card scaled down. A whole 1:1 card in a 46px frame makes the cat about four pixels tall and every theme reads as grey mush.
Measured off a 5–10% grid overlay of each card, as fractions x y w h:
| card | box |
|---|---|
landing-card (grumpy-cat) | 0.00 0.46 0.50 0.50 |
landing-library-card | 0.14 0.40 0.42 0.42 |
landing-bootstrap-card | 0.27 0.615 0.24 0.24 |
landing-operations-card | 0.17 0.475 0.27 0.27 |
landing-engineer-card | 0.00 0.40 0.46 0.46 |
landing-analyst-card | 0.05 0.48 0.36 0.36 |
landing-architecture-card | 0.02 0.38 0.44 0.44 |
Only CARD crops carry one. The avatar is cut from the square crop; a box on the laptop or mobile art would be measured against a composition the frame never shows — and it would validate, because squareness is checkable and relevance is not.
Two of these were wrong twice, and both times only a render caught it. The first pass put the library and analyst boxes on scenery. The operations box was square, in bounds, and clipped the crown off the hard hat; the owner saw it and said so. The schema cannot see a hat. So:
bun run cat avatar:crops # every declared box, drawn on the card and clipped
bun run cat theme:sheet # every theme: palette, contrast, art, both regions
The page ground: the first paint is DARK
The owner, 2026-09-24, verbatim:
“when any page/foio-asst/cat-harness first loads if flashes white before goignt o dark mode. instead it should deafult dark mode then turn to light mode to prevent light flash in dark. update skils”
The rule: the first frame of every published page is dark. A light page becomes light AFTER that, never the other way round, and a reader in dark mode never sees a light frame. A dark frame before light is a blink; a white frame before dark is a flashbulb in a dark room — the asymmetry is the whole reason for the default.
What that takes, on any page that picks its scheme on the client:
- Decide the scheme before any stylesheet paints. An inline, render-
blocking script in
<head>setsdata-fa-schemeon<html>from the reader’s stored choice (fa-color-scheme, the keydocs-ui.jswrites), elseprefers-color-scheme, else dark. On the Jekyll site this is thefa-first-paintblock at the top of_includes/head_custom.html, and it also switches the theme’s stylesheet while the head is still being parsed, so a light reader is light from the first frame too. - Paint
htmldark from the very first CSS (light only when the attribute says light). A page ground that nothing paints is the browser’s WHITE canvas whenever the theme’s sheet is missing — a slow CDN, or a swap. - Never swap a stylesheet the page is already painting with.
jtd.setTheme(name)swaps just-the-docs’ first stylesheet, and a swap UNLOADS the sheet until the new file arrives. Applying a stored “dark” on this dark-configured site swapped-default.cssfor-dark.css— the same colours — on every load: that was the white flash, for every reader who had ever pressed the toggle.applySchemeindocs-ui.jsnow asksjtdShows(name)first.
How it is held: the tools layer’s test/first-paint-scheme.e2e.ts lifts the
shipped snippet out of head_custom.html, holds the deferred bundle, and reads
html’s computed background before the bundle runs; it holds the dark sheet in
flight to prove no swap happens; and it checks a stored light choice ends up
light. A page you add that emits its own <head> is covered by the same spec’s
“generated dashboards” block — add it there.
Pages that switch nothing with script cannot flash, and do not need the
snippet: the generated dashboards (state-visualizer.ts,
gen-translation-status.ts) paint dark from their first CSS, and the viewers
that follow @media (prefers-color-scheme) are decided by the browser before
first paint. Do not give a dashboard the snippet as a tidy-up: it would switch
ON data-fa-scheme="light" rules that were never finished (measured
2026-09-24: 3–6 axe contrast failures per dashboard in light).
Adding a surface
- Find the stage that owns the general rule and read it.
- Add only the difference here.
- If the surface renders art behind text, it inherits
theme-contrastunchanged — a measured scrim, AAA over pure black. - If it renders art at all, render it and LOOK. Nothing in this section was found by reading a diff.
Related
theming— which stage owns your questiontheme-declaration— the rule these specialise
The mark: one geometry, two inks, and the pair is a MEASUREMENT
A mark is not a sticky backdrop — it is a small glyph that sits on the sidebar header, on the page, and in the browser’s tab strip. The rule that makes it different from every other artefact above:
One ink cannot clear 3:1 on both grounds. So a mark is a PAIR, and the second file exists because of a number rather than because somebody wanted a darker one.
Worked, from cat-harness on 2026-09-21 (bean 5r57):
| ink | on white | on the light sidebar #f5f6fa | on the dark sidebar #27262b |
|---|---|---|---|
#596a5b (the supplied art’s own) | 5.77:1 | 5.35:1 | 2.60:1 |
#9db89f (same hue, lifted) | — | — | 7.00:1 |
3:1 is the bar for meaningful non-text content, and the light ink is under it on the dark ground. That is the whole argument for the second file.
Two inks, never two drawings
Both variants are emitted from one trace, differing in a single fill
attribute. They cannot drift, because there is nothing to drift: re-run the
generator and both change together. A second hand-drawn dark mark is the
themes.css-beside-theme.ts failure in a new place.
Not a CSS filter
The obvious cheaper move is one file plus a filter: brightness() under the
dark scheme. Do not: a filter over an arbitrary path is a guess about the
resulting colour, and the entire reason the pair exists is a measured ratio.
You cannot measure what a filter will produce without rendering it, and if you
are rendering it you may as well declare it.
Which surface follows which scheme
They are not the same question and the answer differs per surface:
| surface | follows | why |
|---|---|---|
| sidebar mark | the PAGE’s data-fa-scheme | it sits on the page; both <img>s are emitted and CSS picks one, because Liquid cannot read a client-side attribute |
| browser tab icon | the OPERATING SYSTEM, via media="(prefers-color-scheme: …)" | a <link rel="icon"> is resolved outside the page’s styling and cannot read the DOM — and the tab strip is browser chrome, which follows the OS, so matching the OS is matching what it actually sits on |
A reader who sets the page against their system will see the two disagree.
That is correct, and the note in head_custom.html says so: each matches
its own background, which is the only rule satisfiable everywhere at once.
Absent is a real state
An instance that declares no mark-dark renders its one mark in both
schemes — what every instance did before the role existed. imageForRole
returns undefined and both templates fall back; nothing downstream
special-cases a folio that never opted in.
Tracing supplied art
When the owner hands over a raster mark, trace it rather than approximating it by hand — the mark this replaced was a hand-drawn approximation that differed in kind from the original (a solid silhouette where the art was line work). Three things that cost a cycle each here, worth knowing before the next one:
potracertreats ZERO as foreground, the inverse of what the name suggests. Passing the ink mask directly traces the background.- Crop to square with an explicit canvas, not
Image.croppast the edge: PIL pads with black, and the tracer reads that as ink. - Measure the result. Rasterise the traced path and compare it to the source bitmap — IoU against the original is a fact, “it looks right” over a 2 KB path is not. The mark here shipped at 0.981.
Check the reduction too, rather than assuming a small variant is needed: the
old mark needed one because its whiskers were stroke-width="2.4" on a 120
viewBox, 0.32px at tab size. Fill-only art has no such floor, and the holes
here still survive at 16px (measured ink fraction 0.36, flat from 16 to 32).