令牌驱动方案(主目标:与 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 全绿。
111 lines
4.7 KiB
TypeScript
111 lines
4.7 KiB
TypeScript
/**
|
||
* Live theme access for surfaces that cannot consume the token colors
|
||
* directly — xterm's palette and CodeMirror's theme extensions need concrete
|
||
* values, but the app's scheme flips at runtime (ui-layout's ThemePresenter
|
||
* projects prefers-color-scheme and the user's choice onto
|
||
* body[data-ds-dark-theme] and html { color-scheme }). This module reads the
|
||
* resolved scheme and token values, and notifies subscribers on flips, so
|
||
* the terminal and the editor re-theme in place instead of freezing in the
|
||
* scheme they happened to be created under.
|
||
*/
|
||
|
||
/** Whether the app shell resolved to the dark scheme.
|
||
*
|
||
* The presenter sets `html { color-scheme }` together with the body palette
|
||
* attribute, so a set color-scheme means the decision is authoritative (an
|
||
* absent attribute is then LIGHT even when the OS prefers dark — the user
|
||
* chose light). Before the presenter has run, fall back to the OS media
|
||
* query as the best guess.
|
||
*/
|
||
export function isDarkScheme(): boolean {
|
||
if (typeof document === 'undefined') return true
|
||
const decided = document.documentElement.style.colorScheme !== ''
|
||
if (decided) return document.body.hasAttribute('data-ds-dark-theme')
|
||
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches
|
||
}
|
||
|
||
/** One token's computed value on <body> ('' while the theme has not applied). */
|
||
export function tokenValue(name: string): string {
|
||
if (typeof document === 'undefined') return ''
|
||
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
|
||
* scheme inside it.
|
||
* @returns the disposer.
|
||
*/
|
||
export function subscribeColorScheme(callback: () => void): () => void {
|
||
if (typeof document === 'undefined') return () => {}
|
||
const observer = new MutationObserver(() => { callback() })
|
||
observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
|
||
return () => { observer.disconnect() }
|
||
}
|