@linxin666/dsh-pet 0.3.21 → 0.3.23

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 (40) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +1 -0
  3. package/README.zh.md +1 -0
  4. package/lib/client.js +73 -10
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +10 -3
  7. package/lib/types/client/PetDockEntry.js +1 -1
  8. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  9. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/PetSettingsCard.js +3 -1
  11. package/lib/types/client/PetSprite.d.ts +1 -1
  12. package/lib/types/client/PetSprite.d.ts.map +1 -1
  13. package/lib/types/client/PetSprite.js +45 -8
  14. package/lib/types/client/locales.d.ts +4 -0
  15. package/lib/types/client/locales.d.ts.map +1 -1
  16. package/lib/types/client/locales.js +4 -0
  17. package/lib/types/index.d.ts +2 -0
  18. package/lib/types/index.d.ts.map +1 -1
  19. package/lib/types/index.js +2 -1
  20. package/lib/types/persist.d.ts +32 -0
  21. package/lib/types/persist.d.ts.map +1 -1
  22. package/lib/types/persist.js +37 -0
  23. package/lib/types/service.d.ts +3 -1
  24. package/lib/types/service.d.ts.map +1 -1
  25. package/lib/types/service.js +4 -2
  26. package/package.json +1 -1
  27. package/src/client/PetDockEntry.test.tsx +1 -1
  28. package/src/client/PetDockEntry.tsx +1 -1
  29. package/src/client/PetSettingsCard.tsx +16 -0
  30. package/src/client/PetSprite.test.tsx +63 -1
  31. package/src/client/PetSprite.tsx +45 -8
  32. package/src/client/gameplay-hud.test.tsx +1 -1
  33. package/src/client/locales.ts +4 -0
  34. package/src/client/pet-css.test.ts +14 -0
  35. package/src/client/pet-store.test.ts +1 -1
  36. package/src/client/pet.module.css +7 -4
  37. package/src/index.ts +2 -1
  38. package/src/persist.test.ts +53 -1
  39. package/src/persist.ts +46 -0
  40. package/src/service.ts +7 -1
@@ -61,7 +61,7 @@ const snapshot: PetStateView = {
61
61
  petCooldown: false,
62
62
  feedCooldown: false,
63
63
  },
64
- display: { visible: true, size: 160, right: 24, bottom: 20 },
64
+ display: { visible: true, size: 160, right: 24, bottom: 20, bubbleScale: 1 },
65
65
  pet: { id: 'whale-girl', displayName: '鲸鱼娘', description: '测试用鲸鱼娘' },
66
66
  name: '泡泡',
67
67
  treats: { stocked: 3, max: 5 },
@@ -858,6 +858,68 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
858
858
  expect(el.style.backgroundPosition).toBe('0px 0px')
859
859
  })
860
860
 
