@linxin666/dsh-pet 0.1.20 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +18 -11
  3. package/README.zh.md +18 -11
  4. package/assets/whale/pet.json +9 -0
  5. package/lib/client.js +394 -58
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +1007 -43
  8. package/lib/invariant.js +1 -1
  9. package/lib/{state-Diiq8vjk.js → state-DrMX22GL.js} +86 -28
  10. package/lib/types/affinity.d.ts +4 -0
  11. package/lib/types/affinity.d.ts.map +1 -1
  12. package/lib/types/affinity.js +32 -6
  13. package/lib/types/chatter.d.ts +111 -0
  14. package/lib/types/chatter.d.ts.map +1 -0
  15. package/lib/types/chatter.js +756 -0
  16. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  17. package/lib/types/client/PetDockEntry.js +1 -1
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +133 -16
  20. package/lib/types/client/PluginSettingsCard.d.ts +28 -0
  21. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  22. package/lib/types/client/PluginSettingsCard.js +147 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +1 -0
  25. package/lib/types/client/locales.d.ts +4 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +4 -0
  28. package/lib/types/client/sequences.d.ts +10 -0
  29. package/lib/types/client/sequences.d.ts.map +1 -0
  30. package/lib/types/client/sequences.js +20 -0
  31. package/lib/types/event-projection.d.ts +14 -1
  32. package/lib/types/event-projection.d.ts.map +1 -1
  33. package/lib/types/event-projection.js +38 -17
  34. package/lib/types/ledger.d.ts.map +1 -1
  35. package/lib/types/ledger.js +15 -7
  36. package/lib/types/loopback.d.ts +25 -0
  37. package/lib/types/loopback.d.ts.map +1 -0
  38. package/lib/types/loopback.js +68 -0
  39. package/lib/types/persist.d.ts.map +1 -1
  40. package/lib/types/persist.js +3 -1
  41. package/lib/types/registry.d.ts +7 -1
  42. package/lib/types/registry.d.ts.map +1 -1
  43. package/lib/types/registry.js +47 -3
  44. package/lib/types/remarks.d.ts +2 -0
  45. package/lib/types/remarks.d.ts.map +1 -1
  46. package/lib/types/remarks.js +35 -0
  47. package/lib/types/routes.d.ts.map +1 -1
  48. package/lib/types/routes.js +14 -0
  49. package/lib/types/service.d.ts +6 -0
  50. package/lib/types/service.d.ts.map +1 -1
  51. package/lib/types/service.js +15 -2
  52. package/lib/types/state.d.ts +7 -4
  53. package/lib/types/state.d.ts.map +1 -1
  54. package/lib/types/state.js +20 -20
  55. package/package.json +9 -9
  56. package/src/affinity.test.ts +4 -2
  57. package/src/affinity.ts +37 -6
  58. package/src/chatter.test.ts +154 -0
  59. package/src/chatter.ts +787 -0
  60. package/src/client/PetDockEntry.test.tsx +93 -0
  61. package/src/client/PetDockEntry.tsx +1 -0
  62. package/src/client/PetSprite.test.tsx +253 -3
  63. package/src/client/PetSprite.tsx +192 -37
  64. package/src/client/PluginSettingsCard.tsx +229 -18
  65. package/src/client/index.test.tsx +95 -0
  66. package/src/client/index.ts +1 -0
  67. package/src/client/locales.ts +4 -0
  68. package/src/client/pet-css.test.ts +54 -0
  69. package/src/client/pet.module.css +215 -57
  70. package/src/client/sequences.test.ts +33 -0
  71. package/src/client/sequences.ts +33 -0
  72. package/src/client/settings-card.module.css +90 -1
  73. package/src/event-projection.ts +49 -16
  74. package/src/ledger.test.ts +42 -1
  75. package/src/ledger.ts +15 -7
  76. package/src/loopback.ts +63 -0
  77. package/src/persist.test.ts +18 -1
  78. package/src/persist.ts +3 -1
  79. package/src/registry.test.ts +79 -4
  80. package/src/registry.ts +58 -6
  81. package/src/remarks.ts +36 -0
  82. package/src/routes.ts +11 -0
  83. package/src/service.ts +27 -2
  84. package/src/state.test.ts +13 -0
  85. package/src/state.ts +24 -18
