@linxin666/dsh-pet 0.1.20 → 0.2.1

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 (85) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +18 -11
  3. package/README.zh.md +18 -11
  4. package/assets/whale/pet.json +9 -0
  5. package/lib/client.js +394 -58
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +1007 -43
  8. package/lib/invariant.js +1 -1
  9. package/lib/{state-Diiq8vjk.js → state-DrMX22GL.js} +86 -28
  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 +756 -0
  16. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  17. package/lib/types/client/PetDockEntry.js +1 -1
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +133 -16
  20. package/lib/types/client/PluginSettingsCard.d.ts +28 -0
  21. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  22. package/lib/types/client/PluginSettingsCard.js +147 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +1 -0
  25. package/lib/types/client/locales.d.ts +4 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +4 -0
  28. package/lib/types/client/sequences.d.ts +10 -0
  29. package/lib/types/client/sequences.d.ts.map +1 -0
  30. package/lib/types/client/sequences.js +20 -0
  31. package/lib/types/event-projection.d.ts +14 -1
  32. package/lib/types/event-projection.d.ts.map +1 -1
  33. package/lib/types/event-projection.js +38 -17
  34. package/lib/types/ledger.d.ts.map +1 -1
  35. package/lib/types/ledger.js +15 -7
  36. package/lib/types/loopback.d.ts +25 -0
  37. package/lib/types/loopback.d.ts.map +1 -0
  38. package/lib/types/loopback.js +68 -0
  39. package/lib/types/persist.d.ts.map +1 -1
  40. package/lib/types/persist.js +3 -1
  41. package/lib/types/registry.d.ts +7 -1
  42. package/lib/types/registry.d.ts.map +1 -1
  43. package/lib/types/registry.js +47 -3
  44. package/lib/types/remarks.d.ts +2 -0
  45. package/lib/types/remarks.d.ts.map +1 -1
  46. package/lib/types/remarks.js +35 -0
  47. package/lib/types/routes.d.ts.map +1 -1
  48. package/lib/types/routes.js +14 -0
  49. package/lib/types/service.d.ts +6 -0
  50. package/lib/types/service.d.ts.map +1 -1
  51. package/lib/types/service.js +15 -2
  52. package/lib/types/state.d.ts +7 -4
  53. package/lib/types/state.d.ts.map +1 -1
  54. package/lib/types/state.js +20 -20
  55. package/package.json +9 -9
  56. package/src/affinity.test.ts +4 -2
  57. package/src/affinity.ts +37 -6
  58. package/src/chatter.test.ts +154 -0
  59. package/src/chatter.ts +787 -0
  60. package/src/client/PetDockEntry.test.tsx +93 -0
  61. package/src/client/PetDockEntry.tsx +1 -0
  62. package/src/client/PetSprite.test.tsx +253 -3
  63. package/src/client/PetSprite.tsx +192 -37
  64. package/src/client/PluginSettingsCard.tsx +229 -18
  65. package/src/client/index.test.tsx +95 -0
  66. package/src/client/index.ts +1 -0
  67. package/src/client/locales.ts +4 -0
  68. package/src/client/pet-css.test.ts +54 -0
  69. package/src/client/pet.module.css +215 -57
  70. package/src/client/sequences.test.ts +33 -0
  71. package/src/client/sequences.ts +33 -0
  72. package/src/client/settings-card.module.css +90 -1
  73. package/src/event-projection.ts +49 -16
  74. package/src/ledger.test.ts +42 -1
  75. package/src/ledger.ts +15 -7
  76. package/src/loopback.ts +63 -0
  77. package/src/persist.test.ts +18 -1
  78. package/src/persist.ts +3 -1
  79. package/src/registry.test.ts +79 -4
  80. package/src/registry.ts +58 -6
  81. package/src/remarks.ts +36 -0
  82. package/src/routes.ts +11 -0
  83. package/src/service.ts +27 -2
  84. package/src/state.test.ts +13 -0
  85. package/src/state.ts +24 -18
@@ -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,29 @@ 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)
76
+ // Multi-session bubble stack: collapsed by default (only the display
77
+ // session's bubble + a '+N' badge), expanded on stack hover (peek) or by
78
+ // tapping the badge (pinned, for touch). The display session's bubble
79
+ // anchors the bottom of the stack and never moves when extras open above
80
+ // it, so the pointer target cannot flicker.
81
+ const [stackPeek, setStackPeek] = useState(false)
82
+ const [stackPinned, setStackPinned] = useState(false)
71
83
  const [renaming, setRenaming] = useState(false)
