| doc-id | 21-floating-workbench-island | |||||
|---|---|---|---|---|---|---|
| title | 浮动工作台与灵动岛交互方案 | |||||
| status | active | |||||
| version | 0.2.0 | |||||
| last-updated | 2026-08-29 | |||||
| implementation-status | implemented; browser-visual-validation-pending | |||||
| source-range | Runtime 浮动入口、可拖拽工作台、视口定位、会话位置恢复、动效、响应式、无障碍与专项验收 | |||||
| 参考文献/依赖 |
|
本图按实现职责重绘,不是实际运行截图,也不更新本页 implementation-status 中的待验收项。README 的 GIF 是预设演示状态,不能代替真实事件驱动、拖拽、触摸、缩放或性能验收。英文配图在同目录 en-US 下;素材来源见 事实核对记录。
本文定义 SpotPatch Runtime 浮动入口和上下文工作台的下一阶段交互。已经确认的产品方向是:入口默认位于右下角,入口和展开后的工作台均可由用户拖拽改变位置;选中元素后,工作台保持用户位置,不再以选中元素为定位锚点。页面元素只保留候选高亮和持久编号高亮。
截至 2026-08-29,本文的 Runtime 实现已落地:floating-surface-position.ts 提供纯位置计算,floating-surface-controller.ts 管理 Pointer Events、边缘吸附、视口协调和释放,floating-surface-session.ts 负责按 sessionId 隔离的恢复;runtime-view.ts 已移除目标锚点和旧 placement 算法。专项单元、类型和 lint 校验已覆盖并通过。
这不替代浏览器视觉验收:本次环境未提供可用的受控浏览器,因此真实 Chromium 下的拖拽、窄视口、缩放、触摸与截图验收仍是发布前 required Gate,不能因此被描述为已通过。
通用品牌、目标编辑、Agent、双语、诊断和无障碍规则仍由 (见 doc-id:10-ui-diagnostics) 负责;本文只拥有浮动位置、拖拽、展开方向和对应动效的规范事实,避免两个文档重复维护同一算法。
当前 Runtime 的相关实现分布在 packages/runtime/src/ui/runtime-view.ts、packages/runtime/src/ui/floating-surface-{position,controller}.ts 和 packages/runtime/src/state/floating-surface-session.ts:
- Trigger 和工作台共享归一化锚点;默认锚点解析为右下安全区域,用户拖拽后按 session 恢复;
- 工作台只读取自身尺寸和安全视口,
showHighlight()与showSelectionHighlights()不再参与面板定位; - 位置控制器使用 Pointer Events、pointer capture、单帧布局协调、边缘吸附和取消恢复;
- 旧
dialog-placement.ts、视觉箭头、placement dataset 和目标矩形定位状态已删除; - Runtime 现有业务状态只有
idle | inspecting | selected | previewing,足以驱动入口和工作台展示,不需要为视觉动画复制一套业务状态机; - 当前实现常量为最大宽度
460px、最大高度620px,旧版 UI 规范中的560px × 720px与代码存在漂移。
- 工作台已经承载目标说明、数据链路、Agent、诊断和底部操作,不再是适合依附单个元素的小型气泡;活动目标变化会造成主操作区位置跳动。
- 滚动期间,目标高亮更新与工作台尺寸测量耦合,存在不必要的同步布局读取。
- 多目标分散在页面时,“活动目标决定工作台位置”会让用户已经建立的空间记忆失效。
- 真实 Chromium 的视觉、触摸、缩放和窄视口验收仍未在本次受控环境完成,不能把单元测试替代为视觉证据。
- 首次加载时,灵动岛入口稳定出现在右下角安全区域。
- 用户可以拖拽灵动岛,也可以通过工作台标题栏拖拽展开后的工作台。
- 点击与拖拽有确定边界;拖动不能误触发选择,点击不能产生位置漂移。
- 工作台从当前浮动位置向可用空间展开,始终被限制在可见视口内。
- 选择、滚动和目标尺寸变化只更新高亮,不改变浮动工作台位置。
- 当前开发 Session 内刷新页面后恢复位置;无效或不可用存储安全退回默认位置。
- 桌面、窄视口、触摸输入、浏览器缩放和动态视口均有确定降级。
- 动效使用现有 Runtime 状态投影,不引入第三方动画依赖或并行业务状态。
- 删除被替代的目标定位算法、视觉锚点、测试和无意义回调,不保留兼容死分支。
- 不让 SpotPatch 修改宿主页面布局、为业务页面增加右侧 padding,或推动业务内容避让工作台;
- 不在首版增加可配置公共 API、拖拽网格、多个浮窗、面板尺寸调整或跨设备同步;
- 不使用 HTML Drag and Drop;它不适合同时承担按钮点击、触摸拖动和表单交互;
- 不把用户位置写入项目文件、服务端配置、URL、Cookie 或长期
localStorage; - 不复制 Apple Dynamic Island 的外观和行为;“灵动岛”只表示紧凑胶囊入口、状态反馈和连续展开体验;
- 不因动画尚未完成而阻塞核心选择、说明编辑、Preview 或 Agent 操作。
| Runtime 状态 | 浮动表面 | 页面高亮 | 主要行为 |
|---|---|---|---|
idle 且无保留目标 |
紧凑灵动岛,显示“选择元素” | 无 | 点击进入选择;拖动改变位置 |
idle 且有保留目标 |
紧凑灵动岛,显示可恢复语义 | 按现有导航规则决定是否可见 | 点击恢复工作台;拖动改变位置 |
inspecting |
灵动岛显示明确的选择中状态 | hover 候选高亮;已有目标高亮保留 | 点击页面候选;Escape 取消;仍可拖动入口 |
selected |
当前锚点处展开工作台 | 全部目标编号高亮 | 编辑、追加、发送、预览;标题栏可拖动 |
previewing |
工作台位置与尺寸边界不重置,只切换内容 | 保留目标高亮 | 复制或返回;标题栏仍可拖动 |
浮动表面只消费 Runtime 状态和现有目标数量,不拥有选择、Preview 或 Agent 的领域转换。动画结束事件不得触发业务状态变化。
- 灵动岛:胶囊主体是点击与拖拽共用区域;键盘激活仍按标准按钮语义执行点击。
- 工作台:只允许标题栏中的非交互空白区域或显式拖拽手柄开始拖动。
- GitHub、语言切换、关闭按钮、输入框、选择器、折叠区、滚动区和底部操作不得启动拖动。
- 拖动区域使用 Pointer Events,设置
touch-action: none;表单和正文区域保持浏览器原生选择、滚动和触控行为。 - 只接受 primary pointer;非主鼠标键、多指后续 pointer 和合成事件不能改变位置。
Pointer down 时只记录候选手势,不立即改变业务状态。移动距离超过集中定义的激活阈值后才进入 dragging;进入后通过 setPointerCapture() 持续接收当前 pointer,并抑制本次 click。未超过阈值的 pointer up 保持普通点击。
必须处理 pointerup、pointercancel、丢失 pointer capture、窗口失焦和 dispose。Escape 在拖动期间优先取消本次拖动并恢复上一个已提交位置;没有拖动时继续执行 Runtime 已有的 Escape 规则。不得用固定延时区分点击和拖动。
阈值属于交互策略,只能在浮动表面模块的单一常量所有者中定义。测试引用导出行为或通过输入输出断言,不复制数值字面量。
本方案采用“自由拖动 + 有界边缘吸附”:
- 拖动过程中表面跟随 pointer,并实时钳制在安全视口内;
- 释放时,只有进入集中定义的吸附范围才靠近最近边缘,否则保留自由位置;
- 四角同时满足两个边缘时形成角落停靠;
- 吸附只改变最终位置,不改变 Runtime 状态;
- 窄视口下工作台采用底部工作台布局,禁止保持会遮断主要操作的任意自由位置;灵动岛仍可在安全范围内移动。
吸附是位置算法的确定性输出,不通过 CSS 猜测,也不使用多段 timeout 制造磁吸效果。
灵动岛和工作台共享一个持久化浮动锚点。展开方向由锚点、当前安全视口和工作台实测尺寸计算:
- 靠右时优先向左展开,靠左时优先向右展开;
- 靠下时优先向上展开,靠上时优先向下展开;
- 自由位置按可用面积选择稳定方向,并使用上一次对齐方向避免中心线附近抖动;
- 最终矩形必须再次钳制到安全视口;
- 收起后灵动岛回到同一锚点,不回到默认右下角。
工作台展开后,目标变化不能重新计算浮动锚点。只有用户拖动、视口变化、工作台自身尺寸变化、位置恢复或重置位置可以触发表面布局协调。
位置模型是 Runtime 私有类型,不进入 @spotpatch/shared、公共配置或浏览器/服务端协议。建议的最小模型为:
type SurfaceAlignment = "start" | "end";
interface FloatingSurfacePosition {
readonly xRatio: number;
readonly yRatio: number;
readonly horizontal: SurfaceAlignment;
readonly vertical: SurfaceAlignment;
}xRatio/yRatio 表示安全视口内的归一化锚点,不保存原始屏幕像素。horizontal/vertical 表示浮动表面的哪一侧附着到锚点;它们用于保持中心线附近的稳定展开方向,不是可从单次尺寸可靠推导的重复字段。
模型不得包含以下派生状态:
- 当前
left/top/right/bottom像素; - 工作台宽高;
- 是否正在选择、是否展开;
- 目标矩形或目标 ID;
- 动画进度;
- 是否移动端。
上述信息必须从当前视口、DOM 实测尺寸、Runtime 状态和媒体条件即时派生,避免双向同步。
新的位置模块只接收不可变输入并返回不可变结果,至少包含:
- 默认锚点解析;
- pointer 位移到候选锚点的转换;
- 安全视口钳制;
- 边缘吸附;
- 对齐方向选择与稳定保持;
- 紧凑岛体和展开工作台矩形解析;
- 视口变化后的归一化恢复。
纯函数不读取 DOM、Storage、媒体查询或全局 Window。DOM 尺寸读取和样式写入由控制器边界负责,便于对四边、四角、中心、极窄视口和异常输入做确定性单元测试。
控制器优先使用 window.visualViewport 的偏移和尺寸,缺失时回退 innerWidth/innerHeight。安全边距和移动端边距由 Shadow Root 的布局令牌定义;JavaScript 应读取实际边界容器矩形或解析后的单一令牌结果,不能在 TS 和 CSS 中各维护一份相同数值。
软键盘、浏览器缩放或窗口 resize 后,应调度一次布局协调并钳制当前位置。不得在每个 pointermove 同步重复读取多个布局属性。
建议在现有 packages/runtime 内完成,不新增 workspace package 或公共 export:
packages/runtime/src/
├── state/
│ ├── floating-surface-session.ts
│ └── floating-surface-session.test.ts
└── ui/
├── floating-surface-position.ts
├── floating-surface-position.test.ts
├── floating-surface-controller.ts
├── floating-surface-controller.test.ts
├── runtime-view.ts
└── runtime-view.test.ts
职责如下:
floating-surface-position.ts:纯位置计算和私有类型;取代dialog-placement.ts;floating-surface-controller.ts:Pointer Events、pointer capture、帧合并、DOM 读写和生命周期释放;不处理选择业务;floating-surface-session.ts:版本化 Session Storage 解析与 best-effort 保存;不读取目标草稿;runtime-view.ts:创建 DOM、投影 Runtime 状态、接入控制器和样式,不内联拖拽算法;runtime-controller.ts:继续拥有选择和目标生命周期,不接管浮动位置。
若实施时控制器非常小且只有一个消费者,可以与位置模块合并;只有在职责和测试边界真实独立时才保留三个模块。不得为了匹配本文树形示意制造空包装器、单行转发器或仅重导出的 barrel。
| 状态 | 唯一所有者 |
|---|---|
idle/inspecting/selected/previewing |
现有 Runtime 状态机 |
| 目标集合、活动目标和说明 | 现有 Runtime Controller/Selection Session |
| committed 浮动位置 | Floating Surface Controller + Floating Surface Session |
| 当前 pointer 手势 | Floating Surface Controller 内存状态 |
| 展开方向和像素矩形 | Position 纯函数派生 |
| 视觉过渡 | CSS data-* 状态投影 |
禁止把 dragging 加入 Runtime 领域状态机;它是短生命周期的视图手势。禁止让 CSS class、Storage 和 Controller 分别保存三份“当前是否展开”。
位置恢复沿用现有 Selection Session 的容错模式,但使用独立 key 和独立快照:
- 存储介质:当前 Window 的
sessionStorage; - 隔离范围:带
sessionId的 SpotPatch 命名空间; - 快照内容:版本、归一化锚点和两轴对齐;
- 明确不保存:页面 URL、路径、组件名、目标、说明、Prompt、Agent 状态或用户输入;
- 读取规则:从
unknown开始验证对象形状、版本、有限数、[0, 1]比例和允许枚举;额外字段不进入领域状态,未知版本、比例越界或 JSON 异常时忽略整份快照; - 写入规则:只在一次拖动成功提交或重置位置后写入,不在每个 pointermove 写 Storage;
- 失败规则:访问受限、配额不足或序列化失败不影响当前内存位置和核心 picker;
- 生命周期:清除目标不能清除 UI 位置;新的开发 Session 默认回到右下角。
首版不使用 localStorage,因此不承诺关闭浏览器或重新启动 dev server 后长期保留。若未来需要跨 Session 保存,必须先定义项目隔离键、迁移和显式重置策略,不能直接替换存储介质。
- 空闲:品牌状态点、可读动作文字和快捷键 tooltip;
- 选择中:状态点使用克制呼吸/扫描反馈,文字明确表示正在选择;
- 有保留目标:显示可恢复语义,不把再次点击误导成重新清空选择;
- 拖动中:阴影和缩放提供抓取反馈,暂停 hover 位移;
- 展开:岛体淡出,工作台以共享锚点为 transform origin 进入;
- 收起:执行相反过渡,焦点按现有规则恢复。
Agent 的连接、执行和结果状态继续在工作台内由现有面板表达。首版不把全部 Agent 状态复制到岛体,避免第二套状态文案和颜色映射。
- 颜色、边框、轻微位移和状态点使用集中 CSS motion token;
- 岛体尺寸变化和工作台进入可使用不同 token,但所有值只在
:host设计令牌中定义; - 不使用 JS interval、逐帧 setTimeout 或动画结束后修改业务状态;
- 不对工作台正文做整体大比例缩放,避免文字模糊和焦点元素视觉漂移;
prefers-reduced-motion: reduce下移除位移、缩放、呼吸和磁吸过渡,只保留即时状态切换;- 动画期间的可点击区域必须与最终几何一致,不能出现视觉位置与 hit area 分离。
当前 460px × 620px 只作为首轮视觉验证基线。最终宽高、边距、吸附距离、手势阈值和 motion 时长必须分别归入布局、手势和 CSS 令牌的单一所有者,并在 playground 实测后确定;不得把同一数值复制到测试和多个模块。命名设计令牌不是业务硬编码,散落且相互依赖的裸字面量才是本方案禁止的硬编码。
- 默认右下角;工作台最大宽度以现有
460px为视觉验证基线; - 工作台正文独立滚动,标题栏、关闭入口和底部主操作保持可见;
- 用户拖到任意自由位置后,展开矩形仍必须完全位于安全视口;
- 工作台不能因为目标位于另一侧而移动。
- 灵动岛在可见安全范围内移动;
- 展开后的工作台切换为底部受限布局,左右使用紧凑安全边距;
- 自由桌面位置只作为锚点参考,不强行维持可能越界的像素位置;
- 关闭后灵动岛恢复到经过钳制的最近有效位置;
- 使用动态视口高度并考虑 safe area,不能仅依赖旧式
100vh。
数据链路、诊断、Agent 设置和结果折叠会改变内容高度。现有扩展提供的 onViewChange 仍有实际消费者,但其用途从“重新跟随目标”收敛为“合并调度一次浮动表面边界协调”。如果实施后 CSS 最大高度和滚动容器已能在全部状态保证边界,则应删除该回调及全部调用;不能保留空函数兼容层。
- pointermove 只更新内存中的最新坐标,每个 animation frame 最多执行一次 DOM 写入;
- 拖动开始时读取必要矩形,拖动帧中不反复测量不变尺寸;
- 工作台内容、视口或对齐变化时才重新测量展开表面;
- 目标 scroll/ResizeObserver 继续更新高亮,但不得调用浮动表面测量;
- 布局协调使用单一调度器合并同一帧内的数据链路、Agent 和诊断更新;
- dispose 必须释放 pointer、lost capture、blur、resize、visualViewport 和 media query 监听,取消待执行 animation frame;
- HMR 后旧控制器不得继续持有 DOM、Window 或 pointer 状态;
- 不新增第三方动画、手势或状态管理依赖。
目标是移除当前滚动期间因工作台跟随目标产生的尺寸读取,而不是用更频繁的拖拽布局计算替代它。性能门禁仍以 (见 doc-id:12-testing-acceptance) 为准。
- 灵动岛继续是带可读名称和
aria-pressed的按钮;拖拽是附加能力,不能成为使用 SpotPatch 的必要条件。 - 工作台继续使用
role="dialog"和标题关联;展开、收起和焦点恢复遵循现有规范。 - 标题栏拖拽手柄必须有明确名称;如果使用纯装饰空白区拖拽,仍需提供可聚焦的“重置位置”控制。
- 重置位置必须可由键盘执行,并通过 polite live region 宣告结果。
- 拖动时不连续向读屏播报像素;只在提交、取消或重置后播报一次结果。
- Escape 在拖动时先取消拖动;非拖动状态保持现有确定行为。
- 高对比度、减少动画、页面缩放到 200% 和触控目标均需真实 Chromium 验证。
首版不要求键盘逐像素移动浮层,因为默认位置下所有核心能力完整可用;若后续增加键盘移动,必须通过有限步进和可撤销操作实现,不能复用不透明的 pointer 模拟。
| 情况 | 必须行为 |
|---|---|
| Storage 不可访问或损坏 | 使用内存默认位置,不显示阻断错误 |
visualViewport 不可用 |
回退布局视口并保持边界钳制 |
| Pointer Capture 不可用或丢失 | 安全结束/取消当前拖动,不改变业务状态 |
| 工作台大于可用视口 | 启用窄视口/底部布局和内部滚动 |
| 恢复位置在视口外 | 归一化并钳制,不写入 NaN/Infinity |
| 动画不支持或减少动画 | 即时切换,核心功能保持一致 |
| 内容在拖动中改变尺寸 | 完成当前帧后统一协调,不跳回目标附近 |
所有降级都保持选择、目标说明和 Preview 可用。位置恢复失败不是协议错误,不进入 Agent 错误码或服务端日志。
实施必须在同一变更中处理以下旧实现:
- 删除
packages/runtime/src/ui/dialog-placement.ts; - 删除
packages/runtime/src/ui/dialog-placement.test.ts; - 删除
runtime-view.ts中仅为目标定位存在的currentRect、activeSelectionRect、anchor DOM、placement dataset 和相关 CSS; - 删除
showHighlight()、hideHighlight()、showSelectionHighlights()和hideSelectionHighlights()中对工作台定位的调用; - 将仍有消费者的尺寸变化回调接入统一边界协调器;确认无消费者后连同契约参数和调用点一起删除;
- 更新当前断言“元素内居中/元素旁停靠”的 Runtime View 与 E2E;不得同时保留新旧两套位置测试;
- 清理被新 motion token 取代的重复 transition 字面量;只提取语义相同且多处消费的值;
- 不保留 feature flag、旧定位回退或“双算法临时兼容”,因为该行为没有公共 API 兼容承诺。
删除前必须用 rg 证明无剩余生产引用;删除后执行 TypeScript、ESLint、单元测试、E2E、生产零残留和包验证。
- 默认右下角锚点;
- 自由位置、四边和四角吸附;
- 不同表面尺寸共享锚点并选择稳定展开方向;
- 极窄/极矮视口钳制;
- viewport offset、缩放和 resize;
- 非有限数、越界比例和异常尺寸拒绝或规范化;
- 输入对象不被修改,输出不可变。
- 未超过阈值是 click,超过阈值只提交 drag;
- primary pointer、pointer capture、cancel、lost capture 和 blur;
- 交互子元素不启动拖动;
- 同一帧多次 pointermove 只写一次位置;
- Escape 回滚未提交拖动;
- dispose 后监听和 animation frame 为零;
- 动画状态不改变 Runtime 状态。
- 合法快照恢复;
- 版本、枚举、有限数和范围校验;
- 畸形 JSON、超限文本、Storage getter/setter 异常;
- 不同 sessionId 隔离;
- 拖动提交后写一次,pointermove 不写;
- 清除 Selection Session 不清除位置。
- 默认右下角截图和可访问名称;
- 灵动岛 click 与 drag 分离;
- 右下、左上、边缘和自由位置分别展开;
- 选中屏幕四角元素、滚动和切换活动目标后工作台位置不变,高亮正确移动;
- 展开面板通过标题栏拖动,表单输入和正文选择不触发拖动;
- 刷新恢复、重启新 Session 回默认、重置位置;
- 桌面、窄视口、触摸仿真、200% 缩放和动态视口;
prefers-reduced-motion下无非必要位移/缩放;- 中文、英文、长组件名、长路径、Agent 长错误和折叠区展开后均不越界;
- HMR 后只有一个控制器和一个 SpotPatch Root。
视觉截图只覆盖稳定关键状态,不为每个动画帧生成像素快照。动效通过最终几何、data 状态、计算样式和减少动画模式断言;禁止固定 sleep。
- 收敛 UI 总规范、本文和 E2E 目标;
- 记录当前桌面/窄屏截图、现有包体和相关测试;
- 确认
runtime-view.ts、扩展回调和选择事件的实际依赖。
Gate:文档无冲突;现有质量门禁保持通过;不修改业务代码。
- 实现位置纯函数和版本化位置存储;
- 完成异常输入、边缘、四角、缩放和存储失败单测;
- 暂不接入视觉动画。
Gate:纯模块无 DOM/业务依赖,测试覆盖所有边界。
- 接入 Pointer Events、标题栏手柄、帧合并和边界协调;
- 工作台与目标几何解耦;
- 删除旧 placement、anchor 和测试;
- 完成桌面/窄视口功能 E2E。
Gate:拖动、选择、滚动和恢复全部确定;生产代码不存在旧目标定位分支。
- 建立状态投影、统一 motion token、展开/收起和拖动反馈;
- 完成 reduced motion、焦点和双语;
- 在完整 playground 进行人工视觉校准。
Gate:动画不驱动业务状态,不造成 hit area 偏移或焦点丢失。
- 运行 format、lint、typecheck、unit、compatibility、performance、E2E、production leakage、build 和 package validate;
- 对新增 Runtime gzip 体积做现有门禁比较;
- 更新本文 implementation-status、UI 总规范和当前实现证据;
- 用
rg、依赖检查和 coverage 审核未使用导出、CSS token、监听器及旧选择器。
Gate:全部 required checks 通过;没有未解释 skip、无消费者代码、重复状态或散落策略值。
| 决策 | 结论 | 理由 |
|---|---|---|
| 默认位置 | 右下角 | 与当前入口一致,稳定且不改变宿主布局 |
| 用户位置 | 自由拖动并在边缘范围内吸附 | 兼顾自由度和常用停靠整齐度 |
| 工作台与目标 | 完全解耦 | 避免跳动和滚动布局测量;高亮继续表达关联 |
| 手势技术 | Pointer Events + pointer capture | 统一鼠标、触控笔和触摸输入 |
| 位置持久化 | 按 sessionId 隔离的 sessionStorage | 与当前开发会话边界一致,不长期污染宿主 |
| 业务状态 | 复用现有 Runtime 状态机 | 不复制展开/选择状态 |
| 动画 | CSS 状态投影,无第三方依赖 | 体积小、可降级、易支持 reduced motion |
| 公共 API | 首版不新增 | 位置是本地 UI 偏好,不需要进入协议或配置 |
| 旧目标定位 | 同变更删除 | 不保留无公共兼容承诺的双算法和死代码 |
当前仍需通过 playground 视觉验收确定的是具体尺寸、边距、吸附距离、手势阈值和动效曲线。这些属于集中令牌的校准,不影响位置模型、模块边界和测试策略;在真实浏览器验证前不得把建议值描述为最终视觉事实。