Lucian Labs system B — two universes, one DOM, and the wipe between them. Shipped in mood.
The stage below is the real thing: two token blocks on one selector axis, swapped behind mood's actual wipe algorithm. Every swap randomises the angle, the curve depth and the wave count — press it a few times and no two are alike.
Watch the order: the new skin wipes in behind a curved front, the wordmark blurs through its font change, then the meter fills from zero. It reads as booting into the other mode, not repainting.
Mood's husband mode isn't like House Pour — it is the same palette and the same faces, lifted directly. That's the proof: a house look is portable enough to become one mode of an unrelated app.
| body.wave | body.pour | |
|---|---|---|
| descends from | Console (WaveLoop) | House Pour (DrinkTix) |
| ground | #101014 | #f7f5ef |
| panel | #11141e | #fffefa |
| ink | #eaecf2 | #262319 |
| accent | #ffa40a | #a4813b |
| 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 |
var(--bg), var(--panel), var(--ink), var(--line), var(--gold), var(--r), var(--head), var(--body). Switching is a class change and nothing else.color-scheme follows the skin. html:has(body.wave) is dark, html:has(body.pour) is light — so form controls and scrollbars flip too, instead of staying stuck in the old universe.#1eff00, #c06aff) and muted-heraldic in pour (#3c7a52, #8f6fc7). Same roles, different dialect. A token that keeps its exact value across both skins probably shouldn't be themed.
buildWipeKeyframes() regenerates a @keyframes
wipe-dyn per swap: pick an angle, curve depth (5–16), wave
count (1–3.5) and phase; sample 16 points along a front offset by
amp · sin(…); close the polygon 500 units behind so the
revealed side is solid; emit three clip-path frames at
sweep −260 / 0 / +260, advancing the phase 1.4 each step so the
curve travels instead of sliding rigidly.
Colours and radii normally tween over 0.6s so a skin change morphs.
During a wipe that's wrong — you'd reveal a half-transitioned skin.
no-tween kills every transition for the duration and
comes off in vt.finished. Those 0.6s tweens still earn
their keep: they're the fallback.
The wordmark changes face — Micro 5 to Kaushan Script — which no
transition can interpolate. skinfade blurs it out and
back over 0.6s (opacity: 0 + blur(2px) at
45%). A font swap that cuts looks like a bug; blurred through the
middle it looks deliberate.
zeroFill() snaps the meter to 0% inside the
view-transition callback with transitions off, so the snapshot the
wipe reveals shows an empty bar. When vt.finished
resolves, it sweeps up to the real value over 0.85s. The arrival is
the payoff.
withWipe(apply) takes the mutation as a callback, so the
same choreography serves the mode toggle and the language picker.
Whenever the whole screen changes meaning, it wipes.
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()
}
}
vt.ready and vt.updateCallbackDone reject if the tab hides mid-swap. Unhandled, that's a console error on a perfectly normal user action.finished, not a timer. The freeze comes off when the transition actually ends.You need two token blocks on one selector axis, components that only ever read tokens, and a single function that owns the swap. Then: