diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml
index 4015920581..dad8343dcf 100644
--- a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml
@@ -18,8 +18,8 @@
en: 5214241925b52bc3
zh: bbd66dc72c03f960
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/release-and-spring:
- en: 5eb4f6189d6e18d7
- zh: c0bee7eb1e593d64
+ en: ba2b5a3b4923d585
+ zh: 99544462a464ac50
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/reduced-motion:
en: 7e29bf1c45b5386d
zh: c913a2ce6d1722d1
@@ -27,8 +27,8 @@
en: 6459103379aefe41
zh: 501dee08a977b6a0
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/testing:
- en: 5b69c8ef05b8600c
- zh: a8e5adb0cc20208c
+ en: da3b2b6781aa99f3
+ zh: 37e49fa22f72fb84
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/alternatives-considered:
en: 3d901f0f5e270e29
zh: c1fb89b567ea3c70
diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md
index d70517ac89..a4e9ca4e25 100644
--- a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md
+++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md
@@ -51,7 +51,7 @@ x = x + v * h
`stepSpring` integrates in equal substeps of at most `h = 1/480 s`, so the motion does not depend on the frame rate. One `requestAnimationFrame` loop in the hook clamps a frame to 64 ms, ends at the target when a frame gap exceeds 250 ms or 900 ms have passed, and otherwise stops when each axis is within 1 px of the target and slower than 60 px/s. From rest, a 200 px overflow settles in 367 ms with an inward overshoot within one percentage point of the analytic bound `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%`. The integrator is pure and takes `dt`, so the engine keeps its rule of reading no clock.
-A press on the grip or the corner during a return cancels it. A grip press picks the panel up where it is drawn: `unresistedRect` inverts the resistance, so zero pointer travel draws the same rectangle, and a release in place resumes the return without recording a second operation. A corner press ends the return at its recorded rectangle and resizes from there, because a resize starts from a legal origin. One hook runs at most one return. A window resize ends a running return at its recorded rectangle, a press on another panel ends it at its target, a change of the panel's record that does not project to the target (an undo, a dock, a close) ends it at the new record on the next frame, and unmounting cancels the pending frame.
+A press on the grip or the corner during a return cancels it and picks the panel up where it is drawn. A move does so through `unresistedRect`: it inverts the resistance, so zero pointer travel draws the same rectangle. A resize keeps the drawn origin and caps only the corner by the viewport; its release records the legal rectangle and springs the origin inside, as does a resize whose viewport shrank past its origin. Either way a release in place resumes the return without recording a second operation. One hook runs at most one return. A window resize ends a running return at its recorded rectangle, a press on another panel ends it at its target, a change of the panel's record that does not project to the target (an undo, a dock, a close) ends it at the new record on the next frame, and unmounting cancels the pending frame.
### Reduced motion
@@ -79,7 +79,7 @@ The change touches one package, `@deepseek-ai/dsh-client-ui-dockkit`, and leaves
`tests/float-bounds.client.spec.ts` covers the projection (inside, each edge, corner, the top floor, an oversized panel), the resistance (the table values, slope 1 at the edge, monotone growth bounded by `K`, independent axes), its inverse and its cap, the bounded resize, and the legal size kept by the resistance and its inverse. `tests/spring.client.spec.ts` covers settling inside the deadline, the overshoot bound, frame-rate independence at 30, 60 and 120 Hz, the effect of release velocity, determinism, and the launch cap keeping every return within `K`. `tests/app-region-styles.client.spec.ts` pins the resting clamp in the stylesheet.
-`tests/float-recovery.client.spec.tsx` renders `FloatLayer` over a live controller with hand-driven frames and clock: the resisted preview past an edge, exactly one legal `moveFloat` per release and the spring to it with an inward overshoot, undo in one step, fling against slow push, a fast fling staying within `K`, a drag above the top floor, no animation for a release inside, a push that ends at the stored position, a press mid-return picking the panel up without a jump, the stall snap, the deadline, a body not re-rendered through a drag and its return but re-rendered for a new renderer, a corner press during a return ending it at its target, a resize origin kept inside a viewport that shrank mid-gesture, the 20 px floor on a Windows root without a published caption height, a return yielding to an undo, a close and a press on another panel, unmount cancellation, both reduced-motion answers, the viewport projection on shrink and growth without any intent, a click on a projected panel recording nothing, a window resize during a gesture bounding its release, a resize ending a return, and an off-screen stored rectangle drawn inside. `tests/tab-layout.client.spec.tsx` adds that a float drag re-renders no docked or floating body until the renderer changes, and `components.client.spec.tsx` the same for a `DockSurface` divider drag. The existing `FloatLayer` and `TabLayout` specs still pass with their position assertions read from the resting variables, including the rule that starting a gesture forces no layout read; every changed source file is fully covered. `apps/web/tests/sidebar-right.e2e.ts` drags a Sidebar float past the right and bottom edges in Chromium, checks that the drawn panel stays within 120 px beyond them, waits for the release to settle flush against both edges, then publishes a resting rectangle far outside and larger than the viewport and checks that the stylesheet alone draws it exactly over the viewport.
+`tests/float-recovery.client.spec.tsx` renders `FloatLayer` over a live controller with hand-driven frames and clock: the resisted preview past an edge, exactly one legal `moveFloat` per release and the spring to it with an inward overshoot, undo in one step, fling against slow push, a fast fling staying within `K`, a drag above the top floor, no animation for a release inside, a push that ends at the stored position, a press mid-return picking the panel up without a jump, the stall snap, the deadline, a body not re-rendered through a drag and its return but re-rendered for a new renderer, a corner press during a return picking the panel up where it is drawn and a resize from there springing in, a resize whose viewport shrank past its origin recording a legal rectangle, with no animation under reduced motion, the 20 px floor on a Windows root without a published caption height, a return yielding to an undo, a close and a press on another panel, unmount cancellation, both reduced-motion answers, the viewport projection on shrink and growth without any intent, a click on a projected panel recording nothing, a window resize during a gesture bounding its release, a resize ending a return, and an off-screen stored rectangle drawn inside. `tests/tab-layout.client.spec.tsx` adds that a float drag re-renders no docked or floating body until the renderer changes, and `components.client.spec.tsx` the same for a `DockSurface` divider drag. The existing `FloatLayer` and `TabLayout` specs still pass with their position assertions read from the resting variables, including the rule that starting a gesture forces no layout read; every changed source file is fully covered. `apps/web/tests/sidebar-right.e2e.ts` drags a Sidebar float past the right and bottom edges in Chromium, checks that the drawn panel stays within 120 px beyond them, waits for the release to settle flush against both edges, then publishes a resting rectangle far outside and larger than the viewport and checks that the stylesheet alone draws it exactly over the viewport.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md
index d0e5aa5a27..6ec92d9326 100644
--- a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md
+++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md
@@ -51,7 +51,7 @@ x = x + v * h
`stepSpring` 以等长且不超过 `h = 1/480 s` 的子步积分,因此运动与帧率无关。hook 中唯一的 `requestAnimationFrame` 循环把单帧钳制到 64 ms,帧间隔超过 250 ms 或已过 900 ms 时直接停在目标,否则在每轴距目标 1 px 内且慢于 60 px/s 时停止。从静止开始,200 px 溢出在 367 ms 内稳定,向内过冲与解析上限 `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%` 相差不超过一个百分点。积分器是纯函数并接收 `dt`,因此 engine 保持不读取时钟的规则。
-回弹期间按下抓手或右下角会取消回弹。按下抓手从绘制位置接住面板:`unresistedRect` 反演阻力,因此零指针位移绘制同一矩形,原地松手会恢复回弹且不记录第二次操作。按下右下角会让回弹停在记录的矩形并从那里开始缩放,因为缩放从合法原点开始。一个 hook 最多运行一个回弹。窗口缩放会让进行中的回弹停在记录的矩形;按下另一个面板会让它停在目标;面板记录的变化若不再投影到目标(撤销、停靠、关闭),会在下一帧让它停在新记录;卸载会取消待处理的帧。
+回弹期间按下抓手或右下角会取消回弹,并从绘制位置接住面板。移动通过 `unresistedRect` 做到这一点:它反演阻力,因此零指针位移绘制同一矩形。缩放保留绘制时的原点,只用视口约束右下角;松手时记录合法矩形并用弹簧把原点收回视口,手势中视口缩到原点以内的缩放也是如此。两种情况下原地松手都会恢复回弹且不记录第二次操作。一个 hook 最多运行一个回弹。窗口缩放会让进行中的回弹停在记录的矩形;按下另一个面板会让它停在目标;面板记录的变化若不再投影到目标(撤销、停靠、关闭),会在下一帧让它停在新记录;卸载会取消待处理的帧。
### 减弱动态效果
@@ -79,7 +79,7 @@ x = x + v * h
`tests/float-bounds.client.spec.ts` 覆盖投影(内部、每条边、角、顶部下限、超尺寸面板)、阻力(表中数值、边界处斜率为 1、以 `K` 为上界的单调增长、各轴独立)、其反演与上限,受限缩放,以及阻力与其反演都保持合法尺寸。`tests/spring.client.spec.ts` 覆盖在截止时间内稳定、过冲上限、30/60/120 Hz 下与帧率无关、松手速度的作用、确定性,以及启动速度上限让每条回弹都不超出 `K`。`tests/app-region-styles.client.spec.ts` 固定样式表中的静止钳制。
-`tests/float-recovery.client.spec.tsx` 在活的控制器上渲染 `FloatLayer`,帧与时钟由测试手动推进:越过边界时带阻力的预览、每次松手恰好一次合法的 `moveFloat` 以及带向内过冲的弹回、一步撤销、甩出与慢推的对比、快速甩出不超出 `K`、拖过顶部下限、区域内松手不播放动画、推到存储位置的手势、回弹中按下接住面板且不跳变、停顿直接跳转、截止时间、拖拽与回弹期间不重渲染标签页内容而换新渲染函数后重渲染、回弹中按下右下角让回弹停在目标、窗口在手势中缩到缩放原点以内时原点仍留在视口内、未发布标题栏高度的 Windows 根元素仍保持 20 px 下限、回弹让位于撤销、关闭与按下另一个面板、卸载取消、两种减弱动态效果答复、视口缩小与变大时的投影且不调用任何 intent、单击投影面板不记录任何操作、手势中窗口缩放约束其松手结果、缩放结束回弹,以及屏幕外存储矩形在视口内绘制。`tests/tab-layout.client.spec.tsx` 新增:悬浮面板拖拽期间停靠与悬浮的标签页内容都不重渲染,直到渲染函数改变;`components.client.spec.tsx` 对 `DockSurface` 拖动分隔条做同样的检查。既有的 `FloatLayer` 与 `TabLayout` 测试在位置断言改为读取静止变量后通过,包括开始手势不做布局读取这一规则;每个改动的源文件都被完全覆盖。`apps/web/tests/sidebar-right.e2e.ts` 在 Chromium 中把 Sidebar 悬浮面板拖过右边界和下边界,检查绘制的面板越过两边均不超过 120 px,等待松手后面板贴合两条边静止,再发布一个远在视口外且大于视口的静止矩形,检查仅靠样式表就把它恰好绘制成覆盖整个视口。
+`tests/float-recovery.client.spec.tsx` 在活的控制器上渲染 `FloatLayer`,帧与时钟由测试手动推进:越过边界时带阻力的预览、每次松手恰好一次合法的 `moveFloat` 以及带向内过冲的弹回、一步撤销、甩出与慢推的对比、快速甩出不超出 `K`、拖过顶部下限、区域内松手不播放动画、推到存储位置的手势、回弹中按下接住面板且不跳变、停顿直接跳转、截止时间、拖拽与回弹期间不重渲染标签页内容而换新渲染函数后重渲染、回弹中按下右下角从绘制位置接住面板、从那里缩放后弹回、手势中视口缩到缩放原点以内时记录合法矩形且减弱动态效果下不播放动画、未发布标题栏高度的 Windows 根元素仍保持 20 px 下限、回弹让位于撤销、关闭与按下另一个面板、卸载取消、两种减弱动态效果答复、视口缩小与变大时的投影且不调用任何 intent、单击投影面板不记录任何操作、手势中窗口缩放约束其松手结果、缩放结束回弹,以及屏幕外存储矩形在视口内绘制。`tests/tab-layout.client.spec.tsx` 新增:悬浮面板拖拽期间停靠与悬浮的标签页内容都不重渲染,直到渲染函数改变;`components.client.spec.tsx` 对 `DockSurface` 拖动分隔条做同样的检查。既有的 `FloatLayer` 与 `TabLayout` 测试在位置断言改为读取静止变量后通过,包括开始手势不做布局读取这一规则;每个改动的源文件都被完全覆盖。`apps/web/tests/sidebar-right.e2e.ts` 在 Chromium 中把 Sidebar 悬浮面板拖过右边界和下边界,检查绘制的面板越过两边均不超过 120 px,等待松手后面板贴合两条边静止,再发布一个远在视口外且大于视口的静止矩形,检查仅靠样式表就把它恰好绘制成覆盖整个视口。
## 考虑过的替代方案
diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml
index 6eb9ff7eb3..6bff57dde1 100644
--- a/packages/client/ui-dockkit/README.i18n.yaml
+++ b/packages/client/ui-dockkit/README.i18n.yaml
@@ -18,8 +18,8 @@
en: 718f52cfce426009
zh: e8282e2e3713d242
/deepseek-ai-dsh-client-ui-dockkit/embedding-it:
- en: 9c616cddd30d4e31
- zh: 40153aee97753874
+ en: 87fab28e0608044e
+ zh: 2d5f80a367f1852b
/deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping:
en: cf2d00ce2b7d5719
zh: 5510420f8872e23a
diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md
index 1bdd9b30b6..12f5b74938 100644
--- a/packages/client/ui-dockkit/README.md
+++ b/packages/client/ui-dockkit/README.md
@@ -66,7 +66,7 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp
`DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. At rest both float renderers keep the header at least 20px below the Windows caption (20px below the viewport top in native fullscreen), including newly opened and restored panels. Dragging and resizing start at the displayed position.
-Floating panels stay inside the viewport. Each draws at its stored rectangle projected into the viewport, so a shrinking window pushes it in without recording an operation and a growing window returns it toward the stored position; a panel larger than the viewport draws at the viewport's size. A move follows the pointer one to one inside the viewport and resists past any edge, the top included, drawing at most 120px beyond it; its release records one `moveFloat` with the nearest legal rectangle, or only a raise when that is the legal rectangle of the press, then springs the panel there from where it was drawn, carrying the drawn velocity, staying within those 120px, and overshooting slightly inward. A press on the header during that return picks the panel up where it is drawn; a press on the resize corner ends the return at the recorded rectangle. A resize keeps the corner inside the viewport. At rest the stylesheet clamps every panel's position and size into the viewport once more, so a stale viewport reading still leaves it inside. Under `prefers-reduced-motion: reduce` a move clamps at the edge and nothing animates.
+Floating panels stay inside the viewport. Each draws at its stored rectangle projected into the viewport, so a shrinking window pushes it in without recording an operation and a growing window returns it toward the stored position; a panel larger than the viewport draws at the viewport's size. A move follows the pointer one to one inside the viewport and resists past any edge, the top included, drawing at most 120px beyond it; its release records one `moveFloat` with the nearest legal rectangle, or only a raise when that is the legal rectangle of the press, then springs the panel there from where it was drawn, carrying the drawn velocity, staying within those 120px, and overshooting slightly inward. A press on the header or the resize corner during that return picks the panel up where it is drawn; a resize from there keeps its origin and springs it inside on release. A resize keeps the corner inside the viewport. At rest the stylesheet clamps every panel's position and size into the viewport once more, so a stale viewport reading still leaves it inside. Under `prefers-reduced-motion: reduce` a move clamps at the edge and nothing animates.
## Interaction rules worth keeping
diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md
index f3c1cb24eb..06b1a593f3 100644
--- a/packages/client/ui-dockkit/README.zh.md
+++ b/packages/client/ui-dockkit/README.zh.md
@@ -66,7 +66,7 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间
`DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 静止时两种浮窗渲染器都让标题栏与 Windows 顶栏至少间隔 20px(原生全屏时距视口顶部 20px),新建和恢复的浮窗均适用。拖动和调整大小从实际显示位置开始。
-浮窗始终留在视口内。每个浮窗绘制在其存储矩形投影到视口后的位置,因此窗口缩小时浮窗被推入且不记录操作,窗口变大时浮窗朝存储位置回移;大于视口的浮窗按视口尺寸绘制。移动时浮窗在视口内一比一跟随指针,越过任一边界(包括顶部)后受到阻力,最多越过边界 120px;松手时记录一次带最近合法矩形的 `moveFloat`,若该矩形就是按下时的合法矩形则只提升浮窗,再让浮窗从绘制位置以弹簧回到该矩形,携带绘制速度、不超出这 120px,并略微向内过冲。回弹过程中按下标题栏会从绘制位置接住浮窗;按下缩放角会让回弹停在记录的矩形。调整大小时右下角保持在视口内。静止时样式表会再次把每个浮窗的位置和尺寸钳制在视口内,因此即使视口读数过期,浮窗仍在视口内。在 `prefers-reduced-motion: reduce` 下,移动在边界处钳制且不播放动画。
+浮窗始终留在视口内。每个浮窗绘制在其存储矩形投影到视口后的位置,因此窗口缩小时浮窗被推入且不记录操作,窗口变大时浮窗朝存储位置回移;大于视口的浮窗按视口尺寸绘制。移动时浮窗在视口内一比一跟随指针,越过任一边界(包括顶部)后受到阻力,最多越过边界 120px;松手时记录一次带最近合法矩形的 `moveFloat`,若该矩形就是按下时的合法矩形则只提升浮窗,再让浮窗从绘制位置以弹簧回到该矩形,携带绘制速度、不超出这 120px,并略微向内过冲。回弹过程中按下标题栏或缩放角都会从绘制位置接住浮窗;从那里缩放时保留原点,松手后再以弹簧收回视口。调整大小时右下角保持在视口内。静止时样式表会再次把每个浮窗的位置和尺寸钳制在视口内,因此即使视口读数过期,浮窗仍在视口内。在 `prefers-reduced-motion: reduce` 下,移动在边界处钳制且不播放动画。
## 值得保留的交互规则
diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx
index 6910afd431..acbce6e88a 100644
--- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx
+++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx
@@ -260,23 +260,25 @@ export function useFloatGestures(state: LayoutState, intents: DockIntents) {
// The press stops here: the panel's own press-to-focus would record a focus
// entry before the drag's, and the release below decides which one it is.
event.stopPropagation()
- // A grip press during its own return takes the panel from where it is
- // drawn; a corner press, or a press on another panel, ends the return at
- // its target.
- const drawn = mode === 'move' && settling.current?.paneId === paneId ? preview?.rect : undefined
+ // A press during its own return takes the panel from where it is drawn;
+ // another panel's return ends at its target.
+ const drawn = settling.current?.paneId === paneId ? preview?.rect : undefined
if (cancelSettling() && drawn === undefined) setPreview(undefined)
area.current = floatBounds()
const reduce = reducedMotion()
const shown = drawn ?? legalFloatRect(floatRect(getPane(state, paneId)), area.current)
- const rect = mode === 'move' && !reduce ? unresistedRect(shown, area.current) : legalFloatRect(shown, area.current)
+ const rect = mode === 'move' && !reduce ? unresistedRect(shown, area.current) : shown
const start: FloatDrag = { mode, originX: event.clientX, originY: event.clientY, rect }
/** The rectangle to draw for a pointer position, and the legal one a release there records. */
const reached = (x: number, y: number): { readonly drawn: FloatRect; readonly legal: FloatRect } => {
const desired = draggedRect(start, x, y)
if (mode === 'resize') {
- // The origin may lie outside a viewport that shrank mid-gesture.
- const resized = legalFloatRect(boundedResize(desired, area.current, FLOAT_MIN_SIZE), area.current)
- return { drawn: resized, legal: resized }
+ // A resize keeps its origin, which lies outside the viewport when the
+ // press caught a return or the viewport shrank mid-gesture; the
+ // release records the legal rectangle and springs the panel there.
+ const resized = boundedResize(desired, area.current, FLOAT_MIN_SIZE)
+ const legal = legalFloatRect(resized, area.current)
+ return { drawn: reduce ? legal : resized, legal }
}
const legal = legalFloatRect(desired, area.current)
return { drawn: reduce ? legal : resistedRect(desired, area.current), legal }
diff --git a/packages/client/ui-dockkit/src/engine/geometry.ts b/packages/client/ui-dockkit/src/engine/geometry.ts
index d7f11f1ca5..be6b7cbc75 100644
--- a/packages/client/ui-dockkit/src/engine/geometry.ts
+++ b/packages/client/ui-dockkit/src/engine/geometry.ts
@@ -289,7 +289,7 @@ export function unresistedRect(drawn: FloatRect, bounds: FloatBounds): FloatRect
/**
* A resized rectangle whose bottom-right corner stays inside the bounds, never
* below the minimum size.
- * @param rect - the resized rectangle, its origin legal.
+ * @param rect - the resized rectangle; its origin may lie outside the bounds.
* @param bounds - the viewport region.
* @param min - smallest size the panel may take.
* @returns the rectangle with its size capped by the bounds.
diff --git a/packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx b/packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx
index 7222c13e97..f7cb177b85 100644
--- a/packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx
+++ b/packages/client/ui-dockkit/tests/float-recovery.client.spec.tsx
@@ -267,19 +267,38 @@ describe('interrupting a return', () => {
expect(h.left()).toBe(100)
})
- it('ends at its target when the resize corner is pressed, and resizes from there', () => {
+ it('picks the panel up where it is drawn on a corner press, and a release in place resumes the return', () => {
const h = mounted({ x: 0, y: 80, width: 300, height: 200 })
drag(h.grip(), [150, 90], [150 - 400, 90])
frame()
- expect(h.left()).toBeLessThan(0)
+ const drawn = h.left()
+ expect(drawn).toBeLessThan(0)
fireEvent.pointerDown(h.corner(), { clientX: 250, clientY: 275, pointerId: 8, button: 0 })
expect(queued.size).toBe(0)
- expect(h.left()).toBe(0)
+ expect(h.left()).toBe(drawn)
fireEvent.pointerMove(window, { pointerId: 8, clientX: 250, clientY: 275 })
- expect(h.left()).toBe(0)
+ expect(h.left()).toBe(drawn)
fireEvent.pointerUp(window, { pointerId: 8, clientX: 250, clientY: 275 })
- expect(h.left()).toBe(0)
+ expect(h.left()).toBe(drawn)
expect(h.resizeFloat).not.toHaveBeenCalled()
+ runOut()
+ expect(h.left()).toBe(0)
+ })
+
+ it('resizes a panel caught during a return from where it is drawn, then springs it inside', () => {
+ const h = mounted({ x: 0, y: 80, width: 300, height: 200 })
+ drag(h.grip(), [150, 90], [150 - 400, 90])
+ frame()
+ const drawn = h.left()
+ fireEvent.pointerDown(h.corner(), { clientX: 250, clientY: 275, pointerId: 8, button: 0 })
+ fireEvent.pointerMove(window, { pointerId: 8, clientX: 270, clientY: 295 })
+ // The origin stays where it was drawn; only the size follows the corner.
+ expect(h.left()).toBe(drawn)
+ fireEvent.pointerUp(window, { pointerId: 8, clientX: 270, clientY: 295 })
+ expect(h.resizeFloat).toHaveBeenCalledWith(h.paneId, { x: 0, y: 80, width: 320, height: 220 })
+ expect(h.left()).toBe(drawn)
+ runOut()
+ expect(h.left()).toBe(0)
})
it('yields when its panel closes', () => {
@@ -330,6 +349,19 @@ describe('reduced motion', () => {
expect(queued.size).toBe(0)
})
+ it('draws a resize at its legal rectangle, so a viewport that shrank past its origin animates nothing', () => {
+ vi.stubGlobal('matchMedia', (query: string) => ({ matches: query === '(prefers-reduced-motion: reduce)' }))
+ const h = mounted({ x: 700, y: 80, width: 300, height: 200 })
+ fireEvent.pointerDown(h.corner(), { clientX: 1000, clientY: 280, pointerId: 7, button: 0 })
+ viewport(500, 768)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ fireEvent.pointerMove(window, { pointerId: 7, clientX: 990, clientY: 280 })
+ expect(h.left()).toBe(280)
+ fireEvent.pointerUp(window, { pointerId: 7, clientX: 990, clientY: 280 })
+ expect(h.resizeFloat).toHaveBeenCalledWith(h.paneId, { x: 280, y: 80, width: 220, height: 200 })
+ expect(queued.size).toBe(0)
+ })
+
it('keeps the resistance when the platform states no preference', () => {
vi.stubGlobal('matchMedia', () => ({ matches: false }))
const h = mounted()