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:
parent
7bd3715f1d
commit
b5152ec846
@ -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)]">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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.'
|
||||
|
||||
@ -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: '利用可能なペットがありません。'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: '尚無可用寵物——請在下方選擇一個安裝。'
|
||||
|
||||
@ -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: '暂无可用宠物——请在下方选择一个安装。'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user