feat(pets): Pokédex generate UI — overlay, animated egg, hatch FX, manage

Dedicated generate modal (Cmd-K → Pets → Generate): prompt → 2×2 draft
grid → egg hatch → preview → adopt, width fits each phase.

- Reuses shared primitives (Button/Input/Dialog/Alert/GenerateButton);
  cards use selectableCardClass; only canvas + range stay raw.
- Animated creme pixel egg + PetStarShower hatch celebration (canvas).
- Live streamed drafts with a real Stop (AbortSignal); clean default name.
- Manage generated pets: badge + top ranking, rename (optimistic), safe
  delete (confirm + drop), export — in both the Cmd-K and Settings lists.
- pet-gallery routes every RPC through profile-scoped petRpc; i18n ×5.
This commit is contained in:
Brooklyn Nicholson 2026-06-24 13:48:45 -05:00
parent aab49f6927
commit 743985bf1e
20 changed files with 2353 additions and 27 deletions

View file

@ -20,6 +20,7 @@ import {
Clock,
Cpu,
Download,
Egg,
Globe,
type IconComponent,
Info,
@ -43,6 +44,7 @@ import {
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { openPetGenerate } from '@/store/pet-generate'
import { runGatewayRestart } from '@/store/system-actions'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
@ -409,6 +411,13 @@ export function CommandPalette() {
keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'],
label: cc.pets.title,
to: 'pets'
},
{
icon: Egg,
id: 'appearance-generate-pet',
keywords: ['pet', 'generate', 'create', 'make', 'new pet', 'mascot', 'hatch', 'ai'],
label: cc.generatePet.title,
run: () => openPetGenerate()
}
]
},
@ -653,6 +662,8 @@ export function CommandPalette() {
event.preventDefault()
event.stopPropagation()
goBack()
return
}
}}
onValueChange={setSearch}
@ -663,7 +674,7 @@ export function CommandPalette() {
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage search={search} />
<PetPalettePage onGenerate={() => { closeCommandPalette(); openPetGenerate() }} search={search} />
) : page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (

View file

@ -15,7 +15,7 @@ import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Loader2, PawPrint } from '@/lib/icons'
import { Check, Egg, Loader2, PawPrint } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$petBusy,
@ -31,9 +31,11 @@ import {
interface PetPalettePageProps {
search: string
/** Navigate to the "generate a pet" page (rendered as a header action). */
onGenerate?: () => void
}
export function PetPalettePage({ search }: PetPalettePageProps) {
export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) {
const { t } = useI18n()
const copy = t.commandCenter.pets
const { requestGateway } = useGatewayRequest()
@ -72,6 +74,24 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
return (
<div role="listbox">
{onGenerate && (
<button
className={cn(
'flex w-full items-center gap-2 rounded-md text-left text-foreground transition-colors hover:bg-(--chrome-action-hover)',
HUD_ITEM,
HUD_TEXT
)}
onClick={onGenerate}
onMouseDown={event => event.preventDefault()}
type="button"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-(--chrome-action-hover)">
<Egg className="size-4" />
</span>
<span className="font-medium">{t.commandCenter.generatePet.title}</span>
</button>
)}
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
{shown.length === 0 ? (
@ -104,7 +124,14 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
url={pet.spritesheetUrl}
/>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{pet.displayName}</span>
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{pet.displayName}</span>
{pet.generated && (
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
{copy.generatedTag}
</span>
)}
</span>
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
{pet.slug}
{pet.installed ? ` · ${copy.installed}` : ''}

View file

@ -108,6 +108,7 @@ import { useKeybinds } from './hooks/use-keybinds'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
@ -1028,6 +1029,7 @@ export function DesktopController() {
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<PetGenerateOverlay />
<SessionSwitcher />
{settingsOpen && (

View file

@ -0,0 +1,469 @@
/**
* "Hatch a Pet" a dedicated, Pokédex-style overlay for pet generation.
*
* Previously generation lived as a cramped nested page inside the Cmd-K command
* palette (~34rem popover). This is its own full Radix dialog with room to
* breathe: a device-framed header, its own concept prompt, a roomy draft grid
* that streams in live, and the egg-hatch + reveal flow. It's a thin view over
* the `pet-generate` store; the store owns the generate hatch adopt steps.
*/
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetEggHatch } from '@/components/pet/pet-egg-hatch'
import { PetStarShower } from '@/components/pet/pet-star-shower'
import { PetSprite } from '@/components/pet/pet-sprite'
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { GenerateButton } from '@/components/ui/generate-button'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Egg, Loader2, PawPrint, RefreshCw } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { type PetInfo } from '@/store/pet'
import {
$petGenDrafts,
$petGenerateOpen,
$petGenError,
$petGenPreview,
$petGenSelected,
$petGenStage,
$petGenStatus,
adoptHatched,
cancelGenerate,
cancelHatch,
cleanPetName,
cleanupPetGen,
closePetGenerate,
discardHatched,
generateDrafts,
hatchSelected
} from '@/store/pet-generate'
const VARIANT_COUNT = 4
const PREVIEW_SCALE = 0.7
const PREVIEW_ROWS = [
'idle',
'waving',
'running-right',
'running-left',
'running',
'review',
'jumping',
'failed',
'waiting'
]
const PREVIEW_STATE_MS = 1400
const ROW_TO_FRAME_KEY: Record<string, string> = {
idle: 'idle',
wave: 'wave',
waving: 'wave',
jump: 'jump',
jumping: 'jump',
run: 'run',
running: 'run',
'running-right': 'run',
'running-left': 'run',
failed: 'failed',
review: 'review',
waiting: 'waiting'
}
function frameCountForRow(pet: PetInfo, row: string): number {
const byState = pet.framesByState
const mapped = ROW_TO_FRAME_KEY[row]
return byState?.[row] ?? (mapped ? byState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
}
export function PetGenerateOverlay() {
const open = useStore($petGenerateOpen)
const status = useStore($petGenStatus)
const { requestGateway } = useGatewayRequest()
const handleOpenChange = (next: boolean) => {
if (!next) {
// Deletes a hatched-but-unadopted preview pet so it doesn't linger, then
// resets all generation state.
cleanupPetGen(requestGateway)
closePetGenerate()
}
}
// The draft screen needs room for the 2×2 grid; the single-pet screens
// (hatch egg, reveal) shrink to the pet's frame so it isn't lost in a wide box.
const single = status === 'hatching' || status === 'preview' || status === 'adopting'
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className={cn('max-w-none gap-4 text-center', single ? 'w-[min(17rem,92vw)]' : 'w-[min(23rem,92vw)]')}
>
{open && <PetGenerateContent />}
</DialogContent>
</Dialog>
)
}
function PetGenerateContent() {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
const { requestGateway } = useGatewayRequest()
const status = useStore($petGenStatus)
const error = useStore($petGenError)
const drafts = useStore($petGenDrafts)
const selected = useStore($petGenSelected)
const preview = useStore($petGenPreview)
const stage = useStore($petGenStage)
const [prompt, setPrompt] = useState('')
const busy = status === 'generating' || status === 'hatching'
const hasDrafts = drafts.length > 0
const generating = status === 'generating'
// The idle "describe a pet" state — egg + suggestions get generous, equidistant
// breathing room (gap-7.5) from the prompt; the working states stay compact.
const isEmptyState =
!hasDrafts &&
!generating &&
status !== 'hatching' &&
status !== 'preview' &&
status !== 'adopting' &&
status !== 'stale'
const close = () => {
cleanupPetGen(requestGateway)
closePetGenerate()
}
const generate = () => {
if (prompt.trim() && !busy) {
void generateDrafts(requestGateway, { prompt: prompt.trim() })
}
}
// One-click an example prompt straight into a draft round.
const runExample = (example: string) => {
setPrompt(example)
void generateDrafts(requestGateway, { prompt: example })
}
// Hatch with a clean default name derived from the prompt (the prompt itself
// is grounding text, not a label); the user names it on the reveal screen.
const hatch = () => {
if (prompt.trim()) {
void hatchSelected(requestGateway, { name: cleanPetName(prompt), prompt: prompt.trim() })
}
}
const adopt = (finalName: string) => {
void adoptHatched(requestGateway, finalName).then(out => {
if (out.ok) {
triggerHaptic('crisp')
close()
}
})
}
// The header title tracks the phase instead of sticking on "Generate a pet".
const headerTitle =
status === 'hatching' ? copy.spawning : status === 'preview' || status === 'adopting' ? copy.hatched : copy.title
// Prompt input only belongs on the describe/draft screens.
const showPrompt = status !== 'hatching' && status !== 'preview' && status !== 'adopting'
return (
<>
<DialogHeader>
<DialogTitle icon={Egg}>{headerTitle}</DialogTitle>
</DialogHeader>
<div className={cn('flex min-h-0 flex-1 flex-col', isEmptyState ? 'gap-4' : 'gap-2.5')}>
{/* Concept prompt with the inline sparkle generate/stop affordance (the
same primitive as the commit-message + project-idea fields). */}
{showPrompt && (
<div className="relative">
<Input
autoFocus
className="pr-9"
onChange={event => setPrompt(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
generate()
}
}}
placeholder={copy.placeholder}
value={prompt}
/>
<GenerateButton
className="absolute right-1 top-1/2 -translate-y-1/2"
disabled={!prompt.trim()}
generating={generating}
generatingLabel={t.common.cancel}
label={copy.generate}
onCancel={cancelGenerate}
onGenerate={generate}
/>
</div>
)}
{error && status !== 'preview' && status !== 'adopting' && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{status === 'stale' ? (
<Alert variant="destructive">
<AlertDescription>{copy.staleBackend}</AlertDescription>
</Alert>
) : status === 'hatching' ? (
<HatchingView stage={stage} />
) : (status === 'preview' || status === 'adopting') && preview ? (
<HatchPreview
adopting={status === 'adopting'}
error={error}
onAdopt={adopt}
onDiscard={() => void discardHatched(requestGateway)}
pet={preview}
/>
) : !hasDrafts && !generating ? (
<EmptyHint onExample={runExample} />
) : (
<DraftGrid
busy={busy}
drafts={drafts}
generating={generating}
hasDrafts={hasDrafts}
onHatch={hatch}
onSelect={index => $petGenSelected.set(index)}
selected={selected}
/>
)}
</div>
</>
)
}
// Creative seed prompts — specifics make better pets (petdex's own advice).
// Doubling as guidance and a one-click way to see the flow.
const EXAMPLE_PROMPTS = ['a bubble-tea otter', 'a tiny sock elf', 'a pixel dragon', 'a grumpy office cat', 'a neon axolotl']
function EmptyHint({ onExample }: { onExample: (prompt: string) => void }) {
return (
<div className="flex flex-col items-center gap-2">
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">Need a spark?</p>
<div className="flex flex-wrap items-center justify-center gap-1.5">
{EXAMPLE_PROMPTS.map(example => (
<Button className="rounded-full" key={example} onClick={() => onExample(example)} size="xs" variant="outline">
{example}
</Button>
))}
</div>
</div>
)
}
function HatchingView({ stage }: { stage: { phase: string; state?: string; done?: number; total?: number } | null }) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
const subtitle = stage
? stage.phase === 'row'
? copy.hatchRow(stage.state ?? '', stage.done ?? 0, stage.total ?? 0)
: stage.phase === 'compose'
? copy.hatchComposing
: copy.hatchSaving
: copy.hatchingSub
return <PetEggHatch cancelLabel={t.common.cancel} onCancel={cancelHatch} subtitle={subtitle} />
}
interface DraftGridProps {
busy: boolean
drafts: { index: number; dataUri: string }[]
generating: boolean
hasDrafts: boolean
onHatch: () => void
onSelect: (index: number) => void
selected: number | null
}
function DraftGrid({ busy, drafts, generating, hasDrafts, onHatch, onSelect, selected }: DraftGridProps) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
const slots = generating
? Array.from({ length: VARIANT_COUNT }, (_, i) => drafts.find(draft => draft.index === i) ?? null)
: drafts
return (
<div className="flex flex-col gap-2">
{generating && (
<div className="flex items-center justify-between text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span className="shimmer">{copy.generating}</span>
<span className="tabular-nums">
{drafts.length}/{VARIANT_COUNT}
</span>
</div>
)}
<div className="grid grid-cols-2 gap-2">
{slots.map((draft, i) => {
const isSelected = !generating && draft != null && selected === draft.index
return (
<button
className={cn(
'relative flex aspect-[192/208] items-center justify-center overflow-hidden',
selectableCardClass({ active: isSelected, prominent: true })
)}
disabled={generating || busy || draft == null}
key={draft ? `draft-${draft.index}` : `slot-${i}`}
onClick={() => draft != null && onSelect(draft.index)}
type="button"
>
{draft != null ? (
// Hatches into place as each draft streams back.
<img alt="" className="pet-reveal size-full object-contain p-1.5" draggable={false} src={draft.dataUri} />
) : (
// Incubating: a creme egg resting on its contact shadow.
<div className="relative z-10 flex flex-col items-center">
<PixelEggSprite index={i} mode="bounce" size={48} />
<span className="pet-egg-shadow pet-egg-shadow--sm mt-1" />
</div>
)}
</button>
)
})}
</div>
{hasDrafts && (
<Button className="w-full" disabled={busy || selected === null} onClick={onHatch}>
<PawPrint />
{copy.hatch}
</Button>
)}
</div>
)
}
interface HatchPreviewProps {
pet: PetInfo
adopting: boolean
error: string | null
onAdopt: (name: string) => void
onDiscard: () => void
}
function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: HatchPreviewProps) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
// Empty so the "Name your pet" placeholder shows; blank adopt keeps the
// provisional name from the prompt.
const [name, setName] = useState('')
// Play the egg's crack/hatch frames once before swapping in the live pet.
const [revealed, setRevealed] = useState(false)
// Right after the egg cracks the pet plays its "yay" jump a couple times, then
// hands off to the normal state-cycling preview.
const [celebrating, setCelebrating] = useState(false)
const [stateIndex, setStateIndex] = useState(0)
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
const rows = previewRows.length > 0 ? previewRows : ['idle']
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
const canJump = frameCountForRow(pet, 'jumping') > 0
const rowOverride = celebrating && canJump ? 'jumping' : activeRow
useEffect(() => {
const id = setInterval(() => setStateIndex(i => (i + 1) % rows.length), PREVIEW_STATE_MS)
return () => clearInterval(id)
}, [rows.length])
// On reveal: celebrate (jump) ~2 loops, then drop into the cycling preview.
useEffect(() => {
if (!revealed) {
return
}
setCelebrating(true)
const id = setTimeout(() => {
setCelebrating(false)
setStateIndex(0)
}, 2 * (pet.loopMs ?? 1100))
return () => clearTimeout(id)
}, [revealed, pet.loopMs])
useEffect(() => {
setStateIndex(0)
setName('')
setRevealed(false)
setCelebrating(false)
}, [pet.slug])
const previewInfo: PetInfo = { ...pet, scale: PREVIEW_SCALE }
return (
<div className="flex flex-col items-center gap-2 py-1">
{/* Fills the (now narrow) dialog so the pet frame is the screen width. */}
<div className="relative flex aspect-[192/208] w-full items-center justify-center overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)">
{revealed ? (
<>
<div className="pet-reveal">
<PetSprite info={previewInfo} rowOverride={rowOverride} />
</div>
<PetStarShower />
</>
) : (
// The egg cracks open, then we swap in the live pet.
<PixelEggSprite
mode="hatch"
onDone={() => {
setRevealed(true)
triggerHaptic('crisp')
}}
size={150}
/>
)}
</div>
<Input
autoFocus
className="w-full"
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
onAdopt(name)
}
}}
placeholder={copy.namePlaceholder}
value={name}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex w-full items-center gap-1.5">
<Button disabled={adopting} onClick={onDiscard} variant="ghost">
<RefreshCw />
{copy.startOver}
</Button>
<Button className="flex-1" disabled={adopting} onClick={() => onAdopt(name)}>
{adopting ? <Loader2 className="animate-spin" /> : <PawPrint />}
{copy.adopt}
</Button>
</div>
</div>
)
}