84
+ const [panelAbove, setPanelAbove] = useState(false)
85
+ // Extra margin-bottom for the above-panel so it stacks clear of the
86
+ // bubbles instead of overlapping them (both anchor at the sprite's top).
87
+ const [panelLift, setPanelLift] = useState(0)
72
88
  const [nameDraft, setNameDraft] = useState('')
89
+ // Explicit IME composition tracking: some input methods (WeChat IME on
90
+ // Windows) report keydowns with isComposing === false mid-composition, so
91
+ // the native flag alone is not a safe submit/cancel guard (#303).
92
+ const composingRef = useRef(false)
73
93
  const [dragPos, setDragPos] = useState<{ right: number; bottom: number } | null>(null)
74
94
  const dragRef = useRef<{ startX: number; startY: number; right: number; bottom: number } | null>(null)
75
95
  const hideTimerRef = useRef<number | null>(null)
@@ -83,6 +103,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
83
103
  const columns = definition.columns
84
104
  const rows = definition.rows
85
105
  const tracks = definition.tracks
106
+ const sequences = definition.sequences
86
107
 
87
108
  // Load the atlas once; the definition carries the authoritative per-row
88
109
  // frame counts and per-track durations, so nothing else is fetched.
@@ -106,14 +127,17 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
106
127
  // its track's first frame instead of animating (presentation-only; the
107
128
  // animation state machine is untouched).
108
129
  const spriteScale = display.size / cell.height
130
+ const phase = snapshot?.phase ?? 'idle'
109
131
  const animation = snapshot?.animation ?? 'idle'
110
132
  const scaleRef = useRef(spriteScale)
111
133
  scaleRef.current = spriteScale
112
134
  useEffect(() => {
113
135
  const reduceMotion = typeof window !== 'undefined'
114
136
  && 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)
137
+ const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined
138
+ const leadAnimation = sequence?.[0] ?? animation
139
+ const row = rowOfTrack(leadAnimation)
140
+ const track = trimTrack(tracks[leadAnimation], rows[row] ?? tracks[leadAnimation].frames.length)
117
141
  // Paint one static sprite frame up front either way, so the pet is never
118
142
  // blank while the loop heat-up runs.
119
143
  const leadCol = track.frames[0]!
@@ -124,9 +148,26 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
124
148
  if (reduceMotion) return
125
149
  let raf = 0
126
150
  let last = performance.now()
151
+ let sequenceElapsed = 0
127
152
  const tick = (ts: number): void => {
128
153
  const delta = ts - last
129
154
  last = ts
155
+ if (sequence !== undefined) {
156
+ sequenceElapsed += delta
157
+ const current = sequenceFrameAt(sequence, tracks, sequenceElapsed)
158
+ const currentRow = rowOfTrack(current.animation)
159
+ const currentTrack = trimTrack(
160
+ tracks[current.animation],
161
+ rows[currentRow] ?? tracks[current.animation].frames.length,
162
+ )
163
+ const col = currentTrack.frames[current.frameIndex]!
164
+ const pos = framePosition(cell, columns, currentRow, col, scaleRef.current)
165
+ if (spriteRef.current !== null) {
166
+ spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
167
+ }
168
+ raf = requestAnimationFrame(tick)
169
+ return
170
+ }
130
171
  // row/track come from the effect scope: they were computed once above
131
172
  // and this effect re-runs when animation/tracks/rows change, so the
132
173
  // per-frame recompute (trimTrack slices fresh arrays) is pure waste.
@@ -159,7 +200,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
159
200
  }
160
201
  raf = requestAnimationFrame(tick)
161
202
  return () => cancelAnimationFrame(raf)
162
- }, [animation, cell, columns, rows, tracks])
203
+ }, [animation, phase, cell, columns, rows, tracks, sequences])
163
204
 
164
205
  // Auto-clear the feedback bubble after its CSS animation. The callback
165
206
  // rides a ref so re-renders never reset the timer: the 2s poll rebuilds
@@ -183,6 +224,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
183
224
  }
184
225
  }
185
226
 
