@tom2012/dsh-pomodoro-overlay 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,102 @@
1
+ # 验证记录
2
+
3
+ 验证日期:2026-10-25
4
+ 目标版本:DSH `0.2.1-rc.2`
5
+ GUI:`https://dsh.zhbc.bid:3003/`(不记录认证参数)
6
+ 历史真实 GUI 方法:正式 `dsh plugin --profile web add` 本地 link 安装,重启原部署,在隔离 Chrome 中运行当时的 Host/Client bundle。当前修订的验证范围见下文,不与历史结果混用。
7
+
8
+ ## 自动化
9
+
10
+ - `npm run verify`:通过。
11
+ - `node:test`:**38/38 通过**(覆盖原生设置 slot、外观与字体校验、字体栈及 store 层外观即时更新语义;不是 DOM 样式验收)。
12
+ - 历史独立只读审查:当时无阻断、高、中严重度问题;逐步推进 oracle 对拍 `settleState` 的 896 组边界全部一致。此后未修改 `settleState` 的阶段推进算法。
13
+ - 构建后客户端冒烟:ModuleLoader 加载、配置 ready 后 store 启动、slots 注册,以及 `style.remove`、store 的 `storage` / `focus` / `pageshow` / `visibilitychange` 监听和 interval 清理通过。测试的 React 桩不会挂载 `ActiveOverlay`,因此不宣称 `ResizeObserver` / `MutationObserver`、窗口 resize / scroll、rAF / timeout 或 overlay DOM 清理已做真实 DOM 测试。
14
+ - `npm publish --dry-run`:当前发布包内容检查通过。
15
+
16
+ ## 当前发布基线:0.1.2(2026-10-25)
17
+
18
+ - npm 包及运行时包标识为 `@tom2012/dsh-pomodoro-overlay`,发布版本为 `0.1.2`;GitHub 仓库继续使用 `zbc0315/dsh-pomodoro-overlay`。
19
+ - Cordis patch ID 与 ConfigForm ID 保持 `pomodoro-overlay`,localStorage 键保持 `dsh-pomodoro-overlay:state:v1`;作用域迁移不更改这些持久化读取键。
20
+ - 当前聊天区只显示大倒计时,不显示可见阶段标签、工具条或控制按钮;屏幕阅读器状态保留。
21
+ - 旧版 `running: false` 快照会继续保持暂停;README 已说明仅删除 `dsh-pomodoro-overlay:state:v1` 并刷新的重置方法,不影响 DSH 配置。
22
+ - 当前版本完成自动化测试、构建产物静态检查和 npm 发布演练。下述历史 GUI 结果仅作回归基线,不作为当前七字段设置、字体保存或无阶段标签 UI 已完成真实 GUI 验收的证明。
23
+
24
+ ## 当前功能:原生设置窗口与外观配置(2026-10-25)
25
+
26
+ - 设置入口已从 `plugins.bundle.config` 迁移到 DSH 设置窗口的 `settings.section`,导航 ID 为 `pomodoro`。
27
+ - Host schema 和 ConfigForm 已扩展为七字段:全局字体、专注/休息时长、`#RRGGBB` 颜色和 0–1 不透明度;设置 UI 显示 0–100%。
28
+ - 字体默认等线(DengXian),支持本机字体名称和常用候选;只解析本地字体栈,不下载字体,切换后重新测量数字宽度。
29
+ - 两阶段草稿预览共用所选字体;七个字段使用同一 revision 一次原子保存。
30
+ - `npm run verify` 通过,**38/38 测试通过**;旧配置自动补齐默认字体与外观,不改变运行时间线。
31
+ - Host schema 变更需要在**外部 Terminal** 重载 DSH;禁止从 DSH 会话内部杀掉自己的宿主。真实窗口保存验收将在外部重载后补记。
32
+
33
+ ## 当前修订:移除可见阶段标签(2026-10-25)
34
+
35
+ - 应用户要求,聊天区不再显示「专注 / 休息」阶段标签及其胶囊样式,只保留大倒计时。
36
+ - 屏幕阅读器的阶段与剩余时间文本保留;颜色、透明度、字体和计时行为不变。
37
+ - 包检查新增约束:客户端不得重新出现 `dpo-phase` 可见标签或对应 `data-dpo-phase` 标记。
38
+ - `npm run verify` 通过,**38/38 测试通过**。
39
+
40
+ ## 历史修订:聊天区纯展示(2026-10-25)
41
+
42
+ 应用户要求,已移除聊天区右下角的「暂停 / 跳过 / 重置」及整个工具条。该版本 overlay 保留阶段标签、大倒计时和屏幕阅读器时间文本;设置页的保存/放弃按钮不受影响。
43
+
44
+ - `npm run verify` 再次通过,**34/34 测试通过**。
45
+ - 包检查新增约束:构建产物不得包含 `data-dpo-action`、`dpo-toolbar` 或「番茄钟控制」。
46
+ - 真实客户端 DOM 检查:`overlayButtons: 0`、`actionControls: 0`、`toolbarNodes: 0`、`toolbarStyles: false`。
47
+ - 阶段标签与根 overlay 的 `pointer-events` 均为 `none`,继续不阻断聊天操作。
48
+
49
+ ## 历史真实 GUI 基线(含已移除的控制按钮)
50
+
51
+ 下表是此前带控制按钮版本的完整回归记录;其中暂停/继续、跳过/重置、工具栏和 44px 聊天按钮行仅作历史证据,不再描述当前聊天区 UI。底层状态机及自动循环行为未改变。
52
+
53
+ | 项目 | 结果 |
54
+ | --- | --- |
55
+ | 插件加载 | 插件详情显示 `v0.1.0`,包含的组件共 1 个、运行中 |
56
+ | 自动启动 | 原生配置 ready 后无需点击即开始专注倒计时 |
57
+ | 设置读取 | 设置页显示默认专注 25、休息 5,两个输入框均可编辑 |
58
+ | 非法输入 | 专注设为 0 后显示「请输入 1–240 之间的整数分钟」,未保存 |
59
+ | 原子保存 | 改为 1/1 后一次保存成功,出现新时长从下一阶段生效的提示 |
60
+ | 当前阶段不变 | 保存 1/1 前后,当前 25 分钟专注的 `endsAt` 完全相等,仍显示约 24 分钟 |
61
+ | 下一阶段生效 | 跳过后休息显示 `01:00`;保存 1/1 后的自动循环均为 60 秒 |
62
+ | 专注配色 | 计算样式为主题灰色,alpha `0.2` |
63
+ | 休息配色 | 计算样式为主题红色,alpha `0.3` |
64
+ | 暂停/继续 | 暂停后连续 1.3 秒文字不变并显示「已暂停」;继续后文字随时间减少 |
65
+ | 跳过/重置 | 专注→休息→专注正确;重置回到运行中的 `25:00` 专注 |
66
+ | 桌面几何 | overlay 的 left/top/width 与聊天区一致,bottom 等于 composer 顶部 |
67
+ | 数字适配 | 桌面示例中 322px 数字完整位于 920×584 的 overlay 内 |
68
+ | 鼠标穿透 | overlay 与数字均为 `pointer-events: none`,中心命中元素不属于插件;工具栏为 `auto` |
69
+ | 触控目标 | 暂停/继续、跳过、重置按钮高度均为 44px |
70
+ | 自动休息→专注 | 在原休息边界准确进入专注,新 `startedAt` 等于旧 `endsAt`,时长 60000ms |
71
+ | 自动专注→休息 | 在原专注边界准确进入休息,新 `startedAt` 等于旧 `endsAt`,时长 60000ms |
72
+ | 刷新恢复 | 刷新前后运行状态 `endsAt` 完全相等,并继续原时间线 |
73
+ | 非聊天视图 | 空白新会话没有消息流时隐藏;插件设置页中也隐藏 |
74
+ | 窄窗口 | 380px viewport 下 overlay 为 324×523,数字缩至 106px,无裁切,仍扣除 composer |
75
+
76
+ 自动边界测试只在隔离浏览器的 localStorage 中构造合法、即将到期的运行状态,以缩短等待;执行的是实际刷新、原生配置加载和正式客户端计时逻辑,没有 mock 定时器或替换组件。
77
+
78
+ 测试结束后:
79
+
80
+ - 原生配置已恢复为 **25/5**。
81
+ - 隔离浏览器中的运行状态已重置为运行中的 25 分钟专注。
82
+ - 测试 Chrome 已关闭,临时 profile、重启脚本和临时日志已删除。
83
+
84
+ ## 历史 GUI 环境备注(既有冲突)
85
+
86
+ 未隔离的首次启动出现:
87
+
88
+ ```text
89
+ Failed to load plugins
90
+ @deepseek-ai/dsh-client-ui-sidebar-files
91
+ web boot: 1 entry did not activate
92
+ ```
93
+
94
+ 捕获到的原始异常是:
95
+
96
+ ```text
97
+ slot "sidebar.right.tab.files.actions" is already declared
98
+ ```
99
+
100
+ 只读检查确认,既有 `@tom/dsh-file-tree` 与 DSH 内置 `@deepseek-ai/dsh-client-ui-sidebar-files` 同时声明了这个 slot;番茄钟没有声明或修改该 slot。为让该次历史番茄钟自测能够进入真实聊天区,仅在隔离 Chrome 的 boot 清单中临时跳过了重复的内置 sidebar-files 客户端,并关闭该测试页的 HMR 以防完整清单再次注入。没有因此修改 DSH、file-tree 或 profile。
101
+
102
+ 因此:**上述历史版本的番茄钟真实 GUI 检查全部通过,但这既不表示当前修订已完成全部 GUI 验收,也不表示未隔离的日常 GUI 已解决上述既有冲突。** 日常 GUI 若要正常启动,需要另行处理 file-tree 与内置 sidebar-files 的重复声明;历史测试没有擅自卸载或改写它们。
package/lib/index.js ADDED
@@ -0,0 +1,28 @@
1
+ import z from '@deepseek-ai/schemastery'
2
+
3
+ export const name = '@tom2012/dsh-pomodoro-overlay'
4
+
5
+ /** Volatile fields are exposed through DSH's revision-fenced settings forms. */
6
+ export const Config = z.object({
7
+ focusMinutes: z.number().step(1).min(1).max(240).default(25).volatile()
8
+ .description('专注时长(分钟),从下一专注阶段生效'),
9
+ breakMinutes: z.number().step(1).min(1).max(240).default(5).volatile()
10
+ .description('休息时长(分钟),从下一休息阶段生效'),
11
+ focusColor: z.string().pattern(/^#[0-9a-f]{6}$/i).default('#9ca3af').volatile()
12
+ .description('专注倒计时颜色,保存后立即生效'),
13
+ breakColor: z.string().pattern(/^#[0-9a-f]{6}$/i).default('#ef4444').volatile()
14
+ .description('休息倒计时颜色,保存后立即生效'),
15
+ focusOpacity: z.number().step(0.01).min(0).max(1).default(0.1).volatile()
16
+ .description('专注倒计时数字不透明度,0–1'),
17
+ breakOpacity: z.number().step(0.01).min(0).max(1).default(0.3).volatile()
18
+ .description('休息倒计时数字不透明度,0–1'),
19
+ fontFamily: z.string().pattern(/^[\p{L}\p{N}][\p{L}\p{N} _.-]{0,79}$/u).default('等线').volatile()
20
+ .description('专注与休息共用的时钟字体,默认等线(DengXian)'),
21
+ })
22
+
23
+ export function apply(ctx) {
24
+ // The plugin provides its own settings UI; disable any generated placeholder.
25
+ ctx.inject(['settings'], (child) => {
26
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber))
27
+ })
28
+ }
package/package.json CHANGED
@@ -1,6 +1,79 @@
1
1
  {
2
2
  "name": "@tom2012/dsh-pomodoro-overlay",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "Automatic focus/break Pomodoro cycles with a chat-sized translucent countdown for DeepSeek Harness Web.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/zbc0315/dsh-pomodoro-overlay.git"
10
+ },
11
+ "homepage": "https://github.com/zbc0315/dsh-pomodoro-overlay#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/zbc0315/dsh-pomodoro-overlay/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public",
17
+ "registry": "https://registry.npmjs.org/"
18
+ },
19
+ "engines": {
20
+ "node": "^22.19.0 || >=24.0.0",
21
+ "dsh": ">=0.2.0-rc.1 <0.3.0-0"
22
+ },
23
+ "files": [
24
+ "lib",
25
+ "client",
26
+ "src",
27
+ "tests",
28
+ "tools",
29
+ "cordis.patch.yml",
30
+ "docs",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "build": "node tools/build.mjs",
36
+ "check": "node --check lib/index.js && node --check client/client.js && node tools/check-package.mjs",
37
+ "test": "node --test tests/*.test.js",
38
+ "verify": "npm run build && npm run check && npm test"
39
+ },
40
+ "dsh": {
41
+ "category": "extension",
42
+ "displayName": "番茄钟 Overlay",
43
+ "bundle": {
44
+ "patch": "./cordis.patch.yml"
45
+ },
46
+ "client": {
47
+ "inject": [
48
+ "@deepseek-ai/dsh-client-ui-layout",
49
+ "@deepseek-ai/dsh-client-ui-settings"
50
+ ],
51
+ "platform": "web",
52
+ "immediately": true
53
+ }
54
+ },
55
+ "exports": {
56
+ ".": "./lib/index.js",
57
+ "./client": "./client/client.js",
58
+ "./cordis.patch.yml": "./cordis.patch.yml",
59
+ "./package.json": "./package.json"
60
+ },
61
+ "peerDependencies": {
62
+ "@deepseek-ai/cordis": ">=4.0.0 <5.0.0",
63
+ "@deepseek-ai/dsh": ">=0.2.0-rc.1 <0.3.0-0"
64
+ },
65
+ "dependencies": {
66
+ "@deepseek-ai/schemastery": "~3.18.5"
67
+ },
68
+ "devDependencies": {
69
+ "esbuild": "^0.25.11"
70
+ },
71
+ "keywords": [
72
+ "dsh-plugin",
73
+ "deepseek-harness",
74
+ "pomodoro",
75
+ "timer",
76
+ "focus",
77
+ "番茄钟"
78
+ ]
79
+ }
@@ -0,0 +1,251 @@
1
+ import {
2
+ DEFAULT_CONFIG,
3
+ FOCUS,
4
+ createRunningState,
5
+ formatRemaining,
6
+ pauseState,
7
+ parseConfig,
8
+ remainingAt,
9
+ resetState,
10
+ resumeState,
11
+ settleState,
12
+ skipState,
13
+ stateFromJSON,
14
+ } from './timer-core.js'
15
+
16
+ export const STATE_KEY = 'dsh-pomodoro-overlay:state:v1'
17
+
18
+ function defaultStorage() {
19
+ try {
20
+ return typeof window === 'undefined' ? null : window.localStorage
21
+ } catch {
22
+ return null
23
+ }
24
+ }
25
+
26
+ function defaultEventTarget() {
27
+ return typeof window === 'undefined' ? null : window
28
+ }
29
+
30
+ function defaultDocument() {
31
+ return typeof document === 'undefined' ? null : document
32
+ }
33
+
34
+ /**
35
+ * Browser-local runtime state. Configuration arrives from DSH's native
36
+ * ConfigForm after it is ready; this store never substitutes defaults while
37
+ * that asynchronous read is still pending.
38
+ */
39
+ export function createPomodoroStore(options = {}) {
40
+ const now = options.now ?? (() => Date.now())
41
+ const storage = options.storage === undefined ? defaultStorage() : options.storage
42
+ const events = options.events === undefined ? defaultEventTarget() : options.events
43
+ const documentRef = options.document === undefined ? defaultDocument() : options.document
44
+ const timers = options.timers ?? events
45
+ const tickMs = options.tickMs ?? 250
46
+
47
+ const listeners = new Set()
48
+ let config = parseConfig(options.initialConfig ?? DEFAULT_CONFIG)
49
+ let state = createRunningState(FOCUS, config, now())
50
+ let snapshot = null
51
+ let started = false
52
+ let disposed = false
53
+ let intervalId = null
54
+ let storageAvailable = Boolean(storage)
55
+
56
+ function readState() {
57
+ if (!storage) {
58
+ storageAvailable = false
59
+ return null
60
+ }
61
+ try {
62
+ const value = storage.getItem(STATE_KEY)
63
+ storageAvailable = true
64
+ return stateFromJSON(value)
65
+ } catch {
66
+ storageAvailable = false
67
+ return null
68
+ }
69
+ }
70
+
71
+ function writeState() {
72
+ if (!storage) {
73
+ storageAvailable = false
74
+ return false
75
+ }
76
+ try {
77
+ storage.setItem(STATE_KEY, JSON.stringify(state))
78
+ storageAvailable = true
79
+ return true
80
+ } catch {
81
+ // A persistence failure must not stop the active timer or its next phase.
82
+ storageAvailable = false
83
+ return false
84
+ }
85
+ }
86
+
87
+ function makeSnapshot(time) {
88
+ const remainingMs = remainingAt(state, time)
89
+ return Object.freeze({
90
+ active: started && !disposed,
91
+ phase: state.phase,
92
+ running: state.running,
93
+ text: formatRemaining(remainingMs),
94
+ remainingMs,
95
+ config,
96
+ state,
97
+ storageAvailable,
98
+ updatedAt: state.updatedAt,
99
+ })
100
+ }
101
+
102
+ function emit(time, force = false) {
103
+ const next = makeSnapshot(time)
104
+ const changed = force || !snapshot
105
+ || next.active !== snapshot.active
106
+ || next.phase !== snapshot.phase
107
+ || next.running !== snapshot.running
108
+ || next.text !== snapshot.text
109
+ || next.config !== snapshot.config
110
+ || next.updatedAt !== snapshot.updatedAt
111
+ || next.storageAvailable !== snapshot.storageAvailable
112
+ if (!changed) return
113
+ snapshot = next
114
+ for (const listener of [...listeners]) listener()
115
+ }
116
+
117
+ function refresh(force = false) {
118
+ if (!started || disposed) return
119
+ const time = now()
120
+ const settled = settleState(state, config, time)
121
+ if (settled !== state) {
122
+ state = settled
123
+ writeState()
124
+ }
125
+ emit(time, force)
126
+ }
127
+
128
+ function commit(next, time) {
129
+ state = next
130
+ writeState()
131
+ emit(time, true)
132
+ }
133
+
134
+ function onStorage(event) {
135
+ if (disposed || !started || event?.key !== STATE_KEY) return
136
+ const incoming = stateFromJSON(event.newValue)
137
+ if (!incoming || incoming.updatedAt < state.updatedAt) return
138
+ const time = now()
139
+ const settled = settleState(incoming, config, time)
140
+ state = settled
141
+ if (settled !== incoming) writeState()
142
+ emit(time, true)
143
+ }
144
+
145
+ function onVisibility() {
146
+ if (!documentRef?.hidden) refresh(true)
147
+ }
148
+
149
+ function onWake() {
150
+ refresh(true)
151
+ }
152
+
153
+ function start() {
154
+ if (started || disposed) return
155
+ started = true
156
+
157
+ const storedState = readState()
158
+ state = storedState ?? createRunningState(FOCUS, config, now())
159
+ if (!storedState) writeState()
160
+
161
+ events?.addEventListener?.('storage', onStorage)
162
+ events?.addEventListener?.('focus', onWake)
163
+ events?.addEventListener?.('pageshow', onWake)
164
+ documentRef?.addEventListener?.('visibilitychange', onVisibility)
165
+ intervalId = timers?.setInterval?.(refresh, tickMs) ?? null
166
+ refresh(true)
167
+ }
168
+
169
+ function subscribe(listener) {
170
+ if (typeof listener !== 'function') throw new TypeError('Listener must be a function')
171
+ listeners.add(listener)
172
+ return () => listeners.delete(listener)
173
+ }
174
+
175
+ function getSnapshot() {
176
+ if (!snapshot) snapshot = makeSnapshot(now())
177
+ return snapshot
178
+ }
179
+
180
+ /** Accept a newly ready or updated native DSH configuration. */
181
+ function setConfig(value) {
182
+ if (disposed) return
183
+ const next = parseConfig(value)
184
+ const time = now()
185
+ if (started) {
186
+ // First settle with the OLD durations. Otherwise an expired focus could
187
+ // be rewritten as a break using the new break duration retroactively.
188
+ const settled = settleState(state, config, time)
189
+ if (settled !== state) {
190
+ state = settled
191
+ writeState()
192
+ }
193
+ }
194
+ config = next
195
+ // The actual current phase, total duration and deadline stay untouched.
196
+ emit(time, true)
197
+ }
198
+
199
+ function pause() {
200
+ if (!started || disposed) return
201
+ const time = now()
202
+ commit(pauseState(state, config, time), time)
203
+ }
204
+
205
+ function resume() {
206
+ if (!started || disposed) return
207
+ const time = now()
208
+ const settled = settleState(state, config, time)
209
+ commit(resumeState(settled, config, time), time)
210
+ }
211
+
212
+ function skip() {
213
+ if (!started || disposed) return
214
+ const time = now()
215
+ const settled = settleState(state, config, time)
216
+ commit(skipState(settled, config, time), time)
217
+ }
218
+
219
+ function reset() {
220
+ if (!started || disposed) return
221
+ const time = now()
222
+ commit(resetState(config, time), time)
223
+ }
224
+
225
+ function dispose() {
226
+ if (disposed) return
227
+ disposed = true
228
+ started = false
229
+ if (intervalId !== null) timers?.clearInterval?.(intervalId)
230
+ intervalId = null
231
+ events?.removeEventListener?.('storage', onStorage)
232
+ events?.removeEventListener?.('focus', onWake)
233
+ events?.removeEventListener?.('pageshow', onWake)
234
+ documentRef?.removeEventListener?.('visibilitychange', onVisibility)
235
+ listeners.clear()
236
+ snapshot = null
237
+ }
238
+
239
+ return {
240
+ start,
241
+ subscribe,
242
+ getSnapshot,
243
+ setConfig,
244
+ pause,
245
+ resume,
246
+ skip,
247
+ reset,
248
+ refresh,
249
+ dispose,
250
+ }
251
+ }
@@ -0,0 +1,49 @@
1
+ export const CHAT_SELECTOR = '[data-conversation-content][data-conversation-region="chat"]'
2
+ export const COMPOSER_SELECTOR = '[data-composer-seat]'
3
+ export const CHAT_FLOW_SELECTOR = '[data-chat-flow]'
4
+
5
+ function finiteRect(rect) {
6
+ return rect && ['left', 'top', 'right', 'bottom', 'width', 'height'].every((key) => Number.isFinite(rect[key]))
7
+ }
8
+
9
+ /**
10
+ * Resolve the visible message-only rectangle. This reads current DSH semantic
11
+ * DOM markers but never mutates them. Any mismatch fails closed.
12
+ */
13
+ export function chatOverlayRect(chat) {
14
+ if (!chat || chat.isConnected === false || chat.closest?.('[hidden], [inert]')) return null
15
+ if (!chat.querySelector?.(CHAT_FLOW_SELECTOR)) return null
16
+ const body = chat.getBoundingClientRect?.()
17
+ if (!finiteRect(body) || body.width <= 0 || body.height <= 0) return null
18
+
19
+ const composer = chat.querySelector?.(COMPOSER_SELECTOR)
20
+ const composerRect = composer?.getBoundingClientRect?.()
21
+ const bottom = finiteRect(composerRect)
22
+ ? Math.min(body.bottom, composerRect.top)
23
+ : body.bottom
24
+ const height = bottom - body.top
25
+ if (body.width < 160 || height < 120) return null
26
+
27
+ return {
28
+ left: body.left,
29
+ top: body.top,
30
+ right: body.right,
31
+ bottom,
32
+ width: body.width,
33
+ height,
34
+ }
35
+ }
36
+
37
+ /** Prefer the largest visible Conversation when embedded chats also exist. */
38
+ export function findChatOverlay(documentRef) {
39
+ if (!documentRef?.querySelectorAll) return null
40
+ let best = null
41
+ for (const chat of documentRef.querySelectorAll(CHAT_SELECTOR)) {
42
+ const rect = chatOverlayRect(chat)
43
+ if (!rect) continue
44
+ if (!best || rect.width * rect.height > best.rect.width * best.rect.height) {
45
+ best = { chat, rect }
46
+ }
47
+ }
48
+ return best
49
+ }