@linxin666/dsh-pet 0.3.25 → 0.4.3

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.
@@ -5,38 +5,122 @@
5
5
  * the loader accepts a standalone install of the same package side by side;
6
6
  * without this guard the second instance would still re-register the same
7
7
  * webserver routes, tools, settings namespaces, and system-prompt sections
8
- * and fail the boot. mountOnce makes the second host apply a no-op for the
9
- * lifetime of the first instance (the browser half is already deduped by
10
- * package name in the client module host).
8
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
9
+ * no-op for as long as the first instance lives (the browser half is already
10
+ * deduped by package name in the client module host).
11
+ *
12
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
13
+ * long after the refused mount ran its course: the Host reloads a profile by
14
+ * creating the new loader entries before the old ones are torn down (a
15
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
16
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
17
+ * previous entry still owns the name. Dropping that refused mount lost the
18
+ * plugin for good - the previous entry then disposed its own mount, releasing
19
+ * the name with nobody left to take it, and the family row stayed listed as
20
+ * active while its host routes 404ed (the task-board panel showed
21
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
22
+ * restart recovered it).
23
+ *
24
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
25
+ * the holder releases the name - if the waiting fiber is still alive then. The
26
+ * single-instance guarantee is unchanged: exactly one mount is live per
27
+ * package name, and the replay re-enters the guard so a later mount still
28
+ * dedupes against it.
11
29
  *
12
30
  * The registry rides a global symbol so two module instances of the same
13
- * package (npm copy vs repository link) still share one verdict. cordis
14
- * `ctx.effect` runs its callback immediately and treats the callback's
31
+ * package (npm copy vs repository link) still share one verdict. That symbol
32
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
33
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
34
+ * dsh-community-plugins) are separate repositories carrying their own copy of
35
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
36
+ * must keep the shape every published copy reads (a `Set` of package names
37
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
38
+ * under their own additive key, and the registry reads back a `Set` even when
39
+ * some other build left a different value there. Changing `MOUNTED`'s shape
40
+ * in place broke that contract once: a satellite's legacy copy created a
41
+ * `Set`, the family's new copy read it as a `Map`, and every family row
42
+ * mounted after it failed with "claims.get is not a function".
43
+ *
44
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
15
45
  * return value as the fiber disposer, so the unmarker is returned, not run.
16
46
  */
47
+ /** Published cross-repository contract: package names currently mounted. */
17
48
  const MOUNTED = Symbol.for('dsh-web.mounted-plugins');
49
+ /** Additive key this guard owns: refused mounts waiting for the name. */
50
+ const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters');
51
+ /**
52
+ * The shared name registry, always a `Set` whatever another build stored here:
53
+ * a foreign value (an interim shape, a hand-written global) must not take every
54
+ * family plugin down with it.
55
+ * @returns the process-wide set of mounted package names.
56
+ */
18
57
  function mountedSet() {
19
58
  const registry = globalThis;
20
- return (registry[MOUNTED] ??= new Set());
59
+ const existing = registry[MOUNTED];
60
+ if (existing instanceof Set)
61
+ return existing;
62
+ const created = new Set();
63
+ registry[MOUNTED] = created;
64
+ return created;
65
+ }
66
+ /** Queue per package name for mounts refused while a holder was alive. */
67
+ function mountWaiters() {
68
+ const registry = globalThis;
69
+ return (registry[WAITERS] ??= new Map());
21
70
  }
22
71
  /**
23
72
  * Wrap a cordis plugin apply so the package runs at most once per process.
24
- * The first mount registers normally and unmarks when its fiber disposes;
25
- * any later mount of the same package name is a no-op.
73
+ * The first mount registers normally and releases the name when its fiber
74
+ * disposes; a mount refused while that name is held waits for the release and
75
+ * then runs, unless its own fiber disposes first.
26
76
  * @param packageName - npm package identity shared by every install source.
27
77
  * @param fn - the original plugin apply.
28
78
  * @returns an apply of the same shape.
29
79
  */
30
80
  export function mountOnce(packageName, fn) {
31
- return ((...args) => {
81
+ const mount = (...args) => {
32
82
  const mounted = mountedSet();
33
- if (mounted.has(packageName))
83
+ const ctx = args[0];
84
+ if (mounted.has(packageName)) {
85
+ // Another fiber (the aggregate row, a standalone install, or a copy built
86
+ // from an older revision of this guard) owns the name. Queue instead of
87
+ // dropping: dropping lost the plugin for the rest of the process when the
88
+ // holder was disposed by a reload.
89
+ const waiters = mountWaiters();
90
+ const queue = waiters.get(packageName) ?? [];
91
+ let alive = true;
92
+ const pending = {
93
+ run: () => {
94
+ if (alive)
95
+ mount(...args);
96
+ },
97
+ };
98
+ // A fiber that dies while it waits must not be replayed afterwards: its
99
+ // context is disposed and registering effects on it would throw.
100
+ ctx?.effect?.(() => () => {
101
+ alive = false;
102
+ const index = queue.indexOf(pending);
103
+ if (index >= 0)
104
+ queue.splice(index, 1);
105
+ });
106
+ queue.push(pending);
107
+ waiters.set(packageName, queue);
34
108
  return;
109
+ }
35
110
  mounted.add(packageName);
36
- const ctx = args[0];
37
111
  ctx?.effect?.(() => () => {
38
112
  mounted.delete(packageName);
113
+ const waiters = mountWaiters();
114
+ const queue = waiters.get(packageName);
115
+ if (queue === undefined)
116
+ return;
117
+ waiters.delete(packageName);
118
+ // Defer the handover one microtask so the disposing mount's own effects
119
+ // (routes, ledger, timers) are torn down before the next one registers.
120
+ for (const waiter of queue.splice(0))
121
+ queueMicrotask(() => { waiter.run(); });
39
122
  });
40
123
  return fn(...args);
41
- });
124
+ };
125
+ return mount;
42
126
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-pet",
3
3
  "description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
4
- "version": "0.3.25",
4
+ "version": "0.4.3",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -1,7 +1,16 @@
1
1
  // @vitest-environment jsdom
2
- import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ /**
3
+ * The pet settings card under the aggregate shell, which serves no Host
4
+ * settings form for the 'pet' namespace: the pet choice and its visibility
5
+ * switch both write through the pet API, so the pet can still be turned off
6
+ * when the Host form is absent.
7
+ */
8
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, render, screen } from '@testing-library/react'
10
+ import { useSyncExternalStore } from 'react'
3
11
  import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
4
- import { PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
12
+ import { PetSettingsCard, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
13
+ import { t } from './locales.ts'
5
14
 
6
15
  vi.mock('@deepseek-ai/dsh-client-store', () => ({ // test-standards-allow: the SDK client bundle is a browser module factory and cannot be imported by Vitest
7
16
  createSnapshotStore: (initial: unknown) => {
@@ -32,7 +41,12 @@ function response(value: unknown, ok = true): Response {
32
41
  return { ok, status: ok ? 200 : 500, json: async () => value } as Response
33
42
  }
34
43
 
44
+ beforeAll(() => {
45
+ document.documentElement.lang = 'zh'
46
+ })
47
+
35
48
  afterEach(() => {
49
+ cleanup()
36
50
  vi.useRealTimers()
37
51
  vi.unstubAllGlobals()
38
52
  })
@@ -46,7 +60,6 @@ describe('pet selection without a Host settings form', () => {
46
60
  const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
47
61
  const path = String(input)
48
62
  if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }, { id: 'jyn', displayName: '女仆鲸鱼娘' }])
49
- if (path === '/api/pet/diagnostics') return response({ diagnostics: [] })
50
63
  if (path === '/api/pet/state') return response({ pet: { id: selected } })
51
64
  if (path === '/api/pet/set-pet') {
52
65
  selected = JSON.parse(String(init?.body)).petId as string
@@ -84,7 +97,6 @@ describe('pet selection without a Host settings form', () => {
84
97
  vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
85
98
  const path = String(input)
86
99
  if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }, { id: 'jyn', displayName: '女仆鲸鱼娘' }])
87
- if (path === '/api/pet/diagnostics') return response({ diagnostics: [] })
88
100
  if (path === '/api/pet/state') return response({ pet: { id: 'whale-girl' } })
89
101
  if (path === '/api/pet/set-pet') return response({ ok: false, error: 'unknown-pet' })
90
102
  throw new Error('unexpected request: ' + path)
@@ -101,3 +113,102 @@ describe('pet selection without a Host settings form', () => {
101
113
  controller.dispose()
102
114
  })
103
115
  })
116
+
117
+ describe('pet visibility without a Host settings form', () => {
118
+ it('user turns the pet off through its persisted API and confirms the read-back', async () => {
119
+ // Given the aggregate shell exposes no pet configuration form and the pet is showing
120
+ vi.useFakeTimers()
121
+ const { scope, mutate } = unavailableForm()
122
+ let visible = true
123
+ const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
124
+ const path = String(input)
125
+ if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }])
126
+ if (path === '/api/pet/state') return response({ pet: { id: 'whale-girl' }, display: { visible } })
127
+ if (path === '/api/pet/set-visible') {
128
+ visible = JSON.parse(String(init?.body)).visible as boolean
129
+ return response({ ok: true, display: { visible } })
130
+ }
131
+ throw new Error('unexpected request: ' + path)
132
+ })
133
+ vi.stubGlobal('fetch', fetch)
134
+ const controller = new PetSettingsCardController(scope)
135
+ await vi.advanceTimersByTimeAsync(0)
136
+ const face = controller.inject()
137
+ const state = () => face.hooks.petSettingsCard.getSnapshot()
138
+
139
+ // When the user switches the pet off and clicks Save
140
+ expect(state()).toMatchObject({ petSelectionFallback: true, visible: { text: 'true' }, dirty: false })
141
+ face.edit('visible', 'false')
142
+ expect(state()).toMatchObject({ dirty: true, visible: { text: 'false' } })
143
+ face.save()
144
+ await vi.waitFor(() => { expect(state().saving).toBe(false) })
145
+
146
+ // Then the pet service holds it, the draft clears, and the absent Host form is untouched
147
+ expect(visible).toBe(false)
148
+ expect(state()).toMatchObject({ dirty: false, failed: false, visible: { text: 'false' } })
149
+ expect(mutate).not.toHaveBeenCalled()
150
+ expect(fetch).toHaveBeenCalledWith('/api/pet/set-visible', expect.objectContaining({ body: JSON.stringify({ visible: false }) }))
151
+ controller.dispose()
152
+ })
153
+
154
+ it('user keeps the draft when the pet API refuses the visibility write', async () => {
155
+ // Given the Host has no pet form and the pet API refuses visibility writes
156
+ vi.useFakeTimers()
157
+ const { scope } = unavailableForm()
158
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
159
+ const path = String(input)
160
+ if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }])
161
+ if (path === '/api/pet/state') return response({ pet: { id: 'whale-girl' }, display: { visible: true } })
162
+ if (path === '/api/pet/set-visible') return response({ ok: false }, false)
163
+ throw new Error('unexpected request: ' + path)
164
+ }))
165
+ const controller = new PetSettingsCardController(scope)
166
+ await vi.advanceTimersByTimeAsync(0)
167
+ const face = controller.inject()
168
+ // When the user saves a visibility the pet API refuses
169
+ face.edit('visible', 'false')
170
+ face.save()
171
+ await vi.waitFor(() => { expect(face.hooks.petSettingsCard.getSnapshot().saving).toBe(false) })
172
+ // Then the switch keeps the draft and the failure is visible
173
+ expect(face.hooks.petSettingsCard.getSnapshot()).toMatchObject({ dirty: true, failed: true, visible: { text: 'false' } })
174
+ controller.dispose()
175
+ })
176
+ })
177
+
178
+ describe('pet settings card rendered without a Host settings form', () => {
179
+ it('shows the visibility switch beside the pet choice and hides the Host-only fields', async () => {
180
+ // Given the aggregate shell exposes no pet configuration form
181
+ vi.useFakeTimers()
182
+ const { scope } = unavailableForm()
183
+ vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
184
+ const path = String(input)
185
+ if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }])
186
+ if (path === '/api/pet/state') return response({ pet: { id: 'whale-girl' }, display: { visible: true } })
187
+ throw new Error('unexpected request: ' + path)
188
+ }))
189
+ const controller = new PetSettingsCardController(scope)
190
+ await vi.advanceTimersByTimeAsync(0)
191
+ const face = controller.inject()
192
+ const store = face.hooks.petSettingsCard
193
+
194
+ // When the card renders
195
+ render(
196
+ <PetSettingsCard
197
+ t={t}
198
+ usePetSettingsCard={select => useSyncExternalStore(store.subscribe, () => select(store.getSnapshot()))}
199
+ save={face.save}
200
+ discard={face.discard}
201
+ edit={face.edit}
202
+ resetField={face.resetField}
203
+ />,
204
+ )
205
+
206
+ // Then the pet choice and the switch that turns the pet off are both on screen
207
+ expect(screen.getByLabelText(t('settings.pet'))).toBeTruthy()
208
+ expect(screen.getByLabelText(t('settings.visible'))).toBeTruthy()
209
+ // ...while the fields only the absent Host form owns stay hidden
210
+ expect(screen.queryByLabelText(t('settings.enabled'))).toBeNull()
211
+ expect(screen.queryByLabelText(t('settings.size'))).toBeNull()
212
+ controller.dispose()
213
+ })
214
+ })
@@ -61,8 +61,6 @@ export interface PetSettingsCardState extends CardShell {
61
61
  decorationEnabled: CardFieldState
62
62
  /** Pet choices (registry ids + display names), loaded from the host. */
63
63
  petChoices: readonly { value: string; label: string }[]
64
- /** Registry diagnostics (v1 migration hints, invalid entries), host-served. */
65
- petDiagnostics: readonly PetDiagnosticView[]
66
64
  }
