mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
feat(app): hide the new session pin board behind a pull-the-pin easter egg (#57575)
This commit is contained in:
committed by
GitHub
Unverified
parent
89067305c1
commit
e460997f81
@@ -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 })!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user