@linxin666/dsh-pet 0.1.20 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -10
  3. package/README.zh.md +17 -10
  4. package/assets/whale/pet.json +9 -0
  5. package/lib/client.js +158 -26
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +853 -41
  8. package/lib/invariant.js +1 -1
  9. package/lib/{state-Diiq8vjk.js → state-Ch3f4luZ.js} +51 -23
  10. package/lib/types/affinity.d.ts +4 -0
  11. package/lib/types/affinity.d.ts.map +1 -1
  12. package/lib/types/affinity.js +32 -6
  13. package/lib/types/chatter.d.ts +111 -0
  14. package/lib/types/chatter.d.ts.map +1 -0
  15. package/lib/types/chatter.js +666 -0
  16. package/lib/types/client/PetSprite.d.ts.map +1 -1
  17. package/lib/types/client/PetSprite.js +88 -14
  18. package/lib/types/client/PluginSettingsCard.d.ts +6 -0
  19. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PluginSettingsCard.js +5 -3
  21. package/lib/types/client/sequences.d.ts +10 -0
  22. package/lib/types/client/sequences.d.ts.map +1 -0
  23. package/lib/types/client/sequences.js +20 -0
  24. package/lib/types/event-projection.d.ts +14 -1
  25. package/lib/types/event-projection.d.ts.map +1 -1
  26. package/lib/types/event-projection.js +38 -17
  27. package/lib/types/ledger.d.ts.map +1 -1
  28. package/lib/types/ledger.js +15 -7
  29. package/lib/types/persist.d.ts.map +1 -1
  30. package/lib/types/persist.js +3 -1
  31. package/lib/types/registry.d.ts +7 -1
  32. package/lib/types/registry.d.ts.map +1 -1
  33. package/lib/types/registry.js +36 -0
  34. package/lib/types/remarks.d.ts +2 -0
  35. package/lib/types/remarks.d.ts.map +1 -1
  36. package/lib/types/remarks.js +5 -0
  37. package/lib/types/service.d.ts +6 -0
  38. package/lib/types/service.d.ts.map +1 -1
  39. package/lib/types/service.js +15 -2
  40. package/lib/types/state.d.ts +7 -4
  41. package/lib/types/state.d.ts.map +1 -1
  42. package/lib/types/state.js +20 -20
  43. package/package.json +9 -9
  44. package/src/affinity.test.ts +4 -2
  45. package/src/affinity.ts +37 -6
  46. package/src/chatter.test.ts +154 -0
  47. package/src/chatter.ts +697 -0
  48. package/src/client/PetSprite.test.tsx +190 -1
  49. package/src/client/PetSprite.tsx +112 -25
  50. package/src/client/PluginSettingsCard.tsx +10 -0
  51. package/src/client/pet-css.test.ts +42 -0
  52. package/src/client/pet.module.css +154 -26
  53. package/src/client/sequences.test.ts +33 -0
  54. package/src/client/sequences.ts +33 -0
  55. package/src/event-projection.ts +49 -16
  56. package/src/ledger.test.ts +42 -1
  57. package/src/ledger.ts +15 -7
  58. package/src/persist.test.ts +18 -1
  59. package/src/persist.ts +3 -1
  60. package/src/registry.test.ts +50 -3
  61. package/src/registry.ts +47 -3
  62. package/src/remarks.ts +6 -0
  63. package/src/service.ts +27 -2
  64. package/src/state.test.ts +13 -0
  65. package/src/state.ts +24 -18