227
+ // Clear any pending auto-hide timer on unmount: a stray callback after
228
+ // teardown reads window through react-dom and failed CI runs with
229
+ // "window is not defined" (slow-runner timing, PetSprite.test.tsx).
230
+ useEffect(() => () => clearHideTimer(), [])
231
+
186
232
  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
187
233
  e.preventDefault()
188
234
  ;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
@@ -210,16 +256,58 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
210
256
  const pos = dragPos ?? { right: display.right, bottom: display.bottom }
211
257
  const spriteWidth = Math.round(cell.width * spriteScale)
212
258
  const spriteHeight = Math.round(cell.height * spriteScale)
213
- // Concurrent sessions each render their own bubble (stacked above the
214
- // 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.
259
+
260
+ // Concurrent sessions share one bubble slot: only the display session
261
+ // speaks by default, and the rest hide behind a '+N' badge until the stack
262
+ // is hovered/pinned open. The legacy single 'bubble' is the fallback when
263
+ // the host serves no per-session list. The hover panel normally sits below
264
+ // the sprite, so the bubbles stay visible and clickable — no region swap.
217
265
  const sessionBubbles = snapshot?.sessions ?? []
266
+ const stackOpen = stackPeek || stackPinned
267
+ const collapsed = !stackOpen && sessionBubbles.length > 1
268
+ const visibleSessions = collapsed ? sessionBubbles.slice(0, 1) : sessionBubbles
218
269
  const statusBubble = feedback === null && sessionBubbles.length === 0
219
270
  ? snapshot?.bubble
220
271
  : undefined
272
+ // The display session's inner whisper (碎碎念) — short inner-voice copy
273
+ // woken by the model's output. Instead of a second bubble of its own, a
274
+ // fresh whisper takes over the display session's bubble (the stack top, or
275
+ // the single status bubble) and re-tints it, so the pet never wears two
276
+ // voices at once. Interaction feedback takes over the whole bubble area
277
+ // while it plays, so whispers yield to it like status copy.
278
+ const whisper = feedback === null ? snapshot?.whisper : undefined
279
+ const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
221
280
  const displayName = snapshot?.name ?? definition.displayName
222
281
 
282
+ // A settled session list can no longer stay pinned open.
283
+ useEffect(() => {
284
+ if (sessionBubbles.length <= 1) setStackPinned(false)
285
+ }, [sessionBubbles.length])
286
+
287
+ useLayoutEffect(() => {
288
+ if (!hovered) {
289
+ setPanelAbove(false)
290
+ setPanelLift(0)
291
+ return
292
+ }
293
+ const updatePanelPlacement = (): void => {
294
+ const sprite = spriteRef.current
295
+ const panel = panelRef.current
296
+ if (sprite === null || panel === null) return
297
+ const availableBelow = window.innerHeight - sprite.getBoundingClientRect().bottom
298
+ const above = availableBelow < panel.getBoundingClientRect().height + 8
299
+ setPanelAbove(above)
300
+ // The fallback above-placement shares the sprite's top edge with the
301
+ // bubble(s); lift the panel by the bubble area's height so the two
302
+ // never overlap (8px base gap + 6px clearance above the top bubble).
303
+ const bubbleHeight = above ? bubbleRef.current?.getBoundingClientRect().height ?? 0 : 0
304
+ setPanelLift(bubbleHeight > 0 ? Math.ceil(bubbleHeight) + 14 : 0)
305
+ }
306
+ updatePanelPlacement()
307
+ window.addEventListener('resize', updatePanelPlacement)
308
+ return () => window.removeEventListener('resize', updatePanelPlacement)
309
+ }, [hovered, renaming, pos.right, pos.bottom, display.size, bubblePresent, sessionBubbles.length, stackOpen, feedback])
310
+
223
311
  const float = (
224
312
  <div
225
313
  ref={floatRef}
@@ -230,16 +318,21 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
230
318
  setHovered(true)
231
319
  }}
232
320
  onPointerLeave={(e) => {
233
- // The panel renders OUTSIDE the container's box (absolute, beside
321
+ // The panel renders OUTSIDE the container's box (absolute, below
234
322
  // the sprite), so moving onto it fires pointerleave on the container.
235
323
  // Treat a target still inside the container's DOM (the overflowed
236
324
  // panel) as "still hovering"; otherwise give the pointer a short
237
- // grace period to reach the panel across the gap beside the sprite.
325
+ // grace period to reach the panel across the gap below the sprite.
238
326
  // The bridge ('.panel::after') keeps the pointer inside the hit
239
327
  // area, and the grace period covers a slow mouse crossing the
240
328
  // remaining sliver.
241
329
  const next = e.relatedTarget
242
330
  if (next instanceof Node && floatRef.current?.contains(next)) return
331
+ // Never auto-hide while the rename box is open: moving the pointer
332
+ // onto an IME candidate window (an OS-level window outside the
333
+ // webview) fires pointerleave, and unmounting the input mid-IME-
334
+ // composition crashes some input methods / the renderer (#303).
335
+ if (renaming) return
243
336
  clearHideTimer()
244
337
  hideTimerRef.current = window.setTimeout(() => setHovered(false), 300)
245
338
  }}
@@ -251,7 +344,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
251
344
  width: spriteWidth,
252
345
  height: spriteHeight,
253
346
  backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
254
- backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * rows.length * spriteScale) + 'px',
347
+ backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
255
348
  backgroundRepeat: 'no-repeat',
256
349
  backgroundPosition: '0 0',
257
350
  cursor: dragRef.current === null ? 'grab' : 'grabbing',
@@ -269,33 +362,88 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
269
362
  aria-label={definition.displayName}
270
363
  />
271
364
  {feedback !== null && (
272
- <div key={feedback.at} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
365
+ <div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
273
366
  {feedback.text}
274
367
  </div>
275
368
  )}
276
- {feedback === null && sessionBubbles.length > 0 && (
277
- <div className={styles.bubbleStack}>
278
- {sessionBubbles.map(session => (
279
- <button
280
- key={session.sessionId}
281
- type="button"
282
- className={clsx(styles.bubble, styles.bubbleStatus, styles.bubbleClickable)}
283
- title={props.t('pet.openSessionHint')}
284
- onClick={() => { props.onOpenSession(session.sessionId) }}
369
+ {feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (
370
+ <div
371
+ ref={bubbleRef}
372
+ className={styles.bubbleStack}
373
+ onPointerEnter={() => setStackPeek(true)}
374
+ onPointerLeave={() => setStackPeek(false)}
375
+ >
376
+ {visibleSessions.map((session, index) => {
377
+ // The whisper rides the display session's bubble — the stack's
378
+ // primary entry (DOM-first, rendered bottom-most by the reversed
379
+ // column so it stays glued to the sprite when extras open above).
380
+ // The key swap restarts the entrance animation so the mood change
381
+ // reads as the bubble re-speaking.
382
+ const speaksWhisper = index === 0 && whisper !== undefined
383
+ const bubble = (
384
+ <button
385
+ key={speaksWhisper ? 'whisper:' + whisper : session.sessionId}
386
+ type="button"
387
+ className={clsx(
388
+ styles.bubble,
389
+ styles.bubbleStatus,
390
+ styles.bubbleClickable,
391
+ speaksWhisper && styles.bubbleWhisper,
392
+ )}
393
+ title={props.t('pet.openSessionHint')}
394
+ onClick={() => { props.onOpenSession(session.sessionId) }}
395
+ >
396
+ {speaksWhisper ? whisper : session.bubble}
397
+ </button>
398
+ )
399
+ // The primary bubble carries the '+N' badge while other sessions
400
+ // hide behind it; the badge toggles the pinned (touch) expansion.
401
+ if (index !== 0 || sessionBubbles.length <= 1) return bubble
402
+ return (
403
+ <span key="primary" className={styles.bubbleAnchor}>
404
+ {bubble}
405
+ <button
406
+ type="button"
407
+ className={styles.bubbleMore}
408
+ title={stackOpen
409
+ ? props.t('pet.collapseSessions')
410
+ : props.t('pet.moreSessions', { n: sessionBubbles.length - 1 })}
411
+ aria-label={stackOpen
412
+ ? props.t('pet.collapseSessions')
413
+ : props.t('pet.moreSessions', { n: sessionBubbles.length - 1 })}
414
+ aria-expanded={stackOpen}
415
+ onClick={(e) => {
416
+ e.stopPropagation()
417
+ setStackPinned(open => !open)
418
+ }}
419
+ >
420
+ {stackOpen ? '×' : '+' + String(sessionBubbles.length - 1)}
421
+ </button>
422
+ </span>
423
+ )
424
+ })}
425
+ {sessionBubbles.length === 0 && (statusBubble !== undefined || whisper !== undefined) && (
426
+ // The key swap (status copy <-> whisper) restarts the entrance
427
+ // animation on every mood change.
428
+ <div
429
+ key={whisper === undefined ? 'status' : 'whisper:' + whisper}
430
+ className={clsx(styles.bubble, styles.bubbleStatus, whisper !== undefined && styles.bubbleWhisper)}
431
+ role="status"
432
+ aria-live="polite"
285
433
  >
286
- {session.bubble}
287
- </button>
288
- ))}
289
- </div>
290
- )}
291
- {statusBubble !== undefined && (
292
- <div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
293
- {statusBubble}
434
+ {whisper ?? statusBubble}
435
+ </div>
436
+ )}
294
437
  </div>
295
438
  )}
296
439
  {hovered && dragRef.current === null && (
297
440
  <div
298
- className={styles.panel}
441
+ ref={panelRef}
442
+ className={clsx(styles.panel, panelAbove && styles.panelAbove)}
443
+ data-placement={panelAbove ? 'above' : 'below'}
444
+ style={panelAbove && panelLift > 0
445
+ ? ({ marginBottom: panelLift } as CSSProperties)
446
+ : undefined}
299
447
  onPointerEnter={() => {
300
448
  // Reaching the panel (or its bridge) must cancel any hide timer
301
449
  // the container's pointerleave may have armed while the pointer
@@ -312,12 +460,16 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
312
460
  placeholder={props.t('pet.namePlaceholder')}
313
461
  autoFocus
314
462
  onChange={(e) => setNameDraft(e.target.value)}
463
+ onCompositionStart={() => { composingRef.current = true }}
464
+ onCompositionEnd={() => { composingRef.current = false }}
315
465
  onKeyDown={(e) => {
316
466
  // While an IME composition is active (e.g. selecting a
317
467
  // Chinese candidate), Enter/Escape keydowns belong to the
318
468
  // input method: ignore them so candidate selection can
319
- // neither submit the draft nor close the rename box.
320
- if (e.nativeEvent.isComposing) return
469
+ // neither submit the draft nor close the rename box. The
470
+ // explicit ref and the 'Process' key cover IMEs that mark
471
+ // composition keydowns with isComposing === false (#303).
472
+ if (composingRef.current || e.nativeEvent.isComposing || e.key === 'Process') return
321
473
  if (e.key === 'Enter') {
322
474
  const trimmed = nameDraft.trim()
323
475
  if (trimmed !== '') {
@@ -347,11 +499,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
347
499
  <>
348
500
  <div className={styles.rankRow}>
349
501
  <span className={styles.nameCell}>{displayName}</span>
350
- <span>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
502
+ <span className={styles.statRank}>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
351
503
  </div>
352
504
  <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>
505
+ <span className={styles.statTreats}>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
506
+ <span className={styles.statPoints}>{props.t('pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
355
507
  </div>
356
508
  <div className={styles.actions}>
357
509
  <button type="button" className={styles.action} onClick={props.onFeed}>
@@ -361,6 +513,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
361
513
  type="button"
362
514
  className={styles.action}
363
515
  onClick={() => {
516
+ // Cancel any pending hide so the rename box cannot
517
+ // unmount right as the user starts typing (#303).
518
+ clearHideTimer()
364
519
  setNameDraft(displayName)
365
520
  setRenaming(true)
366
521
  }}
@@ -8,7 +8,7 @@
8
8
  * ui-plugin-config PluginCard in a self-contained slice.
9
9
  */
10
10
 
11
- import { useState, type ReactNode } from 'react'
11
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
12
12
  import type { CardShell } from './settings-form.ts'
13
13
  import css from './settings-card.module.css'
14
14
 
@@ -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}
@@ -245,6 +255,210 @@ export function ValueField(props: FieldProps & {
245
255
  )
246
256
  }
247
257
 
258
+ const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed'])
259
+ // 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
260
+ function isSkinActive(): boolean {
261
+ const datasetList = Object.keys(document.body.dataset)
262
+ const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key))
263
+ return isActive
264
+ }
265
+
266
+ interface SelectOption {
267
+ value: string
268
+ label: string
269
+ }
270
+
271
+ const SELECT_CLOSE_MS = 100
272
+
273
+ /**
274
+ * The shared dual-mode select control. While an appearance skin is active it
275
+ * renders the legacy native `<select>` untouched, so element-level skin
276
+ * selectors keep working; under the default appearance it renders a
277
+ * self-drawn `role="listbox"` popup whose open/close is transition-animated.
278
+ * Staged cards reach it through BooleanField/ChoiceField; immediate-apply
279
+ * editors (the side-card prefs) bind it directly through onEdit.
280
+ * 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
281
+ */
282
+ export function SelectField(props: {
283
+ id: string
284
+ options: ReadonlyArray<SelectOption>
285
+ value: string
286
+ disabled: boolean
287
+ invalid: boolean
288
+ onEdit: (text: string) => void
289
+ }) {
290
+ const { id, options, value } = props
291
+ const [open, setOpen] = useState(false)
292
+ const [closing, setClosing] = useState(false)
293
+ const [phase, setPhase] = useState<'initial' | 'open'>('initial')
294
+ const [activeIndex, setActiveIndex] = useState(0)
295
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
296
+ const wrapRef = useRef<HTMLDivElement | null>(null)
297
+ const popupRef = useRef<HTMLDivElement | null>(null)
298
+
299
+ const currentIndex = () => {
300
+ const index = options.findIndex(option => option.value === value)
301
+ return index >= 0 ? index : 0
302
+ }
303
+
304
+ const close = useCallback(() => {
305
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
306
+ setClosing(true)
307
+ closeTimer.current = setTimeout(() => {
308
+ setClosing(false)
309
+ setOpen(false)
310
+ }, SELECT_CLOSE_MS)
311
+ }, [])
312
+
313
+ const openPopup = () => {
314
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
315
+ setActiveIndex(currentIndex())
316
+ setPhase('initial')
317
+ setClosing(false)
318
+ setOpen(true)
319
+ }
320
+
321
+ const commit = (index: number) => {
322
+ const option = options[index]
323
+ if (option) props.onEdit(option.value)
324
+ close()
325
+ }
326
+
327
+ const onTriggerClick = () => {
328
+ if (props.disabled) return
329
+ if (open && !closing) close()
330
+ else openPopup()
331
+ }
332
+
333
+ const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
334
+ if (props.disabled) return
335
+ const count = options.length
336
+ switch (event.key) {
337
+ case 'ArrowDown':
338
+ case 'ArrowUp':
339
+ case 'Enter':
340
+ case ' ':
341
+ event.preventDefault()
342
+ if (!open) {
343
+ openPopup()
344
+ } else if (!closing) {
345
+ if (event.key === 'ArrowDown') setActiveIndex(index => (index + 1) % count)
346
+ else if (event.key === 'ArrowUp') setActiveIndex(index => (index - 1 + count) % count)
347
+ else commit(activeIndex)
348
+ }
349
+ break
350
+ case 'Escape':
351
+ if (open) {
352
+ event.preventDefault()
353
+ event.stopPropagation()
354
+ close()
355
+ }
356
+ break
357
+ case 'Tab':
358
+ if (open) close()
359
+ break
360
+ }
361
+ }
362
+
363
+ useEffect(() => () => {
364
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
365
+ }, [])
366
+ useLayoutEffect(() => {
367
+ if (open && !closing && phase === 'initial') {
368
+ void popupRef.current?.offsetHeight
369
+ setPhase('open')
370
+ }
371
+ }, [open, closing, phase])
372
+
373
+ useEffect(() => {
374
+ if (!open) return
375
+ const onPointerDown = (event: PointerEvent) => {
376
+ const target = event.target
377
+ if (target instanceof Node && !wrapRef.current?.contains(target)) close()
378
+ }
379
+ document.addEventListener('pointerdown', onPointerDown)
380
+ return () => document.removeEventListener('pointerdown', onPointerDown)
381
+ }, [open, close])
382
+
383
+ useEffect(() => {
384
+ if (props.disabled && open) close()
385
+ }, [props.disabled, open, close])
386
+
387
+ // 使用了皮肤则退回原生select样式防止样式冲突。
388
+ if (isSkinActive()) {
389
+ return (
390
+ <select
391
+ id={id}
392
+ className={css.select}
393
+ value={value}
394
+ disabled={props.disabled}
395
+ onChange={(event) => { props.onEdit(event.target.value) }}
396
+ >
397
+ {options.map(option => (
398
+ <option key={option.value} value={option.value}>{option.label}</option>
399
+ ))}
400
+ </select>
401
+ )
402
+ }
403
+
404
+ const label = options.find(option => option.value === value)?.label ?? ''
405
+ const popupClass = closing
406
+ ? `${css.selectPopup} ${css.selectPopupClose}`
407
+ : phase === 'open'
408
+ ? `${css.selectPopup} ${css.selectPopupOpen}`
409
+ : css.selectPopup
410
+ return (
411
+ <div className={css.selectWrap} ref={wrapRef}>
412
+ <button
413
+ type="button"
414
+ id={id}
415
+ className={`${css.select} ${css.selectButton}`}
416
+ disabled={props.disabled}
417
+ aria-haspopup="listbox"
418
+ aria-expanded={open}
419
+ aria-activedescendant={open ? `${id}-o${activeIndex}` : undefined}
420
+ aria-invalid={props.invalid || undefined}
421
+ onClick={onTriggerClick}
422
+ onKeyDown={onKeyDown}
423
+ >
424
+ <span className={css.selectLabel}>{label}</span>
425
+ <svg
426
+ width="14"
427
+ height="14"
428
+ viewBox="0 0 14 14"
429
+ fill="none"
430
+ xmlns="http://www.w3.org/2000/svg"
431
+ className={open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron}
432
+ aria-hidden="true"
433
+ >
434
+ <path
435
+ d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
436
+ fill="currentColor"
437
+ />
438
+ </svg>
439
+ </button>
440
+ {open
441
+ ? (
442
+ <div className={popupClass} role="listbox" ref={popupRef}>
443
+ {options.map((option, index) => (
444
+ <div
445
+ key={option.value}
446
+ id={`${id}-o${index}`}
447
+ role="option"
448
+ aria-selected={option.value === value}
449
+ className={`${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`}
450
+ onClick={() => { commit(index) }}
451
+ >
452
+ {option.label}
453
+ </div>
454
+ ))}
455
+ </div>
456
+ )
457
+ : null}
458
+ </div>
459
+ )
460
+ }
461
+
248
462
  /** A staged boolean field: 继承 / 开 / 关. */
249
463
  export function BooleanField(props: FieldProps & {
250
464
  /** Copy for the inherit option. */
@@ -274,17 +488,18 @@ export function BooleanField(props: FieldProps & {
274
488
  )
275
489
  : null}
276
490
  </div>
277
- <select
491
+ <SelectField
278
492
  id={props.id}
279
- className={css.select}
493
+ options={[
494
+ { value: '', label: props.inheritLabel },
495
+ { value: 'true', label: props.onLabel },
496
+ { value: 'false', label: props.offLabel },
497
+ ]}
280
498
  value={props.text}
281
499
  disabled={props.disabled}
282
- onChange={(event) => { props.onEdit(event.target.value) }}
283
- >
284
- <option value="">{props.inheritLabel}</option>
285
- <option value="true">{props.onLabel}</option>
286
- <option value="false">{props.offLabel}</option>
287
- </select>
500
+ invalid={props.invalid}
501
+ onEdit={props.onEdit}
502
+ />
288
503
  <p className={css.hint}>{props.hint}</p>
289
504
  </div>
290
505
  )
@@ -317,18 +532,14 @@ export function ChoiceField(props: FieldProps & {
317
532
  )
318
533
  : null}
319
534
  </div>
320
- <select
535
+ <SelectField
321
536
  id={props.id}
322
- className={css.select}
537
+ options={[{ value: '', label: props.inheritLabel }, ...props.choices]}
323
538
  value={props.text}
324
539
  disabled={props.disabled}
325
- onChange={(event) => { props.onEdit(event.target.value) }}
326
- >
327
- <option value="">{props.inheritLabel}</option>
328
- {props.choices.map(choice => (
329
- <option key={choice.value} value={choice.value}>{choice.label}</option>
330
- ))}
331
- </select>
540
+ invalid={props.invalid}
541
+ onEdit={props.onEdit}
542
+ />
332
543
  <p className={props.invalid ? css.invalid : css.hint}>
333
544
  {props.invalid ? props.invalidLabel : props.hint}
334
545
  </p>