feat: 皮肤兼容(令牌驱动)+ CSS 修复(#106 #105 #90 #60,附带 #52 #57 #92) (#110)

令牌驱动方案(主目标:与 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:
Atlas Zeng
2026-08-16 16:37:11 +08:00
committed by GitHub
parent fd9544f16b
commit 3c196fffc3
12 changed files with 569 additions and 70 deletions
+218
View File
@@ -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)
})
+10
View File
@@ -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
+82
View File
@@ -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('')
})
})