@@ -0,0 +1,95 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The global pet entry container opts into the L2 semantic attributes
4
+ * (issue #506): the apply body mounts [data-dsh-pet-root] with
5
+ * data-dsh-plugin="pet" so skins can target the pet subtree.
6
+ */
7
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
8
+ // The npm SDK's client half is a closure-factory bundle for the GUI's
9
+ // __ModuleLoader__ (not importable under vitest); provide defineStore /
10
+ // createSnapshotStore (same fake-store pattern as the settings-card tests).
11
+ vi.mock('@deepseek-ai/dsh-client-runtime/client', () => ({
12
+ defineStore: (spec: {
13
+ init: () => unknown
14
+ actions: Record<string, (draft: never, ...args: never[]) => void>
15
+ }) => ({
16
+ create: () => {
17
+ let value = spec.init()
18
+ const listeners = new Set<() => void>()
19
+ const actions: Record<string, (...args: unknown[]) => void> = {}
20
+ for (const [name, fn] of Object.entries(spec.actions)) {
21
+ actions[name] = (...args: unknown[]) => {
22
+ fn(value as never, ...(args as never[]))
23
+ for (const listener of listeners) listener()
24
+ }
25
+ }
26
+ return {
27
+ getSnapshot: () => value,
28
+ subscribe: (listener: () => void) => {
29
+ listeners.add(listener)
30
+ return () => { listeners.delete(listener) }
31
+ },
32
+ actions,
33
+ }
34
+ },
35
+ }),
36
+ createSnapshotStore: (init: unknown) => {
37
+ let value = init
38
+ const listeners = new Set<() => void>()
39
+ return {
40
+ getSnapshot: () => value,
41
+ set: (next: unknown) => { value = next; for (const listener of listeners) listener() },
42
+ update: (mutator: (draft: never) => void) => { mutator(value as never); for (const listener of listeners) listener() },
43
+ subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } },
44
+ }
45
+ },
46
+ }))
47
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
48
+ import { apply } from './index.ts'
49
+
50
+ beforeAll(() => {
51
+ document.documentElement.lang = 'zh'
52
+ })
53
+
54
+ afterEach(() => {
55
+ document.body.replaceChildren()
56
+ })
57
+
58
+ /** A minimal client root context: ready settings scope, no-op slot system. */
59
+ function fakeContext(): ClientContext {
60
+ const scope = {
61
+ getSnapshot: () => ({
62
+ status: 'ready',
63
+ writable: true,
64
+ value: undefined,
65
+ base: undefined,
66
+ user: {},
67
+ revision: 1,
68
+ mode: 'host',
69
+ }),
70
+ subscribe: () => () => {},
71
+ }
72
+ return {
73
+ effect: (fn: () => unknown) => {
74
+ const dispose = fn()
75
+ return typeof dispose === 'function' ? dispose : () => {}
76
+ },
77
+ locale: { register: () => () => {} },
78
+ get: () => undefined,
79
+ settingsScope: { bind: () => scope },
80
+ slots: {
81
+ inject: (_name: string, callback: () => () => void) => callback(),
82
+ register: () => () => {},
83
+ },
84
+ sessions: undefined,
85
+ } as unknown as ClientContext
86
+ }
87
+
88
+ describe('pet client apply L2 semantic attributes (#506)', () => {
89
+ it('mounts the pet root container with data-dsh-plugin="pet"', () => {
90
+ apply(fakeContext())
91
+ const root = document.body.querySelector('[data-dsh-pet-root]')
92
+ expect(root).not.toBeNull()
93
+ expect(root!.getAttribute('data-dsh-plugin')).toBe('pet')
94
+ })
95
+ })
@@ -292,6 +292,7 @@ export function apply(ctx: ClientContext): void {
292
292
  // when visible, and the hidden-state summon button is fixed-positioned.
293
293
  const container = document.createElement('div')
294
294
  container.dataset.dshPetRoot = ''
295
+ container.dataset.dshPlugin = 'pet'
295
296
  document.body.appendChild(container)
296
297
  const petRoot = createRoot(container)
297
298
  petRoot.render(createElement(PetDockEntry, { ...injected(), t }))
@@ -20,6 +20,8 @@ export const zh = {
20
20
  'pet.state.loading': '宠物正在赶来…',
21
21
  'pet.state.error': '宠物迷路了(连接失败)',
22
22
  'pet.openSessionHint': '点击跳转到对应会话',
23
+ 'pet.moreSessions': '展开其余 {n} 个会话的气泡',
24
+ 'pet.collapseSessions': '收起会话气泡',
23
25
  // 一级设置页(settings.section 席位)。
24
26
  'settings.title': '宠物',
25
27
  'settings.description': '选择宠物并调整它的显示布局。',
@@ -66,6 +68,8 @@ export const en = {
66
68
  'pet.state.loading': 'The pet is on its way…',
67
69
  'pet.state.error': 'The pet is lost (connection failed)',
68
70
  'pet.openSessionHint': 'Click to jump to this session',
71
+ 'pet.moreSessions': 'Expand {n} more session bubbles',
72
+ 'pet.collapseSessions': 'Collapse session bubbles',
69
73
  // First-level settings section (the `settings.section` seat).
70
74
  'settings.title': 'Pet',
71
75
  'settings.description': 'Pick a pet and tune its display layout.',
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ const css = readFileSync(new URL('./pet.module.css', import.meta.url), 'utf8')
5
+
6
+ describe('pet hover panel css', () => {
7
+ it('anchors the panel below the pet', () => {
8
+ const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
9
+ expect(panel).toContain('top: 100%')
10
+ expect(panel).toContain('margin-top: 8px')
11
+ expect(panel).not.toContain('right: 100%')
12
+ })
13
+
14
+ it('extends the hover bridge upward across the panel gap', () => {
15
+ const bridge = css.match(/\.panel::after\s*\{([^}]*)\}/)?.[1] ?? ''
16
+ expect(bridge).toContain('bottom: 100%')
17
+ expect(bridge).toContain('height: 14px')
18
+ })
19
+
20
+ it('keeps the whisper on the shared bubble glass for a unified palette', () => {
21
+ const whisper = css.match(/\.bubbleWhisper\s*\{([^}]*)\}/)?.[1] ?? ''
22
+ // Unified palette: the whisper inherits background/border/shadow/color
23
+ // from .bubble/.bubbleStatus, so a whispering bubble and a status bubble
24
+ // stacked side by side share the same DeepSeek-blue glass; only the mood
25
+ // (quotes, letter-spacing, entrance) is overridden.
26
+ expect(whisper).not.toContain('background')
27
+ expect(whisper).not.toContain('border')
28
+ expect(whisper).not.toContain('box-shadow')
29
+ expect(whisper).not.toContain('color')
30
+ expect(whisper).toContain('animation: pet-whisper-in')
31
+ expect(whisper).not.toContain('font-style: italic')
32
+ expect(whisper).not.toContain('pointer-events')
33
+ expect(whisper).not.toContain('position:')
34
+ expect(css).toContain('@keyframes pet-whisper-in')
35
+ // The mood overrides must be declared after .bubbleStatus to win the cascade.
36
+ expect(css.indexOf('.bubbleWhisper {')).toBeGreaterThan(css.indexOf('.bubbleStatus {'))
37
+ })
38
+
39
+ it('gives the panel and status bubbles entrance animations', () => {
40
+ expect(css).toContain('@keyframes pet-panel-in')
41
+ expect(css).toContain('@keyframes pet-bubble-in')
42
+ const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
43
+ expect(panel).toContain('animation: pet-panel-in')
44
+ })
45
+
46
+ it('can place the panel above and bridge the lower gap', () => {
47
+ const panel = css.match(/\.panelAbove\s*\{([^}]*)\}/)?.[1] ?? ''
48
+ expect(panel).toContain('bottom: 100%')
49
+ expect(panel).toContain('margin-bottom: 8px')
50
+ const bridge = css.match(/\.panelAbove::after\s*\{([^}]*)\}/)?.[1] ?? ''
51
+ expect(bridge).toContain('top: 100%')
52
+ expect(bridge).toContain('bottom: auto')
53
+ })
54
+ })
@@ -23,42 +23,115 @@
23
23
  border-radius: 999px;
