@followbin/dsh-selection 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,22 +8,31 @@ Select text in any app and a small toolbar appears next to it: translate, summar
8
8
 
9
9
  ## Install
10
10
 
11
- You need DSH Desktop 0.2.2 or later on macOS 14 or later. Windows support is written but not tested yet; see [Known limitations](#known-limitations).
11
+ You need DSH Desktop 0.2.2 or later on macOS 14 or later. Windows support is written but not tested yet; see [Known limitations](#known-limitations). The screenshots below show DSH in Chinese; the English interface is laid out the same way.
12
12
 
13
13
  1. In DSH, open **Plugins** in the sidebar and click **Add plugin**.
14
14
  2. Enter `@followbin/dsh-selection` and click **Install**.
15
+
16
+ <img src="docs/images/zh/install-add.webp" alt="The Add plugin dialog with @followbin/dsh-selection entered" width="430">
17
+
15
18
  3. When the dialog says **Installed**, click **Enable now**.
16
- 4. macOS asks whether "DeepSeek Harness" may control your computer using accessibility features. Choose *Open System Settings* and switch DeepSeek Harness on ([why](#permissions-macos)).
19
+
20
+ <img src="docs/images/zh/install-enable.webp" alt="The Installed dialog with its Enable now button" width="430">
21
+
22
+ 4. macOS asks whether "DeepSeek Harness" may control your computer using accessibility features. Choose *Open System Settings* (the highlighted default button is *Deny*) and switch DeepSeek Harness on ([why](#permissions-macos)).
23
+
24
+ <img src="docs/images/zh/install-permission.webp" alt="macOS asking whether DeepSeek Harness may use accessibility features, with Selection assistant now in DSH's sidebar" width="700">
17
25
 
18
26
  Now select some text in any app. Settings are under **Selection assistant** in DSH's sidebar.
19
27
 
20
28
  To install the newest code from GitHub instead, enter `https://github.com/followbin/dsh-selection` in step 2.
21
29
 
22
- **Updating**: when a new version is published, the plugin's page under **Plugins** lists it in **Versions**; click **Update**. A copy installed from GitHub or a local folder has no **Versions** section: uninstall it and install it again. Your settings and usage are kept, because they are stored in `~/.dsh/selection`, outside the plugin.
30
+ **Updating**: when a new version is published, **General → Version** on the plugin's settings page offers **Update to x.y.z**. It is the same update as **Versions → Update** on the plugin's page under **Plugins**: DSH stops the old version, installs the new one and puts the old one back if that fails. A copy installed from GitHub or a local folder cannot update this way: uninstall it and install it again. Your settings and usage are kept, because they are stored in `~/.dsh/selection`, outside the plugin.
23
31
 
24
32
  ## Features
25
33
 
26
34
  - **Toolbar**: the first few skills sit on the toolbar, the rest live in the ⋯ menu together with "Disable in this app" and "More settings…".
35
+ - **Mini toolbar**: a single icon instead; rest the pointer on it and every skill drops down. Switch between the two from the toolbar's menu or the settings page.
27
36
  - **Built-in skills**: Translate, Summarize, Explain, Ask DSH, Read aloud, Copy, Polish, Search.
28
37
  - **Custom skills**: a name, an icon and an instruction. Try them on sample text before saving.
29
38
  - **Answers stream in** with Markdown, can be copied, regenerated, translated into another language or pinned on screen. When an answer is complete, a box below it takes follow-up questions, for every skill.
@@ -44,11 +53,12 @@ Windows needs no extra permission.
44
53
  ![The toolbar below a selection](docs/images/en/toolbar.png)
45
54
 
46
55
  - Select text and the toolbar appears below the selection. Press **Esc**, click elsewhere or start typing to dismiss it.
56
+ - With the mini toolbar, rest the pointer on the icon (or click it) to open the skills; they close once the pointer has left.
47
57
  - **Ask DSH** opens a small chat about the selection. Type a question and press Enter; follow-up questions keep the context.
48
58
  - Drag the toolbar, or a panel by its header, to move it; it stays where you put it.
49
59
  - In a result panel, the pin keeps it open while you click elsewhere (a new selection still replaces it); the language menu re-translates into another language. Click into the answer to select part of it and copy with ⌘C.
50
60
  - Once an answer is complete, type a follow-up in the box below it, for example "more formal" after a translation. Regenerating, or choosing another language, starts the conversation over.
51
- - **⋯ → Disable in this app** stops the toolbar in the current app. **⋯ → More settings…** opens the settings page in DSH.
61
+ - **⋯ → Use the mini toolbar** turns the toolbar into a single icon, and **Use the full toolbar** in its menu turns it back. **⋯ → Disable in this app** stops the toolbar in the current app. **⋯ → More settings…** opens the settings page in DSH.
52
62
 
53
63
  ![An explanation, then a follow-up question and its answer](docs/images/en/follow-up.png)
54
64
 
@@ -65,8 +75,9 @@ Open **Selection assistant** in DSH's sidebar. The page has tabs: General, Skill
65
75
  | --- | --- | --- |
66
76
  | Show the toolbar when text is selected | on | |
67
77
  | Skills, their order, on/off | 8 built-ins | Drag, or focus the handle and press ↑ ↓. Custom skills can be edited and deleted. |
78
+ | Toolbar style | full | Mini is a single icon whose menu holds every skill. Also switchable from the toolbar's menu. |
68
79
  | Show the toolbar | automatically | Or only while holding ⌥ / Alt during the selection. |
69
- | Skills on the toolbar | 5 | The rest go into the ⋯ menu. |
80
+ | Skills on the toolbar | 5 | The rest go into the ⋯ menu. Full toolbar only. |
70
81
  | My language | 简体中文 | Auto translation goes into this language, or into English if the text is already in it. |
71
82
  | Translate into | auto | |
72
83
  | Summaries, explanations and answers in | my language | Or the language of the selected text, or a fixed language. |
@@ -131,7 +142,7 @@ After changing `src/worker/` or `ui/`, click **Restart the assistant** on the se
131
142
  | `pnpm test` | Unit tests for the Host (settings, prompts, model client, HTTP guards, worker messages), the worker's rules, the toolbar page and the settings page (the pages run in jsdom). Plain Node 20 or later is fine. |
132
143
  | `pnpm selftest` | Opens the toolbar, the ⋯ menu and both panel types, then checks native window state, layout and that the frontmost app never changes. No permission needed. Add `--interactive` to click around for 20 seconds. |
133
144
  | `pnpm dev` | Runs the real toolbar outside DSH with a demo model. Settings go to `~/.dsh/selection-dev`. On macOS the accessibility prompt is for your terminal app. |
134
- | `pnpm screenshots` | Redraws the images in `docs/images/` from the real toolbar page and settings page with sample text. Add `en` or `zh` for one language. Needs Google Chrome and ffmpeg. |
145
+ | `pnpm screenshots` | Redraws the toolbar, follow-up, settings and walkthrough images in `docs/images/` from the real pages with sample text; the install screenshots are taken by hand. Add `en` or `zh` for one language. Needs Google Chrome and ffmpeg. |
135
146
  | `pnpm vendor:selection-hook [version]` | Refreshes `vendor/selection-hook` from npm. |
136
147
 
137
148
  How the pieces fit together, and why some unusual choices were made, is in [docs/architecture.md](docs/architecture.md).
package/README.zh-CN.md CHANGED
@@ -12,18 +12,27 @@
12
12
 
13
13
  1. 在 DSH 侧边栏打开「插件」,点「添加插件」。
14
14
  2. 输入 `@followbin/dsh-selection`,点「安装」。
15
+
16
+ <img src="docs/images/zh/install-add.webp" alt="「添加插件」里填好 @followbin/dsh-selection" width="430">
17
+
15
18
  3. 显示「已安装」后,点「立即启用」。
16
- 4. macOS 会询问是否允许「DeepSeek Harness」使用辅助功能控制这台电脑。点「打开系统设置」,把 DeepSeek Harness 打开([为什么需要](#权限macos))。
19
+
20
+ <img src="docs/images/zh/install-enable.webp" alt="安装完成,点「立即启用」" width="430">
21
+
22
+ 4. macOS 会询问是否允许「DeepSeek Harness」使用辅助功能控制这台电脑。点「打开系统设置」(高亮的默认按钮是「拒绝」,别直接按回车),把 DeepSeek Harness 打开([为什么需要](#权限macos))。
23
+
24
+ <img src="docs/images/zh/install-permission.webp" alt="macOS 请求辅助功能权限,DSH 侧边栏已出现「划词助手」" width="700">
17
25
 
18
26
  然后在任意应用里选中一段文字试试。设置在 DSH 侧边栏的「划词助手」里。
19
27
 
20
28
  想装 GitHub 上的最新代码,第 2 步改填 `https://github.com/followbin/dsh-selection`。
21
29
 
22
- **更新**:发布新版本后,在「插件」里打开它,「版本」一栏会列出新版本,点「更新」即可。从 GitHub 或本地目录装的没有「版本」一栏,要先卸载再重新安装。设置和用量存在 `~/.dsh/selection`,不在插件目录里,卸载不会丢。
30
+ **更新**:发布新版本后,插件设置页「常规 → 版本」里会出现「更新到 x.y.z」,点一下就更新。这和在「插件」里打开它、点「版本 → 更新」是同一个操作:DSH 先停掉旧版、装上新版,失败会还原。从 GitHub 或本地目录装的不能这样更新,要先卸载再重新安装。设置和用量存在 `~/.dsh/selection`,不在插件目录里,卸载不会丢。
23
31
 
24
32
  ## 功能
25
33
 
26
34
  - **工具栏**:前几个技能直接显示,其余收进「⋯」菜单;菜单里还有「在此应用中禁用」和「更多设置…」。
35
+ - **迷你模式**:工具栏换成一个小图标,鼠标停在上面就展开全部技能。在工具栏菜单或设置页里随时切换。
27
36
  - **内置技能**:翻译、总结、解释、问问 DSH、朗读、复制、润色、搜索。
28
37
  - **自定义技能**:起个名字、选个图标、写一句指令,保存前可以拿示例文字试一试。
29
38
  - **结果面板**:流式显示,支持 Markdown,可以复制、重新生成、切换翻译语言、固定在屏幕上。每个技能回答完后,下面都有输入框可以接着问。
@@ -44,11 +53,12 @@ Windows 不需要额外权限。
44
53
  ![选区下方的工具栏](docs/images/zh/toolbar.png)
45
54
 
46
55
  - 选中文字后,工具栏出现在选区下方。按 **Esc**、点别处或开始打字都会收起它。
56
+ - 迷你模式下,鼠标停在图标上(或点一下)就展开技能,移开后自动收起。
47
57
  - **问问 DSH** 会打开一个针对这段文字的小对话框。输入问题后按回车,追问会保留上下文。
48
58
  - 按住工具栏,或按住面板的标题栏拖动,可以把它移开;放下后就停在那里。
49
59
  - 结果面板上的图钉可以把面板固定住,点别处也不会收起(再次划词仍会替换它);翻译面板的语言下拉框可以换一种语言重新翻译。在回答里点一下,就能选中其中一部分并用 ⌘C 复制。
50
60
  - 回答完成后,可以在下方输入框里接着问,比如翻译完问「再正式一点」。点重新生成或换一种语言时,对话会从头开始。
51
- - **⋯ → 在此应用中禁用** 会让当前应用里不再弹出;**⋯ → 更多设置…** 会打开 DSH 里的设置页。
61
+ - **⋯ → 切换为迷你模式** 把工具栏换成一个图标,图标菜单里的 **切换为常规模式** 换回来;**⋯ → 在此应用中禁用** 会让当前应用里不再弹出;**⋯ → 更多设置…** 会打开 DSH 里的设置页。
52
62
 
53
63
  ![翻译完成后接着追问](docs/images/zh/follow-up.png)
54
64
 
@@ -65,8 +75,9 @@ Windows 不需要额外权限。
65
75
  | --- | --- | --- |
66
76
  | 选中文字时显示工具栏 | 开 | |
67
77
  | 技能、顺序与开关 | 8 个内置技能 | 拖动排序,也可以聚焦拖动把手后按 ↑ ↓。自定义技能可以编辑、删除。 |
78
+ | 工具栏样式 | 常规 | 迷你模式只有一个图标,菜单里是全部技能;也可以在工具栏菜单里切换。 |
68
79
  | 弹出方式 | 选中后自动弹出 | 也可以设为按住 ⌥ / Alt 选中时才弹出。 |
69
- | 工具栏上的技能数 | 5 | 其余收进「⋯」菜单。 |
80
+ | 工具栏上的技能数 | 5 | 其余收进「⋯」菜单。只在常规模式下有效。 |
70
81
  | 我的母语 | 简体中文 | 自动翻译会译成母语;原文已经是母语时译成英文。 |
71
82
  | 翻译成 | 自动 | |
72
83
  | 总结、解释和回答使用 | 我的母语 | 也可以跟随原文的语言,或固定某种语言。 |
@@ -131,7 +142,7 @@ env ELECTRON_RUN_AS_NODE=1 "/Applications/DeepSeek Harness.app/Contents/MacOS/De
131
142
  | `pnpm test` | 单元测试:Host(设置、提示词、模型调用、HTTP 防护、worker 消息)、worker 的行为规则、工具栏页面和设置页(页面跑在 jsdom 里)。普通 Node 20 及以上就能跑。 |
132
143
  | `pnpm selftest` | 依次打开工具栏、「⋯」菜单和两种结果面板,检查原生窗口状态、布局,以及前台应用始终没变。不需要权限。加 `--interactive` 可以手动点 20 秒。 |
133
144
  | `pnpm dev` | 不经过 DSH、用演示模型运行真实的工具栏,设置存在 `~/.dsh/selection-dev`。在 macOS 上,授权弹窗针对的是你的终端应用。 |
134
- | `pnpm screenshots` | 用真实的工具栏页面和设置页、配上示例文字,重新生成 `docs/images/` 里的图。加 `en` 或 `zh` 只生成一种语言。需要 Google Chrome 和 ffmpeg。 |
145
+ | `pnpm screenshots` | 用真实的工具栏页面和设置页、配上示例文字,重新生成 `docs/images/` 里的工具栏、追问、设置页截图和动图;安装步骤的截图是手工截的。加 `en` 或 `zh` 只生成一种语言。需要 Google Chrome 和 ffmpeg。 |
135
146
  | `pnpm vendor:selection-hook [版本]` | 从 npm 更新 `vendor/selection-hook`。 |
136
147
 
137
148
  各部分怎么协作、为什么有些做法不寻常,见 [docs/architecture.md](docs/architecture.md)。
@@ -7,7 +7,7 @@ dsh-selection ships or depends on the following software. Each is used under its
7
7
  | [selection-hook](https://github.com/0xfullex/selection-hook) 2.1.2 | Vendored in `vendor/selection-hook` (JavaScript loader and N-API prebuilds for macOS and Windows): global input hooks and reading the selected text | MIT | © 2025 0xfullex |
8
8
  | [@webviewjs/webview](https://github.com/webviewjs/webview) 0.6.0 | npm dependency: system web view windows (built on tao and wry) | MIT | © 2024 Twilight |
9
9
  | [koffi](https://koffi.dev/) 3.3.6 (`@koromix/koffi-*` platform packages) | npm dependency: calling macOS and Windows system APIs | MIT | © 2026 Niels Martignène |
10
- | [Lucide](https://lucide.dev/) icons | Path data copied into `src/shared/icons.mjs`, `client.js` and `icon.svg` | ISC (portions MIT, from Feather) | © Lucide Contributors 2022; portions © Cole Bemis 2013–2022 |
10
+ | [Lucide](https://lucide.dev/) icons | Path data copied into `src/shared/icons.mjs` | ISC (portions MIT, from Feather) | © Lucide Contributors 2022; portions © Cole Bemis 2013–2022 |
11
11
 
12
12
  The full MIT text for selection-hook is in `vendor/selection-hook/LICENSE`; the other packages include their license files in `node_modules`.
13
13
 
package/client.js CHANGED
@@ -36,6 +36,7 @@ window.__ModuleLoader__.load({
36
36
  'enabled.hint': '关掉后划词助手不会再弹出,设置保留。',
37
37
  'skills.title': '技能',
38
38
  'skills.hint': '前 {count} 个启用的技能显示在工具栏上,其余收进 ⋯ 菜单。拖动或用方向键调整顺序。',
39
+ 'skills.hintMini': '迷你模式下,所有启用的技能都在图标展开的菜单里。拖动或用方向键调整顺序。',
39
40
  'skills.overflow': '以下收进 ⋯ 菜单',
40
41
  'skills.custom': '自定义',
41
42
  'skills.add': '新建技能',
@@ -63,6 +64,25 @@ window.__ModuleLoader__.load({
63
64
  'toolbar.trigger.auto': '选中文字后自动弹出',
64
65
  'toolbar.trigger.modifier': '按住 ⌥ / Alt 选中时才弹出',
65
66
  'toolbar.size': '工具栏上的技能数',
67
+ 'toolbar.style': '样式',
68
+ 'toolbar.style.full': '常规',
69
+ 'toolbar.style.mini': '迷你',
70
+ 'toolbar.style.hint': '迷你模式只显示一个图标,鼠标停在上面会展开全部技能。在工具栏的菜单里也能切换。',
71
+ 'version.label': '版本',
72
+ 'version.current': '当前 {version}',
73
+ 'version.latest': '当前 {version},已是最新版本',
74
+ 'version.available': '当前 {installed},可以更新到 {version}',
75
+ 'version.needsDsh': '当前 {installed};新版本 {version} 需要先更新 DSH',
76
+ 'version.notUpdatable': '当前 {version}。从 GitHub 或本地目录安装的版本不能在这里更新,可以到「插件」页卸载后重新安装',
77
+ 'version.checkFailed': '当前 {version};检查更新失败:{reason}',
78
+ 'version.updating': '正在更新到 {version},工具栏会短暂停止',
79
+ 'version.updated': '已更新到 {version},新版本已经在运行',
80
+ 'version.restart': '已安装 {version},重启 DSH 后生效',
81
+ 'version.failed': '更新到 {version} 失败:{reason}。可以到「插件」页查看详情',
82
+ 'version.check': '检查更新',
83
+ 'version.checking': '正在检查…',
84
+ 'version.update': '更新到 {version}',
85
+ 'version.updatingShort': '正在更新…',
66
86
  'language.title': '语言',
67
87
  'language.native': '我的母语',
68
88
  'language.translate': '翻译成',
@@ -158,6 +178,7 @@ window.__ModuleLoader__.load({
158
178
  'enabled.hint': 'Turning this off hides the assistant and keeps your settings.',
159
179
  'skills.title': 'Skills',
160
180
  'skills.hint': 'The first {count} enabled skills sit on the toolbar; the rest go into the ⋯ menu. Drag or use the arrow keys to reorder.',
181
+ 'skills.hintMini': 'With the mini toolbar, every enabled skill is in the icon’s menu. Drag or use the arrow keys to reorder.',
161
182
  'skills.overflow': 'In the ⋯ menu',
162
183
  'skills.custom': 'Custom',
163
184
  'skills.add': 'New skill',
@@ -185,6 +206,25 @@ window.__ModuleLoader__.load({
185
206
  'toolbar.trigger.auto': 'Automatically after selecting text',
186
207
  'toolbar.trigger.modifier': 'Only while holding ⌥ / Alt during selection',
187
208
  'toolbar.size': 'Skills on the toolbar',
209
+ 'toolbar.style': 'Style',
210
+ 'toolbar.style.full': 'Full',
211
+ 'toolbar.style.mini': 'Mini',
212
+ 'toolbar.style.hint': 'The mini toolbar is a single icon; rest the pointer on it to see every skill. You can also switch from the toolbar’s menu.',
213
+ 'version.label': 'Version',
214
+ 'version.current': 'Version {version}',
215
+ 'version.latest': 'Version {version}, the latest',
216
+ 'version.available': 'Version {installed}; {version} is available',
217
+ 'version.needsDsh': 'Version {installed}; {version} needs a newer DSH',
218
+ 'version.notUpdatable': 'Version {version}. A copy installed from GitHub or a local folder cannot be updated here; uninstall it on the Plugins page and install it again',
219
+ 'version.checkFailed': 'Version {version}; checking for updates failed: {reason}',
220
+ 'version.updating': 'Updating to {version}; the toolbar stops for a moment',
221
+ 'version.updated': 'Updated to {version}; the new version is running',
222
+ 'version.restart': '{version} is installed and takes effect after DSH restarts',
223
+ 'version.failed': 'Updating to {version} failed: {reason}. The Plugins page has the details',
224
+ 'version.check': 'Check for updates',
225
+ 'version.checking': 'Checking…',
226
+ 'version.update': 'Update to {version}',
227
+ 'version.updatingShort': 'Updating…',
188
228
  'language.title': 'Language',
189
229
  'language.native': 'My language',
190
230
  'language.translate': 'Translate into',
@@ -576,7 +616,7 @@ window.__ModuleLoader__.load({
576
616
  checked: entry.enabled, label: nameOf(entry),
577
617
  onChange: (enabled) => update({ skills: settings.skills.map((skill) => (skill.id === entry.id ? { ...skill, enabled } : skill)) }),
578
618
  })))
579
- if (entry.enabled && enabledSeen === settings.toolbarSize && index < settings.skills.length - 1) {
619
+ if (settings.toolbarStyle !== 'mini' && entry.enabled && enabledSeen === settings.toolbarSize && index < settings.skills.length - 1) {
580
620
  rows.push(h('div', { key: '__overflow', className: 'dss-overflow' }, t('skills.overflow')))
581
621
  }
582
622
  })
@@ -707,6 +747,104 @@ window.__ModuleLoader__.load({
707
747
 
708
748
  // ---------- Page ----------
709
749
 
750
+ // Updates go through DSH's plugin manager, reached the way DSH's own Plugins page reaches it: it stops
751
+ // this version, installs the new one and puts the old one back if that fails. Null where this DSH
752
+ // does not let a page reach it.
753
+ const PACKAGE = '@followbin/dsh-selection'
754
+ const updates = (() => {
755
+ let manager = null
756
+ let state = { phase: 'none' }
757
+ const listeners = new Set()
758
+ const set = (next) => {
759
+ state = next
760
+ for (const listener of listeners) listener(state)
761
+ }
762
+ const reasonOf = (value) => String(value?.message ?? value ?? 'unknown')
763
+ return {
764
+ get: () => state,
765
+ subscribe(listener) {
766
+ listeners.add(listener)
767
+ return () => listeners.delete(listener)
768
+ },
769
+ attach(next) {
770
+ manager = next
771
+ if (state.phase !== 'updating') set({ phase: next ? 'idle' : 'none' })
772
+ },
773
+ async check() {
774
+ if (!manager || state.phase === 'checking' || state.phase === 'updating') return
775
+ set({ phase: 'checking' })
776
+ let list
777
+ try {
778
+ const answer = await manager.listVersions(PACKAGE, {})
779
+ list = answer.ok ? answer.value : { status: 'refused', problem: 'unknown', reason: reasonOf(answer.error) }
780
+ } catch (error) {
781
+ list = { status: 'refused', problem: 'unknown', reason: reasonOf(error) }
782
+ }
783
+ if (list.status === 'listed') {
784
+ // Newest first; a prerelease, a deprecated version or one without a bundle is never offered.
785
+ const usable = list.versions.filter((entry) => entry.bundle && !entry.prerelease && !entry.deprecated)
786
+ const next = usable.find((entry) => !entry.incompatible)
787
+ const blocked = usable.find((entry) => entry.incompatible)
788
+ if (next) set({ phase: 'available', installed: list.installed, version: next.version, registry: list.registry })
789
+ else if (blocked) set({ phase: 'needs-dsh', installed: list.installed, version: blocked.version })
790
+ else set({ phase: 'latest', installed: list.installed })
791
+ } else {
792
+ set(list.problem === 'not-updatable' ? { phase: 'not-updatable' } : { phase: 'check-failed', reason: list.reason || list.problem })
793
+ }
794
+ },
795
+ async update() {
796
+ if (!manager || state.phase !== 'available') return
797
+ const { version, registry } = state
798
+ set({ phase: 'updating', version })
799
+ const requestId = globalThis.crypto?.randomUUID?.() ?? `dss-${Date.now()}-${Math.random().toString(36).slice(2)}`
800
+ let answer
801
+ try {
802
+ answer = await manager.updateBundle(PACKAGE, version, { registry, requestId })
803
+ } catch (error) {
804
+ answer = { ok: false, error }
805
+ }
806
+ // Once applied, DSH replaces this page with the new version's.
807
+ const result = answer.ok ? answer.value.result : null
808
+ if (result?.application === 'applied') set({ phase: 'updated', version })
809
+ else if (result?.application === 'restart-required') set({ phase: 'restart', version })
810
+ else set({ phase: 'failed', version, reason: answer.ok ? (result?.error?.code ?? result?.application ?? 'unknown') : reasonOf(answer.error) })
811
+ },
812
+ }
813
+ })()
814
+
815
+ function VersionRow({ t, version }) {
816
+ const [state, setState] = useState(updates.get())
817
+ useEffect(() => updates.subscribe(setState), [])
818
+ // Checks once when the page opens, or when DSH's plugin manager arrives after it.
819
+ useEffect(() => {
820
+ if (state.phase === 'idle') void updates.check()
821
+ }, [state.phase])
822
+ const installed = state.installed || version
823
+ const hint = {
824
+ none: fill(t('version.current'), { version }),
825
+ idle: fill(t('version.current'), { version }),
826
+ checking: fill(t('version.current'), { version }),
827
+ latest: fill(t('version.latest'), { version: installed }),
828
+ available: fill(t('version.available'), { installed, version: state.version }),
829
+ 'needs-dsh': fill(t('version.needsDsh'), { installed, version: state.version }),
830
+ 'not-updatable': fill(t('version.notUpdatable'), { version }),
831
+ 'check-failed': fill(t('version.checkFailed'), { version, reason: state.reason }),
832
+ updating: fill(t('version.updating'), { version: state.version }),
833
+ updated: fill(t('version.updated'), { version: state.version }),
834
+ restart: fill(t('version.restart'), { version: state.version }),
835
+ failed: fill(t('version.failed'), { version: state.version, reason: state.reason }),
836
+ }[state.phase]
837
+ let action = null
838
+ if (state.phase === 'available') {
839
+ action = h('button', { type: 'button', className: 'dss-button primary', onClick: () => void updates.update() }, fill(t('version.update'), { version: state.version }))
840
+ } else if (state.phase === 'checking' || state.phase === 'updating') {
841
+ action = h('button', { type: 'button', className: 'dss-button', disabled: true }, t(state.phase === 'checking' ? 'version.checking' : 'version.updatingShort'))
842
+ } else if (['latest', 'needs-dsh', 'check-failed', 'failed'].includes(state.phase)) {
843
+ action = h('button', { type: 'button', className: 'dss-button', onClick: () => void updates.check() }, t('version.check'))
844
+ }
845
+ return h(Row, { label: t('version.label'), hint }, action)
846
+ }
847
+
710
848
  function SettingsPage(props) {
711
849
  const t = typeof props.t === 'function' ? props.t : (key) => zh[key] || key
712
850
  const [state, setState] = useState(null)
@@ -865,14 +1003,20 @@ window.__ModuleLoader__.load({
865
1003
 
866
1004
  panel('general',
867
1005
  h('div', { className: 'dss-card', style: { marginTop: 20 } },
868
- h(Row, { label: t('enabled.label'), hint: t('enabled.hint') }, h(Switch, { checked: settings.enabled, label: t('enabled.label'), onChange: (enabled) => update({ enabled }) }))),
1006
+ h(Row, { label: t('enabled.label'), hint: t('enabled.hint') }, h(Switch, { checked: settings.enabled, label: t('enabled.label'), onChange: (enabled) => update({ enabled }) })),
1007
+ h(VersionRow, { t, version: state.version })),
869
1008
  h(Section, { title: t('toolbar.title') },
870
1009
  h('div', { className: 'dss-card' },
1010
+ h(Row, { label: t('toolbar.style'), hint: t('toolbar.style.hint') },
1011
+ h('div', { className: 'dss-segmented', role: 'group', 'aria-label': t('toolbar.style'), style: { marginTop: 0 } },
1012
+ ['full', 'mini'].map((style) => h('button', {
1013
+ key: style, type: 'button', 'aria-pressed': String(settings.toolbarStyle === style), onClick: () => update({ toolbarStyle: style }),
1014
+ }, t(`toolbar.style.${style}`))))),
871
1015
  h(Row, { label: t('toolbar.trigger') },
872
1016
  h('select', { className: 'dss-select', value: settings.trigger, onChange: (event) => update({ trigger: event.target.value }) },
873
1017
  h('option', { value: 'auto' }, t('toolbar.trigger.auto')),
874
1018
  h('option', { value: 'modifier' }, t('toolbar.trigger.modifier')))),
875
- h(Row, { label: t('toolbar.size') },
1019
+ settings.toolbarStyle === 'mini' ? null : h(Row, { label: t('toolbar.size') },
876
1020
  h(NumberInput, { value: settings.toolbarSize, min: limits.toolbarSize.min, max: limits.toolbarSize.max, label: t('toolbar.size'), onChange: (toolbarSize) => update({ toolbarSize }) })))),
877
1021
  h(Section, { title: t('language.title') },
878
1022
  h('div', { className: 'dss-card' },
@@ -890,7 +1034,7 @@ window.__ModuleLoader__.load({
890
1034
  catalog.languages.map((language) => h('option', { key: language.code, value: language.code }, language.name))))))),
891
1035
 
892
1036
  panel('skills',
893
- h(Section, { title: t('skills.title'), hint: fill(t('skills.hint'), { count: settings.toolbarSize }) },
1037
+ h(Section, { title: t('skills.title'), hint: settings.toolbarStyle === 'mini' ? t('skills.hintMini') : fill(t('skills.hint'), { count: settings.toolbarSize }) },
894
1038
  h(SkillList, { t, settings, catalog, update, onEdit: (entry) => setEditing(entry) }),
895
1039
  editing
896
1040
  ? h(SkillEditor, { key: editing.id || 'new', t, catalog, limits, initial: editing.id ? editing : null, onSave: saveSkill, onCancel: () => setEditing(null), prepare: saveNow })
@@ -1012,8 +1156,8 @@ window.__ModuleLoader__.load({
1012
1156
  return h(PageBoundary, props, h(SettingsPage, props))
1013
1157
  }
1014
1158
 
1015
- // Dashed selection frame around lines of text (Lucide "text-select", ISC).
1016
- const SIDEBAR_ICON = 'M5 3a2 2 0 0 0-2 2 M19 3a2 2 0 0 1 2 2 M21 19a2 2 0 0 1-2 2 M5 21a2 2 0 0 1-2-2 M9 3h1 M9 21h1 M14 3h1 M14 21h1 M3 9v1 M21 9v1 M3 14v1 M21 14v1 M7 8h10 M7 12h10 M7 16h6'
1159
+ // The plugin's mark as a line drawing; src/shared/icons.mjs has the same path as BRAND_LINE.
1160
+ const SIDEBAR_ICON = 'M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M12 6.8c.47 2.87 2.33 4.73 5.2 5.2-2.87.47-4.73 2.33-5.2 5.2-.47-2.87-2.33-4.73-5.2-5.2 2.87-.47 4.73-2.33 5.2-5.2Z'
1017
1161
 
1018
1162
  function SidebarIcon({ size = 18, active }) {
1019
1163
  return h('svg', {
@@ -1039,6 +1183,15 @@ window.__ModuleLoader__.load({
1039
1183
  'dsh-selection: settings page',
1040
1184
  )
1041
1185
  ctx.effect(() => events.start(), 'dsh-selection: event stream')
1186
+ // Updates need DSH's plugin manager; a DSH that gives a page none still shows everything else.
1187
+ if (typeof ctx.inject === 'function') {
1188
+ ctx.inject(['remote', 'remote.pluginManager'], (sub) => {
1189
+ sub.effect(() => {
1190
+ updates.attach(sub.remote.pluginManager)
1191
+ return () => updates.attach(null)
1192
+ }, 'dsh-selection: updates')
1193
+ })
1194
+ }
1042
1195
  // "More settings…" in the toolbar menu asks the Host to bring DSH forward on this page.
1043
1196
  ctx.effect(() => events.subscribe((event) => {
1044
1197
  if (event !== 'navigate') return
package/icon.svg CHANGED
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4d6bfe" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/><path d="M7 8h10"/><path d="M7 12h10"/><path d="M7 16h6"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none"><path d="M7.5 12.5V10C7.5 8.62 8.62 7.5 10 7.5H12.5M23.5 7.5H26C27.38 7.5 28.5 8.62 28.5 10V12.5M28.5 23.5V26C28.5 27.38 27.38 28.5 26 28.5H23.5M12.5 28.5H10C8.62 28.5 7.5 27.38 7.5 26V23.5" stroke="url(#dsh-selection-frame)" stroke-width="2.4" stroke-linecap="round"/><path d="M18 10.8C18.655 14.77 21.23 17.345 25.2 18C21.23 18.655 18.655 21.23 18 25.2C17.345 21.23 14.77 18.655 10.8 18C14.77 17.345 17.345 14.77 18 10.8Z" fill="url(#dsh-selection-spark)" stroke="none"/><defs><linearGradient id="dsh-selection-frame" x1="7.5" y1="7.5" x2="28.5" y2="28.5" gradientUnits="userSpaceOnUse"><stop stop-color="#6AECB6"/><stop offset="1" stop-color="#37A6B1"/></linearGradient><linearGradient id="dsh-selection-spark" x1="10.8" y1="10.8" x2="25.2" y2="25.2" gradientUnits="userSpaceOnUse"><stop stop-color="#7CB7FF"/><stop offset="1" stop-color="#145AF3"/></linearGradient></defs></svg>
package/index.js CHANGED
@@ -1,11 +1,15 @@
1
1
  // DSH plugin entry (Host side). Starts the selection service and mounts its HTTP API.
2
2
  // The settings page lives in client.js; the toolbar runs in a worker process (src/worker).
3
+ import { readFileSync } from 'node:fs'
3
4
  import { join } from 'node:path'
4
5
  import { createHttpHandler, ROUTE } from './src/host/http.mjs'
5
6
  import { createLogger } from './src/host/log.mjs'
6
7
  import { createSelectionService } from './src/host/service.mjs'
7
8
  import { dataDirectory } from './src/shared/paths.mjs'
8
9
 
10
+ // Shown on the settings page beside the update check.
11
+ const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
12
+
9
13
  export const name = 'dsh-selection'
10
14
  export const inject = ['webServer', 'connection']
11
15
 
@@ -26,6 +30,7 @@ export function apply(ctx, config = {}) {
26
30
  dataDir,
27
31
  desktop,
28
32
  promptForPermission: config.promptForPermission !== false,
33
+ version,
29
34
  })
30
35
  log('info', 'plugin started', { platform: process.platform, desktop })
31
36
  ctx.effect(() => () => service.dispose(), 'dsh-selection: service')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@followbin/dsh-selection",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Select text in any app and let DSH translate, summarize, explain, answer or read it aloud.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -30,12 +30,14 @@ export class BadRequest extends Error {}
30
30
  * platform?: string,
31
31
  * startWorker?: typeof startWorker,
32
32
  * openExternal?: (url: string) => void,
33
+ * version?: string,
33
34
  * }} options `desktop` is false when the Host is not on the user's machine (e.g. a remote web Host).
34
35
  * Tests replace `platform`, `startWorker` and `openExternal`.
35
36
  */
36
37
  export function createSelectionService({
37
38
  getLlm, log, dataDir = dataDirectory(), desktop, promptForPermission = true,
38
39
  platform = process.platform, startWorker: launchWorker = startWorker, openExternal = (url) => openWithSystem(url, log),
40
+ version = '',
39
41
  }) {
40
42
  const settings = createSettingsStore({ file: join(dataDir, 'settings.json'), log })
41
43
  const model = createModelClient({ getLlm, log })
@@ -101,6 +103,7 @@ export function createSelectionService({
101
103
  enabled: current.enabled,
102
104
  trigger: current.trigger,
103
105
  toolbarSize: current.toolbarSize,
106
+ toolbarStyle: current.toolbarStyle,
104
107
  skills: enabledSkills(current),
105
108
  disabledApps: current.disabledApps,
106
109
  clipboardFallback: current.clipboardFallback,
@@ -131,6 +134,15 @@ export function createSelectionService({
131
134
  case 'cancel':
132
135
  runs.get(message.requestId)?.abort()
133
136
  break
137
+ case 'toolbar-style':
138
+ if (message.style === 'full' || message.style === 'mini') {
139
+ try {
140
+ settings.update((current) => ({ ...current, toolbarStyle: message.style }))
141
+ } catch (error) {
142
+ log('error', 'saving settings failed', { error: String(error?.message ?? error) })
143
+ }
144
+ }
145
+ break
134
146
  case 'disable-app':
135
147
  if (typeof message.app === 'string' && message.app) {
136
148
  try {
@@ -231,6 +243,7 @@ export function createSelectionService({
231
243
  defaults: defaultSettings(),
232
244
  status,
233
245
  recentApps: [...recentApps],
246
+ version,
234
247
  catalog: {
235
248
  builtins: BUILTIN_SKILLS,
236
249
  languages: LANGUAGES,
@@ -57,6 +57,7 @@ export function defaultSettings() {
57
57
  enabled: true,
58
58
  trigger: 'auto',
59
59
  toolbarSize: 5,
60
+ toolbarStyle: 'full',
60
61
  skills: BUILTIN_SKILLS.map((skill) => ({ id: skill.id, enabled: true })),
61
62
  disabledApps: [],
62
63
  nativeLanguage: 'zh-CN',
@@ -83,6 +84,7 @@ export function normalizeSettings(input) {
83
84
  enabled: typeof value.enabled === 'boolean' ? value.enabled : defaults.enabled,
84
85
  trigger: value.trigger === 'modifier' ? 'modifier' : 'auto',
85
86
  toolbarSize: clampInt(value.toolbarSize, LIMITS.toolbarSize, defaults.toolbarSize),
87
+ toolbarStyle: value.toolbarStyle === 'mini' ? 'mini' : 'full',
86
88
  skills: normalizeSkills(value.skills),
87
89
  disabledApps: normalizeApps(value.disabledApps),
88
90
  nativeLanguage: LANGUAGE_CODES.has(value.nativeLanguage) ? value.nativeLanguage : defaults.nativeLanguage,
@@ -266,6 +268,7 @@ function isObject(value) {
266
268
  * @property {boolean} enabled Show the toolbar when text is selected.
267
269
  * @property {'auto' | 'modifier'} trigger `modifier` shows it only while ⌥ / Alt is held.
268
270
  * @property {number} toolbarSize How many enabled skills sit on the toolbar; the rest go to the ⋯ menu.
271
+ * @property {'full' | 'mini'} toolbarStyle Full: skill buttons on the toolbar. Mini: one icon whose menu holds every skill.
269
272
  * @property {Array<{ id: string, enabled: boolean, custom?: true, name?: string, icon?: string, prompt?: string }>} skills
270
273
  * @property {string[]} disabledApps Program names (as reported by the OS) where the toolbar never shows.
271
274
  * @property {string} nativeLanguage Language the user reads best; auto translation flips around it.
@@ -35,8 +35,31 @@ export const ICONS = Object.freeze({
35
35
  plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
36
36
  edit: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>',
37
37
  cursor: '<path d="M17 22h-1a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h1"/><path d="M7 22h1a4 4 0 0 0 4-4v-1"/><path d="M7 2h1a4 4 0 0 1 4 4v1"/>',
38
+ minimize: '<path d="m14 10 7-7"/><path d="M20 10h-6V4"/><path d="m3 21 7-7"/><path d="M4 14h6v6"/>',
39
+ maximize: '<path d="M15 3h6v6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/><path d="M9 21H3v-6"/>',
38
40
  })
39
41
 
42
+ const BRAND_FRAME = 'M7.5 12.5V10C7.5 8.62 8.62 7.5 10 7.5H12.5M23.5 7.5H26C27.38 7.5 28.5 8.62 28.5 10V12.5M28.5 23.5V26C28.5 27.38 27.38 28.5 26 28.5H23.5M12.5 28.5H10C8.62 28.5 7.5 27.38 7.5 26V23.5'
43
+ const BRAND_SPARK = 'M18 10.8C18.655 14.77 21.23 17.345 25.2 18C21.23 18.655 18.655 21.23 18 25.2C17.345 21.23 14.77 18.655 10.8 18C14.77 17.345 17.345 14.77 18 10.8Z'
44
+
45
+ /**
46
+ * The plugin's own mark, drawn for it rather than taken from Lucide: a selection frame around a spark,
47
+ * in the colours of DSH's plugin icons. `id` prefixes the gradient ids, which a document needs unique.
48
+ * The full view box keeps the margin DSH's own plugin icons have; a cropped one fits a small button.
49
+ */
50
+ export function brandIcon(id, viewBox = '0 0 36 36') {
51
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="${viewBox}" fill="none">`
52
+ + `<path d="${BRAND_FRAME}" stroke="url(#${id}-frame)" stroke-width="2.4" stroke-linecap="round"/>`
53
+ + `<path d="${BRAND_SPARK}" fill="url(#${id}-spark)" stroke="none"/>`
54
+ + '<defs>'
55
+ + `<linearGradient id="${id}-frame" x1="7.5" y1="7.5" x2="28.5" y2="28.5" gradientUnits="userSpaceOnUse"><stop stop-color="#6AECB6"/><stop offset="1" stop-color="#37A6B1"/></linearGradient>`
56
+ + `<linearGradient id="${id}-spark" x1="10.8" y1="10.8" x2="25.2" y2="25.2" gradientUnits="userSpaceOnUse"><stop stop-color="#7CB7FF"/><stop offset="1" stop-color="#145AF3"/></linearGradient>`
57
+ + '</defs></svg>'
58
+ }
59
+
60
+ /** The same mark as a one-colour line drawing on a 24×24 grid, for DSH's sidebar. */
61
+ export const BRAND_LINE = 'M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M12 6.8c.47 2.87 2.33 4.73 5.2 5.2-2.87.47-4.73 2.33-5.2 5.2-.47-2.87-2.33-4.73-5.2-5.2 2.87-.47 4.73-2.33 5.2-5.2Z'
62
+
40
63
  /** Icons a user may pick for a custom skill. */
41
64
  export const CUSTOM_SKILL_ICONS = Object.freeze([
42
65
  'sparkles', 'star', 'heart', 'zap', 'lightbulb', 'list', 'quote', 'globe', 'link', 'code', 'polish', 'summarize', 'explain', 'ask',
@@ -20,12 +20,14 @@ const SEARCH_MAX_LENGTH = 2000
20
20
  * idPrefix: string,
21
21
  * now?: () => number,
22
22
  * later?: (callback: () => void, ms: number) => unknown,
23
+ * trackPointer?: (on: boolean) => void,
23
24
  * }} options `positions` are selection-hook's INVALID_COORDINATE and PositionLevel.SEL_FULL.
24
25
  * `idPrefix` must differ between worker processes, so a late answer never lands in a new panel.
26
+ * `trackPointer` turns the hook's mouse moves on while the overlay shows, and off again.
25
27
  */
26
28
  export function createController({
27
29
  overlay, speech, send, log, writeClipboard, openUrl, positions, idPrefix,
28
- now = Date.now, later = setTimeout,
30
+ now = Date.now, later = setTimeout, trackPointer = () => {},
29
31
  }) {
30
32
  let config = null
31
33
  /** `text` goes to the model and the panel; `full` is what Copy, Read aloud and Search use. */
@@ -49,7 +51,11 @@ export function createController({
49
51
 
50
52
  function configure(next) {
51
53
  config = next
52
- overlay.configure({ bar: next.skills.slice(0, next.toolbarSize), more: next.skills.slice(next.toolbarSize) })
54
+ overlay.configure({
55
+ bar: next.skills.slice(0, next.toolbarSize),
56
+ more: next.skills.slice(next.toolbarSize),
57
+ style: next.toolbarStyle === 'mini' ? 'mini' : 'full',
58
+ })
53
59
  if (!next.enabled) dismiss({ force: true, reason: 'disabled' })
54
60
  }
55
61
 
@@ -71,6 +77,7 @@ export function createController({
71
77
  selection = { id: ++nextSelection, text: cut(text, config.maxTextLength), full: text, app: data.programName }
72
78
  shownAt = now()
73
79
  overlay.showBar(anchor)
80
+ trackPointer(true)
74
81
  // The text stays here until the user picks an action; the Host only learns which app it came from.
75
82
  send({ type: 'selection', app: data.programName })
76
83
  }
@@ -89,6 +96,10 @@ export function createController({
89
96
  if (overlay.mode !== 'hidden' && !pressedOnOverlay) dismiss({ reason: 'click-outside', point })
90
97
  }
91
98
 
99
+ function onMouseMove(point) {
100
+ if (overlay.mode !== 'hidden') overlay.pointer(point)
101
+ }
102
+
92
103
  function onMouseWheel(point) {
93
104
  if (overlay.mode === 'bar' && !overlay.contains(point)) dismiss({ reason: 'scroll-outside', point })
94
105
  }
@@ -119,6 +130,7 @@ export function createController({
119
130
  case 'skill':
120
131
  return runSkill(message.id)
121
132
  case 'menu':
133
+ if (message.action === 'toolbar-style') return switchStyle()
122
134
  if (message.action !== 'disable-app' && message.action !== 'settings') return
123
135
  if (message.action === 'disable-app' && selection) send({ type: 'disable-app', app: selection.app })
124
136
  if (message.action === 'settings') send({ type: 'open-settings' })
@@ -165,6 +177,14 @@ export function createController({
165
177
  }
166
178
  }
167
179
 
180
+ /** Swaps the full toolbar and the mini icon where they are, and has the Host save the choice. */
181
+ function switchStyle() {
182
+ if (!config) return
183
+ const style = config.toolbarStyle === 'mini' ? 'full' : 'mini'
184
+ configure({ ...config, toolbarStyle: style })
185
+ send({ type: 'toolbar-style', style })
186
+ }
187
+
168
188
  function runSkill(id) {
169
189
  const skill = config?.skills.find((candidate) => candidate.id === id)
170
190
  if (!skill || !selection) return
@@ -248,9 +268,10 @@ export function createController({
248
268
  panel = null
249
269
  overlay.setKeyable(false)
250
270
  overlay.hide()
271
+ trackPointer(false)
251
272
  }
252
273
 
253
- return { configure, onSelection, onMouseDown, onMouseWheel, onKeyDown, onKeyUp, onPage, onResult }
274
+ return { configure, onSelection, onMouseDown, onMouseMove, onMouseWheel, onKeyDown, onKeyUp, onPage, onResult }
254
275
  }
255
276
 
256
277
  /** Shortens text without splitting a surrogate pair, which encodeURIComponent and JSON would reject. */
@@ -61,12 +61,15 @@ controller = createController({
61
61
  openUrl: (url) => openUrl(url, log),
62
62
  positions: { invalid: SelectionHook.INVALID_COORDINATE, fullSelection: SelectionHook.PositionLevel.SEL_FULL },
63
63
  idPrefix: String(process.pid),
64
+ // Mouse moves cost CPU on every move anywhere, so the hook reports them only while the overlay shows.
65
+ trackPointer: (on) => (on ? hook.enableMouseMoveEvent() : hook.disableMouseMoveEvent()),
64
66
  })
65
67
  applyConfig(config)
66
68
 
67
69
  hook.on('text-selection', guarded('selection', (data) => controller.onSelection(data)))
68
70
  hook.on('mouse-down', guarded('mouse-down', (point) => controller.onMouseDown(point)))
69
71
  hook.on('mouse-wheel', guarded('mouse-wheel', (point) => controller.onMouseWheel(point)))
72
+ hook.on('mouse-move', guarded('mouse-move', (point) => controller.onMouseMove(point)))
70
73
  hook.on('key-down', guarded('key-down', ({ uniKey }) => {
71
74
  if (uniKey === 'Alt' && !altDown) {
72
75
  altDown = true
@@ -10,6 +10,8 @@ import { fitWindow, place, roomBelow } from './placement.mjs'
10
10
  const PUMP_VISIBLE_MS = 16
11
11
  const PUMP_HIDDEN_MS = 250
12
12
  const PAGE_READY_TIMEOUT_MS = 5000
13
+ // Pointer updates for hover: one per frame is as often as the page can draw them.
14
+ const POINTER_INTERVAL_MS = 16
13
15
  // The least height a panel gets for its answer before it scrolls: about eight lines plus the header and
14
16
  // the follow-up box. Below the selection it may grow further, down to the bottom of the screen.
15
17
  const PANEL_MIN_ROOM = 280
@@ -57,6 +59,9 @@ export async function createOverlay({ onMessage, log }) {
57
59
  focused: false,
58
60
  alwaysOnTop: true,
59
61
  windowsSkipTaskbar: true,
62
+ // tao keeps a system shadow on undecorated windows unless told not to; on Windows it outlines the
63
+ // whole transparent window with a 1px border and adds hidden insets around the client area.
64
+ windowsUndecoratedShadow: false,
60
65
  macosHasShadow: false,
61
66
  })
62
67
  // WebView2 defaults its data folder next to the executable, i.e. inside the DSH install directory.
@@ -76,6 +81,10 @@ export async function createOverlay({ onMessage, log }) {
76
81
  let bounds = null
77
82
  // How tall the panel was last allowed to grow, and whether to fill that at once, as told to the page.
78
83
  let room = null
84
+ // The pointer as passed to the page: at most once a frame, and once more when it leaves the window.
85
+ let pointerTimer = null
86
+ let pointerNext = null
87
+ let pointerInside = false
79
88
  // Once the user drags the window it stays where they put it: later size changes keep its top-left.
80
89
  let moved = false
81
90
  let drag = null
@@ -179,7 +188,7 @@ export async function createOverlay({ onMessage, log }) {
179
188
  }
180
189
 
181
190
  return {
182
- /** @param {{ bar: object[], more: object[] }} toolbar Skills on the toolbar and in the ⋯ menu. */
191
+ /** @param {{ bar: object[], more: object[], style: 'full' | 'mini' }} toolbar Skills on the toolbar and in its menu, and how the toolbar is drawn. */
183
192
  configure: (toolbar) => call('configure', toolbar),
184
193
  /** @param {Anchor} point In selection-hook coordinates. */
185
194
  showBar(point) {
@@ -229,6 +238,9 @@ export async function createOverlay({ onMessage, log }) {
229
238
  },
230
239
  hide() {
231
240
  if (mode === 'hidden') return
241
+ clearTimeout(pointerTimer)
242
+ pointerTimer = null
243
+ pointerInside = false
232
244
  overlayWindow.hide()
233
245
  mode = 'hidden'
234
246
  placement = null
@@ -239,6 +251,23 @@ export async function createOverlay({ onMessage, log }) {
239
251
  call('reset')
240
252
  setPump(PUMP_HIDDEN_MS)
241
253
  },
254
+ /**
255
+ * Passes the pointer (selection-hook coordinates) to the page while the window shows: macOS sends
256
+ * no mouse moves to the window of a background process, so the page draws hover from these.
257
+ */
258
+ pointer(point) {
259
+ if (mode === 'hidden' || !bounds) return
260
+ const { x, y } = toLogical(point)
261
+ const inside = x >= bounds.x && x < bounds.x + bounds.width && y >= bounds.y && y < bounds.y + bounds.height
262
+ if (!inside && !pointerInside) return
263
+ pointerInside = inside
264
+ pointerNext = inside ? { x: Math.round(x - bounds.x), y: Math.round(y - bounds.y) } : null
265
+ if (pointerTimer) return
266
+ pointerTimer = setTimeout(() => {
267
+ pointerTimer = null
268
+ if (mode !== 'hidden') call('pointer', pointerNext)
269
+ }, POINTER_INTERVAL_MS)
270
+ },
242
271
  get mode() { return mode },
243
272
  /** Where the window was placed, in the same coordinates as `contains` (self tests and diagnostics). */
244
273
  get bounds() { return bounds },
@@ -1,14 +1,14 @@
1
1
  // The toolbar page as one HTML string: the web view loads it with no file or network access.
2
2
  // Kept apart from overlay.mjs, which loads the native web view, so tests can load the same page.
3
3
  import { readFileSync } from 'node:fs'
4
- import { ICONS } from '../shared/icons.mjs'
4
+ import { ICONS, brandIcon } from '../shared/icons.mjs'
5
5
 
6
6
  /** Inlines the page's stylesheet, scripts and icon table. */
7
7
  export function pageHtml() {
8
8
  const read = (name) => readFileSync(new URL(`../../ui/${name}`, import.meta.url), 'utf8')
9
9
  return read('toolbar.html')
10
10
  .replace('<link rel="stylesheet" href="toolbar.css">', () => `<style>${read('toolbar.css')}</style>`)
11
- .replace('<script src="icons.js"></script>', () => `<script>window.DSH_ICONS = ${JSON.stringify(ICONS)}</script>`)
11
+ .replace('<script src="icons.js"></script>', () => `<script>window.DSH_ICONS = ${JSON.stringify(ICONS)}; window.DSH_BRAND = ${JSON.stringify(brandIcon('brand', '5 5 26 26'))}</script>`)
12
12
  .replace('<script src="markdown.js"></script>', () => `<script>${read('markdown.js')}</script>`)
13
13
  .replace('<script src="toolbar.js"></script>', () => `<script>${read('toolbar.js')}</script>`)
14
14
  }
package/ui/toolbar.css CHANGED
@@ -49,9 +49,10 @@ svg { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; s
49
49
 
50
50
  button, select, textarea { font: inherit; color: inherit; }
51
51
  button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; background: none; border-radius: 8px; cursor: default; }
52
- /* Hover waits for a real pointer movement after each show (data-pointer, see toolbar.js). */
53
- [data-pointer] button:hover { background: var(--hover); }
54
- button:active, [data-pointer] button:active { background: var(--press); }
52
+ /* Hover is data-hover, set from the pointer the worker reports (see bar.pointer in toolbar.js): macOS
53
+ sends no mouse moves to the window of a background process, so :hover would never apply. */
54
+ button[data-hover] { background: var(--hover); }
55
+ button:active { background: var(--press); }
55
56
  button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
56
57
  .icon-button { width: 30px; height: 30px; padding: 0; color: var(--text); }
57
58
  .icon-button[disabled] { opacity: 0.35; pointer-events: none; }
@@ -63,7 +64,13 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
63
64
  .skill { height: 32px; padding: 0 10px 0 9px; font-weight: 500; letter-spacing: 0.1px; }
64
65
  .skill.done svg { color: #1f9d55; }
65
66
  .divider { width: 1px; height: 16px; margin: 0 4px; background: var(--hairline); }
66
- .more-button[aria-expanded="true"] { background: var(--press); }
67
+ .more-button[aria-expanded="true"], .mini-button[aria-expanded="true"] { background: var(--press); }
68
+ /* Mini toolbar: one icon, and every skill in its menu. */
69
+ .mini-button { display: none; color: var(--accent); }
70
+ .mini-button svg { width: 20px; height: 20px; }
71
+ [data-style="mini"] .bar { padding: 3px; border-radius: 10px; }
72
+ [data-style="mini"] .bar-skills, [data-style="mini"] .divider, [data-style="mini"] .more-button { display: none; }
73
+ [data-style="mini"] .mini-button { display: inline-flex; }
67
74
 
68
75
  /* ⋯ menu, opened below the more button */
69
76
  .menu { display: none; position: absolute; min-width: 190px; padding: 6px; border-radius: 12px; z-index: 1; }
@@ -90,10 +97,10 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
90
97
  color: var(--text); font-size: 12px; font-weight: 500; line-height: 26px; cursor: default;
91
98
  text-overflow: ellipsis; white-space: nowrap; overflow: hidden;
92
99
  }
93
- [data-pointer] .panel-language:hover { background-color: var(--press); }
100
+ .panel-language[data-hover] { background-color: var(--press); }
94
101
  [data-kind="translate"] .panel-language { display: inline-block; }
95
102
  .tool { width: 28px; height: 28px; color: var(--muted); }
96
- [data-pointer] .tool:hover { color: var(--text); }
103
+ .tool[data-hover] { color: var(--text); }
97
104
  .tool[aria-pressed="true"] { color: var(--accent); background: var(--hover); }
98
105
  .tool.done { color: #1f9d55; }
99
106
  [data-kind="ask"] .tool[data-tool="retry"] { display: none; }
@@ -101,6 +108,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
101
108
  .panel-body { flex: 0 1 auto; min-height: 0; padding: 0 16px 14px; max-height: 340px; overflow-y: auto; overscroll-behavior: contain; }
102
109
  [data-fill] .panel { height: var(--room); }
103
110
  [data-fill] .panel-body { flex-grow: 1; max-height: none; }
111
+ /* A styled scrollbar, so Windows draws a thin bar without its classic arrow buttons. */
104
112
  .panel-body::-webkit-scrollbar { width: 8px; }
105
113
  .panel-body::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 4px; border: 2px solid var(--surface); }
106
114
  .source {
@@ -126,12 +134,15 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
126
134
  .composer { flex: none; display: none; align-items: flex-end; gap: 6px; padding: 8px 10px 10px 14px; border-top: 1px solid var(--hairline); }
127
135
  [data-kind="ask"] .composer, [data-composer="shown"] .composer { display: flex; }
128
136
  .composer textarea {
129
- flex: 1; min-height: 22px; max-height: 96px; padding: 4px 0; border: 0; outline: 0; resize: none; background: transparent;
137
+ flex: 1; min-height: 28px; max-height: 96px; padding: 4px 0; border: 0; outline: 0; resize: none; background: transparent;
138
+ overflow-y: hidden; scrollbar-width: none;
130
139
  line-height: 1.5; user-select: text; -webkit-user-select: text; cursor: text;
131
140
  }
141
+ /* Past its tallest size it scrolls with the wheel and the caret, without a bar. */
142
+ .composer textarea::-webkit-scrollbar { display: none; }
132
143
  .composer textarea::placeholder { color: var(--muted); }
133
144
  .send { width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff; }
134
- [data-pointer] .send:hover { background: var(--accent); filter: brightness(1.08); }
145
+ .send[data-hover] { background: var(--accent); filter: brightness(1.08); }
135
146
  .send[disabled] { opacity: 0.35; }
136
147
 
137
148
  /* Model output */
@@ -145,7 +156,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
145
156
  .markdown pre code { padding: 0; background: none; font-size: 12px; line-height: 1.5; }
146
157
  .markdown blockquote { padding-left: 10px; border-left: 3px solid var(--hairline); color: var(--muted); }
147
158
  .markdown a { color: var(--accent); text-decoration: none; cursor: pointer; }
148
- [data-pointer] .markdown a:hover { text-decoration: underline; }
159
+ .markdown a[data-hover] { text-decoration: underline; }
149
160
  .markdown table { border-collapse: collapse; font-size: 12.5px; }
150
161
  .markdown th, .markdown td { padding: 4px 8px; border: 1px solid var(--hairline); text-align: left; vertical-align: top; }
151
162
  .markdown th { background: var(--soft); font-weight: 600; }
package/ui/toolbar.html CHANGED
@@ -13,11 +13,13 @@
13
13
  <div class="bar-skills"></div>
14
14
  <span class="divider" aria-hidden="true"></span>
15
15
  <button type="button" class="icon-button more-button" data-i18n-label="more" aria-haspopup="menu" aria-expanded="false"></button>
16
+ <button type="button" class="icon-button mini-button" data-i18n-label="assistant" aria-haspopup="menu" aria-expanded="false"></button>
16
17
  </div>
17
18
 
18
19
  <div class="card menu" role="menu">
19
20
  <div class="menu-skills"></div>
20
21
  <div class="menu-divider" aria-hidden="true"></div>
22
+ <button type="button" class="menu-item plain" role="menuitem" data-menu-action="toolbar-style"></button>
21
23
  <button type="button" class="menu-item plain" role="menuitem" data-menu-action="disable-app" data-i18n="disableApp"></button>
22
24
  <button type="button" class="menu-item plain" role="menuitem" data-menu-action="settings" data-i18n="settings"></button>
23
25
  </div>
package/ui/toolbar.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const STRINGS = {
4
4
  zh: {
5
5
  more: '更多', disableApp: '在此应用中禁用', settings: '更多设置…', copied: '已复制', stop: '停止',
6
+ assistant: '划词助手', miniStyle: '切换为迷你模式', fullStyle: '切换为常规模式',
6
7
  close: '关闭', copyResult: '复制结果', retry: '重新生成', pin: '固定窗口', translateTo: '译为', auto: '自动',
7
8
  askPlaceholder: '就这段内容问点什么…', followPlaceholder: '接着问…', send: '发送', waiting: '正在生成', retryShort: '重试', from: '来自 {app}',
8
9
  errors: {
@@ -15,6 +16,7 @@
15
16
  },
16
17
  en: {
17
18
  more: 'More', disableApp: 'Disable in this app', settings: 'More settings…', copied: 'Copied', stop: 'Stop',
19
+ assistant: 'Selection assistant', miniStyle: 'Use the mini toolbar', fullStyle: 'Use the full toolbar',
18
20
  close: 'Close', copyResult: 'Copy result', retry: 'Regenerate', pin: 'Keep open', translateTo: 'Translate to', auto: 'Auto',
19
21
  askPlaceholder: 'Ask something about this text…', followPlaceholder: 'Ask a follow-up…', send: 'Send', waiting: 'Generating', retryShort: 'Retry', from: 'from {app}',
20
22
  errors: {
@@ -38,8 +40,10 @@
38
40
  const els = {
39
41
  barSkills: $('.bar-skills'),
40
42
  more: $('.more-button'),
43
+ mini: $('.mini-button'),
41
44
  menu: $('.menu'),
42
45
  menuSkills: $('.menu-skills'),
46
+ styleItem: $('[data-menu-action="toolbar-style"]'),
43
47
  panel: $('.panel'),
44
48
  panelIcon: $('.panel-icon'),
45
49
  panelTitle: $('.panel-title'),
@@ -64,6 +68,7 @@
64
68
  })
65
69
  document.querySelectorAll('[data-i18n-placeholder]').forEach((node) => { node.placeholder = t[node.dataset.i18nPlaceholder] })
66
70
  els.more.innerHTML = icon('more')
71
+ els.mini.innerHTML = window.DSH_BRAND || icon('sparkles')
67
72
  $('[data-menu-action="disable-app"]').insertAdjacentHTML('afterbegin', icon('ban'))
68
73
  $('[data-menu-action="settings"]').insertAdjacentHTML('afterbegin', icon('settings'))
69
74
  for (const [tool, name] of [['pin', 'pin'], ['copy', 'copy'], ['retry', 'retry'], ['close', 'close']]) {
@@ -73,6 +78,34 @@
73
78
 
74
79
  const emptyState = (requestId = null, pending = false) => ({ requestId, text: '', turn: null, pending, failure: null })
75
80
  let skills = { bar: [], more: [] }
81
+ let style = 'full'
82
+ // The mini toolbar opens its menu once the pointer rests on the icon, and closes it once the pointer
83
+ // has been out of the window for a moment.
84
+ const HOVER_OPEN_MS = 120
85
+ const HOVER_CLOSE_MS = 300
86
+ let openTimer = null
87
+ let closeTimer = null
88
+ /** The control drawn as hovered. */
89
+ let hovered = null
90
+
91
+ // The menu offers whichever style is not in use.
92
+ function drawStyleItem() {
93
+ els.styleItem.innerHTML = `${icon(style === 'mini' ? 'maximize' : 'minimize')}<span></span>`
94
+ els.styleItem.querySelector('span').textContent = style === 'mini' ? t.fullStyle : t.miniStyle
95
+ }
96
+ drawStyleItem()
97
+
98
+ function stopHoverTimers() {
99
+ clearTimeout(openTimer)
100
+ clearTimeout(closeTimer)
101
+ openTimer = null
102
+ closeTimer = null
103
+ }
104
+
105
+ function clearHover() {
106
+ hovered?.removeAttribute('data-hover')
107
+ hovered = null
108
+ }
76
109
  let state = emptyState()
77
110
  let renderTimer = null
78
111
  // Timers rather than requestAnimationFrame: the view can be occluded or the screen locked, and then
@@ -90,13 +123,16 @@
90
123
  }
91
124
 
92
125
  function setMenu(open) {
126
+ stopHoverTimers()
93
127
  if (open) {
94
128
  const bar = $('.bar')
95
- els.menu.style.left = `${bar.offsetLeft + els.more.offsetLeft - 6}px`
129
+ const button = style === 'mini' ? els.mini : els.more
130
+ els.menu.style.left = `${bar.offsetLeft + button.offsetLeft - 6}px`
96
131
  els.menu.style.top = `${bar.offsetTop + bar.offsetHeight + 6}px`
97
132
  }
98
133
  body.dataset.menu = open ? 'open' : 'closed'
99
- els.more.setAttribute('aria-expanded', String(open))
134
+ els.more.setAttribute('aria-expanded', String(open && style === 'full'))
135
+ els.mini.setAttribute('aria-expanded', String(open && style === 'mini'))
100
136
  queueSize()
101
137
  }
102
138
 
@@ -151,12 +187,23 @@
151
187
  window.bar = {
152
188
  configure(next) {
153
189
  skills = next
154
- els.barSkills.replaceChildren(...next.bar.map((skill) => skillButton(skill, 'skill')))
155
- els.menuSkills.replaceChildren(...next.more.map((skill) => skillButton(skill, 'menu-item')))
190
+ const nextStyle = next.style === 'mini' ? 'mini' : 'full'
191
+ if (nextStyle !== style) {
192
+ setMenu(false)
193
+ style = nextStyle
194
+ }
195
+ body.dataset.style = style
196
+ // The mini toolbar has no skill buttons of its own: every skill is in its menu.
197
+ const onBar = style === 'mini' ? [] : next.bar
198
+ const inMenu = style === 'mini' ? [...next.bar, ...next.more] : next.more
199
+ els.barSkills.replaceChildren(...onBar.map((skill) => skillButton(skill, 'skill')))
200
+ els.menuSkills.replaceChildren(...inMenu.map((skill) => skillButton(skill, 'menu-item')))
201
+ drawStyleItem()
156
202
  queueSize()
157
203
  },
158
204
  showBar() {
159
- delete body.dataset.pointer
205
+ stopHoverTimers()
206
+ clearHover()
160
207
  body.dataset.mode = 'bar'
161
208
  setMenu(false)
162
209
  },
@@ -164,7 +211,7 @@
164
211
  if (body.dataset.mode === 'bar') setMenu(Boolean(open))
165
212
  },
166
213
  showPanel({ skill, text, app, languages, translateTarget }) {
167
- delete body.dataset.pointer
214
+ clearHover()
168
215
  body.dataset.mode = 'panel'
169
216
  body.dataset.menu = 'closed'
170
217
  body.dataset.kind = skill.kind === 'ask' ? 'ask' : skill.id === 'translate' ? 'translate' : 'prompt'
@@ -233,6 +280,7 @@
233
280
  if (requestId !== state.requestId) return
234
281
  state.pending = false
235
282
  body.dataset.composer = 'shown'
283
+ resizeInput()
236
284
  scheduleRender()
237
285
  },
238
286
  fail(requestId, failure) {
@@ -250,8 +298,8 @@
250
298
  queueSize()
251
299
  },
252
300
  flash(skillId) {
253
- // A skill run from the ⋯ menu confirms on the ⋯ button: the menu has closed by then.
254
- flashDone(els.barSkills.querySelector(`[data-skill="${skillId}"]`) || els.more, t.copied)
301
+ // A skill run from the menu confirms on the button that opens the menu: the menu has closed by then.
302
+ flashDone(els.barSkills.querySelector(`[data-skill="${skillId}"]`) || (style === 'mini' ? els.mini : els.more), t.copied)
255
303
  },
256
304
  /**
257
305
  * How tall the panel may grow before it scrolls inside, from the worker's placement. With `fill`
@@ -264,8 +312,36 @@
264
312
  else delete body.dataset.fill
265
313
  queueSize()
266
314
  },
315
+ /**
316
+ * Where the pointer is in the window, or null once it has left, from the worker's global mouse
317
+ * hook: macOS sends no mouse moves to the window of a background process, so hover is drawn here.
318
+ */
319
+ pointer(point) {
320
+ const target = point ? document.elementFromPoint(point.x, point.y) : null
321
+ const control = target?.closest('button, select, a') ?? null
322
+ if (control !== hovered) {
323
+ clearHover()
324
+ control?.setAttribute('data-hover', '')
325
+ hovered = control
326
+ }
327
+ if (style !== 'mini' || body.dataset.mode !== 'bar') return
328
+ if (body.dataset.menu !== 'open') {
329
+ if (!target?.closest('.mini-button') || press) {
330
+ clearTimeout(openTimer)
331
+ openTimer = null
332
+ } else if (openTimer === null) {
333
+ openTimer = setTimeout(() => setMenu(true), HOVER_OPEN_MS)
334
+ }
335
+ } else if (point) {
336
+ clearTimeout(closeTimer)
337
+ closeTimer = null
338
+ } else if (closeTimer === null) {
339
+ closeTimer = setTimeout(() => setMenu(false), HOVER_CLOSE_MS)
340
+ }
341
+ },
267
342
  reset() {
268
- delete body.dataset.pointer
343
+ stopHoverTimers()
344
+ clearHover()
269
345
  body.dataset.mode = 'hidden'
270
346
  body.dataset.menu = 'closed'
271
347
  if (document.activeElement) document.activeElement.blur()
@@ -282,6 +358,7 @@
282
358
  type: 'probe',
283
359
  mode: body.dataset.mode,
284
360
  menu: body.dataset.menu,
361
+ hovered: hovered ? hovered.getAttribute('aria-label') || hovered.textContent.trim() : null,
285
362
  viewport: { width: innerWidth, height: innerHeight },
286
363
  card: { width: Math.round(box.width), height: Math.round(box.height) },
287
364
  buttons: buttons.map((button) => button.getAttribute('aria-label') || button.textContent.trim()),
@@ -339,7 +416,12 @@
339
416
 
340
417
  function resizeInput() {
341
418
  els.input.style.height = 'auto'
342
- els.input.style.height = `${Math.min(els.input.scrollHeight, 96)}px`
419
+ const height = els.input.scrollHeight
420
+ // A hidden box measures nothing; it is measured again when it shows.
421
+ if (height) els.input.style.height = `${Math.min(height, 96)}px`
422
+ // Scrollable only past its tallest size: at fractional display scaling one line can overflow by a
423
+ // fraction of a pixel, which would otherwise let it scroll.
424
+ els.input.style.overflowY = height > 96 ? 'auto' : 'hidden'
343
425
  els.send.disabled = !els.input.value.trim()
344
426
  }
345
427
 
@@ -358,6 +440,10 @@
358
440
  post({ type: 'skill', id: skill.dataset.skill })
359
441
  return
360
442
  }
443
+ if (event.target.closest('.mini-button')) {
444
+ setMenu(true)
445
+ return
446
+ }
361
447
  if (event.target.closest('.more-button')) {
362
448
  setMenu(body.dataset.menu !== 'open')
363
449
  return
@@ -478,13 +564,6 @@
478
564
  if (body.dataset.mode === 'panel') post({ type: 'keyboard', needed: true })
479
565
  })
480
566
 
481
- // WebKit keeps :hover on whatever was under the pointer when it last saw one, through hiding and
482
- // showing the window, and this process is never the active app, so a pointer that does not move
483
- // over the page is not tracked. Hover styles therefore wait for a real movement after each show.
484
- document.addEventListener('pointermove', () => {
485
- if (!('pointer' in body.dataset)) body.dataset.pointer = ''
486
- })
487
-
488
567
  // Dropping a link or file would navigate the view away from this page.
489
568
  for (const type of ['dragover', 'drop']) document.addEventListener(type, (event) => event.preventDefault())
490
569
  window.addEventListener('error', (event) => post({ type: 'page-error', message: String(event.message) }))