From 3c196fffc3274bb30367a8fff77f46eff5005d2c Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Sun, 16 Aug 2026 16:37:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9A=AE=E8=82=A4=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=EF=BC=88=E4=BB=A4=E7=89=8C=E9=A9=B1=E5=8A=A8=EF=BC=89+=20CSS?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8D=EF=BC=88#106=20#105=20#90=20#60=EF=BC=8C?= =?UTF-8?q?=E9=99=84=E5=B8=A6=20#52=20#57=20#92=EF=BC=89=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 令牌驱动方案(主目标:与 dsh-web-ui 皮肤中心兼容): - 面板表面改用通用卡片令牌 --dsw-alias-bg-layer-1,不再消费宿主左导航专属 --dsw-specific-sidebar-fill——dsh-web-ui 10 款皮肤全部覆盖 --dsw-alias-* 层,换肤后侧边栏自动跟随,零每皮肤适配(#60/#105/#106)。 - effectiveTokenValue 增加 alpha 阈值(新增 colorAlpha 解析 computed 颜色): transparent 与 alpha<0.9 的半透明玻璃值(dsh-web-ui 皮肤 bg-base 为 rgba 0.16–0.7)一律回退不透明底色,终端/编辑器文字永不叠在皮肤背景画上 (#90);≥0.9 的近不透明值放行,皮肤仍可控终端表面。 独立修复(保留): - z-index 降到 DSH 浮层栈(100+)之下:面板 40 / 按钮簇 45 / 角手柄面板内 局部 2,Cordis 弹出框不再被底部工作台遮挡(#52)。 - 角手柄移入右面板、CSS 相对定位(bottom: calc(var(--dsh-sidebar-height) + 6px)),删除 JS 内联 viewport 坐标。 - 刷新按钮统一 14px(#57 前半)。 - 拖拽逐帧回归 e2e(#92):真实挂载下 rAF 采样(拖条以 cursor: col-resize 语义定位),断言拖拽期间 data-dsh-sidebar-dragging 存在、#root transition 为 none、会话列与面板边单调 1:1 跟随(实测通过)。 按 KISS 回退首版契约:类名 kebab-case 改名与 data-bs-* 语义钩子全部移除 (令牌驱动下不需要,改名引入无谓 breaking),skin-hooks 测试删除; mount e2e 改为断言 --dsh-sidebar-width 布局变量随面板挂载生效。 文档:AGENTS.md §8 重写为「皮肤兼容(令牌驱动)」;设计文档记录决策与 回退。README 与版本号不在本批更新(发布流程统一处理)。 测试:tests/theme.spec.ts(colorAlpha + effectiveTokenValue 阈值); typecheck/vitest(511)/build/consumer-types/test:mount 全绿。 --- AGENTS.md | 23 ++- docs/plans/2026-08-15-skin-compat-design.md | 67 ++++++ src/client/DiffTab.tsx | 2 +- src/client/ExplorerView.tsx | 2 +- src/client/GitView.tsx | 2 +- src/client/Sidebar.tsx | 100 ++++----- src/client/TerminalView.tsx | 12 +- src/client/sidebar.module.css | 55 +++-- src/client/theme.ts | 66 ++++++ tests/e2e/drag-layout.e2e.ts | 218 ++++++++++++++++++++ tests/e2e/mount.e2e.ts | 10 + tests/theme.spec.ts | 82 ++++++++ 12 files changed, 569 insertions(+), 70 deletions(-) create mode 100644 docs/plans/2026-08-15-skin-compat-design.md create mode 100644 tests/e2e/drag-layout.e2e.ts create mode 100644 tests/theme.spec.ts diff --git a/AGENTS.md b/AGENTS.md index a1c861a..6587b92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -560,7 +560,26 @@ interface OpenTabSeed { --- -## 8. 完整最小示例 +## 8. 皮肤兼容(令牌驱动) + +> 侧边栏的皮肤兼容是**令牌驱动**的:所有视觉值都消费 DSH 的 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌(无硬编码颜色),皮肤系统覆盖什么令牌我们就跟随什么——**不做任何每皮肤适配**。当前已与 dsh-web-ui 皮肤中心兼容:其 10 款皮肤全部覆盖 `--dsw-alias-*` 层,换肤后面板自动跟随(测试:`tests/e2e/mount.e2e.ts` 断言布局变量随面板挂载生效;`tests/theme.spec.ts` 守护令牌读取)。 + +### 8.1 规则 + +- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`(通用卡片表面)。**绝不消费 `--dsw-specific-sidebar-fill`**——那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 皮肤把它做成半透明玻璃或主题色,Aqua 设成 `transparent`),面板消费它会失去填充或与标签令牌冲突。皮肤要整体换面板表面:覆写 `--dsw-alias-bg-layer-1` 即可(dsh-web-ui 10 款皮肤都已覆盖,无需任何额外工作)。 +- **终端/编辑器表面**:经 `effectiveTokenValue` 读取 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明玻璃值(dsh-web-ui 皮肤用 rgba 0.16–0.7)一律回退不透明底色,文字永不叠在皮肤背景画上滚动(issue #90);≥ 0.9 的近不透明值(如皮肤作用域内 0.96 的瓷器玻璃)放行,皮肤仍能控制终端表面。 +- **根锚点**:宿主 div 带 `data-dsh-better-sidebar` 属性(append 到 `document.body`),面板是其 fixed 直接子级。皮肤若要做作用域覆盖(deep-whale 的做法),限定在 `[data-dsh-better-sidebar]` 内即可,避免全局改写影响宿主。 +- **布局变量**(写在 `` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`(面板几何;拖拽期间逐帧更新)。 +- **z-index**:面板 40、折叠按钮簇 45(角手柄在面板内层叠,z-index 2 仅面板内有效)——全部低于 DSH 浮层栈(100/1000+),任何浮层天然盖住侧边栏。 + +### 8.2 注意事项 + +- 类名是 CSS Modules 哈希(`[hash]_[local]`),**不是契约**——皮肤不要依赖类名寻址;需要精确命中单表面时,用 `[data-dsh-better-sidebar]` 属性选择器配合子串类名(如 `[class*='panel']`)或 DOM 结构。 +- 改动本契约(面板表面令牌、透明度阈值、z-index 层级)必须同步更新本文档、设计文档与 `tests/theme.spec.ts`。 + +--- + +## 9. 完整最小示例 > 假设插件 `my-plugin` 要加一个"Database 浏览器" tab + `.csv` 文件预览器。 @@ -636,7 +655,7 @@ function parseCsv(text: string): string[][] { /* ... */ } --- -## 9. 参考实现 +## 10. 参考实现 better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"): diff --git a/docs/plans/2026-08-15-skin-compat-design.md b/docs/plans/2026-08-15-skin-compat-design.md new file mode 100644 index 0000000..237664c --- /dev/null +++ b/docs/plans/2026-08-15-skin-compat-design.md @@ -0,0 +1,67 @@ +# 皮肤兼容(令牌驱动):面板表面、终端透明度回退与独立 CSS 修复 + +**日期**:2026-08-15 +**状态**:已实施(本文档含实施偏差记录) +**目标版本**:随发布 bump(本 PR 不改版本号、不更新 README——发布流程统一处理) + +## 1. 目标 + +让 better-sidebar 与 **dsh-web-ui 的皮肤中心**兼容:用户切换皮肤时侧边栏自动跟随换肤,零每皮肤适配(KISS:不做 aionui 式"每款皮肤 remap 一套令牌"的重活)。附带修复同批 CSS 问题([#52](https://github.com/omdsh-dev/DSH-better-sidebar/issues/52) 弹出框遮挡、[#57](https://github.com/omdsh-dev/DSH-better-sidebar/issues/57) 前半图标尺寸、[#92](https://github.com/omdsh-dev/DSH-better-sidebar/issues/92) 拖拽回归防护),并覆盖 [#60](https://github.com/omdsh-dev/DSH-better-sidebar/issues/60)/[#105](https://github.com/omdsh-dev/DSH-better-sidebar/issues/105)/[#90](https://github.com/omdsh-dev/DSH-better-sidebar/issues/90) 的相关诉求。 + +## 2. 调研结论 + +dsh-web-ui(`zhu1090093659/dsh-web-ui`)的皮肤机制: + +- 皮肤 = cordis 插件(`ui-skin-`),皮肤中心通过 `cordis.patch.yml` 切换激活;皮肤插件 client half 在 `document.body` 上打 `data-dsh-` 属性并注入 CSS。 +- **10 款皮肤全部覆盖两层令牌**:`--dsw-static-*` 基础色板 + `--dsw-alias-*` 语义层(`--dsw-alias-bg-layer-1`、`--dsw-alias-bg-base` 等做成半透明玻璃 rgba 0.16–0.7,`--dsw-specific-sidebar-fill` 也全部覆盖)。 +- dsh-web-ui 自家右侧面板(aionui)用独立 `--aion-*` 令牌,每款皮肤单独 remap——**重**。 + +结论:better-sidebar 只要**消费 DSH 标准令牌**(alias 层),换肤就自动跟随,不需要任何每皮肤适配。此前围绕第三方皮肤插件(Aqua/deep-whale)设计的类名改名与 `data-bs-*` 语义钩子按 KISS 原则**全部回退**(见 §6)。 + +## 3. 设计 + +### 3.1 面板表面(`sidebar.module.css`) + +```css +.panel { background: var(--dsw-alias-bg-layer-1); } +.bottomPanel { background: var(--dsw-alias-bg-layer-1); } +``` + +- 直接消费通用卡片表面令牌,**不新增自有令牌、不加属性钩子**。 +- **绝不消费 `--dsw-specific-sidebar-fill`**:那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 半透明玻璃/主题色、Aqua `transparent`),面板消费它会失去填充或与标签令牌冲突。 +- stock 外观色阶变化仅 ±8 RGB(bluish-50→75 / 900→875),视觉无差;dsh-web-ui 换肤后面板获得与宿主一致的玻璃效果。 + +### 3.2 终端/编辑器透明度回退(`theme.ts` / `TerminalView.tsx`) + +`effectiveTokenValue` 现在把以下值视为"未设置"(触发调用方 `||` 回退): + +- 空串与 CSS 重置关键字(`transparent`/`initial`/`inherit`/`unset`); +- **alpha < 0.9 的半透明颜色**(dsh-web-ui 皮肤的 `--dsw-alias-bg-base` 是 rgba 0.16–0.7 玻璃)。 + +新增 `colorAlpha()` 解析 computed 颜色(rgb()/rgba()/hsl()/hsla() 逗号与空格语法、`#rgb/#rgba/#rrggbb/#rrggbbaa`);无法解析的格式(命名色、`color()`)视为不透明放行。阈值 0.9:≥ 0.9 的近不透明值(如皮肤作用域内 0.96 瓷器玻璃)放行,皮肤仍能控制终端表面。 + +### 3.3 独立修复(与皮肤兼容无关,保留) + +- **z-index(#52)**:`.toggleCluster` 55→45、`.panel`/`.bottomPanel` 50→40;角手柄移入面板后为面板内局部 `z-index: 2`。依据:DSH 浮层栈为 100(Menu/HoverCard/Tooltip/PopupSelect/Modal=1000),30–99 无任何 DSH 元素。 +- **角手柄几何 CSS 化**:移入右面板 DOM,`position: absolute; left: -6px; bottom: calc(var(--dsh-sidebar-height, 0px) + 6px)`——复用拖拽逐帧写入的布局变量,删除 `cornerRef` JS 内联坐标。 +- **刷新图标统一 `size={14}`(#57 前半)**:Explorer/Git/Diff 的 `IconRefreshOutline16` 显式 14px,与 Subagent/Browser 一致。 +- **拖拽逐帧回归 e2e(#92)**:`tests/e2e/drag-layout.e2e.ts` rAF 采样,断言拖拽期间 `data-dsh-sidebar-dragging` 存在、`#root` transition 为 none、会话列与面板边单调 1:1 跟随(实测通过)。拖条以 `cursor: col-resize` 语义定位(无属性钩子可依赖)。 + +## 4. 测试 + +- `tests/theme.spec.ts`:`colorAlpha`(hex/rgb/hsl/不可解析)与 `effectiveTokenValue`(transparent/关键词/半透明回退/0.96 放行/缺失)。 +- `tests/e2e/mount.e2e.ts`:追加 `--dsh-sidebar-width` 布局变量随面板挂载生效的断言(令牌驱动契约的挂载级守护)。 +- `tests/e2e/drag-layout.e2e.ts`:拖拽逐帧回归。 +- 门禁:`pnpm typecheck && pnpm test && pnpm build && pnpm pack && pnpm test:mount`。 + +## 5. 文档 + +- AGENTS.md §8「皮肤兼容(令牌驱动)」:面板表面令牌、透明度阈值、z-index 层级、根锚点 `[data-dsh-better-sidebar]`、类名非契约。 +- README 不在本批更新(发布时随版本条目更新)。 + +## 6. 实施偏差记录 + +- **首版契约(已回退)**:初版围绕第三方皮肤插件设计了 `--dsh-sidebar-surface`/`--dsh-resize-strip-offset` 自有令牌(review 后已删)、panel 家族类名 kebab-case 改名(breaking)与 `data-bs-*` 语义钩子。确认主目标是 dsh-web-ui 皮肤中心后,按 KISS 全部回退:令牌驱动下这些都不需要,改名还引入无谓 breaking。最终契约 = 消费标准令牌 + 透明度阈值,无自有概念。 +- **角手柄 `z-index` 语义变化**:移入面板后 z-index 为面板内层叠(2,与拖条一致),对外绝对层级由面板(40)决定;两面板同开时手柄盒与底面板上沿无重叠(≥6px 间隙),行为不变。 +- **拖拽禁用断言修正**:`transition: none` 的 computed 值为 `transition-property: none`(非 `all`),已按实测修正。 +- **面板默认表面色阶微调**:±8 RGB,未做像素级补偿(e2e 不依赖具体颜色)。 diff --git a/src/client/DiffTab.tsx b/src/client/DiffTab.tsx index 72827f3..715edb8 100644 --- a/src/client/DiffTab.tsx +++ b/src/client/DiffTab.tsx @@ -90,7 +90,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif title={t('refresh')} onClick={refresh} > - + {loading &&
{t('loading')}
} diff --git a/src/client/ExplorerView.tsx b/src/client/ExplorerView.tsx index d0eb593..9a313b6 100644 --- a/src/client/ExplorerView.tsx +++ b/src/client/ExplorerView.tsx @@ -209,7 +209,7 @@ export function ExplorerView(props: { setRefreshTick(tick => tick + 1) }} > - +
diff --git a/src/client/GitView.tsx b/src/client/GitView.tsx index 272476f..4075079 100644 --- a/src/client/GitView.tsx +++ b/src/client/GitView.tsx @@ -297,7 +297,7 @@ export function GitView(props: { title={t('refresh')} onClick={() => { void refresh() }} > - +
diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index c450a36..07035d0 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -462,7 +462,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // pointer up (clamping + persistence). const panelRef = useRef(null) const bottomRef = useRef(null) - const cornerRef = useRef(null) const widthDrag = useRef({ startX: 0, startWidth: 0 }) const [draggingWidth, setDraggingWidth] = useState(false) const bottomDrag = useRef({ startY: 0, startHeight: 0 }) @@ -501,10 +500,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`) document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`) document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`) - if (cornerRef.current !== null) { - cornerRef.current.style.left = `${window.innerWidth - width - 6}px` - cornerRef.current.style.top = `${window.innerHeight - height - 6}px` - } + // The corner handle positions itself relative to the panel (CSS + // `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout + // variables are all it needs — no viewport coordinates written here + // (issue #106: skins that inset the panels must not fight JS coords). } // Drags write at most once per frame: pointer events fire several times @@ -775,11 +774,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { ref={panelRef} className={clsx(css.panel, !state.panelOpen && css.panelHidden)} style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }} + data-dragging={anyDragging || undefined} > {!narrow && (
{ event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) @@ -814,6 +815,49 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { getTabBadge={tabBadgeOf} />
+ {/* + The shared corner (only while BOTH panels are open): the + intersection of the right panel's left edge and the bottom panel's + top edge. Horizontal drags resize the right panel's width, vertical + drags the bottom panel's height — the two panels drag against each + other. Rendered INSIDE the right panel and positioned by CSS + relative to it (left edge + the bottom panel's height via the + --dsh-sidebar-height layout variable) — no JS-written viewport + coordinates to keep in sync. (Never on narrow viewports: the + bottom panel does not exist there.) + */} + {!narrow && state.panelOpen && state.bottomOpen && ( +
{ + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + cornerDrag.current = { + startX: event.clientX, + startY: event.clientY, + startWidth: state.width, + startHeight: state.bottomHeight, + } + setDraggingCorner(true) + }} + onPointerMove={(event) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + const { startX, startY, startWidth, startHeight } = cornerDrag.current + const width = clampWidth(startWidth + (startX - event.clientX)) + const height = clampHeight(startHeight + (startY - event.clientY)) + scheduleDrag(width, height) + }} + onPointerUp={(event) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + event.currentTarget.releasePointerCapture(event.pointerId) + const { startX, startY, startWidth, startHeight } = cornerDrag.current + stopDragScheduling() + store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY))) + setDraggingCorner(false) + }} + /> + )}
{/* The bottom panel: a second, independent workbench. It squeezes ONLY @@ -842,10 +886,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // fill — without it the corner looks cut off). borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined, }} + data-dragging={(draggingBottom || draggingCorner) || undefined} >
{ event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) @@ -896,50 +942,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
)} - {/* - The shared corner (only while BOTH panels are open): the intersection - of the right panel's left edge and the bottom panel's top edge. - Horizontal drags resize the right panel's width, vertical drags the - bottom panel's height — the two panels drag against each other. - (Never on narrow viewports: the bottom panel does not exist there.) - */} - {!narrow && state.panelOpen && state.bottomOpen && ( -
{ - event.preventDefault() - event.currentTarget.setPointerCapture(event.pointerId) - cornerDrag.current = { - startX: event.clientX, - startY: event.clientY, - startWidth: state.width, - startHeight: state.bottomHeight, - } - setDraggingCorner(true) - }} - onPointerMove={(event) => { - if (!event.currentTarget.hasPointerCapture(event.pointerId)) return - const { startX, startY, startWidth, startHeight } = cornerDrag.current - const width = clampWidth(startWidth + (startX - event.clientX)) - const height = clampHeight(startHeight + (startY - event.clientY)) - scheduleDrag(width, height) - }} - onPointerUp={(event) => { - if (!event.currentTarget.hasPointerCapture(event.pointerId)) return - event.currentTarget.releasePointerCapture(event.pointerId) - const { startX, startY, startWidth, startHeight } = cornerDrag.current - stopDragScheduling() - store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY))) - setDraggingCorner(false) - }} - /> - )} ) } diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index d4ec208..ec36dc0 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -27,7 +27,7 @@ import { t } from './locales.ts' import { openWhenSized } from './open-when-sized.ts' import type { SessionScope } from './api.ts' import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts' -import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts' +import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts' import { resolveTerminalFont } from './terminal-font.ts' import css from './sidebar.module.css' @@ -61,8 +61,14 @@ const ANSI_LIGHT: Record = { /** The xterm theme for the current scheme (surface from tokens, ANSI curated). */ function xtermTheme(): ITheme { const dark = isDarkScheme() - const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff') - const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a') + // Skin systems set --dsw-alias-bg-base to `transparent` or translucent + // glass values (the dsh-web-ui skins use rgba 0.16–0.7); effectiveTokenValue + // treats those as unset below the opacity floor, so the opaque fallback + // engages and the terminal never renders see-through over the skin's + // backdrop (issue #90). Effectively opaque scoped surfaces (e.g. a skin's + // 0.96 porcelain) pass through — the skin still controls the terminal. + const background = effectiveTokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff') + const foreground = effectiveTokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a') return { background, foreground, diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 5560abd..2426173 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -10,16 +10,29 @@ /* ── Shell ─────────────────────────────────────────────────────────────── */ +/* Skinning contract (token-driven): every surface here consumes DSH's + generic tokens (`--dsw-alias-*`), NEVER `--dsw-specific-sidebar-fill` — + skin systems override that token to restyle the APP's own left-nav column + (dsh-web-ui skins make it translucent glass or theme colors, Aqua makes it + transparent), and a panel consuming it would lose its fill or clash with + the label tokens. The panels use the generic card surface + `--dsw-alias-bg-layer-1`, which every dsh-web-ui skin overrides — so + switching skins in the dsh-web-ui skin center re-skins the sidebar panels + automatically (see AGENTS.md §8). */ + /* The persistent expand/collapse cluster at the top-right corner: two rail controls side by side (bottom panel's glyph LEFT of the right panel's). Always pinned to the viewport corner — inside the right panel's top-right while it is open, sitting flush in the 34px tab strip (28px buttons at - top: 3) whose right end it really squeezes. */ + top: 3) whose right end it really squeezes. z-index 45: above the panels + (40) but below the app's overlay stack (menus/tooltips/modals sit at + 100+), so a cordis approval popup is never hidden behind the workbench + (issue #52). */ .toggleCluster { position: fixed; top: 3px; right: 10px; - z-index: 55; + z-index: 45; display: flex; flex-direction: row; gap: 4px; @@ -70,12 +83,18 @@ /* Full height: the bottom panel squeezes only the center column, so the right panel never gives up any vertical position. */ bottom: 0; - z-index: 50; + /* z-index 40: above the app's in-flow content, below the app's overlay + stack (100+), so menus/tooltips/modals always win over the panels + (issue #52). The toggle cluster (45) sits above it; the corner handle + is a child and stacks within the panel. */ + z-index: 40; display: flex; flex-direction: column; - /* The native sidebar surface: bluish-50 over white / bluish-900 over dark, - exactly like the app's own sidebar column (SidebarRoot .root). */ - background: var(--dsw-specific-sidebar-fill); + /* The generic card surface (see the root contract above). Never + `--dsw-specific-sidebar-fill` — skin plugins restyle that token for the + app's own left nav and a panel consuming it loses its fill or clashes + with labels (#60/#105/#106). */ + background: var(--dsw-alias-bg-layer-1); border-left: 1px solid var(--dsw-alias-border-l2); /* Expand/collapse slides the panel; fullscreen animates the width. */ transition: @@ -101,6 +120,8 @@ .panelResize { position: absolute; + /* Centered on the panel edge (an 8px hit strip straddling the border, the + same pattern as the host's own frame handles). */ left: -4px; top: 0; bottom: 0; @@ -127,14 +148,16 @@ column only (the Sidebar shell writes left/right inline: from the app's own left sidebar to the right panel's left edge) — the sidebars never give up any position. Same surface as the right panel, with a top - hairline instead of the left one. */ + hairline instead of the left one. z-index 40 mirrors the right panel + (issue #52: below the app's overlay stack so popups win over the + workbench). */ .bottomPanel { position: fixed; bottom: 0; - z-index: 50; + z-index: 40; display: flex; flex-direction: column; - background: var(--dsw-specific-sidebar-fill); + background: var(--dsw-alias-bg-layer-1); border-top: 1px solid var(--dsw-alias-border-l2); /* Expand/collapse slides the panel; height drags animate the height. */ transition: @@ -159,7 +182,7 @@ } /* The bottom panel's height drag: the top edge strip (mirror of the right - panel's width strip). */ + panel's width strip), centered on the border like the width strip. */ .bottomResize { position: absolute; left: 0; @@ -235,12 +258,18 @@ /* The shared corner (both panels open): the intersection of the right panel's left edge and the bottom panel's top edge. Horizontal drags resize the width, vertical drags the height — the two panels drag - against each other. */ + against each other. Rendered INSIDE the right panel and positioned + relative to it (left edge + the bottom panel's height from the layout + variable), so no JS-written viewport coordinates are involved. The + z-index is panel-local (above the panel's content; the panel itself sits + at 40, below the app's overlay stack). */ .cornerHandle { - position: fixed; + position: absolute; + left: -6px; + bottom: calc(var(--dsh-sidebar-height, 0px) + 6px); width: 12px; height: 12px; - z-index: 52; + z-index: 2; cursor: nwse-resize; touch-action: none; } diff --git a/src/client/theme.ts b/src/client/theme.ts index b77af10..789986c 100644 --- a/src/client/theme.ts +++ b/src/client/theme.ts @@ -30,6 +30,72 @@ export function tokenValue(name: string): string { return getComputedStyle(document.body).getPropertyValue(name).trim() } +/** Minimal alpha for a token color to count as effectively opaque. Skin + * systems turn `--dsw-alias-bg-base` translucent for glass panels (the + * dsh-web-ui skins use rgba 0.16–0.7; `transparent` is 0); below this + * floor a text surface (terminal, editor) would render over the skin's + * backdrop art, so callers fall back to an opaque color. Values at or + * above the floor (e.g. a skin's scoped 0.96 porcelain) pass through — + * the skin still controls the surface. */ +const OPAQUE_ALPHA_MIN = 0.9 + +/** The alpha channel of a computed CSS color, or null when the format is + * not parseable (named colors, `color()`… — treated as opaque). Handles + * the shapes getComputedStyle actually returns: the rgb()/rgba() and + * hsl()/hsla() function forms (comma or space syntax, with or without the + * `/ alpha` slot) and the #rgb/#rgba/#rrggbb/#rrggbbaa hex family. */ +export function colorAlpha(color: string): number | null { + const s = color.trim() + // #rgb / #rgba / #rrggbb / #rrggbbaa + const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(s) + if (hex !== null) { + const digits = hex[1]! + if (digits.length === 3 || digits.length === 4) { + const a = digits.length === 4 ? digits[3]! : 'f' + return parseInt(a + a, 16) / 255 + } + const alphaHex = digits.length === 8 ? digits.slice(6) : 'ff' + return parseInt(alphaHex, 16) / 255 + } + // rgb(r g b) / rgb(r g b / a) / rgba(r, g, b, a) — the trailing slot is alpha. + const fn = /^(rgba?|hsla?)\(([^)]+)\)$/i.exec(s) + if (fn !== null) { + const parts = fn[2]!.split(/[,\s/]+/).filter(Boolean) + const alphaPart = parts[3] + if (alphaPart === undefined) return 1 + const alpha = Number.parseFloat(alphaPart) + return Number.isFinite(alpha) ? alpha : 1 + } + return null +} + +/** + * A token value that actually PAINTS something — the guard for text + * surfaces (issue #90). Skin systems routinely set global tokens to + * `transparent` (glass skins) or translucent glass values (`rgba(…,0.16–0.7)`, + * e.g. the dsh-web-ui skins) — both are truthy strings, so callers using + * `|| fallback` never fire and the terminal/editor goes see-through over + * the skin's backdrop. This returns '' for visually inert values (unset + * keywords, transparent, and any color below the opacity floor) so the + * caller's fallback chain engages; effectively opaque values pass through. + */ +export function effectiveTokenValue(name: string): string { + const raw = tokenValue(name) + switch (raw) { + case '': + case 'transparent': + case 'initial': + case 'inherit': + case 'unset': + return '' + default: { + const alpha = colorAlpha(raw) + if (alpha !== null && alpha < OPAQUE_ALPHA_MIN) return '' + return raw + } + } +} + /** * Subscribe to color-scheme flips (the presenter toggles the body * attribute). The callback fires after the attribute changed; re-read the diff --git a/tests/e2e/drag-layout.e2e.ts b/tests/e2e/drag-layout.e2e.ts new file mode 100644 index 0000000..517f529 --- /dev/null +++ b/tests/e2e/drag-layout.e2e.ts @@ -0,0 +1,218 @@ +/** + * Drag-layout lane: the width drag must track the app shell 1:1 — the + * regression test for issue #92 ("主会话框左右抖动"). + * + * The layout push squeezes `#root` via `margin-right: var(--dsh-sidebar-width)` + * (layout.css), and layout.css disables that margin's transition while a drag + * is live via `body[data-dsh-sidebar-dragging]`. If the transition stays + * active during the drag (or the conversation lags the panel edge), the + * conversation visibly shakes at pointer cadence. This spec drives a real + * pointer drag on the width strip while a requestAnimationFrame sampler + * records, per frame: + * + * - the strip's x (the panel edge), + * - the conversation column's right edge (`#root`'s margin push lands + * exactly there), + * - whether `body[data-dsh-sidebar-dragging]` is set, + * - `#root`'s computed transition property/duration. + * + * Then it asserts the drag contract: + * 1. the dragging attribute is present during the drag; + * 2. `#root`'s transition is disabled (none) during the drag; + * 3. the conversation edge follows the panel edge monotonically (no + * oscillation) and 1:1 (total travel within a rounding epsilon). + * + * The server is booted by scripts/e2e-mount.sh; this spec only loads the + * page (same contract as mount.e2e.ts, using its own workspace so the two + * lanes never race on seeding). + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, expect, request, type APIRequestContext } from '@playwright/test' + +const BASE_URL = process.env.DSH_E2E_URL +if (!BASE_URL) { + throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)') +} + +/** This lane's own workspace (distinct from mount.e2e.ts's, lanes run serially + * but against the same server — never share seed paths). */ +const WORKSPACE_PATH = process.env.DSH_E2E_DRAG_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-drag-workspace') + +let api: APIRequestContext + +/** Seed one workspace + one session through the host's unary RPC surface. */ +async function seedSession(): Promise { + mkdirSync(WORKSPACE_PATH, { recursive: true }) + writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'drag lane\n') + const workspace = await api.post(`${BASE_URL}/api/workspace.create`, { + data: { type: 'client-request', rpcId: 'e2e-drag-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } }, + }) + expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true) + const workspaceBody = (await workspace.json()) as { + result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown } + } + expect(workspaceBody.result.ok).toBe(true) + const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId + + const session = await api.post(`${BASE_URL}/api/session.create`, { + data: { type: 'client-request', rpcId: 'e2e-drag-session', method: 'session.create', payload: { workspaceId } }, + }) + expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true) +} + +test.beforeAll(async () => { + api = await request.newContext({ baseURL: BASE_URL }) + await seedSession() +}) + +test.afterAll(async () => { + await api?.dispose() +}) + +interface FrameSample { + t: number + stripX: number + convoRight: number + dragging: boolean + transitionProperty: string + transitionDuration: string +} + +test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) + // The seeded session opens the right panel by default: the layout push + // variable becomes live once the panel mounts (session activation lags + // the shell render). + await expect + .poll(async () => ( + await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) + ), { timeout: 90_000 }) + .not.toBe('') + + // The width drag strip is the panel's left-edge hit strip. There is no + // dedicated hook (the skinning contract is token-driven), so locate it + // semantically: the only `cursor: col-resize` element in the sidebar. + const stripBox = await page.evaluate(() => { + const host = document.querySelector('[data-dsh-better-sidebar]') + if (host === null) return null + const found = [...host.querySelectorAll('*')] + .find(el => getComputedStyle(el).cursor === 'col-resize') + if (found === undefined) return null + const r = found.getBoundingClientRect() + return { x: r.x, y: r.y, width: r.width, height: r.height } + }) + expect(stripBox, 'the width drag strip must be present (cursor: col-resize)').not.toBeNull() + + // Dismiss whatever onboarding takeover is present (same dance as the mount + // lane), so the pointer can reach the strip without a masking overlay. + try { + await expect + .poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 }) + .toBeGreaterThan(0) + } catch { + console.warn('[e2e-drag] no onboarding takeover appeared; proceeding') + } + for (let round = 0; round < 8; round++) { + let dismissed = false + for (const name of ['Continue', 'Configure later']) { + const button = page.getByRole('button', { name, exact: true }).first() + if ((await button.count()) === 0) continue + try { + await button.click({ timeout: 4_000 }) + dismissed = true + await page.waitForTimeout(1_000) + } catch { + // Masked by a takeover stacked above; retry in the next round. + } + } + if (!dismissed) break + } + + // Instrument a per-frame sampler BEFORE the drag begins. + await page.evaluate(() => { + type Sample = FrameSample + const samples: Sample[] = [] + const strip = [...document.querySelectorAll('*')] + .find(el => getComputedStyle(el).cursor === 'col-resize') + // The conversation column: the grid item the layout push squeezes (the + // same nth-child(2) layout.css targets for the vertical push; its right + // edge is where the width push lands). + const center = document.querySelector('#root > div[data-slot="root"] > div > div:nth-child(2)') + const root = document.querySelector('#root') as HTMLElement + const loop = (): void => { + const s = strip?.getBoundingClientRect() ?? { left: 0 } + const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 } + const cs = getComputedStyle(root) + samples.push({ + t: performance.now(), + stripX: s.left, + convoRight: c.right, + dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'), + transitionProperty: cs.transitionProperty, + transitionDuration: cs.transitionDuration, + }) + requestAnimationFrame(loop) + } + requestAnimationFrame(loop) + ;(window as unknown as { __dragSamples: Sample[] }).__dragSamples = samples + }) + + const startX = stripBox!.x + stripBox!.width / 2 + const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60) + + await page.mouse.move(startX, startY) + await page.mouse.down() + // Drag LEFT in steps (widens the panel): the conversation edge must move + // LEFT in lockstep with the panel edge, monotonically, no oscillation. + for (let i = 1; i <= 14; i++) { + await page.mouse.move(startX - i * 10, startY, { steps: 2 }) + await page.waitForTimeout(40) + } + await page.mouse.up() + await page.waitForTimeout(400) + + const samples = await page.evaluate( + () => (window as unknown as { __dragSamples: FrameSample[] }).__dragSamples, + ) + expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20) + + // The drag must actually have moved the panel (sanity: the store committed). + const first = samples.find(s => s.dragging) + const last = [...samples].reverse().find(s => s.dragging) + expect(first, 'the dragging attribute must appear during the drag').toBeDefined() + expect(last!.stripX, 'the panel edge must have moved during the drag').toBeLessThan(first!.stripX - 40) + // The conversation-column selector must have matched (the push lands on it). + expect(last!.convoRight, 'the conversation edge must have moved with the drag').toBeLessThan(first!.convoRight - 40) + + // Contract 1 + 2: while dragging, the body attribute is set and #root's + // margin transition is disabled (computed `transition: none` reads as + // transition-property "none" with 0s duration; the non-dragging rule would + // compute to "margin-right" with the theme duration). + const draggingSamples = samples.filter(s => s.dragging) + expect(draggingSamples.length).toBeGreaterThan(5) + for (const sample of draggingSamples) { + expect(sample.transitionProperty, 'the margin transition must be off while dragging').toBe('none') + expect(sample.transitionDuration, 'the margin transition must be off while dragging').toBe('0s') + } + + // Contract 3: monotonic, 1:1 tracking. During a leftward drag both the + // strip x and the conversation right edge decrease; allow one frame of + // rAF-batching staleness (0 delta), never a reversal. + const tracked = draggingSamples.filter(s => s.t > first!.t) + for (let i = 1; i < tracked.length; i++) { + const stripDelta = tracked[i]!.stripX - tracked[i - 1]!.stripX + const convoDelta = tracked[i]!.convoRight - tracked[i - 1]!.convoRight + expect(stripDelta, 'the strip must move left during the drag').toBeLessThanOrEqual(2) + expect( + convoDelta, + `conversation edge reversed while dragging (jitter): strip ${stripDelta}px, conversation ${convoDelta}px`, + ).toBeLessThanOrEqual(2) + } + // Total travel in lockstep (rounding + one-frame staleness tolerance). + const stripTravel = first!.stripX - last!.stripX + const convoTravel = first!.convoRight - last!.convoRight + expect(Math.abs(convoTravel - stripTravel), 'conversation must track the panel edge 1:1').toBeLessThanOrEqual(8) +}) diff --git a/tests/e2e/mount.e2e.ts b/tests/e2e/mount.e2e.ts index 70f211e..c0412e1 100644 --- a/tests/e2e/mount.e2e.ts +++ b/tests/e2e/mount.e2e.ts @@ -134,6 +134,16 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async const tabBar = sidebar.locator('[title]') await expect(tabBar.first()).toBeAttached({ timeout: 90_000 }) + // The skinning contract is token-driven (AGENTS.md §8): the panels consume + // `--dsw-alias-bg-layer-1`, so switching a skin re-skins the sidebar with + // no per-skin code. The layout push variable must be live once the panel + // mounts (its absence would mean the panel never opened with the session). + await expect + .poll(async () => ( + await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) + ), { timeout: 90_000 }) + .not.toBe('') + // Crash-marker assertions shared by every step. const assertNoCrash = async (): Promise => { await expect diff --git a/tests/theme.spec.ts b/tests/theme.spec.ts new file mode 100644 index 0000000..0552779 --- /dev/null +++ b/tests/theme.spec.ts @@ -0,0 +1,82 @@ +/** + * Live theme-token access tests — issue #90's guard. + * + * `effectiveTokenValue` must treat visually inert values (CSS reset + * keywords, `transparent`, and any color below the opacity floor) as UNSET + * so callers' `|| fallback` chains fire. Skin systems set global tokens like + * `--dsw-alias-bg-base` to `transparent` or translucent glass values (the + * dsh-web-ui skins use rgba 0.16–0.7); without this guard a + * truthy-but-inert value would leave the terminal/editor see-through over + * the skin's backdrop. Effectively opaque values (>= 0.9 alpha, including a + * skin's scoped 0.96 porcelain) pass through so the skin still controls the + * surface. + */ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { colorAlpha, effectiveTokenValue, tokenValue } from '../src/client/theme.ts' + +afterEach(() => { + document.body.removeAttribute('style') +}) + +describe('colorAlpha', () => { + it('parses the hex family', () => { + expect(colorAlpha('#fff')).toBe(1) + expect(colorAlpha('#fff8')).toBeCloseTo(0x88 / 255) + expect(colorAlpha('#112233')).toBe(1) + expect(colorAlpha('#11223344')).toBeCloseTo(0x44 / 255) + }) + + it('parses rgb()/rgba() in comma and space syntax', () => { + expect(colorAlpha('rgb(1, 2, 3)')).toBe(1) + expect(colorAlpha('rgba(255, 255, 255, 0.45)')).toBeCloseTo(0.45) + expect(colorAlpha('rgb(255 255 255 / 0.7)')).toBeCloseTo(0.7) + }) + + it('parses hsl()/hsla()', () => { + expect(colorAlpha('hsl(200, 50%, 50%)')).toBe(1) + expect(colorAlpha('hsla(200, 50%, 50%, 0.3)')).toBeCloseTo(0.3) + }) + + it('treats unparseable formats as opaque', () => { + expect(colorAlpha('white')).toBeNull() + expect(colorAlpha('')).toBeNull() + }) +}) + +describe('effectiveTokenValue', () => { + it('passes through real opaque paint values verbatim', () => { + document.body.style.setProperty('--probe', '#112233') + expect(effectiveTokenValue('--probe')).toBe('#112233') + // Effectively opaque translucency (a skin's scoped 0.96 porcelain glass) + // is a deliberate surface choice — it passes through. + document.body.style.setProperty('--probe', 'rgba(10, 22, 54, 0.96)') + expect(effectiveTokenValue('--probe')).toBe('rgba(10, 22, 54, 0.96)') + }) + + it('treats transparent and CSS reset keywords as unset', () => { + for (const inert of ['transparent', 'initial', 'inherit', 'unset']) { + document.body.style.setProperty('--probe', inert) + expect(effectiveTokenValue('--probe'), inert).toBe('') + } + }) + + it('treats translucent glass values below the opacity floor as unset', () => { + // The dsh-web-ui skins set bg-base to rgba 0.16–0.7 for glass panels; + // the terminal must fall back to an opaque background there. + for (const glass of ['rgba(255, 255, 255, 0.45)', 'rgba(20, 26, 46, 0.7)', '#11223344']) { + document.body.style.setProperty('--probe', glass) + expect(effectiveTokenValue('--probe'), glass).toBe('') + } + }) + + it('treats a missing token as unset', () => { + expect(effectiveTokenValue('--never-defined')).toBe('') + }) + + it('tokenValue still returns the raw value', () => { + document.body.style.setProperty('--probe', 'transparent') + expect(tokenValue('--probe')).toBe('transparent') + expect(effectiveTokenValue('--probe')).toBe('') + }) +})