@pointylab/dsh 0.0.0-stage → 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.
Files changed (88) hide show
  1. package/ASSETS.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +85 -2
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +33 -0
  6. package/package.json +38 -4
  7. package/pet/app/card/card.css +365 -0
  8. package/pet/app/card/card.html +68 -0
  9. package/pet/app/card/card.js +896 -0
  10. package/pet/app/card/markdown.js +64 -0
  11. package/pet/app/card/mascot/blink.png +0 -0
  12. package/pet/app/card/mascot/done.png +0 -0
  13. package/pet/app/card/mascot/error.png +0 -0
  14. package/pet/app/card/mascot/idle.png +0 -0
  15. package/pet/app/card/mascot/peek.png +0 -0
  16. package/pet/app/card/mascot/think.png +0 -0
  17. package/pet/app/card/preload.js +21 -0
  18. package/pet/app/fullscreen.js +131 -0
  19. package/pet/app/main.js +1284 -0
  20. package/pet/app/native/uia-selection.ps1 +158 -0
  21. package/pet/app/notice/notice.html +37 -0
  22. package/pet/app/notice/notice.js +31 -0
  23. package/pet/app/notice/preload.js +7 -0
  24. package/pet/app/overlay/overlay.html +30 -0
  25. package/pet/app/overlay/overlay.js +42 -0
  26. package/pet/app/overlay/preload.js +7 -0
  27. package/pet/app/pill/pill.css +73 -0
  28. package/pet/app/pill/pill.html +11 -0
  29. package/pet/app/pill/pill.js +35 -0
  30. package/pet/app/pill/preload.js +8 -0
  31. package/pet/app/preload.js +16 -0
  32. package/pet/app/renderer/index.html +24 -0
  33. package/pet/app/renderer/lines.js +25 -0
  34. package/pet/app/renderer/pet.js +292 -0
  35. package/pet/app/renderer/renderer2d.js +71 -0
  36. package/pet/app/renderer/style.css +157 -0
  37. package/pet/app/selection.js +246 -0
  38. package/pet/app/shake.js +76 -0
  39. package/pet/app/uipick.js +314 -0
  40. package/pet/assets/cursor/cursor-32.cur +0 -0
  41. package/pet/assets/cursor/cursor-32.png +0 -0
  42. package/pet/assets/cursor/cursor-48.cur +0 -0
  43. package/pet/assets/cursor/cursor-48.png +0 -0
  44. package/pet/assets/cursor/cursor-64.cur +0 -0
  45. package/pet/assets/cursor/cursor-64.png +0 -0
  46. package/pet/assets/cursor/cursor-clean.png +0 -0
  47. package/pet/assets/cursor/make_cursor.py +85 -0
  48. package/pet/assets/cursor/source.png +0 -0
  49. package/pet/assets/pointy.ico +0 -0
  50. package/pet/assets/tray.png +0 -0
  51. package/pet/assets/tray@2x.png +0 -0
  52. package/pet/boot.js +6 -0
  53. package/pet/extension/background.js +252 -0
  54. package/pet/extension/cursor-32.png +0 -0
  55. package/pet/extension/cursor-48.png +0 -0
  56. package/pet/extension/cursor-64.png +0 -0
  57. package/pet/extension/icon.png +0 -0
  58. package/pet/extension/manifest.json +12 -0
  59. package/pet/extension/pick.js +391 -0
  60. package/pet/extension/popup.html +29 -0
  61. package/pet/extension/popup.js +30 -0
  62. package/pet/package.json +11 -0
  63. package/pet/vendor/koffi/LICENSE.txt +22 -0
  64. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.exp +0 -0
  65. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.lib +0 -0
  66. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.node +0 -0
  67. package/pet/vendor/koffi/index.js +634 -0
  68. package/pet/vendor/koffi/indirect.js +533 -0
  69. package/pet/vendor/koffi/package.json +35 -0
  70. package/pet/vendor/ws/LICENSE +20 -0
  71. package/pet/vendor/ws/index.js +22 -0
  72. package/pet/vendor/ws/lib/buffer-util.js +142 -0
  73. package/pet/vendor/ws/lib/constants.js +19 -0
  74. package/pet/vendor/ws/lib/event-target.js +292 -0
  75. package/pet/vendor/ws/lib/extension.js +203 -0
  76. package/pet/vendor/ws/lib/limiter.js +55 -0
  77. package/pet/vendor/ws/lib/permessage-deflate.js +530 -0
  78. package/pet/vendor/ws/lib/receiver.js +743 -0
  79. package/pet/vendor/ws/lib/sender.js +610 -0
  80. package/pet/vendor/ws/lib/stream.js +161 -0
  81. package/pet/vendor/ws/lib/subprotocol.js +62 -0
  82. package/pet/vendor/ws/lib/validation.js +152 -0
  83. package/pet/vendor/ws/lib/websocket-server.js +562 -0
  84. package/pet/vendor/ws/lib/websocket.js +1426 -0
  85. package/pet/vendor/ws/package.json +74 -0
  86. package/pet/vendor/ws/wrapper.mjs +21 -0
  87. package/src/node.js +732 -0
  88. package/src/runtime.js +178 -0
