From dbcb8e5ff0894718319d3129734a6d5107530516 Mon Sep 17 00:00:00 2001 From: VZ Date: Tue, 21 Jul 2026 22:11:58 +0800 Subject: [PATCH 01/20] Create README_zh.md --- README_zh.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 README_zh.md diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 00000000..61924519 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,71 @@ +

+ +

+ +[English](./README.md) | 简体中文 + +# WebChat + +[![持续集成](https://github.com/molvqingtai/WebChat/actions/workflows/ci.yml/badge.svg)](https://github.com/molvqingtai/WebChat/actions) [![GitHub 许可证](https://img.shields.io/github/license/molvqingtai/WebChat)](https://github.com/molvqingtai/WebChat/blob/master/LICENSE) [![Chrome 网上应用店版本](https://img.shields.io/chrome-web-store/v/cpaedhbidlpnbdfegakhiamfpndhjpgf)](https://chromewebstore.google.com/detail/webchat/cpaedhbidlpnbdfegakhiamfpndhjpgf) [![GitHub 发布](https://img.shields.io/github/v/release/molvqingtai/WebChat)](https://github.com/molvqingtai/WebChat/releases) [![询问 DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/molvqingtai/WebChat) + +> 在任何网站上与任何人聊天 + +这是一个匿名聊天浏览器扩展,去中心化且无服务器,利用 WebRTC 进行端到端加密通信。它优先考虑隐私,所有数据都存储在本地。 + +其目的是为任何网站添加聊天室功能,让你再也不感到孤单。 + +### 安装 + +**从插件商店安装** + +- [Chrome](https://chromewebstore.google.com/detail/webchat/cpaedhbidlpnbdfegakhiamfpndhjpgf) +- [Edge](https://microsoftedge.microsoft.com/addons/detail/mmfdplbomjjlgdffecapcpgjmhfhmiob) +- [Firefox](https://addons.mozilla.org/firefox/addon/webchat/) + +**手动安装** + +1. 前往 GitHub 仓库([发布页面](https://github.com/molvqingtai/WebChat/releases)) +2. 点击“Assets”按钮,选择“web-chat-\*.zip” +3. 将 ZIP 文件解压到你电脑上的一个文件夹 +4. 在浏览器中打开扩展管理页面(通常是 chrome://extensions/) + - 启用“开发者模式” + - 点击“加载已解压的扩展程序”,选择你刚解压的文件夹 + +### 使用方法 + +安装扩展后,你会在任何网站的右下角看到一个幽灵图标。点击它,你就能和同一网站上的其他人愉快地聊天了! + +### 视频 + +https://github.com/user-attachments/assets/e7ac9b8e-1b6c-43fb-8469-7a0a2c09d450 + +### 社区 + +加入我们的 Discord 社区,讨论 WebChat 并与其他用户联系: + +[Discord](https://discord.com/channels/1398133810398367805/1398137562043908248) + +### 站在巨人的肩膀上 + +除了去中心化聊天的好主意之外,它还利用了一些了不起的技术。 + +- **[remesh](https://github.com/remesh-js/remesh)**:一个 JavaScript 框架,实现了 DDD 原则,真正分离了 UI 和逻辑,由于独立于 UI,因此可以轻松实现 UI 部分,例如用 Vue 重写。 + +- **[shadcn/ui](https://ui.shadcn.com/)**:一个漂亮的 UI 库,也是无安装概念的先驱,在自定义样式方面提供了无与伦比的便利。 + +- **[wxt](https://wxt.dev/)**:这是我用过构建浏览器扩展最好的框架,没有之一。 + +- ~~**[trystero](https://github.com/dmotz/trystero)**~~:实现去中心化通信的核心依赖,可连接到 IPFS、torrent、Nostr 等去中心化网络。 +- **[Artico](https://github.com/matallui/artico)**:一套灵活的库,帮助你创建自己的基于 WebRTC 的解决方案。 + +- **[ugly-avatar](https://github.com/txstc55/ugly-avatar)**:用它来创建令人惊艳的随机头像。 + +### 赞助商 + +WebChat 是一个开源项目,其持续发展完全依靠这些出色支持者的支持才得以实现。 + +[![由 DartNode 提供支持](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "由 DartNode 提供支持 - 面向开源的免费 VPS") + +### 许可证 + +本项目基于 MIT 许可证开源 - 详情请参阅 [LICENSE](https://github.com/molvqingtai/WebChat/blob/master/LICENSE) 文件。 From b3b152956e073b11949ff3a5e56d17ada4e31f6d Mon Sep 17 00:00:00 2001 From: VZ Date: Tue, 21 Jul 2026 22:12:20 +0800 Subject: [PATCH 02/20] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1dced3a0..9b29d825 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@