24
24
  font-size: 12px;
25
25
  line-height: 1.4;
26
- color: #fff;
26
+ color: #f4f7ff;
27
27
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
28
28
  animation: pet-bubble-pop 2.6s ease-out forwards;
29
29
  pointer-events: none;
30
30
  }
31
31
 
32
+ /* 互动反馈气泡:同属蓝色家族 —— 抚摸是 DeepSeek 品牌蓝,
33
+ 喂食是深海晴空蓝,与状态气泡的蓝黑玻璃保持和谐。 */
32
34
  .bubblePet {
33
- background: rgba(244, 114, 182, 0.92);
35
+ background: rgba(77, 107, 254, 0.95);
34
36
  }
35
37
 
36
38
  .bubbleFeed {
37
- background: rgba(56, 189, 248, 0.92);
39
+ background: rgba(3, 105, 161, 0.95);
38
40
  }
39
41
 
42
+ /* 状态气泡:虎鲸蓝黑渐变玻璃 + DeepSeek 蓝描边微光 + 进场浮现。
43
+ 碎碎念不再单独渲染第二只气泡,也不换色调,而是以 .bubbleWhisper
44
+ 接管这同一只气泡的文案,气泡栈内所有气泡共用同一片玻璃。 */
40
45
  .bubbleStatus {
41
46
  max-width: min(280px, calc(100vw - 24px));
42
47
  overflow: hidden;
43
48
  text-overflow: ellipsis;
44
- background: rgba(15, 23, 42, 0.9);
45
- border: 1px solid rgba(125, 211, 252, 0.5);
46
- animation: none;
49
+ padding: 5px 12px;
50
+ background: linear-gradient(160deg, rgba(19, 28, 54, 0.9), rgba(7, 11, 26, 0.94));
51
+ border: 1px solid rgba(126, 152, 255, 0.45);
52
+ box-shadow:
53
+ 0 4px 12px rgba(2, 6, 23, 0.35),
54
+ inset 0 1px 0 rgba(226, 232, 255, 0.1),
55
+ 0 0 12px rgba(77, 107, 254, 0.18);
56
+ backdrop-filter: blur(8px);
57
+ letter-spacing: 0.02em;
58
+ animation: pet-bubble-in 240ms ease-out;
59
+ }
60
+
61
+ /* 碎碎念(与 .bubbleStatus 叠加使用):同一只气泡、同一片蓝黑玻璃,
62
+ 仅以「」引号、字距与入场动画表现内心独白 —— 弃用旧的深紫粉色调,
63
+ 多会话气泡栈里状态气泡与碎碎念气泡颜色保持统一,不再出现色差。
64
+ 规则顺序在 .bubbleStatus 之后,动画与字距声明方可胜出。 */
65
+ .bubbleWhisper {
66
+ letter-spacing: 0.03em;
67
+ animation: pet-whisper-in 320ms cubic-bezier(0.22, 1, 0.36, 1);
68
+ }
69
+
70
+ .bubbleWhisper::before {
71
+ content: '「';
72
+ margin-right: 2px;
73
+ opacity: 0.7;
47
74
  }