View file

@ -1,12 +1,16 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { type ReactNode, useEffect, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2, PawPrint, Trash2 } from '@/lib/icons'
import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { $petInfo } from '@/store/pet'
@ -16,13 +20,16 @@ import {
$petGalleryError,
$petGalleryStatus,
adoptPet,
exportPet as exportPetAction,
loadPetGallery,
loadPetThumb,
PET_SCALE_DEFAULT,
PET_SCALE_MAX,
PET_SCALE_MIN,
type GalleryPet,
rankedGalleryPets,
removePet as removePetAction,
renamePet as renamePetAction,
setPetEnabled,
setPetScale
} from '@/store/pet-gallery'
@ -48,6 +55,9 @@ export function PetSettings() {
const busySlug = useStore($petBusy)
const petInfo = useStore($petInfo)
const [query, setQuery] = useState('')
const [confirmDelete, setConfirmDelete] = useState<GalleryPet | null>(null)
const [renameTarget, setRenameTarget] = useState<GalleryPet | null>(null)
const [renameValue, setRenameValue] = useState('')
const scale = petInfo.scale ?? PET_SCALE_DEFAULT
useEffect(() => {
@ -71,6 +81,23 @@ export function PetSettings() {
void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const exportPet = (slug: string) => {
void exportPetAction(requestGateway, slug, copy.exportFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const saveRename = () => {
if (!renameTarget || !renameValue.trim()) {
return
}
// Optimistic: the rename paints instantly, so close now and let the RPC
// settle in the background (it rolls back + surfaces an error on failure).
const { slug } = renameTarget
setRenameTarget(null)
triggerHaptic('crisp')
void renamePetAction(requestGateway, slug, renameValue, copy.renameFailed(slug))
}
const toggle = (on: boolean) => {
void setPetEnabled(requestGateway, on, {
noneAvailable: copy.noneAvailable,
@ -142,8 +169,15 @@ export function PetSettings() {
url={pet.spritesheetUrl}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
{pet.displayName}
<span className="flex items-center gap-1.5">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{pet.displayName}
</span>
{pet.generated && (
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
{copy.generatedTag}
</span>
)}
</span>
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{pet.slug}
@ -152,16 +186,36 @@ export function PetSettings() {
</span>
{isBusy && <Loader2 className="size-4 shrink-0 animate-spin text-(--ui-text-tertiary)" />}
</button>
{pet.installed && !isBusy && (
<button
aria-label={copy.uninstall(pet.displayName)}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => void removePet(pet.slug)}
title={copy.uninstall(pet.displayName)}
type="button"
>
<Trash2 className="size-3.5" />
</button>
{!isBusy && (pet.installed || pet.generated) && (
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition focus-within:opacity-100 group-hover:opacity-100">
{pet.generated && (
<PetAction
icon={<Pencil className="size-3.5" />}
label={copy.rename(pet.displayName)}
onClick={() => {
setRenameValue(pet.displayName)
setRenameTarget(pet)
}}
/>
)}
{pet.generated && (
<PetAction
icon={<Download className="size-3.5" />}
label={copy.exportPet(pet.displayName)}
onClick={() => exportPet(pet.slug)}
/>
)}
{pet.installed && (
// Generated pets have no remote source — deletion is
// permanent, so confirm; petdex pets just uninstall.
<PetAction
danger
icon={<Trash2 className="size-3.5" />}
label={pet.generated ? copy.delete(pet.displayName) : copy.uninstall(pet.displayName)}
onClick={() => (pet.generated ? setConfirmDelete(pet) : removePet(pet.slug))}
/>
)}
</div>
)}
</div>
)
@ -226,6 +280,80 @@ export function PetSettings() {
/>
)}
</div>
<ConfirmDialog
confirmLabel={copy.deleteConfirm}
description={copy.deleteBody}
destructive
onClose={() => setConfirmDelete(null)}
onConfirm={async () => {
if (confirmDelete) {
const ok = await removePetAction(requestGateway, confirmDelete.slug, copy.uninstallFailed(confirmDelete.slug))
if (!ok) {
throw new Error(copy.uninstallFailed(confirmDelete.slug))
}
triggerHaptic('crisp')
}
}}
open={confirmDelete !== null}
title={confirmDelete ? copy.deleteTitle(confirmDelete.displayName) : ''}
/>
<Dialog onOpenChange={open => !open && setRenameTarget(null)} open={renameTarget !== null}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{copy.renameTitle}</DialogTitle>
</DialogHeader>
<Input
autoFocus
onChange={event => setRenameValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
saveRename()
}
}}
placeholder={copy.renamePlaceholder}
value={renameValue}
/>
<DialogFooter>
<Button onClick={() => setRenameTarget(null)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={!renameValue.trim()} onClick={saveRename}>
{copy.renameSave}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
/** A single hover-revealed icon action on a pet card (rename / export / delete). */
function PetAction({
danger,
icon,
label,
onClick
}: {
danger?: boolean
icon: ReactNode
label: string
onClick: () => void
}) {
return (
<button
aria-label={label}
className={cn(
'grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) backdrop-blur-sm transition',
danger ? 'hover:text-(--ui-red)' : 'hover:text-foreground'
)}
onClick={onClick}
title={label}
type="button"
>
{icon}
</button>
)
}

View file

@ -0,0 +1,66 @@
/**
* Egg-hatch visuals for the pet generation flow (Cmd-K Pets Generate).
*
* `PetEggHatch` is the incubation beat shown while `pet.hatch` runs: a wobbling
* egg that reads as "something is about to hatch" instead of a bare spinner. The
* reveal celebration is the canvas `PetStarShower`. Motion is disabled under
* `prefers-reduced-motion`.
*/
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Button } from '@/components/ui/button'
interface PetEggHatchProps {
subtitle?: string
onCancel?: () => void
cancelLabel?: string
}
/**
* Thin progress bar. Determinate when given done/total (hatch rows stream one by
* one, so a real percentage is meaningful); indeterminate otherwise (drafts
* return together, so a count would just snap 0100).
*/
export function PetProgress({ done, total }: { done?: number; total?: number }) {
const determinate = typeof done === 'number' && typeof total === 'number' && total > 0
const pct = determinate ? Math.min(100, Math.round((done / total) * 100)) : 0
return (
<div
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={determinate ? pct : undefined}
className="pet-progress"
role="progressbar"
>
{determinate ? (
<div className="pet-progress__fill" style={{ width: `${pct}%` }} />
) : (
<div className="pet-progress__indeterminate" />
)}
</div>
)
}
export function PetEggHatch({ subtitle, onCancel, cancelLabel }: PetEggHatchProps) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-2 py-5">
<div className="flex flex-col items-center">
<PixelEggSprite mode="bounce" size={88} />
<span className="pet-egg-shadow mt-1.5" />
</div>
{subtitle && (
<p className="shimmer max-w-[15rem] text-center text-[length:var(--conversation-caption-font-size)] leading-snug">
{subtitle}
</p>
)}
{onCancel && (
<Button onClick={onCancel} size="xs" variant="text">
{cancelLabel ?? 'Cancel'}
</Button>
)}
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -32,10 +32,33 @@ const STATE_ALIASES: Record<PetState, string[]> = {
waiting: ['waiting']
}
const ROW_TO_STATE: Record<string, PetState> = {
idle: 'idle',
wave: 'wave',
waving: 'wave',
jump: 'jump',
jumping: 'jump',
run: 'run',
running: 'run',
'running-right': 'run',
'running-left': 'run',
failed: 'failed',
review: 'review',
waiting: 'waiting'
}
interface PetSpriteProps {
info: PetInfo
/** On-screen scale multiplier applied on top of the pet's native scale. */
zoom?: number
/**
* Force a specific animation state instead of reading the live `$petState`.
* Used by the generate-flow preview to showcase every row without driving (or
* being driven by) the real agent activity that moves the floating mascot.
*/
stateOverride?: PetState
/** Force a concrete row name from `info.stateRows` (e.g. `running-right`). */
rowOverride?: string
}
/**
@ -49,9 +72,20 @@ interface PetSpriteProps {
* with `memo`, this component effectively never re-renders after mount until
* the pet itself changes.
*/
function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSpriteProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const stateRef = useRef<PetState>($petState.get())
const overrideRef = useRef<PetState | undefined>(stateOverride)
const rowOverrideRef = useRef<string | undefined>(rowOverride)
// Keep the override current without re-running the RAF setup effect.
useEffect(() => {
overrideRef.current = stateOverride
}, [stateOverride])
useEffect(() => {
rowOverrideRef.current = rowOverride
}, [rowOverride])
const frameW = info.frameW ?? DEFAULT_FRAME_W
const frameH = info.frameH ?? DEFAULT_FRAME_H
@ -116,6 +150,7 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
// than flashing blank padding.
const resolve = (s: PetState): { row: number; count: number } => {
const real = framesByState?.[s] ?? frames
if (real > 0) {
return { row: rowIndexForState(s), count: real }
}
@ -123,8 +158,16 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) }
}
const resolveRow = (rowName: string): { row: number; count: number } => {
const row = rows.indexOf(rowName)
const state = ROW_TO_STATE[rowName]
const count = Math.max(1, framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames)
return { row: row >= 0 ? row : rowIndexForState(state ?? 'idle'), count }
}
const render = (now: number) => {
const { row, count } = resolve(stateRef.current)
const forcedRow = rowOverrideRef.current
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current)
// Per-state step keeps every state's loop ~loopMs even when frame counts
// differ; counts vary per row so derive the cadence here, not once.
const stepMs = loopMs / count

View file

@ -0,0 +1,204 @@
import { useEffect, useRef } from 'react'
/**
* Canvas hatch celebration layered over a freshly revealed pet: a one-shot
* sunburst of rotating god-rays, a fast radial star burst (confetti physics
* velocity + decay + gravity + spin), and a light trickle of rising twinkle
* motes. Additive (`lighter`) so the sparkles bloom. No glow-halo flash.
*
* Sized to its container (absolute inset-0, pointer-events: none) and disabled
* under `prefers-reduced-motion`.
*/
const GOLD = '#ffd76a'
const BURST = 15
const VELOCITY = 500
const DECAY = 0.9
const GRAVITY = 90
const RAY_COUNT = 24
const GOLD_MIX = 0.6
const MOTE_MS = 333 // ~3 / sec
interface Star {
x: number
y: number
vx: number
vy: number
size: number
rot: number
vrot: number
phase: number
twinkle: number
life: number
ttl: number
color: string
rise: boolean
}
function readAccent(el: HTMLElement): string {
return getComputedStyle(el).getPropertyValue('--ui-accent').trim() || '#9aa0ff'
}
function sparkle(ctx: CanvasRenderingContext2D, size: number, rot: number, color: string): void {
ctx.rotate(rot)
ctx.fillStyle = color
for (const [rx, ry] of [
[size, size * 0.26],
[size * 0.26, size]
]) {
ctx.beginPath()
ctx.moveTo(0, -ry)
ctx.lineTo(rx, 0)
ctx.lineTo(0, ry)
ctx.lineTo(-rx, 0)
ctx.closePath()
ctx.fill()
}
const core = Math.max(1, Math.round(size * 0.4))
ctx.fillStyle = '#fff'
ctx.fillRect(-core / 2, -core / 2, core, core)
}
export function PetStarShower() {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
const parent = canvas?.parentElement
if (!canvas || !ctx || !parent) {
return
}
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
return
}
const accent = readAccent(canvas)
const dpr = Math.min(window.devicePixelRatio || 1, 3)
let w = 0
let h = 0
let cx = 0
let cy = 0
const resize = () => {
const r = parent.getBoundingClientRect()
w = r.width
h = r.height
cx = w / 2
cy = h * 0.54
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(parent)
const pick = () => (Math.random() < GOLD_MIX ? GOLD : Math.random() < 0.5 ? accent : '#ffffff')
const stars: Star[] = []
for (let i = 0; i < BURST; i++) {
const a = Math.random() * Math.PI * 2
const sp = VELOCITY * (0.4 + Math.random() * 0.7)
stars.push({
x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
size: 3.5 + Math.random() * 5.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 8,
phase: 0, twinkle: 0, life: 0, ttl: 0.8 + Math.random() * 0.7, color: pick(), rise: false
})
}
const rays = { life: 0, ttl: 0.9, rot: Math.random() * 6.28 }
let raf = 0
let last = performance.now()
let acc = 0
let raysAlive = true
const tick = (now: number) => {
raf = requestAnimationFrame(tick)
const ms = now - last
last = now
const dt = Math.min(0.05, ms / 1000)
const decay = Math.pow(DECAY, dt * 60)
acc += ms
if (acc >= MOTE_MS && stars.length < 40) {
acc = 0
stars.push({
x: cx + (Math.random() - 0.5) * w * 0.85, y: cy + Math.random() * h * 0.25,
vx: (Math.random() - 0.5) * 14, vy: -(14 + Math.random() * 26),
size: 2.5 + Math.random() * 3.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 2,
phase: Math.random() * 6.28, twinkle: 5 + Math.random() * 4, life: 0, ttl: 1.2 + Math.random(),
color: pick(), rise: true
})
}
ctx.clearRect(0, 0, w, h)
ctx.globalCompositeOperation = 'lighter'
// Sunburst god-rays — one-shot bloom + slow spin.
if (raysAlive) {
rays.life += dt
rays.rot += dt * 0.6
const t = rays.life / rays.ttl
if (t >= 1) {
raysAlive = false
} else {
const len = Math.max(w, h) * 0.62 * (1 - (1 - t) ** 2)
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(rays.rot)
for (let i = 0; i < RAY_COUNT; i++) {
ctx.rotate((Math.PI * 2) / RAY_COUNT)
const a = (1 - t) * 0.3 * (i % 2 ? 0.65 : 1)
const wd = len * 0.05
const g = ctx.createLinearGradient(0, 0, 0, -len)
g.addColorStop(0, `rgba(255,255,255,${a})`)
g.addColorStop(1, 'rgba(255,255,255,0)')
ctx.fillStyle = g
ctx.beginPath()
ctx.moveTo(-wd, 0)
ctx.lineTo(wd, 0)
ctx.lineTo(0, -len)
ctx.closePath()
ctx.fill()
}
ctx.restore()
}
}
for (let i = stars.length - 1; i >= 0; i--) {
const s = stars[i]
s.life += dt
if (s.rise) {
s.vy += 7 * dt
s.phase += s.twinkle * dt
} else {
s.vx *= decay
s.vy = s.vy * decay + GRAVITY * dt
}
s.x += s.vx * dt
s.y += s.vy * dt
s.rot += s.vrot * dt
if (s.life >= s.ttl || s.y < -12) {
stars.splice(i, 1)
continue
}
const fade = s.rise
? Math.min(1, s.life * 5, (s.ttl - s.life) * 3) * (0.45 + 0.55 * Math.abs(Math.sin(s.phase)))
: Math.min(1, (s.ttl - s.life) * 3)
ctx.save()
ctx.globalAlpha = fade
ctx.translate(Math.round(s.x), Math.round(s.y))
sparkle(ctx, s.size, s.rot, s.color)
ctx.restore()
}
ctx.globalCompositeOperation = 'source-over'
}
raf = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
}
}, [])
return <canvas className="pointer-events-none absolute inset-0 z-10 h-full w-full" ref={canvasRef} />
}

View file

@ -0,0 +1,234 @@
import { type CSSProperties, useEffect, useRef } from 'react'
import eggSheetUrl from './pet-egg-sheet.png'
/**
* Animated pixel egg the iamcrog "bouncing hatching egg" 12-frame sheet
* (32×32 cells, stacked vertically), drawn to a canvas and recolored to a warm
* white/creme shell.
*
* The sheet's shell is mid-gray, so a plain multiply only darkens it (still
* gray). Instead we remap each pixel's luminance through a creme ramp via a 256-
* entry LUT: near-black stays a warm dark outline, midtones become creme shadow,
* highlights go near-white. Done on a 32×32 offscreen then nearest-neighbor
* scaled up so it stays crisp.
*
* Frames 05 are the intact squash/stretch bounce; 611 are the crack/hatch.
* `mode="bounce"` loops 05 (never shows a crack); `mode="hatch"` plays 611
* once then calls onDone.
*/
const FRAME = 32
const TOTAL_FRAMES = 12
const BOUNCE_FRAMES = 6 // 0..5 — intact egg only; cracks start at frame 6
const HATCH_START = 6 // first crack frame
// Per-frame speed *while* a bounce is playing.
const BOUNCE_MS = 250
const HATCH_MS = 190
// Harvest-Moon idle: the egg rests on frame 0 for a long, randomized gap between
// bounces so it reads as "occasionally stirs", not "constantly animating".
const REST_MIN_MS = 2600
const REST_MAX_MS = 6200
// Creme ramp endpoints: warm dark outline → creme shadow → near-white highlight.
const OUTLINE: [number, number, number] = [78, 66, 58]
const SHADOW: [number, number, number] = [214, 198, 168]
const HIGHLIGHT: [number, number, number] = [253, 249, 238]
const OUTLINE_CUTOFF = 46
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
// Precompute the luminance→creme mapping once (shared across every egg). Below
// the cutoff it's the flat outline; above, a SHADOW→HIGHLIGHT ramp.
const CREME_LUT = (() => {
const lut = new Uint8ClampedArray(256 * 3)
for (let g = 0; g < 256; g++) {
const dark = g < OUTLINE_CUTOFF
const t = dark ? 0 : (g - OUTLINE_CUTOFF) / (255 - OUTLINE_CUTOFF)
const from = dark ? OUTLINE : SHADOW
const to = dark ? OUTLINE : HIGHLIGHT
lut.set([lerp(from[0], to[0], t), lerp(from[1], to[1], t), lerp(from[2], to[2], t)], g * 3)
}
return lut
})()
let _sheet: HTMLImageElement | null = null
let _sheetLoading: Promise<HTMLImageElement> | null = null
function loadSheet(): Promise<HTMLImageElement> {
if (_sheet?.complete) {
return Promise.resolve(_sheet)
}
if (!_sheetLoading) {
_sheetLoading = new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
_sheet = img
resolve(img)
}
img.onerror = reject
img.src = eggSheetUrl
})
}
return _sheetLoading
}
interface PixelEggSpriteProps {
mode: 'bounce' | 'hatch'
/** On-screen size (px, square). */
size: number
/**
* Slot position in a grid of eggs. Used to deterministically spread each egg's
* first bounce across the rest window so neighbours never stir together (random
* jitter alone can collide with only a handful of eggs).
*/
index?: number
className?: string
style?: CSSProperties
/** Fired once when a `hatch` run reaches the final frame. */
onDone?: () => void
}
export function PixelEggSprite({ mode, size, index = 0, className, style, onDone }: PixelEggSpriteProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const onDoneRef = useRef(onDone)
onDoneRef.current = onDone
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) {
return
}
const dpr = Math.min(window.devicePixelRatio || 1, 3)
const dim = Math.round(size * dpr)
canvas.width = dim
canvas.height = dim
const lastFrame = TOTAL_FRAMES - 1
// Mild per-egg speed jitter so bounces don't feel mechanical.
const frameMs = (mode === 'bounce' ? BOUNCE_MS : HATCH_MS) * (0.85 + Math.random() * 0.3)
const restMs = () => REST_MIN_MS + Math.random() * (REST_MAX_MS - REST_MIN_MS)
// First bounce: a deterministic per-slot slice of the rest window (so two
// eggs never start together) plus a little random jitter on top.
const firstDelay = ((index % 4) + 1) * (REST_MIN_MS / 4) + Math.random() * REST_MIN_MS
// 32×32 offscreen we recolor per frame, then scale up nearest-neighbor.
const off = document.createElement('canvas')
off.width = FRAME
off.height = FRAME
const offCtx = off.getContext('2d', { willReadFrequently: true })
let sheet: HTMLImageElement | null = null
void loadSheet().then(img => {
sheet = img
})
const render = (frame: number) => {
if (!sheet || !offCtx) {
return
}
offCtx.clearRect(0, 0, FRAME, FRAME)
offCtx.imageSmoothingEnabled = false
offCtx.drawImage(sheet, 0, frame * FRAME, FRAME, FRAME, 0, 0, FRAME, FRAME)
const img = offCtx.getImageData(0, 0, FRAME, FRAME)
const d = img.data
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] === 0) {
continue
}
const g = d[i] * 3
d[i] = CREME_LUT[g]
d[i + 1] = CREME_LUT[g + 1]
d[i + 2] = CREME_LUT[g + 2]
}
offCtx.putImageData(img, 0, 0)
ctx.clearRect(0, 0, dim, dim)
ctx.imageSmoothingEnabled = false
ctx.drawImage(off, 0, 0, FRAME, FRAME, 0, 0, dim, dim)
}
let raf = 0
let step = 0
let finished = false
// bounce: `nextAt` is when the next thing happens — the next bounce frame, or
// the start of a new bounce after a rest. hatch: `lastHatch` time-gates frames.
let resting = mode === 'bounce'
let nextAt = 0
let lastHatch = 0
const tick = (now: number) => {
raf = requestAnimationFrame(tick)
if (!sheet) {
return
}
if (mode === 'hatch') {
if (!lastHatch) {
lastHatch = now
render(HATCH_START)
return
}
if (now - lastHatch < frameMs) {
return
}
lastHatch = now
const frame = Math.min(HATCH_START + step, lastFrame)
render(frame)
if (frame >= lastFrame) {
if (!finished) {
finished = true
onDoneRef.current?.()
}
return // hold the cracked-open last frame
}
step += 1
return
}
// bounce: rest on frame 0, play 0..5, then rest again.
if (!nextAt) {
render(0)
nextAt = now + firstDelay // staggered first bounce, per slot
return
}
if (now < nextAt) {
return
}
if (resting) {
resting = false
step = 0
render(0)
nextAt = now + frameMs
return
}
step += 1
if (step >= BOUNCE_FRAMES) {
resting = true
render(0)
nextAt = now + restMs()
return
}
render(step)
nextAt = now + frameMs
}
raf = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(raf)
}
}, [mode, size, index])
return (
<canvas
className={className}
ref={canvasRef}
style={{ width: size, height: size, imageRendering: 'pixelated', ...style }}
/>
)
}

