# Site Brand — Lucian Labs system C

Not a look. **A whole page wearing any of the five looks**, and changing
between them live, with the widgets on it, under one wipe. Shipped in
elijahlucian.ca.

System A says what a look *is*. System B swaps two of them behind a curved
front. This is the third question: a look names one accent, one gold, a panel,
an ink and a hairline — a site is built on far more than that. What does a
page *read* so that swapping the look re-brands all of it, and what does the
look have to give up to get there?

- Live reference: `https://lucianlabs.ca/branding/site-brand.html`
- Source: `https://lucianlabs.ca/v2/ll-brand.js` (the kit's copy — the catalogue,
  the maths, the wipe)
- Site source: `elijahlucian.ca/web/src/lib/brand/` (`looks.ts`, `derive.ts`,
  `apply.ts`, `useBrand.ts`) and `web/scripts/gen-brand-css.mjs` — where this
  was worked out

## What it is

A **role-token layer** between the look and the page. The look stays the
widgets' 22 tokens (`--bg`, `--accent`, `--radius`, `--font-body`…). From those,
`LLBrand.roles(slug)` derives about seventy-five **roles** — the names a site's
stylesheets read — and `applyToSite()` writes them inline on `<html>` as
`--ll-*`. The site's own tokens read the roles with a fallback:

```css
:root {
  --brand-1: var(--ll-accent, #4fc3f7);
  --panel:   var(--ll-panel, #0a0a0a);
  --glass-8: var(--ll-glass-8, rgba(10, 10, 10, 0.8));
}
```

so a page looks the same with nothing stamped, with the default stamped, and
after the runtime has applied the default. Nothing in the site reads a colour
any other way; that is the same rule as System A's ten roles, with a larger
vocabulary because a page has more jobs than an app panel.

