dsh-web-annotator 0.0.0-stage → 0.2.0-alpha.12

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 (76) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/CONTRIBUTING.md +90 -0
  3. package/LICENSE +21 -0
  4. package/README.md +110 -2
  5. package/README_en.md +136 -0
  6. package/THIRD_PARTY_NOTICES.md +15 -0
  7. package/cordis.patch.yml +15 -0
  8. package/docs/AGENTS.md +16 -0
  9. package/docs/README.md +25 -0
  10. package/docs/architecture.md +170 -0
  11. package/docs/browser-integration.md +94 -0
  12. package/docs/distribution.md +66 -0
  13. package/docs/harness-browser-annotation.patch +1909 -0
  14. package/docs/images/annotation-expanded-dark.png +0 -0
  15. package/docs/images/annotation-expanded.png +0 -0
  16. package/docs/images/annotation-image.png +0 -0
  17. package/docs/images/annotation-message.png +0 -0
  18. package/docs/images/annotation-region.png +0 -0
  19. package/docs/marketplace/cslkkl__dsh-web-annotator.yml +7 -0
  20. package/docs/publishing.md +169 -0
  21. package/docs/roadmap.md +31 -0
  22. package/docs/verification.md +179 -0
  23. package/dsh-plugin.naming.json +27 -0
  24. package/examples/responsive-demo/AGENTS.md +12 -0
  25. package/examples/responsive-demo/README.md +33 -0
  26. package/examples/responsive-demo/index.html +13 -0
  27. package/examples/responsive-demo/src/main.tsx +110 -0
  28. package/examples/responsive-demo/src/style.css +344 -0
  29. package/examples/responsive-demo/vite.config.ts +9 -0
  30. package/lib/THIRD_PARTY_LICENSES.md +345 -0
  31. package/lib/bridge.js +890 -0
  32. package/lib/bridge.js.map +7 -0
  33. package/lib/client.js +1083 -0
  34. package/lib/client.js.map +7 -0
  35. package/lib/host/THIRD_PARTY_NOTICES.md +15 -0
  36. package/lib/host/browser-provider/LICENSE +21 -0
  37. package/lib/host/browser-provider/README.md +130 -0
  38. package/lib/host/browser-provider/lib/client.css +217 -0
  39. package/lib/host/browser-provider/lib/client.css.map +7 -0
  40. package/lib/host/browser-provider/lib/client.js +2360 -0
  41. package/lib/host/browser-provider/lib/client.js.map +7 -0
  42. package/lib/host/browser-provider/lib/index.js +7 -0
  43. package/lib/host/browser-provider/lib/index.js.map +7 -0
  44. package/lib/host/browser-provider/package.json +23 -0
  45. package/lib/host/bundle.json +27 -0
  46. package/lib/host/chat-provider/LICENSE +21 -0
  47. package/lib/host/chat-provider/README.md +186 -0
  48. package/lib/host/chat-provider/lib/client.css +1604 -0
  49. package/lib/host/chat-provider/lib/client.css.map +7 -0
  50. package/lib/host/chat-provider/lib/client.js +11074 -0
  51. package/lib/host/chat-provider/lib/client.js.map +7 -0
  52. package/lib/host/chat-provider/lib/index.js +911 -0
  53. package/lib/host/chat-provider/lib/index.js.map +7 -0
  54. package/lib/host/chat-provider/package.json +31 -0
  55. package/lib/host/layout-provider/LICENSE +21 -0
  56. package/lib/host/layout-provider/README.md +109 -0
  57. package/lib/host/layout-provider/lib/client.css +177 -0
  58. package/lib/host/layout-provider/lib/client.css.map +7 -0
  59. package/lib/host/layout-provider/lib/client.js +565 -0
  60. package/lib/host/layout-provider/lib/client.js.map +7 -0
  61. package/lib/host/layout-provider/lib/index.js +7 -0
  62. package/lib/host/layout-provider/lib/index.js.map +7 -0
  63. package/lib/host/layout-provider/package.json +25 -0
  64. package/lib/index.d.ts +2 -0
  65. package/lib/index.js +9 -0
  66. package/lib/index.js.map +7 -0
  67. package/lib/vite.d.ts +8 -0
  68. package/lib/vite.js +1254 -0
  69. package/lib/vite.js.map +7 -0
  70. package/licenses/deepseek-harness-MIT.txt +21 -0
  71. package/locale/AGENTS.md +11 -0
  72. package/locale/README.md +11 -0
  73. package/locale/en.json +6 -0
  74. package/locale/zh.json +6 -0
  75. package/package.json +154 -4
  76. package/screenshots.json +7 -0