@@ -0,0 +1,292 @@
1
+ import { Sprite2DRenderer } from './renderer2d.js'
2
+ import { pickLine } from './lines.js'
3
+
4
+ const $ = (id) => document.getElementById(id)
5
+ const characterEl = $('character')
6
+ const bubble = $('bubble')
7
+ const bubbleText = $('bubble-text')
8
+ const bubbleDetail = $('bubble-detail')
9
+ const bubbleActions = $('bubble-actions')
10
+ const badge = $('badge')
11
+
12
+ const renderer = new Sprite2DRenderer(characterEl)
13
+
14
+ const HOLD_MS = { done: 6000, error: 10000 }
15
+ const SLEEP_AFTER_MS = 10 * 60 * 1000
16
+ const LINE_GAP_MS = 20000
17
+ const LINE_TIMEOUT_MS = 10000
18
+ const BUBBLE_MS = 6000
19
+ const SCALE_STEP = 0.05
20
+ const SCREEN_EDGE = 8
21
+ // 这些状态一进入就说一句;工具类状态在同一轮里只在类别变化时说
22
+ const SPEAK_ON = new Set(['done', 'error', 'waiting', 'running', 'editing', 'reading', 'web'])
23
+ const ACTIVE = new Set(['thinking', 'running', 'editing', 'reading', 'web'])
24
+
25
+ let connected = false
26
+ let suppressed = false // 全屏 / 勿扰:窗口已藏起来,不说话也不请求台词
27
+ let state = 'idle'
28
+ let holdUntil = 0
29
+ let holdTimer
30
+ let sleepTimer
31
+ let lastLineAt = 0
32
+ let lastSpokenState
33
+ let bubbleTimer
34
+ let errorDetail = ''
35
+ const approvals = [] // 等待中的批准,队首显示在气泡里
36
+ let reqSeq = 0
37
+ const lineWaiters = new Map()
38
+
39
+ function setState(next, { hold = 0, force = false } = {}) {
40
+ const now = Date.now()
41
+ if (!force && now < holdUntil && !(next === 'waiting' || next === 'error' || ACTIVE.has(next))) {
42
+ // done/error 的表情至少留一会儿,期间来的 idle 等到时候再切
43
+ clearTimeout(holdTimer)
44
+ holdTimer = setTimeout(() => setState(next, { force: true }), holdUntil - now)
45
+ return
46
+ }
47
+ clearTimeout(holdTimer)
48
+ holdUntil = hold ? now + hold : 0
49
+ const changed = next !== state
50
+ state = next
51
+ if (approvals.length === 0) renderer.setState(next)
52
+ if (changed) window.pet.rendered(next)
53
+ clearTimeout(sleepTimer)
54
+ if (next === 'idle') sleepTimer = setTimeout(() => setState('sleep'), SLEEP_AFTER_MS)
55
+ return changed
56
+ }
57
+
58
+ function showBubble(text, { kind = '', detail = '', sticky = false } = {}) {
59
+ clearTimeout(bubbleTimer)
60
+ bubble.hidden = false
61
+ bubble.className = kind
62
+ bubbleText.textContent = text
63
+ bubbleDetail.hidden = true
64
+ bubbleDetail.textContent = detail
65
+ bubbleActions.hidden = kind !== 'approval'
66
+ if (kind === 'error' && detail) bubble.classList.add('clickable')
67
+ if (kind === 'approval' && detail) bubbleDetail.hidden = false
68
+ if (!sticky) bubbleTimer = setTimeout(hideBubble, kind === 'error' ? BUBBLE_MS * 2 : BUBBLE_MS)
69
+ }
70
+
71
+ function hideBubble() {
72
+ if (approvals.length) return renderApproval()
73
+ bubble.hidden = true
74
+ }
75
+
76
+ // 先问插件要一句 DSH 模型生成的台词,超时或失败就用内置的
77
+ async function say(kind, detail, { urgent = false } = {}) {
78
+ const now = Date.now()
79
+ if (suppressed) return
80
+ if (!urgent && now - lastLineAt < LINE_GAP_MS) return
81
+ lastLineAt = now
82
+ let text
83
+ if (connected) {
84
+ const reqId = ++reqSeq
85
+ text = await new Promise((resolve) => {
86
+ lineWaiters.set(reqId, resolve)
87
+ window.pet.send({ type: 'line-request', reqId, state: kind, detail })
88
+ setTimeout(() => { lineWaiters.delete(reqId); resolve(undefined) }, LINE_TIMEOUT_MS)
89
+ })
90
+ }
91
+ if (approvals.length) return
92
+ text ||= pickLine(connected ? kind : 'offline')
93
+ if (kind === 'error') showBubble(`${text}(点我看原因)`, { kind: 'error', detail: errorDetail })
94
+ else showBubble(text)
95
+ }
96
+
97
+ function renderApproval() {
98
+ const a = approvals[0]
99
+ if (!a) return
100
+ renderer.setState('waiting')
101
+ const where = a.title ? `「${a.title}」` : '会话'
102
+ showBubble(`${where}要执行 ${a.toolName}:${a.reason || '需要你批准'}`, { kind: 'approval', detail: a.detail, sticky: true })
103
+ badge.hidden = approvals.length < 2
104
+ badge.textContent = String(approvals.length)
105
+ window.pet.approvals(approvals.length)
106
+ window.pet.rendered('approval')
107
+ }
108
+
109
+ function closeApproval(id) {
110
+ const i = approvals.findIndex(a => a.id === id)
111
+ if (i < 0) return
112
+ approvals.splice(i, 1)
113
+ window.pet.approvals(approvals.length)
114
+ badge.hidden = approvals.length < 2
115
+ if (approvals.length) return renderApproval()
116
+ window.pet.front(false)
117
+ bubble.hidden = true
118
+ renderer.setState(state)
119
+ }
120
+
121
+ function answer(decision) {
122
+ const a = approvals[0]
123
+ if (!a) return
124
+ window.pet.send({ type: 'approve', id: a.id, decision })
125
+ closeApproval(a.id)
126
+ }
127
+
128
+ window.pet.onDsh((msg) => {
129
+ switch (msg.type) {
130
+ case 'link':
131
+ connected = msg.connected
132
+ renderer.setOffline(!connected)
133
+ if (!connected) {
134
+ approvals.length = 0
135
+ window.pet.approvals(0)
136
+ window.pet.front(false)
137
+ setState('idle', { force: true })
138
+ say('offline', '', { urgent: true })
139
+ } else {
140
+ showBubble('连上 DSH 了,开工吧~')
141
+ }
142
+ break
143
+ case 'state': {
144
+ const s = msg.state
145
+ if (s === 'error') errorDetail = msg.detail || ''
146
+ const changed = setState(s, { hold: HOLD_MS[s] ?? 0 })
147
+ if (s === 'thinking') lastSpokenState = undefined
148
+ if (changed && SPEAK_ON.has(s) && s !== lastSpokenState && s !== 'waiting') {
149
+ lastSpokenState = s
150
+ if (s === 'error') renderer.alarm()
151
+ say(s, msg.tool ? `${msg.tool} ${msg.detail ?? ''}` : '', { urgent: s === 'error' || s === 'done' })
152
+ }
153
+ break
154
+ }
155
+ case 'notice':
156
+ showBubble(msg.text, { kind: msg.kind ?? '' })
157
+ break
158
+ case 'kept':
159
+ // 没有批准在等时,角标用来显示后台任务:✓ 做完待看,… 还在做
160
+ if (approvals.length < 2) {
161
+ badge.hidden = msg.running + msg.finished === 0
162
+ badge.textContent = msg.finished ? `✓${msg.finished}` : `…${msg.running}`
163
+ }
164
+ break
165
+ case 'suppressed':
166
+ suppressed = msg.on
167
+ break
168
+ case 'status':
169
+ if (!msg.running && ACTIVE.has(state)) setState('idle')
170
+ break
171
+ case 'approval':
172
+ approvals.push(msg)
173
+ window.pet.front(true)
174
+ renderer.poke()
175
+ renderApproval()
176
+ break
177
+ case 'approval-closed':
178
+ closeApproval(msg.id)
179
+ break
180
+ case 'line': {
181
+ const resolve = lineWaiters.get(msg.reqId)
182
+ lineWaiters.delete(msg.reqId)
183
+ resolve?.(msg.text)
184
+ break
185
+ }
186
+ case 'files-result':
187
+ showBubble(msg.ok ? `收到 ${msg.count} 个文件,交给 DSH 了` : msg.error, { kind: msg.ok ? '' : 'error' })
188
+ break
189
+ case 'layout':
190
+ document.documentElement.style.setProperty('--scale', String(msg.scale))
191
+ break
192
+ case 'server-error':
193
+ showBubble(`端口被占用了:${msg.error}`, { kind: 'error', sticky: true })
194
+ break
195
+ }
196
+ })
197
+
198
+ $('btn-allow').addEventListener('click', () => answer('allow'))
199
+ $('btn-reject').addEventListener('click', () => answer('reject'))
200
+
201
+ bubble.addEventListener('click', (e) => {
202
+ if (e.target.closest('button')) return
203
+ if (bubble.classList.contains('error') && bubbleDetail.textContent) {
204
+ clearTimeout(bubbleTimer)
205
+ bubbleDetail.hidden = !bubbleDetail.hidden
206
+ if (bubbleDetail.hidden) bubbleTimer = setTimeout(hideBubble, BUBBLE_MS)
207
+ } else if (!approvals.length) {
208
+ hideBubble()
209
+ }
210
+ })
211
+
212
+ // 拖动窗口;没怎么动就算戳了一下
213
+ let drag
214
+ characterEl.addEventListener('pointerdown', (e) => {
215
+ if (e.button !== 0) return
216
+ characterEl.setPointerCapture(e.pointerId)
217
+ drag = { x: e.screenX, y: e.screenY, moved: 0, at: Date.now() }
218
+ window.pet.dragging(true)
219
+ })
220
+ characterEl.addEventListener('lostpointercapture', () => window.pet.dragging(false))
221
+ characterEl.addEventListener('pointermove', (e) => {
222
+ if (!drag) return
223
+ const dx = e.screenX - drag.x
224
+ const dy = e.screenY - drag.y
225
+ if (!dx && !dy) return
226
+ drag.moved += Math.abs(dx) + Math.abs(dy)
227
+ drag.x = e.screenX
228
+ drag.y = e.screenY
229
+ window.pet.moveBy(dx, dy)
230
+ })
231
+ characterEl.addEventListener('pointerup', () => {
232
+ if (drag && drag.moved < 5 && Date.now() - drag.at < 500) {
233
+ window.pet.clicked()
234
+ renderer.poke()
235
+ if (state === 'sleep') setState('idle', { force: true })
236
+ say('poke', state, { urgent: true })
237
+ }
238
+ drag = undefined
239
+ })
240
+ window.addEventListener('contextmenu', (e) => { e.preventDefault(); window.pet.menu() })
241
+ // 在人物上 Ctrl+滚轮调大小
242
+ characterEl.addEventListener('wheel', (e) => {
243
+ if (!e.ctrlKey) return
244
+ e.preventDefault()
245
+ window.pet.scaleBy(e.deltaY < 0 ? SCALE_STEP : -SCALE_STEP)
246
+ }, { passive: false })
247
+
248
+ // 拖文件给她
249
+ document.addEventListener('dragover', (e) => { e.preventDefault(); characterEl.classList.add('drop-hover') })
250
+ document.addEventListener('dragleave', () => characterEl.classList.remove('drop-hover'))
251
+ document.addEventListener('drop', (e) => {
252
+ e.preventDefault()
253
+ characterEl.classList.remove('drop-hover')
254
+ const paths = [...e.dataTransfer.files].map(f => window.pet.pathForFile(f)).filter(Boolean)
255
+ if (!paths.length) return
256
+ if (!connected) return showBubble('DSH 没连上,交不出去……', { kind: 'error' })
257
+ renderer.poke()
258
+ window.pet.send({ type: 'files', paths })
259
+ })
260
+
261
+ // 立绘、气泡、角标在窗口里的位置报给主进程,主进程据此决定哪里接鼠标、哪里点击穿透
262
+ let lastLayout = ''
263
+ const rectOf = (el) => {
264
+ if (el.hidden) return null
265
+ const r = el.getBoundingClientRect()
266
+ return { x: r.x, y: r.y, width: r.width, height: r.height }
267
+ }
268
+ // 气泡默认在头顶正中;快出屏幕(工作区)时横移回来
269
+ let bubbleShift = 0
270
+ function keepBubbleOnScreen() {
271
+ if (bubble.hidden) return
272
+ const r = bubble.getBoundingClientRect()
273
+ const left = window.screenX + r.left - bubbleShift
274
+ const right = left + r.width
275
+ const min = screen.availLeft + SCREEN_EDGE
276
+ const max = screen.availLeft + screen.availWidth - SCREEN_EDGE
277
+ const shift = Math.round(right > max ? max - right : left < min ? min - left : 0)
278
+ if (shift === bubbleShift) return
279
+ bubbleShift = shift
280
+ bubble.style.setProperty('--shift', `${shift}px`)
281
+ }
282
+
283
+ setInterval(() => {
284
+ keepBubbleOnScreen()
285
+ const layout = { character: rectOf(characterEl), bubble: rectOf(bubble), badge: rectOf(badge) }
286
+ const key = JSON.stringify(layout)
287
+ if (key === lastLayout) return
288
+ lastLayout = key
289
+ window.pet.layout(layout)
290
+ }, 100)
291
+
292
+ setState('idle', { force: true })
@@ -0,0 +1,71 @@
1
+ // 2D 精灵渲染器。以后的 3D 渲染器(three.js + GLB/VRM)实现同样的接口:
2
+ // setState(state) / setOffline(bool) / poke() / alarm() / dispose()
3
+
4
+ const FRAME_OF = {
5
+ idle: 'idle',
6
+ thinking: 'thinking',
7
+ running: 'working',
8
+ editing: 'working',
9
+ reading: 'working',
10
+ web: 'working',
11
+ waiting: 'waiting',
12
+ done: 'done',
13
+ error: 'error',
14
+ sleep: 'sleep',
15
+ }
16
+
17
+ export class Sprite2DRenderer {
18
+ constructor(el, base = '../../assets/sprites/') {
19
+ this.el = el
20
+ this.img = document.createElement('img')
21
+ this.img.alt = ''
22
+ el.appendChild(this.img)
23
+ this.frames = {}
24
+ for (const name of new Set([...Object.values(FRAME_OF), 'blink'])) {
25
+ const img = new Image()
26
+ img.src = `${base}${name}.png`
27
+ this.frames[name] = img.src
28
+ }
29
+ this.state = 'idle'
30
+ this.show('idle')
31
+ this.scheduleBlink()
32
+ }
33
+
34
+ show(frame) {
35
+ this.img.src = this.frames[frame]
36
+ }
37
+
38
+ setState(state) {
39
+ this.state = state
40
+ this.show(FRAME_OF[state] ?? 'idle')
41
+ }
42
+
43
+ setOffline(offline) {
44
+ this.el.classList.toggle('offline', offline)
45
+ }
46
+
47
+ // idle 时随机眨眼
48
+ scheduleBlink() {
49
+ this.blinkTimer = setTimeout(() => {
50
+ if (this.state === 'idle') {
51
+ this.show('blink')
52
+ setTimeout(() => { if (this.state === 'idle') this.show('idle') }, 160)
53
+ }
54
+ this.scheduleBlink()
55
+ }, 2500 + Math.random() * 3500)
56
+ }
57
+
58
+ replay(cls) {
59
+ this.el.classList.remove(cls)
60
+ void this.el.offsetWidth
61
+ this.el.classList.add(cls)
62
+ }
63
+
64
+ poke() { this.replay('hop') }
65
+ alarm() { this.replay('shake') }
66
+
67
+ dispose() {
68
+ clearTimeout(this.blinkTimer)
69
+ this.img.remove()
70
+ }
71
+ }
@@ -0,0 +1,157 @@
1
+ [hidden] { display: none !important; }
2
+
3
+ :root {
4
+ --scale: 0.6;
5
+ --cw: calc(200px * var(--scale));
6
+ --ch: calc(240px * var(--scale));
7
+ --bg: #ffffff;
8
+ --text: #1f2328;
9
+ --muted: #6e7781;
10
+ --line: #e6e8eb;
11
+ --chip: #f2f3f5;
12
+ --accent: #1f2328;
13
+ --accent-text: #ffffff;
14
+ --error: #cf222e;
15
+ --shadow: 0 6px 24px rgba(15, 23, 42, 0.14), 0 1px 4px rgba(15, 23, 42, 0.06);
16
+ }
17
+ @media (prefers-color-scheme: dark) {
18
+ :root {
19
+ --bg: #23252a;
20
+ --text: #e6e8eb;
21
+ --muted: #a0a7b1;
22
+ --line: #34373d;
23
+ --chip: #2f3237;
24
+ --accent: #e6e8eb;
25
+ --accent-text: #1f2328;
26
+ --error: #ff7b72;
27
+ --shadow: 0 6px 24px rgba(0, 0, 0, 0.45), 0 1px 4px rgba(0, 0, 0, 0.3);
28
+ }
29
+ }
30
+
31
+ html, body {
32
+ margin: 0;
33
+ height: 100%;
34
+ background: transparent;
35
+ overflow: hidden;
36
+ user-select: none;
37
+ font-family: "Microsoft YaHei UI", "PingFang SC", sans-serif;
38
+ }
39
+
40
+ #stage {
41
+ position: relative;
42
+ width: 100%;
43
+ height: 100%;
44
+ }
45
+
46
+ /* 人物大小由主进程定(--scale),人物在窗口底部正中 */
47
+ #character {
48
+ position: absolute;
49
+ bottom: 0;
50
+ left: 50%;
51
+ margin-left: calc(var(--cw) / -2);
52
+ width: var(--cw);
53
+ height: var(--ch);
54
+ cursor: grab;
55
+ }
56
+ #character img {
57
+ width: 100%;
58
+ height: 100%;
59
+ object-fit: contain;
60
+ pointer-events: none;
61
+ -webkit-user-drag: none;
62
+ }
63
+ #character.offline img { filter: grayscale(1) opacity(0.7); }
64
+ #character.hop { animation: hop 0.35s ease-out; }
65
+ #character.shake { animation: shake 0.5s ease-in-out 2; }
66
+ #character.drop-hover { filter: drop-shadow(0 0 10px #ffcc33); }
67
+
68
+ @keyframes hop {
69
+ 0%, 100% { transform: translateY(0); }
70
+ 40% { transform: translateY(calc(-18px * var(--scale))); }
71
+ }
72
+ @keyframes shake {
73
+ 0%, 100% { transform: translateX(0); }
74
+ 25% { transform: translateX(-5px); }
75
+ 75% { transform: translateX(5px); }
76
+ }
77
+
78
+ /* 气泡:跟卡片一个风格,宽度随字走,最宽 260 再换行;在头顶正中,小尾巴朝下指着头顶。
79
+ 只有快出屏幕时才横移 --shift,尾巴反向挪回去,仍然指着人物 */
80
+ #bubble {
81
+ position: absolute;
82
+ bottom: calc(var(--ch) + 10px);
83
+ left: 50%;
84
+ transform: translateX(calc(-50% + var(--shift, 0px)));
85
+ box-sizing: border-box;
86
+ width: max-content;
87
+ max-width: 260px;
88
+ padding: 7px 12px;
89
+ border-radius: 12px;
90
+ background: var(--bg);
91
+ border: 1px solid var(--line);
92
+ color: var(--text);
93
+ font-size: 13px;
94
+ line-height: 1.5;
95
+ box-shadow: var(--shadow);
96
+ }
97
+ #bubble::after {
98
+ content: "";
99
+ position: absolute;
100
+ bottom: -6px;
101
+ left: clamp(12px, calc(50% - 5px - var(--shift, 0px)), calc(100% - 22px));
102
+ width: 10px;
103
+ height: 10px;
104
+ background: var(--bg);
105
+ border-right: 1px solid var(--line);
106
+ border-bottom: 1px solid var(--line);
107
+ transform: rotate(45deg);
108
+ }
109
+ #bubble.error #bubble-text { color: var(--error); }
110
+ #bubble.clickable { cursor: pointer; }
111
+
112
+ #bubble-text {
113
+ max-height: 84px;
114
+ overflow: auto;
115
+ }
116
+
117
+ #bubble-detail {
118
+ margin-top: 6px;
119
+ max-height: 44px;
120
+ overflow: auto;
121
+ font-family: Consolas, monospace;
122
+ font-size: 12px;
123
+ white-space: pre-wrap;
124
+ word-break: break-all;
125
+ color: var(--muted);
126
+ user-select: text;
127
+ }
128
+
129
+ #bubble-actions {
130
+ display: flex;
131
+ gap: 8px;
132
+ margin-top: 8px;
133
+ }
134
+ #bubble-actions button {
135
+ flex: 1;
136
+ padding: 4px 0;
137
+ border: 1px solid var(--line);
138
+ border-radius: 8px;
139
+ background: var(--chip);
140
+ color: var(--text);
141
+ font: inherit;
142
+ cursor: pointer;
143
+ }
144
+ #bubble-actions #btn-allow { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
145
+ #bubble-actions button:hover { filter: brightness(0.94); }
146
+
147
+ #badge {
148
+ position: absolute;
149
+ left: calc(50% + var(--cw) * 0.28);
150
+ bottom: calc(var(--ch) * 0.86);
151
+ padding: 1px 7px;
152
+ border-radius: 9px;
153
+ background: #c0392b;
154
+ color: #fff;
155
+ font-size: 12px;
156
+ font-weight: bold;
157
+ }