令牌驱动方案(主目标:与 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user