48
75
 
49
- /* 多会话气泡栈:并行会话各自一个气泡,自精灵头顶向上堆叠,
50
- 最近更新的会话在最上。栈内气泡改流入列布局,不再绝对定位
51
- 互相覆盖。气泡可点击:点击跳转到对应会话。 */
76
+ .bubbleWhisper::after {
77
+ content: '」';
78
+ margin-left: 2px;
79
+ opacity: 0.7;
80
+ }
81
+
82
+ /* 多会话气泡栈:默认收起为「主会话一只气泡 + 右上角 +N 角标」,
83
+ 悬停或点按角标才展开整栈。反向列布局让 DOM 首位的主气泡钉在栈底
84
+ (紧贴精灵),展开时其余气泡从它上方向上生长,主气泡与角标原地
85
+ 不动,悬停目标不会抖动。气泡可点击:点击跳转到对应会话。 */
52
86
  .bubbleStack {
53
87
  position: absolute;
54
88
  bottom: 100%;
55
89
  display: flex;
56
- flex-direction: column;
90
+ flex-direction: column-reverse;
57
91
  align-items: center;
58
92
  gap: 4px;
59
93
  pointer-events: auto;
60
94
  }
61
95
 
96
+ /* 主气泡的定位锚:+N 角标相对它定位,展开整栈时随之固定不动。 */
97
+ .bubbleAnchor {
98
+ position: relative;
99
+ display: inline-flex;
100
+ }
101
+
102
+ /* +N 角标:收起态提示其余在忙会话数,点击/轻触展开或收起整栈。 */
103
+ .bubbleMore {
104
+ position: absolute;
105
+ top: -9px;
106
+ right: -10px;
107
+ z-index: 1;
108
+ min-width: 18px;
109
+ height: 18px;
110
+ padding: 0 5px;
111
+ border: 1px solid rgba(126, 152, 255, 0.55);
112
+ border-radius: 999px;
113
+ display: inline-flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ font-size: 10px;
117
+ font-weight: 600;
118
+ line-height: 1;
119
+ color: #eef2ff;
120
+ background: linear-gradient(160deg, rgba(74, 104, 245, 0.95), rgba(47, 68, 184, 0.95));
121
+ box-shadow: 0 2px 6px rgba(2, 6, 23, 0.4);
122
+ cursor: pointer;
123
+ transition: filter 120ms ease;
124
+ }
125
+
126
+ .bubbleMore:hover {
127
+ filter: brightness(1.15);
128
+ }
129
+
130
+ .bubbleMore:focus-visible {
131
+ outline: none;
132
+ box-shadow: 0 0 0 2px rgba(126, 152, 255, 0.9);
133
+ }
134
+
62
135
  .bubbleStack .bubble {
63
136
  position: relative;
64
137
  bottom: auto;
@@ -80,8 +153,44 @@
80
153
 
81
154
  .bubbleClickable:focus-visible {
82
155
  outline: none;
83
- /* 2px accent ring on the pet's own palette, theme-agnostic. */
84
- box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
156
+ /* 2px DeepSeek-blue ring on the pet's own palette, theme-agnostic. */
157
+ box-shadow: 0 0 0 2px rgba(126, 152, 255, 0.9);
158
+ }
159
+
160
+ @keyframes pet-bubble-in {
161
+ from {
162
+ opacity: 0;
163
+ transform: translateY(6px) scale(0.96);
164
+ }
165
+ to {
166
+ opacity: 1;
167
+ transform: translateY(0) scale(1);
168
+ }
169
+ }
170
+
171
+ @keyframes pet-whisper-in {
172
+ 0% {
173
+ opacity: 0;
174
+ transform: translateY(8px) scale(0.9);
175
+ }
176
+ 60% {
177
+ transform: translateY(-1px) scale(1.02);
178
+ }
179
+ 100% {
180
+ opacity: 1;
181
+ transform: translateY(0) scale(1);
182
+ }
183
+ }
184
+
185
+ @keyframes pet-panel-in {
186
+ from {
187
+ opacity: 0;
188
+ transform: translateY(4px) scale(0.97);
189
+ }
190
+ to {
191
+ opacity: 1;
192
+ transform: translateY(0) scale(1);
193
+ }
85
194
  }
86
195
 
87
196
  @keyframes pet-bubble-pop {
@@ -105,39 +214,56 @@
105
214
  }
106
215
  }
