+English | [简体中文](./README_zh.md)
+
# WebChat
[](https://github.com/molvqingtai/WebChat/actions) [](https://github.com/molvqingtai/WebChat/blob/master/LICENSE) [](https://chromewebstore.google.com/detail/webchat/cpaedhbidlpnbdfegakhiamfpndhjpgf) [](https://github.com/molvqingtai/WebChat/releases) [](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, [
'
-### 使用方法
+## 使用方法
-安装扩展后,你会在任何网站的右下角看到一个幽灵图标。点击它,你就能和同一网站上的其他人愉快地聊天了!
+安装 WebChat 后,每个网站的右下角都会出现一个幽灵图标。点击它即可加入该网站的聊天室,与其他访客聊天。
-### 视频
+## 演示
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 库,也是无安装概念的先驱,在自定义样式方面提供了无与伦比的便利。
+加入 [WebChat Discord 社区](https://discord.com/channels/1398133810398367805/1398137562043908248),分享反馈、提出问题,并结识其他用户。
-- **[wxt](https://wxt.dev/)**:这是我用过构建浏览器扩展最好的框架,没有之一。
+## 构建基础
-- ~~**[trystero](https://github.com/dmotz/trystero)**:实现去中心化通信的核心依赖,可连接到 IPFS、torrent、Nostr 等去中心化网络。~~
-- **[Artico](https://github.com/matallui/artico)**:一套灵活的库,帮助你创建自己的基于 WebRTC 的解决方案。
+WebChat 建立在以下优秀的开源项目之上:
-- **[ugly-avatar](https://github.com/txstc55/ugly-avatar)**:用它来创建令人惊艳的随机头像。
+- **[Remesh](https://github.com/remesh-js/remesh)** 提供了受 DDD 启发的状态模型,使应用逻辑独立于 React UI。
+- **[shadcn/ui](https://ui.shadcn.com/)** 提供了可访问、可定制的 UI 基础组件。
+- **[WXT](https://wxt.dev/)** 提供了跨浏览器扩展框架与构建工具。
+- **[Comctx](https://github.com/molvqingtai/comctx)** 提供了扩展各 JavaScript 上下文之间的 RPC 通信能力。
+- **[Artico](https://github.com/matallui/artico)** 提供了灵活的点对点通信 WebRTC 基础。
+- **[ugly-avatar](https://github.com/txstc55/ugly-avatar)** 生成了 WebChat 独具特色的随机头像。
-### 赞助商
+## 赞助商
-WebChat 是一个开源项目,其持续发展完全依靠这些出色支持者的支持才得以实现。
+WebChat 是一个由贡献者与赞助商共同维系的开源项目。
-[](https://dartnode.com "由 DartNode 提供支持 - 面向开源的免费 VPS")
+
-### 许可证
+## 许可证
-本项目基于 MIT 许可证开源 - 详情请参阅 [LICENSE](./LICENSE) 文件。
+WebChat 基于 [MIT 许可证](./LICENSE) 开放使用。
From e8d8ae426cff805400d1d6787406f2a1fae70558 Mon Sep 17 00:00:00 2001
From: molvqingtai
Date: Mon, 3 Aug 2026 22:24:47 +0800
Subject: [PATCH 13/20] feat(content): highlight reactions and preview message
images
---
.../.openspec.yaml | 4 +
.../README.md | 3 +
.../design.md | 98 +++
.../proposal.md | 33 +
.../specs/webrtc-runtime/spec.md | 164 ++++
.../tasks.md | 36 +
src/app/content/App.render.test.tsx | 3 +-
src/app/content/App.test.ts | 5 +-
src/app/content/App.tsx | 51 +-
.../content/components/like-button.test.tsx | 135 ++++
src/app/content/components/like-button.tsx | 34 +-
src/app/content/components/markdown.test.tsx | 313 ++++++++
src/app/content/components/markdown.tsx | 259 ++++++
.../components/media-preview-geometry.ts | 118 +++
.../components/media-preview.browser.test.tsx | 728 +++++++++++++++++
.../content/components/media-preview.test.tsx | 683 ++++++++++++++++
src/app/content/components/media-preview.tsx | 737 ++++++++++++++++++
src/app/content/components/message-item.tsx | 33 +-
src/app/content/index.test.ts | 36 +-
src/app/content/index.tsx | 40 +-
src/app/content/views/main/index.tsx | 16 +-
src/components/markdown.tsx | 120 ---
src/domain/impls/database/Database.test.ts | 73 ++
src/domain/impls/database/Definition.ts | 6 +
src/domain/impls/database/IndexedDB.ts | 7 +-
25 files changed, 3524 insertions(+), 211 deletions(-)
create mode 100644 openspec/changes/highlight-likes-and-preview-message-images/.openspec.yaml
create mode 100644 openspec/changes/highlight-likes-and-preview-message-images/README.md
create mode 100644 openspec/changes/highlight-likes-and-preview-message-images/design.md
create mode 100644 openspec/changes/highlight-likes-and-preview-message-images/proposal.md
create mode 100644 openspec/changes/highlight-likes-and-preview-message-images/specs/webrtc-runtime/spec.md
create mode 100644 openspec/changes/highlight-likes-and-preview-message-images/tasks.md
create mode 100644 src/app/content/components/like-button.test.tsx
create mode 100644 src/app/content/components/markdown.test.tsx
create mode 100644 src/app/content/components/markdown.tsx
create mode 100644 src/app/content/components/media-preview-geometry.ts
create mode 100644 src/app/content/components/media-preview.browser.test.tsx
create mode 100644 src/app/content/components/media-preview.test.tsx
create mode 100644 src/app/content/components/media-preview.tsx
delete mode 100644 src/components/markdown.tsx
diff --git a/openspec/changes/highlight-likes-and-preview-message-images/.openspec.yaml b/openspec/changes/highlight-likes-and-preview-message-images/.openspec.yaml
new file mode 100644
index 00000000..7b5833bd
--- /dev/null
+++ b/openspec/changes/highlight-likes-and-preview-message-images/.openspec.yaml
@@ -0,0 +1,4 @@
+schema: spec-driven
+created: 2026-08-02
+goal: Unify positive reaction presentation and give every message image one stable,
+ bounded, zoomable preview path in the existing WebChat application root.
diff --git a/openspec/changes/highlight-likes-and-preview-message-images/README.md b/openspec/changes/highlight-likes-and-preview-message-images/README.md
new file mode 100644
index 00000000..0fc8c863
--- /dev/null
+++ b/openspec/changes/highlight-likes-and-preview-message-images/README.md
@@ -0,0 +1,3 @@
+# highlight-likes-and-preview-message-images
+
+Unify positive reaction color and add one stable, bounded, zoomable message-image preview path.
diff --git a/openspec/changes/highlight-likes-and-preview-message-images/design.md b/openspec/changes/highlight-likes-and-preview-message-images/design.md
new file mode 100644
index 00000000..e1d9b1ac
--- /dev/null
+++ b/openspec/changes/highlight-likes-and-preview-message-images/design.md
@@ -0,0 +1,98 @@
+## Context
+
+The message projection already exposes the current user's membership separately from the complete aggregate for both likes and hates. The shared `LikeButton` currently uses only current-user membership for each control's color, so either reaction contributed solely by other users retains the default gray presentation.
+
+The shared Markdown renderer currently has two image branches: Markdown image syntax and image-valued links. Both use `max-width:70%`, neither has a height bound, and their duplicated element construction can diverge. The current App mounts AppMain, AppButton, and Danmaku inside one Shadow-root React tree. No image-preview owner exists.
+
+See `proposal.md` for motivation and `specs/webrtc-runtime/spec.md` for the complete observable contract.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Keep current-user membership as each reaction control's toggle truth while deriving both like and hate color from their matching existing aggregates.
+- Give both Markdown image forms one sanitized inline renderer and one preview activation path.
+- Give each message image one lifecycle-owned Blob URL that its inline and preview images reuse.
+- Size inline images from one message-content CSS query container, with equal `70cqi` maximums on both axes and no runtime geometry owner.
+- Keep exactly one local `MediaPreview` component/state owner inside the existing App root and at the Danmaku component level.
+- Provide deterministic fit, toolbar order, guarded opening, zoom, pan, layer, close, focus, and motion behavior without host-page ownership.
+- Keep interaction controls keyboard-, pointer-, touch-, and reduced-motion-accessible.
+
+**Non-Goals:**
+
+- Changing reaction projection, LWW, count values, add-or-remove semantics, send commands, history, persistence, peer protocol, or message content.
+- Adding another Domain, Extern, application root, portal root, event bus, persistence key, dependency, permission, public API, or host-page style owner.
+- Adding any media type other than the currently confirmed rendered message image.
+- Adding image upload, alternate network fetching, crop, rotation, download, carousel, annotation, square placeholders, runtime measurement, `ResizeObserver`, or persistent preview state.
+- Mutating host-page business state, assigning transition names or styles to host elements, styling the document transition pseudo-tree, or adding an experimental/scoped transition API branch.
+- Changing the WebChat shell, AppButton, Danmaku, notification, unread, Runtime, or page-scroll behavior.
+
+## Decisions
+
+### 1. Separate aggregate reaction emphasis from current-user toggle state
+
+Each reaction control derives its active visual fact directly from whether its already projected aggregate count is greater than zero. A positive like selects the same red treatment for the like icon and count; a positive hate selects that same red treatment for the hate icon and count. Each existing current-user membership boolean remains the independent interaction state that decides whether the next command adds or removes only that user's matching reaction.
+
+The shared reaction-button presentation owns this symmetric `count > 0` rule. Like count changes affect only the like control, and hate count changes affect only the hate control. No second projection, cached color state, Domain query, or reaction record is needed.
+
+### 2. Render every message image through one shared control
+
+Both ReactMarkdown image callbacks delegate to the same message-image component. The containing message content establishes `container-type: inline-size`. The image applies `max-inline-size: 70cqi`, `max-block-size: 70cqi`, `inline-size: auto`, `block-size: auto`, and `object-fit: contain`, then exposes one accessible preview trigger from the sanitized rendered source and alternative text.
+
+That message-image component owns exactly one Blob URL for its current source. The inline image and the preview request reuse that URL; rerendering and activation do not create another URL. The same lifecycle owner revokes it exactly once when its image leaves the message lifecycle. This removes repeated source conversion without making browser image-decoding behavior part of the product contract.
+
+The two equal maximums are derived from the same message-container inline size while the automatic used dimensions preserve the source aspect ratio. There is no fixed square box, crop, JavaScript measurement, `ResizeObserver`, measured-size state, or second sizing owner. This makes size, containment, focus, and activation structurally identical for both syntaxes.
+
+### 3. Make `MediaPreview` the sole local preview owner in the existing root
+
+One `MediaPreview` component owns the current image, activating element, zoom, translation, and in-progress gesture. It lives in the existing App React/Shadow tree at the same composition level as the Danmaku container and renders its overlay in place. A component-scoped React context exposes only the open-image action to the shared message-image renderer; it creates no second business owner, portal, document root, global event, Domain, or Extern.
+
+The current contract accepts only the lifecycle-owned Blob URL and alternative text derived by the shared message-image renderer. The generic component name does not authorize any unconfirmed input type, speculative branch, placeholder, or fallback.
+
+### 4. Split backdrop and preview-body layer ownership
+
+The extension-owned application surface establishes one stacking context above host-page content. Inside it, the backdrop uses a local layer below the existing WebChat shell, AppButton, and Danmaku, while the preview body, image, and controls use a local layer above those surfaces. The icon toolbar is laid out below the preview image rather than above it or over it. The shell therefore remains visible and operable above the backdrop wherever the preview body does not cover it. The backdrop uses a neutral dark fill at exactly `18%` opacity and no blur, gradient, or decorative surface.
+
+During a document View Transition, browser-owned snapshots render in the transition pseudo-layer above ordinary document stacking. That temporary transition placement is consistent with the preview body being above the shell. Layer tests bind the real application stacking context and relative order, not a duplicated root or an assumption about arbitrary host-page z-index values.
+
+### 5. Derive one fitted baseline and one bounded transform
+
+The preview first computes an aspect-preserving natural-size fit inside the viewport minus `24px` on each edge. The smaller of natural size and available size becomes fitted `1x`, so opening does not implicitly upscale a small image. Zoom is one multiplier over that baseline, clamped to `[0.25,4]`. User zoom may therefore reduce the image below its fitted baseline and natural dimensions without changing the initial open size.
+
+For each axis, pan remains zero while the scaled image fits the available rectangle, so every reduced image stays centered. When it overflows, translation is clamped to half the difference between the scaled and available dimensions. An extreme pan therefore aligns, but never moves, the corresponding image edge past the viewport margin. Focal wheel and pinch zoom preserve the image point under the gesture before applying the same clamp. Viewport resize recomputes fit and clamps the current transform without persisting it.
+
+### 6. Centralize pointer, touch, wheel, and keyboard interaction
+
+The four icon controls use familiar zoom-out, zoom-in, reset, and close symbols with accessible names and tooltips. Button and keyboard zoom use `0.25x` steps; every zoom input shares the `0.25x` through `4x` bounds; reset and `0` restore fitted `1x`. The zoom-out control is disabled only at `0.25x`, and zoom-in is disabled at `4x`. Wheel/trackpad and two-pointer pinch use their local focal point. A single captured pointer pans only while zoom makes at least one axis overflow; reduced images that fit remain centered and do not pan.
+
+The owner records whether a pointer sequence became a drag. Release settles pan and suppresses the click that would otherwise bubble to the backdrop. Event prevention is scoped to preview gestures; no handler mutates `document.body`, document scrolling, or host styles. Closing clears every gesture and transform fact.
+
+### 7. Keep open admission, close, and focus settlement in one owner
+
+The owner admits one open request only while it is closed and records the opening state synchronously. Every later open request while opening or open returns without changing the source, activator, transition, zoom, pan, or gesture state. There is no replacement operation.
+
+Backdrop click without a drag, the close icon, Escape, and synchronized shell collapse call the same close operation. It clears the current image and transform exactly once, then restores focus to the saved activating element if it is still connected.
+
+The shell stays operable above the backdrop in every area the preview body does not cover. Its ordinary message and control behavior remains independent except that collapsing the shell also closes its current preview.
+
+### 8. Use one document View Transition with one temporary image identity
+
+When `document.startViewTransition` is available and reduced motion is not requested, `MediaPreview` performs the admitted open or close state operation inside one document View Transition. The activating image and current preview image share one generation-scoped temporary identity, while the browser also captures and briefly crossfades the document root. A pre-existing host-defined named participant remains browser-owned and may also participate. That document participation is visual only: WebChat assigns no name or style to a host element and changes no host business state.
+
+The sole preview owner keeps one active temporary-identity record and restores the original identity when the operation settles or closes. A repeated open cannot supersede that record. Preview state remains authoritative, and the admitted operation settles exactly once without another preview or stale marker. Reduced motion, a missing API, a synchronous failure, a rejected or skipped transition, or another document transition that prevents execution takes the same state operation immediately. Motion never delays close cleanup or focus restoration.
+
+### 9. Verify behavior through current UI boundaries
+
+Focused component controls cover positive and zero aggregates for both likes and hates, type-local color independence, and current-user add/remove behavior for each control. Shared renderer controls cover both Markdown syntaxes, the message-content query owner, equal `70cqi` maximums, automatic aspect-preserving dimensions, one Blob URL per image lifecycle, inline/preview URL identity, one-time revocation, sanitized source/alt preservation, keyboard activation, and the absence of runtime measurement state. `MediaPreview` controls cover composition ancestry, one owner, the split backdrop/body layers, toolbar placement below the image, `24px` fit, no implicit upscale, opening/open repeated-request no-op behavior, every close path, focus, zoom inputs, focal math, pan bounds, drag suppression, reset, resize, event cleanup, document View Transition, whole-page crossfade, reduced motion, failure fallback, and host-state preservation.
+
+Browser-mode coverage verifies rendered geometry, input behavior, focus, and computed layer order. Structural controls exclude a second root/portal, Domain/Extern/persistence/dependency, duplicated image policy, and host `body` mutation.
+
+## Risks / Trade-offs
+
+- [A red reaction no longer means the current user personally contributed it] -> Current-user membership remains the independent toggle input and accessible pressed state for each reaction; red intentionally represents any positive matching aggregate.
+- [The preview body can overlap the WebChat shell] -> This is the confirmed layer result; the preview body wins above the shell while the shell remains visible and operable above the backdrop wherever it is not covered.
+- [Zoom gestures can leak into the host page] -> Consume only active preview wheel/pointer/touch gestures with non-passive handling where required, and never install a body scroll lock.
+- [A pan release can look like a backdrop click] -> Track drag intent under pointer capture and suppress only that settlement click.
+- [Document View Transition crossfades the page root] -> The brief whole-page and shell crossfade is accepted for open and close; final layout, color, layer, and business state remain unchanged.
+- [A later image activation can arrive while opening or open] -> The sole owner records opening synchronously and treats every later open request as a no-op, so no second source, transition, or transform state can compete.
+- [Another document transition can prevent execution] -> The unavailable, skipped, rejected, or blocked path applies the admitted preview state directly.
diff --git a/openspec/changes/highlight-likes-and-preview-message-images/proposal.md b/openspec/changes/highlight-likes-and-preview-message-images/proposal.md
new file mode 100644
index 00000000..e3f83135
--- /dev/null
+++ b/openspec/changes/highlight-likes-and-preview-message-images/proposal.md
@@ -0,0 +1,33 @@
+## Why
+
+Likes and hates from other users currently remain gray unless the current user contributes the same reaction, so each positive reaction aggregate can have two conflicting visual results. Message images also have no height bound and no focused inspection surface, allowing long images to dominate the conversation while leaving their detail difficult to inspect.
+
+## What Changes
+
+- Show one red treatment on each like or hate icon and visible count whenever the matching reaction aggregate contains at least one user, regardless of whether the current user is one of those users. Return that reaction control to the default gray treatment only when its own active count reaches zero.
+- Keep current-user membership in each reaction aggregate as that control's interaction toggle input without changing reaction aggregation, counts, persistence, or peer semantics.
+- Make message content an inline-size query container and render both Markdown image syntax and image-valued links through one message-image presentation whose maximum inline size and maximum block size are both `70cqi`. Keep both used dimensions automatic so the complete image preserves its source aspect ratio without crop, distortion, or a square placeholder.
+- Give each message image one stable Blob URL for its lifecycle. Its inline image and preview image reuse that URL without recreating it on render or activation, and the same lifecycle owner revokes it when the image leaves that lifecycle.
+- Open any valid rendered message image in one centered `MediaPreview` component inside the existing WebChat App root and at the same component level as the Danmaku container.
+- Keep the neutral dark backdrop at `18%` opacity above the host page but below the WebChat shell, AppButton, and Danmaku. Place the preview body and its controls above those WebChat surfaces; the shell remains visible and operable above the backdrop wherever the preview body does not cover it.
+- Fit the initial preview within a `24px` viewport margin without forcing a small source image larger. Keep its icon toolbar below the preview image. Support one bounded `0.25x` to `4x` zoom multiplier over fitted `1x` through icon controls, wheel or trackpad, pinch, keyboard, and pointer or touch drag.
+- Accept an image-open request only while no preview is opening or open. Every later image-open request is a no-op that neither replaces the image nor restarts the transition or resets zoom and position.
+- Close through the backdrop, the close control, Escape, or shell collapse and restore focus to the activating image. Closing resets zoom and pan.
+- Use `document.startViewTransition` for open and close when it is available and motion is allowed. The browser-managed document-root snapshot and brief whole-page crossfade are part of the transition, while WebChat changes no host-page business state or host-element transition styles. Complete the same state change immediately when the API is unavailable, cannot run, or reduced motion is requested.
+
+## Capabilities
+
+### New Capabilities
+
+None.
+
+### Modified Capabilities
+
+- `webrtc-runtime`: Define symmetric aggregate reaction presentation and the bounded, layered, accessible, zoomable message-image preview result in the existing WebChat application root.
+
+## Impact
+
+- Affected behavior: like and hate color when any user has the matching active reaction, inline message-image dimensions and Blob URL lifetime, centered preview, toolbar placement, guarded opening, zoom, pan, close, focus restoration, layer ordering, and motion fallback.
+- Affected implementation: the shared reaction-button presentation, the message-content query container, the shared Markdown image renderer and its one image-resource lifecycle, the current App composition, and one `MediaPreview` component/state owner beside the Danmaku container.
+- Affected verification: symmetric like/hate aggregate color transitions; both image syntaxes; stable URL reuse and revocation; inline dimensions; split backdrop/preview-body layer order; toolbar placement; repeated-open no-op behavior; input methods; bounded transform; close/reset/focus behavior; document View Transition and whole-page crossfade; reduced-motion and failure paths; host-state preservation; and absence of duplicate owners.
+- Unchanged: reaction LWW projection, current-user reaction toggling, message counts and content, source sanitization, message delivery/history, notification and unread behavior, Danmaku behavior, Runtime networking, peer protocol, persistence, public APIs, permissions, dependencies, and host-page document styles. Image bounds add no runtime measurement or observer state.
diff --git a/openspec/changes/highlight-likes-and-preview-message-images/specs/webrtc-runtime/spec.md b/openspec/changes/highlight-likes-and-preview-message-images/specs/webrtc-runtime/spec.md
new file mode 100644
index 00000000..8079f9c5
--- /dev/null
+++ b/openspec/changes/highlight-likes-and-preview-message-images/specs/webrtc-runtime/spec.md
@@ -0,0 +1,164 @@
+## ADDED Requirements
+
+### Requirement: Active reaction aggregates use one symmetric red presentation
+
+For every text message, the like icon and any visible like count SHALL use one red treatment whenever the projected active-like aggregate contains at least one user. The hate icon and any visible hate count SHALL use the same red treatment whenever the projected active-hate aggregate contains at least one user. Each control's red presentation SHALL depend only on whether its own active count is greater than zero, not on which user contributed that reaction or who authored the text message.
+
+Current-user membership in each aggregate SHALL remain the sole input to that user's matching add-or-remove action. A red aggregate whose users do not include the current user SHALL therefore remain an add action for that user. Removing the current user's reaction while another matching reaction remains SHALL keep that control red; removing the final matching reaction SHALL restore that control's default gray icon and remove its zero count.
+
+This presentation SHALL NOT change reaction aggregation, count values, add-or-remove semantics, message ordering, persistence, history, or peer reaction semantics.
+
+#### Scenario: Another user's like is red
+
+- **GIVEN** a text message has one active like from another user and no active like from the current user
+- **WHEN** WebChat renders the message actions
+- **THEN** the like icon and count SHALL use the canonical active red treatment, and activating the control SHALL add rather than remove the current user's like
+
+#### Scenario: Another user's hate is red
+
+- **GIVEN** a text message has one active hate from another user and no active hate from the current user
+- **WHEN** WebChat renders the message actions
+- **THEN** the hate icon and count SHALL use the same canonical active red treatment, and activating the control SHALL add rather than remove the current user's hate
+
+#### Scenario: Current-user removal keeps another matching reaction red
+
+- **GIVEN** the current user and another user have both contributed the same active reaction to one message
+- **WHEN** the current user removes only their own matching reaction
+- **THEN** that count SHALL decrease by one and the remaining positive aggregate SHALL keep its control red
+
+#### Scenario: Final matching removal restores the default treatment
+
+- **GIVEN** the current user owns the only active like or the only active hate on one message
+- **WHEN** that user removes the matching reaction
+- **THEN** that reaction icon SHALL return to its default gray treatment and no zero count SHALL be rendered
+
+#### Scenario: Reaction colors remain type-local
+
+- **GIVEN** a message has a positive aggregate for one reaction type and zero for the other
+- **WHEN** WebChat renders both reaction controls
+- **THEN** only the positive reaction control SHALL be red, while the zero-count control SHALL remain gray, and each current-user toggle action SHALL remain independent
+
+### Requirement: Inline message images share one bounded presentation
+
+Every valid image rendered from either Markdown image syntax or an image-valued Markdown link SHALL use the same inline presentation. Its containing message content SHALL establish an inline-size query container. The image SHALL use that container's inline size as the sole basis for equal `70cqi` maximum inline and block sizes, while both used dimensions remain automatic so the complete source preserves its aspect ratio without cropping, distortion, forced expansion, or a square placeholder.
+
+Each message image SHALL own exactly one stable Blob URL for its current image lifecycle. The inline image and preview destination SHALL reuse that same URL. Rendering, rerendering, and preview activation SHALL NOT create another Blob URL. The same message-image lifecycle owner SHALL revoke the URL exactly once when that image leaves its lifecycle.
+
+The shared presentation SHALL preserve the sanitized rendered source and alternative text. It SHALL NOT introduce JavaScript measurement, `ResizeObserver`, measured-size state, a second sizing owner, a second URL parser, a raw-Markdown source path, a replacement fetch owner, or a different dimension policy between the two supported syntaxes.
+
+#### Scenario: Both image axes use the same message-container bound
+
+- **GIVEN** a valid message image whose natural inline size, block size, or both exceed `70%` of its containing message's inline size
+- **WHEN** the image renders inside the conversation
+- **THEN** neither rendered axis SHALL exceed `70cqi` from that message container, both used dimensions SHALL remain automatic, and the complete image SHALL remain visible at its source aspect ratio without a square placeholder
+
+#### Scenario: Both Markdown image forms are identical
+
+- **GIVEN** one image is written with Markdown image syntax and another valid image URL is written as an image-valued Markdown link
+- **WHEN** WebChat renders both messages
+- **THEN** both images SHALL use the same query-container owner, equal axis maximums, automatic dimensions, containment, activation, alternative-text, and sanitized-source behavior without runtime measurement state
+
+#### Scenario: Inline and preview images reuse one lifecycle URL
+
+- **GIVEN** one valid message image remains in its message lifecycle across rerenders and preview activation
+- **WHEN** WebChat renders its inline image and prepares its preview destination
+- **THEN** both image elements SHALL use the same stable Blob URL, no render or activation SHALL create another URL, and the lifecycle owner SHALL revoke that URL exactly once only when the image leaves its lifecycle
+
+### Requirement: One centered preview keeps its backdrop behind and body above the WebChat shell
+
+Activating a valid rendered message image by pointer, touch, Enter, or Space SHALL open exactly one centered preview within the existing WebChat application root. The preview SHALL fit the complete source image inside a rectangle that remains at least `24px` from every viewport edge. Its initial `1x` state SHALL mean this fitted baseline and SHALL NOT enlarge a source image beyond its natural dimensions.
+
+The WebChat application surface SHALL establish one stacking context above host-page content. The preview backdrop SHALL use a neutral dark color at `18%` opacity and remain below the WebChat shell, AppButton, and Danmaku. The preview body, image, and controls SHALL remain above those WebChat surfaces. The icon toolbar SHALL render below the preview image rather than above or over it. The shell SHALL remain visible and operable above the backdrop wherever the preview body does not cover it. The preview SHALL NOT create a second application root or a document-level portal.
+
+Only one preview SHALL exist. WebChat SHALL accept an image-open request only while no preview is opening or open. Every later image-open request while the accepted preview is opening or open SHALL be a no-op: it SHALL NOT replace the source or activator, restart the transition, or reset zoom, pan, or gesture state. Clicking the backdrop without a preceding preview drag, activating the close control, pressing Escape, or collapsing the WebChat shell SHALL close the preview. Closing SHALL restore keyboard focus to the activating image when that element still exists.
+
+#### Scenario: Preview opens centered within viewport margins
+
+- **WHEN** the user activates a valid rendered message image
+- **THEN** one preview SHALL show the complete image centered within `24px` of every viewport edge at fitted `1x`, without forcing a naturally smaller image larger
+
+#### Scenario: Backdrop stays behind the shell and preview body stays above it
+
+- **GIVEN** a message image preview is open
+- **WHEN** the preview and WebChat shell occupy overlapping viewport space
+- **THEN** the `18%` neutral backdrop SHALL remain below the shell, AppButton, and Danmaku; the preview body SHALL remain above them; and uncovered shell areas SHALL remain visible and operable
+
+#### Scenario: Toolbar stays below the preview image
+
+- **GIVEN** a message image preview is open
+- **WHEN** WebChat lays out the preview image and icon controls
+- **THEN** the toolbar SHALL appear below the preview image without overlaying it
+
+#### Scenario: Another image-open request is a no-op
+
+- **GIVEN** one message image is open and has a non-default zoom or pan
+- **WHEN** the user activates another rendered message image through an uncovered, operable shell area
+- **THEN** the existing preview SHALL keep its original source, activator, zoom, and pan without restarting a transition or creating another preview or backdrop
+
+#### Scenario: Repeated activation during opening is a no-op
+
+- **GIVEN** one accepted image-open request is waiting for its destination to become ready or its transition to settle
+- **WHEN** the user activates any rendered message image again
+- **THEN** the accepted opening SHALL continue unchanged without replacing its source or activator, restarting its transition, or resetting any preview state
+
+#### Scenario: Every close path clears and restores focus
+
+- **WHEN** the user clicks an undragged backdrop, activates the close control, presses Escape, or collapses the shell
+- **THEN** the preview SHALL close, clear its source and transform, and restore focus to its surviving activating image without changing message or host-page state
+
+### Requirement: Preview zoom and pan are bounded and input complete
+
+The preview SHALL expose familiar zoom-out, zoom-in, reset, and close icon controls with accessible names and tooltips. Zoom SHALL be clamped from `0.25x` through `4x` relative to the fitted `1x` opening baseline, allowing the user to reduce an image below both that baseline and its natural dimensions. Zoom-in and zoom-out controls and the `+` and `-` keys SHALL change zoom by `0.25x`; zoom-out SHALL remain available above `0.25x` and zoom-in SHALL remain available below `4x`. The `0` key and reset control SHALL restore fitted `1x` and zero pan.
+
+Wheel or trackpad input and a two-pointer pinch SHALL zoom around the current gesture focal point while sharing the same `0.25x` through `4x` bounds. On every axis where the scaled image fits the available preview rectangle, translation SHALL be zero so a reduced image remains centered and cannot be dragged away from that center. Pointer or single-touch drag SHALL pan only on axes where the scaled image exceeds the available rectangle. Pan SHALL remain bounded so an oversized image edge cannot be dragged past its corresponding `24px` viewport boundary and no blank gap can be revealed beyond that edge.
+
+Preview drag SHALL use pointer capture or equivalent local ownership so releasing a drag does not activate the backdrop close path. Closing the preview SHALL reset zoom, gesture, and pan state, and every later accepted opening SHALL begin at fitted `1x` with zero pan. Preview input handling MAY prevent the specific wheel, pointer, and touch events that it consumes, but SHALL NOT lock scrolling by modifying the host document or `body`.
+
+#### Scenario: Fixed controls reach the shared lower bound
+
+- **GIVEN** the preview is open at fitted `1x`
+- **WHEN** the user activates zoom-out three times through the icon or `-` key and then tries to zoom out again
+- **THEN** the image SHALL be centered at `0.25x` with zero pan, the next zoom-out SHALL have no effect, the zoom-out control SHALL be disabled at that bound, and one zoom-in step SHALL produce `0.5x`
+
+#### Scenario: Reset restores the fitted baseline
+
+- **GIVEN** the preview has non-default zoom and pan
+- **WHEN** the user activates reset or presses `0`
+- **THEN** the image SHALL return to centered fitted `1x` with zero pan
+
+#### Scenario: Focal zoom and pan stay bounded
+
+- **GIVEN** a preview image is zoomed with wheel, trackpad, or pinch and is larger than the available rectangle
+- **WHEN** the user continues zooming around the gesture point and drags in either direction
+- **THEN** the gesture point SHALL remain the zoom focus until clamped to `0.25x` through `4x`, pan SHALL apply only along overflowing axes, every fitting axis SHALL remain centered, and no image edge SHALL move past its matching `24px` viewport boundary to reveal an external blank gap
+
+#### Scenario: Drag release does not close the preview
+
+- **GIVEN** the user begins a pointer or touch drag on the zoomed image
+- **WHEN** the pointer is released over the backdrop
+- **THEN** the preview SHALL retain its bounded pan and SHALL NOT treat that release as a backdrop click
+
+#### Scenario: Host document remains unmodified
+
+- **WHEN** the preview consumes a zoom or pan gesture
+- **THEN** only that preview interaction SHALL be intercepted and WebChat SHALL NOT write scroll-lock, overflow, touch-action, or other preview state to the host document or `body`
+
+### Requirement: Preview motion uses one document View Transition
+
+When `document.startViewTransition` is available and the user has not requested reduced motion, the accepted opening and closing of a message image preview SHALL perform the state operation inside one document View Transition. The activating image and current preview image SHALL use one generation-scoped temporary shared identity. The browser-managed document-root snapshot and brief whole-page crossfade, including the shell, SHALL be part of that transition, while the preview transition remains above ordinary shell stacking. A pre-existing host-defined named participant MAY also participate under browser ownership.
+
+WebChat SHALL NOT assign a transition name or style to a host-page element, style the document transition pseudo-tree, mutate host-page business state, or create another preview state owner. The sole preview owner SHALL restore its active temporary identity when the operation settles or closes. A later image-open request SHALL NOT supersede that identity or operation. The accepted preview source or closed state SHALL settle exactly once without a duplicate preview or stale marker.
+
+When reduced motion is requested, the API is missing, the transition throws, rejects, or is skipped, or another document transition prevents execution, the same open or close state change SHALL complete immediately without animation. Motion SHALL NOT delay close cleanup or focus restoration. Final page layout, colors, layer order, and business state SHALL be identical on animated and immediate paths.
+
+#### Scenario: Supported motion includes the accepted root crossfade
+
+- **GIVEN** document View Transition is available and reduced motion is not requested
+- **WHEN** the user opens or closes a message image
+- **THEN** the activating image and current preview image SHALL share one temporary transition identity, the browser MAY crossfade the document root and shell or include a pre-existing host-defined named participant, the preview transition SHALL remain above ordinary shell stacking, and WebChat SHALL assign no host name or style and change no host business state
+
+#### Scenario: Immediate paths preserve the same final visual state
+
+- **GIVEN** reduced motion is requested or document View Transition is missing, throws, rejects, is skipped, or cannot run beside another document transition
+- **WHEN** the user opens or closes a message image
+- **THEN** the preview SHALL reach the same final open or closed state immediately with identical final layout, colors, and layer order and with no duplicate owner, stale identity, delayed cleanup, blocked focus restoration, or host business-state mutation
diff --git a/openspec/changes/highlight-likes-and-preview-message-images/tasks.md b/openspec/changes/highlight-likes-and-preview-message-images/tasks.md
new file mode 100644
index 00000000..64034c38
--- /dev/null
+++ b/openspec/changes/highlight-likes-and-preview-message-images/tasks.md
@@ -0,0 +1,36 @@
+## 1. Product Authority
+
+- [x] 1.1 Define symmetric reaction presentation: any positive like or hate aggregate makes only its matching icon and visible count red, while current-user membership remains that control's independent add-or-remove input.
+- [x] 1.2 Define one message-content inline-size query owner and equal `70cqi` maximum inline and block sizes for both rendered Markdown image forms, with automatic aspect-preserving dimensions and no runtime measurement state.
+- [x] 1.3 Define one centered `MediaPreview` owner in the existing App root, with an `18%` backdrop below the shell/AppButton/Danmaku, a preview body above them, a toolbar below the preview image, and a `24px` viewport fit margin.
+- [x] 1.4 Define a fitted `1x` opening baseline, user zoom from `0.25x` through `4x`, fixed `0.25x` controls, shared wheel/trackpad/pinch bounds, centered reduced images, bounded drag pan, and reset on close.
+- [x] 1.5 Define backdrop/control/Escape/shell-collapse close, focus restoration, repeated image-open no-op behavior while opening or open, document View Transition with accepted whole-page crossfade, and immediate reduced-motion/failure settlement.
+- [x] 1.6 Exclude image replacement while opening or open, reaction projection/count/add-or-remove changes, protocol, persistence, host-scroll, upload, download, carousel, speculative media, second-root, second-owner, runtime image measurement, host-element transition naming/styling, host business-state mutation, and experimental transition-API changes.
+- [x] 1.7 Define exactly one lifecycle-owned Blob URL per message image, reused by the inline and preview images and revoked by that same owner when the image leaves its lifecycle.
+
+## 2. Regression Coverage
+
+- [x] 2.1 Cover symmetric like/hate positive-aggregate red, zero-aggregate gray, type-local independence, current-user add/remove semantics, and removal while another matching reaction remains.
+- [x] 2.2 Cover both Markdown image forms through one shared renderer with the same message-content query owner, one stable Blob URL reused by inline and preview images, one-time lifecycle revocation, equal `70cqi` maximums, automatic dimensions, complete containment, and sanitized source and alternative text.
+- [x] 2.3 Add structural controls excluding per-render or per-activation Blob URL creation, JavaScript measurement, `ResizeObserver`, measured-size state, square placeholders, duplicate image policies, and a second sizing or URL-lifecycle owner.
+- [x] 2.4 Cover preview ancestry, one-owner composition, centered `24px` fit, natural-size non-upscale, the real host/backdrop/shell/preview-body order, toolbar placement below the preview image, neutral `18%` backdrop, preview-body overlap, and uncovered shell operability.
+- [x] 2.5 Cover production-boundary pointer, touch, keyboard, wheel/trackpad, and pinch activation; editable-shell isolation; icon names and tooltips; fixed `0.25x` control steps; the shared `0.25x` through `4x` bounds; centered, non-pannable reduced images; focal zoom; axis-specific pan bounds; resize reclamping; drag-release suppression; and reset to fitted `1x`.
+- [x] 2.6 Cover backdrop, close-control, production-boundary Escape, and shell-collapse settlement; repeated image-open no-op behavior while opening or open without source, activator, transition, or transform changes; transform cleanup; focus restoration; and preview-local event handling without host scroll lock.
+- [x] 2.7 Cover document View Transition, accepted root/shell crossfade and pre-existing host-named participants, temporary image identity, reduced-motion and every unavailable/failure path, identical final visuals, and host name/style/business-state preservation without duplicate state or stale markers.
+
+## 3. Minimum Implementation
+
+- [x] 3.1 Derive each reaction control's red emphasis directly from its own positive aggregate while preserving matching current-user membership as the independent pressed/toggle truth.
+- [x] 3.2 Consolidate both Markdown image paths into one accessible image control that owns one stable Blob URL per image lifecycle, reuses it for inline and preview images, revokes it exactly once on lifecycle exit, and establishes the message-content inline-size query container with equal `70cqi` maximums on both image axes.
+- [x] 3.3 Keep image dimensions automatic and contained without JavaScript measurement, `ResizeObserver`, measured-size state, crop, distortion, forced expansion, or a square placeholder.
+- [x] 3.4 Add one `MediaPreview` component/state owner beside Danmaku in the existing App root, exposing only the preview-open action to rendered message images.
+- [x] 3.5 Implement the confirmed extension-owned stacking context and split backdrop/shell/preview-body order, toolbar below the preview image, fit, close, synchronous open admission with later requests as no-ops, focus, preview-subtree keyboard ownership, shared `0.25x` through `4x` zoom bounds, centered reduced images, focal transform, pre-paint bounded pan, gesture cleanup, and shell-collapse behavior without a portal, Domain, Extern, persistence, dependency, or host business-state mutation.
+- [x] 3.6 Use one document View Transition and one generation-scoped temporary image-identity owner; restore identity on settlement or close, accept the browser root crossfade, and preserve direct reduced-motion/unavailable/failure settlement without host-element styling, another state owner, or delayed cleanup.
+
+## 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.
+- [ ] 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 `feat/like-color-and-image-preview` branch and one Draft PR, then 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 Publish the pushed final branch and exact in the parent channel, update final OpenSpec/task truth, and keep Ready/merge conditional on the closeout exact's identity and CI under the Owner's same-PR merge authorization.
diff --git a/src/app/content/App.render.test.tsx b/src/app/content/App.render.test.tsx
index 8b041c18..3c065387 100644
--- a/src/app/content/App.render.test.tsx
+++ b/src/app/content/App.render.test.tsx
@@ -27,7 +27,8 @@ vi.mock('remesh-react', () => ({
vi.mock('@/domain/AppStatus', () => ({
default: () => ({
query: {
- ReadyQuery: () => 'initialization-ready'
+ ReadyQuery: () => 'initialization-ready',
+ OpenQuery: () => 'app-open'
},
command: {
UpdateOpenCommand: (open: boolean) => `update-open-${open}`
diff --git a/src/app/content/App.test.ts b/src/app/content/App.test.ts
index 9e955d28..a06aa9da 100644
--- a/src/app/content/App.test.ts
+++ b/src/app/content/App.test.ts
@@ -35,8 +35,11 @@ describe('content component hierarchy', () => {
'',
- ' {
const send = useRemeshSend()
const appStatusDomain = useRemeshDomain(AppStatusDomain())
const initializationReady = useRemeshQuery(appStatusDomain.query.ReadyQuery())
+ const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
const worldRoomDomain = useRemeshDomain(WorldRoomDomain())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
@@ -37,6 +43,8 @@ const App = () => {
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const danmakuIsEnabled = userInfo?.danmakuEnabled ?? false
const danmakuContainerRef = useRef(null)
+ const mediaPreviewRef = useRef(null)
+ const openMediaPreview = useCallback((request: MediaPreviewRequest) => mediaPreviewRef.current?.open(request), [])
useEffect(() => {
if (initializationReady && messageListLoadFinished && userInfoSetFinished) {
@@ -74,25 +82,28 @@ const App = () => {
return (