fix(app): drive the branch picker and wordmark lodger through state machines (#61986)

This commit is contained in:
Luke Parker
2026-11-05 00:41:12 -06:00
committed by GitHub
Unverified
parent b9ead97f54
commit 008bb307e4
9 changed files with 1430 additions and 273 deletions
@@ -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) {
+32 -36
View File
@@ -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>
+341 -161
View File
@@ -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">