107
216
 
108
- /* 悬浮面板位于精灵左侧(bottom 对齐),不再占据精灵上方的空间:
217
+ /* 悬浮面板默认位于精灵脚下,不再占据精灵上方的空间:
109
218
  会话气泡栈独占精灵头顶区域,两者互不遮挡 —— 气泡因此可以始终
110
- 显示并保持可点击,不必在 hover 时让位给面板。 */
219
+ 显示并保持可点击,不必在 hover 时让位给面板。仅当视口底部空间
220
+ 不足时,面板才回退到精灵上方。 */
111
221
  .panel {
112
222
  position: absolute;
113
- bottom: 0;
114
- right: 100%;
115
- margin-right: 8px;
116
- background: rgba(15, 23, 42, 0.92);
117
- border: 1px solid rgba(148, 163, 184, 0.35);
118
- border-radius: 10px;
119
- padding: 8px 10px;
120
- color: #e2e8f0;
223
+ top: 100%;
224
+ margin-top: 8px;
225
+ background: linear-gradient(165deg, rgba(19, 28, 54, 0.95), rgba(7, 11, 26, 0.97));
226
+ border: 1px solid rgba(126, 152, 255, 0.3);
227
+ border-radius: 12px;
228
+ padding: 10px 12px;
229
+ color: #e6ebf8;
121
230
  font-size: 12px;
122
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
123
- backdrop-filter: blur(6px);
231
+ box-shadow:
232
+ 0 8px 24px rgba(2, 6, 23, 0.45),
233
+ 0 0 0 1px rgba(77, 107, 254, 0.1),
234
+ inset 0 1px 0 rgba(226, 232, 255, 0.08);
235
+ backdrop-filter: blur(10px);
124
236
  display: flex;
125
237
  flex-direction: column;
126
- gap: 6px;
127
- min-width: 132px;
238
+ gap: 7px;
239
+ min-width: 148px;
240
+ animation: pet-panel-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
241
+ transform-origin: 50% 0;
128
242
  }