@@ -0,0 +1,1909 @@
1
+ diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md
2
+ index 6315827..b61b1db 100644
3
+ --- a/docs/subsystems/sidebar-right.md
4
+ +++ b/docs/subsystems/sidebar-right.md
5
+ @@ -2,6 +2,8 @@
6
+
7
+ English | [中文](sidebar-right.zh.md)
8
+
9
+ +Browser declares additional toolbar and annotation-list child slots with data and bounded page-picker callbacks; their owner props and carrier restrictions are defined in the [Browser package reference](../../packages/client/ui-sidebar-browser/README.md#annotation-extension-points).
10
+ +
11
+ The right Sidebar is the Web Client's per-Session docking surface: a column of panes and tabs beside the conversation in which addressed content — a workspace file, a directory tree, the product's own pages — opens, splits, floats, and closes. [`dsh-client-ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.md) owns the surface, the tab-type registry, and the navigation service; [`dsh-client-ui-dockkit`](../../packages/client/ui-dockkit/README.md) is its internal layout engine; [`dsh-client-resources`](../../packages/client/resources/README.md) turns addresses into live values for any component; [`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) provides both the Host workspace service and the Client `file` resource provider.
12
+
13
+ This page is the reference for the subsystem's contracts: addresses, tab-type registration, the navigation service, the extension slots and their owner props, the resource model, the Workspace Files service, the shipped types, and what is deliberately not built. How the layout engine, the frame, and the surface fit together is in the [Agent Note](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md); slot mechanics are in the [Slots reference](slots.md).
14
+ diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md
15
+ index 1df6007..1588a87 100644
16
+ --- a/docs/subsystems/sidebar-right.zh.md
17
+ +++ b/docs/subsystems/sidebar-right.zh.md
18
+ @@ -2,6 +2,8 @@
19
+
20
+ [English](sidebar-right.md) | 中文
21
+
22
+ +Browser 另行声明工具栏和批注列表子插槽,提供数据与受限的页面 picker 回调;owner props 和载体限制见 [Browser 包参考](../../packages/client/ui-sidebar-browser/README.zh.md#批注扩展点)。
23
+ +
24
+ 右侧 Sidebar 是 Web Client 里每个会话一份的停靠面:会话区旁的一列 pane 与 tab,按地址寻址的内容——工作区文件、目录树、产品自带页面——在这里打开、分栏、浮出、关闭。[`dsh-client-ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.zh.md) 拥有这个面、tab 类型注册表与导航服务;[`dsh-client-ui-dockkit`](../../packages/client/ui-dockkit/README.zh.md) 是它内部的布局引擎;[`dsh-client-resources`](../../packages/client/resources/README.zh.md) 把地址变成任何组件都能读的活数据;[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 同时提供 Host 工作区文件服务与 Client `file` 资源提供者。
25
+
26
+ 本页是该子系统契约的参考:地址、tab 类型注册、导航服务、扩展 slot 与其 owner props、资源模型、Workspace Files 服务、内置类型,以及明确不做的事。布局引擎、frame 与停靠面如何拼在一起见 [Agent Note](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md);slot 机制见 [Slots 参考](slots.zh.md)。
27
+ diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
28
+ index f09b12c..6031abf 100644
29
+ --- a/packages/client/ui-chat/README.md
30
+ +++ b/packages/client/ui-chat/README.md
31
+ @@ -182,3 +182,5 @@ None.
32
+ </details>
33
+
34
+ **Runtime invariant:** No companion is published. Conversation and Slot registration enforce Chat target consistency.
35
+ +
36
+ +The optional `conversation.message.user-text` chain presents recognized user text inside the existing bubble, including local echoes and steering. A contribution receives only the joined text; all-decline preserves reference-aware ordinary rendering. It does not change the persisted message, model request, image attachments or message actions.
37
+ diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
38
+ index 599c4cb..b7f4765 100644
39
+ --- a/packages/client/ui-chat/README.zh.md
40
+ +++ b/packages/client/ui-chat/README.zh.md
41
+ @@ -182,3 +182,5 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
42
+ </details>
43
+
44
+ **运行时不变式:** 不发布伴生入口。Conversation 与 slot 注册已经强制 Chat target 一致。
45
+ +
46
+ +可选的 `conversation.message.user-text` chain 在原有消息气泡中呈现已识别的用户正文,也覆盖本地回显与 steering。插件只收到拼接后的文本;全部拒绝时保留普通正文与引用呈现。它不改变持久化消息、模型请求、图片附件或消息操作。
47
+ diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts
48
+ index e580772..94c73d0 100644
49
+ --- a/packages/client/ui-chat/src/client/apply.ts
50
+ +++ b/packages/client/ui-chat/src/client/apply.ts
51
+ @@ -185,6 +185,7 @@ export function apply(ctx: Context): void {
52
+ children: {
53
+ 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
54
+ 'conversation.message.images': { kind: 'single', scope: 'session' },
55
+ + 'conversation.message.user-text': { kind: 'chain', scope: 'session' },
56
+ },
57
+ store: chatStore,
58
+ inject: (sessionId: SessionId): ChatViewInjected => {
59
+ diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
60
+ index f2269da..8e65d7c 100644
61
+ --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
62
+ +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
63
+ @@ -45,7 +45,7 @@ function turnOf(node: ChatNode | undefined): number | undefined {
64
+ export const ChatNodeSeat = memo(function ChatNodeSeat({
65
+ nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation,
66
+ cwd, openFile, openSkill, inspectCall, forkAt,
67
+ - loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t,
68
+ + loadImage, renderMessageImages, renderUserText, fileMentions, useStore, actions, renderSlot, t,
69
+ }: ChatNodeSeatProps) {
70
+ const node = useChatNode(nodeKey)
71
+ const routedNode = node as ChatNode | undefined
72
+ @@ -131,11 +131,12 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
73
+ forkAt,
74
+ loadImage,
75
+ renderMessageImages,
76
+ + renderUserText,
77
+ fileMentions,
78
+ turnProcess,
79
+ }, [
80
+ node, groupPart, cwd, openFile, openSkill, inspectCall, forkAt,
81
+ - loadImage, renderMessageImages, fileMentions, turnProcess,
82
+ + loadImage, renderMessageImages, renderUserText, fileMentions, turnProcess,
83
+ ])
84
+ if (routedNode === undefined || owner === null) return null
85
+ // Runtime dispatch owns the correlation: every Node's discriminant is the
86
+ diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
87
+ index 460f98b..cf485d5 100644
88
+ --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
89
+ +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
90
+ @@ -10,7 +10,7 @@ import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/
91
+ import {
92
+ Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal,
93
+ } from '@deepseek-ai/dsh-client-ui-primitives'
94
+ -import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts'
95
+ +import type { ChatNodeOwnerProps, ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts'
96
+ import type { ChatSnapshot } from '../contract/snapshot.ts'
97
+ import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
98
+ import { ChatNodeSeat } from './ChatNodeSeat.tsx'
99
+ @@ -77,10 +77,10 @@ const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, pending
100
+ })
101
+ const pendingRows = pendingInputs.map(item => 'requestId' in item ? (
102
+ <PendingSubmissionBubble key={item.requestId} submission={item}
103
+ - renderMessageImages={seatProps.renderMessageImages} t={seatProps.t} />
104
+ + renderMessageImages={seatProps.renderMessageImages} renderUserText={seatProps.renderUserText} t={seatProps.t} />
105
+ ) : (
106
+ <PendingSteeringBubble key={item.id} content={item.content}
107
+ - renderMessageImages={seatProps.renderMessageImages} t={seatProps.t} />
108
+ + renderMessageImages={seatProps.renderMessageImages} renderUserText={seatProps.renderUserText} t={seatProps.t} />
109
+ ))
110
+ const tail = entries.at(-1)
111
+ const node = tail?.kind === 'node' ? seatProps.nodeStore.get(tail.key) : undefined
112
+ @@ -99,7 +99,7 @@ const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, pending
113
+ * ordered business Node crosses the keyed renderer seat.
114
+ */
115
+ export function ChatView({
116
+ - useSession, useChat, useChatNode, useChatNodeProcess, useChatGroup, useConversation, useSessions, useStore, actions, renderSlot,
117
+ + useSession, useChat, useChatNode, useChatNodeProcess, useChatGroup, useConversation, useSessions, useStore, actions, renderSlot, renderSlotChain,
118
+ sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
119
+ usePresentation, useProjection, t,
120
+ }: ChatViewSlotProps) {
121
+ @@ -210,6 +210,10 @@ export function ChatView({
122
+ owner => renderSlot('conversation.message.images', { ...owner, loadImage }),
123
+ [loadImage, renderSlot],
124
+ )
125
+ + const renderUserText = useCallback<ChatNodeOwnerProps['renderUserText']>(
126
+ + (text, fallback) => renderSlotChain('conversation.message.user-text', { text }, { fallback }),
127
+ + [renderSlotChain],
128
+ + )
129
+
130
+ const firstKey = order[0]
131
+ const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null
132
+ @@ -272,6 +276,7 @@ export function ChatView({
133
+ forkAt={forkAt}
134
+ loadImage={loadImage}
135
+ renderMessageImages={renderMessageImages}
136
+ + renderUserText={renderUserText}
137
+ fileMentions={fileMentions}
138
+ renderSlot={renderSlot}
139
+ t={t}
140
+ diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx
141
+ index 9005150..22094af 100644
142
+ --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx
143
+ +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx
144
+ @@ -160,11 +160,12 @@ function TurnMaxTokensItem({ t }: {
145
+
146
+ /** Right-aligned bubble shared by user and steering rows. */
147
+ function UserStyleBubble({
148
+ - content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [],
149
+ + content, renderMessageImages, renderUserText, actions, pending = false, echo = false, referenceLabels = [], skillNames = [],
150
+ previewAttachments, references, t,
151
+ }: {
152
+ content: readonly unknown[]
153
+ renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
154
+ + renderUserText: ChatNodeOwnerProps['renderUserText']
155
+ /** Optional IconActions (or similar) below the bubble; receives the joined text. */
156
+ actions?: (text: string) => ReactNode
157
+ /** Whether this is the Host-authoritative pre-admission steering projection. */
158
+ @@ -219,7 +220,7 @@ function UserStyleBubble({
159
+ </div>
160
+ )}
161
+ {showBubble && <div className={css.bubble}>
162
+ - {projectUserText(text, referenceLabels, skillNames, 'skill', references)}
163
+ + {renderUserText(text, projectUserText(text, referenceLabels, skillNames, 'skill', references))}
164
+ {rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
165
+ </div>}
166
+ {referenceLabels.length > 0 && (
167
+ @@ -239,15 +240,17 @@ function UserStyleBubble({
168
+ * @param props - Pending message content and conversation translator.
169
+ * @returns the pending steering bubble.
170
+ */
171
+ -export function PendingSteeringBubble({ content, renderMessageImages, t }: {
172
+ +export function PendingSteeringBubble({ content, renderMessageImages, renderUserText, t }: {
173
+ content: readonly unknown[]
174
+ renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
175
+ + renderUserText: ChatNodeOwnerProps['renderUserText']
176
+ t: ChatViewSlotProps['t']
177
+ }): ReactNode {
178
+ return (
179
+ <UserStyleBubble
180
+ content={content}
181
+ renderMessageImages={renderMessageImages}
182
+ + renderUserText={renderUserText}
183
+ pending
184
+ t={t}
185
+ actions={text => (
186
+ @@ -270,9 +273,10 @@ export function PendingSteeringBubble({ content, renderMessageImages, t }: {
187
+ * @param props - the session snapshot's pending submission and render seats.
188
+ * @returns the echoed user bubble.
189
+ */
190
+ -export function PendingSubmissionBubble({ submission, renderMessageImages, t }: {
191
+ +export function PendingSubmissionBubble({ submission, renderMessageImages, renderUserText, t }: {
192
+ submission: PendingSubmission
193
+ renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
194
+ + renderUserText: ChatNodeOwnerProps['renderUserText']
195
+ t: ChatViewSlotProps['t']
196
+ }): ReactNode {
197
+ const content = useMemo(
198
+ @@ -300,6 +304,7 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }:
199
+ content={content}
200
+ previewAttachments={previewAttachments}
201
+ renderMessageImages={renderMessageImages}
202
+ + renderUserText={renderUserText}
203
+ pending={submission.placement === 'steering'}
204
+ echo
205
+ t={t}
206
+ @@ -318,7 +323,7 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }:
207
+
208
+ /** User and admitted-steering keyed Chat renderer. */
209
+ export const UserMessageNodeView = memo(function UserMessageNodeView({
210
+ - node, renderMessageImages, openFile, openSkill, t,
211
+ + node, renderMessageImages, renderUserText, openFile, openSkill, t,
212
+ }: ChatNodeViewProps<'user' | 'steering'>) {
213
+ const data = node.data
214
+ return (
215
+ @@ -326,6 +331,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
216
+ content={data.content}
217
+ references={{ openFile, openSkill }}
218
+ renderMessageImages={renderMessageImages}
219
+ + renderUserText={renderUserText}
220
+ {...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }}
221
+ {...data.skillNames === undefined ? {} : { skillNames: data.skillNames }}
222
+ t={t}
223
+ diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts
224
+ index 5c7a8e3..c661e01 100644
225
+ --- a/packages/client/ui-chat/src/client/contract/slots.ts
226
+ +++ b/packages/client/ui-chat/src/client/contract/slots.ts
227
+ @@ -1,4 +1,5 @@
228
+ /** Chat-owned Slot declarations and composed component props. */
229
+ +import type { ReactNode } from 'react'
230
+ import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
231
+ import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
232
+ import type {
233
+ @@ -158,6 +159,8 @@ export interface ChatNodeInjected {
234
+
235
+ /** Stable owner currency delivered to a keyed Chat renderer. */
236
+ export interface ChatNodeOwnerProps {
237
+ + /** Offer one user text to the feature chain while preserving the default presentation. */
238
+ + renderUserText: (text: string, fallback: ReactNode) => ReactNode
239
+ /** Renderer-owned Node portion selected by the grouping Definition. */
240
+ groupPart?: string
241
+ cwd?: string | undefined
242
+ @@ -262,7 +265,7 @@ export interface ChatViewInjected {
243
+ /** Full Chat view props. */
244
+ export type ChatViewSlotProps =
245
+ PropsRuntime<'conversation.view'>
246
+ - & PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images'>
247
+ + & PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images' | 'conversation.message.user-text'>
248
+ & PropsStore<ChatStore>
249
+ & InjectFace<ChatViewInjected>
250
+ & PropsLocale<'chat'>
251
+ @@ -282,6 +285,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
252
+ }
253
+
254
+ interface SlotMap {
255
+ + /** Feature-owned presentation of recognized user text; all-decline keeps the normal bubble. */
256
+ + 'conversation.message.user-text': { kind: 'chain'; scope: 'session'; owner: { readonly text: string } }
257
+ /**
258
+ * Final Chat node renderer, keyed by `ChatNodeKind`. The component receives
259
+ * the typed node, shared Chat actions, and Turn-data hook. Reusing a key
260
+ diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md
261
+ index e32e926..bbeeb71 100644
262
+ --- a/packages/client/ui-layout/README.md
263
+ +++ b/packages/client/ui-layout/README.md
264
+ @@ -31,6 +31,8 @@ The root slot composes the sidebar, main content, and right column. The sidebar
265
+
266
+ Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition.
267
+
268
+ +While the right panel is fullscreen, the covered main column is invisible and inert but remains mounted and measured. This prevents glass themes from painting or focusing the chat above the panel; restoring the panel preserves the same composer and draft.
269
+ +
270
+ <a id="window-chrome-seat"></a>
271
+ ### Window-chrome seat
272
+
273
+ diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md
274
+ index 83b29e5..9ab9b41 100644
275
+ --- a/packages/client/ui-layout/README.zh.md
276
+ +++ b/packages/client/ui-layout/README.zh.md
277
+ @@ -31,6 +31,8 @@ kind: "package-reference"
278
+
279
+ 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。
280
+
281
+ +右侧面板全屏时,后方主栏不可见且设为 inert,但保持挂载和尺寸。玻璃主题因此不会让聊天界面绘制在面板上方或误获焦点;退出全屏恢复同一输入框与草稿。
282
+ +
283
+ <a id="window-chrome-seat"></a>
284
+ ### 窗口 chrome 座
285
+
286
+ diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css
287
+ index 56494a9..c707f8e 100644
288
+ --- a/packages/client/ui-layout/src/client/AppFrame.module.css
289
+ +++ b/packages/client/ui-layout/src/client/AppFrame.module.css
290
+ @@ -74,6 +74,14 @@
291
+ overflow: hidden;
292
+ }
293
+
294
+ +/* Keep the covered main tree mounted and measured, but out of paint and focus.
295
+ + A glass theme can create a stacking context on the right panel, trapping its
296
+ + dock layer below main content. Fullscreen owns the visible center regardless
297
+ + of that theme; restoring it preserves the same composer and draft. */
298
+ +.frame[data-rightbar-fullscreen] .centerCol {
299
+ + visibility: hidden;
300
+ +}
301
+ +
302
+ /* macOS desktop: the frame goes transparent so window vibrancy reaches the
303
+ sidebar column, which keeps only a translucent tint; the centre paints the
304
+ base background itself to stay opaque. The sidebar/centre separator moves
305
+ diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx
306
+ index 841f9f7..1f6ba88 100644
307
+ --- a/packages/client/ui-layout/src/client/AppFrame.tsx
308
+ +++ b/packages/client/ui-layout/src/client/AppFrame.tsx
309
+ @@ -32,8 +32,9 @@ export type AppFrameProps =
310
+ & PropsLocale<'common'>
311
+
312
+ /** Center column grid item (session-body building block). */
313
+ -function CenterColumn(props: { children?: ReactNode }) {
314
+ - return <div className={css.centerCol}>{props.children}</div>
315
+ +function CenterColumn(props: { children?: ReactNode; covered: boolean }) {
316
+ + return <div className={css.centerCol} aria-hidden={props.covered || undefined}
317
+ + {...(props.covered ? { inert: '' } : {})}>{props.children}</div>
318
+ }
319
+
320
+ /** Subscribe to the main key without subscribing the column frame to each panel id. */
321
+ @@ -281,7 +282,7 @@ export function AppFrame({
322
+ {sidebar}
323
+ </div>
324
+ <>
325
+ - <CenterColumn>{main}</CenterColumn>
326
+ + <CenterColumn covered={layoutInfo.rightbarFullscreen}>{main}</CenterColumn>
327
+ <RightbarColumn>
328
+ {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
329
+ </RightbarColumn>
330
+ diff --git a/packages/client/ui-sidebar-browser/README.md b/packages/client/ui-sidebar-browser/README.md
331
+ index b15844e..592d2f2 100644
332
+ --- a/packages/client/ui-sidebar-browser/README.md
333
+ +++ b/packages/client/ui-sidebar-browser/README.md
334
+ @@ -46,6 +46,14 @@ The `browser.new` command opens a separate Browser page in the focused dock pane
335
+
336
+ The toolbar provides Back, Forward, Reload, Go, and Open in system browser. Web also offers a per-tab sandbox toggle; disabling it is temporary and displays an amber warning with neutral text. Desktop shows the observed page title. After a restart, Browser shows the saved title and URL; Restore or Reload opens that address only when requested.
337
+
338
+ +### Annotation extension points
339
+ +
340
+ +Browser declares session-scoped list slots `sidebar.right.tab.browser.toolbar` and `sidebar.right.tab.browser.annotations`. Toolbar contributions appear immediately before Reload. Owner props carry the tab ID, known URL, loading state and bounded `annotate(options)` / `cancelAnnotation()` callbacks; they never expose DOM, guest identifiers or an evaluator. Contributions own localized controls, retained annotation queues and any explicit Session submission.
341
+ +
342
+ +The picker supports element clicks, whitespace points and exact drag rectangles. Holding Space temporarily restores page interaction; spaces in the editor remain text input. Escape, navigation, physical unmount, cancellation and disposal release the picker. Results contain a comment/question intent, note, page identity, viewport, document and viewport rectangles, and bounded DOM candidates; candidates never replace a region's rectangle.
343
+ +
344
+ +Web requires a cooperating page bridge using the `dsh-browser-annotation-v1` MessageChannel request. The provider enforces the known HTTP(S) origin and page URL and rejects absent bridges or invalid results. Desktop executes only the fixed compiled picker inside its approved webview, validates its JSON result and exposes no Node or Electron capability. The local Desktop annotation implementation requires native product validation before release; these operations do not attach screenshots.
345
+ +
346
+ -----
347
+
348
+ <a id="understand-the-implementation"></a>
349
+ diff --git a/packages/client/ui-sidebar-browser/README.zh.md b/packages/client/ui-sidebar-browser/README.zh.md
350
+ index d09d327..df24c5c 100644
351
+ --- a/packages/client/ui-sidebar-browser/README.zh.md
352
+ +++ b/packages/client/ui-sidebar-browser/README.zh.md
353
+ @@ -46,6 +46,14 @@ Client 插件可以调用 `ctx.sidebarRight.openTab('browser', { params: { url }
354
+
355
+ 工具栏提供后退、前进、刷新、前往和在系统浏览器中打开。Web 还提供逐 tab sandbox 开关;关闭它是临时选择,并会显示灰字、琥珀色底的警告。Desktop 显示观察到的页面标题。重启后,Browser 展示保存的标题和 URL;只有点击恢复或刷新才打开该地址。
356
+
357
+ +### 批注扩展点
358
+ +
359
+ +Browser 声明会话作用域的 list 插槽 `sidebar.right.tab.browser.toolbar` 和 `sidebar.right.tab.browser.annotations`。工具栏贡献紧挨“刷新”左侧显示。Owner props 包含 tab ID、已知 URL、加载状态,以及受限的 `annotate(options)` / `cancelAnnotation()` 回调,不暴露 DOM、guest 标识或求值器。贡献方负责本地化控件、保留的批注队列与用户明确触发的会话提交。
360
+ +
361
+ +Picker 支持点击元素、空白位置以及精确拖动矩形。按住空格临时恢复网页交互,编辑器中的空格仍用于文本输入。Escape、导航、物理卸载、取消与销毁都会释放 picker。结果包含评论 / 提问意图、文字、页面身份、视口、文档 / 视口矩形与有界 DOM 候选;候选不会替代区域的真实矩形。
362
+ +
363
+ +Web 需要配合 `dsh-browser-annotation-v1` MessageChannel 请求的页面连接。提供方限制已知 HTTP(S) origin 与完整页面 URL,拒绝缺少连接或无效结果的页面。Desktop 只在已批准的 webview 中执行固定的编译后 picker,校验 JSON 结果,不提供 Node 或 Electron 能力。当前本地 Desktop 批注实现发布前仍需原生产品验证;这些操作不附截图。
364
+ +
365
+ -----
366
+
367
+ <a id="understand-the-implementation"></a>
368
+ diff --git a/packages/client/ui-sidebar-browser/src/client/annotation/iframe-annotation.ts b/packages/client/ui-sidebar-browser/src/client/annotation/iframe-annotation.ts
369
+ new file mode 100644
370
+ index 0000000..867e819
371
+ --- /dev/null
372
+ +++ b/packages/client/ui-sidebar-browser/src/client/annotation/iframe-annotation.ts
373
+ @@ -0,0 +1,74 @@
374
+ +/** Bounded annotation requests to a bridge-enabled iframe owned by Browser. */
375
+ +import type { BrowserAnnotationOptions } from './page-inspector.ts';
376
+ +import { isAnnotationResult, type AnnotationResult } from './protocol.ts';
377
+ +
378
+ +/** Request one picker without exposing the iframe or an evaluator to plugins.
379
+ + * @param frame - provider-owned iframe.
380
+ + * @param url - controller-known HTTP(S) page.
381
+ + * @param options - picker copy and theme.
382
+ + * @param signal - operation lifetime.
383
+ + * @returns saved evidence or cancellation; rejects when the page has no bridge.
384
+ + */
385
+ +export function annotateIframe(
386
+ + frame: HTMLIFrameElement,
387
+ + url: string,
388
+ + options: BrowserAnnotationOptions,
389
+ + signal: AbortSignal,
390
+ +): Promise<AnnotationResult> {
391
+ + if (signal.aborted) return Promise.resolve({ cancelled: true });
392
+ + return new Promise((resolve, reject) => {
393
+ + const channel = new MessageChannel();
394
+ + let ended = false;
395
+ + let ready = false;
396
+ + const handshake = setTimeout(() => {
397
+ + if (!ready) finish(undefined, new Error('annotation-unavailable'));
398
+ + }, 3000);
399
+ + function finish(value?: AnnotationResult, error?: Error) {
400
+ + if (ended) return;
401
+ + ended = true;
402
+ + channel.port1.postMessage({ type: 'cancel' });
403
+ + channel.port1.close();
404
+ + channel.port2.close();
405
+ + clearTimeout(handshake);
406
+ + signal.removeEventListener('abort', cancel);
407
+ + frame.removeEventListener('load', cancel);
408
+ + if (error) reject(error);
409
+ + else resolve(value || { cancelled: true });
410
+ + }
411
+ + function cancel() {
412
+ + finish({ cancelled: true });
413
+ + }
414
+ + signal.addEventListener('abort', cancel, { once: true });
415
+ + frame.addEventListener('load', cancel, { once: true });
416
+ + channel.port1.onmessage = (event) => {
417
+ + if (event.data?.ready === true && !ready) {
418
+ + ready = true;
419
+ + clearTimeout(handshake);
420
+ + return;
421
+ + }
422
+ + if (!isAnnotationResult(event.data)) {
423
+ + finish(undefined, new Error('annotation-invalid'));
424
+ + return;
425
+ + }
426
+ + if (!('cancelled' in event.data) && event.data.url !== url) {
427
+ + finish(undefined, new Error('annotation-page-changed'));
428
+ + return;
429
+ + }
430
+ + finish(event.data);
431
+ + };
432
+ + try {
433
+ + if (!frame.contentWindow) {
434
+ + finish(undefined, new Error('annotation-unavailable'));
435
+ + return;
436
+ + }
437
+ + frame.contentWindow.postMessage(
438
+ + { channel: 'dsh-browser-annotation-v1', type: 'start', options },
439
+ + new URL(url).origin,
440
+ + [channel.port2],
441
+ + );
442
+ + frame.focus();
443
+ + } catch (error) {
444
+ + finish(undefined, error instanceof Error ? error : new Error('annotation-unavailable'));
445
+ + }
446
+ + });
447
+ +}
448
+ diff --git a/packages/client/ui-sidebar-browser/src/client/annotation/page-inspector.ts b/packages/client/ui-sidebar-browser/src/client/annotation/page-inspector.ts
449
+ new file mode 100644
450
+ index 0000000..9d57afa
451
+ --- /dev/null
452
+ +++ b/packages/client/ui-sidebar-browser/src/client/annotation/page-inspector.ts
453
+ @@ -0,0 +1,915 @@
454
+ +/** Data returned by one explicit, in-page annotation gesture. */
455
+ +export interface BrowserAnnotation {
456
+ + /** Provider-captured viewport with the saved area marked; never part of DOM evidence. */
457
+ + readonly screenshot?: BrowserAnnotationImage;
458
+ + /** Requested CSS values for the precisely selected element. */
459
+ + readonly styleChanges?: Record<string, { before: string; after: string }>;
460
+ + readonly intent: 'comment' | 'question';
461
+ + readonly note: string;
462
+ + readonly url: string;
463
+ + readonly title: string;
464
+ + readonly viewport: { width: number; height: number };
465
+ + readonly selection: {
466
+ + kind: 'element' | 'region' | 'point';
467
+ + /** CSS pixels in the document, never substituted with a parent element. */
468
+ + rect: { x: number; y: number; width: number; height: number };
469
+ + viewportRect: { x: number; y: number; width: number; height: number };
470
+ + scroll: { x: number; y: number };
471
+ + candidates: BrowserAnnotationElement[];
472
+ + };
473
+ +}
474
+ +
475
+ +/** Bounded JPEG capture supplied by Browser, not by a visited page. */
476
+ +export interface BrowserAnnotationImage {
477
+ + readonly mediaType: 'image/jpeg';
478
+ + readonly data: string;
479
+ + readonly width: number;
480
+ + readonly height: number;
481
+ +}
482
+ +
483
+ +/** Bounded DOM evidence; candidates do not redefine a point or region. */
484
+ +export interface BrowserAnnotationElement {
485
+ + selector: string;
486
+ + shadowHosts: string[];
487
+ + matchCount: number;
488
+ + tag: string;
489
+ + text: string;
490
+ + rect: { x: number; y: number; width: number; height: number };
491
+ + styles: Record<string, string>;
492
+ + source?: string;
493
+ +}
494
+ +
495
+ +/** Copy and theme supplied by the owning Harness plugin, never by the page. */
496
+ +export interface BrowserAnnotationOptions {
497
+ + readonly intent: 'comment' | 'question';
498
+ + readonly dark: boolean;
499
+ + readonly copy: {
500
+ + select: string;
501
+ + comment: string;
502
+ + question: string;
503
+ + placeholder: string;
504
+ + settings: string;
505
+ + save: string;
506
+ + cancel: string;
507
+ + parent: string;
508
+ + child: string;
509
+ + targetGone: string;
510
+ + empty: string;
511
+ + region: string;
512
+ + point: string;
513
+ + interact: string;
514
+ + color: string;
515
+ + background: string;
516
+ + opacity: string;
517
+ + font: string;
518
+ + fontSize: string;
519
+ + fontWeight: string;
520
+ + spacing: string;
521
+ + elementOnly: string;
522
+ + invalidStyle: string;
523
+ + applyStyles: string;
524
+ + styleHint: string;
525
+ + };
526
+ +}
527
+ +
528
+ +declare global {
529
+ + interface Window {
530
+ + __DSH_PAGE_ANNOTATION_CANCEL__?: () => void;
531
+ + }
532
+ +}
533
+ +
534
+ +/**
535
+ + * Run a bounded picker inside the inspected document. This function is self-contained
536
+ + * so Desktop can execute its compiled body in an approved guest, without a preload.
537
+ + * @param options - localized copy, initial intent, and host theme.
538
+ + * @returns the saved annotation, or a cancellation when no annotation was saved.
539
+ + */
540
+ +export function annotatePage(
541
+ + options: BrowserAnnotationOptions,
542
+ +): Promise<BrowserAnnotation | { cancelled: true }> {
543
+ + window.__DSH_PAGE_ANNOTATION_CANCEL__?.();
544
+ + const doc = document;
545
+ + const originalUrl = location.href;
546
+ + const originalFocus = doc.activeElement;
547
+ + const root = doc.createElement('div');
548
+ + root.setAttribute('data-dsh-page-annotation', '');
549
+ + root.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none;';
550
+ + const shadow = root.attachShadow({ mode: 'open' });
551
+ + const style = doc.createElement('style');
552
+ + style.textContent = `
553
+ + :host{all:initial;color-scheme:${options.dark ? 'dark' : 'light'}}
554
+ + *{box-sizing:border-box}
555
+ + .outline{position:fixed;pointer-events:none;border:2px solid #2979ff;border-radius:5px;background:#2979ff0c}
556
+ + .hint{position:fixed;top:12px;left:50%;transform:translateX(-50%);padding:8px 12px;border-radius:8px;background:#181a20;color:#fff;font:13px/20px system-ui;max-width:calc(100vw - 24px);pointer-events:none}
557
+ + .editor,.options{position:fixed;pointer-events:auto;border:0;background:${options.dark ? '#292a2d' : '#fff'};color:${options.dark ? '#ededee' : '#303338'};box-shadow:0 0 0 .5px ${options.dark ? '#55565b' : '#dcdfe3'},0 2px 8px #00000014;font:14px/22px system-ui}
558
+ + .editor{border-radius:26px;corner-shape:round;padding:7px 8px;width:min(384px,calc(100vw - 24px));overflow:hidden;max-height:calc(100vh - 24px);display:flex;flex-direction:column}
559
+ + .editor.expanded{width:min(420px,calc(100vw - 24px));border-radius:24px;padding:0}
560
+ + .expanded>.row{padding:12px 14px;flex:none}
561
+ + .expanded>.row>.save{display:none}
562
+ + .expanded textarea{max-height:min(66px,20vh)}
563
+ + .editor:focus-within{box-shadow:0 0 0 .5px ${options.dark ? '#85868b' : '#bfc4cc'},0 2px 8px #00000014}
564
+ + .row{display:flex;align-items:center;gap:10px}
565
+ + button,select,textarea{font:inherit;color:inherit}
566
+ + button{border:0;border-radius:8px;padding:6px 8px;background:transparent;cursor:pointer;flex:none}
567
+ + button:disabled{opacity:.4;cursor:default}
568
+ + button:hover:not(:disabled){background:${options.dark ? '#3b3c40' : '#f0f1f3'}}
569
+ + button:focus-visible,select:focus-visible{outline:2px solid #2979ff;outline-offset:2px}
570
+ + .options-toggle,.save{display:flex;align-items:center;justify-content:center;padding:0;width:30px;height:30px;border-radius:50%;corner-shape:round}
571
+ + .options-toggle{color:${options.dark ? '#bdc0c5' : '#737880'}}
572
+ + .save,.save:disabled{background:${options.dark ? '#797c82' : '#a2a5a9'};color:#fff;opacity:1}
573
+ + .save:not(:disabled){background:${options.dark ? '#d2d3d6' : '#777c83'};color:${options.dark ? '#242529' : '#fff'}}
574
+ + .save:hover:not(:disabled){background:${options.dark ? '#eeeff1' : '#60656d'}}
575
+ + textarea{display:block;flex:1;width:100%;min-width:0;height:22px;max-height:110px;resize:none;border:0;background:transparent;padding:0;margin:0;outline:0;line-height:22px;overflow-y:auto}
576
+ + textarea::placeholder{color:${options.dark ? '#aaaeb5' : '#888e96'};opacity:1}
577
+ + .options{position:static;box-shadow:none;flex:1;min-height:0;overflow:auto;font-size:14px;line-height:22px;scrollbar-width:thin}
578
+ + .target-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 20px;background:${options.dark ? '#35363a' : '#f4f4f4'}}
579
+ + .target{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${options.dark ? '#c9ccd1' : '#656b73'}}
580
+ + .target-controls{display:flex;gap:2px;flex:none}
581
+ + .target-controls button{font-size:13px;padding:0;width:24px;height:24px}
582
+ + .properties{padding:12px 20px;display:grid;gap:10px}
583
+ + .property{display:grid;grid-template-columns:minmax(90px,1fr) minmax(0,180px);align-items:center;gap:12px;color:${options.dark ? '#c9ccd1' : '#6e747b'}}
584
+ + .field{display:flex;align-items:center;gap:9px;border:.5px solid ${options.dark ? '#57595f' : '#dedfe2'};border-radius:13px;padding:7px 10px;min-width:0}
585
+ + .field input{font:13px/20px ui-monospace,monospace;background:transparent;color:inherit;border:0;outline:0;width:100%;min-width:0}
586
+ + .field:focus-within{border-color:${options.dark ? '#a2a5ac' : '#a5a9b1'}}
587
+ + .swatch{width:22px;height:22px;border-radius:7px;flex:none;box-shadow:inset 0 0 0 .5px #8886}
588
+ + .intent{display:flex;align-items:center;justify-content:space-between;padding:0 20px 12px;gap:8px;font-size:13px;color:${options.dark ? '#c9ccd1' : '#6e747b'}}
589
+ + select{font:inherit;border:.5px solid ${options.dark ? '#57595f' : '#dedfe2'};background:transparent;border-radius:10px;padding:5px 8px;max-width:160px}
590
+ + .footer{border-top:.5px solid ${options.dark ? '#44464b' : '#eee'};display:flex;align-items:center;justify-content:space-between;padding:10px 12px;flex:none}
591
+ + .footer>.cancel{border:.5px solid ${options.dark ? '#55565b' : '#e5e6e9'};border-radius:20px;padding:6px 12px;font-size:13px}
592
+ + .style-hint{font-size:12px;line-height:18px;margin:3px 0;color:${options.dark ? '#adb1b8' : '#8a9097'}}
593
+ + .error{color:${options.dark ? '#ffb0a9' : '#b82920'};font-size:12px;padding:3px 40px 1px}
594
+ + .hidden{display:none!important}
595
+ + `;
596
+ + const outline = doc.createElement('div');
597
+ + outline.className = 'outline hidden';
598
+ + const hint = doc.createElement('div');
599
+ + hint.className = 'hint';
600
+ + hint.textContent = options.copy.select;
601
+ + const editor = doc.createElement('form');
602
+ + editor.className = 'editor hidden';
603
+ + editor.setAttribute('aria-label', options.copy.placeholder);
604
+ + const row = doc.createElement('div');
605
+ + row.className = 'row';
606
+ + // The injected picker reuses the shared SlidersTwoOutline and CheckOutline artwork.
607
+ + function icon(parts: readonly (readonly [string, Record<string, string>])[]) {
608
+ + const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
609
+ + for (const [name, value] of Object.entries({
610
+ + width: '18',
611
+ + height: '18',
612
+ + viewBox: '0 0 16 16',
613
+ + fill: 'none',
614
+ + stroke: 'currentColor',
615
+ + 'stroke-width': '1.3',
616
+ + 'stroke-linecap': 'round',
617
+ + 'stroke-linejoin': 'round',
618
+ + 'aria-hidden': 'true',
619
+ + }))
620
+ + svg.setAttribute(name, value);
621
+ + for (const [tag, attributes] of parts) {
622
+ + const node = doc.createElementNS('http://www.w3.org/2000/svg', tag);
623
+ + for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value);
624
+ + svg.append(node);
625
+ + }
626
+ + return svg;
627
+ + }
628
+ + const settings = doc.createElement('button');
629
+ + settings.type = 'button';
630
+ + settings.className = 'options-toggle';
631
+ + settings.title = options.copy.settings;
632
+ + settings.setAttribute('aria-label', options.copy.settings);
633
+ + settings.setAttribute('aria-expanded', 'false');
634
+ + settings.setAttribute('aria-haspopup', 'dialog');
635
+ + settings.append(
636
+ + icon([
637
+ + ['path', { d: 'M2.3 5h5.85M12.05 5h1.65' }],
638
+ + ['circle', { cx: '9.95', cy: '5', r: '1.45' }],
639
+ + ['path', { d: 'M2.3 11h1.65M7.85 11h5.85' }],
640
+ + ['circle', { cx: '5.75', cy: '11', r: '1.45' }],
641
+ + ]),
642
+ + );
643
+ + const optionsPanel = doc.createElement('section');
644
+ + optionsPanel.className = 'options hidden';
645
+ + optionsPanel.setAttribute('role', 'dialog');
646
+ + optionsPanel.setAttribute('aria-label', options.copy.settings);
647
+ + const intentRow = doc.createElement('div');
648
+ + intentRow.className = 'intent';
649
+ + const intentLabel = doc.createElement('span');
650
+ + intentLabel.textContent = options.copy.comment + ' / ' + options.copy.question;
651
+ + const intent = doc.createElement('select');
652
+ + intent.setAttribute('aria-label', options.copy.comment + ' / ' + options.copy.question);
653
+ + for (const [value, label] of [
654
+ + ['comment', options.copy.comment],
655
+ + ['question', options.copy.question],
656
+ + ]) {
657
+ + const option = doc.createElement('option');
658
+ + option.value = value!;
659
+ + option.textContent = label!;
660
+ + intent.append(option);
661
+ + }
662
+ + intent.value = options.intent;
663
+ + const input = doc.createElement('textarea');
664
+ + input.placeholder = options.copy.placeholder;
665
+ + input.setAttribute('aria-label', options.copy.placeholder);
666
+ + input.maxLength = 1000;
667
+ + const save = doc.createElement('button');
668
+ + save.type = 'submit';
669
+ + save.className = 'save';
670
+ + save.disabled = true;
671
+ + save.append(
672
+ + icon([
673
+ + [
674
+ + 'path',
675
+ + { d: 'M2.25 8.5L5.49732 11.7473C5.90519 12.1552 6.57263 12.1344 6.95426 11.7018L13.75 4' },
676
+ + ],
677
+ + ]),
678
+ + );
679
+ + save.title = options.copy.save;
680
+ + save.setAttribute('aria-label', options.copy.save);
681
+ + row.append(settings, input, save);
682
+ + const targetLabel = doc.createElement('span');
683
+ + targetLabel.className = 'target';
684
+ + const controls = doc.createElement('div');
685
+ + controls.className = 'controls';
686
+ + function control(label: string, glyph: string) {
687
+ + const button = doc.createElement('button');
688
+ + button.type = 'button';
689
+ + button.title = label;
690
+ + button.setAttribute('aria-label', label);
691
+ + button.textContent = glyph + ' ' + label;
692
+ + controls.append(button);
693
+ + return button;
694
+ + }
695
+ + const parent = control(options.copy.parent, '↑');
696
+ + const child = control(options.copy.child, '↓');
697
+ + const cancel = control(options.copy.cancel, '×');
698
+ + const error = doc.createElement('div');
699
+ + error.className = 'error hidden';
700
+ + error.setAttribute('role', 'alert');
701
+ + intentRow.append(intentLabel, intent);
702
+ + const targetRow = doc.createElement('div');
703
+ + targetRow.className = 'target-row';
704
+ + controls.className = 'target-controls';
705
+ + cancel.remove();
706
+ + parent.textContent = '↑';
707
+ + child.textContent = '↓';
708
+ + targetRow.append(targetLabel, controls);
709
+ + const properties = doc.createElement('div');
710
+ + properties.className = 'properties';
711
+ + const styleHint = doc.createElement('p');
712
+ + styleHint.className = 'style-hint';
713
+ + const fields = new Map<
714
+ + string,
715
+ + { input: HTMLInputElement; before: string; swatch?: HTMLElement }
716
+ + >();
717
+ + for (const [property, label] of [
718
+ + ['color', options.copy.color],
719
+ + ['background-color', options.copy.background],
720
+ + ['opacity', options.copy.opacity],
721
+ + ['font-family', options.copy.font],
722
+ + ['font-size', options.copy.fontSize],
723
+ + ['font-weight', options.copy.fontWeight],
724
+ + ['gap', options.copy.spacing],
725
+ + ]) {
726
+ + const item = doc.createElement('label');
727
+ + item.className = 'property';
728
+ + const labelText = doc.createElement('span');
729
+ + labelText.textContent = label!;
730
+ + const field = doc.createElement('div');
731
+ + field.className = 'field';
732
+ + const value = doc.createElement('input');
733
+ + value.setAttribute('aria-label', label!);
734
+ + value.dataset.property = property;
735
+ + value.maxLength = 1000;
736
+ + let swatch: HTMLElement | undefined;
737
+ + if (property === 'color' || property === 'background-color') {
738
+ + swatch = doc.createElement('span');
739
+ + swatch.className = 'swatch';
740
+ + swatch.setAttribute('aria-hidden', 'true');
741
+ + field.append(swatch);
742
+ + }
743
+ + field.append(value);
744
+ + item.append(labelText, field);
745
+ + properties.append(item);
746
+ + fields.set(property!, { input: value, before: '', ...(swatch ? { swatch } : {}) });
747
+ + }
748
+ + properties.append(styleHint);
749
+ + const footer = doc.createElement('div');
750
+ + footer.className = 'footer hidden';
751
+ + cancel.className = 'cancel';
752
+ + cancel.textContent = options.copy.cancel;
753
+ + const expandedSave = save.cloneNode(true) as HTMLButtonElement;
754
+ + footer.append(cancel, expandedSave);
755
+ + optionsPanel.append(targetRow, properties, intentRow);
756
+ + editor.append(row, optionsPanel, error, footer);
757
+ + shadow.append(style, outline, hint, editor);
758
+ + const surface = doc.createElement('div');
759
+ + surface.style.cssText =
760
+ + 'position:fixed;inset:0;pointer-events:auto;cursor:crosshair;touch-action:none';
761
+ + shadow.prepend(surface);
762
+ + doc.documentElement.append(root);
763
+ +
764
+ + let target: Element | null = null;
765
+ + let hovered: Element | null = null;
766
+ + let selection: {
767
+ + kind: 'element' | 'region' | 'point';
768
+ + rect: { x: number; y: number; width: number; height: number };
769
+ + } | null = null;
770
+ + let gesture: {
771
+ + id: number;
772
+ + x: number;
773
+ + y: number;
774
+ + clientX: number;
775
+ + clientY: number;
776
+ + dragging: boolean;
777
+ + } | null = null;
778
+ + let suspended = false;
779
+ + let suppressClick = false;
780
+ + const descendants: Element[] = [];
781
+ + let animation = 0;
782
+ + let ended = false;
783
+ + let settle!: (value: BrowserAnnotation | { cancelled: true }) => void;
784
+ + const result = new Promise<BrowserAnnotation | { cancelled: true }>((resolve) => {
785
+ + settle = resolve;
786
+ + });
787
+ + const lifecycle = new AbortController();
788
+ + function changes(): Record<string, { before: string; after: string }> {
789
+ + if (selection?.kind !== 'element') return {};
790
+ + return Object.fromEntries(
791
+ + [...fields].flatMap(([key, field]) => {
792
+ + let after = field.input.value.trim();
793
+ + if ((key === 'font-size' || key === 'gap') && /^\d+(\.\d+)?$/.test(after)) after += 'px';
794
+ + return after === field.before ? [] : [[key, { before: field.before, after }]];
795
+ + }),
796
+ + );
797
+ + }
798
+ + function refreshProperties(element: Element | null) {
799
+ + const computed = element ? getComputedStyle(element) : null;
800
+ + for (const [key, field] of fields) {
801
+ + field.before = computed?.getPropertyValue(key) || '';
802
+ + field.input.value = field.before;
803
+ + field.input.disabled = !element;
804
+ + if (field.swatch) field.swatch.style.background = field.before;
805
+ + }
806
+ + properties.classList.toggle('area', !element);
807
+ + for (const field of fields.values())
808
+ + field.input.closest('.property')?.classList.toggle('hidden', !element);
809
+ + styleHint.textContent = element ? options.copy.styleHint : options.copy.elementOnly;
810
+ + }
811
+ + for (const [key, field] of fields) {
812
+ + field.input.addEventListener(
813
+ + 'input',
814
+ + () => {
815
+ + if (field.swatch && CSS.supports(key, field.input.value))
816
+ + field.swatch.style.background = field.input.value;
817
+ + intent.value = 'comment';
818
+ + error.classList.add('hidden');
819
+ + draw();
820
+ + },
821
+ + { signal: lifecycle.signal },
822
+ + );
823
+ + field.input.addEventListener(
824
+ + 'keydown',
825
+ + (event) => {
826
+ + if (event.key === 'Enter' && !event.isComposing && event.keyCode !== 229) {
827
+ + event.preventDefault();
828
+ + event.stopPropagation();
829
+ + submit(event);
830
+ + }
831
+ + },
832
+ + { signal: lifecycle.signal },
833
+ + );
834
+ + }
835
+ + const observer = new ResizeObserver(draw);
836
+ +
837
+ + function finish(value: BrowserAnnotation | { cancelled: true }) {
838
+ + if (ended) return;
839
+ + ended = true;
840
+ + lifecycle.abort();
841
+ + cancelAnimationFrame(animation);
842
+ + observer.disconnect();
843
+ + root.remove();
844
+ + if (window.__DSH_PAGE_ANNOTATION_CANCEL__ === dismiss)
845
+ + delete window.__DSH_PAGE_ANNOTATION_CANCEL__;
846
+ + if (originalFocus instanceof HTMLElement && originalFocus.isConnected)
847
+ + originalFocus.focus({ preventScroll: true });
848
+ + settle(value);
849
+ + }
850
+ + function dismiss() {
851
+ + finish({ cancelled: true });
852
+ + }
853
+ + window.__DSH_PAGE_ANNOTATION_CANCEL__ = dismiss;
854
+ + function showError(message: string) {
855
+ + error.textContent = message;
856
+ + error.classList.remove('hidden');
857
+ + }
858
+ + function valid(element: Element | null): element is Element {
859
+ + return (
860
+ + !!element &&
861
+ + element !== doc.body &&
862
+ + element !== doc.documentElement &&
863
+ + !element.closest('[data-dsh-page-annotation]')
864
+ + );
865
+ + }
866
+ + function hit(x: number, y: number): Element | null {
867
+ + surface.style.pointerEvents = 'none';
868
+ + editor.style.pointerEvents = 'none';
869
+ + let node = doc.elementFromPoint(x, y);
870
+ + while (node?.shadowRoot) {
871
+ + const inner = node.shadowRoot.elementFromPoint(x, y);
872
+ + if (!inner || inner === node) break;
873
+ + node = inner;
874
+ + }
875
+ + surface.style.pointerEvents = suspended ? 'none' : 'auto';
876
+ + editor.style.pointerEvents = 'auto';
877
+ + return valid(node) ? node : null;
878
+ + }
879
+ + function rectInDocument(element: Element) {
880
+ + const rect = element.getBoundingClientRect();
881
+ + return { x: rect.x + scrollX, y: rect.y + scrollY, width: rect.width, height: rect.height };
882
+ + }
883
+ + function gestureRect(x: number, y: number) {
884
+ + return {
885
+ + x: Math.min(gesture!.x, x),
886
+ + y: Math.min(gesture!.y, y),
887
+ + width: Math.max(1, Math.abs(x - gesture!.x)),
888
+ + height: Math.max(1, Math.abs(y - gesture!.y)),
889
+ + };
890
+ + }
891
+ + function selector(element: Element): string {
892
+ + const scope = element.getRootNode() as Document | ShadowRoot;
893
+ + if (element.id) {
894
+ + const id = '#' + CSS.escape(element.id);
895
+ + if (scope.querySelectorAll(id).length === 1) return id;
896
+ + }
897
+ + const pieces: string[] = [];
898
+ + let node: Element | null = element;
899
+ + while (node) {
900
+ + let part = node.localName;
901
+ + const peers = node.parentElement
902
+ + ? Array.from(node.parentElement.children).filter((x) => x.localName === node!.localName)
903
+ + : [];
904
+ + if (peers.length > 1) part += `:nth-of-type(${peers.indexOf(node) + 1})`;
905
+ + pieces.unshift(part);
906
+ + const path = pieces.join(' > ');
907
+ + if (scope.querySelectorAll(path).length === 1 && scope.querySelector(path) === element)
908
+ + return path;
909
+ + node = node.parentElement;
910
+ + }
911
+ + return pieces.join(' > ');
912
+ + }
913
+ + function locator(element: Element) {
914
+ + const shadowHosts: string[] = [];
915
+ + let scope = element.getRootNode();
916
+ + while (scope instanceof ShadowRoot) {
917
+ + shadowHosts.unshift(selector(scope.host));
918
+ + scope = scope.host.getRootNode();
919
+ + }
920
+ + return {
921
+ + selector: selector(element),
922
+ + shadowHosts,
923
+ + matchCount: (element.getRootNode() as Document | ShadowRoot).querySelectorAll(
924
+ + selector(element),
925
+ + ).length,
926
+ + };
927
+ + }
928
+ + function draw() {
929
+ + if (ended) return;
930
+ + if (location.href !== originalUrl) {
931
+ + dismiss();
932
+ + return;
933
+ + }
934
+ + if (suspended) return;
935
+ + const node = target || hovered;
936
+ + if (selection?.kind === 'element' && !target?.isConnected) {
937
+ + outline.classList.add('hidden');
938
+ + showError(options.copy.targetGone);
939
+ + save.disabled = expandedSave.disabled = true;
940
+ + return;
941
+ + }
942
+ + save.disabled = expandedSave.disabled =
943
+ + !selection || (!input.value.trim() && !Object.keys(changes()).length);
944
+ + const documentRect =
945
+ + selection?.kind === 'element' && target
946
+ + ? rectInDocument(target)
947
+ + : selection?.rect || (node?.isConnected ? rectInDocument(node) : null);
948
+ + if (!documentRect) {
949
+ + outline.classList.add('hidden');
950
+ + return;
951
+ + }
952
+ + const rect = {
953
+ + left: documentRect.x - scrollX,
954
+ + top: documentRect.y - scrollY,
955
+ + width: documentRect.width,
956
+ + height: documentRect.height,
957
+ + bottom: documentRect.y - scrollY + documentRect.height,
958
+ + };
959
+ + outline.classList.remove('hidden');
960
+ + Object.assign(outline.style, {
961
+ + left: `${rect.left - (selection?.kind === 'point' ? 5 : 0)}px`,
962
+ + top: `${rect.top - (selection?.kind === 'point' ? 5 : 0)}px`,
963
+ + width: `${selection?.kind === 'point' ? 10 : rect.width}px`,
964
+ + height: `${selection?.kind === 'point' ? 10 : rect.height}px`,
965
+ + borderStyle: selection?.kind === 'region' ? 'dashed' : 'solid',
966
+ + });
967
+ + if (editor.classList.contains('hidden')) return;
968
+ + const width = editor.getBoundingClientRect().width;
969
+ + const height = editor.getBoundingClientRect().height;
970
+ + const left = Math.max(12, Math.min(rect.left, innerWidth - width - 12));
971
+ + const below = rect.bottom + 8;
972
+ + const above = rect.top - height - 8;
973
+ + const top = Math.max(
974
+ + 12,
975
+ + Math.min(
976
+ + below + height <= innerHeight - 12 ? below : above >= 12 ? above : rect.top + 8,
977
+ + innerHeight - height - 12,
978
+ + ),
979
+ + );
980
+ + Object.assign(editor.style, { left: `${left}px`, top: `${top}px` });
981
+ + }
982
+ + function setOptions(open: boolean, restoreFocus = false) {
983
+ + optionsPanel.classList.toggle('hidden', !open);
984
+ + editor.classList.toggle('expanded', open);
985
+ + footer.classList.toggle('hidden', !open);
986
+ + settings.setAttribute('aria-expanded', String(open));
987
+ + draw();
988
+ + if (open) settings.focus({ preventScroll: true });
989
+ + else if (restoreFocus) settings.focus({ preventScroll: true });
990
+ + }
991
+ + settings.addEventListener(
992
+ + 'click',
993
+ + () => {
994
+ + setOptions(optionsPanel.classList.contains('hidden'));
995
+ + },
996
+ + { signal: lifecycle.signal },
997
+ + );
998
+ + intent.addEventListener(
999
+ + 'change',
1000
+ + () => {
1001
+ + setOptions(false);
1002
+ + input.focus({ preventScroll: true });
1003
+ + },
1004
+ + { signal: lifecycle.signal },
1005
+ + );
1006
+ + input.addEventListener(
1007
+ + 'input',
1008
+ + () => {
1009
+ + input.style.height = '22px';
1010
+ + input.style.height = `${Math.min(110, input.scrollHeight)}px`;
1011
+ + error.classList.add('hidden');
1012
+ + draw();
1013
+ + },
1014
+ + { signal: lifecycle.signal },
1015
+ + );
1016
+ + function track() {
1017
+ + draw();
1018
+ + if (!ended) animation = requestAnimationFrame(track);
1019
+ + }
1020
+ + function choose(element: Element) {
1021
+ + if (!valid(element)) return;
1022
+ + observer.disconnect();
1023
+ + target = element;
1024
+ + selection = { kind: 'element', rect: rectInDocument(element) };
1025
+ + observer.observe(element);
1026
+ + hint.classList.add('hidden');
1027
+ + editor.classList.remove('hidden');
1028
+ + error.classList.add('hidden');
1029
+ + const where = locator(element);
1030
+ + targetLabel.textContent = `<${element.localName}>`;
1031
+ + targetLabel.title = [...where.shadowHosts, where.selector].join(' › ');
1032
+ + refreshProperties(element);
1033
+ + parent.disabled =
1034
+ + !valid(element.parentElement) && !(element.getRootNode() instanceof ShadowRoot);
1035
+ + child.disabled = descendants.length === 0;
1036
+ + draw();
1037
+ + if (!animation) animation = requestAnimationFrame(track);
1038
+ + input.focus({ preventScroll: true });
1039
+ + }
1040
+ + function chooseArea(
1041
+ + kind: 'region' | 'point',
1042
+ + rect: { x: number; y: number; width: number; height: number },
1043
+ + ) {
1044
+ + observer.disconnect();
1045
+ + target = null;
1046
+ + selection = { kind, rect };
1047
+ + hint.classList.add('hidden');
1048
+ + editor.classList.remove('hidden');
1049
+ + error.classList.add('hidden');
1050
+ + targetLabel.textContent =
1051
+ + kind === 'region'
1052
+ + ? `${options.copy.region} · ${Math.round(rect.width)} × ${Math.round(rect.height)}`
1053
+ + : options.copy.point;
1054
+ + parent.disabled = child.disabled = true;
1055
+ + targetLabel.title = '';
1056
+ + refreshProperties(null);
1057
+ + draw();
1058
+ + if (!animation) animation = requestAnimationFrame(track);
1059
+ + input.focus({ preventScroll: true });
1060
+ + }
1061
+ + function isOverlay(event: Event) {
1062
+ + return event.composedPath().includes(root);
1063
+ + }
1064
+ + window.addEventListener(
1065
+ + 'pointermove',
1066
+ + (event) => {
1067
+ + if (suspended || !editor.classList.contains('hidden')) return;
1068
+ + if (gesture && gesture.id === event.pointerId) {
1069
+ + gesture.dragging ||=
1070
+ + Math.hypot(event.clientX - gesture.clientX, event.clientY - gesture.clientY) >= 5;
1071
+ + if (gesture.dragging) {
1072
+ + selection = {
1073
+ + kind: 'region',
1074
+ + rect: gestureRect(event.clientX + scrollX, event.clientY + scrollY),
1075
+ + };
1076
+ + draw();
1077
+ + return;
1078
+ + }
1079
+ + }
1080
+ + const node = hit(event.clientX, event.clientY);
1081
+ + hovered = event.shiftKey && valid(node?.parentElement || null) ? node!.parentElement : node;
1082
+ + draw();
1083
+ + },
1084
+ + { capture: true, signal: lifecycle.signal },
1085
+ + );
1086
+ + window.addEventListener(
1087
+ + 'pointerdown',
1088
+ + (event) => {
1089
+ + if (suspended) {
1090
+ + suppressClick = false;
1091
+ + return;
1092
+ + }
1093
+ + if (isOverlay(event) && !event.composedPath().includes(surface)) return;
1094
+ + setOptions(false);
1095
+ + event.preventDefault();
1096
+ + event.stopImmediatePropagation();
1097
+ + suppressClick = true;
1098
+ + if (event.button !== 0 || !editor.classList.contains('hidden')) return;
1099
+ + gesture = {
1100
+ + id: event.pointerId,
1101
+ + x: event.clientX + scrollX,
1102
+ + y: event.clientY + scrollY,
1103
+ + clientX: event.clientX,
1104
+ + clientY: event.clientY,
1105
+ + dragging: false,
1106
+ + };
1107
+ + surface.setPointerCapture(event.pointerId);
1108
+ + },
1109
+ + { capture: true, signal: lifecycle.signal },
1110
+ + );
1111
+ + window.addEventListener(
1112
+ + 'pointerup',
1113
+ + (event) => {
1114
+ + if (!gesture || gesture.id !== event.pointerId || suspended) return;
1115
+ + event.preventDefault();
1116
+ + event.stopImmediatePropagation();
1117
+ + const current = gesture;
1118
+ + const area = gestureRect(event.clientX + scrollX, event.clientY + scrollY);
1119
+ + gesture = null;
1120
+ + if (surface.hasPointerCapture(event.pointerId))
1121
+ + surface.releasePointerCapture(event.pointerId);
1122
+ + if (
1123
+ + current.dragging ||
1124
+ + Math.hypot(event.clientX - current.clientX, event.clientY - current.clientY) >= 5
1125
+ + )
1126
+ + chooseArea('region', area);
1127
+ + else {
1128
+ + const node = hit(event.clientX, event.clientY);
1129
+ + descendants.length = 0;
1130
+ + if (node) choose(event.shiftKey && valid(node.parentElement) ? node.parentElement : node);
1131
+ + else
1132
+ + chooseArea('point', {
1133
+ + x: event.clientX + scrollX,
1134
+ + y: event.clientY + scrollY,
1135
+ + width: 0,
1136
+ + height: 0,
1137
+ + });
1138
+ + }
1139
+ + },
1140
+ + { capture: true, signal: lifecycle.signal },
1141
+ + );
1142
+ + function abortGesture() {
1143
+ + if (!gesture) return;
1144
+ + if (surface.hasPointerCapture(gesture.id)) surface.releasePointerCapture(gesture.id);
1145
+ + gesture = null;
1146
+ + if (editor.classList.contains('hidden')) selection = null;
1147
+ + draw();
1148
+ + }
1149
+ + window.addEventListener('pointercancel', abortGesture, {
1150
+ + capture: true,
1151
+ + signal: lifecycle.signal,
1152
+ + });
1153
+ + window.addEventListener(
1154
+ + 'click',
1155
+ + (event) => {
1156
+ + if (isOverlay(event) && !event.composedPath().includes(surface)) return;
1157
+ + if (suspended && !suppressClick) return;
1158
+ + event.preventDefault();
1159
+ + event.stopImmediatePropagation();
1160
+ + suppressClick = false;
1161
+ + },
1162
+ + { capture: true, signal: lifecycle.signal },
1163
+ + );
1164
+ + function resume() {
1165
+ + if (!suspended) return;
1166
+ + suspended = false;
1167
+ + surface.style.pointerEvents = 'auto';
1168
+ + hint.textContent = options.copy.select;
1169
+ + if (selection) {
1170
+ + editor.classList.remove('hidden');
1171
+ + hint.classList.add('hidden');
1172
+ + } else hint.classList.remove('hidden');
1173
+ + draw();
1174
+ + }
1175
+ + window.addEventListener(
1176
+ + 'keydown',
1177
+ + (event) => {
1178
+ + if (event.isComposing || event.keyCode === 229) return;
1179
+ + if (event.key === 'Escape') {
1180
+ + event.preventDefault();
1181
+ + event.stopImmediatePropagation();
1182
+ + if (optionsPanel.classList.contains('hidden')) dismiss();
1183
+ + else setOptions(false, true);
1184
+ + return;
1185
+ + }
1186
+ + // The comment editor owns spaces. The page receives normal keys while suspended.
1187
+ + if (isOverlay(event) && !event.composedPath().includes(surface)) return;
1188
+ + if (event.code === 'Space' && !event.altKey && !event.ctrlKey && !event.metaKey) {
1189
+ + event.preventDefault();
1190
+ + event.stopImmediatePropagation();
1191
+ + abortGesture();
1192
+ + suspended = true;
1193
+ + setOptions(false);
1194
+ + surface.style.pointerEvents = 'none';
1195
+ + outline.classList.add('hidden');
1196
+ + editor.classList.add('hidden');
1197
+ + hint.classList.remove('hidden');
1198
+ + hint.textContent = options.copy.interact;
1199
+ + } else if (!suspended && !isOverlay(event)) {
1200
+ + event.preventDefault();
1201
+ + event.stopImmediatePropagation();
1202
+ + }
1203
+ + },
1204
+ + { capture: true, signal: lifecycle.signal },
1205
+ + );
1206
+ + window.addEventListener(
1207
+ + 'keyup',
1208
+ + (event) => {
1209
+ + if (event.code === 'Space' && suspended) {
1210
+ + event.preventDefault();
1211
+ + event.stopImmediatePropagation();
1212
+ + resume();
1213
+ + }
1214
+ + },
1215
+ + { capture: true, signal: lifecycle.signal },
1216
+ + );
1217
+ + window.addEventListener(
1218
+ + 'blur',
1219
+ + () => {
1220
+ + abortGesture();
1221
+ + resume();
1222
+ + },
1223
+ + { signal: lifecycle.signal },
1224
+ + );
1225
+ + doc.addEventListener(
1226
+ + 'visibilitychange',
1227
+ + () => {
1228
+ + if (doc.hidden) {
1229
+ + abortGesture();
1230
+ + resume();
1231
+ + }
1232
+ + },
1233
+ + { signal: lifecycle.signal },
1234
+ + );
1235
+ + window.addEventListener('pagehide', dismiss, { once: true, signal: lifecycle.signal });
1236
+ + parent.addEventListener(
1237
+ + 'click',
1238
+ + () => {
1239
+ + if (!target) return;
1240
+ + const scope = target.getRootNode();
1241
+ + const node = target.parentElement || (scope instanceof ShadowRoot ? scope.host : null);
1242
+ + if (valid(node)) {
1243
+ + setOptions(false);
1244
+ + descendants.push(target);
1245
+ + choose(node);
1246
+ + }
1247
+ + },
1248
+ + { signal: lifecycle.signal },
1249
+ + );
1250
+ + child.addEventListener(
1251
+ + 'click',
1252
+ + () => {
1253
+ + const node = descendants.pop();
1254
+ + if (node?.isConnected) {
1255
+ + setOptions(false);
1256
+ + choose(node);
1257
+ + }
1258
+ + },
1259
+ + { signal: lifecycle.signal },
1260
+ + );
1261
+ + cancel.addEventListener('click', dismiss, { signal: lifecycle.signal });
1262
+ + function evidence(element: Element): BrowserAnnotationElement {
1263
+ + const where = locator(element);
1264
+ + const computed = getComputedStyle(element);
1265
+ + const styles: Record<string, string> = {};
1266
+ + for (const name of [
1267
+ + 'display',
1268
+ + 'position',
1269
+ + 'font-size',
1270
+ + 'font-family',
1271
+ + 'color',
1272
+ + 'background-color',
1273
+ + 'padding',
1274
+ + 'margin',
1275
+ + 'gap',
1276
+ + 'opacity',
1277
+ + 'font-weight',
1278
+ + ])
1279
+ + styles[name] = computed.getPropertyValue(name).slice(0, 1000);
1280
+ + const source = element
1281
+ + .closest('[data-web-annotator-source]')
1282
+ + ?.getAttribute('data-web-annotator-source')
1283
+ + ?.slice(0, 1000);
1284
+ + return {
1285
+ + ...where,
1286
+ + tag: element.localName,
1287
+ + text: (element instanceof HTMLElement ? element.innerText : element.textContent || '')
1288
+ + .replace(/\s+/g, ' ')
1289
+ + .trim()
1290
+ + .slice(0, 400),
1291
+ + rect: rectInDocument(element),
1292
+ + styles,
1293
+ + ...(source ? { source } : {}),
1294
+ + };
1295
+ + }
1296
+ + function candidates(rect: { x: number; y: number; width: number; height: number }) {
1297
+ + const found = new Set<Element>();
1298
+ + // Bounded samples also reach open Shadow DOM and avoid scanning the whole page.
1299
+ + for (let row = 0; row < 5; row++)
1300
+ + for (let col = 0; col < 5; col++) {
1301
+ + const node = hit(
1302
+ + rect.x - scrollX + (rect.width * (col + 0.5)) / 5,
1303
+ + rect.y - scrollY + (rect.height * (row + 0.5)) / 5,
1304
+ + );
1305
+ + if (node) found.add(node);
1306
+ + }
1307
+ + return [...found].slice(0, 12).map(evidence);
1308
+ + }
1309
+ + function submit(event: Event) {
1310
+ + event.preventDefault();
1311
+ + if (
1312
+ + !selection ||
1313
+ + (selection.kind === 'element' && !target?.isConnected) ||
1314
+ + location.href !== originalUrl
1315
+ + ) {
1316
+ + showError(options.copy.targetGone);
1317
+ + return;
1318
+ + }
1319
+ + const styleChanges = changes();
1320
+ + for (const [property, { after }] of Object.entries(styleChanges)) {
1321
+ + if (
1322
+ + !CSS.supports(property, after) ||
1323
+ + (property === 'opacity' && !(Number(after) >= 0 && Number(after) <= 1))
1324
+ + ) {
1325
+ + showError(options.copy.invalidStyle);
1326
+ + setOptions(true);
1327
+ + fields.get(property)?.input.focus();
1328
+ + return;
1329
+ + }
1330
+ + }
1331
+ + if (!input.value.trim() && !Object.keys(styleChanges).length) {
1332
+ + showError(options.copy.empty);
1333
+ + input.focus();
1334
+ + return;
1335
+ + }
1336
+ + const rect = selection.kind === 'element' ? rectInDocument(target!) : selection.rect;
1337
+ + const items = selection.kind === 'element' ? [evidence(target!)] : candidates(rect);
1338
+ + finish({
1339
+ + intent: intent.value === 'question' ? 'question' : 'comment',
1340
+ + note: input.value.trim() || options.copy.applyStyles,
1341
+ + ...(Object.keys(styleChanges).length ? { styleChanges } : {}),
1342
+ + url: originalUrl,
1343
+ + title: doc.title.slice(0, 300),
1344
+ + viewport: { width: innerWidth, height: innerHeight },
1345
+ + selection: {
1346
+ + kind: selection.kind,
1347
+ + rect,
1348
+ + viewportRect: { ...rect, x: rect.x - scrollX, y: rect.y - scrollY },
1349
+ + scroll: { x: scrollX, y: scrollY },
1350
+ + candidates: items,
1351
+ + },
1352
+ + });
1353
+ + }
1354
+ + editor.addEventListener('submit', submit, { signal: lifecycle.signal });
1355
+ + input.addEventListener(
1356
+ + 'keydown',
1357
+ + (event) => {
1358
+ + if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) {
1359
+ + event.preventDefault();
1360
+ + event.stopPropagation();
1361
+ + submit(event);
1362
+ + }
1363
+ + },
1364
+ + { signal: lifecycle.signal },
1365
+ + );
1366
+ + animation = requestAnimationFrame(track);
1367
+ + return result;
1368
+ +}
1369
+ diff --git a/packages/client/ui-sidebar-browser/src/client/annotation/protocol.ts b/packages/client/ui-sidebar-browser/src/client/annotation/protocol.ts
1370
+ new file mode 100644
1371
+ index 0000000..2861153
1372
+ --- /dev/null
1373
+ +++ b/packages/client/ui-sidebar-browser/src/client/annotation/protocol.ts
1374
+ @@ -0,0 +1,169 @@
1375
+ +/** Validation for page and guest process results, not same-process slot props. */
1376
+ +import type { BrowserAnnotation, BrowserAnnotationOptions } from './page-inspector.ts';
1377
+ +
1378
+ +/** Result of one annotation operation. */
1379
+ +export type AnnotationResult = BrowserAnnotation | { cancelled: true };
1380
+ +
1381
+ +function object(value: unknown): value is Record<string, unknown> {
1382
+ + return !!value && typeof value === 'object' && !Array.isArray(value);
1383
+ +}
1384
+ +function string(value: unknown, limit: number): value is string {
1385
+ + return typeof value === 'string' && value.length <= limit;
1386
+ +}
1387
+ +function number(value: unknown): value is number {
1388
+ + return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= 1e7;
1389
+ +}
1390
+ +function rect(value: unknown): boolean {
1391
+ + return (
1392
+ + object(value) &&
1393
+ + number(value.x) &&
1394
+ + number(value.y) &&
1395
+ + number(value.width) &&
1396
+ + number(value.height) &&
1397
+ + value.width >= 0 &&
1398
+ + value.height >= 0
1399
+ + );
1400
+ +}
1401
+ +/** Validate bounded JSON evidence received from a visited document.
1402
+ + * @param value - process or MessagePort result.
1403
+ + * @returns whether the result can be retained and rendered.
1404
+ + */
1405
+ +export function isAnnotationResult(value: unknown): value is AnnotationResult {
1406
+ + if (!object(value)) return false;
1407
+ + if (value.cancelled === true) return Object.keys(value).length === 1;
1408
+ + if (
1409
+ + (value.intent !== 'comment' && value.intent !== 'question') ||
1410
+ + !string(value.note, 1000) ||
1411
+ + !value.note.trim() ||
1412
+ + !string(value.url, 8192) ||
1413
+ + !string(value.title, 300)
1414
+ + )
1415
+ + return false;
1416
+ + if (
1417
+ + value.styleChanges !== undefined &&
1418
+ + (!object(value.styleChanges) ||
1419
+ + Object.keys(value.styleChanges).length > 10 ||
1420
+ + !Object.entries(value.styleChanges).every(
1421
+ + ([key, change]) =>
1422
+ + [
1423
+ + 'color',
1424
+ + 'background-color',
1425
+ + 'opacity',
1426
+ + 'font-family',
1427
+ + 'font-size',
1428
+ + 'font-weight',
1429
+ + 'padding',
1430
+ + 'margin',
1431
+ + 'gap',
1432
+ + ].includes(key) &&
1433
+ + object(change) &&
1434
+ + string(change.before, 1000) &&
1435
+ + string(change.after, 1000),
1436
+ + ))
1437
+ + )
1438
+ + return false;
1439
+ + if (
1440
+ + value.screenshot !== undefined &&
1441
+ + (!object(value.screenshot) ||
1442
+ + value.screenshot.mediaType !== 'image/jpeg' ||
1443
+ + !string(value.screenshot.data, 4000000) ||
1444
+ + !/^[A-Za-z0-9+/]+={0,2}$/.test(value.screenshot.data) ||
1445
+ + !number(value.screenshot.width) ||
1446
+ + !number(value.screenshot.height) ||
1447
+ + value.screenshot.width <= 0 ||
1448
+ + value.screenshot.width > 1600 ||
1449
+ + value.screenshot.height <= 0 ||
1450
+ + value.screenshot.height > 1600)
1451
+ + )
1452
+ + return false;
1453
+ + try {
1454
+ + if (!['http:', 'https:'].includes(new URL(value.url).protocol)) return false;
1455
+ + } catch {
1456
+ + return false;
1457
+ + }
1458
+ + if (
1459
+ + !object(value.viewport) ||
1460
+ + !number(value.viewport.width) ||
1461
+ + !number(value.viewport.height) ||
1462
+ + value.viewport.width <= 0 ||
1463
+ + value.viewport.height <= 0
1464
+ + )
1465
+ + return false;
1466
+ + const selection = value.selection;
1467
+ + if (
1468
+ + !object(selection) ||
1469
+ + !['element', 'region', 'point'].includes(String(selection.kind)) ||
1470
+ + !rect(selection.rect) ||
1471
+ + !rect(selection.viewportRect) ||
1472
+ + !object(selection.scroll) ||
1473
+ + !number(selection.scroll.x) ||
1474
+ + !number(selection.scroll.y) ||
1475
+ + !Array.isArray(selection.candidates) ||
1476
+ + selection.candidates.length > 12
1477
+ + )
1478
+ + return false;
1479
+ + if (selection.kind === 'element' && selection.candidates.length !== 1) return false;
1480
+ + for (const item of selection.candidates) {
1481
+ + if (
1482
+ + !object(item) ||
1483
+ + !string(item.selector, 8192) ||
1484
+ + !Array.isArray(item.shadowHosts) ||
1485
+ + item.shadowHosts.length > 32 ||
1486
+ + !item.shadowHosts.every((x) => string(x, 8192)) ||
1487
+ + item.matchCount !== 1 ||
1488
+ + !string(item.tag, 200) ||
1489
+ + !string(item.text, 400) ||
1490
+ + !rect(item.rect) ||
1491
+ + !object(item.styles) ||
1492
+ + Object.keys(item.styles).length > 16 ||
1493
+ + !Object.entries(item.styles).every(([key, val]) => string(key, 100) && string(val, 1000)) ||
1494
+ + (item.source !== undefined && !string(item.source, 1000))
1495
+ + )
1496
+ + return false;
1497
+ + }
1498
+ + try {
1499
+ + return JSON.stringify({ ...value, screenshot: undefined }).length <= 64000;
1500
+ + } catch {
1501
+ + return false;
1502
+ + }
1503
+ +}
1504
+ +
1505
+ +/** Validate picker configuration crossing the iframe bridge.
1506
+ + * @param value - parent message options.
1507
+ + * @returns whether all localized fields are bounded strings.
1508
+ + */
1509
+ +export function isAnnotationOptions(value: unknown): value is BrowserAnnotationOptions {
1510
+ + return (
1511
+ + object(value) &&
1512
+ + (value.intent === 'comment' || value.intent === 'question') &&
1513
+ + typeof value.dark === 'boolean' &&
1514
+ + object(value.copy) &&
1515
+ + [
1516
+ + 'select',
1517
+ + 'comment',
1518
+ + 'question',
1519
+ + 'placeholder',
1520
+ + 'settings',
1521
+ + 'save',
1522
+ + 'cancel',
1523
+ + 'parent',
1524
+ + 'child',
1525
+ + 'targetGone',
1526
+ + 'empty',
1527
+ + 'region',
1528
+ + 'point',
1529
+ + 'interact',
1530
+ + 'color',
1531
+ + 'background',
1532
+ + 'opacity',
1533
+ + 'font',
1534
+ + 'fontSize',
1535
+ + 'fontWeight',
1536
+ + 'spacing',
1537
+ + 'elementOnly',
1538
+ + 'invalidStyle',
1539
+ + 'applyStyles',
1540
+ + 'styleHint',
1541
+ + ].every((key) => string(value.copy && object(value.copy) ? value.copy[key] : null, 500))
1542
+ + );
1543
+ +}
1544
+ diff --git a/packages/client/ui-sidebar-browser/src/client/annotation/screenshot.ts b/packages/client/ui-sidebar-browser/src/client/annotation/screenshot.ts
1545
+ new file mode 100644
1546
+ index 0000000..f8bf8b6
1547
+ --- /dev/null
1548
+ +++ b/packages/client/ui-sidebar-browser/src/client/annotation/screenshot.ts
1549
+ @@ -0,0 +1,49 @@
1550
+ +/** Render a provider-captured viewport with its exact saved selection. */
1551
+ +import type { BrowserAnnotation, BrowserAnnotationImage } from './page-inspector.ts';
1552
+ +
1553
+ +/** Mark the saved rectangle on actual captured pixels and normalize to a bounded JPEG.
1554
+ + * @param dataUrl - Browser capture of the inspected viewport, after picker cleanup.
1555
+ + * @param annotation - saved viewport coordinates.
1556
+ + * @returns image bytes ready for the official Session image attachment path.
1557
+ + */
1558
+ +export async function markScreenshot(
1559
+ + dataUrl: string,
1560
+ + annotation: BrowserAnnotation,
1561
+ +): Promise<BrowserAnnotationImage> {
1562
+ + const picture = new Image();
1563
+ + picture.src = dataUrl;
1564
+ + await picture.decode();
1565
+ + if (!picture.naturalWidth || !picture.naturalHeight) throw new Error('annotation-image-failed');
1566
+ + const scale = Math.min(1, 1600 / Math.max(picture.naturalWidth, picture.naturalHeight));
1567
+ + const canvas = document.createElement('canvas');
1568
+ + canvas.width = Math.round(picture.naturalWidth * scale);
1569
+ + canvas.height = Math.round(picture.naturalHeight * scale);
1570
+ + const context = canvas.getContext('2d');
1571
+ + if (!context) throw new Error('annotation-image-failed');
1572
+ + context.drawImage(picture, 0, 0, canvas.width, canvas.height);
1573
+ + const sx = canvas.width / annotation.viewport.width;
1574
+ + const sy = canvas.height / annotation.viewport.height;
1575
+ + const area = annotation.selection.viewportRect;
1576
+ + context.strokeStyle = '#1683ff';
1577
+ + context.fillStyle = '#1683ff';
1578
+ + context.lineWidth = 2;
1579
+ + const x = area.x * sx,
1580
+ + y = area.y * sy;
1581
+ + if (annotation.selection.kind === 'point') {
1582
+ + if (x < 0 || y < 0 || x > canvas.width || y > canvas.height)
1583
+ + throw new Error('annotation-image-failed');
1584
+ + context.beginPath();
1585
+ + context.arc(x, y, 7, 0, Math.PI * 2);
1586
+ + context.stroke();
1587
+ + } else {
1588
+ + const left = Math.max(1, x),
1589
+ + top = Math.max(1, y);
1590
+ + const right = Math.min(canvas.width - 1, x + area.width * sx);
1591
+ + const bottom = Math.min(canvas.height - 1, y + area.height * sy);
1592
+ + if (right <= left || bottom <= top) throw new Error('annotation-image-failed');
1593
+ + context.strokeRect(left, top, right - left, bottom - top);
1594
+ + }
1595
+ + const data = canvas.toDataURL('image/jpeg', 0.8).split(',')[1]!;
1596
+ + if (data.length > 4000000) throw new Error('annotation-image-failed');
1597
+ + return { mediaType: 'image/jpeg', data, width: canvas.width, height: canvas.height };
1598
+ +}
1599
+ diff --git a/packages/client/ui-sidebar-browser/src/client/annotation/slots.ts b/packages/client/ui-sidebar-browser/src/client/annotation/slots.ts
1600
+ new file mode 100644
1601
+ index 0000000..18856f0
1602
+ --- /dev/null
1603
+ +++ b/packages/client/ui-sidebar-browser/src/client/annotation/slots.ts
1604
+ @@ -0,0 +1,24 @@
1605
+ +/** Browser-owned extension positions expose data and bounded picker callbacks. */
1606
+ +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
1607
+ +import type { BrowserAnnotationOptions } from './page-inspector.ts'
1608
+ +import type { AnnotationResult } from './protocol.ts'
1609
+ +
1610
+ +/** Owner data for toolbar additions and the annotation queue. */
1611
+ +export interface BrowserAnnotationOwner {
1612
+ + readonly tabId: TabId
1613
+ + readonly url: string | undefined
1614
+ + readonly loading: boolean
1615
+ + /** @param options - localized picker configuration. @returns saved evidence or cancellation. */
1616
+ + readonly annotate: (options: BrowserAnnotationOptions) => Promise<AnnotationResult>
1617
+ + /** Cancel only the active picker for this tab. */
1618
+ + readonly cancelAnnotation: () => void
1619
+ +}
1620
+ +
1621
+ +declare module '@deepseek-ai/dsh-client-ui-slots' {
1622
+ + interface SlotMap {
1623
+ + /** Additional Browser toolbar controls; consumers never receive a DOM handle. */
1624
+ + 'sidebar.right.tab.browser.toolbar': { kind: 'list'; scope: 'session'; owner: BrowserAnnotationOwner }
1625
+ + /** Retained annotation review below the existing Browser viewport. */
1626
+ + 'sidebar.right.tab.browser.annotations': { kind: 'list'; scope: 'session'; owner: BrowserAnnotationOwner }
1627
+ + }
1628
+ +}
1629
+ diff --git a/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts b/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts
1630
+ index 45d17bb..1dcf3fa 100644
1631
+ --- a/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts
1632
+ +++ b/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts
1633
+ @@ -7,6 +7,8 @@ import type { BrowserPage, BrowserPageFactory } from './BrowserPage.ts'
1634
+ import { currentBrowserTarget, type BrowserTabState } from './BrowserPersistence.ts'
1635
+ import type { BrowserStore } from './store.ts'
1636
+ import { parseBrowserAddress, type BrowserAddressFailure, type BrowserTarget } from './url.ts'
1637
+ +import type { BrowserAnnotationOptions } from '../annotation/page-inspector.ts'
1638
+ +import type { AnnotationResult } from '../annotation/protocol.ts'
1639
+
1640
+ /** Live tab state; navigation comes from its provider and draft validation stays local. */
1641
+ export interface BrowserControllerState {
1642
+ @@ -38,6 +40,7 @@ export class BrowserController implements HostObservable<BrowserControllerState>
1643
+ private started = false
1644
+ private disposed = false
1645
+ private disposal: Promise<void> | undefined
1646
+ + private annotation: AbortController | undefined
1647
+ private readonly abort = (): void => { void this.dispose() }
1648
+
1649
+ /** @param options - identity, persistence, page factory and source-tab navigation. */
1650
+ @@ -84,9 +87,28 @@ export class BrowserController implements HostObservable<BrowserControllerState>
1651
+ */
1652
+ mount(viewportId: string): () => void {
1653
+ this.publishSaved()
1654
+ - return this.page.presentation.mount(viewportId)
1655
+ + const unmount = this.page.presentation.mount(viewportId)
1656
+ + return () => { this.cancelAnnotation(); unmount() }
1657
+ }
1658
+
1659
+ + /** @param options - localized picker configuration. @returns saved evidence or cancellation. */
1660
+ + async annotate(options: BrowserAnnotationOptions): Promise<AnnotationResult> {
1661
+ + this.cancelAnnotation()
1662
+ + const frame = this.page.frame.getSnapshot()
1663
+ + if (this.disposed || frame.loading || frame.address === 'unknown' || frame.target === undefined || this.page.presentation.annotate === undefined) throw new Error('annotation-unavailable')
1664
+ + const lifetime = new AbortController()
1665
+ + this.annotation = lifetime
1666
+ + try {
1667
+ + const result = await this.page.presentation.annotate(frame.target.url, options, lifetime.signal)
1668
+ + return lifetime.signal.aborted ? { cancelled: true } : result
1669
+ + } finally {
1670
+ + if (this.annotation === lifetime) this.annotation = undefined
1671
+ + }
1672
+ + }
1673
+ +
1674
+ + /** Cancel the current tab's picker. */
1675
+ + cancelAnnotation(): void { this.annotation?.abort(); this.annotation = undefined }
1676
+ +
1677
+ /**
1678
+ * Consume initial navigation once; a saved checkpoint alone never starts a page.
1679
+ * @param initialUrl - explicit typed-open address, or absence.
1680
+ @@ -146,6 +168,7 @@ export class BrowserController implements HostObservable<BrowserControllerState>
1681
+ dispose(): Promise<void> {
1682
+ if (this.disposal !== undefined) return this.disposal
1683
+ this.disposed = true
1684
+ + this.cancelAnnotation()
1685
+ this.options.signal.removeEventListener('abort', this.abort)
1686
+ this.unsubscribe()
1687
+ this.disposal = this.page.frame.dispose()
1688
+ @@ -162,6 +185,7 @@ export class BrowserController implements HostObservable<BrowserControllerState>
1689
+
1690
+ private command(run: () => void): void {
1691
+ if (this.disposed) return
1692
+ + this.cancelAnnotation()
1693
+ const current = this.store.getSnapshot()
1694
+ this.store.set({ ...current, addressFailure: undefined, addressRevision: current.addressRevision + 1 })
1695
+ run()
1696
+ @@ -181,6 +205,10 @@ export interface BrowserMountRequest {
1697
+
1698
+ /** Plain Slot callbacks and a framework-bound state source, not a desktop protocol. */
1699
+ export interface BrowserInjected {
1700
+ + /** @param tabId - tab occurrence. @param options - picker configuration. @returns saved evidence or cancellation. */
1701
+ + annotate(tabId: TabId, options: BrowserAnnotationOptions): Promise<AnnotationResult>
1702
+ + /** @param tabId - tab whose picker is active. */
1703
+ + cancelAnnotation(tabId: TabId): void
1704
+ readonly keyedHooks: {
1705
+ readonly browserState: (key: string) => HostObservable<BrowserControllerState> | undefined
1706
+ }
1707
+ @@ -221,6 +249,8 @@ export function createBrowserControllers(actions: BoundActions<BrowserStore>, cr
1708
+ }>()
1709
+ const controller = (id: TabId): BrowserController | undefined => controllers.get(id)?.controller
1710
+ return {
1711
+ + annotate: (id, options) => controller(id)?.annotate(options) ?? Promise.reject(new Error('annotation-unavailable')),
1712
+ + cancelAnnotation: id => { controller(id)?.cancelAnnotation() },
1713
+ keyedHooks: { browserState: key => controller(key as TabId) },
1714
+ mount(request) {
1715
+ const { tabId, signal } = request
1716
+ diff --git a/packages/client/ui-sidebar-browser/src/client/electron/ElectronWebviewPresentation.ts b/packages/client/ui-sidebar-browser/src/client/electron/ElectronWebviewPresentation.ts
1717
+ index 5d3ff43..de1a20b 100644
1718
+ --- a/packages/client/ui-sidebar-browser/src/client/electron/ElectronWebviewPresentation.ts
1719
+ +++ b/packages/client/ui-sidebar-browser/src/client/electron/ElectronWebviewPresentation.ts
1720
+ @@ -2,9 +2,14 @@
1721
+ import type { DesktopBrowserReservation } from '../../types.ts'
1722
+ import type { BrowserPresentation } from '../view/BrowserPresentation.ts'
1723
+ import css from '../view/Browser.module.css'
1724
+ +import { annotatePage, type BrowserAnnotationOptions } from '../annotation/page-inspector.ts'
1725
+ +import { isAnnotationResult, type AnnotationResult } from '../annotation/protocol.ts'
1726
+ +import { markScreenshot } from '../annotation/screenshot.ts'
1727
+
1728
+ /** The Electron tag API used by its navigation provider. */
1729
+ export interface WebviewElement extends HTMLElement {
1730
+ + capturePage(): Promise<{ toDataURL(): string }>
1731
+ + executeJavaScript(code: string): Promise<unknown>
1732
+ loadURL(url: string): Promise<void>
1733
+ getURL(): string
1734
+ getTitle(): string
1735
+ @@ -30,6 +35,49 @@ export class ElectronWebviewPresentation implements BrowserPresentation {
1736
+
1737
+ constructor(private readonly events: ElectronPresentationEvents) {}
1738
+
1739
+ + /** @param url - controller-known page. @param options - picker configuration. @param signal - lifetime. @returns bounded guest evidence. */
1740
+ + async annotate(url: string, options: BrowserAnnotationOptions, signal: AbortSignal): Promise<AnnotationResult> {
1741
+ + const element = this.element
1742
+ + if (signal.aborted) return { cancelled: true }
1743
+ + if (element === undefined || element.getURL() !== url) throw new Error('annotation-unavailable')
1744
+ + const cancel = (): void => {
1745
+ + void element.executeJavaScript('window.__DSH_PAGE_ANNOTATION_CANCEL__?.()').catch(() => {
1746
+ + // A destroyed or navigated guest has already removed the picker.
1747
+ + })
1748
+ + }
1749
+ + signal.addEventListener('abort', cancel, { once: true })
1750
+ + const navigation = (): void => { cancel() }
1751
+ + element.addEventListener('did-start-navigation', navigation)
1752
+ + try {
1753
+ + element.focus()
1754
+ + const result = await element.executeJavaScript(`(${annotatePage.toString()})(${JSON.stringify(options)})`)
1755
+ + if (signal.aborted || this.element !== element) return { cancelled: true }
1756
+ + if (!isAnnotationResult(result)) throw new Error('annotation-invalid')
1757
+ + if (!('cancelled' in result) && (result.url !== url || element.getURL() !== url)) throw new Error('annotation-page-changed')
1758
+ + if (!('cancelled' in result)) {
1759
+ + try {
1760
+ + const image = await element.capturePage()
1761
+ + const screenshot = await markScreenshot(image.toDataURL(), result)
1762
+ + if (signal.aborted || this.element !== element) return { cancelled: true }
1763
+ + if (element.getURL() !== url || element.isLoading()) throw new Error('annotation-page-changed')
1764
+ + const view = await element.executeJavaScript('({width:innerWidth,height:innerHeight,x:scrollX,y:scrollY})')
1765
+ + if (typeof view !== 'object' || view === null || !('width' in view) || !('height' in view) || !('x' in view) || !('y' in view) ||
1766
+ + view.width !== result.viewport.width || view.height !== result.viewport.height || view.x !== result.selection.scroll.x || view.y !== result.selection.scroll.y) return result
1767
+ + return { ...result, screenshot }
1768
+ + } catch (error) {
1769
+ + if (signal.aborted || this.element !== element) return { cancelled: true }
1770
+ + if (element.getURL() !== url || element.isLoading()) throw new Error('annotation-page-changed')
1771
+ + // A capture failure must retain the saved annotation for detail-only submission.
1772
+ + return result
1773
+ + }
1774
+ + }
1775
+ + return result
1776
+ + } finally {
1777
+ + signal.removeEventListener('abort', cancel)
1778
+ + element.removeEventListener('did-start-navigation', navigation)
1779
+ + }
1780
+ + }
1781
+ +
1782
+ /** @param viewportId - committed content container. @returns ends attachment and releases its guest. */
1783
+ mount(viewportId: string): () => void {
1784
+ const host = document.getElementById(viewportId)
1785
+ diff --git a/packages/client/ui-sidebar-browser/src/client/index.ts b/packages/client/ui-sidebar-browser/src/client/index.ts
1786
+ index d44226e..c0f9218 100644
1787
+ --- a/packages/client/ui-sidebar-browser/src/client/index.ts
1788
+ +++ b/packages/client/ui-sidebar-browser/src/client/index.ts
1789
+ @@ -18,8 +18,12 @@ import type { BrowserPageFactory } from './browser/BrowserPage.ts'
1790
+ import { BROWSER_ID, browserDefinition } from './definition.tsx'
1791
+ import { en, zh } from './locales.ts'
1792
+ import { createBrowserStore } from './browser/store.ts'
1793
+ +import type {} from './annotation/slots.ts'
1794
+
1795
+ export type { BrowserBodyProps } from './view/BrowserBody.tsx'
1796
+ +export type { BrowserAnnotationOwner } from './annotation/slots.ts'
1797
+ +export type { BrowserAnnotation, BrowserAnnotationOptions, BrowserAnnotationElement } from './annotation/page-inspector.ts'
1798
+ +export type { AnnotationResult } from './annotation/protocol.ts'
1799
+ export type { BrowserControllerState, BrowserInjected, BrowserMountRequest } from './browser/BrowserController.ts'
1800
+ export type { BrowserFrame, BrowserFrameState, BrowserLoadError, BrowserSandboxControl } from './browser/BrowserFrame.ts'
1801
+ export type { BrowserPage, BrowserPageFactory, BrowserPageOptions } from './browser/BrowserPage.ts'
1802
+ @@ -81,6 +85,10 @@ export function apply(ctx: Context): void {
1803
+ }, 'ui-sidebar-browser.frames')
1804
+ scope.effect(() => scope.slots.inject('sidebar.right.pane.tab', () => scope.slots.register({
1805
+ name: 'sidebar.right.pane.tab', key: BROWSER_ID, locale: namespace, store,
1806
+ + children: {
1807
+ + 'sidebar.right.tab.browser.toolbar': { kind: 'list', scope: 'session' },
1808
+ + 'sidebar.right.tab.browser.annotations': { kind: 'list', scope: 'session' },
1809
+ + },
1810
+ inject: (sessionId, actions) => {
1811
+ const existing = controllers.get(sessionId)
1812
+ if (existing !== undefined) {
1813
+ diff --git a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx
1814
+ index f819dba..b4fc5f6 100644
1815
+ --- a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx
1816
+ +++ b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx
1817
+ @@ -1,5 +1,5 @@
1818
+ /** Common browser chrome; a presentation adapter attaches the page inside its content container. */
1819
+ -import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
1820
+ +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
1821
+ import type { FormEvent, ReactNode } from 'react'
1822
+ import {
1823
+ Button,
1824
+ @@ -11,7 +11,8 @@ import {
1825
+ SHIELD_OUTLINE_PATH,
1826
+ ICON_REGULAR_STROKE,
1827
+ } from '@deepseek-ai/dsh-client-ui-primitives'
1828
+ -import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
1829
+ +import type { InjectFace, PropsLocale, PropsRuntime, PropsStore, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
1830
+ +import type { BrowserAnnotationOwner } from '../annotation/slots.ts'
1831
+ import type { BrowserInjected } from '../browser/BrowserController.ts'
1832
+ import { emptyBrowserFrame } from '../browser/BrowserFrame.ts'
1833
+ import { currentBrowserTarget } from '../browser/BrowserPersistence.ts'
1834
+ @@ -36,6 +37,7 @@ export type BrowserBodyProps = PropsRuntime<'sidebar.right.pane.tab'>
1835
+ & PropsStore<BrowserStore>
1836
+ & PropsLocale<'sidebarBrowser'>
1837
+ & InjectFace<BrowserInjected>
1838
+ + & PropsRenderSlots<'sidebar.right.tab.browser.toolbar' | 'sidebar.right.tab.browser.annotations'>
1839
+
1840
+ function useBrowserDraft(url: string | undefined, revision: number): readonly [string, (value: string) => void] {
1841
+ const [edit, setEdit] = useState<{ readonly revision: number; readonly value: string }>()
1842
+ @@ -75,12 +77,18 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
1843
+ const failure = state?.addressFailure
1844
+ const error = frame.error
1845
+ const submit = (event: FormEvent): void => { event.preventDefault(); loadUrl(tab.id, draft) }
1846
+ + const annotationOwner: BrowserAnnotationOwner = useMemo(() => ({
1847
+ + tabId: tab.id, url: unknown ? undefined : frame.target?.url, loading: frame.loading || mountEpoch === 0,
1848
+ + annotate: options => props.annotate(tab.id, options),
1849
+ + cancelAnnotation: () => { props.cancelAnnotation(tab.id) },
1850
+ + }), [tab.id, unknown, frame.target?.url, frame.loading, mountEpoch, props.annotate, props.cancelAnnotation])
1851
+
1852
+ return (
1853
+ <div className={css.root}>
1854
+ <form className={css.toolbar} onSubmit={submit}>
1855
+ <button type="button" className={css.tool} aria-label={t('back')} title={t('back')} disabled={!frame.canGoBack} onClick={() => { goBack(tab.id) }}><IconChevronLeftOutlineRegular /></button>
1856
+ <button type="button" className={css.tool} aria-label={t('forward')} title={t('forward')} disabled={!frame.canGoForward} onClick={() => { goForward(tab.id) }}><IconChevronRightOutlineRegular /></button>
1857
+ + {props.renderSlot('sidebar.right.tab.browser.toolbar', annotationOwner)}
1858
+ <Tooltip label={t('reload')} shortcutKeys={tab.refreshShortcut?.keys} side="bottom" delayMs={500}>
1859
+ <button type="button" className={css.tool} aria-label={t('reload')} aria-keyshortcuts={tab.refreshShortcut?.aria} disabled={target === undefined || mountEpoch === 0} onClick={() => { reload(tab.id) }}><IconRefreshOutlineRegular /></button>
1860
+ </Tooltip>
1861
+ @@ -127,6 +135,7 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
1862
+ <div className={css.start}>{t(target === undefined ? 'start' : 'loading')}</div>
1863
+ </div>}
1864
+ </div>
1865
+ + {props.renderSlot('sidebar.right.tab.browser.annotations', annotationOwner)}
1866
+ {unknown && <p className={css.limit}>{t('address.unknown')}</p>}
1867
+ </div>
1868
+ )
1869
+ diff --git a/packages/client/ui-sidebar-browser/src/client/view/BrowserPresentation.ts b/packages/client/ui-sidebar-browser/src/client/view/BrowserPresentation.ts
1870
+ index dadf0dc..0b47111 100644
1871
+ --- a/packages/client/ui-sidebar-browser/src/client/view/BrowserPresentation.ts
1872
+ +++ b/packages/client/ui-sidebar-browser/src/client/view/BrowserPresentation.ts
1873
+ @@ -1,5 +1,9 @@
1874
+ /** The presentation face is separate from page navigation and terminal disposal. */
1875
+ +import type { BrowserAnnotationOptions } from '../annotation/page-inspector.ts'
1876
+ +import type { AnnotationResult } from '../annotation/protocol.ts'
1877
+ export interface BrowserPresentation {
1878
+ + /** @param url - observed page. @param options - picker copy. @param signal - operation lifetime. @returns bounded evidence or cancellation. */
1879
+ + annotate?(url: string, options: BrowserAnnotationOptions, signal: AbortSignal): Promise<AnnotationResult>
1880
+ /**
1881
+ * Attach the page inside a committed content container.
1882
+ * @param viewportId - content element id; its DOM ancestors must remain connected for page retention.
1883
+ diff --git a/packages/client/ui-sidebar-browser/src/client/view/IframePresentation.ts b/packages/client/ui-sidebar-browser/src/client/view/IframePresentation.ts
1884
+ index fc064f5..595677d 100644
1885
+ --- a/packages/client/ui-sidebar-browser/src/client/view/IframePresentation.ts
1886
+ +++ b/packages/client/ui-sidebar-browser/src/client/view/IframePresentation.ts
1887
+ @@ -2,6 +2,9 @@
1888
+ import type { BrowserTarget } from '../browser/url.ts'
1889
+ import type { BrowserPresentation } from './BrowserPresentation.ts'
1890
+ import css from './Browser.module.css'
1891
+ +import { annotateIframe } from '../annotation/iframe-annotation.ts'
1892
+ +import type { BrowserAnnotationOptions } from '../annotation/page-inspector.ts'
1893
+ +import type { AnnotationResult } from '../annotation/protocol.ts'
1894
+
1895
+ /** Fixed iframe policy; top navigation and downloads are not granted directly. */
1896
+ export const WEB_BROWSER_SANDBOX = 'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox'
1897
+ @@ -29,6 +32,12 @@ export class IframePresentation implements BrowserPresentation {
1898
+ /** @param events - provider-owned load and remount callbacks. */
1899
+ constructor(private readonly events: IframePresentationEvents) {}
1900
+
1901
+ + /** @param url - controller-known URL. @param options - picker copy. @param signal - lifetime. @returns saved annotation or cancellation. */
1902
+ + annotate(url: string, options: BrowserAnnotationOptions, signal: AbortSignal): Promise<AnnotationResult> {
1903
+ + if (this.element === undefined) return Promise.reject(new Error('annotation-unavailable'))
1904
+ + return annotateIframe(this.element, url, options, signal)
1905
+ + }
1906
+ +
1907
+ /** @param viewportId - mounted placeholder. @returns removes only the iframe presentation. */
1908
+ mount(viewportId: string): () => void {
1909
+ const host = document.getElementById(viewportId)