@linxin666/dsh-pet 0.4.2 → 0.4.4

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.
@@ -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
- }
package/src/index.test.ts CHANGED
@@ -117,3 +117,42 @@ describe('petSettingsSection', () => {
117
117
  expect(section.petId).toBe('doro')
118
118
  })
119
119
  })
120
+
121
+ describe('petSettingsSection display persistence', () => {
122
+ /** The layout a user dragged the pet to, as its own pet.json holds it. */
123
+ const dragged = { visible: true, size: 160, right: 1428, bottom: 167, bubbleScale: 1 }
124
+
125
+ it('user keeps the dragged position when the profile never committed one', () => {
126
+ // Given a pet.json holding the layout the pet was dragged to
127
+ // And a row config that never set the display fields, so they read back as
128
+ // the schema defaults (the bottom-right corner)
129
+ // When the plugin starts and resolves its active settings
130
+ const section = petSettingsSection(Config({}), 'ouo-neko', dragged)
131
+ // Then the dragged layout stands instead of snapping back to those defaults
132
+ expect(section).toMatchObject({ right: 1428, bottom: 167 })
133
+ })
134
+
135
+ it('user commits a display field and the profile value outranks the persisted one', () => {
136
+ // Given a dragged layout and a profile layer that committed a right inset
137
+ const section = petSettingsSection(Config({}), 'ouo-neko', dragged, { right: 48 })
138
+ // Then the explicit choice wins and the untouched field keeps the dragged one
139
+ expect(section).toMatchObject({ right: 48, bottom: 167 })
140
+ })
141
+
142
+ it('user commits the schema default and the choice is not mistaken for unset', () => {
143
+ // Given a dragged layout and a profile layer that committed the default inset
144
+ const section = petSettingsSection(Config({}), 'ouo-neko', dragged, { right: 24 })
145
+ // Then the explicit (default-valued) choice stands over the persisted one
146
+ expect(section.right).toBe(24)
147
+ })
148
+
149
+ it('user commits a layout at runtime and the result outranks the persisted one', () => {
150
+ // Given a live reference the Host committed into the running config
151
+ const resolved = Config({})
152
+ commitLive(resolved.right, 500)
153
+ // When the plugin resolves the section against an older persisted layout
154
+ const section = petSettingsSection(resolved, 'ouo-neko', dragged)
155
+ // Then the live commit wins and the persisted value fills the other fields
156
+ expect(section).toMatchObject({ right: 500, bottom: 167 })
157
+ })
158
+ })
package/src/index.ts CHANGED
@@ -16,10 +16,10 @@ import { Context, type Volatile } from '@deepseek-ai/cordis'
16
16
  import type {} from '@deepseek-ai/dsh-settings'
17
17
  import type {} from '@deepseek-ai/dsh-host-webserver'
18
18
  import z from '@deepseek-ai/schemastery'
19
- import { PetService, type PetConfig, type PetSettingsSection } from './service.ts'
19
+ import { PET_SETTINGS_NAMESPACE, PetService, type PetConfig, type PetSettingsSection } from './service.ts'
20
20
  import { makePetRoutes } from './routes.ts'
21
21
  import { loadPetRegistry, petPackageRoot } from './registry.ts'
