feat(desktop): pet contact shadow, inward facing, resize-safe; inline toggle

Floating mascot polish: a scale-proportional contact shadow (lighter in
light mode), live mid-drag mirroring so the pet always faces inward, and a
resize handler that re-clamps the saved position so a shrink can never
strand it off-screen. The Cmd+K pets toggle collapses to a single paw
button on the search row (CommandInput gains a `right` slot) instead of a
full row, and the active pet uses the model-picker's neutral check (no
green / "Active" text). Drops the ellipsis from "Pets"/"Change theme" and
the now-dead pets.active / pets.adopting i18n keys.
This commit is contained in:
Brooklyn Nicholson 2026-06-16 19:15:09 -05:00
parent 7bd3715f1d
commit b5152ec846
9 changed files with 128 additions and 93 deletions

View File

@ -63,7 +63,7 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
import { MarketplaceThemePage } from './marketplace-theme-page'
import { PetPalettePage } from './pet-palette-page'
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
interface PaletteItem {
/** Keybind action id — its live combo renders as a hotkey hint. */
@ -648,6 +648,7 @@ export function CommandPalette() {
}}
onValueChange={setSearch}
placeholder={placeholder}
right={page === 'pets' ? <PetInlineToggle /> : undefined}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">

View File

@ -15,7 +15,7 @@ import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Loader2, PawPrint, X } from '@/lib/icons'
import { Check, Loader2, PawPrint } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$petBusy,
@ -26,9 +26,7 @@ import {
loadPetGallery,
loadPetThumb,
rankedGalleryPets,
setPetEnabled,
TOGGLE_OFF,
TOGGLE_ON
setPetEnabled
} from '@/store/pet-gallery'
interface PetPalettePageProps {
@ -58,12 +56,6 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
void adoptPet(requestGateway, slug, copy.adoptFailed).then(ok => ok && triggerHaptic('crisp'))
}
const toggle = (on: boolean) => {
void setPetEnabled(requestGateway, on, { noneAvailable: copy.noneAvailable, fallback: copy.toggleFailed }).then(
ok => ok && triggerHaptic('crisp')
)
}
if (status === 'loading' && !gallery) {
return <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
}
@ -80,25 +72,6 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
return (
<div role="listbox">
<div className="flex gap-1 border-b border-border/60 px-2 py-1.5">
<ToggleButton
active={!enabled}
busy={busy === TOGGLE_OFF}
disabled={mutating}
icon={X}
label={copy.turnOff}
onClick={() => toggle(false)}
/>
<ToggleButton
active={enabled}
busy={busy === TOGGLE_ON}
disabled={mutating}
icon={PawPrint}
label={copy.turnOn}
onClick={() => toggle(true)}
/>
</div>
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
{shown.length === 0 ? (
@ -137,17 +110,11 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
{pet.installed ? ` · ${copy.installed}` : ''}
</span>
</span>
<span className="ml-auto flex shrink-0 items-center gap-1 text-[0.6875rem] text-muted-foreground">
<span className="ml-auto flex shrink-0 items-center text-[0.6875rem] text-muted-foreground">
{isBusy ? (
<>
<Loader2 className="size-3 animate-spin" />
{copy.adopting}
</>
<Loader2 className="size-3 animate-spin" />
) : isActive ? (
<>
<Check className="size-3 text-(--ui-green)" />
{copy.active}
</>
<Check className="size-3.5 text-foreground" />
) : null}
</span>
</button>
@ -158,33 +125,47 @@ export function PetPalettePage({ search }: PetPalettePageProps) {
)
}
function ToggleButton({
active,
busy,
disabled,
icon: Icon,
label,
onClick
}: {
active: boolean
busy: boolean
disabled: boolean
icon: typeof X
label: string
onClick: () => void
}) {
/**
* Single on/off toggle, rendered inline on the palette's search row (see
* `CommandInput`'s `right` slot). The paw lights up when pets are on. Reads the
* same shared gallery atoms, so it stays in sync with the list below.
*/
export function PetInlineToggle() {
const { t } = useI18n()
const copy = t.commandCenter.pets
const { requestGateway } = useGatewayRequest()
const gallery = useStore($petGallery)
const busy = useStore($petBusy)
if (!gallery) {
return null
}
const enabled = gallery.enabled
const toggle = () => {
void setPetEnabled(requestGateway, !enabled, {
noneAvailable: copy.noneAvailable,
fallback: copy.toggleFailed
}).then(ok => ok && triggerHaptic('crisp'))
}
return (
<button
aria-label={enabled ? copy.turnOff : copy.turnOn}
aria-pressed={enabled}
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[0.6875rem] font-medium transition-colors disabled:opacity-50',
active ? 'bg-(--chrome-action-hover) text-foreground' : 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
'flex shrink-0 items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50',
enabled ? 'bg-(--chrome-action-hover) text-foreground' : 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
)}
disabled={disabled}
onClick={onClick}
disabled={Boolean(busy)}
onClick={toggle}
// Don't steal focus from the search input on click.
onMouseDown={event => event.preventDefault()}
title={enabled ? copy.turnOff : copy.turnOn}
type="button"
>
{busy ? <Loader2 className="size-3 animate-spin" /> : <Icon className="size-3.5" />}
{label}
{busy ? <Loader2 className="size-4 animate-spin" /> : <PawPrint className="size-4" />}
</button>
)
}

View File

@ -5,6 +5,7 @@ import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { persistString, storedString } from '@/lib/storage'
import { $petInfo, type PetInfo, setPetInfo } from '@/store/pet'
import { $gatewayState } from '@/store/session'
import { useTheme } from '@/themes/context'
import { PetSprite } from './pet-sprite'
@ -24,6 +25,12 @@ function clampToViewport({ x, y }: Point): Point {
return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) }
}
// The sprite art faces left by default, so mirror it when the pet's center sits
// on the left half of the window — it always faces inward, toward the content.
function facing(leftX: number, petW: number): string {
return leftX + petW / 2 < (window.innerWidth || 800) / 2 ? 'scaleX(-1)' : 'none'
}
function loadPosition(): Point {
try {
const raw = storedString(POSITION_KEY)
@ -61,11 +68,18 @@ const PET_POLL_MS = 3000
export function FloatingPet() {
const { requestGateway } = useGatewayRequest()
const { resolvedMode } = useTheme()
const gatewayState = useStore($gatewayState)
const info = useStore($petInfo)
const [position, setPosition] = useState<Point>(loadPosition)
const containerRef = useRef<HTMLDivElement | null>(null)
const petW = (info.frameW ?? 192) * (info.scale ?? 0.33)
// Soft contact shadow, sized off the pet so every scale/species grounds the
// same way (cf. lairp's per-actor feet ellipse). Lighter on light backgrounds.
const shadowW = Math.round(petW * 0.55)
const shadowH = Math.max(3, Math.round(shadowW * 0.28))
const shadowAlpha = resolvedMode === 'light' ? 0.2 : 0.55
// Live drag offset (pointer → element top-left). Drag updates the DOM
// directly to avoid a React re-render (and canvas reflow) per pointermove —
// state is only committed on release.
@ -102,6 +116,27 @@ export function FloatingPet() {
}
}, [gatewayState, active, requestGateway])
// A window resize must never strand the pet off-screen — re-clamp the
// committed position (and persist it) whenever the viewport shrinks.
useEffect(() => {
const onResize = () =>
setPosition(prev => {
const next = clampToViewport(prev)
if (next.x === prev.x && next.y === prev.y) {
return prev
}
persistString(POSITION_KEY, JSON.stringify(next))
return next
})
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
const onPointerDown = useCallback((e: React.PointerEvent) => {
const el = containerRef.current
@ -115,21 +150,26 @@ export function FloatingPet() {
el.style.cursor = 'grabbing'
}, [])
const onPointerMove = useCallback((e: React.PointerEvent) => {
const drag = dragRef.current
const el = containerRef.current
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
const drag = dragRef.current
const el = containerRef.current
if (!drag || !el) {
return
}
if (!drag || !el) {
return
}
const next = clampToViewport({ x: e.clientX - drag.dx, y: e.clientY - drag.dy })
drag.x = next.x
drag.y = next.y
// Mutate the DOM directly — no setState, so no re-render while dragging.
el.style.left = `${next.x}px`
el.style.top = `${next.y}px`
}, [])
const next = clampToViewport({ x: e.clientX - drag.dx, y: e.clientY - drag.dy })
drag.x = next.x
drag.y = next.y
// Mutate the DOM directly — no setState, so no re-render while dragging. The
// mirror follows the pointer across the midline for the same reason.
el.style.left = `${next.x}px`
el.style.top = `${next.y}px`
el.style.transform = facing(next.x, petW)
},
[petW]
)
const onPointerUp = useCallback((e: React.PointerEvent) => {
const drag = dragRef.current
@ -166,12 +206,29 @@ export function FloatingPet() {
position: 'fixed',
top: position.y,
touchAction: 'none',
transform: facing(position.x, petW),
userSelect: 'none',
zIndex: 60
}}
title={info.displayName || 'pet'}
>
<PetSprite info={info} />
<div
aria-hidden
style={{
background: `radial-gradient(ellipse at center, rgba(0,0,0,${shadowAlpha}) 0%, rgba(0,0,0,0) 70%)`,
bottom: -shadowH * 0.4,
height: shadowH,
left: '50%',
pointerEvents: 'none',
position: 'absolute',
transform: 'translateX(-50%)',
width: shadowW,
zIndex: 0
}}
/>
<div style={{ lineHeight: 0, position: 'relative', zIndex: 1 }}>
<PetSprite info={info} />
</div>
</div>
)
}

View File

@ -17,7 +17,12 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
)
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
interface CommandInputProps extends React.ComponentProps<typeof CommandPrimitive.Input> {
/** Inline trailing slot, rendered on the right of the search row. */
right?: React.ReactNode
}
function CommandInput({ className, right, ...props }: CommandInputProps) {
return (
<div className="flex h-11 items-center gap-2 border-b border-border px-3" data-slot="command-input-wrapper">
<SearchIcon className="size-4 shrink-0 text-muted-foreground" />
@ -29,6 +34,7 @@ function CommandInput({ className, ...props }: React.ComponentProps<typeof Comma
data-slot="command-input"
{...props}
/>
{right}
</div>
)
}

View File

@ -735,10 +735,10 @@ export const en: Translations = {
commandCenter: 'Command Center',
appearance: 'Appearance',
settings: 'Settings',
changeTheme: 'Change theme...',
changeTheme: 'Change theme',
changeColorMode: 'Change color mode...',
pets: {
title: 'Pets...',
title: 'Pets',
placeholder: 'Search pets…',
loading: 'Loading petdex gallery…',
error: 'Could not reach the petdex gallery.',
@ -746,9 +746,7 @@ export const en: Translations = {
empty: 'No matching pets.',
turnOff: 'Turn off',
turnOn: 'Turn on',
active: 'Active',
installed: 'Installed',
adopting: 'Adopting…',
adoptFailed: 'Could not adopt that pet.',
toggleFailed: 'Could not toggle the pet.',
noneAvailable: 'No pets available — pick one below to install.'

View File

@ -859,10 +859,10 @@ export const ja = defineLocale({
commandCenter: 'コマンドセンター',
appearance: '外観',
settings: '設定',
changeTheme: 'テーマを変更...',
changeTheme: 'テーマを変更',
changeColorMode: 'カラーモードを変更...',
pets: {
title: 'ペット...',
title: 'ペット',
placeholder: 'ペットを検索…',
loading: 'petdex ギャラリーを読み込み中…',
error: 'petdex ギャラリーに接続できません。',
@ -870,9 +870,7 @@ export const ja = defineLocale({
empty: '一致するペットがありません。',
turnOff: 'オフ',
turnOn: 'オン',
active: '使用中',
installed: 'インストール済み',
adopting: '採用中…',
adoptFailed: 'ペットを採用できませんでした。',
toggleFailed: 'ペットを切り替えできませんでした。',
noneAvailable: '利用可能なペットがありません。'

View File

@ -622,9 +622,7 @@ export interface Translations {
empty: string
turnOff: string
turnOn: string
active: string
installed: string
adopting: string
adoptFailed: string
toggleFailed: string
noneAvailable: string

View File

@ -830,10 +830,10 @@ export const zhHant = defineLocale({
commandCenter: '命令中心',
appearance: '外觀',
settings: '設定',
changeTheme: '變更主題...',
changeTheme: '變更主題',
changeColorMode: '變更色彩模式...',
pets: {
title: '寵物...',
title: '寵物',
placeholder: '搜尋寵物…',
loading: '正在載入 petdex 畫廊…',
error: '無法連線至 petdex 畫廊。',
@ -841,9 +841,7 @@ export const zhHant = defineLocale({
empty: '沒有符合的寵物。',
turnOff: '關閉',
turnOn: '開啟',
active: '使用中',
installed: '已安裝',
adopting: '正在領養…',
adoptFailed: '無法領養該寵物。',
toggleFailed: '無法切換寵物顯示。',
noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。'

View File

@ -923,10 +923,10 @@ export const zh: Translations = {
commandCenter: '命令中心',
appearance: '外观',
settings: '设置',
changeTheme: '更改主题...',
changeTheme: '更改主题',
changeColorMode: '更改颜色模式...',
pets: {
title: '宠物...',
title: '宠物',
placeholder: '搜索宠物…',
loading: '正在加载 petdex 画廊…',
error: '无法连接到 petdex 画廊。',
@ -934,9 +934,7 @@ export const zh: Translations = {
empty: '没有匹配的宠物。',
turnOff: '关闭',
turnOn: '开启',
active: '当前',
installed: '已安装',
adopting: '正在领养…',
adoptFailed: '无法领养该宠物。',
toggleFailed: '无法切换宠物显示。',
noneAvailable: '暂无可用宠物——请在下方选择一个安装。'