@@ -5,7 +5,7 @@
5
5
  * selection) as composition input, never as submit/cancel (issue #89).
6
6
  */
7
7
  import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
8
- import { cleanup, fireEvent, render, screen } from '@testing-library/react'
8
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
9
9
  import { PetSprite, type PetSpriteProps } from './PetSprite.tsx'
10
10
  import { t } from './locales.ts'
11
11
  import type { PetStateView } from '../service.ts'
@@ -27,6 +27,7 @@ function petDefinition(): PetDefinition {
27
27
  cell: { width: 192, height: 208 },
28
28
  columns: 8,
29
29
  rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
30
+ atlasRows: 9,
30
31
  tracks: {
31
32
  idle: track([0, 1, 2, 3, 4, 5], [400, 400, 400, 400, 400, 400]),
32
33
  'running-right': track([0, 1, 2, 3, 4, 5, 6, 7], [225, 225, 225, 225, 225, 225, 225, 225]),
@@ -86,6 +87,7 @@ beforeAll(() => {
86
87
 
87
88
  afterEach(() => {
88
89
  cleanup()
90
+ vi.restoreAllMocks()
89
91
  })
90
92
 
91
93
  /** Render the pet with mocked callbacks; returns the rename and open spys. */
@@ -176,6 +178,131 @@ describe('PetSprite rename input', () => {
176
178
  expect(onRename).not.toHaveBeenCalled()
177
179
  expect(screen.queryByPlaceholderText('输入新名字')).toBeNull()
178
180
  })
181
+
182
+ it('ignores Enter between compositionStart and compositionEnd even when isComposing is false (#303)', () => {
183
+ // WeChat IME (Windows) marks composition keydowns with isComposing ===
184
+ // false; only the explicit composition events can be trusted.
185
+ const { onRename } = renderPet()
186
+ const input = openRename()
187
+ fireEvent.change(input, { target: { value: '泡泡酱' } })
188
+ fireEvent.compositionStart(input)
189
+ fireEvent.keyDown(input, { key: 'Enter' })
190
+ expect(onRename).not.toHaveBeenCalled()
191
+ expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
192
+ fireEvent.compositionEnd(input)
193
+ fireEvent.keyDown(input, { key: 'Enter' })
194
+ expect(onRename).toHaveBeenCalledWith('泡泡酱')
195
+ })
196
+
197
+ it("ignores 'Process' keydowns emitted by IMEs mid-composition (#303)", () => {
198
+ const { onRename } = renderPet()
199
+ const input = openRename()
200
+ fireEvent.change(input, { target: { value: '泡泡酱' } })
201
+ fireEvent.keyDown(input, { key: 'Process' })
202
+ expect(onRename).not.toHaveBeenCalled()
203
+ expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
204
+ })
205
+
206
+ it('keeps the rename panel open when the pointer leaves mid-rename (#303)', () => {
207
+ // An IME candidate window is an OS-level window: moving the pointer onto
208
+ // it fires pointerleave on the float. The hide timer must not unmount
209
+ // the input mid-composition (that crashes some IMEs / the renderer).
210
+ vi.useFakeTimers()
211
+ try {
212
+ renderPet()
213
+ const input = openRename()
214
+ const float = input.parentElement?.parentElement?.parentElement
215
+ expect(float).not.toBeNull()
216
+ fireEvent.pointerOut(float!, { relatedTarget: null })
217
+ act(() => { vi.advanceTimersByTime(1000) })
218
+ expect(screen.getByPlaceholderText('输入新名字')).toBe(input)
219
+ // After the rename ends, hover behavior works again.
220
+ fireEvent.keyDown(input, { key: 'Escape' })
221
+ fireEvent.pointerOut(float!, { relatedTarget: null })
222
+ act(() => { vi.advanceTimersByTime(1000) })
223
+ expect(screen.queryByText('改名')).toBeNull()
224
+ } finally {
225
+ vi.useRealTimers()
226
+ }
227
+ })
228
+ })
229
+
230
+ describe('PetSprite hover panel placement', () => {
231
+ it('places the panel above when an old saved position leaves no room below', () => {
232
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
233
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
234
+ if (this.style.bottom !== '') {
235
+ return { top: 720, right: 1256, bottom: 880, left: 1108, width: 148, height: 160, x: 1108, y: 720, toJSON: () => ({}) }
236
+ }
237
+ return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
238
+ })
239
+
240
+ renderPet({ display: { ...snapshot.display, bottom: 20 } })
241
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
242
+
243
+ const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
244
+ expect(panel.getAttribute('data-placement')).toBe('above')
245
+ // No bubble above the sprite: the panel keeps its default 8px gap.
246
+ expect(panel.style.marginBottom).toBe('')
247
+ })
248
+
249
+ it('lifts the above-panel clear of the status bubble instead of overlapping it', () => {
250
+ // Regression: the fallback above-placement anchored the panel at the
251
+ // sprite's top edge — the exact region the status bubble occupies — so
252
+ // the panel covered the bubble. The panel must now ride above it.
253
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
254
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
255
+ if (this.className.includes('bubbleStack')) {
256
+ // The bubble area (stack wrapping the status bubble): 40px tall.
257
+ return { top: 650, right: 1200, bottom: 690, left: 1120, width: 80, height: 40, x: 1120, y: 650, toJSON: () => ({}) }
258
+ }
259
+ // Sprite (bottom 974 of a 900px viewport) and panel (86px tall).
260
+ return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
261
+ })
262
+
263
+ renderPet({
264
+ snapshot: { ...snapshot, bubble: '正在思考' },
265
+ display: { ...snapshot.display, bottom: 20 },
266
+ })
267
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
268
+
269
+ const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
270
+ expect(panel.getAttribute('data-placement')).toBe('above')
271
+ // 40px bubble height + 14px (8px base gap + 6px clearance).
272
+ expect(panel.style.marginBottom).toBe('54px')
273
+ // The bubble stays rendered while the lifted panel is open.
274
+ expect(screen.queryByText('正在思考')).not.toBeNull()
275
+ })
276
+
277
+ it('lifts the above-panel clear of the session bubble stack', () => {
278
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
279
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
280
+ // The bubble stack contains buttons, not role=status; spot it by the
281
+ // session bubble text content being present among its descendants.
282
+ if (this.textContent === '正在思考正在使用 grep') {
283
+ // Two stacked bubbles: 88px tall.
284
+ return { top: 600, right: 1200, bottom: 688, left: 1100, width: 100, height: 88, x: 1100, y: 600, toJSON: () => ({}) }
285
+ }
286
+ return { top: 888, right: 1259, bottom: 974, left: 1105, width: 154, height: 86, x: 1105, y: 888, toJSON: () => ({}) }
287
+ })
288
+
289
+ renderPet({
290
+ snapshot: {
291
+ ...snapshot,
292
+ sessions: [
293
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
294
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
295
+ ],
296
+ },
297
+ display: { ...snapshot.display, bottom: 20 },
298
+ })
299
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
300
+
301
+ const panel = screen.getByText('改名').closest('[data-placement]') as HTMLElement
302
+ expect(panel.getAttribute('data-placement')).toBe('above')
303
+ // 88px stack height + 14px clearance.
304
+ expect(panel.style.marginBottom).toBe('102px')
305
+ })
179
306
  })