View file

@ -0,0 +1,62 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface GenerateButtonProps extends Omit<React.ComponentProps<typeof Button>, 'children' | 'onClick'> {
/** True while a generation is in flight. */
generating: boolean
/** Start a generation. */
onGenerate: () => void
/** Cancel an in-flight generation. When omitted, the button just spins while
* generating (for one-shots that can't be cancelled). */
onCancel?: () => void
/** Tooltip + aria label at rest (and while generating if no `generatingLabel`). */
label: string
/** Tooltip while generating (e.g. "Stop" with cancel, "Generating…" without). */
generatingLabel?: string
iconSize?: number | string
}
/** The sparkle "generate with AI" affordance icon + tooltip, shared by the
* commit-message box and the new-project idea field so they stay one pattern.
* Sparkle click generates; with `onCancel`, a Stop square appears mid-run;
* without it, the sparkle spins until the one-shot resolves. */
export function GenerateButton({
generating,
onGenerate,
onCancel,
label,
generatingLabel,
disabled,
iconSize = 12,
className,
...rest
}: GenerateButtonProps) {
const tip = generating ? (generatingLabel ?? label) : label
const cancellable = generating && !!onCancel
return (
<Tip label={tip}>
<Button
aria-label={tip}
className={cn('text-muted-foreground/80 hover:text-foreground', className)}
disabled={generating ? !onCancel : disabled}
onClick={cancellable ? onCancel : onGenerate}
size="icon-xs"
type="button"
variant="ghost"
{...rest}
>
{cancellable ? (
<Square className="fill-current" size={11} />
) : (
<Codicon name="sparkle" size={iconSize} spinning={generating} />
)}
</Button>
</Tip>
)
}

