# dsh-sidebar-rail · 设计说明 > 本文与实现同步维护。验证记录见 [verify.md](verify.md),需求与验收见 [PRD](PRD.md)。 客户端静态模块的 `window.__ModuleLoader__.load({ id, factory })` 使用完整包名 `@skylerfee/dsh-sidebar-rail` 作为 `id`,与 `package.json.name` 和 bundle patch 的模块 `name` 一致。宿主按该包名导入工厂;不能把注册 ID 改成去掉作用域的短名。`shell.overlay` 条目 ID 和本地存储键仍使用各自已有的值。 ## 1. 定位:增量插件,只补一个状态 宿主在 **macOS Desktop + 侧栏折叠** 时没有折叠图标轨。此时 `AppFrame` 把左侧列折到 0 宽(`const collapsedWidth = drawer || darwin || windowsTitlebar ? 0 : SIDEBAR_COLLAPSED`,`SIDEBAR_COLLAPSED = 56`),列上还有 `overflow: hidden`。本插件添加默认隐藏、按需唤出的 56px 图标轨,不为轨道占用宿主布局空间。 其余状态一律不渲染: | 状态 | 宿主已有 | 插件行为 | | --- | --- | --- | | macOS Desktop 展开态 | 完整原生侧栏(搜索、视图选项、添加工作区、设置座位、账号菜单) | 不渲染任何节点 | | Web 折叠态 | 宿主自带 56px 折叠轨(`ui-sidebar` 的 `.collapsed` rail 布局) | 不渲染,避免出现第二条轨 | | Web 展开态 | 完整原生侧栏 | 不渲染 | | Windows / Linux | 未验证 | 不渲染 | 判据是 `document.documentElement.dataset.platform === 'darwin'`、宿主 frame 带 `data-sidebar-collapsed` 且视口宽度 ≥ 768px,任一不成立就 `return null`。插件详情页的设置块独立注册到 `plugins.bundle.config`,不受轨道显隐控制。 ## 2. 挂载点:`shell.overlay` list 槽 ui-layout 在 frame 上把 `shell.overlay` 声明为 **`kind: 'list'`、`scope: 'root'`** 的增量座位,专门给「自己的整帧浮动面」用:条目按 `id` 并存,不需要声明 `children`,不会和别的插件抢位。实际注册写法(见 `client.js` 的 `apply`): ```js ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'dsh-sidebar-rail.rail', order: 10, locale: NS, inject: injectProps, }, (props) => h(Boundary, { fallbackText: props.fallbackText }, h(RailOverlay, props)))), 'dsh-sidebar-rail: rail overlay') ``` 渲染容器是 frame 内的 `.overlayLayer`:`position: absolute; inset: 0; z-index: 20; pointer-events: none`,其直接子元素恢复 `pointer-events: auto`。因此条目自己的根节点默认「透明可穿透」,这正好适合一个把内容 portal 出去的组件。 **关键限制:overlay 条目拿不到 owner props。** 只有 `sidebar` 这类单槽的占用者才会收到 frame 给的 `collapsed / width / drawer`;`shell.overlay` 是列表条目,只有运行时 props 加 `inject` face。所以折叠状态不能靠 props 判断,见下一节。 ## 3. 折叠检测:读宿主发布的 DOM 属性 - 宿主在 frame 元素上发布折叠状态:`
`(`ui-layout` 的 `AppFrame.tsx`)。 - 定位 frame:`.overlayLayer` 是 frame 的直接子节点,且带稳定标记 `data-shell-overlay`。用 `document.querySelector('[data-shell-overlay]')?.parentElement` 拿到 frame,**不用** hash 化的 CSS Module 类名(构建一变就失效)。 - 订阅用**属性范围限定的 MutationObserver**: ```js const observer = new MutationObserver(() => setCollapsed(frame.hasAttribute('data-sidebar-collapsed'))) observer.observe(frame, { attributes: true, attributeFilter: ['data-sidebar-collapsed'] }) return () => { observer.disconnect() } ``` 只观察这一个属性,窗口 resize、无关 re-render 都不会唤醒组件;`useEffect` 清理时 `disconnect()`。 - 兜底:拿不到 frame 时一直按「未折叠」处理(不渲染),不在错误状态下画东西。 ## 4. portal 定位与避让 轨本体用 `createPortal(document.body)` 渲染,样式 `.dsr-rail-float`: ``` position: fixed; left: 0; top: var(--dsh-frame-top-clearance, 48px); bottom: 0; width: 56px; z-index: 60; -webkit-app-region: no-drag; background: var(--dsw-specific-sidebar-fill); border-right: .5px solid var(--dsw-alias-border-l3); transform: translateX(-100%); ``` - **默认隐藏**:左缘保留 12px 热区,进入热区或聚焦轨内控件时唤出轨道。宿主折叠态正文抵达窗口左缘,标签行没有为浮轨留位置,因此轨道可见期间仍是临时覆盖层;隐藏后恢复正文入口。热区只负责唤出,指针位置及导航动作决定收起,避免热区被轨盖住时反复触发显隐。 - **为什么必须 portal**:折叠列宽为 0 且 `.sidebarCol { overflow: hidden }`,任何挂在列内的元素都会被裁没。portal 到 `body` 是唯一不被裁剪的面。 - **为什么 top 从 48px 起**:`--dsh-frame-top-clearance` 是宿主声明在**根元素**上的窗口 chrome 带高度(注释明确写了「Declared on the root element rather than the frame so overlays portalled to document.body inherit it too」,macOS 桌面端 48px)。这条带里有: - macOS 红绿灯(traffic lights 到 x 68); - 宿主自己挂在 `shell.leading` 的重开 / 新会话控件(`.leadingSeat { top: 11px; left: 88px; z-index: 15 }`,`leadingMounted = drawer || darwin && sidebarCollapsed`)。 从 48px 起排就同时避开两者;`var(..., 48px)` 的 fallback 保证变量缺失时也不压 chrome。 - **显式 `-webkit-app-region: no-drag`**:Electron 的 app-region 是**几何**而不是 stacking,浮轨盖住窗口拖拽带时必须自己 opt out(即便 body 级 portal 通常已继承 no-drag),否则点图标会拖窗口。 - **窄窗口不渲染**:判定不是 CSS 媒体查询,而是纯函数 `shouldRenderRail(platform, collapsed, viewportWidth)`(阈值 `RAIL_MIN_VIEWPORT = 768`),`RailOverlay` 用 `window.innerWidth` + `resize` 监听驱动;不满足即 `return null`,组件整体卸载。768 = 680(宿主正文最小宽)+ 56(轨宽)+ 32(两侧安全)。为什么不用 CSS:阈值语义是「正文是否还放得下」,与轨自身盒模型无关;做成纯函数还能被单测覆盖边界(`test/data.test.mjs`)。为什么不是 900:旧实现的 900 是拍的,与宿主 drawer 断点(768)无对应关系;按 680 正文可读宽推导更可解释。 - **浮层位置**:`.dsr-flyout` 也是 `position: fixed`(portal 到 body),宽 288px,`left = rect.right + 8`。`flyoutPlacement` 根据触发按钮与视口计算顶部或底部距离,并以内联 `maxHeight` 限制剩余可视高度;账号浮层从头像底边向上展开。工作区列表和最近会话分别滚动,最近会话区域上限为 `38vh`。 ## 5. 为什么不改宿主列宽 看起来最「干净」的做法是把折叠列宽从 0 改成 56,再把轨嵌回栅格。未采用,原因: 1. 列宽是宿主算出来的:`AppFrame` 把 `gridTemplateColumns` 作为**内联样式**写在 frame 上,并在 resize、折叠动画、drawer 切换时重算。插件要改只能覆写内联样式 / 用 `!important` 盖宿主 CSS,和宿主的几何契约打架。 2. 折叠是一次带过渡的 track 变化(`--ds-transition-duration-slow`),第三方把宽度钉死会破坏动画与拖拽把手逻辑(`DragHandle` 只在展开时挂载)。 3. 即便改宽,macOS 折叠态列内的内容也不是为 56px 轨排的布;宿主的语义是「折叠即隐藏整个列」。 4. 结论:**不做列宽修改、不用 `!important` 覆盖宿主栅格轨**;改为 portal 浮层,代价是浮在主内容之上(已写进 README 的已知限制)。 ## 6. 为什么不替换 `sidebar` 槽 `sidebar` 是 `kind: 'single'`,ui-layout 的槽文档写得很明白: > registering here replaces the navigation column outright rather than adding to it, and the seats it declares disappear with it. 决定性理由不是「抢占风格」而是**设置会整体不可达**:`ui-sidebar` 在自己的注册里声明了 7 个子槽,其中 `sidebar.settings` 由 `ui-settings-general` 的 `SettingsRoot` 占位。一旦插件替换 `sidebar`,这 7 个子槽随之消失 → `SettingsRoot` 根本不挂载 → 设置(含账号页、主题、插件管理入口)全部没有入口。 替换还会一起带走:搜索、视图选项、添加工作区、拖拽排序、行内重命名 / Fork / 归档菜单、账号菜单。这些正是重构要「交回宿主」的部分。 因此本项目**不允许**重新引入替换 `sidebar` 的实现(`AGENTS.md` 已写成硬约束)。 ## 7. z-index 层级取舍 宿主 frame 相关层级(`AppFrame.module.css`):drag handle 11、`.leadingSeat` 15、`.overlayLayer` 20、drawer 模式下 `.drawerMask` 70 / drawer 列 71。 | 面 | z-index | 理由 | | --- | --- | --- | | 浮轨 `.dsr-rail-float` | 60 | 要盖住 overlay 层(20)与 leading seat(15)的视觉层,但不能压过 drawer 体系(70/71)——drawer 只在 <768px 生效,而浮轨在同一阈值下已不渲染(`shouldRenderRail` 的 768),两者不共存 | | 浮层 `.dsr-flyout` | 2147483000(字面值) | 浮层是 body portal,要压过宿主任意表面;宿主没有提供「浮层层级」token,用极大字面值而不是 `!important` | 热区的 z-index 为 59,轨道为 60;轨道出现时会覆盖左侧 56px 正文及其点击区域,因此默认隐藏,不能据轨宽较小推断「不会挡住正文交互」。 ## 8. 事件、计时与清理 | 交互 | 实现 | 清理 | | --- | --- | --- | | 悬停打开 | `pointerenter` 后 120ms(`OPEN_DELAY_MS`)定时器 | `clearTimers()` 卸下两个定时器 | | 移出关闭 | `pointerleave` 后 200ms(`CLOSE_DELAY_MS`);浮层 `onPointerEnter: cancelClose`,跨间隙不闪断 | 同上 | | 轨道显隐 | 12px 热区或轨内焦点唤出;在图标间移动只关闭或切换浮层,导航动作同时收起轨道 | 收起定时器及窗口 `pointermove` 监听随组件清理 | | Escape | `open !== null` 时挂 `window` keydown,关闭浮层 | effect cleanup 摘除监听 | | 键盘进入 | 顶部功能图标上按 `↓` / `→`,打开浮层并聚焦首个按钮;浮层内 Escape 关闭并返回触发图标 | 焦点只移到当前浮层节点 | | 定时任务 / 插件数据 | 每次打开拉取(`idle → loading → ready/failed`),5s 超时或服务不可用进入失败态;Promise resolve 后仍判断 `RemoteResult.ok` | `live` 标记隔离过期结果,清理超时并重置请求标记,切回入口可重试 | | 账号数据 | 组件挂载后读取登录态;登录时并行读取 profile 与余额,各自保留失败占位,不保证实时更新 | 清理后忽略晚到结果 | | 槽注册与订阅 | `ctx.effect(...)` 包裹 overlay 注册、locale 字典、panel 条目订阅、locale 订阅 | effect dispose 自动回收 | | 样式 | `