180
307
 
181
308
  describe('PetSprite status bubble', () => {
@@ -217,6 +344,36 @@ describe('PetSprite status bubble', () => {
217
344
  expect(screen.queryByText('正在使用 grep')).not.toBeNull()
218
345
  })
219
346
 
347
+ it('renders the inner whisper beneath the status bubble', () => {
348
+ renderPet({ snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' } })
349
+ // The whisper shows alongside (not instead of) the status copy.
350
+ expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).not.toBeNull()
351
+ expect(screen.queryByText('正在思考')).not.toBeNull()
352
+ })
353
+
354
+ it('renders the whisper alongside the session bubble stack', () => {
355
+ renderPet({
356
+ snapshot: {
357
+ ...workingSnapshot,
358
+ whisper: '我在这儿陪着你呢,别急别急',
359
+ sessions: [
360
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
361
+ ],
362
+ },
363
+ })
364
+ expect(screen.queryByText('我在这儿陪着你呢,别急别急')).not.toBeNull()
365
+ expect(screen.queryByText('正在思考')).not.toBeNull()
366
+ })
367
+
368
+ it('lets transient interaction feedback take over the whisper too', () => {
369
+ renderPet({
370
+ snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' },
371
+ feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
372
+ })
373
+ expect(screen.queryByText('摸摸成功')).not.toBeNull()
374
+ expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).toBeNull()
375
+ })
376
+
220
377
  it('lets feedback replace the whole session bubble stack', () => {
221
378
  renderPet({
222
379
  snapshot: {
@@ -293,4 +450,36 @@ describe('PetSprite definition-driven render', () => {
293
450
  fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
294
451
  expect(screen.queryByText('泡泡')).not.toBeNull()
295
452
  })
453
+
454
+ it('advances a configured scene sequence after the current track duration', () => {
455
+ vi.spyOn(window, 'matchMedia').mockReturnValue({
456
+ matches: false,
457
+ media: '(prefers-reduced-motion: reduce)',
458
+ onchange: null,
459
+ addEventListener: () => {},
460
+ removeEventListener: () => {},
461
+ addListener: () => {},
462
+ removeListener: () => {},
463
+ dispatchEvent: () => false,
464
+ })
465
+ vi.spyOn(performance, 'now').mockReturnValue(0)
466
+ let nextFrame: FrameRequestCallback | undefined
467
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
468
+ nextFrame = callback
469
+ return 1
470
+ })
471
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
472
+ const definition = petDefinition()
473
+ definition.sequences = {
474
+ thinking: ['running', 'waiting', 'running', 'waiting', 'running'],
475
+ }
476
+ renderPet({
477
+ definition,
478
+ snapshot: { ...snapshot, animation: 'running', phase: 'thinking' },
479
+ })
480
+ const sprite = screen.getByRole('button', { name: '鲸鱼娘' })
481
+ expect(sprite.style.backgroundPosition).toBe('0px -1120px')
482
+ act(() => { nextFrame?.(1_500) })
483
+ expect(sprite.style.backgroundPosition).toBe('0px -960px')
484
+ })
296
485
  })
@@ -9,8 +9,8 @@
9
9
  * @module @linxin666/dsh-pet/client/PetSprite
10
10
  */
11
11
 
12
- import { useEffect, useRef, useState } from 'react'
13
- import type { PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
12
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
13
+ import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
14
14
  import { createPortal } from 'react-dom'
15
15
  import clsx from 'clsx'
16
16
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -19,7 +19,8 @@ import type { PetStateView } from '../service.ts'
19
19
  import type { PetDefinition } from '../registry.ts'
20
20
  import type { PetFeedback } from './pet-store.ts'
21
21
  import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
22
- import type { PetAnimation } from '../state.ts'
22
+ import { sequenceFrameAt } from './sequences.ts'
23
+ import { animationForPhase, type PetAnimation } from '../state.ts'
23
24
  import { NS } from './locales.ts'
24
25
  import styles from './pet.module.css'
25
26
 
@@ -66,10 +67,22 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
66
67
  const { snapshot, definition, display, feedback } = props
67
68
  const spriteRef = useRef<HTMLDivElement | null>(null)
68
69
  const floatRef = useRef<HTMLDivElement | null>(null)
70
+ const panelRef = useRef<HTMLDivElement | null>(null)
71
+ // Whichever bubble surface is currently rendered (feedback, the session
72
+ // stack, or the legacy status bubble) — only one exists at a time.
73
+ const bubbleRef = useRef<HTMLDivElement | null>(null)
69
74
  const [imageReady, setImageReady] = useState(false)
70
75
  const [hovered, setHovered] = useState(false)
71
76
  const [renaming, setRenaming] = useState(false)
77
+ const [panelAbove, setPanelAbove] = useState(false)
78
+ // Extra margin-bottom for the above-panel so it stacks clear of the
79
+ // bubbles instead of overlapping them (both anchor at the sprite's top).
80
+ const [panelLift, setPanelLift] = useState(0)
72
81
  const [nameDraft, setNameDraft] = useState('')
82
+ // Explicit IME composition tracking: some input methods (WeChat IME on
83
+ // Windows) report keydowns with isComposing === false mid-composition, so
84
+ // the native flag alone is not a safe submit/cancel guard (#303).
85
+ const composingRef = useRef(false)
73
86
  const [dragPos, setDragPos] = useState<{ right: number; bottom: number } | null>(null)
74
87
  const dragRef = useRef<{ startX: number; startY: number; right: number; bottom: number } | null>(null)
75
88
  const hideTimerRef = useRef<number | null>(null)
@@ -83,6 +96,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
83
96
  const columns = definition.columns
84
97
  const rows = definition.rows
85
98
  const tracks = definition.tracks
99
+ const sequences = definition.sequences
86
100
 
87
101
  // Load the atlas once; the definition carries the authoritative per-row
88
102
  // frame counts and per-track durations, so nothing else is fetched.
@@ -106,14 +120,17 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
106
120
  // its track's first frame instead of animating (presentation-only; the
107
121
  // animation state machine is untouched).
108
122
  const spriteScale = display.size / cell.height
123
+ const phase = snapshot?.phase ?? 'idle'
109
124
  const animation = snapshot?.animation ?? 'idle'
110
125
  const scaleRef = useRef(spriteScale)
111
126
  scaleRef.current = spriteScale
112
127
  useEffect(() => {
113
128
  const reduceMotion = typeof window !== 'undefined'
114
129
  && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
115
- const row = rowOfTrack(animation)
116
- const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length)
130
+ const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined
131
+ const leadAnimation = sequence?.[0] ?? animation
132
+ const row = rowOfTrack(leadAnimation)
133
+ const track = trimTrack(tracks[leadAnimation], rows[row] ?? tracks[leadAnimation].frames.length)
117
134
  // Paint one static sprite frame up front either way, so the pet is never
118
135
  // blank while the loop heat-up runs.
119
136
  const leadCol = track.frames[0]!
@@ -124,9 +141,26 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
124
141
  if (reduceMotion) return
125
142
  let raf = 0
126
143
  let last = performance.now()
144
+ let sequenceElapsed = 0
127
145
  const tick = (ts: number): void => {
128
146
  const delta = ts - last
129
147
  last = ts
148
+ if (sequence !== undefined) {
149
+ sequenceElapsed += delta
150
+ const current = sequenceFrameAt(sequence, tracks, sequenceElapsed)
151
+ const currentRow = rowOfTrack(current.animation)
152
+ const currentTrack = trimTrack(
153
+ tracks[current.animation],
154
+ rows[currentRow] ?? tracks[current.animation].frames.length,
155
+ )
156
+ const col = currentTrack.frames[current.frameIndex]!
157
+ const pos = framePosition(cell, columns, currentRow, col, scaleRef.current)
158
+ if (spriteRef.current !== null) {
159
+ spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
160
+ }
161
+ raf = requestAnimationFrame(tick)
162
+ return
163
+ }
130
164
  // row/track come from the effect scope: they were computed once above
131
165
  // and this effect re-runs when animation/tracks/rows change, so the
132
166
  // per-frame recompute (trimTrack slices fresh arrays) is pure waste.
@@ -159,7 +193,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
159
193
  }
160
194
  raf = requestAnimationFrame(tick)
161
195
  return () => cancelAnimationFrame(raf)
162
- }, [animation, cell, columns, rows, tracks])
196
+ }, [animation, phase, cell, columns, rows, tracks, sequences])
163
197
 
164
198
  // Auto-clear the feedback bubble after its CSS animation. The callback
165
199
  // rides a ref so re-renders never reset the timer: the 2s poll rebuilds
@@ -210,16 +244,46 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
210
244
  const pos = dragPos ?? { right: display.right, bottom: display.bottom }
211
245
  const spriteWidth = Math.round(cell.width * spriteScale)
212
246
  const spriteHeight = Math.round(cell.height * spriteScale)
247
+
213
248
  // Concurrent sessions each render their own bubble (stacked above the
214
249
  // sprite); the legacy single 'bubble' is the fallback when the host serves
215
- // no per-session list. The hover panel now sits beside the sprite, so the
216
- // bubbles stay visible and clickable while hovering — no region swap.
250
+ // no per-session list. The hover panel normally sits below the sprite, so
251
+ // the bubbles stay visible and clickable while hovering — no region swap.
217
252
  const sessionBubbles = snapshot?.sessions ?? []
218
253
  const statusBubble = feedback === null && sessionBubbles.length === 0
219
254
  ? snapshot?.bubble
220
255
  : undefined
256
+ // The display session's inner whisper (碎碎念) — short inner-voice copy
257
+ // woken by the model's output. Interaction feedback takes over the whole
258
+ // bubble area while it plays, so whispers yield to it like status copy.
259
+ const whisper = feedback === null ? snapshot?.whisper : undefined
260
+ const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
221
261
  const displayName = snapshot?.name ?? definition.displayName
222
262
 
263
+ useLayoutEffect(() => {
264
+ if (!hovered) {
265
+ setPanelAbove(false)
266
+ setPanelLift(0)
267
+ return
268
+ }
269
+ const updatePanelPlacement = (): void => {
270
+ const sprite = spriteRef.current
271
+ const panel = panelRef.current
272
+ if (sprite === null || panel === null) return
273
+ const availableBelow = window.innerHeight - sprite.getBoundingClientRect().bottom
274
+ const above = availableBelow < panel.getBoundingClientRect().height + 8
275
+ setPanelAbove(above)
276
+ // The fallback above-placement shares the sprite's top edge with the
277
+ // bubble(s); lift the panel by the bubble area's height so the two
278
+ // never overlap (8px base gap + 6px clearance above the top bubble).
279
+ const bubbleHeight = above ? bubbleRef.current?.getBoundingClientRect().height ?? 0 : 0
280
+ setPanelLift(bubbleHeight > 0 ? Math.ceil(bubbleHeight) + 14 : 0)
281
+ }
282
+ updatePanelPlacement()
283
+ window.addEventListener('resize', updatePanelPlacement)
284
+ return () => window.removeEventListener('resize', updatePanelPlacement)
285
+ }, [hovered, renaming, pos.right, pos.bottom, display.size, bubblePresent, sessionBubbles.length, feedback])
286
+
223
287
  const float = (
224
288
  <div
225
289
  ref={floatRef}
@@ -230,16 +294,21 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
230
294
  setHovered(true)
231
295
  }}
