mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
fix(app): drive the branch picker and wordmark lodger through state machines (#61986)
This commit is contained in:
committed by
GitHub
Unverified
parent
b9ead97f54
commit
008bb307e4
@@ -46,6 +46,38 @@ story("shows a static pin board with reduced motion", async ({ mount, page }) =>
|
||||
expect(await requestedFrames(page)).toBe(0)
|
||||
})
|
||||
|
||||
story("the lodger pops the pin board out and packs it back into the e", async ({ mount, page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" })
|
||||
await mount("app-new-session-wordmark--flat")
|
||||
const grip = page.locator('[data-slot="wordmark-lodger-grip"]')
|
||||
const flat = page.locator('[data-slot="wordmark-flat"]')
|
||||
const board = page.locator("canvas")
|
||||
await expect(board).toHaveCount(0)
|
||||
|
||||
// Pulling the lodger well past its slip distance pops every pin out into the board.
|
||||
const hole = (await grip.boundingBox())!
|
||||
await page.mouse.move(hole.x + hole.width / 2, hole.y + hole.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(hole.x + hole.width / 2 + 160, hole.y + hole.height / 2 + 80, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
await expect(flat).toBeHidden()
|
||||
await expect(board).not.toHaveCount(0)
|
||||
await expect.poll(() => requestedFrames(page)).toBe(0)
|
||||
|
||||
// Carrying it from its perch back into the "e" reels the pins in and tucks the lodger away.
|
||||
const lodger = (await page.locator('[data-slot="wordmark-lodger"]').boundingBox())!
|
||||
const unit = lodger.width / 234
|
||||
const perch = (await grip.boundingBox())!
|
||||
await page.mouse.move(perch.x + perch.width / 2, perch.y + perch.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(perch.x + perch.width / 2 - 24 * unit, perch.y + perch.height / 2 - 16 * unit, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
await expect(board).toHaveCount(0)
|
||||
await expect(flat).toBeVisible()
|
||||
await expect.poll(() => requestedFrames(page)).toBe(0)
|
||||
await expect(grip).toBeVisible()
|
||||
})
|
||||
|
||||
// Reads the on-screen pixel at a grid cell. The board may be drawn with WebGL, whose canvas cannot be read
|
||||
// back once presented, so the pixel comes from a screenshot decoded in the page.
|
||||
async function pinPixel(page: Page, cell = PIN) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Icon } from "@opencode/ui/icon"
|
||||
import { Slash } from "@opencode/ui/slash"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { Show, createMemo, createSignal, type JSX } from "solid-js"
|
||||
import { Show, createMemo, createSignal, untrack, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
@@ -36,8 +36,15 @@ import { useSettings } from "@/settings/model"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { NewSessionBranch } from "./workspace/selector"
|
||||
import { NewSessionBrickBreaker } from "./brick-breaker"
|
||||
import { NewSessionPinBoard, type PinBoardHandle } from "./pin-board"
|
||||
import { NewSessionWordmark, type LodgerBall } from "./wordmark"
|
||||
import { NewSessionPinBoard } from "./pin-board"
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
import {
|
||||
createWordmarkState,
|
||||
selectBrickBreaker,
|
||||
selectPinBoard,
|
||||
transitionWordmark,
|
||||
type WordmarkEvent,
|
||||
} from "./wordmark-machine"
|
||||
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -49,17 +56,6 @@ export const ProviderTipSchema = Persistence.struct({
|
||||
dismissedAt: Schema.Finite,
|
||||
})
|
||||
|
||||
type WordmarkState = {
|
||||
box?: HTMLDivElement
|
||||
mode: "flat" | "open" | "closing"
|
||||
game: boolean
|
||||
}
|
||||
|
||||
type BrickBreakerHandles = {
|
||||
board?: PinBoardHandle
|
||||
ball?: LodgerBall
|
||||
}
|
||||
|
||||
export function NewSessionView(props: {
|
||||
composer: ComposerModel
|
||||
project: PromptProjectController
|
||||
@@ -74,7 +70,16 @@ export function NewSessionView(props: {
|
||||
|
||||
const settings = useSettings()
|
||||
|
||||
const [wordmark, setWordmark] = createStore<WordmarkState>({ mode: "flat", game: false })
|
||||
const [wordmark, setWordmark] = createSignal(createWordmarkState(false))
|
||||
const [logo, setLogo] = createSignal<HTMLDivElement>()
|
||||
|
||||
const dispatchWordmark = (event: WordmarkEvent) =>
|
||||
untrack(() => {
|
||||
const next = transitionWordmark(wordmark(), event)
|
||||
setWordmark(next.state)
|
||||
|
||||
if (next.effects.some((effect) => effect.type === "focus.composer")) props.composer.restoreFocus()
|
||||
})
|
||||
|
||||
const location = (project?: PromptProject) =>
|
||||
props.location({
|
||||
@@ -84,8 +89,6 @@ export function NewSessionView(props: {
|
||||
},
|
||||
})
|
||||
|
||||
// Imperative handles for the brick breaker; they are not reactive state.
|
||||
const handles: BrickBreakerHandles = {}
|
||||
let panel!: HTMLDivElement
|
||||
|
||||
return (
|
||||
@@ -102,15 +105,11 @@ 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={(box) => setWordmark("box", box)}
|
||||
ref={setLogo}
|
||||
state={wordmark}
|
||||
onEvent={dispatchWordmark}
|
||||
play
|
||||
sound={settings.sounds.interfaceEnabled}
|
||||
onOpen={() => setWordmark("mode", "open")}
|
||||
onClose={() => setWordmark("mode", "closing")}
|
||||
onFlat={() => setWordmark("mode", "flat")}
|
||||
onPlay={(ball) => {
|
||||
handles.ball = ball
|
||||
setWordmark("game", true)
|
||||
}}
|
||||
/>
|
||||
<div class="mt-8 flex flex-col gap-3">
|
||||
<div data-slot="new-session-composer" class="flex flex-col gap-1">
|
||||
@@ -137,28 +136,25 @@ export function NewSessionView(props: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={wordmark.mode !== "flat" && wordmark.box}>
|
||||
{(logo) => (
|
||||
<Show when={selectPinBoard(wordmark(), logo())}>
|
||||
{(board) => (
|
||||
<NewSessionPinBoard
|
||||
logo={logo()}
|
||||
logo={board().logo}
|
||||
sound={settings.sounds.interfaceEnabled}
|
||||
entrance="chain"
|
||||
closing={wordmark.mode === "closing"}
|
||||
ref={(board) => (handles.board = board)}
|
||||
entrance={board().entrance}
|
||||
closing={board().closing}
|
||||
ref={(handle) => dispatchWordmark({ type: "board.mounted", board: handle })}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={wordmark.game && handles.board && handles.ball && { board: handles.board, ball: handles.ball }}>
|
||||
<Show when={selectBrickBreaker(wordmark())}>
|
||||
{(game) => (
|
||||
<NewSessionBrickBreaker
|
||||
panel={panel}
|
||||
board={game().board}
|
||||
ball={game().ball}
|
||||
sound={settings.sounds.interfaceEnabled}
|
||||
onDone={() => {
|
||||
setWordmark("game", false)
|
||||
props.composer.restoreFocus()
|
||||
}}
|
||||
onDone={() => dispatchWordmark({ type: "game.done" })}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PinBoardHandle } from "./pin-board"
|
||||
import type { LodgerBall } from "./wordmark"
|
||||
import {
|
||||
createWordmarkState,
|
||||
selectBrickBreaker,
|
||||
selectPinBoard,
|
||||
selectWordmarkAnimating,
|
||||
selectWordmarkCanInteract,
|
||||
selectWordmarkDropOver,
|
||||
selectWordmarkFlatVisible,
|
||||
selectWordmarkGrip,
|
||||
selectWordmarkLifted,
|
||||
selectWordmarkLodger,
|
||||
transitionWordmark,
|
||||
type WordmarkEffect,
|
||||
type WordmarkEvent,
|
||||
type WordmarkState,
|
||||
} from "./wordmark-machine"
|
||||
|
||||
// The machine only stores and hands over these handles; it never calls them.
|
||||
const board: PinBoardHandle = {
|
||||
collide: () => undefined,
|
||||
left: () => 800,
|
||||
play: () => {},
|
||||
steer: () => {},
|
||||
restore: () => {},
|
||||
}
|
||||
|
||||
const ball: LodgerBall = {
|
||||
radius: () => 5,
|
||||
at: () => ({ x: 0, y: 0 }),
|
||||
place: () => {},
|
||||
blink: () => {},
|
||||
done: () => {},
|
||||
}
|
||||
|
||||
const logo = document.createElement("div")
|
||||
|
||||
function replay(events: WordmarkEvent[], state: WordmarkState = createWordmarkState(false)) {
|
||||
return events.reduce<{ state: WordmarkState; effects: readonly WordmarkEffect[] }>(
|
||||
(step, event) => transitionWordmark(step.state, event),
|
||||
{ state, effects: [] },
|
||||
)
|
||||
}
|
||||
|
||||
const release = (distance: number, options: { nearHole?: boolean; reducedMotion?: boolean } = {}): WordmarkEvent => ({
|
||||
type: "release",
|
||||
distance,
|
||||
nearHole: options.nearHole ?? false,
|
||||
ball,
|
||||
reducedMotion: options.reducedMotion ?? false,
|
||||
})
|
||||
|
||||
const frame = (options: { inHole?: boolean; over?: boolean; reducedMotion?: boolean }): WordmarkEvent => ({
|
||||
type: "carry.frame",
|
||||
inHole: options.inHole ?? false,
|
||||
over: options.over ?? false,
|
||||
reducedMotion: options.reducedMotion ?? false,
|
||||
})
|
||||
|
||||
const grab: WordmarkEvent = { type: "grab" }
|
||||
|
||||
const mounted: WordmarkEvent = { type: "board.mounted", board }
|
||||
|
||||
// The lodger pulled out, the pin board mounted, and the lodger perched beside it.
|
||||
const IDLE = replay([
|
||||
grab,
|
||||
{ type: "drag.slip", reducedMotion: false },
|
||||
mounted,
|
||||
{ type: "settled", phase: "show" },
|
||||
]).state
|
||||
|
||||
const GAME = replay([grab, frame({ over: true }), release(40)], IDLE).state
|
||||
|
||||
describe("wordmark lodger", () => {
|
||||
test("pulling the lodger out pops the pin board, which stays mounted while the lodger perches", () => {
|
||||
const show = replay([grab, release(30)])
|
||||
expect(show.state).toEqual({ status: "show", board: undefined, cue: 0 })
|
||||
expect(selectWordmarkGrip(show.state)).toBe("none")
|
||||
expect(selectPinBoard(show.state, logo)).toEqual({ logo, entrance: "chain", closing: false })
|
||||
expect(selectWordmarkFlatVisible(show.state)).toBe(true)
|
||||
|
||||
const shown = replay([mounted, { type: "cue.step", phase: "show", cue: 2 }], show.state)
|
||||
expect(selectWordmarkFlatVisible(shown.state)).toBe(false)
|
||||
|
||||
expect(IDLE).toEqual({ status: "idle", board })
|
||||
expect(selectWordmarkGrip(IDLE)).toBe("idle")
|
||||
expect(selectWordmarkCanInteract(IDLE)).toBe(true)
|
||||
expect(selectWordmarkAnimating(IDLE)).toBe(false)
|
||||
})
|
||||
|
||||
test("a short pull snaps the lodger back into the hole", () => {
|
||||
const snap = replay([grab, release(10)])
|
||||
expect(snap.state.status).toBe("snap")
|
||||
expect(selectWordmarkGrip(snap.state)).toBe("hidden")
|
||||
expect(selectPinBoard(snap.state, logo)).toBeUndefined()
|
||||
expect(replay([{ type: "settled", phase: "snap" }], snap.state).state.status).toBe("hidden")
|
||||
})
|
||||
|
||||
// Bug: the board handle stayed in a plain object after the pin board unmounted.
|
||||
test("packing the lodger back drops the pin board and its handle", () => {
|
||||
const pack = replay([grab, frame({ inHole: true })], IDLE)
|
||||
expect(pack.state).toEqual({ status: "pack", board, cue: 0 })
|
||||
expect(selectPinBoard(pack.state, logo)).toEqual({ logo, entrance: "chain", closing: true })
|
||||
|
||||
const flat = transitionWordmark(pack.state, { type: "cue.step", phase: "pack", cue: 2 })
|
||||
expect(flat.state).toEqual({ status: "pack", board: undefined, cue: 2 })
|
||||
expect(selectPinBoard(flat.state, logo)).toBeUndefined()
|
||||
|
||||
expect(transitionWordmark(flat.state, { type: "settled", phase: "pack" }).state).toEqual({ status: "hidden" })
|
||||
})
|
||||
|
||||
// Bug: the lodger settled at 0.7s while Brick Breaker exited until 1s; grabbing it then left it frozen in "game".
|
||||
test("the lodger stays out of reach until Brick Breaker finishes its exit", () => {
|
||||
expect(GAME).toEqual({ status: "game", board, ball, ballStage: "active" })
|
||||
expect(selectBrickBreaker(GAME)).toEqual({ board, ball })
|
||||
expect(selectWordmarkGrip(GAME)).toBe("none")
|
||||
|
||||
const returning = transitionWordmark(GAME, { type: "ball.done" }).state
|
||||
expect(selectWordmarkAnimating(returning)).toBe(true)
|
||||
expect(selectWordmarkLifted(returning)).toBe(true)
|
||||
|
||||
const landed = transitionWordmark(returning, { type: "settled", phase: "return" }).state
|
||||
expect(landed).toEqual({ status: "game", board, ball, ballStage: "landed" })
|
||||
expect(selectBrickBreaker(landed)).toEqual({ board, ball })
|
||||
expect(selectWordmarkGrip(landed)).toBe("none")
|
||||
expect(selectWordmarkCanInteract(landed)).toBe(false)
|
||||
expect(selectWordmarkLifted(landed)).toBe(false)
|
||||
expect(selectWordmarkAnimating(landed)).toBe(false)
|
||||
expect(transitionWordmark(landed, grab).state).toBe(landed)
|
||||
expect(transitionWordmark(landed, frame({ inHole: true })).state).toBe(landed)
|
||||
|
||||
const done = transitionWordmark(landed, { type: "game.done" })
|
||||
expect(done).toEqual({ state: { status: "idle", board }, effects: [{ type: "focus.composer" }] })
|
||||
expect(selectBrickBreaker(done.state)).toBeUndefined()
|
||||
expect(selectWordmarkGrip(done.state)).toBe("idle")
|
||||
})
|
||||
|
||||
test("Brick Breaker ending before the lodger lands hands it over to its flight home", () => {
|
||||
const returning = transitionWordmark(GAME, { type: "ball.done" }).state
|
||||
const done = transitionWordmark(returning, { type: "game.done" })
|
||||
expect(done).toEqual({ state: { status: "return", board }, effects: [{ type: "focus.composer" }] })
|
||||
expect(transitionWordmark(done.state, { type: "settled", phase: "return" }).state).toEqual({
|
||||
status: "idle",
|
||||
board,
|
||||
})
|
||||
})
|
||||
|
||||
// Bug: reaching the hole straight from the composer returned before clearing the composer's drop highlight.
|
||||
test("flicking from the composer into the hole clears the drop highlight", () => {
|
||||
const over = replay([grab, frame({ over: true })], IDLE).state
|
||||
expect(selectWordmarkDropOver(over)).toBe(true)
|
||||
expect(transitionWordmark(over, frame({ over: true })).state).toBe(over)
|
||||
|
||||
const pack = transitionWordmark(over, frame({ inHole: true, over: true })).state
|
||||
expect(pack.status).toBe("pack")
|
||||
expect(selectWordmarkDropOver(pack)).toBe(false)
|
||||
})
|
||||
|
||||
test("a carried lodger hops on a tap, flies home when let go, and packs near the hole", () => {
|
||||
const carry = transitionWordmark(IDLE, grab).state
|
||||
expect(carry).toEqual({ status: "carry", board, over: false })
|
||||
expect(selectWordmarkLifted(carry)).toBe(true)
|
||||
|
||||
const hop = transitionWordmark(carry, release(2)).state
|
||||
expect(hop).toEqual({ status: "hop", board, cue: 0 })
|
||||
expect(
|
||||
replay(
|
||||
[
|
||||
{ type: "cue.step", phase: "hop", cue: 1 },
|
||||
{ type: "settled", phase: "hop" },
|
||||
],
|
||||
hop,
|
||||
).state,
|
||||
).toEqual(IDLE)
|
||||
|
||||
const home = transitionWordmark(carry, release(40)).state
|
||||
expect(home).toEqual({ status: "return", board })
|
||||
expect(transitionWordmark(home, { type: "settled", phase: "return" }).state).toEqual(IDLE)
|
||||
|
||||
expect(transitionWordmark(carry, release(40, { nearHole: true })).state).toEqual({ status: "pack", board, cue: 0 })
|
||||
})
|
||||
|
||||
test("dropping on the composer plays only with a pin board and motion", () => {
|
||||
const over = replay([grab, frame({ over: true })], IDLE).state
|
||||
expect(transitionWordmark(over, release(40, { reducedMotion: true })).state).toEqual(IDLE)
|
||||
|
||||
const unmounted = replay([grab, { type: "drag.slip", reducedMotion: false }, { type: "settled", phase: "show" }])
|
||||
const noBoard = replay([grab, frame({ over: true }), release(40)], unmounted.state).state
|
||||
expect(noBoard).toEqual({ status: "return", board: undefined })
|
||||
})
|
||||
|
||||
test("reduced motion skips every animation but still mounts and unmounts the pin board", () => {
|
||||
const idle = replay([grab, { type: "drag.slip", reducedMotion: true }])
|
||||
expect(idle.state).toEqual({ status: "idle", board: undefined })
|
||||
expect(selectPinBoard(idle.state, logo)).toBeDefined()
|
||||
expect(replay([grab, release(30, { reducedMotion: true })]).state.status).toBe("idle")
|
||||
expect(replay([grab, release(10, { reducedMotion: true })]).state.status).toBe("hidden")
|
||||
|
||||
const carry = transitionWordmark(IDLE, grab).state
|
||||
expect(transitionWordmark(carry, release(2, { reducedMotion: true })).state).toEqual(IDLE)
|
||||
expect(transitionWordmark(carry, release(40, { reducedMotion: true })).state).toEqual(IDLE)
|
||||
const hidden = transitionWordmark(carry, frame({ inHole: true, reducedMotion: true })).state
|
||||
expect(hidden).toEqual({ status: "hidden" })
|
||||
expect(selectPinBoard(hidden, logo)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("the static pin board shows no lodger and sweeps in", () => {
|
||||
const state = createWordmarkState(true)
|
||||
expect(selectWordmarkLodger(state)).toBe(false)
|
||||
expect(selectPinBoard(state, logo)).toEqual({ logo, entrance: "sweep", closing: false })
|
||||
expect(selectPinBoard(state, undefined)).toBeUndefined()
|
||||
expect(transitionWordmark(state, mounted).state).toBe(state)
|
||||
})
|
||||
|
||||
test("ignores events that do not apply to the current phase", () => {
|
||||
const hidden = createWordmarkState(false)
|
||||
|
||||
for (const event of [
|
||||
release(40),
|
||||
frame({ inHole: true }),
|
||||
{ type: "cue.step", phase: "show", cue: 1 },
|
||||
{ type: "settled", phase: "show" },
|
||||
{ type: "ball.done" },
|
||||
{ type: "game.done" },
|
||||
] satisfies WordmarkEvent[]) {
|
||||
expect(transitionWordmark(hidden, event)).toEqual({ state: hidden, effects: [] })
|
||||
}
|
||||
|
||||
const show = replay([grab, release(30), { type: "cue.step", phase: "show", cue: 2 }]).state
|
||||
expect(transitionWordmark(show, { type: "cue.step", phase: "show", cue: 1 }).state).toBe(show)
|
||||
expect(transitionWordmark(show, { type: "cue.step", phase: "pack", cue: 3 }).state).toBe(show)
|
||||
expect(transitionWordmark(IDLE, mounted).state).toBe(IDLE)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { PinBoardHandle } from "./pin-board"
|
||||
import type { LodgerBall } from "./wordmark"
|
||||
|
||||
/** Pulling the lodger further than this, in logo units, pops every pin out into the pin board. */
|
||||
export const LODGER_POP = 26
|
||||
|
||||
/** A carried lodger released closer than this to where it was picked up hops in place. */
|
||||
const LODGER_TAP = 4
|
||||
|
||||
/**
|
||||
* The new session wordmark and the lodger that lives in its final "e". `board` is the static pin board (the
|
||||
* `pins` story) with no lodger. Pulling the lodger out (`drag`) either snaps it back (`snap`) or pops the pin board
|
||||
* out (`show`); from its perch (`idle`) it can be carried (`carry`) back into the hole (`pack`), dropped on the
|
||||
* composer to play Brick Breaker (`game`), or let go to hop home (`hop`, `return`).
|
||||
*
|
||||
* `board` holds the pin board's handle while it is mounted. `cue` counts the timed steps of an animation already
|
||||
* run, so each runs once. In `game`, `ballStage` follows the ball after Brick Breaker ends: it flies home
|
||||
* (`returning`), then waits there (`landed`) until Brick Breaker finishes its exit and unmounts.
|
||||
*/
|
||||
export type WordmarkState =
|
||||
| { readonly status: "board" }
|
||||
| { readonly status: "hidden" }
|
||||
| { readonly status: "drag" }
|
||||
| { readonly status: "snap" }
|
||||
| { readonly status: "show"; readonly board: PinBoardHandle | undefined; readonly cue: number }
|
||||
| { readonly status: "idle"; readonly board: PinBoardHandle | undefined }
|
||||
| { readonly status: "hop"; readonly board: PinBoardHandle | undefined; readonly cue: number }
|
||||
| { readonly status: "carry"; readonly board: PinBoardHandle | undefined; readonly over: boolean }
|
||||
| {
|
||||
readonly status: "game"
|
||||
readonly board: PinBoardHandle
|
||||
readonly ball: LodgerBall
|
||||
readonly ballStage: "active" | "returning" | "landed"
|
||||
}
|
||||
| { readonly status: "return"; readonly board: PinBoardHandle | undefined }
|
||||
| { readonly status: "pack"; readonly board: PinBoardHandle | undefined; readonly cue: number }
|
||||
|
||||
export type WordmarkEvent =
|
||||
| { readonly type: "board.mounted"; readonly board: PinBoardHandle }
|
||||
| { readonly type: "grab" }
|
||||
| { readonly type: "drag.slip"; readonly reducedMotion: boolean }
|
||||
/** `nearHole`: the lodger, its drop point or the pointer is within the "e" hole's pull. */
|
||||
| {
|
||||
readonly type: "release"
|
||||
readonly distance: number
|
||||
readonly nearHole: boolean
|
||||
readonly ball: LodgerBall
|
||||
readonly reducedMotion: boolean
|
||||
}
|
||||
/** Sent only when the carried lodger reaches the hole or crosses the composer's edge. */
|
||||
| { readonly type: "carry.frame"; readonly inHole: boolean; readonly over: boolean; readonly reducedMotion: boolean }
|
||||
| { readonly type: "cue.step"; readonly phase: "show" | "hop" | "pack"; readonly cue: number }
|
||||
| { readonly type: "settled"; readonly phase: "snap" | "show" | "hop" | "return" | "pack" }
|
||||
/** Brick Breaker ended and sent the ball home. */
|
||||
| { readonly type: "ball.done" }
|
||||
/** Brick Breaker finished its exit and unmounts. */
|
||||
| { readonly type: "game.done" }
|
||||
|
||||
export type WordmarkEffect = { readonly type: "focus.composer" }
|
||||
|
||||
export type WordmarkTransition = {
|
||||
readonly state: WordmarkState
|
||||
readonly effects: readonly WordmarkEffect[]
|
||||
}
|
||||
|
||||
const BOARD: WordmarkState = Object.freeze({ status: "board" })
|
||||
|
||||
const HIDDEN: WordmarkState = Object.freeze({ status: "hidden" })
|
||||
|
||||
const DRAG: WordmarkState = Object.freeze({ status: "drag" })
|
||||
|
||||
const SNAP: WordmarkState = Object.freeze({ status: "snap" })
|
||||
|
||||
const NONE: readonly WordmarkEffect[] = Object.freeze([])
|
||||
|
||||
const FOCUS: readonly WordmarkEffect[] = Object.freeze([Object.freeze({ type: "focus.composer" as const })])
|
||||
|
||||
export function createWordmarkState(pins: boolean) {
|
||||
return pins ? BOARD : HIDDEN
|
||||
}
|
||||
|
||||
export function transitionWordmark(state: WordmarkState, event: WordmarkEvent): WordmarkTransition {
|
||||
return { state: next(state, event), effects: event.type === "game.done" && state.status === "game" ? FOCUS : NONE }
|
||||
}
|
||||
|
||||
function next(state: WordmarkState, event: WordmarkEvent): WordmarkState {
|
||||
if (event.type === "board.mounted") {
|
||||
if (!("board" in state) || state.board === event.board) return state
|
||||
|
||||
return { ...state, board: event.board }
|
||||
}
|
||||
|
||||
if (event.type === "grab") {
|
||||
if (state.status === "hidden") return DRAG
|
||||
|
||||
if (state.status === "idle") return { status: "carry", board: state.board, over: false }
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
if (event.type === "drag.slip") return state.status === "drag" ? pop(event.reducedMotion) : state
|
||||
|
||||
if (event.type === "release") return release(state, event)
|
||||
|
||||
if (event.type === "carry.frame") {
|
||||
if (state.status !== "carry") return state
|
||||
|
||||
if (event.inHole) return pack(state.board, event.reducedMotion)
|
||||
|
||||
return state.over === event.over ? state : { ...state, over: event.over }
|
||||
}
|
||||
|
||||
if (event.type === "cue.step") {
|
||||
if (state.status !== "show" && state.status !== "hop" && state.status !== "pack") return state
|
||||
|
||||
if (state.status !== event.phase || event.cue <= state.cue) return state
|
||||
|
||||
// The pin board unmounts once the last pin slots back in.
|
||||
if (state.status === "pack" && event.cue >= 2) return { ...state, cue: event.cue, board: undefined }
|
||||
|
||||
return { ...state, cue: event.cue }
|
||||
}
|
||||
|
||||
if (event.type === "settled") return settle(state, event.phase)
|
||||
|
||||
if (state.status !== "game") return state
|
||||
|
||||
if (event.type === "ball.done") return state.ballStage === "active" ? { ...state, ballStage: "returning" } : state
|
||||
|
||||
if (state.ballStage === "returning") return { status: "return", board: state.board }
|
||||
|
||||
return { status: "idle", board: state.board }
|
||||
}
|
||||
|
||||
/** The static pin board shows no lodger. */
|
||||
export function selectWordmarkLodger(state: WordmarkState) {
|
||||
return state.status !== "board"
|
||||
}
|
||||
|
||||
/** The pin board is mounted from the moment the lodger pops it out until the last pin slots back into the "e". */
|
||||
export function selectPinBoard(state: WordmarkState, logo: HTMLDivElement | undefined) {
|
||||
if (!logo || state.status === "hidden" || state.status === "drag" || state.status === "snap") return
|
||||
|
||||
if (state.status === "pack" && state.cue >= 2) return
|
||||
|
||||
return {
|
||||
logo,
|
||||
entrance: state.status === "board" ? ("sweep" as const) : ("chain" as const),
|
||||
closing: state.status === "pack",
|
||||
}
|
||||
}
|
||||
|
||||
export function selectBrickBreaker(state: WordmarkState) {
|
||||
if (state.status !== "game") return
|
||||
|
||||
return { board: state.board, ball: state.ball }
|
||||
}
|
||||
|
||||
export function selectWordmarkFlatVisible(state: WordmarkState) {
|
||||
if (state.status === "show") return state.cue < 2
|
||||
|
||||
return state.status === "hidden" || state.status === "drag" || state.status === "snap" || state.status === "pack"
|
||||
}
|
||||
|
||||
/** Where the lodger can be grabbed: in the "e" counter, on its perch beside the board, or nowhere. */
|
||||
export function selectWordmarkGrip(state: WordmarkState) {
|
||||
if (state.status === "hidden" || state.status === "drag" || state.status === "snap") return "hidden"
|
||||
|
||||
if (state.status === "idle" || state.status === "carry" || state.status === "hop" || state.status === "return")
|
||||
return "idle"
|
||||
|
||||
return "none"
|
||||
}
|
||||
|
||||
/** Carried, returning, or in play, the lodger crosses the composer, so it is lifted above it. */
|
||||
export function selectWordmarkLifted(state: WordmarkState) {
|
||||
if (state.status === "game") return state.ballStage !== "landed"
|
||||
|
||||
return state.status === "carry" || state.status === "return"
|
||||
}
|
||||
|
||||
export function selectWordmarkDropOver(state: WordmarkState) {
|
||||
return state.status === "carry" && state.over
|
||||
}
|
||||
|
||||
export function selectWordmarkAnimating(state: WordmarkState) {
|
||||
if (state.status === "game") return state.ballStage === "returning"
|
||||
|
||||
return state.status !== "board" && state.status !== "hidden" && state.status !== "idle"
|
||||
}
|
||||
|
||||
/** The lodger follows the pointer and can be grabbed only while it rests. */
|
||||
export function selectWordmarkCanInteract(state: WordmarkState) {
|
||||
return state.status === "hidden" || state.status === "idle"
|
||||
}
|
||||
|
||||
function release(state: WordmarkState, event: Extract<WordmarkEvent, { type: "release" }>): WordmarkState {
|
||||
if (state.status === "drag") {
|
||||
if (event.distance > LODGER_POP) return pop(event.reducedMotion)
|
||||
|
||||
return event.reducedMotion ? HIDDEN : SNAP
|
||||
}
|
||||
|
||||
if (state.status !== "carry") return state
|
||||
|
||||
if (event.distance < LODGER_TAP)
|
||||
return event.reducedMotion ? { status: "idle", board: state.board } : { status: "hop", board: state.board, cue: 0 }
|
||||
|
||||
if (state.over && state.board && !event.reducedMotion)
|
||||
return { status: "game", board: state.board, ball: event.ball, ballStage: "active" }
|
||||
|
||||
if (event.nearHole) return pack(state.board, event.reducedMotion)
|
||||
|
||||
return event.reducedMotion ? { status: "idle", board: state.board } : { status: "return", board: state.board }
|
||||
}
|
||||
|
||||
function pop(reducedMotion: boolean): WordmarkState {
|
||||
return reducedMotion ? { status: "idle", board: undefined } : { status: "show", board: undefined, cue: 0 }
|
||||
}
|
||||
|
||||
function pack(board: PinBoardHandle | undefined, reducedMotion: boolean): WordmarkState {
|
||||
return reducedMotion ? HIDDEN : { status: "pack", board, cue: 0 }
|
||||
}
|
||||
|
||||
function settle(state: WordmarkState, phase: Extract<WordmarkEvent, { type: "settled" }>["phase"]): WordmarkState {
|
||||
if (phase === "return" && state.status === "game")
|
||||
return state.ballStage === "returning" ? { ...state, ballStage: "landed" } : state
|
||||
|
||||
if (state.status !== phase) return state
|
||||
|
||||
if (state.status === "snap" || state.status === "pack") return HIDDEN
|
||||
|
||||
if (state.status === "show" || state.status === "hop" || state.status === "return")
|
||||
return { status: "idle", board: state.board }
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Show, createSignal, untrack } from "solid-js"
|
||||
import { NewSessionPinBoard } from "./pin-board"
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
import { createWordmarkState, selectPinBoard, transitionWordmark, type WordmarkEvent } from "./wordmark-machine"
|
||||
|
||||
export default {
|
||||
title: "App/New Session/Wordmark",
|
||||
@@ -10,10 +10,9 @@ export default {
|
||||
}
|
||||
|
||||
function Panel(props: { pins: boolean }) {
|
||||
const [state, setState] = createStore({
|
||||
box: undefined as HTMLDivElement | undefined,
|
||||
mode: (props.pins ? "open" : "flat") as "flat" | "open" | "closing",
|
||||
})
|
||||
const [state, setState] = createSignal(createWordmarkState(props.pins))
|
||||
const [logo, setLogo] = createSignal<HTMLDivElement>()
|
||||
const dispatch = (event: WordmarkEvent) => untrack(() => setState(transitionWordmark(state(), event).state))
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -22,27 +21,21 @@ function Panel(props: { pins: boolean }) {
|
||||
>
|
||||
<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")}
|
||||
/>
|
||||
<NewSessionWordmark ref={setLogo} state={state} onEvent={dispatch} sound={() => true} />
|
||||
<form
|
||||
data-component="composer"
|
||||
class="mt-8 h-24 w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={state.mode !== "flat" && state.box}>
|
||||
{(element) => (
|
||||
<Show when={selectPinBoard(state(), logo())}>
|
||||
{(board) => (
|
||||
<NewSessionPinBoard
|
||||
logo={element()}
|
||||
logo={board().logo}
|
||||
sound={() => true}
|
||||
entrance={props.pins ? "sweep" : "chain"}
|
||||
closing={state.mode === "closing"}
|
||||
entrance={board().entrance}
|
||||
closing={board().closing}
|
||||
ref={(handle) => dispatch({ type: "board.mounted", board: handle })}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { Logo } from "@opencode/ui/logo"
|
||||
import { prefersReducedMotion } from "@opencode/ui/motion"
|
||||
import { For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { type Accessor, createEffect, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { PinSound } from "./pin-sound"
|
||||
import {
|
||||
LODGER_POP,
|
||||
selectWordmarkAnimating,
|
||||
selectWordmarkCanInteract,
|
||||
selectWordmarkDropOver,
|
||||
selectWordmarkFlatVisible,
|
||||
selectWordmarkGrip,
|
||||
selectWordmarkLifted,
|
||||
selectWordmarkLodger,
|
||||
type WordmarkEvent,
|
||||
type WordmarkState,
|
||||
} from "./wordmark-machine"
|
||||
import "./wordmark.css"
|
||||
|
||||
// Each letter in the 234×42 logo viewBox, with its counter shadow, stem, and bottom-centre pivot.
|
||||
@@ -58,17 +70,36 @@ const LETTERS = [
|
||||
|
||||
// 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" | "game"
|
||||
const GRAB: WordmarkEvent = { type: "grab" }
|
||||
|
||||
const BALL_DONE: WordmarkEvent = { type: "ball.done" }
|
||||
|
||||
const HOP_LANDED: WordmarkEvent = { type: "cue.step", phase: "hop", cue: 1 }
|
||||
|
||||
const SETTLED = {
|
||||
snap: { type: "settled", phase: "snap" },
|
||||
show: { type: "settled", phase: "show" },
|
||||
hop: { type: "settled", phase: "hop" },
|
||||
return: { type: "settled", phase: "return" },
|
||||
pack: { type: "settled", phase: "pack" },
|
||||
} as const
|
||||
|
||||
/** The lodger curled up as the brick breaker ball. Positions are the ball's centre, relative to the new session panel. */
|
||||
export type LodgerBall = {
|
||||
@@ -92,6 +123,14 @@ type LetterPose = {
|
||||
opacity: number
|
||||
}
|
||||
|
||||
type DropTarget = {
|
||||
el: HTMLElement | undefined
|
||||
l: number
|
||||
t: number
|
||||
r: number
|
||||
b: number
|
||||
}
|
||||
|
||||
type Parts = {
|
||||
box: HTMLDivElement
|
||||
stage: HTMLDivElement
|
||||
@@ -114,15 +153,14 @@ type Parts = {
|
||||
*/
|
||||
export function NewSessionWordmark(props: {
|
||||
ref: (element: HTMLDivElement) => void
|
||||
pins?: boolean
|
||||
state: Accessor<WordmarkState>
|
||||
onEvent: (event: WordmarkEvent) => void
|
||||
/** Dropping the lodger on the composer starts Brick Breaker. */
|
||||
play?: boolean
|
||||
sound?: () => boolean
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
onFlat?: () => void
|
||||
onPlay?: (ball: LodgerBall) => 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
|
||||
const lodger = selectWordmarkLodger(props.state())
|
||||
let box!: HTMLDivElement
|
||||
|
||||
return (
|
||||
@@ -143,10 +181,9 @@ export function NewSessionWordmark(props: {
|
||||
<WordmarkLodger
|
||||
box={box}
|
||||
sound={props.sound ?? (() => false)}
|
||||
onOpen={() => props.onOpen?.()}
|
||||
onClose={() => props.onClose?.()}
|
||||
onFlat={() => props.onFlat?.()}
|
||||
onPlay={props.onPlay}
|
||||
state={props.state}
|
||||
onEvent={props.onEvent}
|
||||
play={!!props.play}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -162,10 +199,9 @@ export function NewSessionWordmark(props: {
|
||||
function WordmarkLodger(props: {
|
||||
box: HTMLDivElement
|
||||
sound: () => boolean
|
||||
onOpen: () => void
|
||||
onClose: () => void
|
||||
onFlat: () => void
|
||||
onPlay?: (ball: LodgerBall) => void
|
||||
state: Accessor<WordmarkState>
|
||||
onEvent: (event: WordmarkEvent) => void
|
||||
play: boolean
|
||||
}) {
|
||||
let stage!: HTMLDivElement
|
||||
let svg!: SVGSVGElement
|
||||
@@ -180,18 +216,19 @@ function WordmarkLodger(props: {
|
||||
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,
|
||||
props.onPlay,
|
||||
),
|
||||
),
|
||||
)
|
||||
onMount(() => {
|
||||
const lodger = mountLodger(
|
||||
{ box: props.box, stage, svg, flat, notch, letters, grip, neck, pin, critter, body, eyes, blink },
|
||||
props.sound,
|
||||
props.state,
|
||||
props.onEvent,
|
||||
props.play,
|
||||
)
|
||||
|
||||
// Transitions the lodger did not start itself, such as Brick Breaker unmounting, still move its grip.
|
||||
createEffect(() => lodger.sync(props.state()))
|
||||
onCleanup(lodger.dispose)
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={stage} class="absolute">
|
||||
@@ -264,18 +301,25 @@ function WordmarkLodger(props: {
|
||||
function mountLodger(
|
||||
parts: Parts,
|
||||
sound: () => boolean,
|
||||
onOpen: () => void,
|
||||
onClose: () => void,
|
||||
onFlat: () => void,
|
||||
onPlay?: (ball: LodgerBall) => void,
|
||||
state: Accessor<WordmarkState>,
|
||||
onEvent: (event: WordmarkEvent) => void,
|
||||
play: boolean,
|
||||
) {
|
||||
const still = prefersReducedMotion()
|
||||
|
||||
// Built once, so frames and pointer moves dispatch without allocating.
|
||||
const events = {
|
||||
slip: { type: "drag.slip", reducedMotion: still },
|
||||
hole: { type: "carry.frame", inHole: true, over: false, reducedMotion: still },
|
||||
over: { type: "carry.frame", inHole: false, over: true, reducedMotion: still },
|
||||
off: { type: "carry.frame", inHole: false, over: false, reducedMotion: still },
|
||||
} as const
|
||||
|
||||
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,
|
||||
@@ -304,21 +348,54 @@ function mountLodger(
|
||||
dur: 0.34,
|
||||
arc: 10,
|
||||
}
|
||||
|
||||
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, px: 0, py: 0 }
|
||||
|
||||
// The composer, in lodger units while carrying; dropping the lodger on it starts the brick breaker.
|
||||
const drop = { el: undefined as HTMLElement | undefined, l: 0, t: 0, r: 0, b: 0, over: false }
|
||||
const drop: DropTarget = { el: undefined, l: 0, t: 0, r: 0, b: 0 }
|
||||
|
||||
// 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 send = (event: WordmarkEvent) => {
|
||||
onEvent(event)
|
||||
const next = state()
|
||||
sync(next)
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
// Writes what the machine decides: the grip, the flat wordmark, the lift above the composer, and the drop target.
|
||||
const sync = (s: WordmarkState) => {
|
||||
put("grip", placeGrip, selectWordmarkGrip(s))
|
||||
put("flat", (value) => (parts.flat.style.display = value), selectWordmarkFlatVisible(s) ? "" : "none")
|
||||
put(
|
||||
"lifted",
|
||||
(value) => parts.box.parentElement?.toggleAttribute("data-lifted", value === "1"),
|
||||
selectWordmarkLifted(s) ? "1" : "0",
|
||||
)
|
||||
|
||||
if (drop.el)
|
||||
put(
|
||||
"drop",
|
||||
(value) => drop.el?.toggleAttribute("data-lodger-over", value === "1"),
|
||||
selectWordmarkDropOver(s) ? "1" : "0",
|
||||
)
|
||||
|
||||
if (s.status === "idle") scheduleBlink()
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
const r = parts.svg.getBoundingClientRect()
|
||||
rect.left = r.left
|
||||
@@ -332,11 +409,19 @@ function mountLodger(
|
||||
// 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
|
||||
|
||||
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`
|
||||
@@ -345,11 +430,13 @@ function mountLodger(
|
||||
}
|
||||
|
||||
// Moves the interactive hit box between the "e" counter (when hidden) and HOME (when perched beside the board).
|
||||
const placeGrip = (where: "hidden" | "idle" | "none") => {
|
||||
const placeGrip = (where: string) => {
|
||||
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}%`
|
||||
@@ -373,28 +460,44 @@ function mountLodger(
|
||||
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)
|
||||
|
||||
// Each step reads the state again: one step can hand over to the next within the same frame.
|
||||
if (state().status === "drag") dragOut(dt)
|
||||
|
||||
if (state().status === "snap") snapIn(dt)
|
||||
const shown = state()
|
||||
|
||||
if (shown.status === "show") show(c.now - c.t0, shown.cue)
|
||||
const hopping = state()
|
||||
|
||||
if (hopping.status === "hop") hop(c.now - c.t0, hopping.cue)
|
||||
const carried = state()
|
||||
|
||||
if (carried.status === "carry") dragCarry(dt, carried.over)
|
||||
const returning = state()
|
||||
|
||||
if (returning.status === "return" || (returning.status === "game" && returning.ballStage === "returning"))
|
||||
returnHome(c.now - c.t0)
|
||||
const packing = state()
|
||||
|
||||
if (packing.status === "pack") pack(c.now - c.t0, packing.cue)
|
||||
render()
|
||||
if (c.phase !== "hidden" && c.phase !== "idle" && c.phase !== "game") timers.raf = requestAnimationFrame(frame)
|
||||
|
||||
if (!timers.raf && selectWordmarkAnimating(state())) 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()
|
||||
|
||||
if (pull > SLIP) return pop(send(events.slip))
|
||||
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)
|
||||
const stress = clamp01(pull / LODGER_POP)
|
||||
c.body = clamp01(out / 4.5)
|
||||
c.neck = c.body
|
||||
c.eyes = 1
|
||||
@@ -429,24 +532,25 @@ function mountLodger(
|
||||
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
|
||||
send(SETTLED.snap)
|
||||
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,
|
||||
@@ -466,31 +570,26 @@ function mountLodger(
|
||||
look()
|
||||
}
|
||||
|
||||
const pop = () => {
|
||||
// The machine has popped the pin board out: `show`, or `idle` with reduced motion.
|
||||
const pop = (s: WordmarkState) => {
|
||||
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()
|
||||
|
||||
if (s.status === "idle") return settleIdle()
|
||||
wake()
|
||||
}
|
||||
|
||||
const showCues = [
|
||||
{ at: 0.45, run: () => land(0.38) },
|
||||
{
|
||||
at: 0.95,
|
||||
run: () => {
|
||||
parts.flat.style.display = "none"
|
||||
},
|
||||
},
|
||||
// The flat wordmark hides once this cue has run (`selectWordmarkFlatVisible`).
|
||||
{ at: 0.95, run: () => {} },
|
||||
{
|
||||
at: 1.35,
|
||||
run: () => {
|
||||
@@ -503,9 +602,17 @@ function mountLodger(
|
||||
|
||||
// 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()
|
||||
const show = (t: number, cue: number) => {
|
||||
const reached = runCues(showCues, cue, t)
|
||||
|
||||
if (reached !== cue) send({ type: "cue.step", phase: "show", cue: reached })
|
||||
|
||||
if (t >= 2.15) {
|
||||
send(SETTLED.show)
|
||||
|
||||
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
|
||||
@@ -528,8 +635,10 @@ function mountLodger(
|
||||
c.pinY = c.y - 13
|
||||
c.pinRot = u * 360
|
||||
c.pinScale = 1.4
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (t < 1.05) {
|
||||
c.x = HOME
|
||||
c.y = GROUND
|
||||
@@ -538,8 +647,10 @@ function mountLodger(
|
||||
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))
|
||||
@@ -548,8 +659,10 @@ function mountLodger(
|
||||
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
|
||||
@@ -558,10 +671,7 @@ function mountLodger(
|
||||
|
||||
const settleIdle = () => {
|
||||
resetPoses(0)
|
||||
parts.flat.style.display = "none"
|
||||
placeGrip("idle")
|
||||
Object.assign(c, {
|
||||
phase: "idle",
|
||||
x: HOME,
|
||||
y: GROUND,
|
||||
rot: 0,
|
||||
@@ -582,13 +692,14 @@ function mountLodger(
|
||||
}
|
||||
|
||||
// Carrying the lodger from HOME: dangles under the pointer and looks toward the "e" hole.
|
||||
const dragCarry = (dt: number) => {
|
||||
const dragCarry = (dt: number, wasOver: boolean) => {
|
||||
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()
|
||||
|
||||
if (dist < 9) return startPack(send(events.hole))
|
||||
const magnet = clamp01((HOLE_MAGNET - dist) / HOLE_MAGNET) * 0.45
|
||||
const targetX = lerp(rawX, REST.x, magnet)
|
||||
const targetY = lerp(rawY, REST.y, magnet)
|
||||
@@ -610,27 +721,21 @@ function mountLodger(
|
||||
c.pinY = PIN_SLOT.y
|
||||
c.pinRot = 0
|
||||
c.pinScale = 1 + magnet * 0.25
|
||||
|
||||
// Over the composer it curls up and peers down at it, and the composer sinks a little under it.
|
||||
drop.over =
|
||||
!!onPlay && !!drop.el && pointer.x > drop.l && pointer.x < drop.r && pointer.y > drop.t && pointer.y < drop.b
|
||||
c.ball += ((drop.over ? 0.85 : 0) - c.ball) * (1 - Math.exp(-dt * 14))
|
||||
if (!drop.over) return
|
||||
const over =
|
||||
play && !!drop.el && pointer.x > drop.l && pointer.x < drop.r && pointer.y > drop.t && pointer.y < drop.b
|
||||
|
||||
if (over !== wasOver) send(over ? events.over : events.off)
|
||||
c.ball += ((over ? 0.85 : 0) - c.ball) * (1 - Math.exp(-dt * 14))
|
||||
|
||||
if (!over) return
|
||||
c.lookX = 0
|
||||
c.lookY = 0.6
|
||||
c.squint = 0.8
|
||||
c.rot *= 0.4
|
||||
}
|
||||
|
||||
const play = () => {
|
||||
drop.over = false
|
||||
render()
|
||||
if (!onPlay) return
|
||||
Object.assign(c, { phase: "game", rot: 0, crouch: 0, wide: 1, squint: 1 })
|
||||
placeGrip("none")
|
||||
measure()
|
||||
onPlay(ball)
|
||||
}
|
||||
|
||||
const ball: LodgerBall = {
|
||||
radius: () => 5 * rect.scale,
|
||||
at: () => ({ x: rect.px + c.x * rect.scale, y: rect.py + (c.y - 5) * rect.scale }),
|
||||
@@ -650,7 +755,6 @@ function mountLodger(
|
||||
done: () => {
|
||||
measure()
|
||||
Object.assign(c, {
|
||||
phase: "return",
|
||||
fromX: c.x,
|
||||
fromY: c.y,
|
||||
t0: performance.now() / 1000,
|
||||
@@ -659,6 +763,7 @@ function mountLodger(
|
||||
dur: 0.7,
|
||||
arc: 36,
|
||||
})
|
||||
send(BALL_DONE)
|
||||
wake()
|
||||
},
|
||||
}
|
||||
@@ -672,30 +777,26 @@ function mountLodger(
|
||||
c.wide = 1
|
||||
c.pinOpacity = 0
|
||||
c.ball = Math.min(c.ball, 1 - easeInOut(u))
|
||||
|
||||
if (t < c.dur) return
|
||||
tap(0.25)
|
||||
send(SETTLED.return)
|
||||
settleIdle()
|
||||
}
|
||||
|
||||
const startPack = () => {
|
||||
// The machine has sent the lodger back into the hole: `pack`, or `hidden` with reduced motion.
|
||||
const startPack = (s: WordmarkState) => {
|
||||
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
|
||||
}
|
||||
|
||||
if (s.status === "hidden") return restHidden()
|
||||
wake()
|
||||
}
|
||||
|
||||
@@ -706,20 +807,22 @@ function mountLodger(
|
||||
if (sound()) PinSound.slot(75, 78, HOLE.x, W)
|
||||
},
|
||||
},
|
||||
{
|
||||
at: 1.14,
|
||||
run: () => {
|
||||
onFlat()
|
||||
blinkOnce()
|
||||
},
|
||||
},
|
||||
// The pin board unmounts once this cue has run (`selectPinBoard`).
|
||||
{ at: 1.14, run: () => 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()
|
||||
const pack = (t: number, cue: number) => {
|
||||
const reached = runCues(packCues, cue, t)
|
||||
|
||||
if (reached !== cue) send({ type: "cue.step", phase: "pack", cue: reached })
|
||||
|
||||
if (t >= 1.2) {
|
||||
send(SETTLED.pack)
|
||||
|
||||
return restHidden()
|
||||
}
|
||||
|
||||
poses.forEach((p, i) => {
|
||||
const start = 0.32 + (i / 7) * 0.48
|
||||
@@ -746,8 +849,10 @@ function mountLodger(
|
||||
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)
|
||||
@@ -766,8 +871,10 @@ function mountLodger(
|
||||
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
|
||||
@@ -783,17 +890,20 @@ function mountLodger(
|
||||
c.pinOpacity = 1 - u
|
||||
}
|
||||
|
||||
const hop = (t: number) => {
|
||||
const hop = (t: number, cue: 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
|
||||
|
||||
if (t >= 0.43 && cue === 0) {
|
||||
send(HOP_LANDED)
|
||||
tap(0.3)
|
||||
}
|
||||
|
||||
if (t < 0.8) return
|
||||
Object.assign(c, { phase: "idle", y: GROUND, crouch: 0, squint: 1 })
|
||||
send(SETTLED.hop)
|
||||
Object.assign(c, { y: GROUND, crouch: 0, squint: 1 })
|
||||
look()
|
||||
scheduleBlink()
|
||||
}
|
||||
@@ -802,8 +912,10 @@ function mountLodger(
|
||||
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
|
||||
@@ -815,16 +927,22 @@ function mountLodger(
|
||||
|
||||
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)" }], {
|
||||
@@ -832,13 +950,20 @@ function mountLodger(
|
||||
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 visible = () => {
|
||||
const status = state().status
|
||||
|
||||
return status === "idle" || (status === "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()
|
||||
@@ -850,18 +975,23 @@ function mountLodger(
|
||||
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
|
||||
}
|
||||
|
||||
// Stretching along an angle and rotating back keeps the eyes upright while the ball squashes on impact.
|
||||
const stretch = c.stretch
|
||||
? ` rotate(${c.angle} 0 -5) scale(${1 + c.stretch} ${1 - c.stretch * 0.8}) rotate(${-c.angle} 0 -5)`
|
||||
: ""
|
||||
|
||||
put(
|
||||
"critter",
|
||||
attr(parts.critter, "transform"),
|
||||
@@ -878,14 +1008,6 @@ function mountLodger(
|
||||
},
|
||||
`${Math.round(c.ball * 100) / 100}`,
|
||||
)
|
||||
if (drop.el)
|
||||
put("drop", (value) => drop.el?.toggleAttribute("data-lodger-over", value === "1"), drop.over ? "1" : "0")
|
||||
// Carried, returning, or in play, the lodger crosses the composer, so it is lifted above it.
|
||||
put(
|
||||
"lifted",
|
||||
(value) => parts.box.parentElement?.toggleAttribute("data-lifted", value === "1"),
|
||||
c.phase === "carry" || c.phase === "return" || c.phase === "game" ? "1" : "0",
|
||||
)
|
||||
put("eyes", opacity(parts.eyes), `${c.eyes}`)
|
||||
put(
|
||||
"look",
|
||||
@@ -893,6 +1015,7 @@ function mountLodger(
|
||||
`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}`)
|
||||
@@ -901,8 +1024,10 @@ function mountLodger(
|
||||
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",
|
||||
@@ -912,6 +1037,7 @@ function mountLodger(
|
||||
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(
|
||||
@@ -927,40 +1053,58 @@ function mountLodger(
|
||||
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 s = state()
|
||||
|
||||
if (!selectWordmarkCanInteract(s)) return
|
||||
|
||||
if (s.status === "hidden") {
|
||||
const eyes = c.near * 0.55
|
||||
|
||||
if (eyes === c.eyes) return
|
||||
c.eyes = eyes
|
||||
}
|
||||
const gaze = { x: c.lookX, y: c.lookY }
|
||||
|
||||
const gazeX = c.lookX
|
||||
const gazeY = c.lookY
|
||||
look()
|
||||
if (c.phase === "idle" && gaze.x === c.lookX && gaze.y === c.lookY) return
|
||||
|
||||
if (s.status === "idle" && gazeX === c.lookX && gazeY === 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
|
||||
const s = state()
|
||||
|
||||
if (!selectWordmarkCanInteract(s)) return
|
||||
|
||||
if (s.status === "hidden") c.eyes = 0
|
||||
look()
|
||||
wake()
|
||||
}
|
||||
|
||||
const onGrab = (event: PointerEvent) => {
|
||||
if (event.button !== 0 || (c.phase !== "hidden" && c.phase !== "idle")) return
|
||||
const s = state()
|
||||
|
||||
if (event.button !== 0 || !selectWordmarkCanInteract(s)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
parts.grip.setPointerCapture(event.pointerId)
|
||||
measure()
|
||||
if (c.phase === "idle") {
|
||||
|
||||
if (s.status === "idle") {
|
||||
drop.el = panel.querySelector<HTMLElement>('[data-slot="new-session-composer"]') ?? undefined
|
||||
const r = drop.el?.querySelector('[data-component="composer"]')?.getBoundingClientRect()
|
||||
|
||||
if (r) {
|
||||
const p = panel.getBoundingClientRect()
|
||||
drop.l = (r.left - p.left - rect.px) / rect.scale
|
||||
@@ -969,39 +1113,58 @@ function mountLodger(
|
||||
drop.b = (r.bottom - p.top - rect.py) / rect.scale
|
||||
}
|
||||
}
|
||||
|
||||
toUnits(event)
|
||||
Object.assign(pointer, { downX: pointer.x, downY: pointer.y })
|
||||
c.phase = c.phase === "hidden" ? "drag" : "carry"
|
||||
send(GRAB)
|
||||
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()
|
||||
const s = state()
|
||||
|
||||
if (s.status !== "drag" && s.status !== "carry") return
|
||||
const distance = Math.hypot(pointer.x - pointer.downX, pointer.y - pointer.downY)
|
||||
|
||||
if (s.status === "drag") {
|
||||
const next = send({ type: "release", distance, nearHole: false, ball, reducedMotion: still })
|
||||
|
||||
if (next.status === "show" || next.status === "idle") return pop(next)
|
||||
Object.assign(c, { vx: 0, vy: 0 })
|
||||
|
||||
if (next.status === "hidden") 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
|
||||
}
|
||||
if (drop.over && !still) return play()
|
||||
drop.over = false
|
||||
|
||||
const dropX = HOME + (pointer.x - pointer.downX)
|
||||
const dropY = GROUND + (pointer.y - pointer.downY) - 5
|
||||
if (
|
||||
|
||||
const nearHole =
|
||||
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, dur: 0.34, arc: 10 })
|
||||
|
||||
// Brick Breaker mounts during the dispatch and places the ball from this rect.
|
||||
measure()
|
||||
const next = send({ type: "release", distance, nearHole, ball, reducedMotion: still })
|
||||
|
||||
if (next.status === "idle") return settleIdle()
|
||||
|
||||
if (next.status === "pack" || next.status === "hidden") return startPack(next)
|
||||
|
||||
if (next.status === "game") {
|
||||
Object.assign(c, { rot: 0, crouch: 0, wide: 1, squint: 1 })
|
||||
render()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (next.status === "hop") Object.assign(c, { x: HOME, y: GROUND, rot: 0, t0: performance.now() / 1000 })
|
||||
|
||||
if (next.status === "return")
|
||||
Object.assign(c, { fromX: c.x, fromY: c.y, t0: performance.now() / 1000, dur: 0.34, arc: 10 })
|
||||
wake()
|
||||
}
|
||||
|
||||
@@ -1010,7 +1173,7 @@ function mountLodger(
|
||||
// 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")
|
||||
sync(state())
|
||||
panel.addEventListener("pointermove", onMove, { passive: true })
|
||||
panel.addEventListener("pointerenter", onEnter)
|
||||
panel.addEventListener("pointerleave", onLeave)
|
||||
@@ -1020,23 +1183,40 @@ function mountLodger(
|
||||
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)
|
||||
return {
|
||||
sync,
|
||||
dispose: () => {
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Runs each cue whose time has come, from the first one not yet run, and returns how many have run.
|
||||
function runCues(cues: readonly { at: number; run: () => void }[], from: number, t: number) {
|
||||
let cue = from
|
||||
|
||||
while (cue < cues.length && t >= cues[cue].at) cues[cue++].run()
|
||||
|
||||
return cue
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
INITIAL_BRANCH_PICKER_STATE,
|
||||
selectBranchBusy,
|
||||
selectBranchInitial,
|
||||
selectBranchPending,
|
||||
selectBranchPopoverOpen,
|
||||
transitionBranchPicker,
|
||||
type BranchPickerEffect,
|
||||
type BranchPickerEvent,
|
||||
type BranchPickerState,
|
||||
} from "./branch-machine"
|
||||
|
||||
function replay(events: BranchPickerEvent[], state = INITIAL_BRANCH_PICKER_STATE) {
|
||||
return events.reduce<{ state: BranchPickerState; effects: readonly BranchPickerEffect[] }>(
|
||||
(step, event) => transitionBranchPicker(step.state, event),
|
||||
{ state, effects: [] },
|
||||
)
|
||||
}
|
||||
|
||||
const target = (directory: string | undefined): BranchPickerEvent => ({ type: "target.changed", directory })
|
||||
|
||||
const open: BranchPickerEvent = { type: "open.change", open: true }
|
||||
|
||||
const close: BranchPickerEvent = { type: "open.change", open: false }
|
||||
|
||||
const loaded = (attempt: number, items = ["dev", "main"]): BranchPickerEvent => ({
|
||||
type: "branches.loaded",
|
||||
attempt,
|
||||
items,
|
||||
})
|
||||
|
||||
const focus: BranchPickerEffect = { type: "focus.composer" }
|
||||
|
||||
describe("branch picker", () => {
|
||||
test("preloads branches for the project root and opens on them", () => {
|
||||
const start = replay([target("/repo-a")])
|
||||
expect(start.state).toEqual({ status: "loading", directory: "/repo-a", attempt: 1, open: false })
|
||||
expect(start.effects).toEqual([{ type: "branches.fetch", attempt: 1, directory: "/repo-a" }])
|
||||
expect(selectBranchBusy(start.state)).toBe(true)
|
||||
|
||||
const ready = replay([loaded(1)], start.state)
|
||||
expect(selectBranchInitial(ready.state)).toEqual({ items: ["dev", "main"], failed: false })
|
||||
expect(selectBranchPopoverOpen(ready.state)).toBe(false)
|
||||
|
||||
// Every open revalidates while the loaded list stays on screen.
|
||||
const opened = transitionBranchPicker(ready.state, open)
|
||||
expect(selectBranchPopoverOpen(opened.state)).toBe(true)
|
||||
expect(selectBranchBusy(opened.state)).toBe(true)
|
||||
expect(opened.effects).toEqual([{ type: "branches.fetch", attempt: 2, directory: "/repo-a" }])
|
||||
expect(selectBranchInitial(opened.state)).toEqual({ items: ["dev", "main"], failed: false })
|
||||
|
||||
const refreshed = transitionBranchPicker(opened.state, loaded(2, ["dev", "feature/new"]))
|
||||
expect(selectBranchInitial(refreshed.state)).toEqual({ items: ["dev", "feature/new"], failed: false })
|
||||
expect(selectBranchBusy(refreshed.state)).toBe(false)
|
||||
})
|
||||
|
||||
test("a pending open opens once branches arrive, and can be dismissed first", () => {
|
||||
const pending = replay([target("/repo-a"), open])
|
||||
expect(selectBranchPending(pending.state)).toBe(true)
|
||||
expect(selectBranchPopoverOpen(pending.state)).toBe(true)
|
||||
expect(selectBranchInitial(pending.state)).toBeUndefined()
|
||||
|
||||
expect(selectBranchPopoverOpen(transitionBranchPicker(pending.state, loaded(1)).state)).toBe(true)
|
||||
|
||||
const dismissed = transitionBranchPicker(pending.state, { type: "pending.dismiss" })
|
||||
expect(dismissed.effects).toEqual([])
|
||||
expect(selectBranchPopoverOpen(transitionBranchPicker(dismissed.state, loaded(1)).state)).toBe(false)
|
||||
})
|
||||
|
||||
// Bug: `open` stayed true across a project root change, so the new root's preload popped the picker open.
|
||||
test("a project root change closes a pending picker and preloads the new root closed", () => {
|
||||
const pending = replay([target("/repo-a"), open])
|
||||
const moved = transitionBranchPicker(pending.state, target("/repo-b"))
|
||||
expect(moved.state).toEqual({ status: "loading", directory: "/repo-b", attempt: 2, open: false })
|
||||
expect(moved.effects).toEqual([
|
||||
{ type: "branches.abort", attempt: 1 },
|
||||
{ type: "branches.fetch", attempt: 2, directory: "/repo-b" },
|
||||
])
|
||||
expect(selectBranchPopoverOpen(moved.state)).toBe(false)
|
||||
|
||||
expect(transitionBranchPicker(moved.state, loaded(1)).state).toBe(moved.state)
|
||||
expect(selectBranchPopoverOpen(transitionBranchPicker(moved.state, loaded(2)).state)).toBe(false)
|
||||
})
|
||||
|
||||
test("a project root change closes an open picker and drops its revalidation", () => {
|
||||
const opened = replay([target("/repo-a"), loaded(1), open])
|
||||
const moved = transitionBranchPicker(opened.state, target("/repo-b"))
|
||||
expect(moved.effects).toEqual([
|
||||
{ type: "branches.abort", attempt: 2 },
|
||||
{ type: "branches.fetch", attempt: 3, directory: "/repo-b" },
|
||||
])
|
||||
expect(selectBranchPopoverOpen(moved.state)).toBe(false)
|
||||
expect(selectBranchInitial(moved.state)).toBeUndefined()
|
||||
expect(selectBranchPopoverOpen(transitionBranchPicker(moved.state, loaded(3)).state)).toBe(false)
|
||||
})
|
||||
|
||||
// Bug: an outside interaction that never reached `onCloseAutoFocus` left `outside` set, so the next open skipped
|
||||
// returning focus to the composer.
|
||||
test("an outside interaction does not suppress focus on a later open", () => {
|
||||
const outside = replay([target("/repo-a"), loaded(1), open, { type: "interact.outside" }])
|
||||
expect(transitionBranchPicker(outside.state, close).effects).toEqual([])
|
||||
|
||||
// The picker unmounts before Kobalte closes it, then the draft returns to the same root.
|
||||
const reopened = replay([target(undefined), target("/repo-a"), loaded(3), open], outside.state)
|
||||
expect(selectBranchPopoverOpen(reopened.state)).toBe(true)
|
||||
expect(transitionBranchPicker(reopened.state, { type: "branch.selected", branch: "dev" }).effects).toEqual([
|
||||
{ type: "branch.choose", branch: "dev" },
|
||||
focus,
|
||||
])
|
||||
expect(transitionBranchPicker(reopened.state, close).effects).toEqual([focus])
|
||||
|
||||
// Closing an outside-dismissed picker and opening it again also starts clean.
|
||||
const again = replay([close, open], outside.state)
|
||||
expect(transitionBranchPicker(again.state, close).effects).toEqual([focus])
|
||||
})
|
||||
|
||||
// Bug: a slow revalidation that failed after the picker's own search answered replaced the list with an error.
|
||||
test("the picker's own empty search supersedes a revalidation in flight", () => {
|
||||
const failed = replay([target("/repo-a"), { type: "branches.failed", attempt: 1 }])
|
||||
expect(selectBranchInitial(failed.state)).toEqual({ items: [], failed: true })
|
||||
|
||||
const opened = transitionBranchPicker(failed.state, open)
|
||||
expect(opened.effects).toEqual([{ type: "branches.fetch", attempt: 2, directory: "/repo-a" }])
|
||||
|
||||
const synced = transitionBranchPicker(opened.state, {
|
||||
type: "branches.synced",
|
||||
directory: "/repo-a",
|
||||
items: ["main", "dev"],
|
||||
})
|
||||
|
||||
expect(synced.effects).toEqual([{ type: "branches.abort", attempt: 2 }])
|
||||
expect(selectBranchInitial(synced.state)).toEqual({ items: ["main", "dev"], failed: false })
|
||||
expect(selectBranchBusy(synced.state)).toBe(false)
|
||||
|
||||
expect(transitionBranchPicker(synced.state, { type: "branches.failed", attempt: 2 }).state).toBe(synced.state)
|
||||
expect(transitionBranchPicker(synced.state, loaded(2, ["old"])).state).toBe(synced.state)
|
||||
expect(transitionBranchPicker(synced.state, { type: "branches.timed-out", attempt: 2 }).state).toBe(synced.state)
|
||||
|
||||
const elsewhere: BranchPickerEvent = { type: "branches.synced", directory: "/repo-b", items: ["x"] }
|
||||
expect(transitionBranchPicker(opened.state, elsewhere).state).toBe(opened.state)
|
||||
})
|
||||
|
||||
// Bug: the preload had no signal or deadline, so a hung request kept the trigger busy and the picker unopenable.
|
||||
test("a hung preload times out into a picker that offers Retry", () => {
|
||||
const pending = replay([target("/repo-a"), open])
|
||||
const timedOut = transitionBranchPicker(pending.state, { type: "branches.timed-out", attempt: 1 })
|
||||
expect(timedOut.effects).toEqual([{ type: "branches.abort", attempt: 1 }])
|
||||
expect(selectBranchPopoverOpen(timedOut.state)).toBe(true)
|
||||
expect(selectBranchBusy(timedOut.state)).toBe(false)
|
||||
expect(selectBranchInitial(timedOut.state)).toEqual({ items: [], failed: true })
|
||||
})
|
||||
|
||||
test("a failed or timed-out revalidation keeps the list on screen", () => {
|
||||
const opened = replay([target("/repo-a"), loaded(1), open])
|
||||
const failed = transitionBranchPicker(opened.state, { type: "branches.failed", attempt: 2 })
|
||||
expect(failed.effects).toEqual([])
|
||||
expect(selectBranchInitial(failed.state)).toEqual({ items: ["dev", "main"], failed: false })
|
||||
expect(selectBranchBusy(failed.state)).toBe(false)
|
||||
|
||||
const timedOut = transitionBranchPicker(opened.state, { type: "branches.timed-out", attempt: 2 })
|
||||
expect(timedOut.effects).toEqual([{ type: "branches.abort", attempt: 2 }])
|
||||
expect(selectBranchInitial(timedOut.state)).toEqual({ items: ["dev", "main"], failed: false })
|
||||
})
|
||||
|
||||
test("ignores results from superseded attempts", () => {
|
||||
const opened = replay([target("/repo-a"), loaded(1), open])
|
||||
|
||||
for (const event of [
|
||||
loaded(1),
|
||||
{ type: "branches.failed", attempt: 1 },
|
||||
{ type: "branches.timed-out", attempt: 1 },
|
||||
] satisfies BranchPickerEvent[]) {
|
||||
expect(transitionBranchPicker(opened.state, event).state).toBe(opened.state)
|
||||
}
|
||||
|
||||
const loading = replay([target("/repo-a"), target("/repo-b")])
|
||||
expect(transitionBranchPicker(loading.state, { type: "branches.timed-out", attempt: 1 })).toEqual({
|
||||
state: loading.state,
|
||||
effects: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("the shortcut toggles the picker and returns focus; the palette only opens it", () => {
|
||||
const keybind: BranchPickerEvent = { type: "command.select", source: "keybind" }
|
||||
const palette: BranchPickerEvent = { type: "command.select", source: "palette" }
|
||||
|
||||
const pending = replay([target("/repo-a"), keybind])
|
||||
expect(selectBranchPending(pending.state)).toBe(true)
|
||||
expect(transitionBranchPicker(pending.state, palette).state).toBe(pending.state)
|
||||
const unpended = transitionBranchPicker(pending.state, keybind)
|
||||
expect(selectBranchPopoverOpen(unpended.state)).toBe(false)
|
||||
expect(unpended.effects).toEqual([focus])
|
||||
|
||||
const opened = replay([loaded(1), keybind], unpended.state)
|
||||
expect(selectBranchPopoverOpen(opened.state)).toBe(true)
|
||||
expect(opened.effects).toEqual([{ type: "branches.fetch", attempt: 2, directory: "/repo-a" }])
|
||||
expect(transitionBranchPicker(opened.state, palette).state).toBe(opened.state)
|
||||
const closed = transitionBranchPicker(opened.state, keybind)
|
||||
expect(selectBranchPopoverOpen(closed.state)).toBe(false)
|
||||
expect(closed.effects).toEqual([focus])
|
||||
})
|
||||
|
||||
test("Escape or the trigger returns focus; an outside click keeps it where the user put it", () => {
|
||||
const opened = replay([target("/repo-a"), loaded(1), open])
|
||||
expect(transitionBranchPicker(opened.state, close).effects).toEqual([focus])
|
||||
|
||||
const outside = replay([{ type: "interact.outside" }, close], opened.state)
|
||||
expect(outside.effects).toEqual([])
|
||||
expect(selectBranchPopoverOpen(outside.state)).toBe(false)
|
||||
})
|
||||
|
||||
test("leaving worktree creation drops the request and stays inactive", () => {
|
||||
const pending = replay([target("/repo-a"), open])
|
||||
const inactive = transitionBranchPicker(pending.state, target(undefined))
|
||||
expect(inactive.state).toEqual({ status: "inactive", attempt: 1 })
|
||||
expect(inactive.effects).toEqual([{ type: "branches.abort", attempt: 1 }])
|
||||
expect(transitionBranchPicker(inactive.state, open).state).toBe(inactive.state)
|
||||
expect(transitionBranchPicker(inactive.state, target(undefined)).state).toBe(inactive.state)
|
||||
})
|
||||
|
||||
test("dispose aborts the request in flight and ignores everything after", () => {
|
||||
const opened = replay([target("/repo-a"), loaded(1), open])
|
||||
const disposed = transitionBranchPicker(opened.state, { type: "disposed" })
|
||||
expect(disposed.effects).toEqual([{ type: "branches.abort", attempt: 2 }])
|
||||
expect(transitionBranchPicker(disposed.state, loaded(2)).state).toBe(disposed.state)
|
||||
expect(transitionBranchPicker(disposed.state, target("/repo-b")).effects).toEqual([])
|
||||
|
||||
expect(transitionBranchPicker(replay([target("/repo-a"), loaded(1)]).state, { type: "disposed" }).effects).toEqual(
|
||||
[],
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
/** A preload or revalidation that has not answered by then fails, so the picker can offer Retry. */
|
||||
export const BRANCH_PRELOAD_TIMEOUT_MS = 10_000
|
||||
|
||||
export type BranchList = {
|
||||
readonly items: string[]
|
||||
readonly failed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The base-branch picker of a "New worktree" draft. Branches preload before the popover opens, so it never shows a
|
||||
* smaller loading panel: opening during `loading` holds the popover open (`open: true`) until they arrive. In `ready`,
|
||||
* every open revalidates while the loaded list stays on screen (`refreshing`). `popover: "outside"` is an open picker
|
||||
* the user clicked or focused away from; it closes without moving focus back to the composer.
|
||||
*/
|
||||
export type BranchPickerState =
|
||||
| { readonly status: "inactive"; readonly attempt: number }
|
||||
| { readonly status: "loading"; readonly directory: string; readonly attempt: number; readonly open: boolean }
|
||||
| {
|
||||
readonly status: "ready"
|
||||
readonly directory: string
|
||||
readonly attempt: number
|
||||
readonly initial: BranchList
|
||||
readonly refreshing: boolean
|
||||
readonly popover: "closed" | "open" | "outside"
|
||||
}
|
||||
| { readonly status: "disposed" }
|
||||
|
||||
export type BranchPickerEvent =
|
||||
/** The project root while the draft creates a worktree, otherwise `undefined`. */
|
||||
| { readonly type: "target.changed"; readonly directory: string | undefined }
|
||||
| { readonly type: "open.change"; readonly open: boolean }
|
||||
| { readonly type: "command.select"; readonly source: string | undefined }
|
||||
/** A pointer, focus, blur or Escape outside the trigger while the popover waits for branches. */
|
||||
| { readonly type: "pending.dismiss" }
|
||||
| { readonly type: "interact.outside" }
|
||||
| { readonly type: "branch.selected"; readonly branch: string }
|
||||
| { readonly type: "branches.loaded"; readonly attempt: number; readonly items: string[] }
|
||||
| { readonly type: "branches.failed"; readonly attempt: number }
|
||||
| { readonly type: "branches.timed-out"; readonly attempt: number }
|
||||
/** The open picker's own empty-query search answered; it supersedes a revalidation still in flight. */
|
||||
| { readonly type: "branches.synced"; readonly directory: string; readonly items: string[] }
|
||||
| { readonly type: "disposed" }
|
||||
|
||||
export type BranchPickerEffect =
|
||||
| { readonly type: "branches.fetch"; readonly attempt: number; readonly directory: string }
|
||||
| { readonly type: "branches.abort"; readonly attempt: number }
|
||||
| { readonly type: "branch.choose"; readonly branch: string }
|
||||
| { readonly type: "focus.composer" }
|
||||
|
||||
export type BranchPickerTransition = {
|
||||
readonly state: BranchPickerState
|
||||
readonly effects: readonly BranchPickerEffect[]
|
||||
}
|
||||
|
||||
type LiveState = Exclude<BranchPickerState, { status: "disposed" }>
|
||||
|
||||
type ReadyState = Extract<BranchPickerState, { status: "ready" }>
|
||||
|
||||
export const INITIAL_BRANCH_PICKER_STATE: BranchPickerState = Object.freeze({ status: "inactive", attempt: 0 })
|
||||
|
||||
const DISPOSED: BranchPickerState = Object.freeze({ status: "disposed" })
|
||||
|
||||
const NONE: readonly BranchPickerEffect[] = Object.freeze([])
|
||||
|
||||
const FOCUS: BranchPickerEffect = Object.freeze({ type: "focus.composer" })
|
||||
|
||||
export function transitionBranchPicker(state: BranchPickerState, event: BranchPickerEvent): BranchPickerTransition {
|
||||
if (state.status === "disposed") return unchanged(state)
|
||||
|
||||
if (event.type === "disposed") return { state: DISPOSED, effects: abort(state) }
|
||||
|
||||
if (event.type === "target.changed") return retarget(state, event.directory)
|
||||
|
||||
if (state.status === "inactive") return unchanged(state)
|
||||
|
||||
if (event.type === "open.change") return event.open ? open(state) : close(state)
|
||||
|
||||
if (event.type === "command.select") {
|
||||
if (!selectBranchPopoverOpen(state)) return open(state)
|
||||
|
||||
// The palette reopens the picker it was opened over; the shortcut toggles it.
|
||||
if (event.source === "palette") return unchanged(state)
|
||||
|
||||
return closed(state, [FOCUS])
|
||||
}
|
||||
|
||||
if (event.type === "pending.dismiss") return state.status === "loading" ? close(state) : unchanged(state)
|
||||
|
||||
if (event.type === "branches.loaded") return settle(state, event.attempt, { items: event.items, failed: false })
|
||||
|
||||
if (event.type === "branches.failed") return settle(state, event.attempt, undefined)
|
||||
|
||||
if (event.type === "branches.timed-out") {
|
||||
const next = settle(state, event.attempt, undefined)
|
||||
|
||||
return next.state === state
|
||||
? next
|
||||
: { state: next.state, effects: [{ type: "branches.abort", attempt: event.attempt }] }
|
||||
}
|
||||
|
||||
if (state.status !== "ready") return unchanged(state)
|
||||
|
||||
if (event.type === "interact.outside")
|
||||
return state.popover === "open" ? { state: { ...state, popover: "outside" }, effects: NONE } : unchanged(state)
|
||||
|
||||
if (event.type === "branch.selected") {
|
||||
if (state.popover === "closed") return unchanged(state)
|
||||
|
||||
return closed(state, [{ type: "branch.choose", branch: event.branch }, FOCUS])
|
||||
}
|
||||
|
||||
if (event.directory !== state.directory) return unchanged(state)
|
||||
|
||||
return {
|
||||
state: { ...state, initial: { items: event.items, failed: false }, refreshing: false },
|
||||
effects: abort(state),
|
||||
}
|
||||
}
|
||||
|
||||
/** Kobalte's `open`: the popover is open, or held open until branches arrive. */
|
||||
export function selectBranchPopoverOpen(state: BranchPickerState) {
|
||||
if (state.status === "loading") return state.open
|
||||
|
||||
return state.status === "ready" && state.popover !== "closed"
|
||||
}
|
||||
|
||||
/** The popover is held open without content, so Kobalte has no dismissable layer yet. */
|
||||
export function selectBranchPending(state: BranchPickerState) {
|
||||
return state.status === "loading" && state.open
|
||||
}
|
||||
|
||||
export function selectBranchBusy(state: BranchPickerState) {
|
||||
return state.status === "loading" || (state.status === "ready" && state.refreshing)
|
||||
}
|
||||
|
||||
/** The list the popover opens on; kept through a close so the content can animate out. */
|
||||
export function selectBranchInitial(state: BranchPickerState) {
|
||||
return state.status === "ready" ? state.initial : undefined
|
||||
}
|
||||
|
||||
// A new target drops the previous root's request and list, and closes the picker.
|
||||
function retarget(state: LiveState, directory: string | undefined): BranchPickerTransition {
|
||||
if (directory === undefined) {
|
||||
if (state.status === "inactive") return unchanged(state)
|
||||
|
||||
return { state: { status: "inactive", attempt: state.attempt }, effects: abort(state) }
|
||||
}
|
||||
|
||||
if (state.status !== "inactive" && state.directory === directory) return unchanged(state)
|
||||
|
||||
const attempt = state.attempt + 1
|
||||
|
||||
return {
|
||||
state: { status: "loading", directory, attempt, open: false },
|
||||
effects: [...abort(state), { type: "branches.fetch", attempt, directory }],
|
||||
}
|
||||
}
|
||||
|
||||
// Every open revalidates the loaded list.
|
||||
function open(state: Exclude<LiveState, { status: "inactive" }>): BranchPickerTransition {
|
||||
if (state.status === "loading")
|
||||
return state.open ? unchanged(state) : { state: { ...state, open: true }, effects: NONE }
|
||||
|
||||
if (state.popover !== "closed") return unchanged(state)
|
||||
|
||||
const attempt = state.attempt + 1
|
||||
|
||||
return {
|
||||
state: { ...state, attempt, refreshing: true, popover: "open" },
|
||||
effects: [...abort(state), { type: "branches.fetch", attempt, directory: state.directory }],
|
||||
}
|
||||
}
|
||||
|
||||
function close(state: Exclude<LiveState, { status: "inactive" }>): BranchPickerTransition {
|
||||
if (state.status === "loading")
|
||||
return state.open ? { state: { ...state, open: false }, effects: NONE } : unchanged(state)
|
||||
|
||||
if (state.popover === "closed") return unchanged(state)
|
||||
|
||||
// An outside click or focus keeps focus where the user put it.
|
||||
return closed(state, state.popover === "outside" ? NONE : [FOCUS])
|
||||
}
|
||||
|
||||
function closed(
|
||||
state: Exclude<LiveState, { status: "inactive" }>,
|
||||
effects: readonly BranchPickerEffect[],
|
||||
): BranchPickerTransition {
|
||||
if (state.status === "loading") return { state: { ...state, open: false }, effects }
|
||||
|
||||
return { state: { ...state, popover: "closed" }, effects }
|
||||
}
|
||||
|
||||
// A failed revalidation keeps the list on screen; only a failed preload offers Retry.
|
||||
function settle(
|
||||
state: Exclude<LiveState, { status: "inactive" }>,
|
||||
attempt: number,
|
||||
list: BranchList | undefined,
|
||||
): BranchPickerTransition {
|
||||
if (state.attempt !== attempt) return unchanged(state)
|
||||
|
||||
if (state.status === "loading") {
|
||||
const next: ReadyState = {
|
||||
status: "ready",
|
||||
directory: state.directory,
|
||||
attempt,
|
||||
initial: list ?? { items: [], failed: true },
|
||||
refreshing: false,
|
||||
popover: state.open ? "open" : "closed",
|
||||
}
|
||||
|
||||
return { state: next, effects: NONE }
|
||||
}
|
||||
|
||||
if (!state.refreshing) return unchanged(state)
|
||||
|
||||
return { state: { ...state, initial: list ?? state.initial, refreshing: false }, effects: NONE }
|
||||
}
|
||||
|
||||
function abort(state: LiveState): BranchPickerEffect[] {
|
||||
if (state.status === "loading" || (state.status === "ready" && state.refreshing))
|
||||
return [{ type: "branches.abort", attempt: state.attempt }]
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function unchanged(state: BranchPickerState): BranchPickerTransition {
|
||||
return { state, effects: NONE }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createResource, Show } from "solid-js"
|
||||
import { createComputed, createEffect, createSignal, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -9,6 +9,17 @@ import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Typeahead } from "@opencode/ui/typeahead"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import {
|
||||
BRANCH_PRELOAD_TIMEOUT_MS,
|
||||
INITIAL_BRANCH_PICKER_STATE,
|
||||
selectBranchBusy,
|
||||
selectBranchInitial,
|
||||
selectBranchPending,
|
||||
selectBranchPopoverOpen,
|
||||
transitionBranchPicker,
|
||||
type BranchPickerEffect,
|
||||
type BranchPickerEvent,
|
||||
} from "./branch-machine"
|
||||
import type { NewSessionWorkspaceController } from "./controller"
|
||||
|
||||
export function NewSessionBranch(props: { workspace: NewSessionWorkspaceController; onDone: () => void }) {
|
||||
@@ -16,39 +27,60 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
|
||||
const command = useCommand()
|
||||
|
||||
const [state, setState] = createStore({ open: false, truncated: false })
|
||||
const [layout, setLayout] = createStore({ truncated: false })
|
||||
|
||||
let outside = false
|
||||
const [picker, setPicker] = createSignal(INITIAL_BRANCH_PICKER_STATE)
|
||||
|
||||
const requests = new Map<number, { controller: AbortController; timer: ReturnType<typeof setTimeout> }>()
|
||||
|
||||
let trigger: HTMLButtonElement | undefined
|
||||
|
||||
const creating = () => props.workspace.selection.value() === "create"
|
||||
|
||||
// Fetch before opening and retain the result across closes, rather than showing a smaller loading panel.
|
||||
const [branches, branchActions] = createResource(
|
||||
() => (creating() ? props.workspace.project.root() : undefined),
|
||||
async (directory) => ({
|
||||
directory,
|
||||
...(await props.workspace.branches("").then(
|
||||
(items) => ({ items, failed: false }),
|
||||
() => ({ items: [], failed: true }),
|
||||
)),
|
||||
}),
|
||||
const dispatch = (event: BranchPickerEvent) =>
|
||||
untrack(() => {
|
||||
const next = transitionBranchPicker(picker(), event)
|
||||
setPicker(next.state)
|
||||
next.effects.forEach(run)
|
||||
})
|
||||
|
||||
const run = (effect: BranchPickerEffect) => {
|
||||
if (effect.type === "focus.composer") return props.onDone()
|
||||
|
||||
if (effect.type === "branch.choose") return props.workspace.selection.branch(effect.branch)
|
||||
|
||||
if (effect.type === "branches.abort") {
|
||||
const request = requests.get(effect.attempt)
|
||||
requests.delete(effect.attempt)
|
||||
clearTimeout(request?.timer)
|
||||
request?.controller.abort()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const attempt = effect.attempt
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => dispatch({ type: "branches.timed-out", attempt }), BRANCH_PRELOAD_TIMEOUT_MS)
|
||||
requests.set(attempt, { controller, timer })
|
||||
props.workspace.branches("", controller.signal).then(
|
||||
(items) => settle(attempt, controller, { type: "branches.loaded", attempt, items }),
|
||||
() => settle(attempt, controller, { type: "branches.failed", attempt }),
|
||||
)
|
||||
}
|
||||
|
||||
const settle = (attempt: number, controller: AbortController, event: BranchPickerEvent) => {
|
||||
if (controller.signal.aborted) return
|
||||
clearTimeout(requests.get(attempt)?.timer)
|
||||
requests.delete(attempt)
|
||||
dispatch(event)
|
||||
}
|
||||
|
||||
// Branches load before the picker opens, so it never shows a smaller loading panel.
|
||||
createComputed(() =>
|
||||
dispatch({ type: "target.changed", directory: creating() ? props.workspace.project.root() : undefined }),
|
||||
)
|
||||
|
||||
const initial = () => {
|
||||
if (branches.state !== "ready" && branches.state !== "refreshing") return
|
||||
const result = branches.latest
|
||||
|
||||
return result?.directory === props.workspace.project.root() ? result : undefined
|
||||
}
|
||||
|
||||
const changeOpen = (open: boolean) => {
|
||||
setState("open", open)
|
||||
|
||||
// Revalidate on every open; the loaded list stays on screen meanwhile.
|
||||
if (open && initial()) branchActions.refetch()
|
||||
}
|
||||
onCleanup(() => dispatch({ type: "disposed" }))
|
||||
|
||||
command.register("new-session-branch", () => [
|
||||
{
|
||||
@@ -57,26 +89,17 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
category: language.t("command.category.workspace"),
|
||||
keybind: "mod+alt+b",
|
||||
disabled: !creating() || !props.workspace.bar.branch(),
|
||||
onSelect: (source) => {
|
||||
changeOpen(source === "palette" || !state.open)
|
||||
|
||||
// Pending opens have no close-autofocus handler.
|
||||
if (!state.open && !initial()) props.onDone()
|
||||
},
|
||||
onSelect: (source) => dispatch({ type: "command.select", source }),
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
if (!creating()) setState("open", false)
|
||||
})
|
||||
|
||||
// Until content mounts, Kobalte has no dismissable layer to cancel a pending open.
|
||||
createEffect(() => {
|
||||
if (!state.open || initial()) return
|
||||
if (!selectBranchPending(picker())) return
|
||||
|
||||
const close = (event: Event) => {
|
||||
if (event.target instanceof Node && trigger?.contains(event.target)) return
|
||||
setState("open", false)
|
||||
dispatch({ type: "pending.dismiss" })
|
||||
}
|
||||
|
||||
makeEventListener(document, "pointerdown", close, { capture: true })
|
||||
@@ -86,7 +109,7 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
window,
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key === "Escape") setState("open", false)
|
||||
if (event.key === "Escape") dispatch({ type: "pending.dismiss" })
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
@@ -98,7 +121,7 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
: props.workspace.bar.branch()
|
||||
|
||||
const observe = (element: HTMLSpanElement) =>
|
||||
createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth))
|
||||
createResizeObserver(element, () => setLayout("truncated", element.scrollWidth > element.clientWidth))
|
||||
|
||||
return (
|
||||
<Show when={props.workspace.bar.branch()}>
|
||||
@@ -114,11 +137,11 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
<Keybind keys={command.keybindParts("session.branch.choose")} variant="neutral" />
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={state.truncated}>{label()}</Show>
|
||||
<Show when={layout.truncated}>{label()}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
disabled={!creating() && !state.truncated}
|
||||
disabled={!creating() && !layout.truncated}
|
||||
class="flex min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
@@ -133,10 +156,15 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Popover open={state.open} onOpenChange={changeOpen} placement="bottom-start" gutter={4}>
|
||||
<Popover
|
||||
open={selectBranchPopoverOpen(picker())}
|
||||
onOpenChange={(open) => dispatch({ type: "open.change", open })}
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
>
|
||||
<Popover.Trigger
|
||||
ref={trigger}
|
||||
aria-busy={branches.loading}
|
||||
aria-busy={selectBranchBusy(picker())}
|
||||
aria-description={language.t("command.session.branch.choose")}
|
||||
class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 rounded-sm px-1.5 text-v2-text-text-muted transition-colors hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-base"
|
||||
>
|
||||
@@ -147,17 +175,15 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
<Icon name="chevron-down" size="small" class="-ms-0.5 shrink-0 text-v2-icon-icon-muted" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Show when={initial()}>
|
||||
<Show when={selectBranchInitial(picker())}>
|
||||
{(initial) => (
|
||||
<Popover.Content
|
||||
class="w-[243px] overflow-hidden rounded-lg bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onInteractOutside={() => (outside = true)}
|
||||
onCloseAutoFocus={(event) => {
|
||||
// An outside click keeps focus where the user put it.
|
||||
if (event.defaultPrevented || outside) return (outside = false)
|
||||
event.preventDefault()
|
||||
props.onDone()
|
||||
onInteractOutside={(event) => {
|
||||
if (!event.defaultPrevented) dispatch({ type: "interact.outside" })
|
||||
}}
|
||||
// The machine returns focus to the composer, unless the user moved it outside.
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<Typeahead
|
||||
initial={initial()}
|
||||
@@ -168,8 +194,7 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
const directory = props.workspace.project.root()
|
||||
const items = await props.workspace.branches(query, signal)
|
||||
|
||||
if (!query && directory === props.workspace.project.root())
|
||||
branchActions.mutate({ directory, items, failed: false })
|
||||
if (!query) dispatch({ type: "branches.synced", directory, items })
|
||||
|
||||
return items
|
||||
}}
|
||||
@@ -177,10 +202,7 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
delay={100}
|
||||
key={(branch) => branch}
|
||||
selected={props.workspace.bar.branch()}
|
||||
onSelect={(branch) => {
|
||||
props.workspace.selection.branch(branch)
|
||||
setState("open", false)
|
||||
}}
|
||||
onSelect={(branch) => dispatch({ type: "branch.selected", branch })}
|
||||
>
|
||||
{(branch) => (
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
|
||||
Reference in New Issue
Block a user