129
243
 
130
- /* 面板右缘与图标左缘之间的 hover 桥接(issue #49 的水平版):
131
- 面板在图标左侧留有 8px 间隙,指针滑过间隙时会触发容器的
132
- pointerleave,面板会在指针抵达按钮前收起。这段透明伪元素把命中区
133
- 向右延伸、覆盖间隙,使指针在图标与按钮之间始终停留在 hover 区域。 */
244
+ /* 面板顶边与精灵底边之间的 hover 桥接(issue #49):
245
+ 面板在精灵下方留有 8px 间隙,透明伪元素向上覆盖间隙,
246
+ 使指针在精灵与按钮之间始终停留在 hover 区域。 */
134
247
  .panel::after {
135
248
  content: '';
136
249
  position: absolute;
137
- top: 0;
138
- bottom: 0;
139
- left: 100%;
140
- width: 14px;
250
+ bottom: 100%;
251
+ left: 0;
252
+ right: 0;
253
+ height: 14px;
254
+ }
255
+
256
+ .panelAbove {
257
+ top: auto;
258
+ bottom: 100%;
259
+ margin-top: 0;
260
+ margin-bottom: 8px;
261
+ transform-origin: 50% 100%;
262
+ }
263
+
264
+ .panelAbove::after {
265
+ top: 100%;
266
+ bottom: auto;
141
267
  }
142
268
 
