From e460997f81886b88ba6d7a7fbb3a7ca8e62b822c Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:52:58 +1000 Subject: [PATCH] feat(app): hide the new session pin board behind a pull-the-pin easter egg (#57575) --- packages/app/src/new-session/pin-board.tsx | 165 +++- packages/app/src/new-session/pin-sound.ts | 8 +- packages/app/src/new-session/view.tsx | 25 +- packages/app/src/new-session/wordmark.css | 57 ++ .../app/src/new-session/wordmark.stories.tsx | 66 +- packages/app/src/new-session/wordmark.tsx | 914 +++++++++++++++++- 6 files changed, 1165 insertions(+), 70 deletions(-) create mode 100644 packages/app/src/new-session/wordmark.css diff --git a/packages/app/src/new-session/pin-board.tsx b/packages/app/src/new-session/pin-board.tsx index e79a6ec3054..17ca0a2ffce 100644 --- a/packages/app/src/new-session/pin-board.tsx +++ b/packages/app/src/new-session/pin-board.tsx @@ -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 })! diff --git a/packages/app/src/new-session/pin-sound.ts b/packages/app/src/new-session/pin-sound.ts index ed46639eb83..dde8e0cd7dc 100644 --- a/packages/app/src/new-session/pin-sound.ts +++ b/packages/app/src/new-session/pin-sound.ts @@ -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 diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index 4bd504f56ec..99b93874a51 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -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() + const [wordmark, setWordmark] = createStore({ + box: undefined as HTMLDivElement | undefined, + mode: "flat" as "flat" | "open" | "closing", + }) return (
@@ -71,7 +75,13 @@ export function NewSessionView(props: { />
- + setWordmark("box", box)} + sound={settings.sounds.interfaceEnabled} + onOpen={() => setWordmark("mode", "open")} + onClose={() => setWordmark("mode", "closing")} + onFlat={() => setWordmark("mode", "flat")} + />
@@ -102,8 +112,15 @@ export function NewSessionView(props: {
- - {(logo) => } + + {(logo) => ( + + )}
diff --git a/packages/app/src/new-session/wordmark.css b/packages/app/src/new-session/wordmark.css new file mode 100644 index 00000000000..3ce133221fb --- /dev/null +++ b/packages/app/src/new-session/wordmark.css @@ -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; +} diff --git a/packages/app/src/new-session/wordmark.stories.tsx b/packages/app/src/new-session/wordmark.stories.tsx index 659abcdbfcb..2a87621b2c8 100644 --- a/packages/app/src/new-session/wordmark.stories.tsx +++ b/packages/app/src/new-session/wordmark.stories.tsx @@ -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() +function Panel(props: { pins: boolean }) { + const [state, setState] = createStore({ + box: undefined as HTMLDivElement | undefined, + mode: (props.pins ? "open" : "flat") as "flat" | "open" | "closing", + }) - return ( -
-
-
- -
-
+ return ( +
+
+
+ setState("box", box)} + pins={props.pins} + sound={() => true} + onOpen={() => setState("mode", "open")} + onClose={() => setState("mode", "closing")} + onFlat={() => setState("mode", "flat")} + /> +
- {(element) => true} />}
- ) - }, + + {(element) => ( + true} + entrance={props.pins ? "sweep" : "chain"} + closing={state.mode === "closing"} + /> + )} + +
+ ) +} + +export const Flat = { + render: () => , +} + +export const PinBoard = { + render: () => , } diff --git a/packages/app/src/new-session/wordmark.tsx b/packages/app/src/new-session/wordmark.tsx index c2ad620a7b5..b6c16f27931 100644 --- a/packages/app/src/new-session/wordmark.tsx +++ b/packages/app/src/new-session/wordmark.tsx @@ -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 (