232
296
  onPointerLeave={(e) => {
233
- // The panel renders OUTSIDE the container's box (absolute, beside
297
+ // The panel renders OUTSIDE the container's box (absolute, below
234
298
  // the sprite), so moving onto it fires pointerleave on the container.
235
299
  // Treat a target still inside the container's DOM (the overflowed
236
300
  // panel) as "still hovering"; otherwise give the pointer a short
237
- // grace period to reach the panel across the gap beside the sprite.
301
+ // grace period to reach the panel across the gap below the sprite.
238
302
  // The bridge ('.panel::after') keeps the pointer inside the hit
239
303
  // area, and the grace period covers a slow mouse crossing the
240
304
  // remaining sliver.
241
305
  const next = e.relatedTarget
242
306
  if (next instanceof Node && floatRef.current?.contains(next)) return
307
+ // Never auto-hide while the rename box is open: moving the pointer
308
+ // onto an IME candidate window (an OS-level window outside the
309
+ // webview) fires pointerleave, and unmounting the input mid-IME-
310
+ // composition crashes some input methods / the renderer (#303).
311
+ if (renaming) return
243
312
  clearHideTimer()
244
313
  hideTimerRef.current = window.setTimeout(() => setHovered(false), 300)
245
314
  }}
@@ -251,7 +320,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
251
320
  width: spriteWidth,