67
65
 
68
66
  /** The registration-side face the card's slot entry injects. */
@@ -79,12 +77,6 @@ interface PetChoice {
79
77
  displayName: string
80
78
  }
81
79
 
82
- /** One registry diagnostic as served by '/api/pet/diagnostics' (#623). */
83
- export interface PetDiagnosticView {
84
- level: 'error' | 'warning'
85
- message: string
86
- }
87
-
88
80
  /** Fetch the registry list (the same data the sprite renders from). */
89
81
  async function fetchPetChoices(): Promise<PetChoice[]> {
90
82
  const response = await fetch('/api/pet/pets')
@@ -92,21 +84,22 @@ async function fetchPetChoices(): Promise<PetChoice[]> {
92
84
  return (await response.json()) as PetChoice[]
93
85
  }
94
86
 
95
- /** Fetch the registry diagnostics (v1 migration hints, invalid entries). */
96
- async function fetchPetDiagnostics(): Promise<PetDiagnosticView[]> {
97
- const response = await fetch('/api/pet/diagnostics')
98
- if (!response.ok) throw new Error('pet diagnostics failed: ' + response.status)
99
- const body = (await response.json()) as { diagnostics?: PetDiagnosticView[] }
100
- return body.diagnostics ?? []
87
+ /** The persisted state the fallback mirrors: the selection and its visibility. */
88
+ interface PetState {
89
+ /** Selected registry id. */
90
+ petId: string
91
+ /** Whether the pet is currently rendered. */
92
+ visible: boolean
101
93
  }
102
94
 
103
- /** Read the selection from the same persisted state the pet renders. */
104
- async function fetchSelectedPetId(): Promise<string> {
95
+ /** Read the selection and visibility from the same persisted state the pet renders. */
96
+ async function fetchPetState(): Promise<PetState> {
105
97
  const response = await fetch('/api/pet/state')
106
98
  if (!response.ok) throw new Error('pet state failed: ' + response.status)
107
- const body = (await response.json()) as { pet?: { id?: unknown } }
99
+ const body = (await response.json()) as { pet?: { id?: unknown }; display?: { visible?: unknown } }
108
100
  if (typeof body.pet?.id !== 'string') throw new Error('pet state has no selected pet')
109
- return body.pet.id
101
+ // A Host without the display block predates the visibility switch; the pet shows.
102
+ return { petId: body.pet.id, visible: body.display?.visible !== false }
110
103
  }
111
104
 
112
105
  /** Bridges the 'pet' scope onto the card's staged form. */
@@ -118,9 +111,10 @@ export class PetSettingsCardController {
118
111
  // without rebuilding the form.
119
112
  private readonly petChoices: string[] = []
120
113
  private readonly petLabels = new Map<string, string>()
121
- private diagnostics: PetDiagnosticView[] = []
122
114
  private selectedPetId: string | undefined
115
+ private selectedVisible: boolean | undefined
123
116
  private stagedPetId: string | undefined
117
+ private stagedVisible: boolean | undefined
124
118
  private savingPet = false
125
119
  private petSaveFailed = false
126
120
  private loaded = false
@@ -150,22 +144,10 @@ export class PetSettingsCardController {
150
144
  this.pendingTimer = undefined
151
145
  if (this.disposed) return
152
146
  void this.loadPets()
153
- void this.loadDiagnostics()
154
- void this.loadSelectedPet()
147
+ void this.loadPetState()
155
148
  }, 0)
156
149
  }
157
150
 
158
- /** Fetch registry diagnostics once (soft-fail: an empty list on error). */
159
- private async loadDiagnostics(): Promise<void> {
160
- try {
161
- this.diagnostics = await fetchPetDiagnostics()
162
- if (this.disposed) return
163
- this.store.set(this.projection())
164
- } catch {
165
- this.diagnostics = []
166
- }
167
- }
168
-
169
151
  /** Resolve the registry choices once (retried a few times on failure). */
170
152
  private async loadPets(): Promise<void> {
171
153
  if (this.loaded || this.disposed) return
@@ -189,11 +171,12 @@ export class PetSettingsCardController {
189
171
  }
190
172
  }
191
173
 
192
- private async loadSelectedPet(): Promise<void> {
174
+ private async loadPetState(): Promise<void> {
193
175
  try {
194
- const petId = await fetchSelectedPetId()
176
+ const state = await fetchPetState()
195
177
  if (this.disposed) return
196
- this.selectedPetId = petId
178
+ this.selectedPetId = state.petId
179
+ this.selectedVisible = state.visible
197
180
  this.store.set(this.projection())
198
181
  } catch {
199
182
  // The Host form remains the authority when it is available. An absent
@@ -206,24 +189,58 @@ export class PetSettingsCardController {
206
189
  return shell.available && !shell.exposed && this.selectedPetId !== undefined
207
190
  }
208
191
 
209
- private async savePetSelection(): Promise<void> {
192
+ /**
193
+ * The visibility the fallback switch renders: the staged draft when the user
194
+ * moved it, the persisted value otherwise. There is no user layer to override
195
+ * here, so the field is never marked overridden.
196
+ */
197
+ private fallbackVisible(): CardFieldState {
198
+ const value = this.stagedVisible ?? this.selectedVisible
199
+ return { text: value === undefined ? '' : String(value), overridden: false, invalid: false }
200
+ }
201
+
202
+ /**
203
+ * Persist the staged selection and visibility through the pet API — the only
204
+ * writer available when the aggregate shell serves this card, since there is
205
+ * no Host settings form to mutate — then confirm the read-back before the
206
+ * drafts are cleared.
207
+ */
208
+ private async saveFallback(): Promise<void> {
210
209
  const petId = this.stagedPetId
211
- if (petId === undefined || this.savingPet || !this.petChoices.includes(petId)) return
210
+ const visible = this.stagedVisible
211
+ if (this.savingPet) return
212
+ if (petId === undefined && visible === undefined) return
213
+ if (petId !== undefined && !this.petChoices.includes(petId)) return
212
214
  this.savingPet = true
213
215
  this.petSaveFailed = false
214
216
  this.store.set(this.projection())
215
217
  try {
216
- const response = await fetch('/api/pet/set-pet', {
217
- method: 'POST',
218
- headers: { 'content-type': 'application/json' },
219
- body: JSON.stringify({ petId }),
220
- })
221
- const result = (await response.json()) as { ok?: boolean; petId?: string }
222
- if (!response.ok || result.ok !== true || result.petId !== petId || await fetchSelectedPetId() !== petId) {
223
- throw new Error('pet selection was not persisted')
218
+ if (petId !== undefined) {
219
+ const response = await fetch('/api/pet/set-pet', {
220
+ method: 'POST',
221
+ headers: { 'content-type': 'application/json' },
222
+ body: JSON.stringify({ petId }),
223
+ })
224
+ const result = (await response.json()) as { ok?: boolean; petId?: string }
225
+ if (!response.ok || result.ok !== true || result.petId !== petId) {
226
+ throw new Error('pet selection was not persisted')
227
+ }
224
228
  }
225
- this.selectedPetId = petId
229
+ if (visible !== undefined) {
230
+ const response = await fetch('/api/pet/set-visible', {
231
+ method: 'POST',
232
+ headers: { 'content-type': 'application/json' },
233
+ body: JSON.stringify({ visible }),
234
+ })
235
+ if (!response.ok) throw new Error('pet visibility was not persisted')
236
+ }
237
+ const persisted = await fetchPetState()
238
+ if (petId !== undefined && persisted.petId !== petId) throw new Error('pet selection was not persisted')
239
+ if (visible !== undefined && persisted.visible !== visible) throw new Error('pet visibility was not persisted')
240
+ this.selectedPetId = persisted.petId
241
+ this.selectedVisible = persisted.visible
226
242
  if (this.stagedPetId === petId) this.stagedPetId = undefined
243
+ if (this.stagedVisible === visible) this.stagedVisible = undefined
227
244
  } catch {
228
245
  this.petSaveFailed = true
229
246
  } finally {
@@ -241,7 +258,8 @@ export class PetSettingsCardController {
241
258
  ...(fallback ? {
242
259
  exposed: true,
243
260
  writable: true,
244
- dirty: this.stagedPetId !== undefined && this.stagedPetId !== this.selectedPetId,
261
+ dirty: this.stagedPetId !== undefined && this.stagedPetId !== this.selectedPetId
262
+ || this.stagedVisible !== undefined && this.stagedVisible !== this.selectedVisible,
245
263
  invalid: this.stagedPetId !== undefined && !this.petChoices.includes(this.stagedPetId),
246
264
  saving: this.savingPet,
247
265
  failed: this.petSaveFailed,
@@ -250,7 +268,7 @@ export class PetSettingsCardController {
250
268
  petSelectionFallback: fallback,
251
269
  enabled: this.form.field('enabled'),
252
270
  decorationEnabled: this.form.field('decorationEnabled'),
253
- visible: this.form.field('visible'),
271
+ visible: fallback ? this.fallbackVisible() : this.form.field('visible'),
254
272
  size: this.form.field('size'),
255
273
  right: this.form.field('right'),
256
274
  bottom: this.form.field('bottom'),
@@ -261,7 +279,6 @@ export class PetSettingsCardController {
261
279
  ? { ...configuredPet, text: this.selectedPetId }
262
280
  : configuredPet,
263
281
  petChoices: this.petChoices.map(id => ({ value: id, label: this.petLabels.get(id) ?? id })),
264
- petDiagnostics: this.diagnostics,
265
282
  }
266
283
  }
267
284
 
@@ -275,22 +292,25 @@ export class PetSettingsCardController {
275
292
  hooks: { petSettingsCard: this.store },
276
293
  edit: (field, value) => {
277
294
  if (!this.fallback()) return actions.edit(field, value)
278
- if (field !== 'petId') return
279
- this.stagedPetId = value === '' ? undefined : value
295
+ if (field === 'petId') this.stagedPetId = value === '' ? undefined : value
296
+ else if (field === 'visible') this.stagedVisible = value === '' ? undefined : value === 'true'
297
+ else return
280
298
  this.petSaveFailed = false
281
299
  this.store.set(this.projection())
282
300
  },
283
301
  resetField: (field) => {
284
302
  if (!this.fallback()) return actions.resetField(field)
285
- if (field !== 'petId') return
286
- this.stagedPetId = undefined
303
+ if (field === 'petId') this.stagedPetId = undefined
304
+ else if (field === 'visible') this.stagedVisible = undefined
305
+ else return
287
306
  this.petSaveFailed = false
288
307
  this.store.set(this.projection())
289
308
  },
290
- save: () => { if (this.fallback()) void this.savePetSelection(); else actions.save() },
309
+ save: () => { if (this.fallback()) void this.saveFallback(); else actions.save() },
291
310
  discard: () => {
292
311
  if (!this.fallback()) return actions.discard()
293
312
  this.stagedPetId = undefined
313
+ this.stagedVisible = undefined
294
314
  this.petSaveFailed = false
295
315
  this.store.set(this.projection())
296
316
  },
@@ -378,17 +398,7 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
378
398
  onEdit={(text) => { props.edit('petId', text) }}
379
399
  onReset={() => { props.resetField('petId') }}
380
400
  />
381
- {state.petDiagnostics.length === 0 ? null : (
382
- <li className={sectionCss.diagnostics} data-dsh-part="diagnostics">
383
- <span className={sectionCss.diagnosticsTitle}>{t('settings.diagnosticsTitle')}</span>
384
- <ul>
385
- {state.petDiagnostics.map((diagnostic, index) => (
386
- <li key={index} data-level={diagnostic.level}>{diagnostic.message}</li>
387
- ))}
388
- </ul>
389
- </li>
390
- )}
391
- {state.petSelectionFallback ? null : <BooleanField
401
+ <BooleanField
392
402
  id="settings-pet-visible"
393
403
  label={t('settings.visible')}
394
404
  hint={t('settings.visibleHint')}
@@ -399,7 +409,7 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
399
409
  {...state.visible}
400
410
  onEdit={(text) => { props.edit('visible', text) }}
401
411
  onReset={() => { props.resetField('visible') }}
402
- />}
412
+ />
403
413
  {state.petSelectionFallback ? null : <ValueField
404
414
  id="settings-pet-size"
405
415
  label={t('settings.size')}
@@ -57,7 +57,6 @@ export const zh = {
57
57
  'pet.collapseSessions': '收起会话气泡',
58
58
  // 一级设置页(settings.section 席位)。
59
59
  'settings.title': '宠物',
60
- 'settings.diagnosticsTitle': '宠物目录诊断',
61
60
  'settings.description': '选择宠物并调整它的显示布局。',
62
61
  'settings.pet': '宠物',
63
62
  'settings.petHint': '选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。',
@@ -143,7 +142,6 @@ export const en = {
143
142
  'pet.collapseSessions': 'Collapse session bubbles',
144
143
  // First-level settings section (the `settings.section` seat).
145
144
  'settings.title': 'Pet',
146
- 'settings.diagnosticsTitle': 'Pet directory diagnostics',
147
145
  'settings.description': 'Pick a pet and tune its display layout.',
148
146
  'settings.pet': 'Pet',
149
147
  'settings.petHint': 'Choose which pet shows. Names are stored per pet; rename from the pet hover panel.',
@@ -2,6 +2,14 @@ import { readFileSync } from 'node:fs'
2
2
  import { describe, expect, it } from 'vitest'
3
3
 
4
4
  const css = readFileSync(new URL('./pet.module.css', import.meta.url), 'utf8')
5
+ const settingsCss = readFileSync(new URL('./settings-section.module.css', import.meta.url), 'utf8')
6
+
7
+ describe('pet settings section css', () => {
8
+ it('carries no leftover registry diagnostics block after its removal', () => {
9
+ expect(settingsCss).not.toContain('.diagnostics')
10
+ expect(settingsCss).toContain('.sectionList')
11
+ })
12
+ })
5
13
 
6
14
  describe('pet bubble typography (#1549)', () => {
7
15
  it('scales the bubble text and padding from the sprite-supplied variable', () => {
@@ -3,26 +3,3 @@
3
3
  margin: 0;
4
4
  padding: 0;
5
5
  }
6
-
7
- /* Registry diagnostics block (pet-center M2 P7, issue #623). */
8
- .diagnostics {
9
- margin: 8px 0;
10
- padding: 8px 12px;
11
- border: 1px dashed var(--dsw-alias-border-l2);
12
- border-radius: 8px;
13
- font-size: 12px;
14
- color: var(--dsw-alias-label-tertiary);
15
- }
16
- .diagnosticsTitle {
17
- display: block;
18
- font-weight: 600;
19
- margin-bottom: 4px;
20
- color: var(--dsw-alias-label-primary);
21
- }
22
- .diagnostics ul {
23
- margin: 0;
24
- padding-left: 16px;
25
- }
26
- .diagnostics li[data-level="error"] {
27
- color: var(--dsw-alias-label-error, #c04848);
28
- }