feat(app): hide the new session pin board behind a pull-the-pin easter egg (#57575)

This commit is contained in:
Luke Parker
2026-10-21 10:52:58 +10:00
committed by GitHub
Unverified
parent 89067305c1
commit e460997f81
6 changed files with 1165 additions and 70 deletions
+127 -38
View File
@@ -1,4 +1,4 @@
import { onCleanup, onMount } from "solid-js"
import { createEffect, onCleanup, onMount } from "solid-js"
import { createQuads, type Quads, type Rgba } from "./pin-quads"
import { PinSound } from "./pin-sound"
import { prefersReducedMotion } from "@opencode/ui/motion"
@@ -28,6 +28,8 @@ const AIR = 1
const REST = 2
const RETURN = 3
export type PinEntrance = "sweep" | "chain"
const REST_TILT = [0.42, 0.7] as const
const LIGHT = [0.55 / Math.hypot(0.55, 0.83), -0.83 / Math.hypot(0.55, 0.83)] as const
const SHADES = 16
@@ -64,6 +66,7 @@ type Pin = {
duration: number
lift: number
fd: number
bd: number
}
/**
@@ -71,11 +74,22 @@ type Pin = {
* A click or drag knocks pins out; they pop toward the viewer, pile up on the composer,
* then slot back in. The animation loop only runs while something is moving.
*/
export function NewSessionPinBoard(props: { logo: HTMLElement; sound: () => boolean }) {
export function NewSessionPinBoard(props: {
logo: HTMLElement
sound: () => boolean
entrance?: PinEntrance
closing?: boolean
}) {
let back!: HTMLCanvasElement
let front!: HTMLCanvasElement
onMount(() => onCleanup(mountPinBoard(back, front, props.logo, props.sound)))
onMount(() => {
const board = mountPinBoard(back, front, props.logo, props.sound, props.entrance)
createEffect(() => {
if (props.closing) board.close()
})
onCleanup(board.dispose)
})
return (
<>
@@ -95,7 +109,13 @@ export function NewSessionPinBoard(props: { logo: HTMLElement; sound: () => bool
)
}
function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo: HTMLElement, sound: () => boolean) {
function mountPinBoard(
back: HTMLCanvasElement,
front: HTMLCanvasElement,
logo: HTMLElement,
sound: () => boolean,
entrance: PinEntrance = "sweep",
) {
const stage = back.parentElement!
// The pins behind the composer and the ones popped in front of it are each one layer of quads.
const layers = { back: createQuads(back, () => wake()), front: createQuads(front, () => wake()) }
@@ -125,7 +145,7 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo:
const pointer = { x: 0, y: 0, lx: 0, ly: 0, vx: 0, vy: 0, inside: false, down: false, dragX: 0, dragY: 0 }
const tilt = { x: REST_TILT[0], y: REST_TILT[1] }
const piles = { top: new Int16Array(0), floor: new Int16Array(0) }
const clock = { born: performance.now() / 1000 - (still ? REVEAL * 2 : 0), last: 0, knocked: -99 }
const clock = { born: performance.now() / 1000 - (still ? REVEAL * 2 : 0), last: 0, knocked: -99, closed: 0 }
let colors = readColors(stage)
let raf = 0
@@ -149,12 +169,16 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo:
const s = stage.getBoundingClientRect()
const l = logo.getBoundingClientRect()
const composer = stage.querySelector('[data-component="composer"]')?.getBoundingClientRect()
const pitch = Math.max(3, Math.round(l.width / COLS))
const x0 = Math.round(l.left - s.left + (l.width - pitch * COLS) / 2)
const y0 = Math.round(l.top - s.top + (l.height - pitch * ROWS) / 2)
view.left = s.left
view.top = s.top
if (view.w === s.width && view.h === s.height && view.pitch === pitch && view.x0 === x0 && view.y0 === y0) return
view.w = s.width
view.h = s.height
view.dpr = Math.min(2, devicePixelRatio || 1)
view.pitch = Math.max(3, Math.round(l.width / COLS))
view.pitch = pitch
view.depth = view.pitch * 1.7
view.size = view.pitch * 0.8
view.composer = composer && {
@@ -162,8 +186,8 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo:
right: composer.right - s.left,
top: composer.top - s.top,
}
view.x0 = Math.round(l.left - s.left + (l.width - view.pitch * COLS) / 2)
view.y0 = Math.round(l.top - s.top + (l.height - view.pitch * ROWS) / 2)
view.x0 = x0
view.y0 = y0
for (const p of pins) {
p.hx = view.x0 + (p.c + 0.5) * view.pitch
p.hy = view.y0 + (p.r + 0.5) * view.pitch
@@ -356,8 +380,8 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo:
rot: 0,
spin: 0,
pop: 0,
e: 0.9,
z: -0.8,
e: clock.closed ? 0.5 : 0.9,
z: clock.closed ? -0.2 : -0.8,
zv: 0,
})
if (sound()) PinSound.slot(p.c, COLS, p.hx, view.w)
@@ -399,8 +423,10 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo:
if (view.region.full && pins.every((p) => p.state === HOME)) setRegion(false)
}
const revealSpan = entrance === "sweep" ? REVEAL : 1.3
const settled = (now: number) => {
if (now - clock.born < REVEAL || pointer.down || view.activity > 1e-3) return false
if (clock.closed) return now - clock.closed >= 1.2 && pins.every((p) => p.state === HOME)
if (now - clock.born < revealSpan || pointer.down || view.activity > 1e-3) return false
return pins.every((p) => p.state === HOME)
}
@@ -449,15 +475,26 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo:
const half = view.size / 2
const size = view.size
const age = now - clock.born
const closeAge = clock.closed ? now - clock.closed : -1
for (const p of pins) {
if (p.state !== HOME || age < (p.c / COLS) * 0.45) continue
if (p.state !== HOME) continue
const g =
closeAge >= 0
? closeOf(p, closeAge)
: entrance === "sweep"
? age >= (p.c / COLS) * 0.45
? 1
: 0
: chainOf(p, age)
if (g <= 0.05) continue
const s = Math.min(1, g)
quads.shadow(
p.hx + ox - LIGHT[0] * 2.5 - half - 1,
p.hy + oy - LIGHT[1] * 2.5 - half - 1,
p.hx + ox * s - LIGHT[0] * 2.5 * s - half - 1,
p.hy + oy * s - LIGHT[1] * 2.5 * s - half - 1,
size + 2,
size + 2,
colors.shadow,
[colors.shadow[0], colors.shadow[1], colors.shadow[2], colors.shadow[3] * s],
)
}
@@ -496,12 +533,12 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo:
for (const p of band) {
if (p.lv !== lv) continue
const fx = p.hx + ox * p.fd + sx * half
quads.quad(fx, p.hy + oy * p.fd - half, ox * (1 - p.fd), oy * (1 - p.fd), 0, size, colors.side[lv][sideX])
quads.quad(fx, p.hy + oy * p.fd - half, ox * (p.bd - p.fd), oy * (p.bd - p.fd), 0, size, colors.side[lv][sideX])
}
for (const p of band) {
if (p.lv !== lv) continue
const fy = p.hy + oy * p.fd + sy * half
quads.quad(p.hx + ox * p.fd - half, fy, size, 0, ox * (1 - p.fd), oy * (1 - p.fd), colors.side[lv][sideY])
quads.quad(p.hx + ox * p.fd - half, fy, size, 0, ox * (p.bd - p.fd), oy * (p.bd - p.fd), colors.side[lv][sideY])
}
}
for (const p of band)
@@ -534,10 +571,21 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo:
band.length = 0
for (const p of order) {
if (p.state !== HOME) continue
const grow = Math.min(1, Math.max(0, (age - (p.c / COLS) * 0.45) / 0.3))
if (!grow) continue
const height = (p.lv === 1 ? 0.35 : 1) * (1 - Math.max(0, p.z)) + Math.max(0, -p.z) * 0.6
p.fd = 1 - height * (1 - Math.pow(1 - grow, 3))
if (entrance === "sweep" && closeAge < 0) {
const grow = Math.min(1, Math.max(0, (age - (p.c / COLS) * 0.45) / 0.3))
if (!grow) continue
p.bd = 1
p.fd = 1 - height * (1 - Math.pow(1 - grow, 3))
}
if (entrance !== "sweep" || closeAge >= 0) {
const w = closeAge >= 0 ? closeOf(p, closeAge) : chainOf(p, age)
if (w <= 0.01) continue
const g = Math.min(1, w)
p.bd = g
p.fd = (1 - height) * g
p.e = Math.max(p.e, Math.sin(g * Math.PI) * 0.85)
}
const k = p.c * sx + p.r * sy
if (k !== current && band.length) {
flush()
@@ -652,25 +700,47 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo:
}
layout()
// The reveal staggers columns over 0.45 s and each rises over 0.3 s; the sweep spans the same window.
if (!still && sound()) PinSound.sweep({ duration: REVEAL, columns: COLS })
if (!still && sound()) PinSound.sweep({ duration: entrance === "sweep" ? REVEAL : 0.9, columns: COLS })
return () => {
cancelAnimationFrame(raf)
resize.disconnect()
theme.disconnect()
scheme.removeEventListener("change", onTheme)
stage.removeEventListener("pointerenter", onEnter)
stage.removeEventListener("pointermove", onMove)
stage.removeEventListener("pointerleave", onLeave)
stage.removeEventListener("pointerdown", onDown)
stage.removeEventListener("pointerup", onUp)
stage.removeEventListener("pointercancel", onUp)
stage.removeEventListener("lostpointercapture", onUp)
stage.removeEventListener("selectstart", onSelect)
stage.removeEventListener("dragstart", onSelect)
window.removeEventListener("blur", onUp)
layers.back.dispose()
layers.front.dispose()
return {
close: () => {
if (clock.closed) return
const now = performance.now() / 1000
clock.closed = now
pointer.down = false
piles.top.fill(0)
piles.floor.fill(0)
for (const p of pins) {
if (p.state === HOME) continue
p.state = RETURN
p.x0 = p.x
p.y0 = p.y
p.rot0 = p.rot
p.t0 = now + (p.c / COLS) * 0.18
p.duration = 0.28 + Math.random() * 0.06
p.lift = 24 + Math.random() * 42
}
if (!still && sound()) PinSound.sweep({ duration: 0.95, columns: COLS, reverse: true })
wake()
},
dispose: () => {
cancelAnimationFrame(raf)
resize.disconnect()
theme.disconnect()
scheme.removeEventListener("change", onTheme)
stage.removeEventListener("pointerenter", onEnter)
stage.removeEventListener("pointermove", onMove)
stage.removeEventListener("pointerleave", onLeave)
stage.removeEventListener("pointerdown", onDown)
stage.removeEventListener("pointerup", onUp)
stage.removeEventListener("pointercancel", onUp)
stage.removeEventListener("lostpointercapture", onUp)
stage.removeEventListener("selectstart", onSelect)
stage.removeEventListener("dragstart", onSelect)
window.removeEventListener("blur", onUp)
layers.back.dispose()
layers.front.dispose()
},
}
}
@@ -703,9 +773,28 @@ function createPin(c: number, r: number, lv: number): Pin {
duration: 0,
lift: 0,
fd: 0,
bd: 1,
}
}
// Radial chain-reaction from the starter pin in the "e" (column 75, row 3).
function chainOf(p: Pin, age: number) {
const dist = Math.hypot(p.c - 75, (p.r - 3) * 1.4)
const u = (age - (dist / 76) * 0.62) / 0.36
if (u <= 0) return 0
if (u >= 1) return 1
return 1 - Math.pow(1 - u, 3)
}
// Reverse wave reeling pins from left ("o") back into the "e" hole when the lodger is tucked back in.
function closeOf(p: Pin, closeAge: number) {
const dist = Math.hypot(p.c - 75, (p.r - 3) * 1.4)
const u = (closeAge - (0.32 + ((76 - dist) / 76) * 0.48)) / 0.32
if (u <= 0) return 1
if (u >= 1) return 0
return 1 - u * u * (3 - 2 * u)
}
// Theme colours are resolved through a 1×1 canvas so any CSS colour syntax works.
function readColors(stage: HTMLElement) {
const probe = document.createElement("canvas").getContext("2d", { willReadFrequently: true })!
+4 -4
View File
@@ -98,8 +98,8 @@ const note = (step: number) =>
const panOf = (x: number, width: number) => (width ? (x / width) * 1.6 - 0.8 : 0)
export const PinSound = {
/** The wordmark rising in: a gritty stone slide sweeping left to right with pebbles settling under it. */
sweep(input: { duration: number; columns: number }) {
/** The wordmark rising in: a gritty stone slide sweeping across with pebbles settling under it. */
sweep(input: { duration: number; columns: number; reverse?: boolean }) {
const a = audio()
if (!a) return
const c = a.context
@@ -112,8 +112,8 @@ export const PinSound = {
const grit = c.createBiquadFilter()
grit.type = "bandpass"
grit.Q.value = 1.4
grit.frequency.setValueAtTime(260, at)
grit.frequency.exponentialRampToValueAtTime(1500, end)
grit.frequency.setValueAtTime(input.reverse ? 1500 : 260, at)
grit.frequency.exponentialRampToValueAtTime(input.reverse ? 260 : 1500, end)
const body = c.createBiquadFilter()
body.type = "lowpass"
body.frequency.value = 2400
+21 -4
View File
@@ -2,6 +2,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { Tooltip } from "@opencode/ui/tooltip"
import { Icon } from "@opencode/ui/icon"
import { Show, createMemo, createSignal, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { getFilename } from "@opencode/util/path"
import {
@@ -57,7 +58,10 @@ export function NewSessionView(props: {
)
const settings = useSettings()
const [wordmark, setWordmark] = createSignal<HTMLDivElement>()
const [wordmark, setWordmark] = createStore({
box: undefined as HTMLDivElement | undefined,
mode: "flat" as "flat" | "open" | "closing",
})
return (
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
@@ -71,7 +75,13 @@ export function NewSessionView(props: {
/>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<NewSessionWordmark ref={setWordmark} />
<NewSessionWordmark
ref={(box) => setWordmark("box", box)}
sound={settings.sounds.interfaceEnabled}
onOpen={() => setWordmark("mode", "open")}
onClose={() => setWordmark("mode", "closing")}
onFlat={() => setWordmark("mode", "flat")}
/>
<div class="mt-8 flex flex-col gap-3">
<div data-slot="new-session-composer">
<div class="relative z-10">
@@ -102,8 +112,15 @@ export function NewSessionView(props: {
</div>
</div>
</div>
<Show when={wordmark()}>
{(logo) => <NewSessionPinBoard logo={logo()} sound={settings.sounds.interfaceEnabled} />}
<Show when={wordmark.mode !== "flat" && wordmark.box}>
{(logo) => (
<NewSessionPinBoard
logo={logo()}
sound={settings.sounds.interfaceEnabled}
entrance="chain"
closing={wordmark.mode === "closing"}
/>
)}
</Show>
<NewSessionTips selection={props.composer.model.selection} onDone={props.composer.restoreFocus} />
</div>
+57
View File
@@ -0,0 +1,57 @@
[data-component="new-session-wordmark"] {
--icon-strong-base: var(--icon-base);
--wordmark-shimmer-opacity: 0.45;
--lodger-body: color-mix(in srgb, var(--v2-icon-icon-base) 22%, var(--v2-background-bg-base));
--lodger-side: color-mix(in srgb, var(--v2-icon-icon-base) 38%, var(--v2-background-bg-base));
--lodger-eye: var(--v2-icon-icon-base);
--lodger-crack: color-mix(in srgb, var(--v2-icon-icon-base) 55%, var(--v2-background-bg-base));
.wordmark-shimmer {
--icon-base: white;
--icon-strong-base: white;
--icon-weak-base: transparent;
/* Blend above the base logo's opacity so the sweep only adds light in either theme. */
color: white;
mix-blend-mode: screen;
opacity: 0;
mask-image: linear-gradient(to right, transparent 40%, black 50%, transparent 60%);
mask-size: 300% 100%;
mask-repeat: no-repeat;
animation: new-session-wordmark-shimmer 800ms linear both;
}
[data-slot="lodger-blink"] {
transform-box: fill-box;
transform-origin: center;
}
}
[data-color-scheme="light"] [data-component="new-session-wordmark"] {
--icon-weak-base: color-mix(in srgb, var(--icon-base) 30%, transparent);
}
[data-color-scheme="dark"] [data-component="new-session-wordmark"] {
/* Preserve the previous dark highlight strength: 0.16 × 0.7 × 0.6. */
--wordmark-shimmer-opacity: 0.0672;
}
/* Oversizing the mask moves its highlight physically left to right, including in RTL. */
@keyframes new-session-wordmark-shimmer {
0% {
mask-position: 100% 0;
opacity: 0;
}
20%,
80% {
opacity: var(--wordmark-shimmer-opacity);
}
100% {
mask-position: 0% 0;
opacity: 0;
}
}
:root[data-motion="reduce"] [data-component="new-session-wordmark"] .wordmark-shimmer {
animation: none;
}
@@ -1,4 +1,5 @@
import { createSignal, Show } from "solid-js"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { NewSessionPinBoard } from "./pin-board"
import { NewSessionWordmark } from "./wordmark"
@@ -8,26 +9,51 @@ export default {
component: NewSessionWordmark,
}
export const PinBoard = {
render: () => {
const [logo, setLogo] = createSignal<HTMLDivElement>()
function Panel(props: { pins: boolean }) {
const [state, setState] = createStore({
box: undefined as HTMLDivElement | undefined,
mode: (props.pins ? "open" : "flat") as "flat" | "open" | "closing",
})
return (
<div
data-component="new-session"
class="relative isolate h-[640px] w-full overflow-hidden rounded-[10px] bg-v2-background-bg-base"
>
<div class="absolute inset-x-0 top-[25%] flex justify-center px-6">
<div class="w-full max-w-[720px]">
<NewSessionWordmark ref={setLogo} />
<form
data-component="composer"
class="mt-8 h-24 w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
/>
</div>
return (
<div
data-component="new-session"
class="relative isolate h-[640px] w-full overflow-hidden rounded-[10px] bg-v2-background-bg-base"
>
<div class="absolute inset-x-0 top-[25%] flex justify-center px-6">
<div class="w-full max-w-[720px]">
<NewSessionWordmark
ref={(box) => setState("box", box)}
pins={props.pins}
sound={() => true}
onOpen={() => setState("mode", "open")}
onClose={() => setState("mode", "closing")}
onFlat={() => setState("mode", "flat")}
/>
<form
data-component="composer"
class="mt-8 h-24 w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
/>
</div>
<Show when={logo()}>{(element) => <NewSessionPinBoard logo={element()} sound={() => true} />}</Show>
</div>
)
},
<Show when={state.mode !== "flat" && state.box}>
{(element) => (
<NewSessionPinBoard
logo={element()}
sound={() => true}
entrance={props.pins ? "sweep" : "chain"}
closing={state.mode === "closing"}
/>
)}
</Show>
</div>
)
}
export const Flat = {
render: () => <Panel pins={false} />,
}
export const PinBoard = {
render: () => <Panel pins={true} />,
}
+910 -4
View File
@@ -1,12 +1,918 @@
/** Reserves the wordmark's space. `NewSessionPinBoard` draws the logo into it. */
export function NewSessionWordmark(props: { ref: (element: HTMLDivElement) => void }) {
import { Logo } from "@opencode/ui/logo"
import { prefersReducedMotion } from "@opencode/ui/motion"
import { For, onCleanup, onMount, Show } from "solid-js"
import { PinSound } from "./pin-sound"
import "./wordmark.css"
// Each letter in the 234×42 logo viewBox, with its counter shadow, stem, and bottom-centre pivot.
const LETTERS = [
{
cx: 12,
weak: "M18 30H6V18H18V30Z",
fill: "var(--icon-base)",
stem: "M18 12H6V30H18V12ZM24 36H0V6H24V36Z",
},
{
cx: 42,
weak: "M48 30H36V18H48V30Z",
fill: "var(--icon-base)",
stem: "M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z",
},
{
cx: 72,
weak: "M84 24V30H66V24H84Z",
fill: "var(--icon-base)",
stem: "M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z",
},
{
cx: 102,
weak: "M108 36H96V18H108V36Z",
fill: "var(--icon-base)",
stem: "M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z",
},
{
cx: 132,
weak: "M144 30H126V18H144V30Z",
fill: "var(--icon-strong-base)",
stem: "M144 12H126V30H144V36H120V6H144V12Z",
},
{
cx: 162,
weak: "M168 30H156V18H168V30Z",
fill: "var(--icon-strong-base)",
stem: "M168 12H156V30H168V12ZM174 36H150V6H174V36Z",
},
{
cx: 192,
weak: "M198 30H186V18H198V30Z",
fill: "var(--icon-strong-base)",
stem: "M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z",
},
{
cx: 222,
weak: "M234 24V30H216V24H234Z",
fill: "var(--icon-strong-base)",
stem: "M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z",
},
] as const
// Lodger geometry, in the logo's 234×42 viewBox units. The lodger is drawn from its bottom centre.
const W = 234
const H = 42
const HOLE = { x: 222, y: 15 }
const REST = { x: 222, y: 21 }
const PIN_SLOT = { x: 226.5, y: 10.5 }
const GROUND = 36
const HOME = 246
const POP = 26
const SLIP = 60
const HOLE_MAGNET = 24
type Phase = "hidden" | "drag" | "snap" | "show" | "idle" | "hop" | "carry" | "return" | "pack"
type LetterPose = {
x: number
y: number
sx: number
sy: number
opacity: number
}
type Parts = {
box: HTMLDivElement
stage: HTMLDivElement
svg: SVGSVGElement
flat: HTMLDivElement
notch: SVGRectElement
letters: SVGGElement[]
grip: HTMLDivElement
neck: SVGLineElement
pin: SVGGElement
critter: SVGGElement
body: SVGRectElement
eyes: SVGGElement
blink: SVGGElement
}
/**
* Reserves the wordmark's space. With `pins`, `NewSessionPinBoard` draws the logo into it. Otherwise it shows
* the flat logo, and something lives in the top counter of the final "e".
*/
export function NewSessionWordmark(props: {
ref: (element: HTMLDivElement) => void
pins?: boolean
sound?: () => boolean
onOpen?: () => void
onClose?: () => void
onFlat?: () => void
}) {
// Read once: the lodger stays mounted after handing over to the pin board, and can be dragged back into the hole.
const lodger = !props.pins
let box!: HTMLDivElement
return (
<div
data-component="new-session-wordmark"
aria-hidden="true"
class="pointer-events-none mx-auto w-full max-w-[720px]"
class="pointer-events-none mx-auto w-full max-w-[720px] text-v2-background-bg-inverse"
>
<div ref={props.ref} data-slot="wordmark-box" class="mx-auto aspect-[234/42] w-4/5" />
<div
ref={(el) => {
box = el
props.ref(el)
}}
data-slot="wordmark-box"
class="relative mx-auto aspect-[234/42] w-4/5"
>
<Show when={lodger}>
<WordmarkLodger
box={box}
sound={props.sound ?? (() => false)}
onOpen={() => props.onOpen?.()}
onClose={() => props.onClose?.()}
onFlat={() => props.onFlat?.()}
/>
</Show>
</div>
</div>
)
}
/**
* Eyes peek out of the "e" when the pointer comes near. Dragging them pulls the lodger and a single corner pin
* out on a stretchy neck; pull far enough and that pin triggers an elastic 3D pull-wave across the wordmark.
* Dragging the lodger back into the "e" hole reels all the pins back into the flat wordmark.
*/
function WordmarkLodger(props: {
box: HTMLDivElement
sound: () => boolean
onOpen: () => void
onClose: () => void
onFlat: () => void
}) {
let stage!: HTMLDivElement
let svg!: SVGSVGElement
let flat!: HTMLDivElement
let notch!: SVGRectElement
let grip!: HTMLDivElement
let neck!: SVGLineElement
let pin!: SVGGElement
let critter!: SVGGElement
let body!: SVGRectElement
let eyes!: SVGGElement
let blink!: SVGGElement
const letters: SVGGElement[] = []
onMount(() =>
onCleanup(
mountLodger(
{ box: props.box, stage, svg, flat, notch, letters, grip, neck, pin, critter, body, eyes, blink },
props.sound,
props.onOpen,
props.onClose,
props.onFlat,
),
),
)
return (
<div ref={stage} class="absolute">
<div ref={flat} data-slot="wordmark-flat" class="absolute inset-0">
<svg viewBox="0 0 234 42" fill="none" class="block size-full overflow-visible">
<For each={LETTERS}>
{(item, i) => (
<g ref={(el) => (letters[i()] = el)}>
<g class="opacity-[0.16]">
<path d={item.weak} fill="var(--icon-weak-base)" />
<path d={item.stem} fill={item.fill} />
</g>
<Show when={i() === 7}>
<rect
ref={notch}
x="224.6"
y="8.6"
width="3.8"
height="3.8"
fill="var(--v2-background-bg-base)"
style={{ opacity: 0 }}
/>
</Show>
</g>
)}
</For>
</svg>
<Logo class="wordmark-shimmer absolute inset-0 size-full" />
</div>
<svg
ref={svg}
data-slot="wordmark-lodger"
viewBox="0 0 234 42"
class="absolute inset-0 size-full overflow-visible"
>
<line ref={neck} stroke="var(--lodger-body)" stroke-linecap="round" style={{ opacity: 0 }} />
<g ref={pin} style={{ opacity: 0 }}>
<polygon points="-1.9,-1.9 1.9,-1.9 3.6,0.5 -0.2,0.5" fill="var(--lodger-side)" />
<polygon points="1.9,-1.9 3.6,0.5 3.6,4.3 1.9,1.9" fill="var(--lodger-side)" />
<rect x="-1.9" y="-1.9" width="3.8" height="3.8" rx="0.45" fill="var(--lodger-eye)" />
</g>
<g ref={critter}>
<rect
ref={body}
x="-6"
y="-10"
width="12"
height="10"
rx="1.5"
fill="var(--lodger-body)"
style={{ opacity: 0 }}
/>
<g ref={eyes} style={{ opacity: 0 }}>
<g ref={blink} data-slot="lodger-blink">
<rect x="-3.5" y="-7.3" width="2" height="2.6" rx="0.5" fill="var(--lodger-eye)" />
<rect x="1.5" y="-7.3" width="2" height="2.6" rx="0.5" fill="var(--lodger-eye)" />
</g>
</g>
</g>
</svg>
<div
ref={grip}
data-slot="wordmark-lodger-grip"
class="pointer-events-auto absolute cursor-grab touch-none active:cursor-grabbing"
/>
</div>
)
}
function mountLodger(
parts: Parts,
sound: () => boolean,
onOpen: () => void,
onClose: () => void,
onFlat: () => void,
) {
const still = prefersReducedMotion()
const poses: LetterPose[] = LETTERS.map(() => ({ x: 0, y: 0, sx: 1, sy: 1, opacity: 1 }))
const c = {
phase: "hidden" as Phase,
now: 0,
t0: 0,
cue: 0,
x: REST.x,
y: REST.y,
vx: 0,
vy: 0,
fromX: REST.x,
fromY: REST.y,
rot: 0,
crouch: 0,
body: 0,
neck: 0,
eyes: 0,
near: 0,
lookX: 0,
lookY: 0,
wide: 1,
squint: 1,
notch: 0,
pinOpacity: 0,
pinX: PIN_SLOT.x,
pinY: PIN_SLOT.y,
pinRot: 0,
pinScale: 1,
}
const pointer = { x: HOLE.x, y: HOLE.y, inside: false, downX: HOLE.x, downY: HOLE.y }
const impacts: { at: number; amp: number }[] = []
const timers = { raf: 0, blink: 0, last: 0 }
const panel = parts.box.closest<HTMLElement>('[data-component="new-session"]') ?? document.documentElement
// The lodger's screen rect, read on resize and pointer entry so pointer moves never force a layout.
const rect = { left: 0, top: 0, scale: 1 }
// Last written DOM values, so frames and pointer moves only touch what changed.
const written = new Map<string, string>()
const put = (key: string, write: (value: string) => void, value: string) => {
if (written.get(key) === value) return
written.set(key, value)
write(value)
}
const measure = () => {
const r = parts.svg.getBoundingClientRect()
rect.left = r.left
rect.top = r.top
rect.scale = r.width / W || 1
}
// Snaps the flat wordmark stage to the exact integer-pitch footprint and rounded origin used by `NewSessionPinBoard`.
const fitStage = () => {
const l = parts.box.getBoundingClientRect()
if (!l.width) return
const s = parts.box.closest('[data-component="new-session"]')?.getBoundingClientRect()
const pitch = Math.max(3, Math.round(l.width / 78))
const x = s ? Math.round(l.left - s.left + (l.width - pitch * 78) / 2) - (l.left - s.left) : (l.width - pitch * 78) / 2
const y = s ? Math.round(l.top - s.top + (l.height - pitch * 14) / 2) - (l.top - s.top) : (l.height - pitch * 14) / 2
parts.stage.style.left = `${x}px`
parts.stage.style.top = `${y}px`
parts.stage.style.width = `${pitch * 78}px`
parts.stage.style.height = `${pitch * 14}px`
measure()
}
// Moves the interactive hit box between the "e" counter (when hidden) and HOME (when perched beside the board).
const placeGrip = (where: "hidden" | "idle" | "none") => {
if (where === "none") {
parts.grip.style.display = "none"
return
}
parts.grip.style.display = ""
const box = where === "hidden" ? { x: 213, y: 8, w: 18, h: 14 } : { x: 234, y: 18, w: 24, h: 24 }
parts.grip.style.left = `${(box.x / W) * 100}%`
parts.grip.style.top = `${(box.y / H) * 100}%`
parts.grip.style.width = `${(box.w / W) * 100}%`
parts.grip.style.height = `${(box.h / H) * 100}%`
}
const resetPoses = (opacity = 1) => {
for (const p of poses) Object.assign(p, { x: 0, y: 0, sx: 1, sy: 1, opacity })
}
const wake = () => {
if (timers.raf) return
timers.last = performance.now() / 1000 - 1 / 60
timers.raf = requestAnimationFrame(frame)
}
const frame = (ms: number) => {
timers.raf = 0
c.now = ms / 1000
const dt = Math.min(1 / 30, Math.max(1 / 240, c.now - timers.last))
timers.last = c.now
if (c.phase === "drag") dragOut(dt)
if (c.phase === "snap") snapIn(dt)
if (c.phase === "show") show(c.now - c.t0)
if (c.phase === "hop") hop(c.now - c.t0)
if (c.phase === "carry") dragCarry(dt)
if (c.phase === "return") returnHome(c.now - c.t0)
if (c.phase === "pack") pack(c.now - c.t0)
render()
if (c.phase !== "hidden" && c.phase !== "idle") timers.raf = requestAnimationFrame(frame)
}
const dragOut = (dt: number) => {
const dx = pointer.x - pointer.downX
const dy = pointer.y - pointer.downY
const pull = Math.hypot(dx, dy)
if (pull > SLIP) return pop()
const give = 1 / (1 + pull / 40)
const ease = 1 - Math.exp(-dt * 30)
c.x += (REST.x + dx * give - c.x) * ease
c.y += (REST.y + dy * give - c.y) * ease
const out = Math.hypot(c.x - REST.x, c.y - REST.y)
const stress = clamp01(pull / POP)
c.body = clamp01(out / 4.5)
c.neck = c.body
c.eyes = 1
c.wide = 1 + stress * 0.32
c.rot = clamp((c.x - REST.x) * 2, 24)
c.crouch = -clamp01(out / 20) * 0.18
c.lookX = clamp(dx / 15, 1)
c.lookY = clamp(dy / 15, 1) * 0.6
c.notch = clamp01(out / 3)
c.pinOpacity = c.notch
c.pinX = lerp(PIN_SLOT.x, c.x - 1.5, clamp01(out / 7))
c.pinY = lerp(PIN_SLOT.y, c.y - 11, clamp01(out / 7))
c.pinRot = (c.x - REST.x) * 2.5
c.pinScale = 1 + stress * 0.35
poses[7].x = (c.x - REST.x) * 0.12
poses[7].y = (c.y - REST.y) * 0.12
poses[7].sx = 1 + stress * 0.05
poses[7].sy = 1 + stress * 0.05
}
const snapIn = (dt: number) => {
c.vx += (-(c.x - REST.x) * 420 - c.vx * 18) * dt
c.vy += (-(c.y - REST.y) * 420 - c.vy * 18) * dt
c.x += c.vx * dt
c.y += c.vy * dt
const out = Math.hypot(c.x - REST.x, c.y - REST.y)
const fade = Math.exp(-dt * 10)
c.body = clamp01(out / 4.5)
c.neck = c.body
c.notch *= fade
c.pinOpacity *= fade
c.rot *= fade
c.crouch *= fade
c.wide = 1 + (c.wide - 1) * fade
for (const p of poses) {
p.x *= fade
p.y *= fade
p.sx = 1 + (p.sx - 1) * fade
p.sy = 1 + (p.sy - 1) * fade
}
if (out > 0.05 || Math.hypot(c.vx, c.vy) > 0.5) return
restHidden()
if (sound()) PinSound.slot(70, 78, c.x, W)
blinkOnce()
}
const restHidden = () => {
resetPoses(1)
parts.flat.style.display = ""
placeGrip("hidden")
Object.assign(c, {
phase: "hidden",
x: REST.x,
y: REST.y,
vx: 0,
vy: 0,
rot: 0,
crouch: 0,
wide: 1,
squint: 1,
body: 0,
neck: 0,
notch: 0,
pinOpacity: 0,
eyes: c.near * 0.55,
})
look()
}
const pop = () => {
c.now = performance.now() / 1000
Object.assign(c, {
phase: "show",
t0: c.now,
cue: 0,
fromX: c.x,
fromY: c.y,
neck: 0,
})
placeGrip("none")
onOpen()
if (sound()) PinSound.knock(c.x, W)
if (still) return settleIdle()
wake()
}
const showCues = [
{ at: 0.45, run: () => land(0.38) },
{
at: 0.95,
run: () => {
parts.flat.style.display = "none"
},
},
{
at: 1.35,
run: () => {
if (sound()) PinSound.slot(75, 78, PIN_SLOT.x, W)
},
},
{ at: 1.72, run: () => land(0.32) },
{ at: 1.95, run: () => blinkOnce() },
]
// Pulling one single pin out of the "e" triggers an elastic 3D pull-wave that pops every pin out toward
// the viewer from right to left, then the lodger hops up to slot its starter pin back in.
const show = (t: number) => {
while (c.cue < showCues.length && t >= showCues[c.cue].at) showCues[c.cue++].run()
if (t >= 2.15) return settleIdle()
Object.assign(c, { rot: 0, crouch: 0, wide: 1, squint: 1, lookX: -1, lookY: 0, body: 1, eyes: 1 })
c.notch = t < 1.35 ? 1 : 0
c.pinOpacity = t < 1.35 ? 1 : 0
poses.forEach((p, i) => {
const start = (7 - i) * 0.08
const u = seg(t, start, start + 0.28)
p.opacity = 1 - u
p.x = 0
p.y = 0
})
if (t < 0.45) {
const u = t / 0.45
c.x = lerp(c.fromX, HOME, easeOut(u))
c.y = lerp(c.fromY, GROUND, u) - Math.sin(Math.PI * u) * 20
c.rot = 360 * easeInOut(u)
c.wide = 1.35
c.pinX = c.x - 1
c.pinY = c.y - 13
c.pinRot = u * 360
c.pinScale = 1.4
return
}
if (t < 1.05) {
c.x = HOME
c.y = GROUND
c.lookX = -1
c.pinX = c.x
c.pinY = c.y - 12.5 - Math.sin(t * 8) * 0.8
c.pinRot = Math.sin(t * 5) * 10
c.pinScale = 1.3
return
}
if (t < 1.35) {
const u = seg(t, 1.05, 1.35)
c.x = lerp(HOME, 233, easeInOut(u))
c.y = lerp(GROUND, 18, easeOut(u)) - Math.sin(Math.PI * u) * 7
c.pinX = lerp(HOME, PIN_SLOT.x, easeInOut(u))
c.pinY = lerp(GROUND - 12.5, PIN_SLOT.y, easeInOut(u))
c.pinRot = 0
c.pinScale = lerp(1.3, 1, u)
return
}
const u = seg(t, 1.35, 1.72)
c.x = lerp(233, HOME, easeInOut(u))
c.y = lerp(18, GROUND, u) - Math.sin(Math.PI * u) * 10
c.squint = t > 1.72 ? 0.35 : 1
}
const settleIdle = () => {
resetPoses(0)
parts.flat.style.display = "none"
placeGrip("idle")
Object.assign(c, {
phase: "idle",
x: HOME,
y: GROUND,
rot: 0,
crouch: 0,
wide: 1,
squint: 1,
body: 1,
eyes: 1,
neck: 0,
notch: 0,
pinOpacity: 0,
})
look()
render()
scheduleBlink()
}
// Carrying the lodger from HOME: dangles under the pointer and looks toward the "e" hole.
const dragCarry = (dt: number) => {
const dx = pointer.x - pointer.downX
const dy = pointer.y - pointer.downY
const rawX = HOME + dx
const rawY = GROUND + dy
const dist = Math.hypot(rawX - HOLE.x, rawY - 5 - HOLE.y)
if (dist < 9) return startPack()
const magnet = clamp01((HOLE_MAGNET - dist) / HOLE_MAGNET) * 0.45
const targetX = lerp(rawX, REST.x, magnet)
const targetY = lerp(rawY, REST.y, magnet)
const ease = 1 - Math.exp(-dt * 28)
const vx = targetX - c.x
c.x += vx * ease
c.y += (targetY - c.y) * ease
c.rot = clamp(vx * 3.5 + Math.sin(c.now * 22) * 5, 26)
c.crouch = -0.12 + magnet * 0.2
c.wide = 1.2
c.squint = 1 - magnet * 0.3
const hx = HOLE.x - c.x
const hy = HOLE.y - (c.y - 5)
const hd = Math.hypot(hx, hy) || 1
c.lookX = (hx / hd) * Math.min(1, hd / 10)
c.lookY = (hy / hd) * Math.min(1, hd / 10) * 0.6
c.pinOpacity = magnet
c.pinX = PIN_SLOT.x
c.pinY = PIN_SLOT.y
c.pinRot = 0
c.pinScale = 1 + magnet * 0.25
}
const returnHome = (t: number) => {
const u = seg(t, 0, 0.34)
c.x = lerp(c.fromX, HOME, easeOut(u))
c.y = lerp(c.fromY, GROUND, u) - Math.sin(Math.PI * u) * 10
c.rot = lerp(c.rot, 0, u)
c.crouch = 0
c.wide = 1
c.pinOpacity = 0
if (t < 0.34) return
tap(0.25)
settleIdle()
}
const startPack = () => {
c.now = performance.now() / 1000
resetPoses(0)
parts.flat.style.display = ""
placeGrip("none")
Object.assign(c, {
phase: "pack",
t0: c.now,
cue: 0,
fromX: c.x,
fromY: c.y,
})
onClose()
if (sound()) PinSound.knock(c.x, W)
if (still) {
onFlat()
restHidden()
return
}
wake()
}
const packCues = [
{
at: 0.95,
run: () => {
if (sound()) PinSound.slot(75, 78, HOLE.x, W)
},
},
{
at: 1.14,
run: () => {
onFlat()
blinkOnce()
},
},
]
// Reverse sequence: the lodger hops into the "e" hole, grabs the corner pin, and reels all 800 pins
// from left ("o") back into the "e" before tucking itself back inside.
const pack = (t: number) => {
while (c.cue < packCues.length && t >= packCues[c.cue].at) packCues[c.cue++].run()
if (t >= 1.2) return restHidden()
poses.forEach((p, i) => {
const start = 0.32 + (i / 7) * 0.48
const u = seg(t, start, start + 0.32)
p.opacity = u
p.x = 0
p.y = 0
})
// Hops from the cursor into the "e" counter and grabs the corner starter pin while any loose pins fly home.
if (t < 0.3) {
const u = t / 0.3
c.x = lerp(c.fromX, REST.x, easeOut(u))
c.y = lerp(c.fromY, REST.y - 3, easeOut(u)) - Math.sin(Math.PI * u) * 8
c.rot = lerp(c.rot, 10, u)
c.crouch = 0
c.wide = 1.15
c.squint = 1
c.body = 1
c.eyes = 1
c.notch = u
c.pinOpacity = 1
c.pinX = lerp(PIN_SLOT.x, REST.x + 3.5, u)
c.pinY = lerp(PIN_SLOT.y, REST.y - 10.5, u)
c.pinRot = u * 18
c.pinScale = 1.3
return
}
// Reels the wave in from "o" -> "e" while holding the corner pin.
if (t < 0.9) {
const u = seg(t, 0.3, 0.9)
const tug = Math.sin(u * Math.PI * 3)
c.x = REST.x + tug * 1.2
c.y = REST.y - 3 + u * 2
c.rot = tug * 10
c.crouch = Math.abs(tug) * 0.14
c.wide = 1
c.squint = 0.45
c.lookX = -1
c.lookY = 0
c.notch = 1
c.pinOpacity = 1
c.pinX = lerp(REST.x + 3.5, PIN_SLOT.x, u)
c.pinY = lerp(REST.y - 10.5, PIN_SLOT.y, u)
c.pinRot = (1 - u) * 18
c.pinScale = lerp(1.3, 1, u)
return
}
// Slots the corner pin flush and sinks back into the "e" hole.
const u = seg(t, 0.9, 1.14)
c.x = REST.x
c.y = lerp(REST.y - 1, REST.y, easeOut(u))
c.rot = 0
c.crouch = Math.sin(u * Math.PI) * 0.18
c.wide = 1
c.squint = 1
c.lookX = 0
c.lookY = 0
c.body = 1 - easeInOut(u)
c.notch = 1 - u
c.pinOpacity = 1 - u
}
const hop = (t: number) => {
const u = seg(t, 0.08, 0.43)
c.crouch = t < 0.08 ? 0.15 * (t / 0.08) : 0
c.y = GROUND - Math.sin(Math.PI * u) * 8
c.squint = 0.35
if (t >= 0.43 && c.cue === 0) {
c.cue = 1
tap(0.3)
}
if (t < 0.8) return
Object.assign(c, { phase: "idle", y: GROUND, crouch: 0, squint: 1 })
look()
scheduleBlink()
}
const look = () => {
if (!pointer.inside) {
c.lookX = 0
c.lookY = 0
return
}
const dx = pointer.x - c.x
const dy = pointer.y - (c.y - 6)
const d = Math.hypot(dx, dy) || 1
const reach = Math.min(1, d / 15)
// Gaze snaps to 0.08 units (about 0.2 px), so far-off pointer moves that would not move the eyes skip the frame.
c.lookX = Math.round(((dx / d) * reach) / 0.08) * 0.08
c.lookY = Math.round(((dy / d) * reach * 0.6) / 0.08) * 0.08
}
const impact = (amp: number) => {
impacts.push({ at: c.now, amp })
while (impacts.length && c.now - impacts[0].at > 1) impacts.shift()
}
const land = (amp: number) => {
impact(amp)
if (sound()) PinSound.knock(c.x, W)
}
const tap = (amp: number) => {
if (amp) impact(amp)
if (sound()) PinSound.bounce(c.x, W, 3000)
}
const blinkOnce = () => {
if (still) return
parts.blink.animate([{ transform: "scaleY(1)" }, { transform: "scaleY(0.1)" }, { transform: "scaleY(1)" }], {
duration: 160,
easing: "ease-in-out",
})
}
// Blinks are only scheduled while the eyes are showing, so the hidden lodger holds no timers.
const visible = () => c.phase === "idle" || (c.phase === "hidden" && c.eyes > 0.1)
const scheduleBlink = () => {
if (timers.blink || still || !visible()) return
timers.blink = window.setTimeout(
() => {
timers.blink = 0
if (!visible()) return
blinkOnce()
scheduleBlink()
},
2500 + Math.random() * 3500,
)
}
const render = () => {
const squash = impacts.reduce((sum, i) => {
const t = c.now - i.at
return t < 0 ? sum : sum + i.amp * Math.exp(-t * 10) * Math.cos(t * 30)
}, 0)
const sy = (1 - squash) * (1 - c.crouch)
const sx = (1 + squash * 0.7) * (1 + c.crouch * 0.8)
const attr = (el: Element, name: string) => (value: string) => el.setAttribute(name, value)
const opacity = (el: SVGElement) => (value: string) => {
el.style.opacity = value
}
put("critter", attr(parts.critter, "transform"), `translate(${c.x} ${c.y}) rotate(${c.rot} 0 -5) scale(${sx} ${sy})`)
put("body", opacity(parts.body), `${c.body}`)
put("eyes", opacity(parts.eyes), `${c.eyes}`)
put(
"look",
attr(parts.eyes, "transform"),
`translate(${c.lookX} ${c.lookY - 6}) scale(${c.wide} ${c.wide * c.squint}) translate(0 6)`,
)
put("neck", opacity(parts.neck), `${c.neck}`)
if (c.neck) {
const len = Math.hypot(c.x - HOLE.x, c.y - 5 - HOLE.y)
put("neck-x1", attr(parts.neck, "x1"), `${HOLE.x}`)
put("neck-y1", attr(parts.neck, "y1"), `${HOLE.y}`)
put("neck-x2", attr(parts.neck, "x2"), `${c.x}`)
put("neck-y2", attr(parts.neck, "y2"), `${c.y - 5}`)
put("neck-w", attr(parts.neck, "stroke-width"), `${Math.max(2, Math.min(6, 40 / Math.max(1, len)))}`)
}
put("notch", opacity(parts.notch), `${c.notch}`)
put("pin", opacity(parts.pin), `${c.pinOpacity}`)
if (c.pinOpacity)
put(
"pin-at",
attr(parts.pin, "transform"),
`translate(${c.pinX} ${c.pinY}) rotate(${c.pinRot}) scale(${c.pinScale})`,
)
poses.forEach((p, i) => {
const cx = LETTERS[i].cx
const el = parts.letters[i]
if (!el) return
put(`letter-${i}`, (value) => (el.style.opacity = value), `${p.opacity}`)
put(
`letter-at-${i}`,
attr(el, "transform"),
`translate(${cx + p.x} ${GROUND + p.y}) scale(${p.sx} ${p.sy}) translate(${-cx} ${-GROUND})`,
)
})
}
const toUnits = (event: PointerEvent) => {
pointer.x = (event.clientX - rect.left) / rect.scale
pointer.y = (event.clientY - rect.top) / rect.scale
pointer.inside = true
}
// Pointer moves only schedule a frame when the lodger visibly changes; far from the "e" they do nothing.
const onMove = (event: PointerEvent) => {
toUnits(event)
c.near = clamp01((24 - Math.hypot(pointer.x - HOLE.x, pointer.y - HOLE.y)) / 14)
if (c.phase !== "hidden" && c.phase !== "idle") return
if (c.phase === "hidden") {
const eyes = c.near * 0.55
if (eyes === c.eyes) return
c.eyes = eyes
}
const gaze = { x: c.lookX, y: c.lookY }
look()
if (c.phase === "idle" && gaze.x === c.lookX && gaze.y === c.lookY) return
scheduleBlink()
wake()
}
const onEnter = () => measure()
const onLeave = () => {
pointer.inside = false
c.near = 0
if (c.phase !== "hidden" && c.phase !== "idle") return
if (c.phase === "hidden") c.eyes = 0
look()
wake()
}
const onGrab = (event: PointerEvent) => {
if (event.button !== 0 || (c.phase !== "hidden" && c.phase !== "idle")) return
event.preventDefault()
event.stopPropagation()
parts.grip.setPointerCapture(event.pointerId)
measure()
toUnits(event)
Object.assign(pointer, { downX: pointer.x, downY: pointer.y })
c.phase = c.phase === "hidden" ? "drag" : "carry"
wake()
}
const onRelease = () => {
if (c.phase === "drag") {
if (Math.hypot(pointer.x - pointer.downX, pointer.y - pointer.downY) > POP) return pop()
Object.assign(c, { phase: "snap", vx: 0, vy: 0 })
if (still) restHidden()
render()
return
}
if (c.phase !== "carry") return
const moved = Math.hypot(pointer.x - pointer.downX, pointer.y - pointer.downY)
if (moved < 4) {
if (still) return settleIdle()
Object.assign(c, { phase: "hop", x: HOME, y: GROUND, rot: 0, t0: performance.now() / 1000, cue: 0 })
wake()
return
}
const dropX = HOME + (pointer.x - pointer.downX)
const dropY = GROUND + (pointer.y - pointer.downY) - 5
if (
Math.hypot(c.x - HOLE.x, c.y - 5 - HOLE.y) < HOLE_MAGNET ||
Math.hypot(dropX - HOLE.x, dropY - HOLE.y) < HOLE_MAGNET ||
Math.hypot(pointer.x - HOLE.x, pointer.y - HOLE.y) < HOLE_MAGNET
)
return startPack()
if (still) return settleIdle()
Object.assign(c, { phase: "return", fromX: c.x, fromY: c.y, t0: performance.now() / 1000 })
wake()
}
const resize = new ResizeObserver(fitStage)
resize.observe(parts.box)
// The panel can resize while the clamped wordmark keeps its size but moves, e.g. when the sidebar toggles.
resize.observe(panel)
fitStage()
placeGrip("hidden")
panel.addEventListener("pointermove", onMove, { passive: true })
panel.addEventListener("pointerenter", onEnter)
panel.addEventListener("pointerleave", onLeave)
parts.grip.addEventListener("pointerdown", onGrab)
parts.grip.addEventListener("pointerup", onRelease)
parts.grip.addEventListener("pointercancel", onRelease)
parts.grip.addEventListener("lostpointercapture", onRelease)
render()
return () => {
cancelAnimationFrame(timers.raf)
clearTimeout(timers.blink)
resize.disconnect()
panel.removeEventListener("pointermove", onMove)
panel.removeEventListener("pointerenter", onEnter)
panel.removeEventListener("pointerleave", onLeave)
parts.grip.removeEventListener("pointerdown", onGrab)
parts.grip.removeEventListener("pointerup", onRelease)
parts.grip.removeEventListener("pointercancel", onRelease)
parts.grip.removeEventListener("lostpointercapture", onRelease)
}
}
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
const clamp = (v: number, limit: number) => Math.max(-limit, Math.min(limit, v))
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
const seg = (t: number, from: number, to: number) => clamp01((t - from) / (to - from))
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3)
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)