# Skin Swap — Lucian Labs system B

Two complete visual universes in one DOM, and the transition that gets you
between them. Shipped in mood (`mood.lucianlabs.ca`).

This is the proof of the whole brand kit's central claim — that components
written against token *names* can change brand by changing values alone. Mood
does it live, mid-session, without re-rendering the app.

- Live reference: `https://lucianlabs.ca/branding/skin-swap.html`
- Source: `mood/wifemood-src.html` (single file — skin tokens near the top,
  `withWipe` / `buildWipeKeyframes` near the bottom)

## The two skins

Mood is a satirical relationship standing-meter with two modes, and each mode
gets its own universe:

| | `body.wave` — wife mode | `body.pour` — husband mode |
| --- | --- | --- |
| Descends from | Console (WaveLoop) | **House Pour (DrinkTix)** |
| Ground | `#101014` | `#f7f5ef` |
| Panel | `#11141e` | `#fffefa` |
| Ink | `#eaecf2` | `#262319` |
| Accent | `#ffa40a` amber | `#a4813b` champagne |
| Radius | `0` | `14px` |
| Display | Micro 5 | Cormorant Garamond |
| Wordmark | Micro 5 | Kaushan Script |
| Body | Finlandica | Avenir Next |
| Reads as | MMO raid HUD | a printed card |

The `pour` skin is not *like* House Pour — it is the same palette and the same
faces, lifted directly. That's the point: a house look is portable enough to
become one mode of an unrelated app.

## How it's wired

Both skins are `:root` / `body.pour` token blocks in one stylesheet. Every
component reads `var(--bg)`, `var(--panel)`, `var(--ink)`, `var(--line)`,
`var(--gold)`, `var(--r)`, `var(--head)`, `var(--body)`. Switching mode is a
class change on `<body>` — nothing else.

Two details make it hold together:

- **`color-scheme` follows the skin.** `html:has(body.wave)` sets
  `color-scheme: dark`, `html:has(body.pour)` sets `light`. Form controls and
  scrollbars flip with the skin instead of staying stuck in the old universe.
- **Semantic ramps re-map, they don't survive.** The rarity scale
  (`--common` → `--legendary`) goes MMO-neon in wave (`#1eff00`, `#c06aff`) and
  muted-heraldic in pour (`#3c7a52`, `#8f6fc7`). Same roles, different dialect.
  A token that keeps its exact value across both skins is a token that probably
  shouldn't be themed.

## The transition

A naive class swap flashes. Mood does four things instead.

### 1. A curved wipe at a random angle

The new skin is revealed behind a **sinusoidal front swept across the viewport
at a freshly randomised angle**, so no two swaps look alike.

`buildWipeKeyframes()` regenerates a `@keyframes wipe-dyn` rule per swap:

- Pick `theta` (angle, 0–2π), `amp` (curve depth, 5–16), `freq` (1–3.5 waves
  along the front), `phase` (0–2π).
- Build a unit vector `(dx, dy)` for the sweep direction and its normal
  `(nx, ny)` for the front's length.
- Sample 16 points along the front, offsetting each by
  `amp · sin((t/SPAN) · freq · 2π + phase)` — that's the curve.
- Close the polygon 500 units behind the front so the revealed side is solid.
- Emit three `clip-path: polygon(…)` frames at sweep positions −260, 0, +260,
  advancing the phase by 1.4 each step so the curve *travels* rather than
  sliding rigidly.

Driven through the View Transitions API: `::view-transition-old(root)` gets
`animation: none` (the old skin just holds), and `::view-transition-new(root)`
runs `wipe-dyn` for 0.7s on `cubic-bezier(0.4, 0, 0.2, 1)`.

### 2. Freeze the tweens during the swap

Colours, borders and radii normally tween over 0.6s so a skin change *morphs*.
During a wipe that's wrong — you'd wipe in a half-transitioned skin. So
`body.no-tween` kills every transition for the duration, and the class comes off
in `vt.finished`. **The wipe reveals a fully-formed skin, not one in motion.**

Those 0.6s tweens still earn their keep: they're the fallback, and they're what
runs for any change too small to deserve a wipe.

### 3. Crossfade the wordmark

The brand mark changes *face* (Micro 5 ↔ Kaushan Script), which can't tween. So
`skinfade` blurs it out and back over 0.6s — `opacity: 0` plus `blur(2px)` at
45%. A font swap that just cuts looks like a bug; blurred through the middle it
looks intentional.

### 4. Land on an empty meter and fill

`zeroFill()` snaps the meter to 0% *inside* the view-transition callback, with
transitions off, so the snapshot the wipe reveals shows an empty bar. Once
`vt.finished` resolves, `animateFillIn()` sweeps it up to the real value over
0.85s on `cubic-bezier(0.22, 1, 0.36, 1)`.

The arrival is the payoff: the new universe wipes in, then its meter fills. It
reads as the app *booting into* the other mode rather than repainting.

## The path is shared

`withWipe(apply)` takes the mutation as a callback, so the same choreography
serves the mode toggle *and* the language picker. One transition, reused —
whenever the whole screen changes meaning, it wipes.

```js
function withWipe(apply) {
  var reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
  if (document.startViewTransition && !reduced) {
    buildWipeKeyframes()
    document.body.classList.add('no-tween')
    var vt = document.startViewTransition(apply)
    // aborted transitions (hidden tab) are harmless — swallow them
    vt.finished.catch(noop).then(function () {
      document.body.classList.remove('no-tween')
      animateFillIn(); sketchIn()
    })
  } else {
    // no View Transitions, or reduced motion: crossfade the wordmark,
    // let the 0.6s token tweens do the morph, still fill on land
    header.classList.add('skin-swap')
    apply(); animateFillIn(); sketchIn()
  }
}
```

Three things to copy verbatim:

1. **Degrade to the tween.** No View Transitions support, or
   `prefers-reduced-motion`, and the swap still works — it morphs over 0.6s
   instead of wiping. The fallback is not "no animation", it's the *other*
   animation.
2. **Swallow aborted transitions.** `vt.ready` and `vt.updateCallbackDone`
   reject if the tab hides mid-swap. Unhandled, that's a console error on a
   perfectly normal user action.
3. **Cleanup lives in `finished`, not a timer.** The `no-tween` class comes off
   when the transition actually ends.

## Porting this

You need: two token blocks on one selector axis, components that only ever read
tokens, and a single function that owns the swap. Then:

- Wipe when the screen changes **meaning** (mode, language, identity). Don't
  wipe for a filter or a sort — that's what the 0.6s tweens are for.
- Freeze tweens during the wipe. Always.
- Give anything that can't tween — a font swap, an icon set — its own crossfade.
- Re-animate one signature element on arrival, so landing feels like arriving.
- Randomise the wipe. The first swap is a nice transition; the fifth identical
  swap is wallpaper. Randomising angle, curve depth and frequency keeps it alive
  across a session without any extra design work.
