mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
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:
parent
aab49f6927
commit
743985bf1e
20 changed files with 2353 additions and 27 deletions
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}` : ''}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
469
apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx
Normal file
469
apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
66
apps/desktop/src/components/pet/pet-egg-hatch.tsx
Normal file
66
apps/desktop/src/components/pet/pet-egg-hatch.tsx
Normal 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 0→100).
|
||||
*/
|
||||
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>
|
||||
)
|
||||
}
|
||||
BIN
apps/desktop/src/components/pet/pet-egg-sheet.png
Normal file
BIN
apps/desktop/src/components/pet/pet-egg-sheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -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
|
||||
|
|
|
|||
204
apps/desktop/src/components/pet/pet-star-shower.tsx
Normal file
204
apps/desktop/src/components/pet/pet-star-shower.tsx
Normal 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} />
|
||||
}
|
||||
234
apps/desktop/src/components/pet/pixel-egg-sprite.tsx
Normal file
234
apps/desktop/src/components/pet/pixel-egg-sprite.tsx
Normal 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 0–5 are the intact squash/stretch bounce; 6–11 are the crack/hatch.
|
||||
* `mode="bounce"` loops 0–5 (never shows a crack); `mode="hatch"` plays 6–11
|
||||
* 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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
62
apps/desktop/src/components/ui/generate-button.tsx
Normal file
62
apps/desktop/src/components/ui/generate-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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...',
|
||||
|
|
|
|||
|
|
@ -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 を検索...',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }]
|
||||
})
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
527
apps/desktop/src/store/pet-generate.ts
Normal file
527
apps/desktop/src/store/pet-generate.ts
Normal 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')
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue