@linxin666/dsh-pet 0.1.14 → 0.1.16

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 (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +73 -38
  3. package/README.zh.md +91 -55
  4. package/assets/whale/pet.json +12 -1
  5. package/cordis.patch.yml +4 -4
  6. package/lib/client.js +296 -347
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +975 -317
  9. package/lib/invariant.js +1 -1
  10. package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
  11. package/lib/types/affinity.d.ts +15 -0
  12. package/lib/types/affinity.d.ts.map +1 -1
  13. package/lib/types/affinity.js +14 -0
  14. package/lib/types/client/PetDockEntry.d.ts +14 -12
  15. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  16. package/lib/types/client/PetDockEntry.js +13 -8
  17. package/lib/types/client/PetSettingsCard.d.ts +22 -9
  18. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PetSettingsCard.js +42 -7
  20. package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
  21. package/lib/types/client/PetSprite.d.ts.map +1 -0
  22. package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
  23. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  24. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  25. package/lib/types/client/PluginSettingsCard.js +30 -8
  26. package/lib/types/client/index.d.ts +11 -8
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +32 -14
  29. package/lib/types/client/locales.d.ts +10 -10
  30. package/lib/types/client/locales.js +10 -10
  31. package/lib/types/client/pet-store.d.ts +5 -0
  32. package/lib/types/client/pet-store.d.ts.map +1 -1
  33. package/lib/types/client/pet-store.js +4 -0
  34. package/lib/types/client/settings-form.d.ts +76 -5
  35. package/lib/types/client/settings-form.d.ts.map +1 -1
  36. package/lib/types/client/settings-form.js +97 -20
  37. package/lib/types/client/spritesheet.d.ts +14 -48
  38. package/lib/types/client/spritesheet.d.ts.map +1 -1
  39. package/lib/types/client/spritesheet.js +13 -81
  40. package/lib/types/dsh-home.d.ts +17 -0
  41. package/lib/types/dsh-home.d.ts.map +1 -0
  42. package/lib/types/dsh-home.js +34 -0
  43. package/lib/types/event-projection.d.ts +36 -0
  44. package/lib/types/event-projection.d.ts.map +1 -0
  45. package/lib/types/event-projection.js +98 -0
  46. package/lib/types/index.d.ts +24 -11
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +53 -33
  49. package/lib/types/ledger.d.ts +79 -0
  50. package/lib/types/ledger.d.ts.map +1 -0
  51. package/lib/types/ledger.js +146 -0
  52. package/lib/types/persist.d.ts +15 -4
  53. package/lib/types/persist.d.ts.map +1 -1
  54. package/lib/types/persist.js +39 -8
  55. package/lib/types/registry.d.ts +158 -0
  56. package/lib/types/registry.d.ts.map +1 -0
  57. package/lib/types/registry.js +276 -0
  58. package/lib/types/routes.d.ts +10 -10
  59. package/lib/types/routes.d.ts.map +1 -1
  60. package/lib/types/routes.js +169 -51
  61. package/lib/types/service.d.ts +59 -57
  62. package/lib/types/service.d.ts.map +1 -1
  63. package/lib/types/service.js +111 -211
  64. package/package.json +2 -2
  65. package/src/affinity.ts +33 -0
  66. package/src/client/PetDockEntry.tsx +34 -26
  67. package/src/client/PetSettingsCard.tsx +67 -22
  68. package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
  69. package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
  70. package/src/client/PluginSettingsCard.tsx +90 -18
  71. package/src/client/index.ts +38 -16
  72. package/src/client/locales.ts +10 -10
  73. package/src/client/pet-store.ts +9 -0
  74. package/src/client/settings-card.module.css +9 -0
  75. package/src/client/settings-form.ts +153 -19
  76. package/src/client/spritesheet.ts +16 -92
  77. package/src/dsh-home.test.ts +35 -0
  78. package/src/dsh-home.ts +36 -0
  79. package/src/event-projection.ts +128 -0
  80. package/src/index.test.ts +23 -0
  81. package/src/index.ts +89 -45
  82. package/src/ledger.test.ts +69 -0
  83. package/src/ledger.ts +190 -0
  84. package/src/persist.test.ts +52 -3
  85. package/src/persist.ts +49 -11
  86. package/src/registry.test.ts +132 -0
  87. package/src/registry.ts +400 -0
  88. package/src/routes.ts +166 -53
  89. package/src/service.ts +153 -285
  90. package/lib/types/client/WhalePet.d.ts.map +0 -1
@@ -1,47 +1,47 @@
1
1
  /**
2
- * Whale-girl companion component — the browser half's centerpiece. Renders a
2
+ * Pet sprite companion component — the browser half's centerpiece. Renders a
3
3
  * fixed-position floating sprite (React portal onto document.body), plays
4
- * the spritesheet track matching the host animation snapshot, and exposes
5
- * the interaction surface: click to pet, hover panel with feed/hide, drag to
6
- * reposition (persisted via setConfig).
7
- * @module @linxin666/dsh-pet/client/WhalePet
4
+ * the track matching the host animation snapshot, and exposes the
5
+ * interaction surface: click to pet, hover panel with feed/rename/hide, drag
6
+ * to reposition (persisted via setConfig). Everything visual comes from the
7
+ * pet definition the host serves ('/api/pet/pets' + the state snapshot's
8
+ * pet id), so one component renders every registry entry.
9
+ * @module @linxin666/dsh-pet/client/PetSprite
8
10
  */
9
11
 
10
12
  import { useEffect, useRef, useState } from 'react'
11
13
  import type { PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
12
14
  import { createPortal } from 'react-dom'
15
+ import clsx from 'clsx'
13
16
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
14
17
  import type { PetDisplayConfig } from '../persist.ts'
15
18
  import type { PetStateView } from '../service.ts'
19
+ import type { PetDefinition } from '../registry.ts'
16
20
  import type { PetFeedback } from './pet-store.ts'
17
- import { framePosition, FRAME_WIDTH, FRAME_HEIGHT, FRAME_COLUMNS, TRACKS, rowOfTrack, trimTrack, detectFrameCounts } from './spritesheet.ts'
21
+ import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
18
22
  import type { PetAnimation } from '../state.ts'
19
23
  import { NS } from './locales.ts'
20
24
  import styles from './pet.module.css'
21
25
 
22
- /** Browser URL of the whale-girl atlas (served by the host half's own route). */
23
- export const PET_SPRITESHEET_URL = '/pet/whale/spritesheet.webp'
24
-
25
- /** Browser URL of the whale-girl manifest (authoritative per-row frame counts). */
26
- export const PET_MANIFEST_URL = '/pet/whale/pet.json'
27
-
28
- /** Props injected by the slot registration (store actions + locale). */
29
- export interface WhalePetProps {
26
+ /** Props injected by the plugin apply body (store actions + locale). */
27
+ export interface PetSpriteProps {
30
28
  /** Latest host snapshot; null while loading. */
31
29
  snapshot: PetStateView | null
30
+ /** The selected pet's registry definition (atlas URL + geometry + tracks). */
31
+ definition: PetDefinition
32
32
  /** Display configuration (persisted by the host). */
33
33
  display: PetDisplayConfig
34
34
  /** Active reaction bubble, if any. */
35
35
  feedback: PetFeedback | null
36
- /** Pet the whale girl (click). */
36
+ /** Pet the sprite (click). */
37
37
  onPet: () => void
38
- /** Feed the whale girl (panel button). */
38
+ /** Feed the sprite (panel button). */
39
39
  onFeed: () => void
40
- /** Hide the whale girl (panel button). */
40
+ /** Hide the sprite (panel button). */
41
41
  onHide: () => void
42
42
  /** Persist a drag position. */
43
43
  onDragEnd: (right: number, bottom: number) => void
44
- /** Rename the pet (persisted by the host). */
44
+ /** Rename the selected pet (persisted by the host). */
45
45
  onRename: (name: string) => void
46
46
  /** Clear the reaction bubble (after its CSS animation). */
47
47
  onFeedbackDone: () => void
@@ -56,16 +56,15 @@ function clampOffset(value: number, max: number): number {
56
56
 
57
57
  /**
58
58
  * The floating pet. The spritesheet frame advances on requestAnimationFrame
59
- * with per-frame durations from TRACKS; the atlas image is loaded once and
60
- * the background position is written straight to the sprite element (no
61
- * per-frame React state).
59
+ * with per-frame durations from the definition's tracks; the atlas image is
60
+ * loaded once and the background position is written straight to the sprite
61
+ * element (no per-frame React state).
62
62
  */
63
- export function WhalePet(props: WhalePetProps): ReactPortal {
64
- const { snapshot, display, feedback } = props
63
+ export function PetSprite(props: PetSpriteProps): ReactPortal {
64
+ const { snapshot, definition, display, feedback } = props
65
65
  const spriteRef = useRef<HTMLDivElement | null>(null)
66
66
  const floatRef = useRef<HTMLDivElement | null>(null)
67
67
  const [imageReady, setImageReady] = useState(false)
68
- const [frameCounts, setFrameCounts] = useState<number[] | null>(null)
69
68
  const [hovered, setHovered] = useState(false)
70
69
  const [renaming, setRenaming] = useState(false)
71
70
  const [nameDraft, setNameDraft] = useState('')
@@ -78,39 +77,25 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
78
77
  elapsed: 0,
79
78
  })
80
79
 
81
- // Load the atlas once; then resolve per-row frame counts so tracks never
82
- // play the transparent trailing cells of a short row. One decoded Image
83
- // feeds both the sprite render and the frame-count detection. The counts
84
- // prefer the authoritatively recorded `frames` field on the pet.json
85
- // manifest route and only fall back to the getImageData atlas scan when
86
- // that field is absent (older manifests).
80
+ const cell = definition.cell
81
+ const columns = definition.columns
82
+ const rows = definition.rows
83
+ const tracks = definition.tracks
84
+
85
+ // Load the atlas once; the definition carries the authoritative per-row
86
+ // frame counts and per-track durations, so nothing else is fetched.
87
87
  useEffect(() => {
88
88
  let cancelled = false
89
89
  const img = new Image()
90
90
  img.onload = () => {
91
- if (cancelled) return
92
- setImageReady(true)
93
- fetch(PET_MANIFEST_URL)
94
- .then((res) => (res.ok ? res.json() : Promise.resolve<{ frames?: unknown }>({})))
95
- .then((manifest: { frames?: unknown }) => {
96
- if (cancelled) return
97
- const frames = manifest.frames
98
- if (Array.isArray(frames) && frames.length === 9 && frames.every((n) => typeof n === 'number')) {
99
- setFrameCounts(frames as number[])
100
- } else {
101
- setFrameCounts(detectFrameCounts(img))
102
- }
103
- })
104
- .catch(() => {
105
- if (!cancelled) setFrameCounts(detectFrameCounts(img))
106
- })
91
+ if (!cancelled) setImageReady(true)
107
92
  }
108
- img.src = PET_SPRITESHEET_URL
93
+ img.src = definition.atlasUrl
109
94
  return () => {
110
95
  cancelled = true
111
96
  img.onload = null
112
97
  }
113
- }, [])
98
+ }, [definition.atlasUrl])
114
99
 
115
100
  // Frame loop: advance the current track and write background-position.
116
101
  // Offsets must be in SCALED coordinates (background-position applies to the
@@ -118,23 +103,21 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
118
103
  // the loop reads every tick. Under prefers-reduced-motion the sprite holds
119
104
  // its track's first frame instead of animating (presentation-only; the
120
105
  // animation state machine is untouched).
121
- const spriteScale = display.size / FRAME_HEIGHT
106
+ const spriteScale = display.size / cell.height
122
107
  const animation = snapshot?.animation ?? 'idle'
123
108
  const scaleRef = useRef(spriteScale)
124
109
  scaleRef.current = spriteScale
125
110
  useEffect(() => {
126
111
  const reduceMotion = typeof window !== 'undefined'
127
112
  && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
113
+ const row = rowOfTrack(animation)
114
+ const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length)
128
115
  // Paint one static sprite frame up front either way, so the pet is never
129
116
  // blank while the loop heat-up runs.
130
- const row = rowOfTrack(animation)
131
- const track = frameCounts === null
132
- ? TRACKS[animation]
133
- : trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length)
134
117
  const leadCol = track.frames[0]!
135
- const lead = framePosition(row, leadCol, scaleRef.current)
118
+ const lead = framePosition(cell, columns, row, leadCol, scaleRef.current)
136
119
  if (spriteRef.current !== null) {
137
- spriteRef.current.style.backgroundPosition = `${lead.x}px ${lead.y}px`
120
+ spriteRef.current.style.backgroundPosition = lead.x + 'px ' + lead.y + 'px'
138
121
  }
139
122
  if (reduceMotion) return
140
123
  let raf = 0
@@ -145,9 +128,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
145
128
  // Trim the track to the row's real frame count (transparent cells
146
129
  // would render as a vanishing pet).
147
130
  const row = rowOfTrack(animation)
148
- const track = frameCounts === null
149
- ? TRACKS[animation]
150
- : trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length)
131
+ const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length)
151
132
  const st = frameRef.current
152
133
  if (st.track !== animation) {
153
134
  st.track = animation
@@ -169,18 +150,18 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
169
150
  }
170
151
  }
171
152
  const col = track.frames[st.index]!
172
- const { x, y } = framePosition(row, col, scaleRef.current)
153
+ const pos = framePosition(cell, columns, row, col, scaleRef.current)
173
154
  if (spriteRef.current !== null) {
174
- spriteRef.current.style.backgroundPosition = `${x}px ${y}px`
155
+ spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
175
156
  }
176
157
  raf = requestAnimationFrame(tick)
177
158
  }
178
159
  raf = requestAnimationFrame(tick)
179
160
  return () => cancelAnimationFrame(raf)
180
- }, [animation, frameCounts])
161
+ }, [animation, cell, columns, rows, tracks])
181
162
 
182
163
  // Auto-clear the feedback bubble after its CSS animation. The callback
183
- // rides a ref so re-renders never reset the timer: the 800ms poll rebuilds
164
+ // rides a ref so re-renders never reset the timer: the 2s poll rebuilds
184
165
  // `props` every tick, and depending on it would starve the timeout.
185
166
  const feedbackDoneRef = useRef(props.onFeedbackDone)
186
167
  feedbackDoneRef.current = props.onFeedbackDone
@@ -192,7 +173,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
192
173
 
193
174
  // Dragging: pointer events on the sprite; position is right/bottom based.
194
175
  // `draggedRef` records whether the pointer actually moved, so the browser's
195
- // trailing click (fired after pointerup) does not pet the whale.
176
+ // trailing click (fired after pointerup) does not pet the sprite.
196
177
  const draggedRef = useRef(false)
197
178
  const clearHideTimer = (): void => {
198
179
  if (hideTimerRef.current !== null) {
@@ -226,9 +207,10 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
226
207
  }
227
208
 
228
209
  const pos = dragPos ?? { right: display.right, bottom: display.bottom }
229
- const spriteWidth = Math.round(FRAME_WIDTH * spriteScale)
230
- const spriteHeight = Math.round(FRAME_HEIGHT * spriteScale)
210
+ const spriteWidth = Math.round(cell.width * spriteScale)
211
+ const spriteHeight = Math.round(cell.height * spriteScale)
231
212
  const statusBubble = feedback === null && !hovered ? snapshot?.bubble : undefined
213
+ const displayName = snapshot?.name ?? definition.displayName
232
214
 
233
215
  const float = (
234
216
  <div
@@ -245,7 +227,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
245
227
  // container. Treat a target still inside the container's DOM (the
246
228
  // overflowed panel) as "still hovering"; otherwise give the pointer a
247
229
  // short grace period to reach the panel across the gap above it. The
248
- // bridge (`.panel::after`) keeps the pointer inside the hit area, and
230
+ // bridge ('.panel::after') keeps the pointer inside the hit area, and
249
231
  // the grace period covers a slow mouse crossing the remaining sliver.
250
232
  const next = e.relatedTarget
251
233
  if (next instanceof Node && floatRef.current?.contains(next)) return
@@ -259,8 +241,8 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
259
241
  style={{
260
242
  width: spriteWidth,
261
243
  height: spriteHeight,
262
- backgroundImage: imageReady ? `url(${PET_SPRITESHEET_URL})` : undefined,
263
- backgroundSize: `${FRAME_WIDTH * FRAME_COLUMNS * spriteScale}px ${FRAME_HEIGHT * 9 * spriteScale}px`,
244
+ backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
245
+ backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * rows.length * spriteScale) + 'px',
264
246
  backgroundRepeat: 'no-repeat',
265
247
  backgroundPosition: '0 0',
266
248
  cursor: dragRef.current === null ? 'grab' : 'grabbing',
@@ -275,15 +257,15 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
275
257
  props.onPet()
276
258
  }}
277
259
  role="button"
278
- aria-label="whale girl"
260
+ aria-label={definition.displayName}
279
261
  />
280
262
  {feedback !== null && (
281
- <div key={feedback.at} className={`${styles.bubble} ${feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet}`}>
263
+ <div key={feedback.at} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
282
264
  {feedback.text}
283
265
  </div>
284
266
  )}
285
267
  {statusBubble !== undefined && (
286
- <div className={`${styles.bubble} ${styles.bubbleStatus}`} role="status" aria-live="polite">
268
+ <div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
287
269
  {statusBubble}
288
270
  </div>
289
271
  )}
@@ -340,7 +322,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
340
322
  ) : (
341
323
  <>
342
324
  <div className={styles.rankRow}>
343
- <span className={styles.nameCell}>{snapshot?.name ?? '鲸鱼娘'}</span>
325
+ <span className={styles.nameCell}>{displayName}</span>
344
326
  <span>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
345
327
  </div>
346
328
  <div className={styles.rankRow}>
@@ -355,7 +337,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
355
337
  type="button"
356
338
  className={styles.action}
357
339
  onClick={() => {
358
- setNameDraft(snapshot?.name ?? '')
340
+ setNameDraft(displayName)
359
341
  setRenaming(true)
360
342
  }}
361
343
  >
@@ -1,25 +1,44 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/PluginSettingsCard.tsx. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
- * Shared chrome for the plugin settings card: a disclosure header naming the
3
- * plugin and what its settings govern, the controls inside, and the save that
4
- * writes them. Renders nothing while the namespace is unavailable — a
3
+ * Family-shared chrome for plugin settings cards: a disclosure header naming
4
+ * the plugin and what its settings govern, the controls inside, and the save
5
+ * that writes them. Renders nothing while the namespace is unavailable — a
5
6
  * deployment that does not compose the owning plugin should show no trace of
6
- * it. Mirrors the official ui-plugin-config PluginCard in a self-contained
7
- * slice (this package must not depend on a sibling UI package).
7
+ * it. Inlined into each consumer's client bundle; mirrors the official
8
+ * ui-plugin-config PluginCard in a self-contained slice.
8
9
  */
9
10
 
10
11
  import { useState, type ReactNode } from 'react'
11
12
  import type { CardShell } from './settings-form.ts'
12
- import type { SettingsCardKey } from './locales.ts'
13
13
  import css from './settings-card.module.css'
14
14
 
15
+ /** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
16
+ export const CARD_COPY_KEYS = [
17
+ 'settings.collapse',
18
+ 'settings.expand',
19
+ 'settings.notExposed',
20
+ 'settings.unsaved',
21
+ 'settings.readOnly',
22
+ 'settings.saveFailed',
23
+ 'settings.discard',
24
+ 'settings.save',
25
+ 'settings.saving',
26
+ ] as const
27
+
28
+ /** Copy key the card chrome itself reads. */
29
+ export type CardCopyKey = (typeof CARD_COPY_KEYS)[number]
30
+
31
+ /** Key domain for the plugin's own copy (inferred per consumer). */
32
+ export type SettingsCardKey<TKey extends string = string> = TKey | CardCopyKey
33
+
15
34
  /** Card chrome shared by every plugin settings card. */
16
- export interface PluginSettingsCardProps {
35
+ export interface PluginSettingsCardProps<TKey extends string = string> {
17
36
  /** Locale reader for this card's copy. */
18
- t: (key: SettingsCardKey) => string
37
+ t: (key: SettingsCardKey<TKey>, params?: Record<string, string | number>) => string
19
38
  /** Locale key of the plugin's name. */
20
- titleKey: SettingsCardKey
39
+ titleKey: TKey
21
40
  /** Locale key of the line describing what this plugin's settings govern. */
22
- descriptionKey: SettingsCardKey
41
+ descriptionKey: TKey
23
42
  /** The card's form state: availability, writability, and what a save would do. */
24
43
  state: CardShell
25
44
  /** Write every staged edit. */
@@ -35,11 +54,12 @@ export interface PluginSettingsCardProps {
35
54
  * @param props - the plugin's copy keys, its form state, and its controls.
36
55
  * @returns the card, or nothing while the namespace is still loading.
37
56
  */
38
- export function PluginSettingsCard(props: PluginSettingsCardProps) {
57
+ export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
39
58
  const [open, setOpen] = useState(false)
40
59
  const { state } = props
41
60
  if (!state.available) return null
42
61
  const title = props.t(props.titleKey)
62
+ const description = props.t(props.descriptionKey)
43
63
  const blocked = !state.dirty || state.invalid || state.saving
44
64
  const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
45
65
  // The namespace exists but the Host does not serve it to this client (the
@@ -57,8 +77,8 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
57
77
  onClick={() => { setOpen(!open) }}
58
78
  >
59
79
  <span className={css.headText}>
60
- <span className={css.name}>{title}</span>
61
- <span className={css.description}>{props.t(props.descriptionKey)}</span>
80
+ <span className={css.name} title={title}>{title}</span>
81
+ <span className={css.description} title={description}>{description}</span>
62
82
  </span>
63
83
  <svg
64
84
  width="14"
@@ -94,10 +114,10 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
94
114
  onClick={() => { setOpen(!open) }}
95
115
  >
96
116
  <span className={css.headText}>
97
- <span className={css.name}>{title}</span>
98
- <span className={css.description}>{props.t(props.descriptionKey)}</span>
117
+ <span className={css.name} title={title}>{title}</span>
118
+ <span className={css.description} title={description}>{description}</span>
99
119
  </span>
100
- {state.dirty ? <span className={css.pending}>{props.t('settings.unsaved')}</span> : null}
120
+ {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
101
121
  <svg
102
122
  width="14"
103
123
  height="14"
@@ -118,7 +138,13 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
118
138
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
119
139
  {props.children}
120
140
  <div className={css.footer}>
121
- {state.failed ? <p className={css.failed} role="status">{props.t('settings.saveFailed')}</p> : null}
141
+ {state.failed
142
+ ? (
143
+ <p className={css.failed} role="status">
144
+ {props.t('settings.saveFailed')}{state.failedReason ? ' - ' + state.failedReason : ''}
145
+ </p>
146
+ )
147
+ : null}
122
148
  <button
123
149
  type="button"
124
150
  className={css.discard}
@@ -259,4 +285,50 @@ export function BooleanField(props: FieldProps & {
259
285
  <p className={css.hint}>{props.hint}</p>
260
286
  </div>
261
287
  )
262
- }
288
+ }
289
+
290
+ /** A staged enumerated field rendered as a select. */
291
+ export function ChoiceField(props: FieldProps & {
292
+ /** Copy for the inherit option (draft text is the empty string). */
293
+ inheritLabel: string
294
+ /** Choices rendered in order; `value` is the draft/stored text. */
295
+ choices: ReadonlyArray<{ value: string; label: string }>
296
+ }) {
297
+ return (
298
+ <div className={css.field}>
299
+ <div className={css.head}>
300
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
301
+ {props.overridden
302
+ ? (
303
+ <span className={css.badges}>
304
+ <span className={css.badge}>{props.overriddenLabel}</span>
305
+ <button
306
+ type="button"
307
+ className={css.reset}
308
+ disabled={props.disabled}
309
+ onClick={props.onReset}
310
+ >
311
+ {props.resetLabel}
312
+ </button>
313
+ </span>
314
+ )
315
+ : null}
316
+ </div>
317
+ <select
318
+ id={props.id}
319
+ className={css.select}
320
+ value={props.text}
321
+ disabled={props.disabled}
322
+ onChange={(event) => { props.onEdit(event.target.value) }}
323
+ >
324
+ <option value="">{props.inheritLabel}</option>
325
+ {props.choices.map(choice => (
326
+ <option key={choice.value} value={choice.value}>{choice.label}</option>
327
+ ))}
328
+ </select>
329
+ <p className={props.invalid ? css.invalid : css.hint}>
330
+ {props.invalid ? props.invalidLabel : props.hint}
331
+ </p>
332
+ </div>
333
+ )
334
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
- * dsh-pet browser half — mounts the whale-girl as a global floating surface
3
- * and drives it from the host's same-origin `/api/pet/*` JSON endpoints: poll
4
- * the host snapshot (~800 ms), forward interactions, persist drag positions.
5
- * The pet is host-global (no session dimension), so it mounts directly onto
6
- * `document.body` via a single React root rather than a session-scoped slot —
7
- * on the new-conversation screen no session exists, and a dock-mounted pet
8
- * would vanish there (issue #48). When the pet is hidden the entry becomes a
2
+ * dsh-pet browser half — mounts the selected pet as a global floating
3
+ * surface and drives it from the host's same-origin '/api/pet/*' JSON
4
+ * endpoints: fetch the registry list once, poll the host snapshot (~2 s),
5
+ * forward interactions, persist drag positions. The pet is host-global (no
6
+ * session dimension), so it mounts directly onto 'document.body' via a
7
+ * single React root rather than a session-scoped slot — on the
8
+ * new-conversation screen no session exists, and a dock-mounted pet would
9
+ * vanish there (issue #48). When the pet is hidden the entry becomes a
9
10
  * fixed-position summon button.
10
11
  * @module @linxin666/dsh-pet/client
11
12
  */
@@ -20,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
20
21
  import type { PetDisplayConfig } from '../persist.ts'
21
22
  import type { PetInteractResult, PetStateView } from '../service.ts'
22
23
  import type { PetInteraction } from '../affinity.ts'
24
+ import type { PetDefinition } from '../registry.ts'
23
25
  import { createElement } from 'react'
24
26
  import { createRoot } from 'react-dom/client'
25
27
  import { createPetStore, type PetStoreInstance } from './pet-store.ts'
@@ -30,10 +32,12 @@ import { NS, en, zh, t } from './locales.ts'
30
32
  /** The host pet API as the browser sees it (same-origin JSON endpoints). */
31
33
  interface PetHttpApi {
32
34
  state(): Promise<PetStateView>
35
+ pets(): Promise<PetDefinition[]>
33
36
  interact(kind: PetInteraction): Promise<PetInteractResult>
34
37
  setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }>
35
38
  setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }>
36
39
  setName(name: string): Promise<{ ok: true; name: string } | { ok: false; error: string }>
40
+ setPet(petId: string): Promise<{ ok: true; petId: string } | { ok: false; error: string }>
37
41
  }
38
42
 
39
43
  /** Same-origin JSON fetch helper (GET without body, POST with JSON body). */
@@ -46,7 +50,7 @@ async function petFetch<T>(path: string, body?: unknown): Promise<T> {
46
50
  body: JSON.stringify(body),
47
51
  })
48
52
  if (!response.ok) {
49
- throw new Error(`pet ${path} failed: ${response.status}`)
53
+ throw new Error('pet ' + path + ' failed: ' + response.status)
50
54
  }
51
55
  return (await response.json()) as T
52
56
  }
@@ -54,14 +58,16 @@ async function petFetch<T>(path: string, body?: unknown): Promise<T> {
54
58
  /** The live host API instance (always defined; failures surface per call). */
55
59
  const petApi: PetHttpApi = {
56
60
  state: () => petFetch('/api/pet/state'),
61
+ pets: () => petFetch('/api/pet/pets'),
57
62
  interact: (kind) => petFetch('/api/pet/interact', { kind }),
58
63
  setVisible: (visible) => petFetch('/api/pet/set-visible', { visible }),
59
64
  setConfig: (patch) => petFetch('/api/pet/set-config', patch),
60
65
  setName: (name) => petFetch('/api/pet/set-name', { name }),
66
+ setPet: (petId) => petFetch('/api/pet/set-pet', { petId }),
61
67
  }
62
68
 
63
69
  /** Poll interval for the host snapshot. */
64
- const POLL_MS = 800
70
+ const POLL_MS = 2000
65
71
 
66
72
  /** Settings namespace the pet settings card edits (the Host plugin registers it). */
67
73
  const PET_SETTINGS_NS = 'pet'
@@ -71,14 +77,16 @@ export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote
71
77
 
72
78
  /** Re-exported for consumers that type against the injected face. */
73
79
  export type { PetInjected, PetDockEntryProps } from './PetDockEntry.tsx'
80
+ export type { PetSpriteProps } from './PetSprite.tsx'
74
81
  export type { PetUiState, PetFeedback } from './pet-store.ts'
75
82
  export type { PetSettingsCardFace, PetSettingsCardState } from './PetSettingsCard.tsx'
83
+ export type { PetDefinition } from '../registry.ts'
76
84
 
77
85
  declare module '@deepseek-ai/dsh-client-ui-slots' {
78
86
  interface SlotMap {
79
87
  /**
80
88
  * The child slot the Web UI plugin group declares; this card registers
81
- * into the group instead of the top-level `settings.plugin.item` list.
89
+ * into the group instead of the top-level 'settings.plugin.item' list.
82
90
  * Spelled here with the same shape so this package can register without
83
91
  * depending on the sibling UI package.
84
92
  */
@@ -103,7 +111,6 @@ declare module '@deepseek-ai/cordis' {
103
111
  }
104
112
  }
105
113
 
106
-
107
114
  /**
108
115
  * Client plugin body: register dictionaries, mount the global pet entry and
109
116
  * poll loop while the plugin is enabled, and seat the settings card in the
@@ -122,8 +129,9 @@ export function apply(ctx: ClientContext): void {
122
129
  : snapshot.status === 'unavailable'
123
130
  }
124
131
 
125
- // Plugin configuration card: one staged form over the `pet` settings
126
- // namespace, contributed to the Web UI plugin group.
132
+ // Plugin configuration card: one staged form over the 'pet' settings
133
+ // namespace, contributed to the Web UI plugin group. The controller loads
134
+ // the petId choices from the registry endpoint itself.
127
135
  const petSettings = new PetSettingsCardController(settingsScope)
128
136
  ctx.slots.inject('web-ui.plugin.item', () => ctx.slots.register({
129
137
  name: 'web-ui.plugin.item',
@@ -145,10 +153,24 @@ export function apply(ctx: ClientContext): void {
145
153
  // it stateless on the new-conversation screen (no session to scope by).
146
154
  const petStore: PetStoreInstance = createPetStore().create()
147
155
  const setSnapshot = petStore.actions.setSnapshot
156
+ const setPets = petStore.actions.setPets
148
157
  const setState = petStore.actions.setState
149
158
  const setFeedback = petStore.actions.setFeedback
150
159
 
160
+ // The registry list is fetched lazily with retries baked into the poll
161
+ // cycle: until it lands, the dock entry renders nothing and every 2s
162
+ // tick tries again. After it lands, one list feeds both the sprite and
163
+ // the settings card's choices.
164
+ let petsLoaded = false
151
165
  const pollNow = (): void => {
166
+ if (!petsLoaded) {
167
+ petApi.pets().then((list) => {
168
+ petsLoaded = true
169
+ setPets(list)
170
+ }, () => {
171
+ // Retry on the next poll tick.
172
+ })
173
+ }
152
174
  petApi.state().then((snapshot) => {
153
175
  setSnapshot(snapshot)
154
176
  }, () => {
@@ -161,7 +183,7 @@ export function apply(ctx: ClientContext): void {
161
183
  // change while the page is hidden, so a background interval would
162
184
  // only burn RPCs (browser throttling is an unreliable backstop).
163
185
  // Coming back to the tab refreshes the pet immediately instead of
164
- // waiting out the next 800 ms cycle.
186
+ // waiting out the next 2 s cycle.
165
187
  let timer: number | undefined
166
188
  const stop = (): void => {
167
189
  if (timer !== undefined) {
@@ -253,8 +275,8 @@ export function apply(ctx: ClientContext): void {
253
275
  // for a global floating surface — the dock is session-scoped, so a pet
254
276
  // mounted there would vanish on the new-conversation screen (issue #48).
255
277
  // The entry therefore mounts straight onto document.body via a single
256
- // React root for the page lifetime: WhalePet portals itself to body when
257
- // visible, and the hidden-state summon button is fixed-positioned.
278
+ // React root for the page lifetime: PetSprite portals itself to body
279
+ // when visible, and the hidden-state summon button is fixed-positioned.
258
280
  const container = document.createElement('div')
259
281
  container.dataset.dshPetRoot = ''
260
282
  document.body.appendChild(container)
@@ -17,11 +17,13 @@ export const zh = {
17
17
  'pet.rank': '亲密度 {rank}',
18
18
  'pet.points': '{points} 点',
19
19
  'pet.treats': '小鱼干 ×{n}',
20
- 'pet.state.loading': '鲸鱼娘正在赶来…',
21
- 'pet.state.error': '鲸鱼娘迷路了(连接失败)',
20
+ 'pet.state.loading': '宠物正在赶来…',
21
+ 'pet.state.error': '宠物迷路了(连接失败)',
22
22
  // 插件设置卡片(settings.plugin.item 席位)。
23
23
  'settings.title': '宠物',
24
- 'settings.description': '鲸鱼娘的显示布局与名字。',
24
+ 'settings.description': '选择宠物并调整它的显示布局。',
25
+ 'settings.pet': '宠物',
26
+ 'settings.petHint': '选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。',
25
27
  'settings.enabled': '启用宠物',
26
28
  'settings.enabledHint': '关闭后隐藏宠物并停止轮询,可在设置里重新启用。',
27
29
  'settings.visible': '显示宠物',
@@ -32,8 +34,6 @@ export const zh = {
32
34
  'settings.rightHint': '距视口右边缘的水平内缩距离。',
33
35
  'settings.bottom': '距底部(px)',
34
36
  'settings.bottomHint': '距视口底边的垂直内缩距离。',
35
- 'settings.name': '名字',
36
- 'settings.nameHint': '宠物显示名,1–20 个字符。',
37
37
  'settings.inherit': '继承',
38
38
  'settings.on': '开',
39
39
  'settings.off': '关',
@@ -62,11 +62,13 @@ export const en = {
62
62
  'pet.rank': 'Affinity {rank}',
63
63
  'pet.points': '{points} pts',
64
64
  'pet.treats': 'Treats ×{n}',
65
- 'pet.state.loading': 'The whale girl is on her way…',
66
- 'pet.state.error': 'The whale girl is lost (connection failed)',
65
+ 'pet.state.loading': 'The pet is on its way…',
66
+ 'pet.state.error': 'The pet is lost (connection failed)',
67
67
  // Plugin settings card (the `settings.plugin.item` seat).
68
68
  'settings.title': 'Pet',
69
- 'settings.description': 'The whale girl\u2019s display layout and name.',
69
+ 'settings.description': 'Pick a pet and tune its display layout.',
70
+ 'settings.pet': 'Pet',
71
+ 'settings.petHint': 'Choose which pet shows. Names are stored per pet; rename from the pet hover panel.',
70
72
  'settings.enabled': 'Enable the pet',
71
73
  'settings.enabledHint': 'When off, the pet hides and polling stops; re-enable it here.',
72
74
  'settings.visible': 'Show the pet',
@@ -77,8 +79,6 @@ export const en = {
77
79
  'settings.rightHint': 'Horizontal inset from the viewport right edge.',
78
80
  'settings.bottom': 'Bottom inset (px)',
79
81
  'settings.bottomHint': 'Vertical inset from the viewport bottom edge.',
80
- 'settings.name': 'Name',
81
- 'settings.nameHint': 'The pet\u2019s display name, 1\u201320 characters.',
82
82
  'settings.inherit': 'Inherit',
83
83
  'settings.on': 'On',
84
84
  'settings.off': 'Off',