@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.
- package/README.i18n.yaml +2 -2
- package/README.md +18 -11
- package/README.zh.md +18 -11
- package/assets/whale/pet.json +9 -0
- package/lib/client.js +394 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +1007 -43
- package/lib/invariant.js +1 -1
- package/lib/{state-Diiq8vjk.js → state-DrMX22GL.js} +86 -28
- package/lib/types/affinity.d.ts +4 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +32 -6
- package/lib/types/chatter.d.ts +111 -0
- package/lib/types/chatter.d.ts.map +1 -0
- package/lib/types/chatter.js +756 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +1 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +133 -16
- package/lib/types/client/PluginSettingsCard.d.ts +28 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +147 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +1 -0
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/sequences.d.ts +10 -0
- package/lib/types/client/sequences.d.ts.map +1 -0
- package/lib/types/client/sequences.js +20 -0
- package/lib/types/event-projection.d.ts +14 -1
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +38 -17
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +15 -7
- package/lib/types/loopback.d.ts +25 -0
- package/lib/types/loopback.d.ts.map +1 -0
- package/lib/types/loopback.js +68 -0
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +3 -1
- package/lib/types/registry.d.ts +7 -1
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +47 -3
- package/lib/types/remarks.d.ts +2 -0
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +35 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +14 -0
- package/lib/types/service.d.ts +6 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +15 -2
- package/lib/types/state.d.ts +7 -4
- package/lib/types/state.d.ts.map +1 -1
- package/lib/types/state.js +20 -20
- package/package.json +9 -9
- package/src/affinity.test.ts +4 -2
- package/src/affinity.ts +37 -6
- package/src/chatter.test.ts +154 -0
- package/src/chatter.ts +787 -0
- package/src/client/PetDockEntry.test.tsx +93 -0
- package/src/client/PetDockEntry.tsx +1 -0
- package/src/client/PetSprite.test.tsx +253 -3
- package/src/client/PetSprite.tsx +192 -37
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/index.test.tsx +95 -0
- package/src/client/index.ts +1 -0
- package/src/client/locales.ts +4 -0
- package/src/client/pet-css.test.ts +54 -0
- package/src/client/pet.module.css +215 -57
- package/src/client/sequences.test.ts +33 -0
- package/src/client/sequences.ts +33 -0
- package/src/client/settings-card.module.css +90 -1
- package/src/event-projection.ts +49 -16
- package/src/ledger.test.ts +42 -1
- package/src/ledger.ts +15 -7
- package/src/loopback.ts +63 -0
- package/src/persist.test.ts +18 -1
- package/src/persist.ts +3 -1
- package/src/registry.test.ts +79 -4
- package/src/registry.ts +58 -6
- package/src/remarks.ts +36 -0
- package/src/routes.ts +11 -0
- package/src/service.ts +27 -2
- package/src/state.test.ts +13 -0
- 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
|
+
})
|
package/src/client/index.ts
CHANGED
|
@@ -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 }))
|
package/src/client/locales.ts
CHANGED
|
@@ -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: #
|
|
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(
|
|
35
|
+
background: rgba(77, 107, 254, 0.95);
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
.bubbleFeed {
|
|
37
|
-
background: rgba(
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
84
|
-
box-shadow: 0 0 0 2px rgba(
|
|
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
|
-
/*
|
|
217
|
+
/* 悬浮面板默认位于精灵脚下,不再占据精灵上方的空间:
|
|
109
218
|
会话气泡栈独占精灵头顶区域,两者互不遮挡 —— 气泡因此可以始终
|
|
110
|
-
显示并保持可点击,不必在 hover
|
|
219
|
+
显示并保持可点击,不必在 hover 时让位给面板。仅当视口底部空间
|
|
220
|
+
不足时,面板才回退到精灵上方。 */
|
|
111
221
|
.panel {
|
|
112
222
|
position: absolute;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
border:
|
|
118
|
-
|
|
119
|
-
|
|
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:
|
|
123
|
-
|
|
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:
|
|
127
|
-
min-width:
|
|
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
|
-
/*
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
向右延伸、覆盖间隙,使指针在图标与按钮之间始终停留在 hover 区域。 */
|
|
244
|
+
/* 面板顶边与精灵底边之间的 hover 桥接(issue #49):
|
|
245
|
+
面板在精灵下方留有 8px 间隙,透明伪元素向上覆盖间隙,
|
|
246
|
+
使指针在精灵与按钮之间始终停留在 hover 区域。 */
|
|
134
247
|
.panel::after {
|
|
135
248
|
content: '';
|
|
136
249
|
position: absolute;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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(
|
|
311
|
+
border: 1px solid rgba(126, 152, 255, 0.5);
|
|
164
312
|
border-radius: 6px;
|
|
165
|
-
background: rgba(
|
|
166
|
-
color: #
|
|
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: #
|
|
174
|
-
/* 2px
|
|
175
|
-
box-shadow: 0 0 0 2px rgba(
|
|
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: #
|
|
191
|
-
background: linear-gradient(180deg, #
|
|
192
|
-
|
|
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
|
|
206
|
-
glass panel regardless of the GUI theme (theme-agnostic). */
|
|
207
|
-
box-shadow: 0 0 0 2px rgba(
|
|
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(
|
|
212
|
-
background: rgba(
|
|
213
|
-
color: #
|
|
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(
|
|
224
|
-
color: #
|
|
225
|
-
background: rgba(
|
|
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: #
|
|
230
|
-
border-color: rgba(
|
|
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(
|
|
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
|
+
}
|