22
- import { BUBBLE_SCALE_MAX, BUBBLE_SCALE_MIN, DEFAULT_PET_ID, DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
22
+ import { BUBBLE_SCALE_MAX, BUBBLE_SCALE_MIN, DEFAULT_PET_ID, DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN, type PetDisplayConfig } from './persist.ts'
23
23
  import { mountOnce } from './mount-once.ts'
24
24
 
25
25
  export { PetService, MAX_SESSION_BUBBLES } from './service.ts'
@@ -203,29 +203,115 @@ function readLive<T>(field: LiveField<T> | undefined, fallback: T): T {
203
203
  return typeof ref.get === 'function' ? ref.get() ?? fallback : field as T
204
204
  }
205
205
 
206
+ /**
207
+ * Resolve one display field of the section. The precedence is deliberate:
208
+ *
209
+ * 1. an explicit profile value (`committed`) — the user's own choice, and the
210
+ * only signal that carries a value equal to the schema default on purpose;
211
+ * 2. a live value the row committed at runtime, i.e. one that differs from the
212
+ * schema default, which is all an untouched field ever resolves to;
213
+ * 3. what the pet's own `pet.json` already holds (`persisted`), so a field the
214
+ * profile never touched keeps the position the user dragged instead of
215
+ * snapping back to the schema default on the next mount.
216
+ *
217
+ * Step 3 is what makes a drag survive a restart. The display fields carry
218
+ * schema defaults (`right: 24`, `bottom: 20` — the bottom-right corner), and a
219
+ * config that never set them reads back exactly like one that set them to the
220
+ * defaults, so without `persisted` the startup section resets the layout every
221
+ * time the profile commits none — which is what an aggregate install does.
222
+ * @param key - the display field to resolve.
223
+ * @param live - the row config's own field, absent when the entry declares none.
224
+ * @param persisted - the display the pet already shows (its own `pet.json`).
225
+ * @param committed - the profile layer's explicit display values, when known.
226
+ * @returns the value the pet runs with.
227
+ */
228
+ function displayField<K extends keyof PetDisplayConfig>(
229
+ key: K,
230
+ live: LiveField<PetDisplayConfig[K]> | undefined,
231
+ persisted: Partial<PetDisplayConfig>,
232
+ committed: Partial<PetDisplayConfig>,
233
+ ): PetDisplayConfig[K] {
234
+ const explicit = committed[key]
235
+ if (explicit !== undefined) return explicit
236
+ const value = readLive<PetDisplayConfig[K]>(live, PET_FORM_DEFAULTS[key])
237
+ if (value !== PET_FORM_DEFAULTS[key]) return value
238
+ const kept = persisted[key]
239
+ return kept === undefined ? value : kept
240
+ }
241
+
206
242
  /**
207
243
  * The settings section the pet runs with: the effective values of the row's
208
244
  * own config. `fallbackPetId` covers a mount whose config names no pet at all
209
245
  * (a direct mount outside a Loader) — under a Loader the schema default is
210
246
  * always present, so the persisted selection stands whenever the config
211
- * carries it.
247
+ * carries it. `persisted` and `committed` give the display fields the same
248
+ * treatment; see {@link displayField}.
212
249
  * @param config - the effective config of the pet row.
213
250
  * @param fallbackPetId - pet id to use when the config names none.
251
+ * @param persisted - the display the pet already shows (its own `pet.json`).
252
+ * @param committed - the profile layer's explicit display values, when known.
214
253
  * @returns the resolved settings section.
215
254
  */
216
- export function petSettingsSection(config: PetFormConfig, fallbackPetId: string): PetSettingsSection {
255
+ export function petSettingsSection(
256
+ config: PetFormConfig,
257
+ fallbackPetId: string,
258
+ persisted: Partial<PetDisplayConfig> = {},
259
+ committed: Partial<PetDisplayConfig> = {},
260
+ ): PetSettingsSection {
217
261
  return {
218
- visible: readLive(config.visible, PET_FORM_DEFAULTS.visible),
219
- size: readLive(config.size, PET_FORM_DEFAULTS.size),
220
- right: readLive(config.right, PET_FORM_DEFAULTS.right),
221
- bottom: readLive(config.bottom, PET_FORM_DEFAULTS.bottom),
222
- bubbleScale: readLive(config.bubbleScale, PET_FORM_DEFAULTS.bubbleScale),
262
+ visible: displayField('visible', config.visible, persisted, committed),
263
+ size: displayField('size', config.size, persisted, committed),
264
+ right: displayField('right', config.right, persisted, committed),
265
+ bottom: displayField('bottom', config.bottom, persisted, committed),
266
+ bubbleScale: displayField('bubbleScale', config.bubbleScale, persisted, committed),
223
267
  petId: readLive(config.petId, fallbackPetId),
224
268
  enabled: readLive(config.enabled, PET_FORM_DEFAULTS.enabled),
225
269
  decorationEnabled: readLive(config.decorationEnabled, PET_FORM_DEFAULTS.decorationEnabled),
226
270
  }
227
271
  }
228
272
 
273
+ /** The settings row ids a pet install is served under, most specific first. */
274
+ const PET_SETTINGS_ROW_IDS: readonly string[] = ['web-ui-pet', 'ui-pet', PET_SETTINGS_NAMESPACE]
275
+
276
+ /**
277
+ * The fields this host half reads off one settings descriptor. The settings
278
+ * service serves one descriptor per plugin row (see `describe`), and a plugin's
279
+ * own config IS its settings page, so the two that matter here are the row's
280
+ * namespace and the profile layer's explicit values.
281
+ */
282
+ interface ServedSettingsRow {
283
+ /** The profile entry's id, which is also this plugin's settings namespace. */
284
+ ns: string
285
+ /** The profile layer's explicit values; absent fields were never committed. */
286
+ user?: Partial<PetDisplayConfig>
287
+ }
288
+
289
+ /**
290
+ * The settings row serving this plugin's own config, or undefined when the
291
+ * settings service is not mounted (a direct mount) or serves no row for the pet.
292
+ *
293
+ * Every settings write has to address this row's id, and that id is not the
294
+ * package's plugin name: an aggregate bundle renames child rows (`pet` becomes
295
+ * `web-ui-pet`). The browser half resolves the same id through its own
296
+ * `servedEntryId`, so both halves write to one namespace.
297
+ * @param ctx - the plugin's context.
298
+ * @returns the serving row, when there is one.
299
+ */
300
+ function servedRow(ctx: Context): ServedSettingsRow | undefined {
301
+ try {
302
+ const settings = ctx.get('settings', false) as { describe(): readonly ServedSettingsRow[] } | undefined
303
+ if (settings === undefined) return undefined
304
+ const rows = settings.describe()
305
+ for (const id of PET_SETTINGS_ROW_IDS) {
306
+ const row = rows.find(candidate => candidate.ns === id)
307
+ if (row !== undefined) return row
308
+ }
309
+ return undefined
310
+ } catch {
311
+ return undefined
312
+ }
313
+ }
314
+
229
315
  /** Register the pet service and its API + asset routes on the context. */
230
316
  export const apply = mountOnce('@linxin666/dsh-pet', applyImpl)
231
317
 
@@ -244,13 +330,21 @@ function applyImpl(ctx: Context, config: PetPluginConfig = {}): void {
244
330
  decorationEnabled: readLive(config.decorationEnabled, PET_FORM_DEFAULTS.decorationEnabled),
245
331
  registry,
246
332
  })
333
+ // The mirror back into the settings document (see syncSettingsFromPet) has to
334
+ // address the row that serves this plugin's config, which an aggregate bundle
335
+ // renames; the id is resolved lazily, once the composition serves it.
336
+ service.settingsNamespaceProvider = () => servedRow(ctx)?.ns
247
337
 
248
338
  // The effective settings ARE this row's own config: the Host serves one form
249
339
  // per profile entry from `Config` above, and every edit is committed into the
250
340
  // running config, so the plugin re-reads the section here instead of holding
251
341
  // a separate settings document (0.1.6 registered one through
252
- // settings.installSection/register and re-resolved it on every change).
253
- const current = (): PetSettingsSection => petSettingsSection(config, service.selectedPetId())
342
+ // settings.installSection/register and re-resolved it on every change). The
343
+ // display fields additionally read the persisted layout and the profile layer,
344
+ // so a mount with a config that never set them cannot reset a dragged
345
+ // position to the schema defaults (see displayField).
346
+ const current = (): PetSettingsSection =>
347
+ petSettingsSection(config, service.selectedPetId(), service.display(), servedRow(ctx)?.user)
254
348
  // The browser half talks to the pet through same-origin JSON endpoints and
255
349
  // loads each pet's atlas from the registry's own media route (RPC domains
256
350
  // are platform-registered, so the pet serves its own API — the same