View file

@ -389,11 +389,23 @@ export const en: Translations = {
unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.",
noMatch: query => `No pets match "${query}".`,
installedTag: 'installed',
generatedTag: 'Generated',
countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`,
count: n => `${n} pet${n === 1 ? '' : 's'}.`,
uninstall: name => `Uninstall ${name}`,
delete: name => `Delete ${name}`,
deleteTitle: name => `Delete ${name}?`,
deleteBody: "This permanently deletes the pet — it can't be reinstalled.",
deleteConfirm: 'Delete',
rename: name => `Rename ${name}`,
renameTitle: 'Rename pet',
renamePlaceholder: 'Name your pet',
renameSave: 'Save',
exportPet: name => `Export ${name}`,
adoptFailed: slug => `Could not adopt ${slug}`,
uninstallFailed: slug => `Could not uninstall ${slug}`,
renameFailed: slug => `Could not rename ${slug}`,
exportFailed: slug => `Could not export ${slug}`,
noneAvailable: 'No pets available to turn on right now.',
turnOnFailed: 'Could not turn the pet on.',
turnOffFailed: 'Could not turn the pet off.'
@ -760,10 +772,32 @@ export const en: Translations = {
turnOff: 'Turn off',
turnOn: 'Turn on',
installed: 'Installed',
generatedTag: 'Generated',
adoptFailed: 'Could not adopt that pet.',
toggleFailed: 'Could not toggle the pet.',
noneAvailable: 'No pets available — pick one below to install.'
},
generatePet: {
title: 'Generate a pet',
placeholder: 'Describe a pet to generate…',
promptHint: 'Type a description, then press Enter to draft four looks.',
readyHint: 'Press Enter to draft four looks from your description.',
generate: 'Generate',
generating: 'Generating…',
retry: 'Retry',
hatch: 'Hatch',
spawning: 'Spawning…',
hatching: 'Hatching your pet…',
hatchingSub: 'Bringing every frame to life — this takes a moment.',
hatched: 'It hatched!',
hatchRow: (state, done, total) => `Drawing ${state}${done}/${total}`,
hatchComposing: 'Composing the spritesheet…',
hatchSaving: 'Saving your pet…',
namePlaceholder: 'Name your pet',
staleBackend: 'Update Hermes to generate pets.',
adopt: 'Adopt',
startOver: 'Start over'
},
installTheme: {
title: 'Install theme...',
placeholder: 'Search the VS Code Marketplace...',

View file

@ -304,11 +304,23 @@ export const ja = defineLocale({
unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。',
noMatch: query => `${query}」に一致するペットがありません。`,
installedTag: 'インストール済み',
generatedTag: '生成',
countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`,
count: n => `${n} 件のペット。`,
uninstall: name => `${name} をアンインストール`,
delete: name => `${name} を削除`,
deleteTitle: name => `${name} を削除しますか?`,
deleteBody: 'ペットを完全に削除します。再インストールはできません。',
deleteConfirm: '削除',
rename: name => `${name} の名前を変更`,
renameTitle: 'ペットの名前を変更',
renamePlaceholder: 'ペットに名前を付ける',
renameSave: '保存',
exportPet: name => `${name} をエクスポート`,
adoptFailed: slug => `${slug} を採用できませんでした`,
uninstallFailed: slug => `${slug} をアンインストールできませんでした`,
renameFailed: slug => `${slug} の名前を変更できませんでした`,
exportFailed: slug => `${slug} をエクスポートできませんでした`,
noneAvailable: 'オンにできるペットがありません。',
turnOnFailed: 'ペットをオンにできませんでした。',
turnOffFailed: 'ペットをオフにできませんでした。'
@ -880,10 +892,32 @@ export const ja = defineLocale({
turnOff: 'オフ',
turnOn: 'オン',
installed: 'インストール済み',
generatedTag: '生成',
adoptFailed: 'ペットを採用できませんでした。',
toggleFailed: 'ペットを切り替えできませんでした。',
noneAvailable: '利用可能なペットがありません。'
},
generatePet: {
title: 'ペットを生成',
placeholder: '生成するペットを説明…',
promptHint: '説明を入力して Enter を押すと、4 つの見た目を生成します。',
readyHint: 'Enter を押すと、説明から 4 つの見た目を生成します。',
generate: '生成',
generating: '生成中…',
retry: '再試行',
hatch: '孵化',
spawning: 'スポーン中…',
hatching: 'ペットを孵化しています…',
hatchingSub: 'すべてのフレームに命を吹き込んでいます。少々お待ちください。',
hatched: '孵化しました!',
hatchRow: (state, done, total) => `${state} を描画中… ${done}/${total}`,
hatchComposing: 'スプライトシートを合成中…',
hatchSaving: 'ペットを保存中…',
namePlaceholder: 'ペットに名前を付ける',
staleBackend: 'ペットを生成するには Hermes を更新してください。',
adopt: '迎え入れる',
startOver: 'やり直す'
},
installTheme: {
title: 'テーマをインストール...',
placeholder: 'VS Code Marketplace を検索...',

View file

@ -284,11 +284,23 @@ export interface Translations {
unreachable: string
noMatch: (query: string) => string
installedTag: string
generatedTag: string
countCapped: (cap: number, total: number) => string
count: (n: number) => string
uninstall: (name: string) => string
delete: (name: string) => string
deleteTitle: (name: string) => string
deleteBody: string
deleteConfirm: string
rename: (name: string) => string
renameTitle: string
renamePlaceholder: string
renameSave: string
exportPet: (name: string) => string
adoptFailed: (slug: string) => string
uninstallFailed: (slug: string) => string
renameFailed: (slug: string) => string
exportFailed: (slug: string) => string
noneAvailable: string
turnOnFailed: string
turnOffFailed: string
@ -635,10 +647,32 @@ export interface Translations {
turnOff: string
turnOn: string
installed: string
generatedTag: string
adoptFailed: string
toggleFailed: string
noneAvailable: string
}
generatePet: {
title: string
placeholder: string
promptHint: string
readyHint: string
generate: string
generating: string
retry: string
hatch: string
spawning: string
hatching: string
hatchingSub: string
hatched: string
hatchRow: (state: string, done: number, total: number) => string
hatchComposing: string
hatchSaving: string
namePlaceholder: string
staleBackend: string
adopt: string
startOver: string
}
installTheme: {
title: string
placeholder: string

View file

@ -291,11 +291,23 @@ export const zhHant = defineLocale({
unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。',
noMatch: query => `沒有符合「${query}」的寵物。`,
installedTag: '已安裝',
generatedTag: '生成',
countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`,
count: n => `${n} 個寵物。`,
uninstall: name => `解除安裝 ${name}`,
delete: name => `刪除 ${name}`,
deleteTitle: name => `刪除 ${name}`,
deleteBody: '此操作會永久刪除寵物,且無法重新安裝。',
deleteConfirm: '刪除',
rename: name => `重新命名 ${name}`,
renameTitle: '重新命名寵物',
renamePlaceholder: '為寵物取個名字',
renameSave: '儲存',
exportPet: name => `匯出 ${name}`,
adoptFailed: slug => `無法領養 ${slug}`,
uninstallFailed: slug => `無法解除安裝 ${slug}`,
renameFailed: slug => `無法重新命名 ${slug}`,
exportFailed: slug => `無法匯出 ${slug}`,
noneAvailable: '目前沒有可開啟的寵物。',
turnOnFailed: '無法開啟寵物。',
turnOffFailed: '無法關閉寵物。'
@ -850,10 +862,32 @@ export const zhHant = defineLocale({
turnOff: '關閉',
turnOn: '開啟',
installed: '已安裝',
generatedTag: '生成',
adoptFailed: '無法領養該寵物。',
toggleFailed: '無法切換寵物顯示。',
noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。'
},
generatePet: {
title: '生成寵物',
placeholder: '描述要生成的寵物……',
promptHint: '輸入描述,然後按 Enter 生成四種造型。',
readyHint: '按 Enter 依描述生成四種造型。',
generate: '生成',
generating: '生成中……',
retry: '重試',
hatch: '孵化',
spawning: '召喚中……',
hatching: '正在孵化你的寵物……',
hatchingSub: '正在為每一格注入生命——請稍候。',
hatched: '孵化成功!',
hatchRow: (state, done, total) => `正在繪製 ${state}…… ${done}/${total}`,
hatchComposing: '正在合成精靈表……',
hatchSaving: '正在儲存你的寵物……',
namePlaceholder: '為寵物命名',
staleBackend: '請更新 Hermes 以生成寵物。',
adopt: '領養',
startOver: '重新開始'
},
installTheme: {
title: '安裝主題...',
placeholder: '搜尋 VS Code Marketplace...',

View file

@ -379,11 +379,23 @@ export const zh: Translations = {
unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。',
noMatch: query => `没有匹配「${query}」的宠物。`,
installedTag: '已安装',
generatedTag: '生成',
countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`,
count: n => `${n} 个宠物。`,
uninstall: name => `卸载 ${name}`,
delete: name => `删除 ${name}`,
deleteTitle: name => `删除 ${name}`,
deleteBody: '此操作会永久删除宠物,且无法重新安装。',
deleteConfirm: '删除',
rename: name => `重命名 ${name}`,
renameTitle: '重命名宠物',
renamePlaceholder: '给宠物起个名字',
renameSave: '保存',
exportPet: name => `导出 ${name}`,
adoptFailed: slug => `无法领养 ${slug}`,
uninstallFailed: slug => `无法卸载 ${slug}`,
renameFailed: slug => `无法重命名 ${slug}`,
exportFailed: slug => `无法导出 ${slug}`,
noneAvailable: '当前没有可开启的宠物。',
turnOnFailed: '无法开启宠物。',
turnOffFailed: '无法关闭宠物。'
@ -947,10 +959,32 @@ export const zh: Translations = {
turnOff: '关闭',
turnOn: '开启',
installed: '已安装',
generatedTag: '生成',
adoptFailed: '无法领养该宠物。',
toggleFailed: '无法切换宠物显示。',
noneAvailable: '暂无可用宠物——请在下方选择一个安装。'
},
generatePet: {
title: '生成宠物',
placeholder: '描述要生成的宠物……',
promptHint: '输入描述,然后按 Enter 生成四种造型。',
readyHint: '按 Enter 根据描述生成四种造型。',
generate: '生成',
generating: '生成中……',
retry: '重试',
hatch: '孵化',
spawning: '召唤中……',
hatching: '正在孵化你的宠物……',
hatchingSub: '正在为每一帧注入生命——请稍候。',
hatched: '孵化成功!',
hatchRow: (state, done, total) => `正在绘制 ${state}…… ${done}/${total}`,
hatchComposing: '正在合成精灵表……',
hatchSaving: '正在保存你的宠物……',
namePlaceholder: '给宠物起个名字',
staleBackend: '请更新 Hermes 以生成宠物。',
adopt: '领养',
startOver: '重新开始'
},
installTheme: {
title: '安装主题...',
placeholder: '搜索 VS Code Marketplace...',

View file

@ -29,6 +29,7 @@ import {
IconCopy as CopyIcon,
IconCpu as Cpu,
IconDownload as Download,
IconEgg as Egg,
IconExternalLink as ExternalLink,
IconEye as Eye,
IconEyeOff as EyeOff,
@ -133,6 +134,7 @@ export {
CopyIcon,
Cpu,
Download,
Egg,
ExternalLink,
Eye,
EyeOff,

View file

@ -27,6 +27,8 @@ export interface GalleryPet {
spritesheetUrl?: string
/** petdex's hand-picked set — used only to rank "popular" pets first. */
curated?: boolean
/** Hatched locally by the user (createdBy=generator) — badged + ranked first. */
generated?: boolean
}
export interface PetGallery {
@ -39,7 +41,12 @@ export type PetGalleryStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error'
/** The recovering `requestGateway` from `useGatewayRequest` passed in so the
* store reuses the hook's reconnect/reauth handling instead of duplicating it. */
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
export type GatewayRequest = <T>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
signal?: AbortSignal
) => Promise<T>
/** Profile-scoped pet RPC. Pets are per-profile, so every call carries the active
* profile (the gateway no-ops it for the launch profile). One chokepoint so no
@ -115,16 +122,21 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole
$petGalleryStatus.set('loading')
}
let localOk = false
try {
const [next, info] = await Promise.all([
petRpc<PetGallery>(request, 'pet.gallery'),
// Phase 1: local pets only — instant, never blocks on the remote petdex
// manifest. The user's own/generated pets render right away.
const [local, info] = await Promise.all([
petRpc<PetGallery>(request, 'pet.gallery', { localOnly: true }),
petRpc<PetInfo>(request, 'pet.info')
])
if (next) {
$petGallery.set(next)
if (local) {
$petGallery.set(local)
$petGalleryStatus.set('ready')
$petGalleryError.set(null)
localOk = true
}
if (info) {
@ -142,6 +154,21 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole
} finally {
galleryLoad = null
}
// Phase 2: merge in the full petdex catalog in the background. A slow/failed
// manifest fetch never hides the local pets shown in phase 1.
if (localOk) {
try {
const full = await petRpc<PetGallery>(request, 'pet.gallery')
if (full) {
$petGallery.set(full)
$petGalleryStatus.set('ready')
}
} catch {
// Keep the local-only gallery; the petdex catalog just stays unmerged.
}
}
})()
return galleryLoad
@ -161,6 +188,24 @@ async function syncInfo(request: GatewayRequest): Promise<void> {
}
}
/**
* Reflect a just-adopted *local* pet without any network: optimistically mark it
* active/installed in the cached gallery and repaint the live mascot via the
* local `pet.info`. Adopting a generated pet is a disk+config op it must never
* wait on `pet.gallery`'s remote petdex manifest fetch.
*/
export async function applyAdoptedPet(request: GatewayRequest, slug: string, displayName: string): Promise<void> {
patchGallery(gallery => ({
...gallery,
enabled: true,
active: slug,
pets: gallery.pets.some(p => p.slug === slug)
? gallery.pets.map(p => (p.slug === slug ? { ...p, installed: true, displayName } : p))
: [...gallery.pets, { slug, displayName, installed: true, spritesheetUrl: '' }]
}))
await syncInfo(request)
}
/**
* Filter (drop the internal `clawd*` pets + apply a search query) and rank the
* gallery for a picker. Ranking has no popularity data, so it leans on the
@ -175,8 +220,15 @@ export function rankedGalleryPets(gallery: PetGallery | null, query = ''): Galle
const needle = query.trim().toLowerCase()
// User-generated pets first, then the active pet, then installed, then curated.
// Guard every term with a boolean — local-only pets omit curated/generated, and
// `Number(undefined)` is NaN, which poisons the sort (it would sink those pets
// below the render cap and hide them entirely).
const rank = (p: GalleryPet) =>
Number(gallery.enabled && p.slug === gallery.active) * 4 + Number(p.installed) * 2 + Number(p.curated)
(p.generated ? 8 : 0) +
(gallery.enabled && p.slug === gallery.active ? 4 : 0) +
(p.installed ? 2 : 0) +
(p.curated ? 1 : 0)
return gallery.pets
.filter(
@ -309,14 +361,111 @@ export function setPetScale(request: GatewayRequest, scale: number): void {
}, 200)
}
/** Export a pet as a `.zip` (pet.json + spritesheet) and save it via the browser. */
export async function exportPet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
$petBusy.set(slug)
$petGalleryError.set(null)
try {
const res = await petRpc<{ ok: boolean; filename: string; zipBase64: string }>(request, 'pet.export', { slug })
if (!res?.ok || !res.zipBase64) {
throw new Error(fallback)
}
const bytes = Uint8Array.from(atob(res.zipBase64), c => c.charCodeAt(0))
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = res.filename || `${slug}.zip`
anchor.click()
URL.revokeObjectURL(url)
return true
} catch (e) {
$petGalleryError.set(e instanceof Error ? e.message : fallback)
return false
} finally {
$petBusy.set(null)
}
}
/**
* Rename a pet optimistic. The new name shows instantly (so the dialog can
* close immediately); the RPC runs in the background and the backend also
* realigns the slug/dir, so we reconcile the slug + thumb cache when it returns,
* and roll the name back if it fails.
*/
export function renamePet(request: GatewayRequest, slug: string, name: string, fallback: string): Promise<boolean> {
const trimmed = name.trim()
if (!trimmed) {
return Promise.resolve(false)
}
const prev = $petGallery.get()?.pets.find(p => p.slug === slug)?.displayName ?? ''
// Optimistic: paint the new name now (slug reconciles when the RPC returns).
patchGallery(g => ({
...g,
pets: g.pets.map(p => (p.slug === slug ? { ...p, displayName: trimmed } : p))
}))
$petGalleryError.set(null)
return (async () => {
try {
const res = await petRpc<{ ok: boolean; slug: string; displayName: string }>(request, 'pet.rename', {
slug,
name: trimmed
})
if (!res?.ok) {
throw new Error(fallback)
}
const newSlug = res.slug || slug
if (newSlug !== slug) {
thumbCache.delete(slug)
patchGallery(g => ({
...g,
active: g.active === slug ? newSlug : g.active,
pets: g.pets
.filter(p => p.slug !== newSlug || p.slug === slug)
.map(p => (p.slug === slug ? { ...p, slug: newSlug, displayName: res.displayName || trimmed } : p))
}))
}
return true
} catch (e) {
// Roll the optimistic name back so the list reflects on-disk truth.
patchGallery(g => ({
...g,
pets: g.pets.map(p => (p.slug === slug ? { ...p, displayName: prev } : p))
}))
$petGalleryError.set(e instanceof Error ? e.message : fallback)
return false
}
})()
}
/** Uninstall a pet; turns the mascot off if it was the active one. */
export function removePet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
return mutate(slug, fallback, request, async () => {
await petRpc(request, 'pet.remove', { slug })
// Evict the by-slug thumb cache so a reused slug doesn't render this pet's
// stale thumbnail (the backend drops its disk thumb in parallel).
thumbCache.delete(slug)
patchGallery(g => ({
...g,
enabled: g.active === slug ? false : g.enabled,
pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: false } : p))
active: g.active === slug ? '' : g.active,
// Petdex pets can be reinstalled from the manifest, so we just mark them
// uninstalled. Generated / local-only pets have no remote source — once
// deleted they're gone, so drop them from the list entirely.
pets: g.pets.flatMap(p => {
if (p.slug !== slug) {
return [p]
}
return p.generated || !p.spritesheetUrl ? [] : [{ ...p, installed: false }]
})
}))
})
}

View file

@ -0,0 +1,527 @@
import { atom } from 'nanostores'
import { $gateway } from '@/store/gateway'
import { type PetInfo } from '@/store/pet'
import { type GatewayRequest, applyAdoptedPet } from '@/store/pet-gallery'
/**
* Feature store for the "generate a pet" flow (Cmd-K Pets Generate).
*
* Three backend steps, mirrored as state here:
* - `pet.generate` produces N cheap base-look *drafts* keyed by a `token`.
* - `pet.hatch` turns the chosen draft into a full animated pet installed but
* NOT active and returns its renderer payload so we can preview all frames.
* - the user then *adopts* (`pet.select`) or *discards* (`pet.remove`) it.
*
* The store owns the draft set, the selected variant, the hatched preview, and
* the busy/error status so the page is a thin view. Retry == regenerate (new
* token). Kept separate from `pet-gallery` because its lifecycle (ephemeral
* drafts + an unadopted preview) is unrelated to the long-lived gallery cache.
*/
// Generation is many grounded image calls — far longer than the default 30s RPC
// timeout. Drafts fan out 4 base looks; hatch fans out ~8 animation rows. Even
// parallelized, a cold provider call is slow, so we give these calls real
// headroom (the bug was "request timed out: pet.generate" on the 30s default).
const GENERATE_TIMEOUT_MS = 240_000
const HATCH_TIMEOUT_MS = 420_000
// Filler words to drop when deriving a default name from a free-text prompt.
const NAME_STOPWORDS = new Set([
'a',
'an',
'and',
'at',
'by',
'cute',
'for',
'from',
'in',
'of',
'on',
'style',
'the',
'to',
'with'
])
/**
* Derive a short, friendly default name from a generation prompt. The prompt
* (e.g. "2d dragon in the style of ragnarok online") is grounding text, not a
* name using it verbatim makes a terrible label + slug. We keep the first few
* meaningful words, title-cased and capped, so a blank adopt still reads well.
* The user can always override on the reveal screen or rename later.
*/
export function cleanPetName(prompt: string): string {
const words = prompt
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
.split(/\s+/)
.filter(Boolean)
const meaningful = words.filter(w => !NAME_STOPWORDS.has(w.toLowerCase()))
const picked = (meaningful.length ? meaningful : words).slice(0, 3)
const name = picked
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
.slice(0, 28)
.trim()
return name || 'Pet'
}
export interface PetDraft {
index: number
/** Downscaled PNG data URI preview from the gateway. */
dataUri: string
}
export type PetGenStatus =
| 'idle'
| 'generating'
| 'ready'
| 'hatching'
| 'preview'
| 'adopting'
| 'error'
| 'stale'
/** Live hatch step for the egg screen — which row is being drawn, then compose/save. */
export interface PetHatchStage {
phase: 'row' | 'compose' | 'save'
state?: string
done?: number
total?: number
}
export const $petGenStatus = atom<PetGenStatus>('idle')
export const $petGenStage = atom<PetHatchStage | null>(null)
export const $petGenError = atom<string | null>(null)
/** Whether the dedicated "Generate a pet" Pokédex overlay is open. */
export const $petGenerateOpen = atom(false)
export function openPetGenerate(): void {
// Always open on a clean slate — don't resurface the last run's drafts/preview.
resetPetGen()
$petGenerateOpen.set(true)
}
export function closePetGenerate(): void {
$petGenerateOpen.set(false)
}
export const $petGenToken = atom<string | null>(null)
/** Prompt that produced the current draft token; hatch uses this for consistency. */
export const $petGenPrompt = atom<string>('')
export const $petGenDrafts = atom<PetDraft[]>([])
export const $petGenSelected = atom<number | null>(null)
/** The hatched-but-unadopted pet: its renderer payload, played in the preview. */
export const $petGenPreview = atom<PetInfo | null>(null)
function isMissingMethod(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /method not found|-32601|unknown method|no such method/i.test(message)
}
/** Clear all generation state (on close, or before a fresh run). */
export function resetPetGen(): void {
$petGenStatus.set('idle')
$petGenStage.set(null)
$petGenError.set(null)
$petGenToken.set(null)
$petGenPrompt.set('')
$petGenDrafts.set([])
$petGenSelected.set(null)
$petGenPreview.set(null)
}
/**
* Reset on palette close, deleting an unadopted preview pet first so a hatched-
* but-never-adopted creature doesn't linger in the gallery. Fire-and-forget.
*/
export function cleanupPetGen(request: GatewayRequest): void {
const preview = $petGenPreview.get()
if ($petGenStatus.get() === 'preview' && preview?.slug) {
void request('pet.remove', { slug: preview.slug }).catch(() => {})
}
resetPetGen()
}
interface GenerateOptions {
prompt: string
style?: string
count?: number
}
// A Stop (or a fresh round) must invalidate the in-flight call. This primitive
// pairs a monotonic run id with the current run's cancel fn; `begin` opens a
// run, `isCurrent` gates stale callbacks/events, `arm` registers the aborter,
// `stop` supersedes + fires it. Drives both the draft and hatch flows.
interface Run {
begin: () => number
isCurrent: (id: number) => boolean
arm: (cancel: () => void) => void
stop: () => void
disarmIf: (id: number) => void
}
function cancelableRun(): Run {
let id = 0
let cancel: (() => void) | null = null
return {
begin: () => (id += 1),
isCurrent: n => n === id,
arm: fn => {
cancel = fn
},
stop: () => {
id += 1
cancel?.()
cancel = null
},
disarmIf: n => {
if (n === id) {
cancel = null
}
}
}
}
const gen = cancelableRun()
/**
* Stop the in-flight draft generation (real abort). If any drafts have already
* streamed in, keep them and drop into the ready/picker state (no reason to wait
* for all 4) otherwise reset to idle.
*/
export function cancelGenerate(): void {
gen.stop()
$petGenError.set(null)
const drafts = $petGenDrafts.get()
if (drafts.length > 0) {
if ($petGenSelected.get() === null) {
$petGenSelected.set(drafts[0]?.index ?? 0)
}
$petGenStatus.set('ready')
return
}
$petGenStatus.set('idle')
$petGenDrafts.set([])
$petGenSelected.set(null)
$petGenToken.set(null)
}
const hatch = cancelableRun()
// A Stop invalidates the in-flight hatch and drops back to the draft picker (the
// server still finishes, so we delete the pet it created).
/** Stop the in-flight hatch and return to the draft picker. */
export function cancelHatch(): void {
hatch.stop()
$petGenStage.set(null)
$petGenError.set(null)
$petGenStatus.set($petGenDrafts.get().length > 0 ? 'ready' : 'idle')
}
/** Generate (or retry) a fresh set of base-look drafts for `prompt`. */
export async function generateDrafts(request: GatewayRequest, options: GenerateOptions): Promise<boolean> {
const prompt = options.prompt.trim()
if (!prompt) {
return false
}
const runId = gen.begin()
const controller = new AbortController()
gen.arm(() => {
controller.abort()
const token = $petGenToken.get()
if (token) {
void request('pet.cancel', { token }).catch(() => {})
}
})
// Starting a fresh generation round supersedes any unadopted preview pet.
const preview = $petGenPreview.get()
if (preview?.slug) {
await request('pet.remove', { slug: preview.slug }).catch(() => {})
}
$petGenStatus.set('generating')
$petGenError.set(null)
$petGenPreview.set(null)
$petGenDrafts.set([])
$petGenSelected.set(null)
// Stream drafts in as the backend finishes each one (pet.generate.progress),
// so the grid fills live instead of sitting on placeholders until all N land.
const off =
$gateway.get()?.on<PetDraft & { token: string; count: number }>('pet.generate.progress', event => {
const draft = event.payload
// Token-only init event (no draft yet): learn the token immediately so an
// early Stop can still tell the backend to cancel this run.
if (draft?.token && !draft.dataUri) {
if (gen.isCurrent(runId) && $petGenStatus.get() === 'generating') {
$petGenToken.set(draft.token)
}
return
}
if (!draft?.dataUri || typeof draft.index !== 'number') {
return
}
// Ignore events from a superseded/stopped run, and only stream while live.
if (!gen.isCurrent(runId) || $petGenStatus.get() !== 'generating') {
return
}
// Capture the token from the stream so a Stop can still hatch the partial set.
if (draft.token) {
$petGenToken.set(draft.token)
}
const current = $petGenDrafts.get()
if (current.some(d => d.index === draft.index)) {
return
}
$petGenDrafts.set(
[...current, { index: draft.index, dataUri: draft.dataUri }].sort((a, b) => a.index - b.index)
)
}) ?? (() => {})
try {
const result = await request<{ ok: boolean; token: string; drafts: PetDraft[] }>(
'pet.generate',
{
prompt,
style: options.style ?? 'auto',
count: options.count ?? 4
},
GENERATE_TIMEOUT_MS,
controller.signal
)
// Stopped (or superseded by a newer round) while the RPC was in flight.
if (!gen.isCurrent(runId)) {
return false
}
if (!result?.ok || !result.drafts?.length) {
throw new Error('generation produced no drafts')
}
$petGenToken.set(result.token)
$petGenPrompt.set(prompt)
$petGenDrafts.set(result.drafts)
$petGenSelected.set(result.drafts[0]?.index ?? 0)
$petGenStatus.set('ready')
return true
} catch (e) {
if (!gen.isCurrent(runId)) {
return false
}
if (isMissingMethod(e)) {
$petGenStatus.set('stale')
} else {
$petGenStatus.set('error')
$petGenError.set(e instanceof Error ? e.message : 'Could not generate pet drafts.')
}
return false
} finally {
off()
gen.disarmIf(runId)
}
}
interface HatchOptions {
name: string
description?: string
prompt?: string
style?: string
}
/**
* Hatch the selected draft into a full pet (installed but NOT yet active) and
* load its renderer payload into the preview. Adoption is a separate, explicit
* step (`adoptHatched`) so the user sees every frame play before committing.
* Returns true when the preview is ready.
*/
export async function hatchSelected(request: GatewayRequest, options: HatchOptions): Promise<boolean> {
const token = $petGenToken.get()
const index = $petGenSelected.get()
const name = options.name.trim()
const concept = ($petGenPrompt.get() || options.prompt || name).trim()
if (token === null || index === null || !name) {
return false
}
const hatchRunId = hatch.begin()
const controller = new AbortController()
hatch.arm(() => {
controller.abort()
void request('pet.cancel', { token }).catch(() => {})
})
$petGenStatus.set('hatching')
$petGenStage.set(null)
$petGenError.set(null)
// Stream the hatch steps (which row is drawing, then compose/save) to the egg
// screen so a multi-minute hatch shows live progress instead of a black box.
const offProgress =
$gateway
.get()
?.on<{ event: string; state?: string; done?: string; total?: string }>('pet.hatch.progress', event => {
const p = event.payload
if (!p || !hatch.isCurrent(hatchRunId) || $petGenStatus.get() !== 'hatching') {
return
}
if (p.event === 'row' && p.state) {
$petGenStage.set({
phase: 'row',
state: p.state,
done: Number(p.done) || undefined,
total: Number(p.total) || undefined
})
} else if (p.event === 'compose') {
$petGenStage.set({ phase: 'compose' })
} else if (p.event === 'save') {
$petGenStage.set({ phase: 'save' })
}
}) ?? (() => {})
try {
const result = await request<{ ok: boolean; slug: string; displayName: string; pet?: PetInfo }>(
'pet.hatch',
{
token,
index,
name,
description: options.description ?? '',
prompt: concept,
style: options.style ?? 'auto'
},
HATCH_TIMEOUT_MS,
controller.signal
)
// Stopped mid-hatch: the server created the pet anyway, so delete it.
if (!hatch.isCurrent(hatchRunId)) {
if (result?.slug) {
void request('pet.remove', { slug: result.slug }).catch(() => {})
}
return false
}
if (!result?.ok || !result.pet?.spritesheetBase64) {
throw new Error('hatch produced no preview')
}
$petGenPreview.set({ ...result.pet, enabled: true })
$petGenStatus.set('preview')
return true
} catch (e) {
if (!hatch.isCurrent(hatchRunId)) {
return false
}
$petGenStatus.set('error')
$petGenError.set(e instanceof Error ? e.message : 'Could not hatch the pet.')
return false
} finally {
offProgress()
if (hatch.isCurrent(hatchRunId)) {
$petGenStage.set(null)
hatch.disarmIf(hatchRunId)
}
}
}
export interface AdoptOutcome {
ok: boolean
slug?: string
displayName?: string
}
/**
* Adopt the previewed pet: optionally rename it to the user's chosen name (set
* on the reveal screen), activate it (`pet.select`), refresh the gallery + live
* mascot, and clear generation state. No-op unless a preview exists.
*/
export async function adoptHatched(request: GatewayRequest, name?: string): Promise<AdoptOutcome> {
const preview = $petGenPreview.get()
if (!preview?.slug) {
return { ok: false }
}
$petGenStatus.set('adopting')
$petGenError.set(null)
try {
// Name is collected after hatch, so apply it before activating. The rename
// also realigns the slug to the chosen name (so lists show what the user
// typed, not the prompt), so adopt the *returned* slug. Best-effort: a
// rename failure shouldn't block adopting under the provisional slug.
const finalName = name?.trim()
let adoptSlug = preview.slug
if (finalName && finalName !== preview.displayName) {
const renamed = await request<{ ok: boolean; slug: string }>('pet.rename', {
slug: preview.slug,
name: finalName
}).catch(() => null)
if (renamed?.slug) {
adoptSlug = renamed.slug
}
}
const result = await request<{ ok: boolean; slug: string; displayName: string }>('pet.select', {
slug: adoptSlug
})
if (!result?.ok) {
throw new Error('adopt failed')
}
// pet.select already set the active mascot (disk + config). Reflect it
// locally — no remote petdex manifest fetch — and close immediately.
resetPetGen()
void applyAdoptedPet(request, result.slug, result.displayName)
return { ok: true, slug: result.slug, displayName: result.displayName }
} catch (e) {
$petGenStatus.set('preview')
$petGenError.set(e instanceof Error ? e.message : 'Could not adopt the pet.')
return { ok: false }
}
}
/**
* Throw away the previewed pet (`pet.remove`) and return to the draft picker so
* the user can choose another base or regenerate. Best-effort on the delete.
*/
export async function discardHatched(request: GatewayRequest): Promise<void> {
const preview = $petGenPreview.get()
if (preview?.slug) {
await request('pet.remove', { slug: preview.slug }).catch(() => {})
}
$petGenPreview.set(null)
$petGenError.set(null)
$petGenStatus.set($petGenDrafts.get().length > 0 ? 'ready' : 'idle')
}

View file

@ -1426,3 +1426,235 @@ canvas {
animation: none;
}
}
/* -------------------------------------------------------------------------- */
/* Pet egg hatch (Cmd-K → Pets → Generate) */
/* The incubation wobble + reveal flash/pop give the draft→pet step a */
/* Pokémon-style "egg is hatching" beat instead of a bare spinner. */
/* -------------------------------------------------------------------------- */
.pet-egg {
position: relative;
width: 5.5rem;
height: 7rem;
border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
background:
radial-gradient(120% 90% at 32% 26%, color-mix(in srgb, var(--ui-accent) 14%, #fff) 0%, #f4ecd8 46%, #e4d3ad 100%);
box-shadow:
inset -0.45rem -0.6rem 1.1rem color-mix(in srgb, #000 16%, transparent),
inset 0.35rem 0.4rem 0.7rem color-mix(in srgb, #fff 70%, transparent),
0 0.4rem 0.9rem color-mix(in srgb, #000 22%, transparent);
transform-origin: 50% 88%;
animation: pet-egg-wobble 2.4s ease-in-out infinite;
}
/* Compact egg (empty-state hero). Children are %-based so they track the size;
only the rem box-shadow needs scaling down to stay crisp. */
.pet-egg--sm {
width: 3.25rem;
height: 4.1rem;
box-shadow:
inset -0.28rem -0.38rem 0.7rem color-mix(in srgb, #000 16%, transparent),
inset 0.22rem 0.26rem 0.45rem color-mix(in srgb, #fff 70%, transparent),
0 0.25rem 0.55rem color-mix(in srgb, #000 22%, transparent);
}
.pet-egg__shine {
position: absolute;
top: 14%;
left: 22%;
width: 28%;
height: 22%;
border-radius: 50%;
background: color-mix(in srgb, #fff 85%, transparent);
filter: blur(2px);
opacity: 0.85;
}
.pet-egg__spot {
position: absolute;
border-radius: 50%;
background: color-mix(in srgb, var(--ui-accent) 70%, #b89b63);
opacity: 0.55;
}
.pet-egg__glow {
position: absolute;
inset: -35%;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--ui-accent) 55%, transparent) 0%, transparent 62%);
animation: pet-egg-glow 2.4s ease-in-out infinite;
pointer-events: none;
}
.pet-egg-shadow {
width: 4.5rem;
height: 0.8rem;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, #000 32%, transparent) 0%, transparent 72%);
animation: pet-egg-shadow 2.4s ease-in-out infinite;
}
/* Contact shadow sized for the compact incubator egg (roughly its footprint). */
.pet-egg-shadow--sm {
width: 3rem;
height: 0.6rem;
}
/* Hatch wiggle for the pixel egg (rocks around its base). */
.pet-wobble {
transform-origin: 50% 85%;
animation: pet-egg-wobble 2.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.pet-wobble {
animation: none;
}
}
@keyframes pet-egg-wobble {
0%,
62%,
100% {
transform: rotate(0deg);
}
8% {
transform: rotate(-7deg);
}
16% {
transform: rotate(6deg);
}
24% {
transform: rotate(-5deg);
}
32% {
transform: rotate(4deg);
}
40% {
transform: rotate(0deg);
}
/* the "almost out" burst */
70% {
transform: rotate(-12deg);
}
76% {
transform: rotate(12deg);
}
82% {
transform: rotate(-9deg);
}
88% {
transform: rotate(7deg);
}
94% {
transform: rotate(-3deg);
}
}
@keyframes pet-egg-glow {
0%,
100% {
opacity: 0.35;
transform: scale(0.92);
}
70% {
opacity: 0.4;
}
84% {
opacity: 0.85;
transform: scale(1.08);
}
}
@keyframes pet-egg-shadow {
0%,
62%,
100% {
transform: scaleX(1);
opacity: 0.6;
}
76% {
transform: scaleX(0.8);
opacity: 0.45;
}
}
.pet-reveal {
animation: pet-reveal-pop 620ms cubic-bezier(0.22, 1.4, 0.4, 1) both;
}
@keyframes pet-reveal-pop {
0% {
opacity: 0;
transform: scale(0.35) translateY(0.4rem);
}
60% {
opacity: 1;
transform: scale(1.12) translateY(0);
}
100% {
transform: scale(1) translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.pet-egg,
.pet-egg__glow,
.pet-egg-shadow,
.pet-reveal {
animation: none;
}
.pet-reveal {
opacity: 1;
transform: none;
}
}
/* Pet generation progress bar — determinate (hatch rows: done/total) or */
/* indeterminate (drafts, which return together so a % would just snap). */
.pet-progress {
position: relative;
height: 0.25rem;
width: 100%;
overflow: hidden;
border-radius: 9999px;
background: color-mix(in srgb, var(--ui-accent) 15%, transparent);
}
.pet-progress__fill {
position: absolute;
inset: 0 auto 0 0;
height: 100%;
border-radius: 9999px;
background: var(--ui-accent);
transition: width 320ms ease;
}
.pet-progress__indeterminate {
position: absolute;
top: 0;
bottom: 0;
width: 40%;
border-radius: 9999px;
background: var(--ui-accent);
animation: pet-progress-slide 1.15s ease-in-out infinite;
}
@keyframes pet-progress-slide {
0% {
left: -42%;
}
100% {
left: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.pet-progress__indeterminate {
animation: none;
left: 0;
width: 100%;
opacity: 0.4;
}
}