Three things had to be derived rather than copied, and each is a section
below: the **family** (one accent has to become six), the **light-look
rules** (a dark site's black glass, white ink and black shadows all invert),
and the **root wipe** (the page has to change under the same front as the
widgets, without two transitions fighting).

## The role contract

Every role, what it means, and what it resolves to on a dark look (Claudecore)
and a light one (House Pour). Values are what `LLBrand.roles()` returns today;
`LLBrand.css()` prints all five looks.

| Role | Meaning | Claudecore | House Pour |
| --- | --- | --- | --- |
| `--ll-mode` | dark or light — whether --bg reads light (OKLCH L ≥ 0.6) | `dark` | `light` |
| `--ll-accent` | the primary — the look's --accent, lifted until it reads on --bg | `#4fc3f7` | `#a4813b` |
| `--ll-accent-dark` | accent pressed (L −0.10) | `#24a3d5` | `#856318` |
| `--ll-accent-light` | accent hover (L +0.08) | `#90daff` | `#bd9954` |
| `--ll-brand-2` | accent +30° — the neighbour; scrollbar hover, gradients, not a dock | `#8bb5ff` | `#888d43` |
| `--ll-brand-3` | accent +144° — a dock hue (the site's labs / stream) | `#fb929b` | `#3893b4` |
| `--ll-brand-7` | accent −144° — a dock hue (database) | `#c0ba51` | `#8f7aba` |
| `--ll-brand-7-dark` | brand-7 pressed (L −0.10) | `#a19a2d` | `#725d9a` |
| `--ll-brand-8` | accent +72° — a dock hue (live chat) | `#c5a0f7` | `#4a8f63` |
| `--ll-spaces` | accent −72° — a dock hue (thoughts) | `#64ce98` | `#b86d83` |
| `--ll-spaces-dark` | spaces pressed (L −0.06) | `#4fbb86` | `#a45b71` |
| `--ll-spaces-light` | spaces hover (L +0.10) | `#86efb7` | `#d98ba1` |
| `--ll-gold-1` | gold three steps from the bg | `#ffe072` | `#8c551f` |
| `--ll-gold-2` | gold two steps from the bg | `#ffcd56` | `#9c5e34` |
| `--ll-gold-3` | gold one step from the bg | `#ffb939` | `#ab6747` |
| `--ll-gold-4` | the sell — the look's --accent-2, or accent −45° when accent-2 is the accent again | `#ffa40a` | `#ba715b` |
| `--ll-on-accent` | ink ON an accent fill, picked by contrast against the fill, not by mode | `#060a10` | `#262319` |
| `--ll-on-gold` | ink ON a gold fill | `#060a10` | `#262319` |
| `--ll-ok` | status green at the accent's lightness | `#3ed577` | `#00984b` |
| `--ll-warn` | status amber | `#d9b000` | `#a38300` |
| `--ll-err` | status red | `#ff918e` | `#d95557` |
| `--ll-info` | status blue | `#80b8ff` | `#4488db` |
| `--ll-on-ok` | ink on an ok fill | `#060a10` | `#262319` |
| `--ll-on-warn` | ink on a warn fill | `#060a10` | `#262319` |
| `--ll-on-err` | ink on an err fill | `#060a10` | `#262319` |
| `--ll-on-info` | ink on an info fill | `#060a10` | `#262319` |
| `--ll-bg` | page ground ← --bg | `#060a10` | `#f1eee6` |
| `--ll-panel` | panel ← --panel | `#0a0a0a` | `#fffefa` |
| `--ll-ink` | primary ink ← --ink | `#dfe8f2` | `#262319` |
| `--ll-muted` | secondary ink ← --muted | `#9fb2c8` | `#71695a` |
| `--ll-faint` | tertiary ink ← --faint | `#7d8fa5` | `#a79f8d` |
| `--ll-label` | label ink ← --label | `#5d8fc9` | `#71695a` |
| `--ll-line` | every boundary ← --line | `#2a4a70` | `rgba(38, 35, 25, 0.2)` |
| `--ll-orb` | the circle ← --orb | `#397fd9` | `#a4813b` |
| `--ll-shade` | the drop-shadow base — #000 on dark looks, the ink at 30% on light ones | `#000000` | `rgba(38, 35, 25, 0.3)` |
| `--ll-look-shadow` | the look's own --shadow, verbatim | `0 4px 20px rgba(0,0,0,0.6), 0 0 14px …` | `0 1px 2px rgba(38,35,25,.04), 0 14px …` |
| `--ll-radius` | ← --radius | `8px` | `10px` |
| `--ll-radius-btn` | ← --radius-btn | `4px` | `14px` |
| `--ll-border-w` | the look's --border, verbatim — a floor is a host rule (elijahlucian.ca: `max(2px, var(--ll-border-w))`) | `1.5px` | `1px` |
| `--ll-font-display` | ← --font-display | `'Finlandica', system-ui, sans-serif` | `'Cormorant Garamond', Georgia, serif` |
| `--ll-font-label` | ← --font-label | `'Finlandica', system-ui, sans-serif` | `'Avenir Next', Avenir, -apple-system,…` |
| `--ll-font-body` | ← --font-body | `'Finlandica', system-ui, sans-serif` | `'Avenir Next', Avenir, -apple-system,…` |
| `--ll-font-num` | ← --font-num | `'Space Mono', 'Inconsolata', monospace` | `'Inconsolata', 'IBM Plex Mono', monos…` |
| `--ll-tracking` | ← --tracking | `0.14em` | `0.2em` |
| `--ll-glass-1` | panel at 10% — a translucent fill over a backdrop | `rgba(10, 10, 10, 0.1)` | `rgba(255, 254, 250, 0.1)` |
| `--ll-glass-2` | panel at 20% — a translucent fill over a backdrop | `rgba(10, 10, 10, 0.2)` | `rgba(255, 254, 250, 0.2)` |
| `--ll-glass-3` | panel at 30% — a translucent fill over a backdrop | `rgba(10, 10, 10, 0.3)` | `rgba(255, 254, 250, 0.3)` |
| `--ll-glass-4` | panel at 40% — a translucent fill over a backdrop | `rgba(10, 10, 10, 0.4)` | `rgba(255, 254, 250, 0.4)` |
| `--ll-glass-5` | panel at 50% — a translucent fill over a backdrop | `rgba(10, 10, 10, 0.5)` | `rgba(255, 254, 250, 0.5)` |
| `--ll-glass-6` | panel at 60% — a translucent fill over a backdrop | `rgba(10, 10, 10, 0.6)` | `rgba(255, 254, 250, 0.6)` |
| `--ll-glass-7` | panel at 70% — a translucent fill over a backdrop | `rgba(10, 10, 10, 0.7)` | `rgba(255, 254, 250, 0.7)` |
| `--ll-glass-8` | panel at 80% — a translucent fill over a backdrop | `rgba(10, 10, 10, 0.8)` | `rgba(255, 254, 250, 0.8)` |
| `--ll-glass-9` | panel at 90% — a translucent fill over a backdrop | `rgba(10, 10, 10, 0.9)` | `rgba(255, 254, 250, 0.9)` |
| `--ll-glass-panel` | panel at 94% — a dock or modal fill | `rgba(10, 10, 10, 0.94)` | `rgba(255, 254, 250, 0.94)` |
| `--ll-ink-1` | ink at 10% — text, icons, borders at alpha | `rgba(223, 232, 242, 0.1)` | `rgba(38, 35, 25, 0.1)` |
| `--ll-ink-2` | ink at 20% — text, icons, borders at alpha | `rgba(223, 232, 242, 0.2)` | `rgba(38, 35, 25, 0.2)` |
| `--ll-ink-3` | ink at 30% — text, icons, borders at alpha | `rgba(223, 232, 242, 0.3)` | `rgba(38, 35, 25, 0.3)` |
| `--ll-ink-4` | ink at 40% — text, icons, borders at alpha | `rgba(223, 232, 242, 0.4)` | `rgba(38, 35, 25, 0.4)` |
| `--ll-ink-5` | ink at 50% — text, icons, borders at alpha | `rgba(223, 232, 242, 0.5)` | `rgba(38, 35, 25, 0.5)` |
| `--ll-ink-6` | ink at 60% — text, icons, borders at alpha | `rgba(223, 232, 242, 0.6)` | `rgba(38, 35, 25, 0.6)` |
| `--ll-ink-7` | ink at 70% — text, icons, borders at alpha | `rgba(223, 232, 242, 0.7)` | `rgba(38, 35, 25, 0.7)` |
| `--ll-ink-8` | ink at 80% — text, icons, borders at alpha | `rgba(223, 232, 242, 0.8)` | `rgba(38, 35, 25, 0.8)` |
| `--ll-ink-9` | ink at 90% — text, icons, borders at alpha | `rgba(223, 232, 242, 0.9)` | `rgba(38, 35, 25, 0.9)` |
| `--ll-gloss-1` | ink at 6% — the inset lit top edge | `rgba(223, 232, 242, 0.06)` | `rgba(38, 35, 25, 0.06)` |
| `--ll-gloss-2` | ink at 10% — the inset lit top edge | `rgba(223, 232, 242, 0.1)` | `rgba(38, 35, 25, 0.1)` |
| `--ll-gloss-3` | ink at 14% — the inset lit top edge | `rgba(223, 232, 242, 0.14)` | `rgba(38, 35, 25, 0.14)` |
| `--ll-shade-1` | shade at 10% — drop-shadow colour only, never a fill | `rgba(0, 0, 0, 0.1)` | `rgba(38, 35, 25, 0.03)` |
| `--ll-shade-2` | shade at 20% — drop-shadow colour only, never a fill | `rgba(0, 0, 0, 0.2)` | `rgba(38, 35, 25, 0.06)` |
| `--ll-shade-3` | shade at 30% — drop-shadow colour only, never a fill | `rgba(0, 0, 0, 0.3)` | `rgba(38, 35, 25, 0.09)` |
| `--ll-shade-4` | shade at 40% — drop-shadow colour only, never a fill | `rgba(0, 0, 0, 0.4)` | `rgba(38, 35, 25, 0.12)` |
| `--ll-shade-5` | shade at 50% — drop-shadow colour only, never a fill | `rgba(0, 0, 0, 0.5)` | `rgba(38, 35, 25, 0.15)` |
| `--ll-shade-6` | shade at 60% — drop-shadow colour only, never a fill | `rgba(0, 0, 0, 0.6)` | `rgba(38, 35, 25, 0.18)` |
| `--ll-shade-7` | shade at 70% — drop-shadow colour only, never a fill | `rgba(0, 0, 0, 0.7)` | `rgba(38, 35, 25, 0.21)` |
| `--ll-juice-ink` | = --ll-on-accent — what the motion system sets on an accent fill | `#060a10` | `#262319` |
| `--ll-juice-fill` | = --ll-glass-6 — the motion system's fill | `rgba(10, 10, 10, 0.6)` | `rgba(255, 254, 250, 0.6)` |

Two attributes ride along: `html[data-ll-look="<slug>"]` names the look and
`html[data-ll-mode="dark|light"]` says whether the ground is light, for the rare
rule that has to know (a `color-scheme` swap, a photo treatment).

Two things to know about the values. The ladders are computed, not `color-mix`ed
on the page — so a component that overrides `--ll-panel` locally does not get a
re-derived glass; it gets the look's. And not every value is a hex: House
Pour's `--line` is an `rgba()` string, and the shadows are full shorthand.
Read roles, never parse them.

## Adopting it

Three pieces, in load order. The first cannot be a script tag.

### 1. The head snippet — before any stylesheet

`window.LLBrand` exists once `ll-brand.js` has evaluated, which on a real page
is after first paint. A returning "console" visitor would see the default look
paint, then snap. So the stamp has to come from an inline script that runs
before the stylesheets — copy this into `<head>`, above the first `<link>`:

```html
<meta name="theme-color" content="#4fc3f7" />
<!-- The look, before any stylesheet. Mirrors the catalogue in
     https://lucianlabs.ca/v2/ll-brand.js — the font strings must match it
     exactly, or the widgets load every face a second time. -->
<script>
  ;(function () {
    var LOOKS = {
      claudecore:    { fonts: 'Finlandica:wght@300;400;700&family=Space+Mono:wght@400;700', accent: '#4fc3f7', mode: 'dark' },
      console:       { fonts: 'Micro+5&family=Inconsolata:wght@400;700', accent: '#397fd9', mode: 'dark' },
      'house-pour':  { fonts: 'Cormorant+Garamond:wght@500;600;700&family=Kaushan+Script&family=Inconsolata:wght@400;700', accent: '#a4813b', mode: 'light' },
      'punch-clock': { fonts: 'Micro+5&family=Finlandica:wght@400;700&family=Inconsolata:wght@400;700', accent: '#3fcc60', mode: 'dark' },
      'field-notes': { fonts: 'Exo+2:wght@500;700&family=Source+Serif+4:wght@400;600&family=Fira+Code:wght@400;500', accent: '#e8a020', mode: 'dark' },
    }
    var slug = 'claudecore'
    try {
      var stored = localStorage.getItem('ll-look')
      if (stored && LOOKS[stored]) slug = stored
    } catch (e) {
      // storage off — the default look is the right answer
    }
    var look = LOOKS[slug]
    var root = document.documentElement
    root.setAttribute('data-ll-look', slug)
    root.setAttribute('data-ll-mode', look.mode)
    var meta = document.querySelector('meta[name="theme-color"]')
    if (meta) meta.setAttribute('content', look.accent)
    // the same registry ll-shop.js and ll-brand.js keep, so neither adds a second <link>
    var loaded = (window.__llFonts = window.__llFonts || new Set())
    if (!loaded.has(slug)) {
      loaded.add(slug)
      var link = document.createElement('link')
      link.rel = 'stylesheet'
      link.href = 'https://fonts.googleapis.com/css2?family=' + look.fonts + '&display=swap'
      document.head.appendChild(link)
    }
  })()
</script>
```

The stamp only means something if a stylesheet already knows every look. Build
one once and ship it:

```sh
node --input-type=module -e "await import('./v2/ll-brand.js'); process.stdout.write(LLBrand.css())" > brand.css
```

That is `:root { …claudecore roles… }` followed by one
`html[data-ll-look="<slug>"] { … }` block per look — the same `roles()` the
runtime uses, so build-time and runtime cannot disagree. Link it before your own
stylesheets. (elijahlucian.ca generates a smaller set — the 44 site roles it
consumes — from a local mirror inside `npm run build`; `css()` emits all 75.)

### 2. The script

```html
<script src="https://lucianlabs.ca/v2/ll-brand.js"></script>
```

Straight from lucianlabs.ca, not the CDN; there is no v1. Load order against
`ll-shop.js` does not matter: whichever evaluates first creates
`window.LLBrand`, and this file upgrades the shop's object in place rather than
replacing it, because the dock may already be holding the reference.

### 3. `follow()`

```js
LLBrand.follow()
```

Boots the page into `LLBrand.current ?? 'claudecore'` and subscribes to
`ll-look` on the document, so a pip in the shop, a pip in the dock, or any
`LLBrand.set()` re-brands the page. Returns an unsubscribe. While it is active
the page carries `html[data-ll-wipe-root="1"]` — the flag the shop reads to
sweep the root (below).

Call it at load, not on an event: `<ll-shop>` is created lazily by the dock
when the visitor opens it, so on a plain load **no `ll-look` ever fires**. A
page that waits to be told which look it is wearing waits forever.

To drive it:

```js
LLBrand.set('house-pour')                     // the root wipe — page and widgets, one front
LLBrand.set('console', { wipe: 'groups' })    // widgets only, the page holds
LLBrand.set('console', { wipe: 'none' })      // just apply
```

## The family: one accent becomes six

`brands.json` bans "more than one accent hue per view", and every look obeys
it: one voice, one sell, everything else neutral. A host page is the sanctioned
exception, and the reason is already in the kit. `claudecore.md` records where
the look came from — the machine on one side and elijahlucian.ca's widgets on
the other — and lists the widgets' **voice colour** as "per-widget: pink, blue,
teal, green". That spread is not decoration. It is how a visitor tells the labs
dock from the database dock from the live chat at a glance, with six of them
floating over one page. Collapse them to one hue and the docks become one
undifferentiated stack.

So a look cannot simply replace the primary and leave the rest pink. It has to
*supply* the family. `roles()` does it by rotation:

| Role | Where it sits | Job on the site |
| --- | --- | --- |
| `--ll-accent` | the look's `--accent` | the primary — nav, links, focus |
| `--ll-gold-4` | the look's `--accent-2` | the sell — CTAs, the dankvision dock |
| `--ll-brand-2` | accent +30° | the neighbour — scrollbar hover, gradients; not a dock |
| `--ll-brand-8` | accent +72° | live chat |
| `--ll-brand-3` | accent +144° | labs / stream |
| `--ll-brand-7` | accent −144° | database |
| `--ll-spaces` | accent −72° | thoughts |

Four rules make the rotation hold:

1. **Rotate in OKLCH, never HSL.** A 120° turn in HSL from a bright pink lands
   on a green that reads twice as bright; the family would not sit at one
   visual weight. OKLCH keeps L and C fixed while h moves, which is the whole
   point of deriving a family. `ll-brand.js` carries the conversion by hand —
   the kit has no dependencies and a look change is the only caller.
2. **Walk chroma into gamut, never clip.** A saturated rotated orange that is
   clipped per channel becomes a yellow. Out-of-gamut colours have their chroma
   binary-searched down at fixed L and h instead, so the hue survives.
3. **Clamp for legibility.** Each derived hue is pushed away from `--bg`, 0.03 L
   at a time, until it reads at 3:1 — the WCAG non-text floor, and the docks are
   borders and 2px rules, exactly that class. On a light look "away" is darker;
   on a dark look it is lighter.
4. **Dodge the gold.** A derived hue within 28° of `--accent-2` is nudged 36°
   past it, so no two docks share a colour. And when a look names the same
   colour twice (House Pour's `--accent-2` *is* its `--accent`), the gold turns
   45° toward the warm side instead of coinciding with the nav.

The lighter golds (`--ll-gold-3/2/1`) step *away from the background*, not
upward — on a light look "lighter" is the direction that disappears.

Status (`--ll-ok/warn/err/info`) is not part of the family. It is a fixed
vocabulary — green, amber, red, blue on every look, or a health dot lies the
moment a look's accent lands on red. Each seed keeps its own hue and chroma,
takes the accent's lightness so it sits at the look's weight, then gets the
same clamp.

## Light looks: glass, gloss, shade, and the ink that flips

A site built dark for years accumulates literals that only work dark:
`rgba(0,0,0,.6)` panels, `rgba(255,255,255,.1)` hairlines, `#000` shadows,
white text on the accent. Switch the look to House Pour and every one of them
is wrong at once — black glass over ivory reads as soot, white hairlines vanish,
a `.6` black shadow under an off-white card reads as a hole. Four rules replace
the literals with roles:

- **Glass is the panel at alpha.** `--ll-glass-1…9` is `--panel` at 10–90%,
  `--ll-glass-panel` at 94%. Black on the dark looks, warm off-white on House
  Pour. Every `rgba(0,0,0,x)` *background* — including the second argument of a
  `color-mix` — becomes a glass step.
- **Ink at alpha is the ink.** `--ll-ink-1…9` is `--ink` at 10–90%. Every
  `rgba(255,255,255,x)` on text, an icon or a border becomes an ink step.
- **Gloss is the ink side; shade is the ground side.** `--ll-gloss-1/2/3` (ink at
  6/10/14%) is the inset lit top edge; `--ll-shade-1…7` is the drop-shadow
  colour, `#000` at alpha on dark looks and the ink at 30% × alpha on light ones
  — House Pour's own `--shadow` is exactly that recipe, so the same box-shadow
  lands faint there rather than as a hole. Shade is never a fill.
- **Ink on a fill follows the fill, not the mode.** `--ll-on-accent` and
  `--ll-on-gold` are whichever of `--bg` and `--ink` contrasts better *against
  the accent*. Claudecore's sky blue takes dark ink; Console's deeper blue takes
  light ink; House Pour's champagne takes dark. The motion system's `--ll-juice-ink`
  is this value, which is why a button's label survives a look change without
  a `[data-ll-mode]` rule anywhere.

The failure all four prevent is the same one: a light look that is technically
applied and visibly broken, with nothing wrong in the palette.

## The root wipe

System B's wipe, with the root let loose. The shop's own wipe pins
`::view-transition-old(root)` and `::view-transition-new(root)` to `animation:
none` so only its widget groups sweep and the host page keeps rendering live
underneath. Once the page changes *with* the look, that pin is backwards: the
page must be cut by the same front as the widgets — one angle, one curve, one
clock — or it snaps while they sweep.

`LLBrand.wipe(apply, { root: true })` builds the same keyframes the shop does
(the polygon math is copied verbatim — this file must work on a page that never
loads the shop, and the shop must keep working alone) and puts `root` in the
`new` selector alongside `ll-shop`, `ll-radio`, `ll-clock`, `ll-hire` and
`ll-dock`. Timing is UX.md's **move** — 600ms on the emphasised curve, the
duration for a screen changing identity. The browser's own crossfade adds a
`plus-lighter` blend to old and new as an animation; replacing the animation
removes it, and the wipe says `mix-blend-mode: normal` anyway.

Three things to copy verbatim:

1. **Freeze, then reveal.** `html[data-ll-wiping="1"]` sets `transition: none
   !important` on everything for the life of the transition. The front must
   reveal a formed look; a page tweening underneath it reads as a glitch.
2. **Ticket the cleanup.** A second `set()` aborts the first transition, whose
   `finished` still settles — while the second front is mid-sweep. Only the
   newest wipe may lift the freeze.
3. **Degrade to the tween.** No View Transitions, or reduced motion, and the
   same change morphs over 600ms under `html[data-ll-morph="1"]` — the other
   animation, never nothing.

### The coordination contract

Two files can start a transition, and a second `startViewTransition` aborts the
first. The rules:

- **`LLBrand.set()` owns the transition when it is the caller.** Inside the
  callback it applies the roles, *then* dispatches `ll-look` with
  `source: 'll-brand'`. Every widget's listener treats a non-`'api'` source as
  "just apply" — the shop re-skins, the dock re-tints, nobody starts a second
  wipe. They are all painted before the new snapshot is taken.
- **The shop owns it when a pip is clicked.** The shop starts its own
  transition and broadcasts from inside its callback. `follow()`'s listener
  runs synchronously there and applies the page, so the page is in the same
  snapshot. For the root to sweep too, the shop's `llBuildWipe` reads
  `html[data-ll-wipe-root="1"]` — set by `follow()`, cleared by its unsubscribe
  — and, when it is there, adds `root` to its `new` selector and drops the pin.
  Explicitly, in the shop's source; not with `!important` from outside.
- **Tweens freeze for the duration.** A site-wide `transition: all` would show
  the new snapshot mid-morph. `html[data-ll-wiping="1"]` kills every transition
  while a wipe runs; it comes off in `finished`, not on a timer. For a wipe the
  shop started, the page has no promise to hang on, so it freezes for 800ms —
  a hair past the shop's 0.7s.
- **Aborted transitions are swallowed.** A hidden tab or a second swap rejects
  `ready` and `updateCallbackDone`; both are caught, so a normal user action
  never logs an error.

## Reduced motion

No View Transitions, or `prefers-reduced-motion: reduce`, and the swap
degrades to the **other** animation, never to nothing (skin-swap.md's first
rule). `html[data-ll-morph="1"]` hands every element a 600ms colour tween —
background, colour, border, outline, box-shadow, fill, stroke — for the
duration of the change, then comes off. UX.md's reduced-motion line is "colour
and opacity stay; transforms and loops drop", and this is only colour. A host
with its own tweens wins: the morph rule is written with `:where()`, so it has
zero specificity and any host rule — even a bare element selector — outranks it.
(The freeze during a real wipe is the opposite: `!important`, on purpose.)

## What we learned

The findings from re-branding elijahlucian.ca, each of which cost a build:

- **First paint flashes without a head script.** The widgets are `defer`
  scripts that evaluate after React has painted; the stored look is only in
  `localStorage`; nothing else knows it. The inline stamp plus a generated
  per-look stylesheet is the only flash-free path. A script tag is too late.
- **The lazy shop fires no event on load.** `<ll-shop>` is created by the dock
  on demand. A page that waits for `ll-look` to learn its look never learns it.
  Boot from `LLBrand.current` (at `load`, since the widgets are deferred), then
  listen.
- **The catalogue is not fetchable cross-origin.** `brands.json` is served as
  JSON without CORS, and every `/v2/*.json` is the SPA fallback. A host site
  cannot ask the kit what a look is at runtime; it carries a copy, or it loads
  `ll-brand.js` and asks `LLBrand`. That is why the catalogue now lives in one
  JS file and `brands.json` is written *from* it.
- **Black-glass literals are the bulk of the work.** Not the palette — the
  hundreds of `rgba(0,0,0,x)` and `rgba(255,255,255,x)` a dark site collects.
  The glass and ink ladders exist so each one is a one-token replacement, and
  so the `color-mix` second argument (the most-missed case) has a name too.
- **Juice ink flips on the accent, not the mode.** A light look with a dark
  accent and a dark look with a light accent both exist. Deciding the button
  label by `data-ll-mode` gets one of them wrong; deciding by contrast against
  the fill gets both right.
- **Cascade ties are decided by order, and inline wins.** The generated
  `html[data-ll-look]` blocks and the site's `:root` fallbacks both apply; the
  runtime's inline `style` on `<html>` beats both. So build-time and runtime
  can both be present and the runtime always wins — which is only safe because
  they are the same function.

## Porting this

You need: components that read tokens with a fallback, an inline stamp in the
head, a generated stylesheet keyed on that stamp, and `follow()`. Then:

- **Name roles by job, not by hue.** `--ll-brand-3` is "the labs dock", not
  "pink". The moment a name says pink, a look that makes it teal is a bug
  instead of a brand.
- **Derive, never enumerate.** Five looks × seventy-five roles is a table
  nobody maintains. One function, run twice — at build and at runtime.
- **Give every dark literal a light answer before you ship a light look.**
  Glass, ink, gloss, shade, on-fill — grep for `rgba(0,0,0`, `rgba(255,255,255`,
  `#000` and `#fff` outside the token block and expect the count to reach zero.
- **Un-pin the root only when the page actually changes.** A widget-only page
  keeps the shop's pin; a branded page lifts it through the flag. Two
  transitions on one page is always wrong.
- **Keep the site's rules out of the kit.** The 2px border floor, the six
  named docks and the legacy aliases (`--trans-black`, `--text-color-2`) are
  elijahlucian.ca's. The kit derives the roles; what a site calls them is the
  site's business.