252
321
  height: spriteHeight,
253
322
  backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
254
- backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * rows.length * spriteScale) + 'px',
323
+ backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
255
324
  backgroundRepeat: 'no-repeat',
256
325
  backgroundPosition: '0 0',
257
326
  cursor: dragRef.current === null ? 'grab' : 'grabbing',
@@ -269,12 +338,12 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
269
338
  aria-label={definition.displayName}
270
339
  />
271
340
  {feedback !== null && (
272
- <div key={feedback.at} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
341
+ <div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
273
342
  {feedback.text}
274
343
  </div>
275
344
  )}
276
- {feedback === null && sessionBubbles.length > 0 && (
277
- <div className={styles.bubbleStack}>
345
+ {feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (
346
+ <div ref={bubbleRef} className={styles.bubbleStack}>
278
347
  {sessionBubbles.map(session => (
279
348
  <button
280
349
  key={session.sessionId}
@@ -286,16 +355,27 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
286
355
  {session.bubble}
287
356
  </button>
288
357
  ))}
289
- </div>
290
- )}
291
- {statusBubble !== undefined && (
292
- <div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
293
- {statusBubble}
358
+ {sessionBubbles.length === 0 && statusBubble !== undefined && (
359
+ <div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
360
+ {statusBubble}
361
+ </div>
362
+ )}
363
+ {whisper !== undefined && (
364
+ // key restarts the entrance animation per fresh whisper.
365
+ <div key={whisper} className={styles.bubbleWhisper}>
366
+ {whisper}
367
+ </div>
368
+ )}
294
369
  </div>
295
370
  )}
296
371
  {hovered && dragRef.current === null && (
297
372
  <div
298
- className={styles.panel}
373
+ ref={panelRef}
374
+ className={clsx(styles.panel, panelAbove && styles.panelAbove)}
375
+ data-placement={panelAbove ? 'above' : 'below'}
376
+ style={panelAbove && panelLift > 0
377
+ ? ({ marginBottom: panelLift } as CSSProperties)
378
+ : undefined}
299
379
  onPointerEnter={() => {
300
380
  // Reaching the panel (or its bridge) must cancel any hide timer
301
381
  // the container's pointerleave may have armed while the pointer
@@ -312,12 +392,16 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
312
392
  placeholder={props.t('pet.namePlaceholder')}
313
393
  autoFocus
314
394
  onChange={(e) => setNameDraft(e.target.value)}
395
+ onCompositionStart={() => { composingRef.current = true }}
396
+ onCompositionEnd={() => { composingRef.current = false }}
315
397
  onKeyDown={(e) => {
316
398
  // While an IME composition is active (e.g. selecting a
317
399
  // Chinese candidate), Enter/Escape keydowns belong to the
318
400
  // input method: ignore them so candidate selection can
319
- // neither submit the draft nor close the rename box.
320
- if (e.nativeEvent.isComposing) return
401
+ // neither submit the draft nor close the rename box. The
402
+ // explicit ref and the 'Process' key cover IMEs that mark
403
+ // composition keydowns with isComposing === false (#303).
404
+ if (composingRef.current || e.nativeEvent.isComposing || e.key === 'Process') return
321
405
  if (e.key === 'Enter') {
322
406
  const trimmed = nameDraft.trim()
323
407
  if (trimmed !== '') {
@@ -347,11 +431,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
347
431
  <>
348
432
  <div className={styles.rankRow}>
349
433
  <span className={styles.nameCell}>{displayName}</span>
350
- <span>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
434
+ <span className={styles.statRank}>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
351
435
  </div>
352
436
  <div className={styles.rankRow}>
353
- <span>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
354
- <span>{props.t('pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
437
+ <span className={styles.statTreats}>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
438
+ <span className={styles.statPoints}>{props.t('pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
355
439
  </div>
356
440
  <div className={styles.actions}>
357
441
  <button type="button" className={styles.action} onClick={props.onFeed}>
@@ -361,6 +445,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
361
445
  type="button"
362
446
  className={styles.action}
363
447
  onClick={() => {
448
+ // Cancel any pending hide so the rename box cannot
449
+ // unmount right as the user starts typing (#303).
450
+ clearHideTimer()
364
451
  setNameDraft(displayName)
365
452
  setRenaming(true)
366
453
  }}
@@ -56,6 +56,12 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
56
56
  * already provides the selection.
57
57
  */
58
58
  alwaysOpen?: boolean
59
+ /**
60
+ * Hide the save/discard footer: cards whose body applies its own changes
61
+ * immediately (an embedded external settings section) have no staged
62
+ * edits, so the footer would sit there permanently disabled.
63
+ */
64
+ hideFooter?: boolean
59
65
  /** The plugin's controls. */
60
66
  children: ReactNode
61
67
  }
@@ -140,6 +146,9 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
140
146
  <div className={css.body}>
141
147
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
142
148
  {props.children}
149
+ {props.hideFooter === true
150
+ ? null
151
+ : (
143
152
  <div className={css.footer}>
144
153
  {state.failed
145
154
  ? (
@@ -165,6 +174,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
165
174
  {props.t(!state.saving ? 'settings.save' : 'settings.saving')}
166
175
  </button>
167
176
  </div>
177
+ )}
168
178
  </div>
169
179
  )
170
180
  : null}
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ const css = readFileSync(new URL('./pet.module.css', import.meta.url), 'utf8')
5
+
6
+ describe('pet hover panel css', () => {
7
+ it('anchors the panel below the pet', () => {
8
+ const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
9
+ expect(panel).toContain('top: 100%')
10
+ expect(panel).toContain('margin-top: 8px')
11
+ expect(panel).not.toContain('right: 100%')
12
+ })
13
+
14
+ it('extends the hover bridge upward across the panel gap', () => {
15
+ const bridge = css.match(/\.panel::after\s*\{([^}]*)\}/)?.[1] ?? ''
16
+ expect(bridge).toContain('bottom: 100%')
17
+ expect(bridge).toContain('height: 14px')
18
+ })
19
+
20
+ it('styles the whisper bubble as a distinct non-interactive inner voice', () => {
21
+ const whisper = css.match(/\.bubbleWhisper\s*\{([^}]*)\}/)?.[1] ?? ''
22
+ expect(whisper).toContain('font-style: italic')
23
+ expect(whisper).toContain('pointer-events: none')
24
+ expect(css).toContain('@keyframes pet-whisper-in')
25
+ })
26
+
27
+ it('gives the panel and status bubbles entrance animations', () => {
28
+ expect(css).toContain('@keyframes pet-panel-in')
29
+ expect(css).toContain('@keyframes pet-bubble-in')
30
+ const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
31
+ expect(panel).toContain('animation: pet-panel-in')
32
+ })
33
+
34
+ it('can place the panel above and bridge the lower gap', () => {
35
+ const panel = css.match(/\.panelAbove\s*\{([^}]*)\}/)?.[1] ?? ''
36
+ expect(panel).toContain('bottom: 100%')
37
+ expect(panel).toContain('margin-bottom: 8px')
38
+ const bridge = css.match(/\.panelAbove::after\s*\{([^}]*)\}/)?.[1] ?? ''
39
+ expect(bridge).toContain('top: 100%')
40
+ expect(bridge).toContain('bottom: auto')
41
+ })
42
+ })