143
269
  .rankRow {
@@ -149,6 +275,28 @@
149
275
 
150
276
  .nameCell {
151
277
  font-weight: 600;
278
+ letter-spacing: 0.02em;
279
+ background: linear-gradient(90deg, #a9c1ff, #6c8bff 60%, #4d6bfe);
280
+ -webkit-background-clip: text;
281
+ background-clip: text;
282
+ color: transparent;
283
+ }
284
+
285
+ /* 面板数值的专属配色:称号浅品牌蓝、小鱼干清蓝、亲密度暖金 ——
286
+ 与 DeepSeek 蓝同族,仅留一枚金色点缀,两行数据扫一眼就能分辨。 */
287
+ .statRank {
288
+ color: #9db4ff;
289
+ font-weight: 600;
290
+ }
291
+
292
+ .statTreats {
293
+ color: #7dd3fc;
294
+ font-weight: 600;
295
+ }
296
+
297
+ .statPoints {
298
+ color: #fcd34d;
299
+ font-weight: 600;
152
300
  }
153
301
 
154
302
  .renameRow {
@@ -160,19 +308,19 @@
160
308
  .nameInput {
161
309
  flex: 1;
162
310
  min-width: 0;
163
- border: 1px solid rgba(125, 211, 252, 0.5);
311
+ border: 1px solid rgba(126, 152, 255, 0.5);
164
312
  border-radius: 6px;
165
- background: rgba(30, 41, 59, 0.9);
166
- color: #e2e8f0;
313
+ background: rgba(19, 28, 54, 0.9);
314
+ color: #e6ebf8;
167
315
  font-size: 12px;
168
316
  padding: 3px 6px;
169
317
  outline: none;
170
318
  }
171
319
 
172
320
  .nameInput:focus {
173
- border-color: #38bdf8;
174
- /* 2px accent ring (theme-agnostic) replacing the removed default outline. */
175
- box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.45);
321
+ border-color: #4d6bfe;
322
+ /* 2px DeepSeek-blue ring (theme-agnostic) replacing the removed default outline. */
323
+ box-shadow: 0 0 0 2px rgba(77, 107, 254, 0.45);
176
324
  }
177
325
 
178
326
  .actions {
@@ -186,31 +334,37 @@
186
334
  border-radius: 6px;
187
335
  padding: 4px 8px;
188
336
  font-size: 12px;
337
+ font-weight: 600;
189
338
  cursor: pointer;
190
- color: #0f172a;
191
- background: linear-gradient(180deg, #7dd3fc, #38bdf8);
192
- transition: filter 120ms ease, box-shadow 120ms ease;
339
+ color: #fff;
340
+ background: linear-gradient(180deg, #4a68f5, #3a55e0);
341
+ box-shadow: 0 2px 6px rgba(77, 107, 254, 0.3);
342
+ transition: filter 120ms ease, box-shadow 120ms ease, transform 120ms ease;
193
343
  }
194
344
 
195
345
  .action:hover {
196
346
  filter: brightness(1.08);
347
+ transform: translateY(-1px);
348
+ box-shadow: 0 4px 10px rgba(77, 107, 254, 0.4);
197
349
  }
198
350
 
199
351
  .action:active {
200
352
  filter: brightness(0.94);
353
+ transform: translateY(0);
354
+ box-shadow: 0 1px 4px rgba(77, 107, 254, 0.3);
201
355
  }
202
356
 
203
357
  .action:focus-visible {
204
358
  outline: none;
205
- /* 2px accent ring on the pet's own palette; stays visible on the dark
206
- glass panel regardless of the GUI theme (theme-agnostic). */
207
- box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
359
+ /* 2px DeepSeek-blue ring on the pet's own palette; stays visible on the
360
+ dark glass panel regardless of the GUI theme (theme-agnostic). */
361
+ box-shadow: 0 0 0 2px rgba(126, 152, 255, 0.9);
208
362
  }
209
363
 
210
364
  .summon {
211
- border: 1px dashed rgba(125, 211, 252, 0.6);
212
- background: rgba(15, 23, 42, 0.75);
213
- color: #7dd3fc;
365
+ border: 1px dashed rgba(126, 152, 255, 0.6);
366
+ background: rgba(7, 11, 26, 0.75);
367
+ color: #8ea6ff;
214
368
  border-radius: 999px;
215
369
  padding: 2px 10px;
216
370
  font-size: 11px;
@@ -220,31 +374,35 @@
220
374
  }
221
375
 
222
376
  .summon:hover {
223
- border-color: rgba(125, 211, 252, 0.95);
224
- color: #bae6fd;
225
- background: rgba(15, 23, 42, 0.9);
377
+ border-color: rgba(126, 152, 255, 0.95);
378
+ color: #c3d3ff;
379
+ background: rgba(7, 11, 26, 0.9);
226
380
  }
227
381
 
228
382
  .summon:active {
229
- color: #7dd3fc;
230
- border-color: rgba(125, 211, 252, 0.8);
383
+ color: #8ea6ff;
384
+ border-color: rgba(126, 152, 255, 0.8);
231
385
  }
232
386
 
233
387
  .summon:focus-visible {
234
388
  outline: none;
235
- box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.85);
389
+ box-shadow: 0 0 0 2px rgba(126, 152, 255, 0.9);
236
390
  }
237
391
 
238
392
  /* Reduced motion: freeze the reaction bubble and kill the overlay transitions
239
393
  so the pet's motion is static or minimal without hiding interaction. */
240
394
  @media (prefers-reduced-motion: reduce) {
241
- .bubble {
395
+ .bubble,
396
+ .bubbleStatus,
397
+ .bubbleWhisper,
398
+ .panel {
242
399
  animation: none;
243
400
  opacity: 1;
244
401
  }
245
402
 
246
403
  .action,
247
- .summon {
404
+ .summon,
405
+ .bubbleMore {
248
406
  transition: none;
249
407
  }
250
408
  }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { PetTrackDef } from '../registry.ts'
3
+ import type { PetAnimation } from '../state.ts'
4
+ import { sequenceFrameAt } from './sequences.ts'
5
+
6
+ const track = (durations: number[]): PetTrackDef => ({
7
+ frames: durations.map((_, index) => index),
8
+ durations,
9
+ loop: true,
10
+ })
11
+
12
+ const tracks = {
13
+ running: track([100, 200]),
14
+ waiting: track([300, 100]),
15
+ } as Record<PetAnimation, PetTrackDef>
16
+
17
+ describe('sequenceFrameAt', () => {
18
+ const sequence: PetAnimation[] = ['running', 'waiting']
19
+
20
+ it('finishes every frame of one track before advancing', () => {
21
+ expect(sequenceFrameAt(sequence, tracks, 0)).toEqual({ animation: 'running', frameIndex: 0 })
22
+ expect(sequenceFrameAt(sequence, tracks, 100)).toEqual({ animation: 'running', frameIndex: 1 })
23
+ expect(sequenceFrameAt(sequence, tracks, 299)).toEqual({ animation: 'running', frameIndex: 1 })
24
+ expect(sequenceFrameAt(sequence, tracks, 300)).toEqual({ animation: 'waiting', frameIndex: 0 })
25
+ expect(sequenceFrameAt(sequence, tracks, 600)).toEqual({ animation: 'waiting', frameIndex: 1 })
26
+ })
27
+
28
+ it('loops the whole sequence and preserves overshoot', () => {
29
+ expect(sequenceFrameAt(sequence, tracks, 700)).toEqual({ animation: 'running', frameIndex: 0 })
30
+ expect(sequenceFrameAt(sequence, tracks, 850)).toEqual({ animation: 'running', frameIndex: 1 })
31
+ expect(sequenceFrameAt(sequence, tracks, 1_420)).toEqual({ animation: 'running', frameIndex: 0 })
32
+ })
33
+ })
@@ -0,0 +1,33 @@
1
+ /** Pure timing helpers for manifest-defined scene animation sequences. */
2
+
3
+ import type { PetTrackDef } from '../registry.ts'
4
+ import type { PetAnimation } from '../state.ts'
5
+
6
+ export interface SequenceFrame {
7
+ animation: PetAnimation
8
+ frameIndex: number
9
+ }
10
+
11
+ /** Resolve the active track and frame after elapsed milliseconds of a looping sequence. */
12
+ export function sequenceFrameAt(
13
+ sequence: readonly PetAnimation[],
14
+ tracks: Record<PetAnimation, PetTrackDef>,
15
+ elapsedMs: number,
16
+ ): SequenceFrame {
17
+ const itemDurations = sequence.map(animation => tracks[animation].durations.reduce((sum, value) => sum + value, 0))
18
+ const sequenceDuration = itemDurations.reduce((sum, value) => sum + value, 0)
19
+ let offset = Math.max(0, elapsedMs) % sequenceDuration
20
+ let itemIndex = 0
21
+ while (itemIndex < sequence.length - 1 && offset >= itemDurations[itemIndex]!) {
22
+ offset -= itemDurations[itemIndex]!
23
+ itemIndex += 1
24
+ }
25
+ const animation = sequence[itemIndex]!
26
+ const track = tracks[animation]
27
+ let frameIndex = 0
28
+ while (frameIndex < track.frames.length - 1 && offset >= track.durations[frameIndex]!) {
29
+ offset -= track.durations[frameIndex]!
30
+ frameIndex += 1
31
+ }
32
+ return { animation, frameIndex }
33
+ }