861
+ it('advances uneven per-frame durations on each frame own clock', () => {
862
+ vi.spyOn(window, 'matchMedia').mockReturnValue({
863
+ matches: false,
864
+ media: '(prefers-reduced-motion: reduce)',
865
+ onchange: null,
866
+ addEventListener: () => {},
867
+ removeEventListener: () => {},
868
+ addListener: () => {},
869
+ removeListener: () => {},
870
+ dispatchEvent: () => false,
871
+ })
872
+ let now = 0
873
+ vi.spyOn(performance, 'now').mockImplementation(() => now)
874
+ const timers: { at: number; callback: () => void }[] = []
875
+ let timerId = 0
876
+ vi.spyOn(window, 'setTimeout').mockImplementation(((callback: () => void, delay = 0) => {
877
+ timers.push({ at: now + delay, callback })
878
+ return ++timerId
879
+ }) as typeof window.setTimeout)
880
+ vi.spyOn(window, 'clearTimeout').mockImplementation(() => {})
881
+ const frames: FrameRequestCallback[] = []
882
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
883
+ frames.push(callback)
884
+ return frames.length
885
+ })
886
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
887
+ const uneven: DecorationView = { ...decoration, durations: [100, 300, 50, 50] }
888
+ renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration: uneven } })
889
+ const el = ornament()!
890
+ const step = (ms: number): void => {
891
+ for (const callback of frames.splice(0)) callback(now)
892
+ now += ms
893
+ for (;;) {
894
+ const due = timers.filter(t => t.at <= now)
895
+ if (due.length === 0) break
896
+ for (const t of due) {
897
+ const idx = timers.indexOf(t)
898
+ if (idx >= 0) timers.splice(idx, 1)
899
+ t.callback()
900
+ }
901
+ }
902
+ }
903
+ expect(el.style.backgroundPosition).toBe('0px 0px')
904
+ // One 500 ms segment (100 + 300 + 50 + 50) plus 1 ms: the catch-up loop
905
+ // must subtract each frame's own duration, so it lands one full cycle
906
+ // later on frame 0 with 99 ms of its 100 ms left — not on frame 1.
907
+ act(() => { step(501) })
908
+ expect(el.style.backgroundPosition).toBe('0px 0px')
909
+ act(() => { step(99) })
910
+ expect(el.style.backgroundPosition).toBe('-24px 0px')
911
+ // Frame 1 lasts 300 ms and the next wake uses that duration.
912
+ act(() => { step(299) })
913
+ expect(el.style.backgroundPosition).toBe('-24px 0px')
914
+ act(() => { step(1) })
915
+ expect(el.style.backgroundPosition).toBe('-48px 0px')
916
+ // Frames 2 and 3 are 50 ms each.
917
+ act(() => { step(50) })
918
+ expect(el.style.backgroundPosition).toBe('-72px 0px')
919
+ act(() => { step(50) })
920
+ expect(el.style.backgroundPosition).toBe('0px 0px')
921
+ })
922
+
861
923
  it('holds the segment last frame when the loop is off and stops scheduling', () => {
862
924
  vi.spyOn(window, 'matchMedia').mockReturnValue({
863
925
  matches: false,
@@ -14,7 +14,7 @@ import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactElement, Re
14
14
  import { createPortal } from 'react-dom'
15
15
  import clsx from 'clsx'
16
16
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
17
- import type { PetDisplayConfig } from '../persist.ts'
17
+ import { bubbleScaleFor, type PetDisplayConfig } from '../persist.ts'
18
18
  import type { PetStateView } from '../service.ts'
19
19
  import { announcementFresh, type PetAnnouncement } from '../announce.ts'
20
20
  import type { PetDefinition } from '../registry.ts'
@@ -139,7 +139,7 @@ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhas
139
139
  const delta = now - last
140
140
  last = now
141
141
  elapsed += delta
142
- const duration = decoration.durations[index] ?? 120
142
+ let duration = decoration.durations[index] ?? 120
143
143
  // The segment's frame rate (duration ms, typically 90-160) is far
144
144
  // below the rAF cadence, so a 60fps loop would spend ~90% of its
145
145
  // ticks doing nothing. Schedule by the remaining time to the next
@@ -151,6 +151,10 @@ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhas
151
151
  elapsed -= duration
152
152
  if (index < segment.to) index += 1
153
153
  else if (decoration.loop) index = segment.from
154
+ // Durations are per frame: a catch-up that crosses frames must
155
+ // subtract and schedule with the frame it lands on, not the one
156
+ // the tick started from.
157
+ duration = decoration.durations[index] ?? 120
154
158
  } while (elapsed >= duration)
155
159
  // Only advance the background when the frame actually changes.
156
160
  el.style.backgroundPosition = position(index)
@@ -311,15 +315,39 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
311
315
  // custom visual (pet-center M3) replaces the atlas entirely.
312
316
  useEffect(() => {
313
317
  if (props.visual !== undefined) return
318
+ setImageReady(false)
314
319
  let cancelled = false
315
- const img = new Image()
316
- img.onload = () => {
317
- if (!cancelled) setImageReady(true)
320
+ let retryTimer: ReturnType<typeof setTimeout> | undefined
321
+ let attempt = 0
322
+ const maxAttempts = 3
323
+ let activeImg: HTMLImageElement | null = null
324
+
325
+ const loadAtlas = () => {
326
+ const img = new Image()
327
+ activeImg = img
328
+ img.onload = () => {
329
+ if (!cancelled) setImageReady(true)
330
+ }
331
+ img.onerror = () => {
332
+ if (cancelled) return
333
+ if (attempt < maxAttempts) {
334
+ attempt += 1
335
+ const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000)
336
+ retryTimer = setTimeout(loadAtlas, delay)
337
+ }
338
+ }
339
+ img.src = definition.atlasUrl
318
340
  }
319
- img.src = definition.atlasUrl
341
+
342
+ loadAtlas()
343
+
320
344
  return () => {
321
345
  cancelled = true
322
- img.onload = null
346
+ if (retryTimer !== undefined) clearTimeout(retryTimer)
347
+ if (activeImg !== null) {
348
+ activeImg.onload = null
349
+ activeImg.onerror = null
350
+ }
323
351
  }
324
352
  }, [definition.atlasUrl, props.visual])
325
353
 
@@ -484,6 +512,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
484
512
  const pos = dragPos ?? { right: display.right, bottom: display.bottom }
485
513
  const spriteWidth = Math.round(cell.width * spriteScale)
486
514
  const spriteHeight = Math.round(cell.height * spriteScale)
515
+ // Bubble typography follows the sprite's own scale (#1549), bounded so a
516
+ // shrunk pet never carries unreadably small text.
517
+ const bubbleScale = bubbleScaleFor(display)
487
518
 
488
519
  // Concurrent sessions share one bubble slot: only the display session
489
520
  // speaks by default, and the rest hide behind a '+N' badge until the stack
@@ -549,7 +580,13 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
549
580
  <div
550
581
  ref={floatRef}
551
582
  className={styles.float}
552
- style={{ right: pos.right, bottom: pos.bottom, zIndex: 2147483000 }}
583
+ style={{
584
+ right: pos.right,
585
+ bottom: pos.bottom,
586
+ zIndex: 2147483000,
587
+ // Read by .bubble / .bubbleStatus in pet.module.css.
588
+ ...({ '--pet-bubble-scale': String(bubbleScale) } as CSSProperties),
589
+ }}
553
590
  onPointerEnter={() => {
554
591
  clearHideTimer()
555
592
  setHovered(true)
@@ -117,7 +117,7 @@ function snapshot(view: PetGameplayStateView, skin?: string): PetStateView {
117
117
  sessionActive: false,
118
118
  sessions: [],
119
119
  affinity: { points: 0, rank: '幼鲸', rankEmoji: '*', pets: 0, feeds: 0, turns: 0, petCooldown: false, feedCooldown: false },
120
- display: { visible: true, size: 160, right: 24, bottom: 20 },
120
+ display: { visible: true, size: 160, right: 24, bottom: 20, bubbleScale: 1 },
121
121
  pet: { id: 'miku', displayName: 'Miku', description: '' },
122
122
  name: 'Miku',
123
123
  treats: { stocked: 0, max: 5 },
@@ -69,6 +69,8 @@ export const zh = {
69
69
  'settings.visibleHint': '关闭后宠物隐藏,可从聊天输入区重新召唤。',
70
70
  'settings.size': '大小(px)',
71
71
  'settings.sizeHint': '精灵单元高度,范围 32–1024。',
72
+ 'settings.bubbleScale': '气泡字号倍率',
73
+ 'settings.bubbleScaleHint': '状态、碎碎念与用量气泡的字号会随宠物大小自动缩放;这一项是在此基础上再乘一个倍率(0.5–2,默认 1)。字号本身不小于 10px、不大于 24px。',
72
74
  'settings.right': '距右侧(px)',
73
75
  'settings.rightHint': '距视口右边缘的水平内缩距离。',
74
76
  'settings.bottom': '距底部(px)',
@@ -153,6 +155,8 @@ export const en = {
153
155
  'settings.visibleHint': 'When off, the pet hides; summon it again from the input row.',
154
156
  'settings.size': 'Size (px)',
155
157
  'settings.sizeHint': 'Sprite cell height, 32\u20131024.',
158
+ 'settings.bubbleScale': 'Bubble text multiplier',
159
+ 'settings.bubbleScaleHint': 'Status, whisper, and usage bubbles scale with the pet automatically; this multiplies that result (0.5\u20132, default 1). The rendered text stays between 10px and 24px.',
156
160
  'settings.right': 'Right inset (px)',
157
161
  'settings.rightHint': 'Horizontal inset from the viewport right edge.',
158
162
  'settings.bottom': 'Bottom inset (px)',
@@ -3,6 +3,20 @@ import { describe, expect, it } from 'vitest'
3
3
 
4
4
  const css = readFileSync(new URL('./pet.module.css', import.meta.url), 'utf8')
5
5
 
6
+ describe('pet bubble typography (#1549)', () => {
7
+ it('scales the bubble text and padding from the sprite-supplied variable', () => {
8
+ const bubble = css.match(/\.bubble\s*\{([^}]*)\}/)?.[1] ?? ''
9
+ expect(bubble).toContain('font-size: calc(12px * var(--pet-bubble-scale, 1))')
10
+ expect(bubble).toContain('padding: calc(4px * var(--pet-bubble-scale, 1)) calc(10px * var(--pet-bubble-scale, 1))')
11
+ expect(bubble).not.toContain('font-size: 12px')
12
+ })
13
+
14
+ it('keeps the status bubble width in proportion with its text', () => {
15
+ const status = css.match(/\.bubbleStatus\s*\{([^}]*)\}/)?.[1] ?? ''
16
+ expect(status).toContain('max-width: min(calc(280px * var(--pet-bubble-scale, 1)), calc(100vw - 24px))')
17
+ })
18
+ })
19
+
6
20
  describe('pet hover panel css', () => {
7
21
  it('anchors the panel below the pet', () => {
8
22
  const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
@@ -21,7 +21,7 @@ const snapshot = (over: Partial<PetStateView> = {}): PetStateView => ({
21
21
  petCooldown: false,
22
22
  feedCooldown: false,
23
23
  },
24
- display: { visible: true, size: 160, right: 24, bottom: 120 },
24
+ display: { visible: true, size: 160, right: 24, bottom: 120, bubbleScale: 1 },
25
25
  pet: { id: 'whale-girl', displayName: 'Whale', description: '' },
26
26
  name: 'Whale',
27
27
  treats: { stocked: 2, max: 10 },
@@ -26,9 +26,12 @@
26
26
  bottom: 100%;
27
27
  margin-bottom: 6px;
28
28
  white-space: nowrap;
29
- padding: 4px 10px;
29
+ /* Bubble typography follows the pet's own scale (#1549): the sprite sets
30
+ --pet-bubble-scale from its display config, and 1 keeps the 12px baseline
31
+ this stylesheet was drawn for. */
32
+ padding: calc(4px * var(--pet-bubble-scale, 1)) calc(10px * var(--pet-bubble-scale, 1));
30
33
  border-radius: 999px;
31
- font-size: 12px;
34
+ font-size: calc(12px * var(--pet-bubble-scale, 1));
32
35
  line-height: 1.4;
33
36
  color: #f4f7ff;
34
37
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
@@ -50,10 +53,10 @@
50
53
  碎碎念不再单独渲染第二只气泡,也不换色调,而是以 .bubbleWhisper
51
54
  接管这同一只气泡的文案,气泡栈内所有气泡共用同一片玻璃。 */
52
55
  .bubbleStatus {
53
- max-width: min(280px, calc(100vw - 24px));
56
+ max-width: min(calc(280px * var(--pet-bubble-scale, 1)), calc(100vw - 24px));
54
57
  overflow: hidden;
55
58
  text-overflow: ellipsis;
56
- padding: 5px 12px;
59
+ padding: calc(5px * var(--pet-bubble-scale, 1)) calc(12px * var(--pet-bubble-scale, 1));
57
60
  background: linear-gradient(160deg, rgba(19, 28, 54, 0.9), rgba(7, 11, 26, 0.94));
58
61
  border: 1px solid rgba(126, 152, 255, 0.45);
59
62
  box-shadow:
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ import z from 'schemastery'
18
18
  import { PetService, PET_SETTINGS_NAMESPACE, type PetConfig, type PetSettingsSection } from './service.ts'
19
19
  import { makePetRoutes } from './routes.ts'
20
20
  import { loadPetRegistry, petPackageRoot } from './registry.ts'
21
- import { DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
21
+ import { BUBBLE_SCALE_MAX, BUBBLE_SCALE_MIN, DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
22
22
  import { mountOnce } from './mount-once.ts'
23
23
 
24
24
  export { PetService, MAX_SESSION_BUBBLES } from './service.ts'
@@ -132,6 +132,7 @@ export function makePetSettingsSchema(fallbackPetId: string) {
132
132
  size: z.number().step(1).min(DISPLAY_SIZE_MIN).max(DISPLAY_SIZE_MAX).default(160),
133
133
  right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
134
134
  bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
135
+ bubbleScale: z.number().step(0.05).min(BUBBLE_SCALE_MIN).max(BUBBLE_SCALE_MAX).default(1),
135
136
  petId: z.string().default(fallbackPetId),
136
137
  enabled: z.boolean().default(true),
137
138
  decorationEnabled: z.boolean().default(true),
@@ -5,6 +5,11 @@ import { join } from 'node:path'
5
5
  import { AFFINITY_MAX, emptyAffinity } from './affinity.ts'
6
6
  import { defaultTreatConfig, emptyTreatLedger } from './treats.ts'
7
7
  import {
8
+ BUBBLE_FONT_MAX_PX,
9
+ BUBBLE_FONT_MIN_PX,
10
+ BUBBLE_SCALE_MAX,
11
+ BUBBLE_SCALE_MIN,
12
+ bubbleScaleFor,
8
13
  DEFAULT_PET_ID,
9
14
  DISPLAY_INSET_MAX,
10
15
  DISPLAY_SIZE_MAX,
@@ -52,7 +57,7 @@ describe('loadPetPersist', () => {
52
57
  skins: { otter: 'lanhainishang' },
53
58
  affinity: { ...emptyAffinity(), points: 42, pets: 3, feeds: 1, turns: 10 },
54
59
  treats: { ...emptyTreatLedger(), treats: 7, lastTreatGrantAt: 1234, turnsAtLastTreatGrant: 9 },
55
- display: { visible: false, size: 200, right: 10, bottom: 40 },
60
+ display: { visible: false, size: 200, right: 10, bottom: 40, bubbleScale: 1.25 },
56
61
  gameplay: {
57
62
  otter: { stats: { hunger: 55.5 }, currencies: { coins: 12 }, mode: 'work' as const, settledAt: 777 },
58
63
  },
@@ -164,6 +169,53 @@ describe('loadPetPersist', () => {
164
169
  }
165
170
  })
166
171
 
172
+ it('defaults the bubble scale to the untouched baseline (#1549)', () => {
173
+ const dir = tempDir()
174
+ try {
175
+ expect(loadPetPersist(dir).display.bubbleScale).toBe(1)
176
+ } finally {
177
+ rmSync(dir, { recursive: true, force: true })
178
+ }
179
+ })
180
+
181
+ it('clamps a persisted bubble scale into the supported range (#1549)', () => {
182
+ const dir = tempDir()
183
+ try {
184
+ writeFileSync(join(dir, 'pet.json'), JSON.stringify({ display: { visible: true, size: 160, right: 0, bottom: 0, bubbleScale: 99 } }), 'utf8')
185
+ expect(loadPetPersist(dir).display.bubbleScale).toBe(BUBBLE_SCALE_MAX)
186
+ writeFileSync(join(dir, 'pet.json'), JSON.stringify({ display: { visible: true, size: 160, right: 0, bottom: 0, bubbleScale: 'big' } }), 'utf8')
187
+ expect(loadPetPersist(dir).display.bubbleScale).toBe(defaultDisplayConfig.bubbleScale)
188
+ writeFileSync(join(dir, 'pet.json'), JSON.stringify({ display: { visible: true, size: 160, right: 0, bottom: 0, bubbleScale: -3 } }), 'utf8')
189
+ expect(loadPetPersist(dir).display.bubbleScale).toBe(BUBBLE_SCALE_MIN)
190
+ } finally {
191
+ rmSync(dir, { recursive: true, force: true })
192
+ }
193
+ })
194
+
195
+ it('follows the pet size and stays inside the readable band (#1549)', () => {
196
+ // The baseline the stylesheet was drawn for: 12px at the default 160px pet.
197
+ expect(bubbleScaleFor({ size: 160, bubbleScale: 1 })).toBe(1)
198
+ // A shrunk pet shrinks the bubble, but never below the floor (the ratio
199
+ // is rounded to two decimals so the same config always paints the same).
200
+ expect(bubbleScaleFor({ size: 100, bubbleScale: 1 })).toBe(0.83)
201
+ expect(bubbleScaleFor({ size: 32, bubbleScale: 1 })).toBe(0.83)
202
+ // An enlarged pet caps the bubble instead of growing it without bound.
203
+ expect(bubbleScaleFor({ size: 1024, bubbleScale: 1 })).toBe(BUBBLE_FONT_MAX_PX / 12)
204
+ // The user's multiplier rides on top of the automatic following.
205
+ expect(bubbleScaleFor({ size: 160, bubbleScale: 2 })).toBe(2)
206
+ expect(bubbleScaleFor({ size: 160, bubbleScale: 0.5 })).toBe(0.83)
207
+ })
208
+
209
+ it('keeps the baseline when a host omits or corrupts the bubble scale (#1549)', () => {
210
+ // A host that predates the field serves no bubbleScale. NaN would reach
211
+ // --pet-bubble-scale and collapse every bubble's text to zero.
212
+ expect(bubbleScaleFor({ size: 160 })).toBe(1)
213
+ expect(bubbleScaleFor({ size: 160, bubbleScale: Number.NaN })).toBe(1)
214
+ expect(bubbleScaleFor({ size: Number.NaN, bubbleScale: 1 })).toBe(1)
215
+ // A corrupt size must not leak through either; the multiplier still applies.
216
+ expect(bubbleScaleFor({ size: Number.POSITIVE_INFINITY, bubbleScale: 2 })).toBe(2)
217
+ })
218
+
167
219
  it('clamps oversized display size to the max', () => {
168
220
  const dir = tempDir()
169
221
  try {
package/src/persist.ts CHANGED
@@ -25,6 +25,13 @@ export interface PetDisplayConfig {
25
25
  right: number
26
26
  /** Vertical inset from the viewport bottom edge, px. */
27
27
  bottom: number
28
+ /**
29
+ * Multiplier on the bubble typography's automatic size following (#1549).
30
+ * The rendered bubble scale is `size / 160 * bubbleScale`, bounded by
31
+ * {@link BUBBLE_FONT_MIN_PX}..{@link BUBBLE_FONT_MAX_PX}, so 1 keeps the
32
+ * 12px baseline the stylesheet was drawn for at the default 160px pet.
33
+ */
34
+ bubbleScale: number
28
35
  }
29
36
 
30
37
  export const defaultDisplayConfig: PetDisplayConfig = {
@@ -32,6 +39,7 @@ export const defaultDisplayConfig: PetDisplayConfig = {
32
39
  size: 160,
33
40
  right: 24,
34
41
  bottom: 120,
42
+ bubbleScale: 1,
35
43
  }
36
44
 
37
45
  /** Display value bounds (shared by load-time validation and setConfig). */
@@ -39,6 +47,42 @@ export const DISPLAY_SIZE_MIN = 32
39
47
  export const DISPLAY_SIZE_MAX = 1024
40
48
  export const DISPLAY_INSET_MAX = 10_000
41
49
 
50
+ /** Bubble typography bounds (issue #1549). */
51
+ export const BUBBLE_SCALE_MIN = 0.5
52
+ export const BUBBLE_SCALE_MAX = 2
53
+ /** Pixel size the bubble stylesheet was drawn for, at the default pet size. */
54
+ export const BUBBLE_BASE_FONT_PX = 12
55
+ /** Pet size that baseline matches; other sizes scale the bubble with them. */
56
+ export const BUBBLE_BASE_SIZE_PX = 160
57
+ /** Readability floor and layout ceiling of the scaled bubble text. */
58
+ export const BUBBLE_FONT_MIN_PX = 10
59
+ export const BUBBLE_FONT_MAX_PX = 24
60
+
61
+ /**
62
+ * Bubble typography scale for one display config (issue #1549): the bubble
63
+ * follows the pet's own size so a shrunk pet does not carry a full-size
64
+ * bubble, and the user's multiplier rides on top. The result is a CSS ratio
65
+ * against {@link BUBBLE_BASE_FONT_PX}, bounded so the text never drops below
66
+ * the readability floor or outgrows the pet. `bubbleScale` is optional at
67
+ * runtime: a host that predates the field (a rolling update, or any snapshot
68
+ * that omits it) falls back to the baseline, because a NaN ratio written into
69
+ * `--pet-bubble-scale` collapses every bubble's text to zero.
70
+ * @param display - display config; `bubbleScale` may be absent on older hosts.
71
+ * @returns the ratio written to `--pet-bubble-scale` (always finite).
72
+ */
73
+ export function bubbleScaleFor(display: Pick<PetDisplayConfig, 'size'> & { bubbleScale?: number }): number {
74
+ // Guard the arithmetic, not just the clamp: Math.min/max propagate NaN, so a
75
+ // missing or non-finite field would otherwise reach the stylesheet.
76
+ const size = Number.isFinite(display.size) ? display.size : BUBBLE_BASE_SIZE_PX
77
+ const multiplier = typeof display.bubbleScale === 'number' && Number.isFinite(display.bubbleScale)
78
+ ? display.bubbleScale
79
+ : 1
80
+ const scaled = (size / BUBBLE_BASE_SIZE_PX) * multiplier
81
+ const min = BUBBLE_FONT_MIN_PX / BUBBLE_BASE_FONT_PX
82
+ const max = BUBBLE_FONT_MAX_PX / BUBBLE_BASE_FONT_PX
83
+ return Math.round(Math.min(max, Math.max(min, scaled)) * 100) / 100
84
+ }
85
+
42
86
  /** Everything persisted for the pet. */
43
87
  export interface PetPersist {
44
88
  /** Selected pet id (a registry entry; clamped at service startup). */
@@ -198,6 +242,8 @@ export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
198
242
  size: Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, finiteNum(rawDisplay.size, base.display.size)))),
199
243
  right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), DISPLAY_INSET_MAX)),
200
244
  bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), DISPLAY_INSET_MAX)),
245
+ // Fractional on purpose: the multiplier is a ratio, not a pixel count.
246
+ bubbleScale: Math.min(BUBBLE_SCALE_MAX, Math.max(BUBBLE_SCALE_MIN, finiteNum(rawDisplay.bubbleScale, base.display.bubbleScale))),
201
247
  }
202
248
  const petId = typeof parsed.petId === 'string' && parsed.petId.trim() !== ''
203
249
  ? parsed.petId.trim()
package/src/service.ts CHANGED
@@ -31,6 +31,8 @@ import {
31
31
  DEFAULT_PET_NAME,
32
32
  DISPLAY_INSET_MAX,
33
33
  DISPLAY_SIZE_MAX,
34
+ BUBBLE_SCALE_MAX,
35
+ BUBBLE_SCALE_MIN,
34
36
  DISPLAY_SIZE_MIN,
35
37
  PET_NAME_MAX_LENGTH,
36
38
  loadPetPersist,
@@ -111,6 +113,8 @@ export interface PetSettingsSection {
111
113
  right: number
112
114
  /** Vertical inset from the viewport bottom edge, px. */
113
115
  bottom: number
116
+ /** Bubble typography multiplier (#1549); see PetDisplayConfig. */
117
+ bubbleScale?: number
114
118
  /** Master switch for the plugin (browser half + host routes). */
115
119
  enabled?: boolean
116
120
  /**
@@ -756,12 +760,13 @@ export class PetService extends Service {
756
760
  return { ok: true, skin }
757
761
  }
758
762
 
759
- /** RPC: update display config (size / position). Values are clamped to whole pixels. */
763
+ /** RPC: update display config (size / position / bubble scale). Pixel values are clamped to whole pixels. */
760
764
  async setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }> {
761
765
  const next = { ...this.ledger.snapshot.display, ...patch }
762
766
  next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, next.size)))
763
767
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)))
764
768
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)))
769
+ next.bubbleScale = Math.min(BUBBLE_SCALE_MAX, Math.max(BUBBLE_SCALE_MIN, next.bubbleScale))
765
770
  this.ledger.setDisplay(next)
766
771
  this.flush()
767
772
  this.syncSettingsFromPet()
@@ -800,6 +805,7 @@ export class PetService extends Service {
800
805
  next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, section.size)))
801
806
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)))
802
807
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)))
808
+ next.bubbleScale = Math.min(BUBBLE_SCALE_MAX, Math.max(BUBBLE_SCALE_MIN, section.bubbleScale ?? next.bubbleScale))
803
809
  this.ledger.setDisplay(next)
804
810
  this.flush()
805
811
  }