+English | [简体中文](./README_zh.md) + # WebChat [![CI](https://github.com/molvqingtai/WebChat/actions/workflows/ci.yml/badge.svg)](https://github.com/molvqingtai/WebChat/actions) [![GitHub License](https://img.shields.io/github/license/molvqingtai/WebChat)](https://github.com/molvqingtai/WebChat/blob/master/LICENSE) [![Chrome Web Store Version](https://img.shields.io/chrome-web-store/v/cpaedhbidlpnbdfegakhiamfpndhjpgf)](https://chromewebstore.google.com/detail/webchat/cpaedhbidlpnbdfegakhiamfpndhjpgf) [![GitHub Release](https://img.shields.io/github/v/release/molvqingtai/WebChat)](https://github.com/molvqingtai/WebChat/releases) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/molvqingtai/WebChat) From d9dbcc48a87e59d12e5402754fe273f0cc45d5f8 Mon Sep 17 00:00:00 2001 From: VZ Date: Tue, 21 Jul 2026 22:14:01 +0800 Subject: [PATCH 03/20] Update README_zh.md --- README_zh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_zh.md b/README_zh.md index 61924519..ed27fa99 100644 --- a/README_zh.md +++ b/README_zh.md @@ -68,4 +68,4 @@ WebChat 是一个开源项目,其持续发展完全依靠这些出色支持者 ### 许可证 -本项目基于 MIT 许可证开源 - 详情请参阅 [LICENSE](https://github.com/molvqingtai/WebChat/blob/master/LICENSE) 文件。 +本项目基于 MIT 许可证开源 - 详情请参阅 [LICENSE](./LICENSE) 文件。 From 26e35e29fc651c63c4fbeb3f97e4eac5252b12b9 Mon Sep 17 00:00:00 2001 From: VZ Date: Tue, 21 Jul 2026 22:14:23 +0800 Subject: [PATCH 04/20] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b29d825..2a9150c6 100644 --- a/README.md +++ b/README.md @@ -68,4 +68,4 @@ WebChat is a open source project with its ongoing development made possible enti ### License -This project is licensed under the MIT License - see the [LICENSE](https://github.com/molvqingtai/WebChat/blob/master/LICENSE) file for details +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details From 653c40ebe774724e34a80a8d6c2d3c4938dc8da7 Mon Sep 17 00:00:00 2001 From: VZ Date: Tue, 21 Jul 2026 22:20:19 +0800 Subject: [PATCH 05/20] Update README_zh.md --- README_zh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_zh.md b/README_zh.md index ed27fa99..28e902dc 100644 --- a/README_zh.md +++ b/README_zh.md @@ -55,7 +55,7 @@ https://github.com/user-attachments/assets/e7ac9b8e-1b6c-43fb-8469-7a0a2c09d450 - **[wxt](https://wxt.dev/)**:这是我用过构建浏览器扩展最好的框架,没有之一。 -- ~~**[trystero](https://github.com/dmotz/trystero)**~~:实现去中心化通信的核心依赖,可连接到 IPFS、torrent、Nostr 等去中心化网络。 +- ~~**[trystero](https://github.com/dmotz/trystero)**:实现去中心化通信的核心依赖,可连接到 IPFS、torrent、Nostr 等去中心化网络。~~ - **[Artico](https://github.com/matallui/artico)**:一套灵活的库,帮助你创建自己的基于 WebRTC 的解决方案。 - **[ugly-avatar](https://github.com/txstc55/ugly-avatar)**:用它来创建令人惊艳的随机头像。 From 366b552145ce12661525261314d3b0ccd6b6b970 Mon Sep 17 00:00:00 2001 From: molvqingtai Date: Mon, 3 Aug 2026 08:00:23 +0800 Subject: [PATCH 06/20] docs(openspec): keep expanded shell below viewport top --- .../show-shared-domain-unread-badge/README.md | 3 ++- .../show-shared-domain-unread-badge/design.md | 24 +++++++++++++------ .../proposal.md | 9 +++---- .../specs/webrtc-runtime/spec.md | 22 +++++++++++++++++ .../show-shared-domain-unread-badge/tasks.md | 24 +++++++++---------- 5 files changed, 58 insertions(+), 24 deletions(-) diff --git a/openspec/changes/show-shared-domain-unread-badge/README.md b/openspec/changes/show-shared-domain-unread-badge/README.md index 7e0e6e95..858350fb 100644 --- a/openspec/changes/show-shared-domain-unread-badge/README.md +++ b/openspec/changes/show-shared-domain-unread-badge/README.md @@ -1,3 +1,4 @@ # show-shared-domain-unread-badge -Synchronize open, position, and unread status across every same-domain AppButton. +Synchronize open, position, and unread status across every same-domain AppButton while keeping the launcher and open +shell within their viewport margins. diff --git a/openspec/changes/show-shared-domain-unread-badge/design.md b/openspec/changes/show-shared-domain-unread-badge/design.md index d0e149ae..044ca763 100644 --- a/openspec/changes/show-shared-domain-unread-badge/design.md +++ b/openspec/changes/show-shared-domain-unread-badge/design.md @@ -1,6 +1,6 @@ ## Context -`AppStatusDomain` is the single business owner for one same-domain AppButton status containing `open`, position, and unread attention. Every same-domain tab observes that complete status, while each tab projects the shared edge-relative position into its own viewport. The first-delivery boundary admits a remote text once, and the same-domain synchronization boundary distributes every AppButton status update. +`AppStatusDomain` is the single business owner for one same-domain AppButton status containing `open`, position, and unread attention. Every same-domain tab observes that complete status, while each tab projects the shared edge-relative position into its own viewport. That local projection must account for the expanded shell as well as the launcher; otherwise a shared point that is valid for the launcher can push the shell above the viewport when dragged upward. The first-delivery boundary admits a remote text once, and the same-domain synchronization boundary distributes every AppButton status update. See `proposal.md` for the product motivation and `specs/webrtc-runtime/spec.md` for the observable contract. @@ -11,6 +11,8 @@ See `proposal.md` for the product motivation and `specs/webrtc-runtime/spec.md` - Preserve one AppStatus business owner and one same-domain status containing `open`, position, and unread attention. - Synchronize expand, collapse, position, unread mark, and unread clear across every same-domain tab while isolating other domains. - Define one edge-relative position that projects from the left-bottom or right-bottom anchor, preserves the launcher's fixed edge margins through viewport-derived bounds, and never writes merely because a viewport resized. +- Keep the expanded shell's top edge at least `40px` below the viewport top at either horizontal anchor and every supported shell width, including upward drag, opening or reopening, same-domain open synchronization, and viewport resize. +- Derive launcher and expanded-shell placement from one local geometry owner without adding persisted position state or a corrective panel-only position. - Make open, position, and unread writes field-scoped so no update can clobber another shared fact. - Preserve the current drag start, pointer following, bounds, cursor, selection suppression, release behavior, and continuous midpoint crossing. - Mark a collapsed domain unread on first-delivered remote text, keep an expanded domain read, and project one count-free badge from `!open && unread`. @@ -23,6 +25,7 @@ See `proposal.md` for the product motivation and `specs/webrtc-runtime/spec.md` - Enumerating browser tabs or windows, coupling unread to browser focus/highlight state, or changing notification behavior. - Changing message delivery, history, projection, barrage, Runtime, protocol, peer, connection, or persistence-version behavior. - Adding position snapping, rebound, easing, automatic repositioning writes, drag-handle changes, or a second position owner. +- Changing the shell's existing supported size range, launcher-to-shell relationship, collapsed launcher bounds, or the launcher's horizontal and bottom margins. - Adding alternate indicator variants or browser-specific unread policy. ## Decisions @@ -41,29 +44,35 @@ The launcher is `44x44px`. In either horizontal half, its center remains at leas Each tab derives its rendered position from those coordinates and its own current viewport. Bounds are derived from the current viewport and AppButton geometry so the launcher remains fully visible with those margins. If a viewport can contain the launcher but is too small to satisfy a fixed margin, only that tab uses the nearest fully visible bound with the largest feasible margin; the shared coordinate remains unchanged. A later larger viewport therefore restores the exact `50px` horizontal-center and `22px` bottom-edge minima from the unchanged coordinate. Resize observes the new viewport and performs no persistence write. -### 3. Preserve continuous drag behavior across the midpoint +### 3. Keep the expanded shell below one local top bound + +The collapsed launcher keeps its existing vertical range. While WebChat is expanded, the same local geometry owner adds the shell constraint: the shell's top edge remains at least `40px` below the viewport top. The bound is derived from the existing shell and launcher geometry, and the AppButton and AppMain consume the same projected point. Both horizontal anchors and every supported shell width therefore produce the same top-inset result without a second panel transform, DOM measurement, or position owner. + +A shared coordinate may have been captured while the shell was collapsed or in a differently sized tab. Opening, reopening, same-domain open synchronization, and viewport resize locally project such a coordinate to the nearest shell-safe point without mutating or persisting the shared position. A later compatible local layout can project the unchanged coordinate again. An actual user drag still writes the bounded shared point through the existing field-scoped position command. + +### 4. Preserve continuous drag behavior across the midpoint Dragging begins from the existing hand control, follows the latest pointer position once per animation frame, prevents text selection, retains the grab cursor, remains bounded, and ends on mouse release. Crossing the viewport midpoint converts the horizontal coordinate to the opposite edge at the same rendered center in that frame, so the AppButton remains under the pointer with no jump. The anchor change adds no snap, rebound, easing, delayed settle, or alternate release behavior. After initialization, only user drag changes the shared position; same-domain tabs then observe that field update. -### 4. Derive unread from the synchronized open state +### 5. Derive unread from the synchronized open state The first-delivery path sets unread when the shared domain is collapsed, regardless of which same-domain page wins atomic insertion. When the shared domain is expanded, the conversation is already visible across its tabs and the delivery does not mark unread. Self-authored text, history application, and duplicate delivery do not set attention. Reactions and system notices are outside this text-only contract. Unread is an attention state, not a visible count. Additional eligible texts keep the same visible result rather than multiplying indicators. Browser-window focus, active/highlighted tab, notification-enabled, and notification-type state never gate, redirect, or clear unread attention. Highlighted-tab comparison belongs only to browser notifications. -### 5. Open and collapse the domain as one surface +### 6. Open and collapse the domain as one surface A user action that changes the domain from collapsed to expanded sets `open` for every same-domain tab and clears unread as one domain update. A collapse action sets every same-domain tab to collapsed and leaves unread clear. The next eligible remote text then marks the collapsed domain unread. Focus, hydration, and synchronization alone perform neither action. The shared invariant is `open => !unread`. Opening wins over any earlier unread mark, an eligible delivery cannot mark an expanded domain, and a later eligible delivery after collapse marks the domain again. Clearing or toggling domain A cannot mutate domain B. -### 6. Preserve one exact AppButton indicator +### 7. Preserve one exact AppButton indicator The AppButton owns the only unread presentation. When `!open && unread`, every same-domain AppButton uses a top-right `size-5` container at `-top-1 -right-1`, a full-size fully rounded orange-400 ping at 75% opacity, and a fully rounded orange-500 `size-3` center. Presence enters and exits through a 0.1-second opacity transition. It contains no text or number, does not resize the button, and remains absent while the domain is expanded. -### 7. Verify shared status and local projection together +### 8. Verify shared status and local projection together -Deterministic controls model tabs A, B, and C on domain A and tab D on domain B. They first drag a domain-A AppButton to both bottom corners and require A/B/C to share the edge-relative position while D remains unchanged. Both anchors preserve the `50px` center distance (`28px` outer-edge margin) and `22px` bottom margin. Controls resize narrow and wide viewports without a shared write, prove bounded projection and restoration, cross the midpoint without a visual discontinuity, and preserve the current drag event/animation behavior. +Deterministic controls model tabs A, B, and C on domain A and tab D on domain B. They first drag a domain-A AppButton to both bottom corners and require A/B/C to share the edge-relative position while D remains unchanged. Both anchors preserve the `50px` center distance (`28px` outer-edge margin) and `22px` bottom margin. With the shell expanded on either side and at each supported width, controls drag upward to the local bound and require at least `40px` above the shell. They open or reopen from a shell-unsafe shared point and resize narrow and wide viewports without a shared write, prove bounded local projection and restoration, cross the midpoint without a visual discontinuity, and preserve the current drag event/animation behavior. The same controls start A/B/C collapsed, force each possible same-domain insertion winner, admit a remote text once, and require badges on A/B/C only. Opening through C must expand and clear all three without affecting D; delivery while expanded must remain read; collapsing through A and admitting a later text must restore all three badges. They also cover field-write isolation, repeated eligible text, self/history/duplicate exclusions, browser focus and active/highlighted tabs, disabled and mention-only notification settings, delayed hydration, and the exact indicator structure and motion classes. @@ -71,6 +80,7 @@ The same controls start A/B/C collapsed, force each possible same-domain inserti - [Same-domain tabs can write different fields concurrently] -> Commands persist only their addressed fields; opening intentionally updates open and unread together, preserving position. - [Tabs can have different viewport sizes] -> Every tab projects the same edge-relative coordinates through local viewport bounds without feeding automatic projection changes back into shared state. +- [A collapsed or differently sized surface can provide a point that is unsafe for an expanded shell] -> The single local geometry projection adds the expanded-shell top bound without rewriting the shared coordinate. - [A drag changes horizontal anchor] -> Conversion uses the same rendered center in the crossing frame, preserving continuous pointer following without a visible jump. - [Open and delivery can occur close together] -> The domain invariant keeps every expanded state read; only a first-delivered remote text observed while collapsed can mark unread. - [Several unread texts arrive before reading] -> They retain one attention truth and one visual indicator; this feature intentionally exposes no count. diff --git a/openspec/changes/show-shared-domain-unread-badge/proposal.md b/openspec/changes/show-shared-domain-unread-badge/proposal.md index 185aea26..ff8d09c7 100644 --- a/openspec/changes/show-shared-domain-unread-badge/proposal.md +++ b/openspec/changes/show-shared-domain-unread-badge/proposal.md @@ -1,6 +1,6 @@ ## Why -Every same-domain surface represents the same WebChat AppButton and needs one consistent open state, placement, and unread-attention truth. Edge-relative placement must remain meaningful across different window sizes, and collapsed surfaces need one shared visible signal for eligible remote text. +Every same-domain surface represents the same WebChat AppButton and needs one consistent open state, placement, and unread-attention truth. Edge-relative placement must remain meaningful across different window sizes, the expanded shell must not cross the viewport top when it is dragged upward, and collapsed surfaces need one shared visible signal for eligible remote text. ## What Changes @@ -9,6 +9,7 @@ Every same-domain surface represents the same WebChat AppButton and needs one co - Mark unread only when a first-delivered remote text reaches a collapsed domain. An expanded domain is already presenting the conversation and remains read. - Represent position from the bottom-left edge while the AppButton is in the left half of the viewport and from the bottom-right edge while it is in the right half. Crossing the midpoint changes the anchor without moving the rendered button away from the pointer. - Reproject the saved edge-relative position against each viewport while preserving the `44x44px` launcher's fixed bounds: its center stays at least `50px` from either horizontal edge (`28px` outer-edge margin) and its bottom edge stays at least `22px` above the viewport bottom whenever the viewport can satisfy those margins. A smaller viewport uses only its nearest fully visible local bound; resizing leaves the shared position unchanged and performs no persistence write. +- While WebChat is expanded, apply one additional local vertical bound so upward dragging, opening or reopening, and viewport resizing keep the shell's top edge at least `40px` below the viewport top. The result applies at either horizontal anchor and every supported shell width without rewriting the shared position merely because local projection changed. - Preserve the current hand-control drag interaction: continuous animation-frame pointer following, bounded movement, selection suppression, and grab cursor, with no snap, rebound, easing, or release-behavior change. - Preserve zero unread attention for self-authored text, history application, and duplicate delivery. Browser-window focus, active/highlighted tab, and browser-notification enabled/type settings do not participate in unread eligibility or clearing. - Keep the AppButton indicator count-free: a top-right orange ping with an opaque orange center and a short opacity presence transition. @@ -26,7 +27,7 @@ None. ## Impact -- Affected behavior: AppButton placement, dragging, unread attention, and badge visibility across multiple tabs and viewport sizes of the same or different WebChat domains. -- Affected implementation: the existing AppStatus owner, its same-domain synchronization boundary, the draggable position projection, and the AppButton badge projection. -- Affected verification: deterministic whole-status synchronization, responsive edge projection, midpoint crossing, drag continuity, collapsed and expanded delivery, read clearing, field-write isolation, exclusion, focus/highlight/settings independence, and indicator-presentation controls. +- Affected behavior: AppButton and expanded-shell placement, dragging, unread attention, and badge visibility across multiple tabs and viewport sizes of the same or different WebChat domains. +- Affected implementation: the existing AppStatus owner, its same-domain synchronization boundary, the single local placement projection, and the AppButton badge projection. +- Affected verification: deterministic whole-status synchronization, responsive edge and expanded-shell projection, midpoint crossing, drag continuity, reopen and resize behavior, collapsed and expanded delivery, read clearing, field-write isolation, exclusion, focus/highlight/settings independence, and indicator-presentation controls. - Unchanged: message content and history, notification eligibility and presentation, barrage, Runtime networking, peer protocol, public APIs, permissions, dependencies, and cross-domain isolation. diff --git a/openspec/changes/show-shared-domain-unread-badge/specs/webrtc-runtime/spec.md b/openspec/changes/show-shared-domain-unread-badge/specs/webrtc-runtime/spec.md index 7865d250..04896aee 100644 --- a/openspec/changes/show-shared-domain-unread-badge/specs/webrtc-runtime/spec.md +++ b/openspec/changes/show-shared-domain-unread-badge/specs/webrtc-runtime/spec.md @@ -10,6 +10,10 @@ The launcher SHALL be `44x44px`. In a viewport that can satisfy the fixed margin Each tab SHALL reproject the shared edge-relative coordinates against its own current viewport. It SHALL derive bounds from that viewport and the AppButton geometry so the launcher remains fully visible with the fixed margins. If a viewport can contain the launcher but is too small to satisfy a fixed margin, only that tab's rendered projection SHALL use the nearest fully visible bound with the largest feasible margin; the shared coordinate SHALL remain unchanged. Resizing SHALL perform no shared-state mutation or persistence write, and a later larger viewport SHALL restore the fixed margins from the unchanged shared coordinate. +While WebChat is expanded, the same local geometry projection SHALL add a vertical bound that keeps the shell's top edge at least `40px` below the viewport top. The bound SHALL apply at both horizontal anchors and every shell width allowed by the existing resizer. Upward dragging SHALL stop at the nearest point before the shell would violate that inset, and the shell SHALL NOT render above the viewport top. The collapsed launcher's existing vertical range, the shell's supported size range and launcher relationship, and the launcher's horizontal and bottom margins SHALL remain unchanged. + +If a shared coordinate captured while collapsed or in another viewport would place the expanded shell above its top bound, opening, reopening, same-domain open synchronization, and viewport resize SHALL use the nearest shell-safe local projection. Those automatic projections SHALL NOT mutate or persist the shared position. A later compatible local layout SHALL project the unchanged shared coordinate again unless a user drag has written a new bounded position. + Dragging SHALL begin from the existing hand control, follow the latest pointer position once per animation frame, prevent text selection, retain the grab cursor, remain within the derived bounds, and end on mouse release. When the AppButton center crosses the viewport midpoint, the horizontal anchor SHALL change and its edge distance SHALL be converted from the same rendered center in that frame. The button SHALL remain under the pointer without a visual jump, snap, rebound, easing, delayed settle, or release-behavior change. After initialization, only a user drag SHALL change the shared position. Same-domain tabs SHALL observe that field update. @@ -48,6 +52,24 @@ The visible AppButton indicator SHALL be count-free and SHALL NOT resize the but - **WHEN** that tab projects the AppButton after resize - **THEN** the launcher SHALL remain fully visible at the nearest derived bound with the largest feasible local margin, the shared position SHALL NOT be rewritten, and a later larger viewport SHALL project the original shared coordinate and fixed margins again +#### Scenario: Upward drag preserves the expanded-shell top inset + +- **GIVEN** WebChat is expanded at either horizontal anchor and at any shell width allowed by the existing resizer +- **WHEN** the user drags the AppButton upward beyond the shell-safe range +- **THEN** the rendered AppButton SHALL stop at the nearest local bound, the shell top SHALL remain at least `40px` below the viewport top and SHALL NOT overflow above it, and the existing horizontal, bottom, pointer-following, and release behavior SHALL remain unchanged + +#### Scenario: Opening locally bounds a shell-unsafe shared point + +- **GIVEN** the shared position was captured while collapsed or in another viewport and would place an expanded shell above its top bound in this tab +- **WHEN** WebChat opens, reopens, or becomes open through same-domain synchronization +- **THEN** this tab SHALL use the nearest shell-safe local projection with at least `40px` above the shell, SHALL NOT mutate or persist the shared position, and SHALL preserve that position for a later compatible local layout + +#### Scenario: Resize locally preserves the expanded-shell top inset + +- **GIVEN** WebChat is expanded and the viewport changes so the current local projection would violate the shell's top bound +- **WHEN** the tab reprojects the shared position +- **THEN** it SHALL keep the shell at least `40px` below the viewport top without a shared mutation or persistence write, at either horizontal anchor and every supported shell width + #### Scenario: Crossing the midpoint is visually continuous - **GIVEN** the user is dragging the AppButton from one viewport half toward the other diff --git a/openspec/changes/show-shared-domain-unread-badge/tasks.md b/openspec/changes/show-shared-domain-unread-badge/tasks.md index 153370e3..a1d67cce 100644 --- a/openspec/changes/show-shared-domain-unread-badge/tasks.md +++ b/openspec/changes/show-shared-domain-unread-badge/tasks.md @@ -5,8 +5,8 @@ - [x] 1.3 Define a user-driven open action as the domain read action, collapse as a synchronized domain action, and preserve cross-domain isolation. - [x] 1.4 Define collapsed first-delivery eligibility, expanded delivery as already read, self/history/duplicate exclusions, and independence from browser focus, active/highlighted tab, and notification settings. - [x] 1.5 Freeze the count-free AppButton indicator's placement, orange ping/center styling, and presence transition. -- [x] 1.6 Define left-bottom and right-bottom position coordinates, symmetric `50px` horizontal-center / `28px` outer-edge / `22px` bottom-edge minimum margins, midpoint anchor conversion, viewport-derived visibility bounds, and resize-only reprojection with no persistence write. -- [x] 1.7 Freeze whole-status synchronization, field-scoped open/position/unread writes, and the current continuous hand-control drag behavior. +- [x] 1.6 Define left-bottom and right-bottom position coordinates, symmetric `50px` horizontal-center / `28px` outer-edge / `22px` bottom-edge minimum margins, the expanded shell's `40px` top inset, midpoint anchor conversion, viewport-derived visibility bounds, and automatic local reprojection with no persistence write. +- [x] 1.7 Freeze whole-status synchronization, field-scoped open/position/unread writes, and the current continuous hand-control drag behavior at every launcher and expanded-shell bound. ## 2. Regression Coverage @@ -14,9 +14,9 @@ - [x] 2.2 Prove opening through C expands and clears A/B/C together, expanded delivery remains read, collapsing through A collapses all three, a later text restores all three badges, and D remains unchanged. - [x] 2.3 Cover self-authored text, history application, duplicate delivery, repeated eligible text, browser focus, active/highlighted tabs, disabled notifications, and both notification-type settings. - [x] 2.4 Cover delayed hydration and field-scoped open, position, and unread writes so they cannot overwrite another current field or violate `open => !unread`. -- [x] 2.5 Cover left-half and right-half projection, symmetric fixed edge margins at both bottom corners, exact-midpoint ownership, same-domain synchronization, cross-domain isolation, and different viewport sizes. -- [x] 2.6 Prove resize performs no shared write, a smaller viewport bounds only the rendered position, and a larger viewport restores projection from the unchanged shared coordinates. -- [x] 2.7 Prove midpoint crossing retains the rendered center and current animation-frame pointer following, drag bounds, cursor, selection suppression, and mouse-release behavior without snap, rebound, or easing. +- [ ] 2.5 Cover left-half and right-half projection, symmetric fixed edge margins at both bottom corners, the `40px` expanded-shell top inset at either anchor and every supported shell width, exact-midpoint ownership, same-domain synchronization, cross-domain isolation, and different viewport sizes. +- [ ] 2.6 Prove opening, reopening, same-domain open synchronization, and resize perform no position write; an incompatible local layout bounds only the rendered expanded position; and a later compatible layout restores projection from the unchanged shared coordinates. +- [ ] 2.7 Prove upward and midpoint-crossing drags retain the shell top inset, rendered center, and current animation-frame pointer following, drag bounds, cursor, selection suppression, and mouse-release behavior without snap, rebound, or easing. - [x] 2.8 Cover the AppButton indicator's exact visibility, structure, tokens, animation, and count-free layout through the fixed Vitest, happy-dom, Testing Library, and Vitest Browser Mode stack selected by responsibility. ## 3. Minimum Repair @@ -25,13 +25,13 @@ - [x] 3.2 Set domain attention at the first-delivered remote-text boundary only while the shared domain is collapsed. - [x] 3.3 Synchronize expand/collapse across the current domain, clear unread on open, enforce `open => !unread`, and project badge visibility without enumerating browser tabs/windows. - [x] 3.4 Persist open, position, and boolean unread attention through field-scoped updates within one shared status, with hydration adopting rather than rewriting current field values. -- [x] 3.5 Project the shared position from the selected bottom edge, preserve the symmetric fixed edge margins, convert anchors continuously at the midpoint, and derive local visibility bounds without writing on resize. -- [x] 3.6 Reuse the existing hand-control drag interaction, AppButton indicator, and same-domain synchronization boundary without adding a Domain, count, setting, API, permission, dependency, Runtime/protocol change, or browser-specific branch. +- [ ] 3.5 Project the shared position from the selected bottom edge, preserve the fixed launcher margins and expanded-shell top inset, convert anchors continuously at the midpoint, and derive every local bound without an automatic position write. +- [ ] 3.6 Reuse the existing hand-control drag interaction, AppButton indicator, shell sizing, and same-domain synchronization boundary without adding a position owner, Domain, count, setting, API, permission, dependency, Runtime/protocol change, or browser-specific branch. ## 4. Delivery Gates -- [x] 4.1 Pass focused regressions, the complete source test suite, typecheck, lint, format, Chrome/Firefox production builds, strict OpenSpec validation, OpenSpec Doctor, diff, identity, and clean-worktree gates on one exact. -- [x] 4.2 Obtain fresh architecture-first Review of the complete requirement-branch diff and close every finding before publication. -- [x] 4.3 Publish the reviewed exact through the single requirement branch/PR and require exact-bound CI to pass. -- [x] 4.4 Keep QA, QC, and UX absent unless the Owner explicitly requests one; record any performed or unavailable browser behavior verification truthfully without making it a source/CI blocker. -- [x] 4.5 Record explicit Owner acceptance and update final OpenSpec/task truth; keep Ready/merge conditional on the closeout exact's identity and CI. +- [ ] 4.1 Pass focused regressions, the complete source test suite, typecheck, lint, format, Chrome/Firefox production builds, strict OpenSpec validation, OpenSpec Doctor, diff, identity, and clean-worktree gates on one exact. +- [ ] 4.2 Obtain fresh architecture-first Review of the complete requirement-branch diff and close every finding before publication. +- [ ] 4.3 Publish the reviewed exact through the single requirement branch/PR and require exact-bound CI to pass. +- [ ] 4.4 Keep QA, QC, and UX absent unless the Owner explicitly requests one; record any performed or unavailable browser behavior verification truthfully without making it a source/CI blocker. +- [ ] 4.5 Record explicit Owner acceptance and update final OpenSpec/task truth; keep Ready/merge conditional on the closeout exact's identity and CI. From 3bfaa3aa770e9ccd5445b5d72d5f70e960595bf1 Mon Sep 17 00:00:00 2001 From: molvqingtai Date: Mon, 3 Aug 2026 09:27:35 +0800 Subject: [PATCH 07/20] docs(openspec): preserve short viewport shell geometry --- .../show-shared-domain-unread-badge/design.md | 12 ++++++++---- .../proposal.md | 5 +++-- .../specs/webrtc-runtime/spec.md | 18 +++++++++++++----- .../show-shared-domain-unread-badge/tasks.md | 10 +++++----- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/openspec/changes/show-shared-domain-unread-badge/design.md b/openspec/changes/show-shared-domain-unread-badge/design.md index 044ca763..84820496 100644 --- a/openspec/changes/show-shared-domain-unread-badge/design.md +++ b/openspec/changes/show-shared-domain-unread-badge/design.md @@ -11,7 +11,8 @@ See `proposal.md` for the product motivation and `specs/webrtc-runtime/spec.md` - Preserve one AppStatus business owner and one same-domain status containing `open`, position, and unread attention. - Synchronize expand, collapse, position, unread mark, and unread clear across every same-domain tab while isolating other domains. - Define one edge-relative position that projects from the left-bottom or right-bottom anchor, preserves the launcher's fixed edge margins through viewport-derived bounds, and never writes merely because a viewport resized. -- Keep the expanded shell's top edge at least `40px` below the viewport top at either horizontal anchor and every supported shell width, including upward drag, opening or reopening, same-domain open synchronization, and viewport resize. +- In viewports at least `459px` high, keep the expanded shell's top edge at least `40px` below the viewport top at either horizontal anchor and every supported shell width, including upward drag, opening or reopening, same-domain open synchronization, and viewport resize. +- Below `459px`, retain the `375px` shell minimum height, its `22px` launcher relationship, and the launcher's viewport bounds without a shell-height or top-inset adaptation. - Derive launcher and expanded-shell placement from one local geometry owner without adding persisted position state or a corrective panel-only position. - Make open, position, and unread writes field-scoped so no update can clobber another shared fact. - Preserve the current drag start, pointer following, bounds, cursor, selection suppression, release behavior, and continuous midpoint crossing. @@ -46,9 +47,11 @@ Each tab derives its rendered position from those coordinates and its own curren ### 3. Keep the expanded shell below one local top bound -The collapsed launcher keeps its existing vertical range. While WebChat is expanded, the same local geometry owner adds the shell constraint: the shell's top edge remains at least `40px` below the viewport top. The bound is derived from the existing shell and launcher geometry, and the AppButton and AppMain consume the same projected point. Both horizontal anchors and every supported shell width therefore produce the same top-inset result without a second panel transform, DOM measurement, or position owner. +The collapsed launcher keeps its existing vertical range. In a viewport at least `459px` high, the same local geometry owner adds the expanded-shell constraint: the shell's top edge remains at least `40px` below the viewport top. The threshold follows the fixed geometry: the `40px` top inset, `375px` shell minimum height, and shell bottom `22px` above the launcher bottom require that launcher edge at `437px`; its `22px` viewport-bottom margin then requires `459px`. The AppButton and AppMain consume the same projected point, so both horizontal anchors and every supported shell width produce the same result without a second panel transform, DOM measurement, or position owner. -A shared coordinate may have been captured while the shell was collapsed or in a differently sized tab. Opening, reopening, same-domain open synchronization, and viewport resize locally project such a coordinate to the nearest shell-safe point without mutating or persisting the shared position. A later compatible local layout can project the unchanged coordinate again. An actual user drag still writes the bounded shared point through the existing field-scoped position command. +Below `459px`, the shell retains its `375px` minimum height and `22px` launcher relationship, and the launcher retains its viewport-derived bounds and margin fallback. This layout does not shrink the shell or add a separate top-inset adaptation, so the `40px` shell inset is not guaranteed at that height. + +A shared coordinate may have been captured while the shell was collapsed or in a differently sized tab. At `459px` or taller, opening, reopening, same-domain open synchronization, and viewport resize locally project such a coordinate to the nearest shell-safe point without mutating or persisting the shared position. A shorter viewport uses the launcher projection without a position write; crossing back to `459px` locally restores the shell-safe projection from the unchanged coordinate. An actual user drag still writes the bounded shared point through the existing field-scoped position command. ### 4. Preserve continuous drag behavior across the midpoint @@ -72,7 +75,7 @@ The AppButton owns the only unread presentation. When `!open && unread`, every s ### 8. Verify shared status and local projection together -Deterministic controls model tabs A, B, and C on domain A and tab D on domain B. They first drag a domain-A AppButton to both bottom corners and require A/B/C to share the edge-relative position while D remains unchanged. Both anchors preserve the `50px` center distance (`28px` outer-edge margin) and `22px` bottom margin. With the shell expanded on either side and at each supported width, controls drag upward to the local bound and require at least `40px` above the shell. They open or reopen from a shell-unsafe shared point and resize narrow and wide viewports without a shared write, prove bounded local projection and restoration, cross the midpoint without a visual discontinuity, and preserve the current drag event/animation behavior. +Deterministic controls model tabs A, B, and C on domain A and tab D on domain B. They first drag a domain-A AppButton to both bottom corners and require A/B/C to share the edge-relative position while D remains unchanged. Both anchors preserve the `50px` center distance (`28px` outer-edge margin) and `22px` bottom margin. At `459px` or taller, controls expand the shell on either side and at each supported width, drag upward to the local bound, and require at least `40px` above the shell. They open or reopen from a shell-unsafe shared point and resize across the threshold without a shared write, proving both the bounded tall-viewport projection and the unchanged short-viewport geometry. Midpoint crossing remains continuous and the drag event/animation behavior remains unchanged. The same controls start A/B/C collapsed, force each possible same-domain insertion winner, admit a remote text once, and require badges on A/B/C only. Opening through C must expand and clear all three without affecting D; delivery while expanded must remain read; collapsing through A and admitting a later text must restore all three badges. They also cover field-write isolation, repeated eligible text, self/history/duplicate exclusions, browser focus and active/highlighted tabs, disabled and mention-only notification settings, delayed hydration, and the exact indicator structure and motion classes. @@ -81,6 +84,7 @@ The same controls start A/B/C collapsed, force each possible same-domain inserti - [Same-domain tabs can write different fields concurrently] -> Commands persist only their addressed fields; opening intentionally updates open and unread together, preserving position. - [Tabs can have different viewport sizes] -> Every tab projects the same edge-relative coordinates through local viewport bounds without feeding automatic projection changes back into shared state. - [A collapsed or differently sized surface can provide a point that is unsafe for an expanded shell] -> The single local geometry projection adds the expanded-shell top bound without rewriting the shared coordinate. +- [A viewport below `459px` cannot satisfy every fixed dimension] -> It retains the shell minimum height, launcher relationship, and launcher bounds without a separate shell adaptation; the top-inset guarantee begins at `459px`. - [A drag changes horizontal anchor] -> Conversion uses the same rendered center in the crossing frame, preserving continuous pointer following without a visible jump. - [Open and delivery can occur close together] -> The domain invariant keeps every expanded state read; only a first-delivered remote text observed while collapsed can mark unread. - [Several unread texts arrive before reading] -> They retain one attention truth and one visual indicator; this feature intentionally exposes no count. diff --git a/openspec/changes/show-shared-domain-unread-badge/proposal.md b/openspec/changes/show-shared-domain-unread-badge/proposal.md index ff8d09c7..ccdc9ac4 100644 --- a/openspec/changes/show-shared-domain-unread-badge/proposal.md +++ b/openspec/changes/show-shared-domain-unread-badge/proposal.md @@ -1,6 +1,6 @@ ## Why -Every same-domain surface represents the same WebChat AppButton and needs one consistent open state, placement, and unread-attention truth. Edge-relative placement must remain meaningful across different window sizes, the expanded shell must not cross the viewport top when it is dragged upward, and collapsed surfaces need one shared visible signal for eligible remote text. +Every same-domain surface represents the same WebChat AppButton and needs one consistent open state, placement, and unread-attention truth. Edge-relative placement must remain meaningful across different window sizes, the expanded shell must not cross the viewport top when the viewport can contain its fixed geometry, and collapsed surfaces need one shared visible signal for eligible remote text. ## What Changes @@ -9,7 +9,8 @@ Every same-domain surface represents the same WebChat AppButton and needs one co - Mark unread only when a first-delivered remote text reaches a collapsed domain. An expanded domain is already presenting the conversation and remains read. - Represent position from the bottom-left edge while the AppButton is in the left half of the viewport and from the bottom-right edge while it is in the right half. Crossing the midpoint changes the anchor without moving the rendered button away from the pointer. - Reproject the saved edge-relative position against each viewport while preserving the `44x44px` launcher's fixed bounds: its center stays at least `50px` from either horizontal edge (`28px` outer-edge margin) and its bottom edge stays at least `22px` above the viewport bottom whenever the viewport can satisfy those margins. A smaller viewport uses only its nearest fully visible local bound; resizing leaves the shared position unchanged and performs no persistence write. -- While WebChat is expanded, apply one additional local vertical bound so upward dragging, opening or reopening, and viewport resizing keep the shell's top edge at least `40px` below the viewport top. The result applies at either horizontal anchor and every supported shell width without rewriting the shared position merely because local projection changed. +- While WebChat is expanded in a viewport at least `459px` high, apply one additional local vertical bound so upward dragging, opening or reopening, and viewport resizing keep the shell's top edge at least `40px` below the viewport top. The result applies at either horizontal anchor and every supported shell width without rewriting the shared position merely because local projection changed. +- Below `459px`, retain the `375px` shell minimum height, its `22px` launcher relationship, and the launcher's viewport bounds without shrinking the shell or adding a top-inset adaptation. - Preserve the current hand-control drag interaction: continuous animation-frame pointer following, bounded movement, selection suppression, and grab cursor, with no snap, rebound, easing, or release-behavior change. - Preserve zero unread attention for self-authored text, history application, and duplicate delivery. Browser-window focus, active/highlighted tab, and browser-notification enabled/type settings do not participate in unread eligibility or clearing. - Keep the AppButton indicator count-free: a top-right orange ping with an opaque orange center and a short opacity presence transition. diff --git a/openspec/changes/show-shared-domain-unread-badge/specs/webrtc-runtime/spec.md b/openspec/changes/show-shared-domain-unread-badge/specs/webrtc-runtime/spec.md index 04896aee..6b5154c7 100644 --- a/openspec/changes/show-shared-domain-unread-badge/specs/webrtc-runtime/spec.md +++ b/openspec/changes/show-shared-domain-unread-badge/specs/webrtc-runtime/spec.md @@ -10,9 +10,11 @@ The launcher SHALL be `44x44px`. In a viewport that can satisfy the fixed margin Each tab SHALL reproject the shared edge-relative coordinates against its own current viewport. It SHALL derive bounds from that viewport and the AppButton geometry so the launcher remains fully visible with the fixed margins. If a viewport can contain the launcher but is too small to satisfy a fixed margin, only that tab's rendered projection SHALL use the nearest fully visible bound with the largest feasible margin; the shared coordinate SHALL remain unchanged. Resizing SHALL perform no shared-state mutation or persistence write, and a later larger viewport SHALL restore the fixed margins from the unchanged shared coordinate. -While WebChat is expanded, the same local geometry projection SHALL add a vertical bound that keeps the shell's top edge at least `40px` below the viewport top. The bound SHALL apply at both horizontal anchors and every shell width allowed by the existing resizer. Upward dragging SHALL stop at the nearest point before the shell would violate that inset, and the shell SHALL NOT render above the viewport top. The collapsed launcher's existing vertical range, the shell's supported size range and launcher relationship, and the launcher's horizontal and bottom margins SHALL remain unchanged. +While WebChat is expanded in a viewport at least `459px` high, the same local geometry projection SHALL add a vertical bound that keeps the shell's top edge at least `40px` below the viewport top. The bound SHALL apply at both horizontal anchors and every shell width allowed by the existing resizer. Upward dragging SHALL stop at the nearest point before the shell would violate that inset, and the shell SHALL NOT render above the viewport top. The shell SHALL retain its `375px` minimum height and its bottom edge SHALL remain `22px` above the launcher bottom edge. The collapsed launcher's vertical range and the launcher's horizontal and bottom margins SHALL remain unchanged. -If a shared coordinate captured while collapsed or in another viewport would place the expanded shell above its top bound, opening, reopening, same-domain open synchronization, and viewport resize SHALL use the nearest shell-safe local projection. Those automatic projections SHALL NOT mutate or persist the shared position. A later compatible local layout SHALL project the unchanged shared coordinate again unless a user drag has written a new bounded position. +In a viewport below `459px`, the shell SHALL retain its `375px` minimum height and `22px` launcher relationship, and the launcher SHALL retain its viewport-derived bounds and margin fallback. The layout SHALL NOT shrink the shell or add a separate top-inset adaptation, and the `40px` shell top inset SHALL NOT be required at that height. + +If a shared coordinate captured while collapsed or in another viewport would place the expanded shell above its top bound in a viewport at least `459px` high, opening, reopening, same-domain open synchronization, and viewport resize SHALL use the nearest shell-safe local projection. Those automatic projections SHALL NOT mutate or persist the shared position. A shorter viewport SHALL use the launcher projection without a position write, and crossing back to at least `459px` SHALL restore the shell-safe local projection from the unchanged shared coordinate unless a user drag has written a new bounded position. Dragging SHALL begin from the existing hand control, follow the latest pointer position once per animation frame, prevent text selection, retain the grab cursor, remain within the derived bounds, and end on mouse release. When the AppButton center crosses the viewport midpoint, the horizontal anchor SHALL change and its edge distance SHALL be converted from the same rendered center in that frame. The button SHALL remain under the pointer without a visual jump, snap, rebound, easing, delayed settle, or release-behavior change. @@ -54,22 +56,28 @@ The visible AppButton indicator SHALL be count-free and SHALL NOT resize the but #### Scenario: Upward drag preserves the expanded-shell top inset -- **GIVEN** WebChat is expanded at either horizontal anchor and at any shell width allowed by the existing resizer +- **GIVEN** WebChat is expanded in a viewport at least `459px` high, at either horizontal anchor and at any shell width allowed by the existing resizer - **WHEN** the user drags the AppButton upward beyond the shell-safe range - **THEN** the rendered AppButton SHALL stop at the nearest local bound, the shell top SHALL remain at least `40px` below the viewport top and SHALL NOT overflow above it, and the existing horizontal, bottom, pointer-following, and release behavior SHALL remain unchanged #### Scenario: Opening locally bounds a shell-unsafe shared point -- **GIVEN** the shared position was captured while collapsed or in another viewport and would place an expanded shell above its top bound in this tab +- **GIVEN** this viewport is at least `459px` high and the shared position was captured while collapsed or in another viewport and would place an expanded shell above its top bound in this tab - **WHEN** WebChat opens, reopens, or becomes open through same-domain synchronization - **THEN** this tab SHALL use the nearest shell-safe local projection with at least `40px` above the shell, SHALL NOT mutate or persist the shared position, and SHALL preserve that position for a later compatible local layout #### Scenario: Resize locally preserves the expanded-shell top inset -- **GIVEN** WebChat is expanded and the viewport changes so the current local projection would violate the shell's top bound +- **GIVEN** WebChat is expanded and the viewport changes to at least `459px` high so the current local projection would violate the shell's top bound - **WHEN** the tab reprojects the shared position - **THEN** it SHALL keep the shell at least `40px` below the viewport top without a shared mutation or persistence write, at either horizontal anchor and every supported shell width +#### Scenario: A short viewport preserves the fixed shell geometry + +- **GIVEN** WebChat is expanded in a viewport below `459px` high +- **WHEN** the shell opens, the AppButton is dragged, or the viewport is resized within that range +- **THEN** the shell SHALL retain its `375px` minimum height and `22px` launcher relationship, the launcher SHALL retain its viewport-derived bounds and margin fallback, no shell-height or top-inset adaptation SHALL be added, and no automatic position write SHALL occur + #### Scenario: Crossing the midpoint is visually continuous - **GIVEN** the user is dragging the AppButton from one viewport half toward the other diff --git a/openspec/changes/show-shared-domain-unread-badge/tasks.md b/openspec/changes/show-shared-domain-unread-badge/tasks.md index a1d67cce..8f855452 100644 --- a/openspec/changes/show-shared-domain-unread-badge/tasks.md +++ b/openspec/changes/show-shared-domain-unread-badge/tasks.md @@ -5,7 +5,7 @@ - [x] 1.3 Define a user-driven open action as the domain read action, collapse as a synchronized domain action, and preserve cross-domain isolation. - [x] 1.4 Define collapsed first-delivery eligibility, expanded delivery as already read, self/history/duplicate exclusions, and independence from browser focus, active/highlighted tab, and notification settings. - [x] 1.5 Freeze the count-free AppButton indicator's placement, orange ping/center styling, and presence transition. -- [x] 1.6 Define left-bottom and right-bottom position coordinates, symmetric `50px` horizontal-center / `28px` outer-edge / `22px` bottom-edge minimum margins, the expanded shell's `40px` top inset, midpoint anchor conversion, viewport-derived visibility bounds, and automatic local reprojection with no persistence write. +- [x] 1.6 Define left-bottom and right-bottom position coordinates, symmetric `50px` horizontal-center / `28px` outer-edge / `22px` bottom-edge minimum margins, the expanded shell's `40px` top inset at viewport heights `>=459px`, unchanged fixed shell geometry below that threshold, midpoint anchor conversion, and automatic local reprojection with no persistence write. - [x] 1.7 Freeze whole-status synchronization, field-scoped open/position/unread writes, and the current continuous hand-control drag behavior at every launcher and expanded-shell bound. ## 2. Regression Coverage @@ -14,9 +14,9 @@ - [x] 2.2 Prove opening through C expands and clears A/B/C together, expanded delivery remains read, collapsing through A collapses all three, a later text restores all three badges, and D remains unchanged. - [x] 2.3 Cover self-authored text, history application, duplicate delivery, repeated eligible text, browser focus, active/highlighted tabs, disabled notifications, and both notification-type settings. - [x] 2.4 Cover delayed hydration and field-scoped open, position, and unread writes so they cannot overwrite another current field or violate `open => !unread`. -- [ ] 2.5 Cover left-half and right-half projection, symmetric fixed edge margins at both bottom corners, the `40px` expanded-shell top inset at either anchor and every supported shell width, exact-midpoint ownership, same-domain synchronization, cross-domain isolation, and different viewport sizes. -- [ ] 2.6 Prove opening, reopening, same-domain open synchronization, and resize perform no position write; an incompatible local layout bounds only the rendered expanded position; and a later compatible layout restores projection from the unchanged shared coordinates. -- [ ] 2.7 Prove upward and midpoint-crossing drags retain the shell top inset, rendered center, and current animation-frame pointer following, drag bounds, cursor, selection suppression, and mouse-release behavior without snap, rebound, or easing. +- [ ] 2.5 Cover left-half and right-half projection, symmetric fixed edge margins at both bottom corners, the `40px` expanded-shell top inset at heights `>=459px` for either anchor and every supported shell width, exact-midpoint ownership, same-domain synchronization, cross-domain isolation, and different viewport sizes. +- [ ] 2.6 Prove opening, reopening, same-domain open synchronization, and resize perform no position write; heights below `459px` retain the `375px` shell minimum, `22px` launcher relationship, and launcher bounds without top-inset adaptation; and crossing back to `>=459px` restores shell-safe projection from the unchanged shared coordinates. +- [ ] 2.7 Prove upward drags at heights `>=459px` and midpoint-crossing drags retain the applicable shell top inset, rendered center, and current animation-frame pointer following, drag bounds, cursor, selection suppression, and mouse-release behavior without snap, rebound, or easing. - [x] 2.8 Cover the AppButton indicator's exact visibility, structure, tokens, animation, and count-free layout through the fixed Vitest, happy-dom, Testing Library, and Vitest Browser Mode stack selected by responsibility. ## 3. Minimum Repair @@ -25,7 +25,7 @@ - [x] 3.2 Set domain attention at the first-delivered remote-text boundary only while the shared domain is collapsed. - [x] 3.3 Synchronize expand/collapse across the current domain, clear unread on open, enforce `open => !unread`, and project badge visibility without enumerating browser tabs/windows. - [x] 3.4 Persist open, position, and boolean unread attention through field-scoped updates within one shared status, with hydration adopting rather than rewriting current field values. -- [ ] 3.5 Project the shared position from the selected bottom edge, preserve the fixed launcher margins and expanded-shell top inset, convert anchors continuously at the midpoint, and derive every local bound without an automatic position write. +- [ ] 3.5 Project the shared position from the selected bottom edge, preserve the fixed launcher margins, apply the expanded-shell top inset at heights `>=459px`, retain the fixed shell geometry below that threshold, convert anchors continuously at the midpoint, and derive every local bound without an automatic position write. - [ ] 3.6 Reuse the existing hand-control drag interaction, AppButton indicator, shell sizing, and same-domain synchronization boundary without adding a position owner, Domain, count, setting, API, permission, dependency, Runtime/protocol change, or browser-specific branch. ## 4. Delivery Gates From 34ab956cfda7008c8e33fac2b7328c8a1386ecf9 Mon Sep 17 00:00:00 2001 From: molvqingtai Date: Mon, 3 Aug 2026 09:43:21 +0800 Subject: [PATCH 08/20] fix(app-button): preserve expanded shell top inset --- .../content/views/app-button/index.test.ts | 8 +- src/app/content/views/app-button/index.tsx | 8 +- .../content/views/app-button/position.test.ts | 88 ++++++++++++++----- src/app/content/views/app-button/position.ts | 34 +++++-- src/app/content/views/app-main/index.test.ts | 45 ++++++++-- src/app/content/views/app-main/index.tsx | 2 +- src/hooks/useDraggable.test.tsx | 14 +++ 7 files changed, 155 insertions(+), 44 deletions(-) diff --git a/src/app/content/views/app-button/index.test.ts b/src/app/content/views/app-button/index.test.ts index f27e5ee0..6ac515de 100644 --- a/src/app/content/views/app-button/index.test.ts +++ b/src/app/content/views/app-button/index.test.ts @@ -69,9 +69,11 @@ describe('reconnect action availability', () => { const value = source() expect(value).toContain('const windowSize = useWindowResize()') - expect(value).toContain('const projectedPosition = projectAppButtonPosition(appPosition, windowSize)') - expect(value).toContain('const dragBounds = getAppButtonDragBounds(windowSize)') - expect(value).toContain('captureAppButtonPosition(position, windowSize)') + expect(value).toContain( + 'const projectedPosition = projectAppButtonPosition(appPosition, windowSize, appOpenStatus)' + ) + expect(value).toContain('const dragBounds = getAppButtonDragBounds(windowSize, appOpenStatus)') + expect(value).toContain('captureAppButtonPosition(position, windowSize, appOpenStatus)') expect(value).toContain('onChange: handlePositionChange') expect(value).not.toContain('positionPersistenceStarted') expect(value).not.toContain('useWindowResize(() =>') diff --git a/src/app/content/views/app-button/index.tsx b/src/app/content/views/app-button/index.tsx index 1449d14d..171d5df8 100644 --- a/src/app/content/views/app-button/index.tsx +++ b/src/app/content/views/app-button/index.tsx @@ -227,13 +227,13 @@ const AppButton: FC = () => { const [menuOpen, setMenuOpen] = useState(false) const windowSize = useWindowResize() - const projectedPosition = projectAppButtonPosition(appPosition, windowSize) - const dragBounds = getAppButtonDragBounds(windowSize) + const projectedPosition = projectAppButtonPosition(appPosition, windowSize, appOpenStatus) + const dragBounds = getAppButtonDragBounds(windowSize, appOpenStatus) const handlePositionChange = useCallback( (position: { x: number; y: number }) => { - send(appStatusDomain.command.UpdatePositionCommand(captureAppButtonPosition(position, windowSize))) + send(appStatusDomain.command.UpdatePositionCommand(captureAppButtonPosition(position, windowSize, appOpenStatus))) }, - [appStatusDomain.command, send, windowSize] + [appOpenStatus, appStatusDomain.command, send, windowSize] ) const { diff --git a/src/app/content/views/app-button/position.test.ts b/src/app/content/views/app-button/position.test.ts index 6cb5b848..d31e5a08 100644 --- a/src/app/content/views/app-button/position.test.ts +++ b/src/app/content/views/app-button/position.test.ts @@ -8,69 +8,115 @@ import { describe('AppButton edge-relative position', () => { const viewport = { width: 1000, height: 800 } + const shellMinimumHeight = 375 + const shellTopInset = 40 + + const getShellTop = (launcherBottomEdge: number) => launcherBottomEdge - APP_BUTTON_SIZE / 2 - shellMinimumHeight it('captures and projects the launcher center from the selected bottom edge', () => { - expect(captureAppButtonPosition({ x: 200, y: 700 }, viewport)).toEqual({ x: -200, y: 100 }) - expect(projectAppButtonPosition({ x: -200, y: 100 }, viewport)).toEqual({ x: 200, y: 700 }) + expect(captureAppButtonPosition({ x: 200, y: 700 }, viewport, false)).toEqual({ x: -200, y: 100 }) + expect(projectAppButtonPosition({ x: -200, y: 100 }, viewport, false)).toEqual({ x: 200, y: 700 }) - expect(captureAppButtonPosition({ x: 800, y: 700 }, viewport)).toEqual({ x: 200, y: 100 }) - expect(projectAppButtonPosition({ x: 200, y: 100 }, viewport)).toEqual({ x: 800, y: 700 }) + expect(captureAppButtonPosition({ x: 800, y: 700 }, viewport, false)).toEqual({ x: 200, y: 100 }) + expect(projectAppButtonPosition({ x: 200, y: 100 }, viewport, false)).toEqual({ x: 800, y: 700 }) }) it('captures both bottom corners at the symmetric fixed margins', () => { - expect(captureAppButtonPosition({ x: -100, y: 900 }, viewport)).toEqual({ x: -50, y: 22 }) - expect(captureAppButtonPosition({ x: 1100, y: 900 }, viewport)).toEqual({ x: 50, y: 22 }) + expect(captureAppButtonPosition({ x: -100, y: 900 }, viewport, false)).toEqual({ x: -50, y: 22 }) + expect(captureAppButtonPosition({ x: 1100, y: 900 }, viewport, false)).toEqual({ x: 50, y: 22 }) }) it('projects a right-bottom coordinate inside the fixed margins', () => { - expect(projectAppButtonPosition({ x: 0, y: 0 }, viewport)).toEqual({ x: 950, y: 778 }) + expect(projectAppButtonPosition({ x: 0, y: 0 }, viewport, false)).toEqual({ x: 950, y: 778 }) }) it('assigns the exact midpoint to the right edge without moving the rendered center', () => { - const captured = captureAppButtonPosition({ x: 500, y: 640 }, viewport) + const captured = captureAppButtonPosition({ x: 500, y: 640 }, viewport, false) expect(captured).toEqual({ x: 500, y: 160 }) - expect(projectAppButtonPosition(captured, viewport)).toEqual({ x: 500, y: 640 }) + expect(projectAppButtonPosition(captured, viewport, false)).toEqual({ x: 500, y: 640 }) }) it('keeps midpoint crossing visually continuous while changing anchors', () => { - const before = captureAppButtonPosition({ x: 499, y: 600 }, viewport) - const after = captureAppButtonPosition({ x: 501, y: 600 }, viewport) + const before = captureAppButtonPosition({ x: 499, y: 600 }, viewport, false) + const after = captureAppButtonPosition({ x: 501, y: 600 }, viewport, false) expect(before).toEqual({ x: -499, y: 200 }) expect(after).toEqual({ x: 499, y: 200 }) - expect(projectAppButtonPosition(before, viewport).x).toBe(499) - expect(projectAppButtonPosition(after, viewport).x).toBe(501) + expect(projectAppButtonPosition(before, viewport, false).x).toBe(499) + expect(projectAppButtonPosition(after, viewport, false).x).toBe(501) }) it('bounds only a narrow viewport projection and restores the unchanged coordinate when widened', () => { const shared = { x: 500, y: 500 } - expect(projectAppButtonPosition(shared, { width: 400, height: 250 })).toEqual({ x: 50, y: 44 }) - expect(projectAppButtonPosition(shared, { width: 1200, height: 900 })).toEqual({ x: 700, y: 400 }) + expect(projectAppButtonPosition(shared, { width: 400, height: 250 }, false)).toEqual({ x: 50, y: 44 }) + expect(projectAppButtonPosition(shared, { width: 1200, height: 900 }, false)).toEqual({ x: 700, y: 400 }) expect(shared).toEqual({ x: 500, y: 500 }) }) it('projects one shared coordinate independently in different tab viewports', () => { const shared = { x: -180, y: 60 } - expect(projectAppButtonPosition(shared, { width: 500, height: 400 })).toEqual({ x: 180, y: 340 }) - expect(projectAppButtonPosition(shared, { width: 1000, height: 800 })).toEqual({ x: 180, y: 740 }) + expect(projectAppButtonPosition(shared, { width: 500, height: 400 }, false)).toEqual({ x: 180, y: 340 }) + expect(projectAppButtonPosition(shared, { width: 1000, height: 800 }, false)).toEqual({ x: 180, y: 740 }) + }) + + it.each([ + { side: 'left', position: { x: -200, y: 756 }, expectedX: 200 }, + { side: 'right', position: { x: 200, y: 756 }, expectedX: 800 } + ])('keeps the expanded shell top inset at the $side anchor', (expected) => { + const projected = projectAppButtonPosition(expected.position, viewport, true) + + expect(projected).toEqual({ x: expected.expectedX, y: 437 }) + expect(getShellTop(projected.y)).toBe(shellTopInset) + expect(expected.position).toEqual({ x: expected.side === 'left' ? -200 : 200, y: 756 }) + }) + + it('reprojects opening and reopening locally while only a user drag captures the expanded bound', () => { + const shared = { x: -200, y: 756 } + + expect(projectAppButtonPosition(shared, viewport, false)).toEqual({ x: 200, y: 44 }) + expect(projectAppButtonPosition(shared, viewport, true)).toEqual({ x: 200, y: 437 }) + expect(projectAppButtonPosition(shared, viewport, false)).toEqual({ x: 200, y: 44 }) + expect(projectAppButtonPosition(shared, viewport, true)).toEqual({ x: 200, y: 437 }) + expect(shared).toEqual({ x: -200, y: 756 }) + + const dragged = captureAppButtonPosition({ x: 200, y: 100 }, viewport, true) + expect(dragged).toEqual({ x: -200, y: 363 }) + expect(projectAppButtonPosition(dragged, viewport, true)).toEqual({ x: 200, y: 437 }) + }) + + it('keeps launcher bounds and fixed shell geometry below the expanded-inset threshold', () => { + const shortViewport = { width: 500, height: 458 } + const shared = { x: -180, y: 400 } + const launcherBounds = { minX: 50, maxX: 450, minY: 44, maxY: 436 } + + expect(getAppButtonDragBounds(shortViewport, false)).toEqual(launcherBounds) + expect(getAppButtonDragBounds(shortViewport, true)).toEqual(launcherBounds) + expect(projectAppButtonPosition(shared, shortViewport, false)).toEqual({ x: 180, y: 58 }) + expect(projectAppButtonPosition(shared, shortViewport, true)).toEqual({ x: 180, y: 58 }) + expect(shared).toEqual({ x: -180, y: 400 }) }) it('derives stable bounds from the launcher geometry and current viewport', () => { - const bounds = getAppButtonDragBounds(viewport) + const bounds = getAppButtonDragBounds(viewport, false) expect(bounds).toEqual({ minX: 50, maxX: 950, minY: 44, maxY: 778 }) expect(bounds.minX - APP_BUTTON_SIZE / 2).toBe(28) expect(viewport.width - bounds.maxX - APP_BUTTON_SIZE / 2).toBe(28) expect(viewport.height - bounds.maxY).toBe(22) - const constrained = getAppButtonDragBounds({ width: 80, height: 50 }) + const constrained = getAppButtonDragBounds({ width: 80, height: 50 }, false) expect(constrained).toEqual({ minX: 40, maxX: 40, minY: 44, maxY: 44 }) expect(constrained.minX - APP_BUTTON_SIZE / 2).toBe(18) expect(80 - constrained.maxX - APP_BUTTON_SIZE / 2).toBe(18) expect(50 - constrained.maxY).toBe(6) - expect(projectAppButtonPosition({ x: 0, y: 0 }, { width: 80, height: 50 })).toEqual({ x: 40, y: 44 }) - expect(getAppButtonDragBounds({ width: 30, height: 20 })).toEqual({ minX: 15, maxX: 15, minY: 20, maxY: 20 }) + expect(projectAppButtonPosition({ x: 0, y: 0 }, { width: 80, height: 50 }, false)).toEqual({ x: 40, y: 44 }) + expect(getAppButtonDragBounds({ width: 30, height: 20 }, false)).toEqual({ + minX: 15, + maxX: 15, + minY: 20, + maxY: 20 + }) }) }) diff --git a/src/app/content/views/app-button/position.ts b/src/app/content/views/app-button/position.ts index e03c40dd..7d9b4431 100644 --- a/src/app/content/views/app-button/position.ts +++ b/src/app/content/views/app-button/position.ts @@ -4,6 +4,9 @@ import { clamp } from '@/utils' export const APP_BUTTON_SIZE = 44 const APP_BUTTON_HORIZONTAL_CENTER_MARGIN = 50 const APP_BUTTON_BOTTOM_MARGIN = 22 +const APP_SHELL_TOP_INSET = 40 +const APP_SHELL_MINIMUM_HEIGHT = 375 +const APP_SHELL_MINIMUM_LAUNCHER_BOTTOM_EDGE = APP_SHELL_TOP_INSET + APP_SHELL_MINIMUM_HEIGHT + APP_BUTTON_SIZE / 2 export interface ViewportSize { width: number @@ -17,10 +20,14 @@ export interface AppButtonPoint { y: number } -export const getAppButtonDragBounds = ({ width, height }: ViewportSize) => { +export const getAppButtonDragBounds = ({ width, height }: ViewportSize, expanded: boolean) => { const horizontalInset = Math.min(APP_BUTTON_HORIZONTAL_CENTER_MARGIN, width / 2) - const minimumBottomEdge = Math.min(APP_BUTTON_SIZE, height) - const maximumBottomEdge = Math.max(minimumBottomEdge, height - APP_BUTTON_BOTTOM_MARGIN) + const launcherMinimumBottomEdge = Math.min(APP_BUTTON_SIZE, height) + const maximumBottomEdge = Math.max(launcherMinimumBottomEdge, height - APP_BUTTON_BOTTOM_MARGIN) + const minimumBottomEdge = + expanded && maximumBottomEdge >= APP_SHELL_MINIMUM_LAUNCHER_BOTTOM_EDGE + ? APP_SHELL_MINIMUM_LAUNCHER_BOTTOM_EDGE + : launcherMinimumBottomEdge return { minX: horizontalInset, maxX: width - horizontalInset, @@ -29,25 +36,34 @@ export const getAppButtonDragBounds = ({ width, height }: ViewportSize) => { } } -const boundAppButtonPoint = (point: AppButtonPoint, viewport: ViewportSize): AppButtonPoint => { - const bounds = getAppButtonDragBounds(viewport) +const boundAppButtonPoint = (point: AppButtonPoint, viewport: ViewportSize, expanded: boolean): AppButtonPoint => { + const bounds = getAppButtonDragBounds(viewport, expanded) return { x: clamp(point.x, bounds.minX, bounds.maxX), y: clamp(point.y, bounds.minY, bounds.maxY) } } -export const projectAppButtonPosition = (position: AppButtonPosition, viewport: ViewportSize): AppButtonPoint => +export const projectAppButtonPosition = ( + position: AppButtonPosition, + viewport: ViewportSize, + expanded: boolean +): AppButtonPoint => boundAppButtonPoint( { x: position.x < 0 ? -position.x : viewport.width - position.x, y: viewport.height - position.y }, - viewport + viewport, + expanded ) -export const captureAppButtonPosition = (point: AppButtonPoint, viewport: ViewportSize): AppButtonPosition => { - const bounded = boundAppButtonPoint(point, viewport) +export const captureAppButtonPosition = ( + point: AppButtonPoint, + viewport: ViewportSize, + expanded: boolean +): AppButtonPosition => { + const bounded = boundAppButtonPoint(point, viewport, expanded) return { x: bounded.x < viewport.width / 2 ? -bounded.x : viewport.width - bounded.x, y: viewport.height - bounded.y diff --git a/src/app/content/views/app-main/index.test.ts b/src/app/content/views/app-main/index.test.ts index cf6bddb4..f61e3d82 100644 --- a/src/app/content/views/app-main/index.test.ts +++ b/src/app/content/views/app-main/index.test.ts @@ -6,6 +6,7 @@ const fixture = vi.hoisted(() => ({ open: false, position: { x: 50, y: 22 }, viewport: { width: 1200, height: 800 }, + panelSize: 400, resizeDirection: null as 'left' | 'right' | null, initialX: null as string | number | null, animateX: null as string | number | null @@ -26,7 +27,7 @@ vi.mock('remesh-react', () => ({ vi.mock('@/hooks/useResizable', () => ({ default: ({ direction }: { direction: 'left' | 'right' }) => { fixture.resizeDirection = direction - return { size: 400, setRef: () => {} } + return { size: fixture.panelSize, setRef: () => {} } } })) vi.mock('@/hooks/useWindowResize', () => ({ default: () => fixture.viewport })) @@ -60,6 +61,7 @@ afterEach(() => { fixture.open = false fixture.position = { x: 50, y: 22 } fixture.viewport = { width: 1200, height: 800 } + fixture.panelSize = 400 fixture.resizeDirection = null fixture.initialX = null fixture.animateX = null @@ -152,15 +154,46 @@ describe('AppMain panel ownership', () => { expect(resizeHandle.className).toContain(expected.handleClass) }) + it.each([ + { side: 'left', position: { x: -200, y: 756 }, expectedLeft: '200px' }, + { side: 'right', position: { x: 200, y: 756 }, expectedLeft: '800px' } + ])('keeps the expanded shell at its top inset on the $side for every supported width', (expected) => { + fixture.open = true + fixture.viewport = { width: 1000, height: 800 } + fixture.position = expected.position + const view = render(content()) + const panel = document.querySelector('[data-webchat-panel]')! + + for (const panelSize of [375, 500, 750]) { + fixture.panelSize = panelSize + view.rerender(content()) + + expect(panel.style.width).toBe(`${panelSize}px`) + expect(panel.style.left).toBe(expected.expectedLeft) + expect(panel.style.bottom).toBe('calc(100vh - 437px + 22px)') + expect(panel.className).toContain('inset-y-10') + expect(panel.className).toContain('min-h-[375px]') + } + }) + it('reprojects a bounded shared coordinate on resize and derives the panel side from the rendered point', () => { fixture.open = true - fixture.position = { x: 500, y: 500 } - fixture.viewport = { width: 400, height: 250 } + fixture.position = { x: 500, y: 400 } + fixture.viewport = { width: 400, height: 458 } const view = render(content()) const panel = document.querySelector('[data-webchat-panel]')! expect(panel.style.left).toBe('50px') - expect(panel.style.bottom).toBe('calc(100vh - 44px + 22px)') + expect(panel.style.bottom).toBe('calc(100vh - 58px + 22px)') + expect(panel.className).toContain('min-h-[375px]') + expect(fixture.resizeDirection).toBe('right') + expect(fixture.animateX).toBe('0') + + fixture.viewport = { width: 400, height: 459 } + view.rerender(content()) + + expect(panel.style.left).toBe('50px') + expect(panel.style.bottom).toBe('calc(100vh - 437px + 22px)') expect(fixture.resizeDirection).toBe('right') expect(fixture.animateX).toBe('0') @@ -168,9 +201,9 @@ describe('AppMain panel ownership', () => { view.rerender(content()) expect(panel.style.left).toBe('700px') - expect(panel.style.bottom).toBe('calc(100vh - 400px + 22px)') + expect(panel.style.bottom).toBe('calc(100vh - 500px + 22px)') expect(fixture.resizeDirection).toBe('left') expect(fixture.animateX).toBe('-100%') - expect(fixture.position).toEqual({ x: 500, y: 500 }) + expect(fixture.position).toEqual({ x: 500, y: 400 }) }) }) diff --git a/src/app/content/views/app-main/index.tsx b/src/app/content/views/app-main/index.tsx index 9ae1a08d..f1da0ef7 100644 --- a/src/app/content/views/app-main/index.tsx +++ b/src/app/content/views/app-main/index.tsx @@ -18,7 +18,7 @@ const AppMain: FC = ({ children, className }) => { const position = useRemeshQuery(appStatusDomain.query.PositionQuery()) const { width, height } = useWindowResize() - const { x: absoluteX, y: absoluteY } = projectAppButtonPosition(position, { width, height }) + const { x: absoluteX, y: absoluteY } = projectAppButtonPosition(position, { width, height }, true) const isOnRightSide = absoluteX >= width / 2 const { size, setRef } = useResizable({ diff --git a/src/hooks/useDraggable.test.tsx b/src/hooks/useDraggable.test.tsx index 85d4aad4..077f0b3f 100644 --- a/src/hooks/useDraggable.test.tsx +++ b/src/hooks/useDraggable.test.tsx @@ -90,6 +90,20 @@ describe('useDraggable', () => { expect(onChange).not.toHaveBeenCalled() }) + it('stops an upward drag at the expanded shell bound and reports that bounded point', () => { + const onChange = vi.fn() + render() + const handle = screen.getByTestId('drag-handle') + + fireEvent.mouseDown(handle, { clientX: 200, clientY: 700 }) + fireEvent.mouseMove(document, { clientX: 200, clientY: 100 }) + flushAnimationFrame() + + expect(handle.dataset.x).toBe('200') + expect(handle.dataset.y).toBe('437') + expect(onChange).toHaveBeenLastCalledWith({ x: 200, y: 437 }) + }) + it('reprojects changed inputs and bounds locally without reporting a user drag', async () => { const onChange = vi.fn() const view = render( From e3d0bc32456e8e9e1417bb21a1b679bcfc54f0b6 Mon Sep 17 00:00:00 2001 From: molvqingtai Date: Mon, 3 Aug 2026 10:53:15 +0800 Subject: [PATCH 09/20] fix(content): unify shell and launcher geometry --- src/app/content/App.render.test.tsx | 17 +- src/app/content/App.test.ts | 23 +- src/app/content/App.tsx | 27 +- .../views/app-button/index.browser.test.tsx | 8 +- .../content/views/app-button/index.test.ts | 19 +- src/app/content/views/app-button/index.tsx | 71 +++--- .../content/views/app-button/position.test.ts | 48 +++- src/app/content/views/app-button/position.ts | 64 ++++- .../views/app-main/index.browser.test.tsx | 237 ++++++++++++++++-- src/app/content/views/app-main/index.test.ts | 104 ++------ src/app/content/views/app-main/index.tsx | 40 ++- src/hooks/useDraggable.test.tsx | 46 +--- src/hooks/useDraggable.ts | 28 +-- 13 files changed, 473 insertions(+), 259 deletions(-) diff --git a/src/app/content/App.render.test.tsx b/src/app/content/App.render.test.tsx index ee6e49d1..6e388e39 100644 --- a/src/app/content/App.render.test.tsx +++ b/src/app/content/App.render.test.tsx @@ -15,6 +15,10 @@ vi.mock('remesh-react', () => ({ switch (query) { case 'initialization-ready': return fixture.ready + case 'app-open': + return false + case 'app-position': + return { x: 50, y: 22 } case 'user-load-finished': return true case 'user-info': @@ -26,8 +30,15 @@ vi.mock('remesh-react', () => ({ })) vi.mock('@/domain/AppStatus', () => ({ default: () => ({ - query: { ReadyQuery: () => 'initialization-ready' }, - command: { UpdateOpenCommand: (open: boolean) => `update-open-${open}` } + query: { + ReadyQuery: () => 'initialization-ready', + OpenQuery: () => 'app-open', + PositionQuery: () => 'app-position' + }, + command: { + UpdateOpenCommand: (open: boolean) => `update-open-${open}`, + UpdatePositionCommand: () => 'update-position' + } }) })) vi.mock('@/domain/ChatRoom', () => ({ @@ -82,6 +93,8 @@ vi.mock('@/app/content/components/danmaku-container', async () => { vi.mock('sonner', () => ({ Toaster: () =>
})) vi.mock('@/utils', () => ({ checkDarkMode: () => false, + clamp: (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value)), + isInRange: (value: number, minimum: number, maximum: number) => value >= minimum && value <= maximum, cn: (...values: unknown[]) => values.filter(Boolean).join(' ') })) diff --git a/src/app/content/App.test.ts b/src/app/content/App.test.ts index 59cff8f2..435e11fc 100644 --- a/src/app/content/App.test.ts +++ b/src/app/content/App.test.ts @@ -26,14 +26,14 @@ describe('content component hierarchy', () => { expect(app).toMatch(/(?:function App\(\)|const App = \(\) =>)/) ordered(app, [ '
', + '', '
', '
', '