令牌驱动方案(主目标:与 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 全绿。
This commit is contained in:
@@ -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]` 内即可,避免全局改写影响宿主。
|
||||
- **布局变量**(写在 `<html>` 上,面板打开时有效):`--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 就是参考实现("吃狗粮"):
|
||||
|
||||
|
||||
@@ -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-<id>`),皮肤中心通过 `cordis.patch.yml` 切换激活;皮肤插件 client half 在 `document.body` 上打 `data-dsh-<skin>` 属性并注入 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 不依赖具体颜色)。
|
||||
@@ -90,7 +90,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
|
||||
title={t('refresh')}
|
||||
onClick={refresh}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
|
||||
|
||||
@@ -209,7 +209,7 @@ export function ExplorerView(props: {
|
||||
setRefreshTick(tick => tick + 1)
|
||||
}}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.explorerBody}>
|
||||
|
||||
@@ -297,7 +297,7 @@ export function GitView(props: {
|
||||
title={t('refresh')}
|
||||
onClick={() => { void refresh() }}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
+51
-49
@@ -462,7 +462,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// pointer up (clamping + persistence).
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const cornerRef = useRef<HTMLDivElement | null>(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 && (
|
||||
<div
|
||||
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
|
||||
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
@@ -814,6 +815,49 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
getTabBadge={tabBadgeOf}
|
||||
/>
|
||||
</div>
|
||||
{/*
|
||||
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 && (
|
||||
<div
|
||||
className={css.cornerHandle}
|
||||
data-dragging={draggingCorner || undefined}
|
||||
onPointerDown={(event) => {
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
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}
|
||||
>
|
||||
<div
|
||||
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
|
||||
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
@@ -896,50 +942,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
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 && (
|
||||
<div
|
||||
ref={cornerRef}
|
||||
className={css.cornerHandle}
|
||||
style={{
|
||||
left: window.innerWidth - state.width - 6,
|
||||
top: window.innerHeight - state.bottomHeight - 6,
|
||||
}}
|
||||
data-dragging={draggingCorner || undefined}
|
||||
onPointerDown={(event) => {
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
/** 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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<HTMLElement>('*')]
|
||||
.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<HTMLElement>('*')]
|
||||
.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)
|
||||
})
|
||||
@@ -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<void> => {
|
||||
await expect
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user