/**
* dsh-liquid-glass · Client 半体
*
* 分层玻璃(v1.9.2):**中间工作区全透明,两侧栏与浮层磨砂玻璃**。
*
* 三个关键设计:
* 1. 折射用 SVG 滤镜(feTurbulence + feDisplacementMap),背景会真的弯折,
* 而不是单纯糊掉 —— 这是液态玻璃和普通毛玻璃的区别。
* 2. 目标元素靠**运行时 DOM 探测**定位:能拿到的稳定钩子(DSH 自己的
* data-* 属性、AppFrame 的列类名、UI 包里的模块类名)优先,
* 拿不到就用**几何特征**兜底。哈希类名(`LdtX1G_xxx`)永远不当选择器。
* 3. 面板颜色全部**从当前主题令牌派生**,浅色/深色主题都不会压掉文字对比度。
*
* 为什么中间全透明、两侧要磨砂:
* 全透明(v1.8 的老行为)会让侧栏文字直接压在壁纸上,两块字叠在一起看不清。
* 所以中间工作区保持全透明(壁纸完整透出来,也是视觉焦点),
* 左栏 / 右栏 / 审批卡 / 弹窗各给一档**模糊 + 淡色玻璃**,文字才压得住。
*/
/* 模块加载器一碰这个文件就留痕(先于 factory 执行)。
用来区分「文件没被送到浏览器」和「factory 没被调用」——
这两种情况的表象完全一样(界面毫无变化),只能靠留痕分辨。 */
try {
if (!window.__DSHLG_LOAD__) window.__DSHLG_LOAD__ = [];
window.__DSHLG_LOAD__.push({ at: Date.now(), href: location.href, hasLoader: !!window.__ModuleLoader__ });
} catch { /* 忽略 */ }
window.__ModuleLoader__.load({
id: '@local/dsh-liquid-glass',
factory() {
try { window.__DSHLG_FACTORY__ = (window.__DSHLG_FACTORY__ || 0) + 1; } catch { /* 忽略 */ }
/* 加载期最外层兜底:factory 里任何未捕获异常都不能冒泡出去,
否则 DSH 会判「entry 未激活」并影响同一次 boot 的其他插件。 */
try {
/* 版本号与 package.json 保持一致(2026-09-28 统一:之前两边分叉成 2.5.2 / 1.10.0)。 */
const VERSION = '2.6.0';
const STYLE_ID = 'dshlg-critical'; // 内联兜底材质
const STYLE_ID_DYN = 'dshlg-style'; // 运行时动态部分(令牌/几何相关)
const STYLE_LINK_ID = 'dshlg-stylesheet';
const SVG_ID = 'dshlg-svg';
const FILTER_REF = 'dshlg-refract';
const BG_ID = 'dshlg-backdrop';
const WALL_ID = 'dshlg-wall';
const CTRL_ID = 'dshlg-controls';
const GALLERY_ID = 'dshlg-gallery';
const RESTORE_ID = 'dshlg-restore'; // 控制条收起后的「找回」入口
const SETTINGS_ID = 'dshlg-settings'; // 设置面板
const GEAR_ID = 'dshlg-gear'; // 打开设置面板的齿轮按钮
const SETTINGS_KEY = 'dshlg.settings'; // 本地持久化键名
/**
* 请求重画一遍的**模块级**入口。
*
* 为什么需要它:pass() 是 applyGlass 内部的局部函数,而设置面板/拖动这些
* 代码位于模块级作用域 —— 直接调用 pass() 会抛 ReferenceError
* (实测:点壁纸卡片报「pass is not defined」,设置改完也不重绘)。
* 这里放一个回调,由 applyGlass 在 pass 定义好之后赋值给它。
*/
let requestPass = () => { /* applyGlass 启动后会被替换 */ };
const SAPPHIRE_ID = 'dshlg-sapphire-defs'; // 深蓝宝石渐变定义容器
const FADE_TOP_ID = 'dshlg-fade-top'; // 页面顶部渐隐带
const FADE_BOTTOM_ID = 'dshlg-fade-bottom'; // 页面底部渐隐带
const BANNER_ID = 'dshlg-banner';
const CLS_GLASS = 'dshlg-glass';
const CLS_TOOLBAR = 'dshlg-toolbar';
const CLS_SIDE = 'dshlg-side';
const CLS_RIGHT = 'dshlg-right';
const CLS_APPROVAL = 'dshlg-approval';
const CLS_MODAL = 'dshlg-modal';
const CLS_BAR = 'dshlg-bar'; // 左栏会话行(和右栏同款玻璃)
const CLS_BRAND = 'dshlg-brand'; // 左上角品牌区(蓝色液态玻璃)
/* ══════════════════════════════════════════════════════════════
* 控制中心(M0 外壳 + M1 工作区管理)
*
* 为什么整块放在**模块级**而不是塞进 applyGlass():
* · applyGlass 已经是 700+ 行的单个函数,再塞进去没法维护;
* · 模块级函数**不能**引用 applyGlass 内部的局部量(如 pass)——
* 本项目踩过「点了必抛 ReferenceError」的坑,所以这里只走
* 已经存在的模块级挂钩 requestPass()。
* · 不引入任何新的色调变量:材质全部复用已有的 --dshlg-bar-tone
* 与 --dshlg-panel-veil / --dshlg-panel-ink(和设置面板同一套)。
* · 面板本体**不做 backdrop-filter**(全屏/容器上的 backdrop 会闪,
* 而且会变成 fixed 后代的包含块)——玻璃感由描边 + 顶光给出,
* 与控制条 / 设置面板完全一致。
* ══════════════════════════════════════════════════════════════ */
const CC_ID = 'dshlg-cc'; // 面板本体
const CC_BACKDROP_ID = 'dshlg-cc-backdrop'; // 面板外部的点击兜底面
const CC_POS_KEY = 'cc'; // 位置记忆键(与 'bar' / 'gear' 同一套)
/* ══ 控制中心错误埋点 ═══════════════════════════════════════════
用户报「点任何位置都没反应(包括关闭)」—— 本项目有「静默异常」前科:
模块级函数引用了 applyGlass 内部的标识符、或只插了调用没插定义,
都会让点击 handler 一进去就抛,表现就是「点了像没点」。
这里把四个入口都包起来,错误落到 window.__DSHLG_CC_ERR(**不冒到
window**,否则 settings.test.mjs 的「插件无异常」会变红)。
同时记 where / when / target / count,便于判断是哪一个入口。 */
const ccErr = { count: 0, where: '—', message: '', stack: '', when: '', target: '' };
try { window.__DSHLG_CC_ERR = ccErr; } catch { /* 忽略 */ }
function ccNoteError(where, error, event) {
ccErr.count += 1;
ccErr.where = where;
ccErr.message = String((error && error.message) || error);
ccErr.stack = String((error && error.stack) || '').split('\n').slice(0, 6).join(' | ');
ccErr.when = new Date().toISOString();
try {
const t = event && event.target;
ccErr.target = t ? (t.tagName || '?') + (t.className && typeof t.className === 'string' ? '.' + t.className.split(' ')[0] : '')
+ (t.dataset && t.dataset.ccAct ? '[act=' + t.dataset.ccAct + ']' : '')
+ (t.dataset && t.dataset.ccWs ? '[ws=' + t.dataset.ccWs + ']' : '') : '—';
} catch { ccErr.target = '?'; }
try { console.error('[dsh-liquid-glass] 控制中心 ' + where + ' 失败:', error); } catch { /* 忽略 */ }
}
/* 控制中心 API 基址。
⚠️ 千万不能用同源相对路径:DSH 页面的同源服务器上没有这个路由
(用户实测面板显示「控制服务未连接 · HTTP 404」)。
宿主半体把 /dshlg-control/* 挂在**插件自己的 3932x 本地服务**上,
所以要在**调用时**取当时探到的端口。
ccPort 是模块级的:控制中心是模块级代码,看不到 applyGlass 里的 wallPort。 */
let ccPort = null;
const CC_API_FALLBACK = '/dshlg-control';
function ccApi() {
return ccPort
? 'http://127.0.0.1:' + ccPort + '/dshlg-control'
: CC_API_FALLBACK;
}
const CC_TABS = [
{ id: 'workspaces', label: '工作区' },
{ id: 'sessions', label: '会话' },
{ id: 'plugins', label: '插件' },
{ id: 'system', label: '系统' },
];
/* 面板状态集中在一个对象里:散落的模块级 let 最容易出现
「只插了调用没插定义」,集中一处一眼能看全,也不容易撞名。 */
const ccState = {
tab: 'workspaces',
lastFocus: null,
workspaces: null, // null = 还没拉到
workspacesError: null,
sessions: null,
sessionsError: null,
health: null,
sortAsc: true,
showArchived: false,
wizard: null, // { step:'path'|'name', path, name, error }
confirm: null, // { kind, id, name, sessions } 危险动作二次确认
renamingId: null,
busy: false,
notice: null, // 人话提示(成功 / 失败都走它)
renderError: null, // 错误边界:分页渲染抛异常时记这里
sig: '', // 上一次渲染的状态签名(相同就整段跳过)
/* 插件市场(2026-09-27 新增)。list=null 表示还没加载过清单 */
market: {
at: 0, // 清单时间戳(缓存判活用)
list: null, // 精简后的清单(见 ccMarketTrim)
categories: null, // { id: { zh, en } }
loading: false,
error: '', // 清单加载失败的人话原因
cached: false, // 这份是缓存来的(界面上标注出来)
triedCache: false, // 是否已经试过读本地缓存(只试一次)
q: '', // 搜索词
cat: '', // 分类筛选('' = 全部)
sort: 'downloads', // downloads | stars | added
shown: 40, // 当前渲染多少条(点「显示更多」翻倍)
},
};
/**
* 渲染签名。只把**影响这个分页内容**的状态放进来:
* 数据(工作区/会话/健康)+ 当前分页 + 排序筛选 + 向导/确认/重命名
* 的行身份 + 提示文案。不含 busy(避免转圈时反复重建 DOM)。
* 与本项目在控制条上用的 state-stamp 是同一个思路。
*/
function ccSig() {
const j = (v) => { try { return JSON.stringify(v); } catch { return 'x'; } };
const w = ccState.workspaces;
return [
ccState.tab,
ccState.sortAsc ? 'a' : 'd',
ccState.showArchived ? 'h' : 'v',
w === null ? 'w:null' : 'w:' + j(w.map((x) => [x.id, x.name, x.path, x.current ? 1 : 0, x.pinned ? 1 : 0, x.archived ? 1 : 0, x.sessionCount])),
ccState.workspacesError ? 'werr' : '',
ccState.sessions === null ? 's:null' : 's:' + j(ccState.sessions.map((x) => [x.id, x.severity, x.title])),
ccState.sessionsError ? 'serr' : '',
ccState.health ? 'hl:' + (ccState.health.ok ? 'ok' : 'bad') + ':' + (ccState.health.version || '') : 'hl:null',
ccState.notice ? ccState.notice.kind + ':' + ccState.notice.text : '',
ccState.wizard ? 'wz:' + ccState.wizard.step + ':' + (ccState.wizard.error || '') : '',
ccState.confirm ? 'cf:' + ccState.confirm.kind + ':' + ccState.confirm.id : '',
ccState.renamingId ? 'rn:' + ccState.renamingId : '',
ccState.renderError || '',
/* 插件市场:清单/筛选/搜索任何一项变了都要重绘 */
'mk:' + ccState.market.at
+ ':' + (ccState.market.list ? ccState.market.list.length : -1)
+ ':' + (ccState.market.loading ? 1 : 0)
+ ':' + ccState.market.error
+ ':' + ccState.market.q
+ ':' + ccState.market.cat
+ ':' + ccState.market.sort
+ ':' + ccState.market.shown,
].join('|');
}
/* 健康状态的短缓存:避免每次切分页都打一次宿主 */
const ccCache = { health: null, healthAt: 0, healthTried: 0 };
/* ── 小工具(全部定义在使用点之前)──────────────────────────── */
/** 转义后放进 innerHTML。 */
function ccEsc(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* 错误人话化:宿主返回 { ok:false, error:{ code, message } }。
* 认不出的 code 就把原文附上 —— 绝不显示「操作失败」这种没信息量的话。
*/
function ccHumanError(error) {
const code = String((error && error.code) || '').toLowerCase();
const raw = String((error && error.message) || error || '').trim();
const map = {
name_taken: '这个名字已经被别的空间用了,换一个。',
duplicate: '这个名字已经被别的空间用了,换一个。',
exists: '这个名字或目录已经存在了。',
invalid_name: '名字不合法:不能为空,也不能包含 \\ / : * ? " < > | 这些字符。',
bad_name: '名字不合法:不能为空,也不能包含 \\ / : * ? " < > | 这些字符。',
not_found: '找不到这个空间,可能已经被改名或归档了。刷新一下列表。',
no_such_workspace: '找不到这个空间,可能已经被改名或归档了。刷新一下列表。',
enoent: '目录不存在,或者当前账号没有权限读它。',
eacces: '没有权限访问这个目录。',
eperm: '没有权限访问这个目录。',
eexist: '目标目录已经存在了。',
not_a_directory: '这个路径不是一个目录。',
enotdir: '这个路径不是一个目录。',
unsupported: '当前宿主版本还不支持这个操作(需要升级宿主半体)。',
not_implemented: '当前宿主版本还没有实现这个操作。',
timeout: '宿主没有在预期时间内响应,稍后再试。',
offline: '控制服务未连接,稍后再试。',
busy: '宿主正忙,稍后再试。',
internal: '宿主内部出错,看 DSH 日志能拿到细节。',
};
if (code && map[code]) return map[code];
if (/ENOENT/i.test(raw)) return '目录不存在,或者当前账号没有权限读它。';
if (/EACCES|EPERM/i.test(raw)) return '没有权限访问这个目录。';
if (/EEXIST/i.test(raw)) return '目标目录已经存在了。';
if (/fetch|network|failed to load|连接/i.test(raw)) return '控制服务未连接:' + (raw || '网络请求失败');
return raw || '未知错误(宿主没有给出原因)。';
}
/** 统一取 JSON。返回 { ok, data, error },**绝不抛异常**。 */
async function ccFetchJson(url, options) {
try {
const res = await fetch(url, Object.assign({ cache: 'no-store' }, options || {}));
if (!res.ok) {
return { ok: false, error: { code: 'http_' + res.status, message: 'HTTP ' + res.status } };
}
const data = await res.json().catch(() => null);
if (data === null) return { ok: false, error: { code: 'bad_json', message: '返回内容不是 JSON' } };
return { ok: true, data };
} catch (error) {
return { ok: false, error: { code: 'offline', message: String((error && error.message) || error) } };
}
}
/** 宿主健康状态(缓存 8 秒;force=true 给「重试」按钮用)。 */
async function ccLoadHealth(force) {
const now = Date.now();
if (!force && ccCache.health && now - ccCache.healthAt < 8000) return ccCache.health;
if (!force && ccCache.healthTried && now - ccCache.healthTried < 8000) return ccCache.health;
ccCache.healthTried = now;
const out = await ccFetchJson(ccApi() + '/health');
ccCache.health = out.ok
? {
ok: out.data && out.data.ok !== false,
version: out.data && out.data.version,
services: (out.data && out.data.services) || {},
/* 宿主会挑出不可用的官方服务名,直接透出去给「系统」页显示
(实测 /dshlg-control/health 顶层字段含 degraded 与 carrier) */
degraded: Array.isArray(out.data && out.data.degraded) ? out.data.degraded : [],
carrier: (out.data && out.data.carrier) || '',
}
: { ok: false, error: out.error };
ccCache.healthAt = Date.now();
ccState.health = ccCache.health;
return ccCache.health;
}
/**
* 把「宿主返回体」里的数组取出来。
*
* 宿主的路由统一是 { ok, ...payload } 的形状(health / workspaces /
* sessions 都是),数组在 payload 的某个字段里,**不是**顶层数组。
* 这里做容错:数组直接用;对象按已知字段名依次试。
* 找不到就返回 null,让调用方走「形状不认识」的降级提示 —— 而不是
* 静默变成空列表(上一版就是这样:页面显示「共 0 个」,而宿主明明有 2 个)。
*/
function ccPickArray(data, keys) {
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return null;
for (const k of keys) {
if (Array.isArray(data[k])) return data[k];
}
return null;
}
async function ccLoadWorkspaces() {
const out = await ccFetchJson(ccApi() + '/workspaces');
if (!out.ok) {
ccState.workspaces = null;
ccState.workspacesError = out.error;
return null;
}
const list = ccPickArray(out.data, ['workspaces', 'items', 'list', 'data', 'rows']);
if (list === null) {
ccState.workspaces = null;
ccState.workspacesError = { code: 'bad_shape', message: '返回体里找不到工作区数组(顶层字段:' + Object.keys(out.data || {}).join(', ') + ')' };
return null;
}
ccState.workspaces = list;
ccState.workspacesError = null;
return ccState.workspaces;
}
async function ccLoadSessions() {
const out = await ccFetchJson(ccApi() + '/sessions/inspect');
if (!out.ok) {
ccState.sessions = null;
ccState.sessionsError = out.error;
return null;
}
const list = ccPickArray(out.data, ['sessions', 'items', 'list', 'data', 'rows']);
if (list === null) {
ccState.sessions = null;
ccState.sessionsError = { code: 'bad_shape', message: '返回体里找不到会话数组(顶层字段:' + Object.keys(out.data || {}).join(', ') + ')' };
return null;
}
ccState.sessions = list;
ccState.sessionsError = null;
return ccState.sessions;
}
/* ── 面板的建 / 开 / 关 ───────────────────────────────────── */
function ccEl() { return document.getElementById(CC_ID); }
function ccIsOpen() {
const el = ccEl();
return !!el && el.hasAttribute('data-dshlg-cc-open');
}
function ccSetNotice(kind, text) {
ccState.notice = text ? { kind, text } : null;
}
/** 面板壳:标题 + 可选动作按钮(用 DOM 追加,不重写 innerHTML)。 */
function ccShell(titleText, actionHtml) {
const wrap = document.createElement('div');
wrap.className = 'cc-sec';
const h = document.createElement('h4');
h.textContent = titleText;
wrap.appendChild(h);
if (actionHtml) {
const box = document.createElement('div');
box.innerHTML = actionHtml;
wrap.appendChild(box);
}
return wrap;
}
function ccNoticeNode() {
const note = ccState.notice;
if (!note) return null;
const div = document.createElement('div');
div.className = 'cc-note ' + (note.kind === 'error' ? 'cc-err' : 'cc-ok');
div.textContent = note.text;
return div;
}
/** 降级页:宿主不可达时给人话,而不是空白面板。 */
function ccDegradedNode(what, error) {
const box = document.createElement('div');
box.className = 'cc-degraded';
const svc = (ccState.health && ccState.health.services) || {};
const lines = Object.keys(svc).map((k) => k + (svc[k] ? ' ✓' : ' ✗'));
box.innerHTML = '控制服务未连接'
+ '
' + ccEsc(what) + '暂时不可用。'
+ (error ? '原因:' + ccEsc(ccHumanError(error)) : '')
+ '
'
+ '当前可用:'
+ ccEsc(lines.length ? lines.join(' ') : '(宿主没有上报服务清单)') + '
'
+ ''
+ ''
+ '需要宿主半体(index.js)提供 ' + ccEsc(ccApi()) + ' 路由'
+ '
';
return box;
}
/* ── 分页 1:工作区(M1)──────────────────────────────────── */
function ccTabWorkspaces() {
const frag = document.createDocumentFragment();
const notice = ccNoticeNode();
if (notice) frag.appendChild(notice);
if (ccState.workspacesError) {
frag.appendChild(ccDegradedNode('工作区列表', ccState.workspacesError));
return frag;
}
if (ccState.workspaces === null) {
const loading = document.createElement('div');
loading.className = 'cc-empty';
loading.textContent = '正在读取工作区…';
frag.appendChild(loading);
void ccLoadWorkspaces().then(() => { if (ccIsOpen()) ccRender(); });
return frag;
}
const bar = document.createElement('div');
bar.className = 'cc-actions';
bar.innerHTML =
''
+ ''
+ ''
+ ''
+ '共 ' + ccState.workspaces.length + ' 个';
frag.appendChild(bar);
if (ccState.wizard) frag.appendChild(ccWizardNode());
const list = ccState.workspaces
.filter((w) => ccState.showArchived || !w.archived)
.slice()
.sort((a, b) => {
if (!!b.pinned !== !!a.pinned) return (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0);
const r = String(a.name || '').localeCompare(String(b.name || ''), 'zh-Hans-CN');
return ccState.sortAsc ? r : -r;
});
if (list.length === 0) {
const empty = document.createElement('div');
empty.className = 'cc-empty';
empty.textContent = ccState.workspaces.length === 0 ? '宿主没返回任何工作区。' : '当前筛选下没有工作区。';
frag.appendChild(empty);
}
for (const w of list) frag.appendChild(ccWorkspaceRow(w));
const tail = document.createElement('div');
tail.className = 'cc-tail';
tail.textContent = '删除一律只做「归档」—— 本项目还没验证过真删除的语义边界,所以不提供。';
frag.appendChild(tail);
return frag;
}
function ccWorkspaceRow(w) {
const row = document.createElement('div');
row.className = 'cc-row' + (w.current ? ' is-current' : '');
const id = String(w.id ?? '');
const chips = [];
if (w.current) chips.push('当前');
if (w.pinned) chips.push('置顶');
if (w.archived) chips.push('已归档');
chips.push('' + Number(w.sessionCount || 0) + ' 会话');
row.innerHTML = ''
+ '
' + ccEsc(w.name || '(未命名)') + '
'
+ '
' + ccEsc(w.path || '(无路径)') + '
'
+ '
'
+ '' + chips.join('') + '
';
const main = row.querySelector('.cc-main');
const acts = document.createElement('div');
acts.className = 'cc-actions';
acts.innerHTML =
(w.current ? '' : '')
+ ''
+ ''
+ (w.archived
? ''
: '');
main.appendChild(acts);
/* 危险动作二次确认:就地展开,并显示会影响多少个会话 */
if (ccState.confirm && String(ccState.confirm.id) === id) {
const c = ccState.confirm;
const box = document.createElement('div');
box.className = 'cc-confirm';
box.innerHTML = '确定要' + (c.kind === 'archive' ? '归档' : '取消归档') + '「' + ccEsc(c.name) + '」吗?'
+ '会影响 ' + Number(c.sessions || 0) + ' 个会话。'
+ (c.kind === 'archive' ? '归档不会删除任何文件,之后可以取消归档。' : '')
+ '
'
+ ''
+ ''
+ ''
+ '
';
main.appendChild(box);
}
/* 重命名:就地输入 */
if (ccState.renamingId === id) {
const form = document.createElement('div');
form.className = 'cc-form';
form.innerHTML = ''
+ ''
+ ''
+ ''
+ ''
+ '
';
main.appendChild(form);
}
return row;
}
function ccWizardNode() {
const wiz = ccState.wizard;
const box = document.createElement('div');
box.className = 'cc-confirm';
if (!wiz) return box;
if (wiz.step === 'path') {
box.innerHTML = '新建空间 · 第 1 步:选目录'
+ '填一个已存在目录的绝对路径。宿主会校验它是否存在、是不是目录、有没有权限。
'
+ '';
return box;
}
box.innerHTML = '新建空间 · 第 2 步:起名字'
+ '目录:' + ccEsc(wiz.path || '') + '
'
+ '';
return box;
}
/* ── 分页 2:会话 ─────────────────────────────────────────── */
function ccTabSessions() {
const frag = document.createDocumentFragment();
const intro = ccShell('会话巡检', '只读:列出有问题的会话和修复建议,本面板不会替你改会话。
');
frag.appendChild(intro);
const notice = ccNoticeNode();
if (notice) frag.appendChild(notice);
if (ccState.sessionsError) {
frag.appendChild(ccDegradedNode('会话巡检', ccState.sessionsError));
return frag;
}
if (ccState.sessions === null) {
const loading = document.createElement('div');
loading.className = 'cc-empty';
loading.textContent = '正在读取…';
frag.appendChild(loading);
void ccLoadSessions().then(() => { if (ccIsOpen()) ccRender(); });
return frag;
}
if (ccState.sessions.length === 0) {
const empty = document.createElement('div');
empty.className = 'cc-empty';
empty.textContent = '没有发现需要处理的会话。';
frag.appendChild(empty);
return frag;
}
const order = { high: 0, error: 0, warn: 1, medium: 1, low: 2, info: 3 };
const rows = ccState.sessions
.slice()
.sort((a, b) => (order[String(a.severity || 'info').toLowerCase()] ?? 9) - (order[String(b.severity || 'info').toLowerCase()] ?? 9));
for (const s of rows) {
const row = document.createElement('div');
row.className = 'cc-row';
const sev = String(s.severity || 'info').toLowerCase();
const chipCls = (sev === 'high' || sev === 'error') ? 'cc-bad' : ((sev === 'warn' || sev === 'medium') ? 'cc-warn' : '');
row.innerHTML = ''
+ '
' + ccEsc(s.title || s.id || '(无标题)') + '
'
+ '
' + ccEsc(s.symptom || '') + '
'
+ (s.suggestion ? '
建议:' + ccEsc(s.suggestion) + '
' : '')
+ '
'
+ '' + ccEsc(sev) + '
';
frag.appendChild(row);
}
const tail = document.createElement('div');
tail.className = 'cc-tail';
tail.textContent = '共 ' + rows.length + ' 条。';
frag.appendChild(tail);
return frag;
}
/* ── 分页 3:插件市场 ───────────────────────────────────────
2026-09-27 新增(用户要「一个能下载插件的地方」)。
三条硬约束(都查证过,不是猜的):
① **不自己实现安装**。DSH 自带官方插件管理器
(@deepseek-ai/dsh-client-ui-plugin-manager + 宿主 dsh-plugin-manager),
装插件时它在 profile 里跑 pnpm,并带「供应链校验 + 授权确认 + 风险提示」。
自己跑 pnpm = 重复实现一条安全关键路径,还可能改坏 profile 让 DSH 起不来。
所以本页只做:**找到 → 复制安装标识 → 打开官方管理器**,真正的安装由它执行。
② **清单不自己维护**。社区已有公开清单(4377 个插件、23 分类、中英说明、
star/下载量、权限能力与红线警告),ACAO:* 可以直接从页面拉。
想加自己的私藏清单,往下面 MARKET_URLS 再加一条 URL 就行。
③ **5MB 清单不静默拉**。第一次要用户点一下「加载清单」,之后 24h 内走本地缓存。
安全观:清单是第三方数据 → 一律 ccEsc 之后才进 innerHTML;
权限红线(capabilityRedLines)直接标红展示,不藏。 */
/** 清单来源:第一条是社区公开清单,第二条是你自己仓库里的补充清单(可以留空数组)。 */
const MARKET_URLS = [
'https://awesome-dsh-plugin.com/plugins.json',
'https://raw.githubusercontent.com/pure-serendipity-five/dsh-liquid-glass-studio/main/market/index.json',
];
const MARKET_CACHE_KEY = 'dshlg-market-v1';
const MARKET_TTL = 24 * 60 * 60 * 1000;
const MARKET_PAGE = 40; // 一次渲染多少条(4377 条全渲染会把 DOM 拖死)
/** 只保留界面要用的字段 —— 4377 条全字段存不进 localStorage 配额。 */
function ccMarketTrim(p) {
const d = (p && p.description) || {};
const desc = String((d && (d.zh || d.en)) || p.desc || p.title || '')
.replace(/\s+/g, ' ').slice(0, 180);
return {
name: String(p.name || ''),
owner: String(p.owner || ''),
url: String(p.url || ''),
page: String(p.page || ''),
cat: String(p.category || ''),
desc,
install: String(p.install || ''),
npm: p.npm ? String(p.npm) : '',
version: p.version ? String(p.version) : '',
stars: Number(p.stars || 0),
downloads: p.downloads == null ? null : Number(p.downloads),
added: String(p.added || ''),
caps: Array.isArray(p.capabilities) ? p.capabilities.slice(0, 8).map(String) : [],
red: Array.isArray(p.capabilityRedLines) ? p.capabilityRedLines.slice(0, 3).map(String) : [],
};
}
/** 从清单条目的 install 命令里取出「装什么」—— 官方管理器的「包名」框要的就是这一段。
社区清单的 install 形如:
dsh plugin --profile web add @anonyjcy/dsh-j-space
dsh plugin --profile web add github:CAI-MH/dsh-quality-review
dsh plugin --profile web add github:ayahunter/dsh-trail#path:/packages/bundle */
function ccMarketSpec(p) {
const raw = String((p && p.install) || '').trim();
const m = /\badd\s+(.+)$/.exec(raw);
let spec = m ? m[1].trim() : '';
spec = spec.replace(/^["']|["']$/g, '');
if (!spec && p) spec = p.npm || ('github:' + p.owner + '/' + p.name);
return spec;
}
function ccMarketCatLabel(id) {
const cats = ccState.market.categories;
const c = cats && cats[id];
return (c && (c.zh || c.en)) || id || '';
}
function ccTimeAgo(ts) {
if (!ts) return '未知';
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
if (s < 90) return s + ' 秒前';
if (s < 5400) return Math.round(s / 60) + ' 分钟前';
if (s < 172800) return Math.round(s / 3600) + ' 小时前';
return Math.round(s / 86400) + ' 天前';
}
/** 读本地缓存(不联网)。坏缓存当没有,绝不让它把面板带崩。 */
function ccMarketCacheLoad() {
try {
const raw = localStorage.getItem(MARKET_CACHE_KEY);
if (!raw) return false;
const o = JSON.parse(raw);
if (!o || !Array.isArray(o.list) || !o.list.length) return false;
ccState.market.list = o.list;
ccState.market.categories = o.categories || null;
ccState.market.at = Number(o.at || 0);
ccState.market.cached = true;
return true;
} catch { return false; }
}
/** 拉清单(多源合并;一条都没拿到就保住旧清单并把原因说清楚)。 */
async function ccLoadMarket(force) {
const mk = ccState.market;
if (mk.loading) return;
if (!force && mk.list && Date.now() - mk.at < MARKET_TTL) { mk.cached = true; return; }
mk.loading = true;
mk.error = '';
if (ccIsOpen()) ccRender();
const merged = [];
const seen = new Set();
const errs = [];
let got = 0;
for (const url of MARKET_URLS) {
try {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) { errs.push(url + ' → HTTP ' + res.status); continue; }
const data = await res.json();
const list = Array.isArray(data) ? data : ((data && Array.isArray(data.plugins)) ? data.plugins : []);
if (data && data.categories && !mk.categories) mk.categories = data.categories;
for (const p of list) {
if (!p || !p.name) continue;
const key = String(p.name) + '@' + String(p.owner || '');
if (seen.has(key)) continue;
seen.add(key);
merged.push(ccMarketTrim(p));
}
got += 1;
} catch (error) {
errs.push(url + ' → ' + String((error && error.message) || error));
}
}
mk.loading = false;
if (!got) {
mk.error = '清单没拉到:' + errs.join(';');
if (ccIsOpen()) ccRender();
return;
}
mk.list = merged;
mk.at = Date.now();
mk.cached = false;
mk.shown = MARKET_PAGE;
try {
localStorage.setItem(MARKET_CACHE_KEY, JSON.stringify({ at: mk.at, categories: mk.categories, list: merged }));
} catch { /* 配额不够就只留内存,不影响使用 */ }
if (ccIsOpen()) ccRender();
}
/** 筛选 + 排序(每次都用一份新数组,别把原清单顺序打乱)。 */
function ccMarketFiltered() {
const mk = ccState.market;
const list = mk.list || [];
const q = mk.q.trim().toLowerCase();
const out = [];
for (const p of list) {
if (mk.cat && p.cat !== mk.cat) continue;
if (q) {
const hay = (p.name + ' ' + p.owner + ' ' + p.desc + ' ' + p.npm).toLowerCase();
if (hay.indexOf(q) < 0) continue;
}
out.push(p);
}
const s = mk.sort;
out.sort((a, b) => {
if (s === 'stars') return (b.stars || 0) - (a.stars || 0);
if (s === 'added') return String(b.added).localeCompare(String(a.added));
if (s === 'name') return String(a.name).localeCompare(String(b.name));
return (b.downloads || 0) - (a.downloads || 0);
});
return out;
}
/** 复制到剪贴板。navigator.clipboard 在没焦点时会失败,所以留一条 execCommand 兜底。 */
async function ccCopyText(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* 落兜底 */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch { return false; }
}
/**
* 打开 DSH 自带的插件管理器(左侧栏那个「插件」入口)。
* 为什么用「找 DOM 入口并点它」:DSH 没对外暴露调起插件管理器的接口(查过 app.asar),
* 而它是官方唯一的安装入口。找不到就返回 false,让人话提示去引导,绝不硬点别的元素。
*/
function ccOpenPluginManager() {
const wanted = ['插件', 'Plugins', 'Plugin'];
const nodes = document.querySelectorAll('button, a, [role="button"], [role="menuitem"], [role="tab"]');
let fallback = null;
for (const el of nodes) {
const txt = String(el.textContent || '').replace(/\s+/g, '');
if (wanted.indexOf(txt) < 0) continue;
if (el.closest('#' + CC_ID) || el.closest('#' + CTRL_ID) || el.closest('#' + GEAR_ID)) continue;
if (el.closest('[data-conversation-region], [data-conversation-scroll]')) continue;
if (el.closest('[class*="sidebar"], [class*="Sidebar"], nav, [role="navigation"]')) {
try { el.click(); return true; } catch { /* 试下一个 */ }
}
if (!fallback) fallback = el;
}
if (fallback) { try { fallback.click(); return true; } catch { /* 放弃 */ } }
return false;
}
/** 外部链接:开不了就把地址复制走(宿主可能拦 window.open)。 */
function ccOpenLink(url) {
if (!url) return;
let opened = null;
try { opened = window.open(url, '_blank', 'noopener,noreferrer'); } catch { opened = null; }
if (!opened) {
void ccCopyText(url);
ccSetNotice('ok', '外部链接没打开(可能被宿主拦了),地址已复制到剪贴板:' + url);
ccRender();
}
}
/** 「安装」= 复制安装标识 + 打开官方管理器。**不**自己跑安装。 */
async function ccMarketInstall(spec, name) {
if (!spec) {
ccSetNotice('error', '这条清单没有安装标识,点「仓库」去看它的 README 吧。');
ccRender();
return;
}
const copied = await ccCopyText(spec);
const opened = ccOpenPluginManager();
ccSetNotice('ok',
(name ? name + ':' : '') + (copied ? '安装标识已复制(' : '请手动复制(')
+ spec + (copied ? ')' : ')')
+ (opened ? ',已打开 DSH 插件管理器,粘进「包名」框回车即可。'
: ',请点左侧栏的「插件」打开插件管理器,粘进「包名」框回车。')
+ '装完要完全退出 DSH(托盘右键 → 退出)再启动才生效。');
ccRender();
}
function ccMarketRow(p) {
const row = document.createElement('div');
row.className = 'cc-row';
const chips = [];
if (p.version) chips.push('v' + ccEsc(p.version) + '');
if (p.downloads != null) chips.push('↓ ' + p.downloads + '');
if (p.stars) chips.push('★ ' + p.stars + '');
const cat = ccMarketCatLabel(p.cat);
if (cat) chips.push('' + ccEsc(cat) + '');
const main = document.createElement('div');
main.className = 'cc-main';
main.innerHTML = '' + ccEsc(p.name)
+ ' ' + ccEsc(p.owner) + '
'
+ '' + ccEsc(p.desc || '(作者没写说明)') + '
'
+ '' + ccEsc(ccMarketSpec(p)) + '
';
const meta = document.createElement('div');
meta.className = 'cc-meta';
meta.innerHTML = chips.join('');
row.appendChild(main);
row.appendChild(meta);
const acts = document.createElement('div');
acts.className = 'cc-actions';
acts.innerHTML = ''
+ (p.url ? '' : '')
+ (p.page ? '' : '');
main.appendChild(acts);
/* 权限能力与红线:安全相关的信息不藏,直接摊开 */
if (p.red && p.red.length) {
const warn = document.createElement('div');
warn.className = 'cc-mk-desc';
warn.innerHTML = '权限红线 ' + ccEsc(p.red.join(';'))
+ (p.caps.length ? ' 能力:' + ccEsc(p.caps.join(' ')) + '' : '');
main.appendChild(warn);
} else if (p.caps.length) {
const caps = document.createElement('div');
caps.className = 'cc-mk-desc';
caps.innerHTML = '能力 '
+ ccEsc(p.caps.join(' ')) + '';
main.appendChild(caps);
}
return row;
}
function ccTabPlugins() {
const frag = document.createDocumentFragment();
const notice = ccNoticeNode();
if (notice) frag.appendChild(notice);
const mk = ccState.market;
/* ① 本插件自己的状态(出问题时一眼看全) */
const tagged = document.querySelectorAll('[data-dshlg-region]').length;
frag.appendChild(ccShell('本插件',
'版本v' + ccEsc(VERSION) + '
'
+ '运行期样式'
+ (document.getElementById(STYLE_ID_DYN) ? '已注入' : '未注入')
+ ' 静态材质:' + (document.getElementById(STYLE_LINK_ID) ? '已加载' : '内联兜底')
+ '
'
+ '已标记玻璃面' + tagged + ' 块
'));
/* ② 没缓存也没加载过:先说清楚拉多大、从哪拉,再由用户点 */
if (mk.list === null && !mk.loading && !mk.triedCache) {
mk.triedCache = true;
ccMarketCacheLoad();
}
if (mk.loading && mk.list === null) {
const box = document.createElement('div');
box.className = 'cc-empty';
box.textContent = '正在加载插件清单…(约 5 MB,第一次会慢一点)';
frag.appendChild(box);
return frag;
}
if (mk.list === null) {
frag.appendChild(ccShell('插件市场',
'还没加载插件清单。'
+ '
来源:' + ccEsc(MARKET_URLS[0]) + ''
+ '(约 5 MB,加载一次后本机缓存 24 小时)。
'
+ '
装插件走 DSH 官方插件管理器:本页只负责「找到 + 复制」,'
+ '真正的安装由它执行(它自己会做供应链校验与授权确认)。
'
+ (mk.error ? '
' + ccEsc(mk.error) + '
' : '')
+ '
'
+ ''
+ ''
+ '
'));
return frag;
}
/* ③ 已加载:工具栏 + 分类 + 排序 + 列表 */
const all = mk.list.length;
const filtered = ccMarketFiltered();
const shown = filtered.slice(0, mk.shown);
const cats = mk.categories ? Object.keys(mk.categories) : [];
const sortDefs = [['downloads', '按下载'], ['stars', '按星标'], ['added', '按收录'], ['name', '按名字']];
const catChips = ['']
.concat(cats.map((id) => ''));
const sortChips = sortDefs.map(([id, label]) => '');
const sec = ccShell('插件市场',
''
+ ''
+ ''
+ ''
+ '
'
+ '' + catChips.join('') + '
'
+ '' + sortChips.join('') + '
'
+ '共 ' + all + ' 个插件'
+ (mk.cached ? '(本地缓存,' + ccEsc(ccTimeAgo(mk.at)) + '更新的;点「刷新清单」可拉最新)' : '(在线,' + ccEsc(ccTimeAgo(mk.at)) + '更新)')
+ ' 筛选出 ' + filtered.length + ' 个'
+ (filtered.length > shown.length ? ',先显示前 ' + shown.length + ' 个' : '')
+ (mk.error ? ' ⚠ ' + ccEsc(mk.error) : '') + '
');
const list = document.createElement('div');
list.className = 'cc-list';
if (!filtered.length) {
const empty = document.createElement('div');
empty.className = 'cc-empty';
empty.textContent = '没有匹配的插件,换个词或点「全部」。';
list.appendChild(empty);
}
for (const p of shown) list.appendChild(ccMarketRow(p));
sec.appendChild(list);
if (filtered.length > shown.length) {
const more = document.createElement('div');
more.className = 'cc-actions';
more.innerHTML = '';
sec.appendChild(more);
}
frag.appendChild(sec);
const tip = document.createElement('div');
tip.className = 'cc-tail';
tip.textContent = '本页只负责「找到 + 复制安装标识」;真正安装由 DSH 官方插件管理器执行'
+ '(它会做供应链校验与授权确认)。装完要完全退出 DSH(托盘右键 → 退出)再启动才生效。';
frag.appendChild(tip);
return frag;
}
/* ── 分页 4:系统 ─────────────────────────────────────────── */
/**
* 壁纸诊断:一次把「壁纸层在哪 / 谁盖住了它」量出来。
*
* 为什么要有这个:2026-09-28 用户报「壁纸只显示在左上角一小块」,
* 而**连不上真机 DOM**(DSH 没开调试端口),复现台里又是正常的 ——
* 与其猜,不如让用户把这块截图发来,一次定位。
*/
function ccWallProbe() {
const vw = window.innerWidth;
const vh = window.innerHeight;
const vpArea = Math.max(1, vw * vh);
const out = {
viewport: [vw, vh],
brightWall: document.documentElement.getAttribute('data-dshlg-bright-wall'),
wall: null,
covering: [],
overlayLike: [],
tokens: {},
};
const w = document.getElementById(WALL_ID);
if (w) {
const r = w.getBoundingClientRect();
const c = getComputedStyle(w);
const frame = w.querySelector('iframe');
out.wall = {
rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
position: c.position, inset: c.inset, display: c.display,
opacity: c.opacity, zIndex: c.zIndex, background: c.backgroundColor,
parent: w.parentElement ? (w.parentElement.tagName + (w.parentElement.id ? '#' + w.parentElement.id : '')) : '?',
iframeSrc: frame ? String(frame.src || '').slice(0, 120) : '(没有 iframe)',
iframeRect: frame ? [Math.round(frame.getBoundingClientRect().width), Math.round(frame.getBoundingClientRect().height)] : null,
};
} else {
out.wall = { missing: true };
}
/* 找出「盖住 ≥35% 视口 且底色不透明」的元素 —— 壁纸就是被这些东西挡住的 */
try {
for (const el of document.querySelectorAll('#root *')) {
if (out.covering.length >= 12) break;
const r = el.getBoundingClientRect();
if (r.width * r.height < vpArea * 0.35) continue;
const c = getComputedStyle(el);
const m = String(c.backgroundColor).match(/rgba?\(([^)]+)\)/);
if (!m) continue;
const parts = m[1].split(',').map(Number);
const alpha = parts.length > 3 ? parts[3] : 1;
if (!(alpha >= 0.5)) continue;
out.covering.push({
el: el.tagName + '.' + String(el.className || '').split(' ').slice(0, 2).join('.'),
bg: c.backgroundColor,
size: Math.round(r.width) + 'x' + Math.round(r.height),
pos: c.position, z: c.zIndex,
});
}
} catch (error) {
out.covering = ['扫描失败:' + String((error && error.message) || error)];
}
/* 所有类名里带 _overlay / _scrim 的元素(本插件为它们写过规则) */
try {
for (const el of document.querySelectorAll('[class*="_overlay"], [class*="_scrim"]')) {
const c = getComputedStyle(el);
const r = el.getBoundingClientRect();
out.overlayLike.push({
el: String(el.className || '').split(' ').slice(0, 2).join('.'),
bg: c.backgroundColor,
size: Math.round(r.width) + 'x' + Math.round(r.height),
z: c.zIndex,
});
}
} catch { /* 忽略 */ }
try {
const cs = getComputedStyle(document.documentElement);
for (const k of ['--dsw-alias-bg-base', '--dsw-alias-bg-layer-1', '--dsw-alias-bg-mask-1', '--dsw-menu-surface-fill']) {
out.tokens[k] = String(cs.getPropertyValue(k) || '').trim() || '(空)';
}
} catch { /* 忽略 */ }
return out;
}
function ccTabSystem() {
const frag = document.createDocumentFragment();
const h = ccState.health;
if (!h) {
const loading = document.createElement('div');
loading.className = 'cc-empty';
loading.textContent = '正在读取宿主状态…';
frag.appendChild(loading);
void ccLoadHealth().then(() => { if (ccIsOpen()) ccRender(); });
return frag;
}
if (!h.ok) {
frag.appendChild(ccDegradedNode('宿主控制接口', h.error));
} else {
const svc = h.services || {};
const keys = Object.keys(svc);
frag.appendChild(ccShell('宿主',
'状态已连接
'
+ '宿主版本' + ccEsc(h.version || '(未上报)') + '
'
+ '服务'
+ (keys.length ? keys.map((k) => ccEsc(k) + (svc[k] ? ' ✓' : ' ✗')).join(' ') : '(宿主没有上报服务清单)')
+ '
'
+ '不可用服务'
+ ccEsc((h.degraded && h.degraded.length) ? h.degraded.join(' ') : '无') + '
'
+ '接口载体'
+ ccEsc(h.carrier || ccApi()) + '
'));
}
frag.appendChild(ccShell('应用',
'页面来源'
+ ccEsc(String(location.origin || location.href).slice(0, 120)) + '
'
+ '视口'
+ window.innerWidth + ' × ' + window.innerHeight + '
'
+ '材质来源'
+ ccEsc((stylesheetUrls && stylesheetUrls()[0]) || '(未知)') + '
'));
/* 壁纸诊断:壁纸显示不对时,把下面这块截图就够了(不用开 F12) */
const wallSec = ccShell('壁纸诊断',
'壁纸显示不对时,把下面这块整体截图给我即可 —— '
+ '它列出了壁纸层的位置、以及「盖住视口且不透明」的元素(就是挡住壁纸的那些)。
');
const pre = document.createElement('pre');
pre.className = 'cc-pre';
try {
pre.textContent = JSON.stringify(ccWallProbe(), null, 1);
} catch (error) {
pre.textContent = '诊断失败:' + String((error && error.message) || error);
}
wallSec.appendChild(pre);
frag.appendChild(wallSec);
const acts = document.createElement('div');
acts.className = 'cc-actions';
acts.innerHTML = ''
+ '';
frag.appendChild(acts);
const tail = document.createElement('div');
tail.className = 'cc-tail';
tail.textContent = '凭据 / 密钥管理刻意不做:宿主 HTTP 服务绝不承载任何密钥读写。';
frag.appendChild(tail);
return frag;
}
function ccTabNode(tabId) {
if (tabId === 'sessions') return ccTabSessions();
if (tabId === 'plugins') return ccTabPlugins();
if (tabId === 'system') return ccTabSystem();
return ccTabWorkspaces();
}
/* ── 渲染(含错误边界)────────────────────────────────────── */
/**
* 错误边界:任一分页渲染抛异常 → 只把**这一个分页**换成
* 「这个分页出错了 + 重试」,整页和另外几个分页都不受影响。
* 这里刻意自己 try/catch,绝不让异常冒到 window.onerror。
*/
function ccRender() {
try {
return ccRenderInner();
} catch (error) {
/* 这一层很关键:ccRender 开头那段(取 body/tabs、算签名、建 tab 按钮)
原来**没有**保护,那里一旦抛,整个点击就静默死掉。
注意这里不要引用 flk():本项目没有这个函数(曾因此差点插进来)。 */
ccNoteError('ccRender', error, null);
return undefined;
}
}
function ccRenderInner() {
const el = ccEl();
if (!el) return;
const body = el.querySelector('.cc-body');
const tabs = el.querySelector('.cc-tabs');
if (!body || !tabs) return;
/* 去重闸门:签名没变就一个节点都不碰。
面板关着时也一律不重绘 —— 关闭状态下重建 DOM 只会去动
一个 display:none 的子树,白烧帧,还可能带起合成层重绘。 */
const sig = ccSig();
if (!ccEl().hasAttribute('data-dshlg-cc-open') && ccState.sig) return;
if (sig === ccState.sig) return;
ccState.sig = sig;
tabs.replaceChildren();
/* 自适应可读性:复用设置面板那套(背后太亮/有文字 → 加厚 + 换深字)。
放在 rAF 里 —— 要先完成布局才能采样背后。 */
requestAnimationFrame(() => { try { ccAdaptReadability(el); } catch { /* 忽略 */ } });
for (const tab of CC_TABS) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = tab.label;
btn.dataset.ccTab = tab.id;
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', tab.id === ccState.tab ? 'true' : 'false');
if (tab.id === ccState.tab) btn.classList.add('is-on');
tabs.appendChild(btn);
}
ccState.renderError = null;
try {
body.replaceChildren(ccTabNode(ccState.tab));
} catch (error) {
ccState.renderError = String((error && error.message) || error);
console.error('[dsh-liquid-glass] 控制中心分页渲染失败(已隔离):', error);
const box = document.createElement('div');
box.className = 'cc-degraded cc-err';
box.innerHTML = '这个分页出错了'
+ '' + ccEsc(ccState.renderError) + '
'
+ ''
+ ''
+ '
';
body.replaceChildren(box);
}
}
/* ── 宿主动作(全部经 requestPass 挂钩,不引用 applyGlass 内部量)── */
async function ccPost(action, payload) {
const out = await ccFetchJson(ccApi() + '/workspace/' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {}),
});
if (!out.ok) { ccSetNotice('error', ccHumanError(out.error)); return false; }
if (out.data && out.data.ok === false) { ccSetNotice('error', ccHumanError(out.data.error)); return false; }
return true;
}
async function ccDoWorkspaceAction(kind, id) {
if (ccState.busy) return;
const list = ccState.workspaces || [];
const w = list.find((x) => String(x.id) === String(id)) || { id, name: id, sessionCount: 0 };
ccState.busy = true;
try {
if (kind === 'switch') {
const ok = await ccPost('switch', { id });
if (ok) {
ccSetNotice('ok', '已请求切换到「' + (w.name || id) + '」。');
await ccLoadWorkspaces();
} else {
/* 契约里没有 switch 端点:说人话 + 给退路,别只丢一个「不支持」 */
ccSetNotice('error', '宿主还不支持从插件里切换空间,请用 DSH 自己的工作区入口。'
+ '(这个能力要等宿主把 ' + ccApi() + '/workspace/switch 做出来)');
}
} else if (kind === 'pin') {
const ok = await ccPost('pin', { id, pinned: !w.pinned });
if (ok) { ccSetNotice('ok', (w.pinned ? '已取消置顶' : '已置顶') + '「' + (w.name || id) + '」'); await ccLoadWorkspaces(); }
} else if (kind === 'archive' || kind === 'unarchive') {
const ok = await ccPost('archive', { id, archived: kind === 'archive' });
if (ok) {
ccSetNotice('ok', (kind === 'archive' ? '已归档' : '已取消归档') + '「' + (w.name || id) + '」');
await ccLoadWorkspaces();
}
} else if (kind === 'rename') {
const input = document.querySelector('#' + CC_ID + ' [data-cc-rename-input]');
const name = input ? String(input.value || '').trim() : '';
if (!name) { ccSetNotice('error', '名字不能为空。'); return; }
const ok = await ccPost('rename', { id, name });
if (ok) {
ccSetNotice('ok', '已改名为「' + name + '」');
ccState.renamingId = null;
await ccLoadWorkspaces();
}
}
} finally {
ccState.busy = false;
ccState.confirm = null;
}
}
async function ccDoCreate() {
const wiz = ccState.wizard;
if (!wiz || ccState.busy) return;
ccState.busy = true;
try {
const ok = await ccPost('create', { path: wiz.path, name: wiz.name });
if (ok) {
ccState.wizard = null;
ccSetNotice('ok', '空间已创建:' + (wiz.name || wiz.path));
await ccLoadWorkspaces();
} else if (ccState.notice && ccState.notice.kind === 'error' && ccState.wizard) {
/* 把宿主的错误显示在向导里,用户不用抬头找提示 */
ccState.wizard.error = ccState.notice.text;
}
} finally {
ccState.busy = false;
}
}
/* ── 事件(面板只建一次,全部走委托)──────────────────────── */
function ccOnClick(event) {
try {
return ccOnClickInner(event);
} catch (error) {
ccNoteError('ccOnClick', error, event);
return undefined;
}
}
function ccOnClickInner(event) {
const el = ccEl();
if (!el || !event || !event.target || !event.target.closest) return;
/* 回执:handler 真的进来了就留痕(判断「点了没反应」时先看这个) */
try {
window.__DSHLG_CC_LAST = { at: Date.now(), target: String(event.target.tagName || '') };
} catch { /* 忽略 */ }
const tabBtn = event.target.closest('#' + CC_ID + ' .cc-tabs button');
if (tabBtn && tabBtn.dataset.ccTab) {
ccState.tab = tabBtn.dataset.ccTab;
ccState.notice = null;
ccState.renderError = null;
ccState.sig = ''; // 状态签名作废:强制重绘这一次
if (ccState.tab === 'sessions' && ccState.sessions === null && !ccState.sessionsError) void ccLoadSessions();
if (ccState.tab === 'system') void ccLoadHealth();
ccRender();
return;
}
const act = event.target.closest('[data-cc-act]');
if (act) {
const a = act.dataset.ccAct;
/* ── 插件市场 ─────────────────────────────────────────── */
if (a === 'mk-load') { void ccLoadMarket(true); return; }
if (a === 'mk-open-mgr') {
const ok = ccOpenPluginManager();
ccSetNotice(ok ? 'ok' : 'error', ok
? '已打开 DSH 插件管理器(左侧栏那个「插件」页)。把包名或仓库标识粘进去即可。'
: '没找到「插件」入口。请点左侧栏的「插件」,或重启一次 DSH 后再试。');
ccRender();
return;
}
if (a === 'mk-cat') {
ccState.market.cat = act.dataset.cat || '';
ccState.market.shown = MARKET_PAGE;
ccRender();
return;
}
if (a === 'mk-sort') {
ccState.market.sort = act.dataset.sort || 'downloads';
ccRender();
return;
}
if (a === 'mk-more') {
ccState.market.shown += MARKET_PAGE;
ccRender();
return;
}
if (a === 'mk-install') { void ccMarketInstall(act.dataset.mk || '', act.dataset.mkname || ''); return; }
if (a === 'mk-link') { ccOpenLink(act.dataset.url || ''); return; }
if (a === 'close') { ccClose(); return; }
if (a === 'retry-tab') { ccState.renderError = null; ccRender(); return; }
if (a === 'retry') {
ccCache.health = null; ccCache.healthAt = 0; ccCache.healthTried = 0;
ccState.workspaces = null; ccState.workspacesError = null;
ccState.sessions = null; ccState.sessionsError = null;
ccSetNotice(null, null);
void ccLoadHealth(true).then(() => { if (ccIsOpen()) ccRender(); });
return;
}
if (a === 'reload') {
ccState.workspaces = null;
ccSetNotice(null, null);
void ccLoadWorkspaces().then(() => { if (ccIsOpen()) ccRender(); });
return;
}
if (a === 'sort') { ccState.sortAsc = !ccState.sortAsc; ccRender(); return; }
if (a === 'show-archived') { ccState.showArchived = !ccState.showArchived; ccRender(); return; }
if (a === 'new') {
ccState.wizard = { step: 'path', path: '', name: '', error: null };
ccSetNotice(null, null);
ccRender();
return;
}
if (a === 'reset-pos') {
clearPos();
el.removeAttribute('style');
el.removeAttribute('data-dshlg-moved');
delete el.dataset.dshlgMoved;
ccRender();
return;
}
if (a === 'reload-health') { ccCache.healthAt = 0; void ccLoadHealth(true).then(() => { if (ccIsOpen()) ccRender(); }); return; }
if (a === 'log-report') {
try {
console.info('[dsh-liquid-glass] 控制中心诊断', {
version: VERSION,
health: ccState.health,
workspaces: ccState.workspaces,
sessions: ccState.sessions,
viewport: [window.innerWidth, window.innerHeight],
});
ccSetNotice('ok', '诊断信息已打到控制台(F12 → Console)。');
} catch (error) {
ccSetNotice('error', String((error && error.message) || error));
}
ccRender();
return;
}
}
const wizBtn = event.target.closest('[data-cc-wiz]');
if (wizBtn && ccState.wizard) {
const k = wizBtn.dataset.ccWiz;
if (k === 'cancel') { ccState.wizard = null; ccRender(); return; }
if (k === 'back') { ccState.wizard.step = 'path'; ccState.wizard.error = null; ccRender(); return; }
if (k === 'path-next') {
const input = el.querySelector('[data-cc-wiz-path]');
const path = input ? String(input.value || '').trim() : '';
if (!path) { ccState.wizard.error = '请填一个目录的绝对路径。'; ccRender(); return; }
if (!/^[a-zA-Z]:[\\/]/.test(path) && path.charAt(0) !== '/') {
ccState.wizard.error = '看起来不是绝对路径:Windows 上应形如 D:\\目录,Unix 上以 / 开头。';
ccRender();
return;
}
ccState.wizard.path = path;
ccState.wizard.step = 'name';
ccState.wizard.error = null;
ccRender();
return;
}
if (k === 'create') {
const input = el.querySelector('[data-cc-wiz-name]');
ccState.wizard.name = input ? String(input.value || '').trim() : '';
void ccDoCreate().then(() => { if (ccIsOpen()) ccRender(); });
return;
}
}
const cf = event.target.closest('[data-cc-confirm]');
if (cf) {
if (cf.dataset.ccConfirm === 'no') { ccState.confirm = null; ccRender(); return; }
const c = ccState.confirm;
if (!c) return;
const kind = c.kind;
const id = c.id;
ccState.confirm = null;
void ccDoWorkspaceAction(kind, id).then(() => { if (ccIsOpen()) ccRender(); });
return;
}
const ws = event.target.closest('[data-cc-ws]');
if (ws) {
const kind = ws.dataset.ccWs;
const id = ws.dataset.id;
if (kind === 'rename') { ccState.renamingId = id; ccState.notice = null; ccRender(); return; }
if (kind === 'rename-cancel') { ccState.renamingId = null; ccRender(); return; }
if (kind === 'rename-ok') { void ccDoWorkspaceAction('rename', id).then(() => { if (ccIsOpen()) ccRender(); }); return; }
if (kind === 'archive' || kind === 'unarchive') {
const w = (ccState.workspaces || []).find((x) => String(x.id) === String(id)) || {};
ccState.confirm = { kind, id, name: w.name || id, sessions: w.sessionCount || 0 };
ccRender();
return;
}
void ccDoWorkspaceAction(kind, id).then(() => { if (ccIsOpen()) ccRender(); });
}
}
/** Esc 通道。向导/重命名打开时,Esc 先退那一层而不是整个面板。 */
function ccOnKeydown(event) {
if (event.key !== 'Escape' && event.key !== 'Esc') return;
if (!ccIsOpen()) return;
const t = event.target;
const inPanel = t && t.closest && t.closest('#' + CC_ID);
if (inPanel && (ccState.wizard || ccState.renamingId)) {
ccState.wizard = null;
ccState.renamingId = null;
ccRender();
} else {
ccClose();
}
event.preventDefault();
event.stopPropagation();
}
/**
* 销毁:插件被 dispose 时由 applyGlass 的 cleanup 调用。
*
* 存在的理由:控制中心在 **document** 上挂了两个捕获监听
* (点外部 + Esc),而 cleanup 里那份 id 清单不认识 CC_ID /
* CC_BACKDROP_ID,也不会摘 document 级监听 —— 不在这里收拾,
* 插件卸载后会留下一对「对着已删节点做事的」全局监听。
*/
function ccTeardown() {
try { document.removeEventListener('pointerdown', ccOnOutsidePointer, true); } catch { /* 忽略 */ }
try { document.removeEventListener('keydown', ccOnKeydown, true); } catch { /* 忽略 */ }
for (const id of [CC_ID, CC_BACKDROP_ID]) {
try { document.getElementById(id)?.remove(); } catch { /* 忽略 */ }
}
ccState.sig = '';
}
/**
* 让控制中心面板也吃设置面板那套自适应可读性。
*
* adaptPanelReadability() 是写死给设置面板的(读 SETTINGS_ID、
* 把 --dshlg-panel-veil / --dshlg-panel-ink 写在那个元素上)。
* 这里**不改它**(改它要动 settings.test.mjs 正在测的那段),
* 而是临时借一下 id:在 rAF 里同步执行、没有 await,
* 所以借 id 的窗口就是一次函数调用,不会被别的事件插进来。
* 结果落到控制中心自己的行内变量上 —— 两个面板同一个材质,
* 但各自独立自适应,不会互相串。
*/
function ccAdaptReadability(el) {
const box = el || ccEl();
if (!box || box.hasAttribute('hidden')) return null;
const real = document.getElementById(SETTINGS_ID);
const owned = !real ? document.createElement('div') : null;
if (owned) { owned.id = SETTINGS_ID; owned.setAttribute('hidden', ''); document.body.appendChild(owned); }
const borrow = document.getElementById(SETTINGS_ID);
const mine = borrow !== box;
const backup = mine ? borrow.id : null;
if (mine) borrow.id = SETTINGS_ID + '-backup';
box.id = SETTINGS_ID;
try {
return adaptPanelReadability();
} finally {
box.id = CC_ID;
if (mine && backup) borrow.id = backup;
owned?.remove();
}
}
/**
* 插件市场搜索框:输入即筛,但要**防抖 250ms** ——
* 清单有 4000+ 条,逐字符重绘面板会卡;而且重绘会重建这个 input
* (焦点会丢),所以重绘后要把焦点和光标位置还回去。
*/
let ccSearchTimer = 0;
function ccMarketSearch(value) {
ccState.market.q = String(value || '');
ccState.market.shown = MARKET_PAGE;
ccRender();
/* 重绘后把焦点与光标还回搜索框,否则打第二个字就得重新点一次 */
try {
const box = ccEl();
const inp = box && box.querySelector('[data-cc-search]');
if (inp && document.activeElement !== inp) {
inp.focus({ preventScroll: true });
const n = String(inp.value || '').length;
if (typeof inp.setSelectionRange === 'function') inp.setSelectionRange(n, n);
}
} catch { /* 忽略 */ }
}
function ccOnInput(event) {
const t = event.target;
if (!t || !t.hasAttribute || !t.hasAttribute('data-cc-search')) return;
const value = String(t.value || '');
if (ccSearchTimer) { clearTimeout(ccSearchTimer); ccSearchTimer = 0; }
ccSearchTimer = setTimeout(() => { ccSearchTimer = 0; ccMarketSearch(value); }, 250);
}
/** 输入框里按 Enter = 点同组的确认按钮(键盘用户不用去够鼠标)。 */
function ccOnKeydownInner(event) {
if (event.key !== 'Enter') return;
const t = event.target;
if (!t || !t.closest) return;
const el = ccEl();
if (!el || !el.contains(t)) return;
/* 市场搜索框:Enter 立刻搜(跳过防抖) */
if (t.hasAttribute('data-cc-search')) {
if (ccSearchTimer) { clearTimeout(ccSearchTimer); ccSearchTimer = 0; }
ccMarketSearch(t.value);
return;
}
if (t.hasAttribute('data-cc-rename-input')) {
const ok = t.closest('.cc-form')?.querySelector('[data-cc-ws="rename-ok"]');
if (ok) { event.preventDefault(); ok.click(); }
return;
}
if (t.hasAttribute('data-cc-wiz-path')) {
const next = el.querySelector('[data-cc-wiz="path-next"]');
if (next) { event.preventDefault(); next.click(); }
return;
}
if (t.hasAttribute('data-cc-wiz-name')) {
const create = el.querySelector('[data-cc-wiz="create"]');
if (create) { event.preventDefault(); create.click(); }
}
}
/** 点外部通道:兜底面 + document 捕获各一条,互为保险。 */
function ccOnOutsidePointer(event) {
try {
return ccOnOutsidePointerInner(event);
} catch (error) {
ccNoteError('ccOnOutsidePointer', error, event);
return undefined;
}
}
function ccOnOutsidePointerInner(event) {
if (!ccIsOpen()) return;
const box = ccEl();
if (!box) return;
const t = event.target;
if (t && box.contains(t)) return;
if (t && t.closest && t.closest('#' + CTRL_ID + ', #' + GEAR_ID)) return; // 点入口不算外部
ccClose();
}
/* ── 开 / 关 ─────────────────────────────────────────────── */
function ccOpen(tabId) {
/* 需要时先建起来 —— 万一入口走的是「齿轮里的按钮」而控制条被收起,
这时 ensureControlCenter 可能还没被调过。幂等,重复调没代价。 */
if (!ccEl()) ensureControlCenter();
const el = ccEl();
if (!el) return false;
if (tabId) ccState.tab = tabId;
try { ccState.lastFocus = document.activeElement; } catch { ccState.lastFocus = null; }
el.setAttribute('data-dshlg-cc-open', '');
el.removeAttribute('hidden');
const back = document.getElementById(CC_BACKDROP_ID);
if (back) back.setAttribute('data-dshlg-cc-open', '');
ccState.wizard = null;
ccState.confirm = null;
ccState.renderError = null;
ccRender();
void ccLoadHealth().then(() => { if (ccIsOpen() && ccState.tab === 'system') ccRender(); });
/* 焦点:落在第一个控件(不是整个面板),键盘用户 Tab 一下就能走 */
requestAnimationFrame(() => {
try {
const first = el.querySelector('.cc-tabs button') || el.querySelector('button');
if (first && typeof first.focus === 'function') first.focus({ preventScroll: true });
} catch { /* 忽略 */ }
});
return true;
}
function ccClose() {
try {
return ccCloseInner();
} catch (error) {
ccNoteError('ccClose', error, null);
return false;
}
}
function ccCloseInner() {
const el = ccEl();
if (!el) return false;
el.removeAttribute('data-dshlg-cc-open');
el.setAttribute('hidden', '');
const back = document.getElementById(CC_BACKDROP_ID);
if (back) back.removeAttribute('data-dshlg-cc-open');
ccState.wizard = null;
ccState.confirm = null;
ccState.renamingId = null;
const prev = ccState.lastFocus;
ccState.lastFocus = null;
requestAnimationFrame(() => {
try {
if (prev && prev.isConnected && typeof prev.focus === 'function') prev.focus({ preventScroll: true });
else {
const g = document.getElementById(GEAR_ID);
if (g && typeof g.focus === 'function') g.focus({ preventScroll: true });
}
} catch { /* 忽略 */ }
});
return false;
}
function ccToggle(tabId) {
return ccIsOpen() ? ccClose() : ccOpen(tabId);
}
/**
* 建面板(幂等)。只在第一次真正建 DOM + 挂监听。
* 面板本体不做 backdrop-filter(见控制中心材质的注释)。
*/
function ensureControlCenter() {
let el = ccEl();
if (!el) {
let back = document.getElementById(CC_BACKDROP_ID);
if (!back) {
back = document.createElement('div');
back.id = CC_BACKDROP_ID;
back.dataset.dshlgKeep = '1';
back.setAttribute('aria-hidden', 'true');
/* 点外部通道 1:兜底面独立覆盖整个视口,最容易命中 */
back.addEventListener('pointerdown', (event) => {
const t = event.target;
const box = ccEl();
if (t && t.closest && t.closest('#' + CTRL_ID + ', #' + GEAR_ID)) return;
if (box && t && box.contains(t)) return;
if (ccIsOpen()) ccClose();
});
document.body.appendChild(back);
}
el = document.createElement('div');
el.id = CC_ID;
el.dataset.dshlgKeep = '1';
el.setAttribute('hidden', '');
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'false');
el.setAttribute('aria-label', '控制中心');
el.innerHTML = '控制中心'
+ ''
+ '
'
+ ''
+ '';
el.addEventListener('click', ccOnClick);
el.addEventListener('keydown', ccOnKeydownInner);
el.addEventListener('input', ccOnInput); // 插件市场的搜索框
document.body.appendChild(el);
/* 点外部通道 2:document 捕获。兜底面可能被别的浮层盖住 / 被 pointer-events 挡住 */
document.addEventListener('pointerdown', ccOnOutsidePointer, true);
/* 关闭通道 3:Esc。挂 document 捕获,面板没聚焦也能关 */
document.addEventListener('keydown', ccOnKeydown, true);
/* 拖动 + 位置记忆:直接复用项目里成熟的那两个(不重写)。
注意 makeDraggable 的约定:位置写在 **left / bottom** 上
(见 applySavedPos / savePos),所以面板的 CSS 必须用
left + bottom 定位,**不能**用 top/transform 居中 ——
否则拖动时 top 与 bottom 会同时生效,面板被抻成一条。
回调签名是 onMoved(el),不是 onMoved(pos)。 */
try {
makeDraggable(el, CC_POS_KEY, (node) => {
if (node) node.setAttribute('data-dshlg-moved', '');
});
applySavedPos(el, CC_POS_KEY);
} catch (error) {
console.warn('[dsh-liquid-glass] 控制中心拖动初始化失败(不影响打开/关闭):', error);
}
}
ccRender();
return el;
}
/* ══════════════════════════════════════════════════════════
* ★ 改这里调效果。存盘后按 Ctrl+R 生效。
*
* 这一版按 **iOS 26 Liquid Glass** 规格调:
* · 半透明白色叠层 ≈8%、背景 18px 高斯模糊、饱和度 1.6×
* · 圆角 20px、描边 1px 白 18%
* · **只有边缘 10~15% 宽度向内折射**(透镜感),中间不扭
* · 最外缘 ≤2px 低强度彩虹色差
* · 顶部 1px 白色高光线(≈30%),四周一圈菲涅尔边缘光
* · 玻璃下柔和浮动阴影 0 8px 32px / 35% 黑
* · 激活(hover/聚焦)时:模糊减弱、折射与高光增强
* · 高光随指针移动(--dshlg-mx/my)
* ══════════════════════════════════════════════════════════ */
const CONFIG = {
/* ── 四档通透度(设计规格:8% / 3% / 1.5% / 0)───────────
* 改这一行就能整体换档,各区域的相对厚薄关系保持不变。
* 注意取舍:越透越好看,但**文字压壁纸的可读性越差**。
* sidebar/right 这类大面积为读字服务,所以默认给 'regular'。 */
/* 用户定稿:**全透明玻璃,只有边框看得出是玻璃**。
'off' = 白纱 0;玻璃感全部由 1px 描边 + 顶部高光 + 菲涅尔内环给出。 */
glassTier: 'off',
/* 自制界面的位置微调 */
ui: {
/* 齿轮按钮与控制条右边界之间的间距(px)。
控制条右边若是头像/其它按钮,调大这个值即可避开。 */
gearGap: 72,
},
/* ── 玻璃质感(iOS 26 规格,全区域共用)─────────────────
* 每档还可以单独覆盖 alpha / frost,不写就吃这里的默认值。 */
glass: {
/* 半透明白色叠层的不透明度。0.08 = 规格值。
* 越小人越「无色」,但压字能力也越弱。 */
/* 背景高斯模糊半径 px(18 = 规格值) */
blur: 18,
/* 饱和度提升(1.6 = 规格值) */
saturate: 1.6,
/* 圆角 px(20 = 规格值) */
radius: 20,
/* 描边:1px 白,18% */
border: 1,
borderAlpha: 0.18,
/* 顶部 1px 白色高光线(30% 亮度) */
topLine: 0.3,
/* 四周菲涅尔边缘光强度(0~1),0 = 关 */
fresnel: 0.22,
/* 浮动阴影:0 8px 32px,35% 黑 */
shadowY: 8,
shadowBlur: 32,
shadowAlpha: 0.35,
/* 折射(透镜感):只有边缘这一圈向内弯折。
* edgeRefract —— 边缘折射强度 px(0 = 不折射)
* edgeWidth —— 边缘带占短边的比例(0.10~0.15 是规格区间) */
edgeRefract: 0,
edgeWidth: 0.12,
/* 最外缘的彩虹色差:宽度 px(≤2)与强度(0 = 关) */
chroma: 2,
chromaAlpha: 0.22,
/* 激活态(鼠标悬停 / 键盘聚焦):模糊减弱、折射与高光增强 */
activeBlur: 0.7,
activeRefract: 1.6,
activeHighlight: 1.5,
/* 高光随指针滑移(跟着 --dshlg-mx/my 走) */
pointerFollow: true,
},
/* ── 分层玻璃:每一个区域单独一档 ───────────────────────
* 这一版的核心:不再用一个 panelAlpha 把整个界面抹平,
* 而是「哪一层该透、哪一层该糊」分开控制。
*
* 每档两个数:
* alpha —— 玻璃底色不透明度。0 = 全透明(文字会压在壁纸上)
* null = 用 CONFIG.glass.tint(规格值 0.08)
* frost —— backdrop-filter 的模糊半径 px。0 = 不模糊
* null = 用 CONFIG.glass.blur(规格值 18)
*
* 中间工作区刻意**没有**开关:它永远是全透明的。 */
middle: { alpha: 0, frost: 0 },
/* 左侧「会话工作区」。面积大、字多,给它比规格稍厚一点点的底,
* 否则花哨壁纸上的会话标题会糊成一片。 */
/* frost: 0 = 不模糊(纯透明 + 边缘光学);调成 18 就是磨砂玻璃 */
sidebarGlass: { alpha: null, frost: 0 },
/* 右侧栏(会话面板)。和左栏一档,保持「同一层」的观感。 */
rightGlass: { alpha: null, frost: 0 },
/* 聊天输入卡片(composer)。用户要求「和工作区输入栏也改一下」——
以前它跟随 panelAlpha(≈0,几乎没有底),现在和侧栏同一档:
可读性够、又不会变成一块白的。 */
composerGlass: { alpha: null, frost: 0 },
/* 下拉菜单、popover、菜单面板。菜单要读得快,底厚一点。 */
/* ⚠️ 弹窗/菜单/popover 是**可读卡片**,不是结构性窗格,不能跟着 α0.138 走。
上一轮「六面统一」把它们一起压薄,导致 DSH 自己的设置对话框变成全透明、
文字与工作区重叠看不清 —— 这是那个问题的根因。这里恢复厚底。 */
overlayGlass: { alpha: 0.74, frost: null },
/* 审批对话框(「等待审批 / 允许一次」那张卡)。
* 浮在对话流里,必须压得住标题与命令,但仍要透出壁纸。 */
approvalGlass: { alpha: null, frost: 0 },
/* 真正的模态弹窗(设置确认、风险确认这类居中对话框)。 */
modalGlass: { alpha: null, frost: 0 },
/* ── 左上角品牌区(DeepSeek 标 + 名字)───────────────
* 用户要求:改「蓝色液态玻璃质感」,旁边的白块也一并收干净。
* 做法是**叠加蓝色玻璃底**(不是替换图标):文字与 logo 都留着,
* 只在下面垫一层蓝色玻璃 + 边缘高光。
* alpha —— 蓝色叠加强度;0 = 关掉(恢复原样,只保留去白块)
* hue —— 蓝色(HSL 色相角度,210 ≈ DeepSeek 蓝) */
/* 品牌区按用户明确规格:**浅蓝色玻璃底 + 深蓝色字标/鱼标**。
不跟随主题推导 —— 用户要的就是这个配色,深浅模式下都一致。
alpha 是玻璃浓度(0.2 时在壁纸上几乎看不出蓝,只有一圈淡描边)。 */
/* 用户定稿:品牌区也要**全透明** —— alpha 0 = 不画任何底,
只留 1px 描边与顶部高光(和其余玻璃面一致)。
想要回浅蓝玻璃底,把它调回 0.42 即可。 */
brand: {
alpha: 0,
hue: 210,
/* 玻璃底:浅蓝(blue-200) */
base: { r: 191, g: 219, b: 254 },
/* 字标与鱼标:深蓝(blue-800) */
ink: { r: 30, g: 64, b: 175 },
/* 品牌区显示的文字。设成空字符串则保留官方原始字标。
为什么默认替换:官方是矢量字标 + 铭牌,我们清不掉铭牌那块底。 */
label: 'Agent-枝星',
/* 宝石配色:ice(冰钻白蓝,默认,暗色壁纸上最跳)/ gold(香槟金)/ aqua(青玉)
深蓝在暗色壁纸上会看不清,所以默认不用深蓝。 */
gem: 'ice',
},
/* ── 右栏收起后的表现 ─────────────────────────────────
* DSH 的右栏「收起」是把面板滑出视口、并去掉 data-sidebar-right-open
* (track 那一格仍然占位),所以收起后如果还留着玻璃,
* 就会在右边留下一条**模糊的空带子** —— 用户要的是「和工作区一样透明」。
* 'auto' —— 面板真的开着才给玻璃;收起后全透明(默认)
* 'keep' —— 不管开合都保留玻璃 */
rightWhenClosed: 'auto',
/* 侧栏/右栏是否也做边缘折射。
* 默认关:整列面积太大,SVG 折射在这个尺度上很吃显卡,
* 而且「整列透镜」看着不像 iOS。要试就改成 true。 */
refractRegions: false,
/* 浮层的渲染方式:
* 'frost' —— backdrop-filter 模糊(默认,最通透)
* 'clear' —— 不模糊,改用更实的半透明色面板(显卡吃不消时用)
* 'off' —— 浮层完全不处理,交给 DSH 自己的材质 */
overlayMode: 'frost',
/* ── 通用透明度(保留旧语义,向后兼容)────────────────── */
panelAlpha: 0,
/* 浮层(下拉菜单 / 弹窗 / 工具提示)的令牌级不透明度。
* overlay.alpha 是给「我们自己识别出来的浮层」用的;
* 这一项是给**没被识别到的**浮层令牌兜底。 */
overlayAlpha: 0.9,
/* 面板很透时给文字加一层衬底阴影 —— 压在壁纸上可读性会好很多。
* 0 = 关闭;0.55 左右比较自然。 */
textShadow: 0.55,
/* ── 内容块底色(对话里的卡片 / 代码块 / 工具调用框)─────
* 'glass' —— 只清掉不透明底色,另给一层极淡的玻璃底(默认,推荐)
* 'none' —— 一块不留,连内容块也全透明(v1.8 的老行为,字会压壁纸)
* 'keep' —— 内容块完全不动,保留 DSH 自己的底色
* 无论哪一档,**两侧栏与浮层的玻璃底都不受影响**。 */
contentFill: 'glass',
contentFillAlpha: 0.06,
/* 逐层清掉容器底色(contentFill 之外的兜底,默认给 0 不参与)。 */
clearBackgroundDepth: 0,
/* ── 文字颜色 ──────────────────────────────────────────
* 空字符串 = 用应用自己的配色(跟随明暗主题)。
* 填颜色(如 '#ffffff')= 强制所有文字用这个颜色。
* 当前:统一白字(用户明确要求,深浅主题都白)。 */
textColor: '#ffffff',
/* true 时连「写死了颜色」的元素也一起强制(#root * { color: … !important })。
* 不打开的话「统一白字」做不到 —— DSH 里大量文字是写死颜色/走状态色的
* (标题、正文、按钮、时间戳),只改令牌覆盖不到。
* 代价:链接色、成功/警告/错误这类状态色也会变白。
* 若发现某处本该有颜色却变白了,把它改回 false。 */
textColorAggressive: true,
/* 次要文字(时间戳、说明文字)是否跟着一起变。
* false = 保留应用原本的次要文字色(通常更淡)。 */
textColorSecondaryToo: true,
/* ── 各区域的开关 ─────────────────────────────────────── */
// 聊天框(消息输入卡片)
composer: true,
// 聊天框内的按钮行
toolbar: true,
// 左侧会话工作区(这一版默认开)
sidebar: true,
// 右侧栏(这一版默认开)
right: true,
// 顶栏(Windows 上其实是窗口标题条区域,默认不动它)
topbar: false,
// 审批对话框 / 模态弹窗 玻璃化
approval: true,
modal: true,
// 模糊半径(px)—— 液态玻璃一般 6~12
blur: 7,
// 饱和度提升,让透过来的颜色更"活"
saturate: 1.7,
// 聊天框折射强度(px)—— 0 关闭折射,只要毛玻璃。12~24 效果明显
refract: 0, // 0 = 不做边缘折射(SVG 滤镜 backdrop 在动画壁纸上会闪烁)
// 圆角(px)
radius: 18,
// 背景层:为了让玻璃"有东西可折射",
// 在应用后面铺一层随主题走的柔和光斑。
// 接上壁纸后会自动让位(壁纸垫底效果更好)。
backdrop: true,
/* ── 壁纸 ──────────────────────────────────────────────
* 由宿主半体(index.js)起的本地只读服务提供文件。
* 这里填相对 wallpapers/ 的入口路径。
* 宅邸禅院 → 'zen/index.html'
*/
wallpaper: {
enabled: true,
entry: 'zen/index.html',
// 宿主服务监听的候选端口,顺序与 index.js 保持一致
ports: [39321, 39322, 39323, 39324],
// 壁纸整体压暗 0~1(壁纸太亮会影响读字)
dim: 0.15,
// 壁纸整体模糊 px。⚠️ 壁纸是跨源 iframe(独立合成层),
// 外面套 filter 会把它变成一片黑,所以默认 0,别乱调。
blur: 0,
// 壁纸不透明度
opacity: 1,
// 显示左下角的玻璃控制条(切场景 / 环境音 / 音量)
controls: true,
// 隐藏壁纸自带的 HUD(否则那个「院」按钮会浮在右边)
hideWallpaperHud: true,
// 下拉框里是否也列出 scene / application 类型的壁纸。
// 那些依赖 Wallpaper Engine 的 DirectX 运行时,选了也渲染不出来,默认不显示。
showUnsupported: false,
// 壁纸画廊(缩略图九宫格)默认展开还是收起
galleryOpen: false,
},
// 右下角状态横幅。已关闭;排查问题时改回 true 即可。
// (无论开关,Console 里始终有详细日志。)
report: false,
};
/* ── 工具 ───────────────────────────────────────────── */
function parseColor(value) {
const s = String(value ?? '').trim();
if (!s || s === 'transparent' || s === 'none') return null;
let m = /^#([0-9a-f]{3})$/i.exec(s);
if (m) {
const h = m[1];
return {
r: Number.parseInt(h[0] + h[0], 16),
g: Number.parseInt(h[1] + h[1], 16),
b: Number.parseInt(h[2] + h[2], 16),
};
}
m = /^#([0-9a-f]{6})$/i.exec(s);
if (m) {
const n = Number.parseInt(m[1], 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i.exec(s);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]) };
return null;
}
const toHex = (c) =>
'#' +
[c.r, c.g, c.b]
.map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
.join('');
/** 把 0~1 的两端夹住;非数字回落到 fallback。 */
function clamp01(v, fallback = 0) {
const n = Number(v);
if (!Number.isFinite(n)) return fallback;
return Math.max(0, Math.min(1, n));
}
/** 非负数值;非数字回落 fallback。 */
function num(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? Math.max(0, n) : fallback;
}
/** 四档通透度 → 白纱不透明度(设计规格 8% / 3% / 1.5% / 0)。 */
const GLASS_TIERS = {
clear: 0.08, // 规格里的 Clear 档基准
regular: 0.03, // 默认:更透,仍压得住大部分壁纸
thin: 0.015, // 很透,花哨壁纸上字会吃力
off: 0, // 完全无色(只剩模糊与边缘光学)
};
/**
* 各区域的**厚度倍数**(乘在档位值上)。
* 为什么用倍数而不是绝对值:换通透度档时,各区域之间的相对关系
* 必须保持不变 —— 否则换一档就要把所有区域重调一遍。
* 倍数取舍来自「这块面上的字有多重要」:
* 审批卡 > 弹窗 > 浮层菜单 > 左栏 ≈ 右栏 > 输入卡片。
*/
/* 用户定稿:**所有玻璃面都对齐「右侧栏展开」那一档**(α = 0.03 × 4.6 ≈ 0.138),
不再按区域分厚薄 —— 之前审批卡 ×10、弹窗 ×13 明显偏厚,看着不像同一套材质。 */
const REGION_MULT = {
sidebarGlass: 4.6,
rightGlass: 4.6,
composerGlass: 4.6,
overlayGlass: 4.6,
approvalGlass: 4.6,
modalGlass: 4.6,
};
/** 取 iOS 26 规格里的一项(CONFIG.glass 的兜底)。 */
function g(key, fallback) {
const raw = CONFIG.glass && typeof CONFIG.glass === 'object' ? CONFIG.glass : {};
let v = raw[key];
if (v === null || v === undefined) {
/* tint 没显式给 → 取通透度档位 */
if (key === 'tint') {
const tier = String(CONFIG.glassTier ?? 'regular');
v = GLASS_TIERS[tier] ?? GLASS_TIERS.regular;
} else {
return fallback;
}
}
return v;
}
/**
* 取某一档玻璃配置。
*
* `alpha` 写 null(或不写)= **档位值 × 该区域倍数**;
* 写数字 = 该区域单独指定,不受档位影响。
* `frost` 写 null = 用规格模糊半径(18px)。
* 老配置把整档写成数字也照样认(向后兼容)。
*/
function layer(name, fallbackAlpha) {
const raw = CONFIG[name];
const obj = raw && typeof raw === 'object' ? raw : {};
const pick = (v, spec, fb) => {
if (v === null || v === undefined) {
if (typeof raw === 'number') return raw; // 老写法:整档就是一个数字
return g(spec, fb);
}
return v;
};
let alpha = obj.alpha;
if (alpha === null || alpha === undefined) {
// 档位值 × 区域倍数
const mult = REGION_MULT[name] ?? 5;
alpha = clamp01(num(g('tint', 0.03), 0.03) * mult);
}
return {
alpha: clamp01(pick(alpha, 'tint', fallbackAlpha)),
frost: num(pick(obj.frost, 'blur', 18), 18),
};
}
/**
* DSH 背景令牌的**参考默认值**。
* 实际生效的透明度由 CONFIG 各档决定(见 buildBaseCss),
* 这里只用于「读不到令牌时」的兜底颜色。
*/
const TOKEN_ALPHAS = {
'--dsw-alias-bg-layer-1': 0.86,
'--dsw-alias-bg-layer-2': 0.74,
'--dsw-alias-bg-overlay': 0.96,
'--dsw-specific-sidebar-fill': 0.84,
};
/**
* 读当前主题。
* 先把自己的样式摘掉再读,否则读到的会是自己上一次写进去的值。
*
* 除了判断明暗,还要**记下这些令牌的原始颜色** —— 后面只把它们变半透明,
* 颜色沿用应用自己的,这样浅色/深色主题都不会压掉文字对比度。
*/
function readTheme() {
const mine = document.getElementById(STYLE_ID_DYN);
const parent = mine ? mine.parentNode : null;
const out = { base: null, dark: true, raw: '', tokens: {} };
try {
if (mine && parent) parent.removeChild(mine);
const read = (name) => {
let v = getComputedStyle(document.documentElement).getPropertyValue(name);
if (!String(v).trim()) v = getComputedStyle(document.body).getPropertyValue(name);
return String(v).trim();
};
/* 品牌蓝要用它做基准(只换色相,保住主题的明度) */
const labelRgb = parseColor(read('--dsw-alias-label-primary'));
if (labelRgb) out.tokens['--dsw-alias-label-primary'] = labelRgb;
for (const name of Object.keys(TOKEN_ALPHAS)) {
const rgb = parseColor(read(name));
if (rgb) out.tokens[name] = rgb;
}
const baseRaw = read('--dsw-alias-bg-base');
const labelRaw = read('--dsw-alias-label-primary');
out.raw = baseRaw;
out.base = parseColor(baseRaw);
const label = parseColor(labelRaw);
if (out.base) {
out.dark = (0.2126 * out.base.r + 0.7152 * out.base.g + 0.0722 * out.base.b) / 255 < 0.5;
} else if (label) {
out.dark = (0.2126 * label.r + 0.7152 * label.g + 0.0722 * label.b) / 255 > 0.5;
}
} catch (error) {
out.error = String(error && error.message);
} finally {
if (mine && parent) parent.appendChild(mine);
}
return out;
}
function rgbaOf(rgb, alpha) {
const a = Math.max(0, Math.min(1, Number(alpha)));
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${Number.isFinite(a) ? a : 1})`;
}
/**
* 把颜色换成指定色相(保留明度/饱和度),用来做品牌蓝。
* 为什么要换算而不是直接写死 #4d6bfe:
* 深浅主题下品牌字与 logo 的明度本来就不同,写死会一边糊一边飘。
* 只换色相 → 深浅主题各自保持自己的对比度,观感却统一成蓝色。
*/
function toHue(c, hue) {
const r = c.r / 255; const gg = c.g / 255; const b = c.b / 255;
const max = Math.max(r, gg, b); const min = Math.min(r, gg, b);
const l = (max + min) / 2;
const d = max - min;
/* 源色近中性(白/灰)时饱和度≈0,只换色相等于换了个白 ——
所以设一个饱和度下限,让品牌蓝在任何主题下都真的蓝。 */
const rawSat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
const sat = Math.max(rawSat, 0.55);
const h = Number(hue) / 360;
const q = l < 0.5 ? l * (1 + sat) : l + sat - l * sat;
const p = 2 * l - q;
const f = (t0) => {
let t = t0;
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return {
r: Math.round(f(h + 1 / 3) * 255),
g: Math.round(f(h) * 255),
b: Math.round(f(h - 1 / 3) * 255),
};
}
/** CSS 字符串里的 rgb 三连("77, 107, 254")按给定色相重算。 */
function hueShiftCssLinear(value, hue) {
return String(value).replace(
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)([^)]*)\)/g,
(all, r, gg, b, rest) => {
const c = toHue({ r: +r, g: +gg, b: +b }, hue);
return `rgba(${c.r}, ${c.g}, ${c.b}${rest})`;
},
);
}
/**
* 左上角品牌区的玻璃参数。
*
* 叠加两层蓝色:一层径向做「液态」的体积感,一层线性做上亮下暗。
* 色相由 CONFIG.brand.hue 定,明度由主题的 label-primary 派生 ——
* 于是深色主题是「浅蓝玻璃字」,浅色主题是「深蓝玻璃字」。
*/
function buildBrandVars(theme) {
const cfg = CONFIG.brand && typeof CONFIG.brand === 'object' ? CONFIG.brand : {};
const alpha = clamp01(cfg.alpha === undefined ? 0.42 : cfg.alpha);
/* 配色写死成规格值,不再从主题推 —— 用户要「浅蓝底 + 深蓝字标」,
主题推导会在深色模式下把底变成近白,反而不符合要求。 */
const blue = cfg.base ?? { r: 191, g: 219, b: 254 };
const ink = cfg.ink ?? { r: 30, g: 64, b: 175 };
const inkStr = `${ink.r}, ${ink.g}, ${ink.b}`;
const blueStr = `${blue.r}, ${blue.g}, ${blue.b}`;
const blur = num(layer('sidebarGlass', 0.12).frost, 18);
const sat = num(g('saturate', 1.6), 1.6);
const edgeRefract = num(g('edgeRefract', 10), 10);
const filter =
edgeRefract > 0
? `blur(${blur}px) saturate(${sat}) url(#${FILTER_REF})`
: `blur(${blur}px) saturate(${sat})`;
return `--dshlg-brand-ink: ${inkStr};
-dshlg-brand-tone: ${blueStr};
--dshlg-brand-a: ${alpha};
--dshlg-brand-filter: ${alpha > 0 ? filter : 'none'};`.replace('-dshlg-brand-tone', '--dshlg-brand-tone');
}
/** 计算值是不是「透明」(空串 / transparent / none / rgba(...,0))。 */
function isClear(v) {
const s = String(v ?? '').trim().toLowerCase();
if (!s || s === 'transparent' || s === 'none' || s === 'rgba(0, 0, 0, 0)') return true;
const m = /rgba?\(([^)]*)\)/.exec(s);
if (m) {
const parts = m[1].split(/[,/\s]+/).filter(Boolean);
if (parts.length >= 4) return Number(parts[3]) === 0;
}
return false;
}
/* ── SVG 折射滤镜(只弯折边缘一圈)───────────────────── */
/**
* 建「边缘透镜」滤镜;已存在时只更新强度与边缘带宽。
*
* 为什么必须有 mask:`backdrop-filter` 里的 SVG 滤镜是**整面**生效的,
* 直接挂 feDisplacementMap 会把整块玻璃扭成毛玻璃汤 —— 那不是 iOS。
* iOS 26 的做法是**只有边缘 10~15% 宽度**向内折射,中间保持清晰,
* 于是用一张「外圈白、内部黑」的 mask 去限制位移图:
* · mask 用 objectBoundingBox(默认)→ 每块面按自己的尺寸缩放,
* 侧栏与审批卡拿到的是同一条**相对厚度**的边;
* · 外圈白只给 35% 不透明度:feDisplacementMap 对 0.5 灰就取满幅位移,
* 不压亮度边缘会扭过头。
*/
function ensureFilter(scale) {
const band = edgeBandPct(g('edgeWidth', 0.12));
const mid = band * 0.5;
const inner = band * 1.05;
const existing = document.getElementById(SVG_ID);
if (existing) {
const disp = existing.querySelector('feDisplacementMap');
if (disp) disp.setAttribute('scale', String(num(scale, 0)));
const mask = existing.querySelector('mask');
if (mask) {
const stops = mask.querySelectorAll('stop');
if (stops[1]) stops[1].setAttribute('offset', `${mid.toFixed(1)}%`);
if (stops[2]) stops[2].setAttribute('offset', `${inner.toFixed(1)}%`);
}
return;
}
const NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('id', SVG_ID);
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('width', '0');
svg.setAttribute('height', '0');
svg.style.cssText =
'position:fixed;left:0;top:0;width:0;height:0;overflow:hidden;pointer-events:none';
// ① 边缘掩码:外圈亮、内部黑(stop 的透明度由 CSS 里的 .stop-* 给)
const mask = document.createElementNS(NS, 'mask');
mask.setAttribute('id', `${FILTER_REF}-edge`);
mask.setAttribute('x', '-20%');
mask.setAttribute('y', '-20%');
mask.setAttribute('width', '140%');
mask.setAttribute('height', '140%');
const mkStop = (offset, cls) => {
const s = document.createElementNS(NS, 'stop');
s.setAttribute('offset', offset);
s.setAttribute('class', cls);
return s;
};
const grad = document.createElementNS(NS, 'radialGradient');
grad.setAttribute('id', `${FILTER_REF}-edge-grad`);
grad.appendChild(mkStop('0%', 'stop-outer'));
grad.appendChild(mkStop(`${mid.toFixed(1)}%`, 'stop-mid'));
grad.appendChild(mkStop(`${inner.toFixed(1)}%`, 'stop-inner'));
grad.appendChild(mkStop('100%', 'stop-inner'));
const defs = document.createElementNS(NS, 'defs');
defs.appendChild(grad);
svg.appendChild(defs);
const rect = document.createElementNS(NS, 'rect');
rect.setAttribute('x', '-20%');
rect.setAttribute('y', '-20%');
rect.setAttribute('width', '140%');
rect.setAttribute('height', '140%');
rect.setAttribute('fill', `url(#${FILTER_REF}-edge-grad)`);
mask.appendChild(rect);
svg.appendChild(mask);
// ② 滤镜:噪声 → 柔化 → 位移(位移图被 mask 限制在边缘)
const filter = document.createElementNS(NS, 'filter');
filter.setAttribute('id', FILTER_REF);
// 滤镜区域放大,避免折射把边缘推出可视范围
filter.setAttribute('x', '-20%');
filter.setAttribute('y', '-20%');
filter.setAttribute('width', '140%');
filter.setAttribute('height', '140%');
filter.setAttribute('color-interpolation-filters', 'sRGB');
filter.setAttribute('mask', `url(#${FILTER_REF}-edge)`);
const turb = document.createElementNS(NS, 'feTurbulence');
turb.setAttribute('type', 'fractalNoise');
turb.setAttribute('baseFrequency', '0.006 0.009');
turb.setAttribute('numOctaves', '2');
turb.setAttribute('seed', '7');
turb.setAttribute('result', 'noise');
const soft = document.createElementNS(NS, 'feGaussianBlur');
soft.setAttribute('in', 'noise');
soft.setAttribute('stdDeviation', '3');
soft.setAttribute('result', 'soft');
const disp = document.createElementNS(NS, 'feDisplacementMap');
disp.setAttribute('in', 'SourceGraphic');
disp.setAttribute('in2', 'soft');
disp.setAttribute('scale', String(num(scale, 0)));
disp.setAttribute('xChannelSelector', 'R');
disp.setAttribute('yChannelSelector', 'G');
filter.appendChild(turb);
filter.appendChild(soft);
filter.appendChild(disp);
svg.appendChild(filter);
document.body.appendChild(svg);
}
/* ── DOM 探测 ───────────────────────────────────────── */
/** 圆角是否 >= min(读计算值,兼容 px / 百分比)。 */
function radiusOf(el) {
const cs = getComputedStyle(el);
const v = cs.borderTopLeftRadius || '0';
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
/** 元素在 DOM 里的深度(越浅越靠近 #root)。 */
function depthOf(el) {
let d = 0;
for (let n = el; n; n = n.parentElement) d++;
return d;
}
/** 尺寸描述,用来在报告里核对几何判据。 */
function rectOf(el) {
if (!el || !el.getBoundingClientRect) return null;
const r = el.getBoundingClientRect();
return {
w: Math.round(r.width),
h: Math.round(r.height),
left: Math.round(r.left),
right: Math.round(r.right),
};
}
/**
* 找左侧那一栏「会话工作区」。
*
* 为什么不按类名找:DSH 的类名是哈希的(`LdtX1G_xxx`),而且每次构建都会变,
* 拿它当选择器一次升级就废。也不按结构猜(`root.children[0].children[0]`
* 这种),布局一改就错。
*
* 所以用**几何特征**,它由「左侧整列」这个事实本身决定:
* · 左边缘贴着 x=0
* · 高度接近整个视口
* · 宽度是一栏的量级(不是整个窗口,也不是一根分隔线)
* · 里面有若干个按钮(会话列表、设置入口…)
* 满足这些的**最外层**元素就是它。
*/
function findLeftColumn(root, vw, vh) {
/* ① 语义钩子优先:ui-sidebar 的根容器类名是 _root,
它一定包着 logoRow / panelList / regionArea 这些稳定后缀。 */
const inner = root.querySelector('[class*="_logoRow"]')
?? root.querySelector('[class*="_panelList"]')
?? root.querySelector('[class*="_regionArea"]');
if (inner) {
const outer = inner.closest('[class*="_root"]') ?? inner.parentElement;
if (outer && outer.getBoundingClientRect().width >= 40) return outer;
}
/* ② 几何兜底(DSH 换结构时用):左缘贴视口、近满高、一栏宽、内含 ≥2 个按钮 */
const maxWidth = Math.min(520, vw * 0.45);
let best = null;
let bestDepth = Infinity;
for (const el of root.querySelectorAll('*')) {
const r = el.getBoundingClientRect();
if (r.width < 44 || r.width > maxWidth) continue;
if (r.height < vh * 0.75) continue;
if (r.left > 4) continue;
if (el.querySelectorAll('button').length < 2) continue;
const d = depthOf(el);
if (d < bestDepth) {
bestDepth = d;
best = el;
}
}
return best;
}
/**
* 找右侧栏(会话面板)。
*
* 它的结构是:右栏轨道(rightbarCol)里放一个 `position:absolute; right:0`
* 的面板,面板**宽度可以超出轨道**(所以轨道本身不能套玻璃,否则只有窄窄一条)。
*
* 判据(同样只看几何 + 结构事实):
* · 贴着视口右边缘
* · 高度接近整个视口
* · 宽度 ≥ 240px 且 ≤ 视口 70%
* · 里面**有东西**(按钮 / 文本 / 滚动容器),空的轨道不算
* 取最外层那个。
*/
function findRightPanel(root, frame, vw, vh) {
const maxWidth = vw * 0.7 + 8;
const container = frame ?? root;
let best = null;
let bestDepth = Infinity;
for (const el of container.querySelectorAll('*')) {
if (el === frame) continue;
const r = el.getBoundingClientRect();
if (r.width < 240 || r.width > maxWidth) continue;
if (r.height < vh * 0.75) continue;
if (Math.abs(r.right - vw) > 6) continue;
const text = (el.textContent ?? '').trim();
if (el.querySelectorAll('button, input, textarea, a[href]').length === 0 && text.length < 8) {
continue;
}
const d = depthOf(el);
if (d < bestDepth) {
bestDepth = d;
best = el;
}
}
return best;
}
/**
* 找右栏面板本体(DSH 的 SidebarRight `.panel`)。
*
* 它自己发布了稳定钩子(源码实测):
* `data-sidebar-right-session=""`
* `data-sidebar-right-panel="push|fullscreen"`
* `data-sidebar-right-open` ← **只在展开时才有**
* `aria-hidden="true"` ← 收起的另一面
* 这些比几何判据硬得多,所以优先用它们。
*/
function resolveRightPanel(root, frame) {
const container = frame ?? root ?? document;
const hooked = container.querySelectorAll('[data-sidebar-right-session]');
if (hooked.length > 0) {
/* 一个页面里可能有多个 session 面板(历史会话各留一个),
必须挑**开着的那个**,否则会拿到一个收起的、把开着的那块也判成收起。
代价只有一次遍历,收益是省掉一整类误判。 */
for (const el of hooked) {
if (el.hasAttribute('data-sidebar-right-open')) return el;
}
return hooked[hooked.length - 1];
}
return container.querySelector('[data-sidebar-right-panel]');
}
/**
* 右栏轨道里「真正画出内容」的那一层。
*
* 两种情况都要覆盖:
* · 面板是绝对定位、比轨道更宽(能露到轨道外面)→ 按右边缘贴视口找;
* · 面板就在轨道里、宽度等于轨道(真实 DSH 的 .LdcXKW_panel 也是这样,
* 它 top/bottom:0、right:0)→ 找轨道里「铺满轨道且有内容」的最外层。
*
* 为什么非要往里找一层:轨道(网格列)本身没内容,
* 把玻璃糊在轨道上,等于只糊了一条空带子。
*/
function findPanelInsideColumn(col, vw, vh) {
const track = col.getBoundingClientRect();
let wide = null;
let filling = null;
for (const el of col.querySelectorAll('*')) {
const r = el.getBoundingClientRect();
if (r.width < 240 || r.height < vh * 0.6) continue;
// A) 比轨道宽、右边缘贴视口 → 绝对定位的浮层面板
if (
r.width <= vw * 0.7 + 8 &&
r.width > track.width + 8 &&
Math.abs(r.right - vw) <= 6
) {
if (!wide || depthOf(el) < depthOf(wide)) wide = el;
continue;
}
// B) 铺满轨道、且自己有内容 → 轨道内的面板本体
if (
Math.abs(r.width - track.width) <= 6 &&
Math.abs(r.height - track.height) <= 6 &&
el.querySelectorAll('button, input, textarea, a[href], [class*="_file"], [class*="_row"]').length > 0
) {
if (!filling || depthOf(el) < depthOf(filling)) filling = el;
}
}
return wide ?? filling;
}
/**
* 右栏面板「现在是不是真的打开着」。
*
* ⚠️ 这里必须**属性优先**,不能靠几何:
* · DSH 收起是把面板滑出视口,且 `transform` 作用在**子元素**
* (`[data-dockkit-host]` 上),面板自己的 rect **不会变**;
* · 面板自己的 `visibility` 也可能是 visible(隐藏落在子元素上)。
* 实测源码:`.LdcXKW_panel [data-dockkit-host]{visibility:hidden;transform:translateX(...)}`
* 所以只要「面板自己没有 data-sidebar-right-open」就先倾向收起,
* 再拿「dock 子元素是否真的出现在视口里」兜底。
*/
function isOpenPanel(panel) {
if (!panel) return false;
try {
/* ① DSH 自己发布的开关:展开时才有这个属性(源码实测) */
const owner = panel.closest('[data-sidebar-right-session]') ?? panel;
const hasOpenAttr = owner.hasAttribute('data-sidebar-right-open');
if (!hasOpenAttr) return false;
/* ② 面板自己都不显示 → 收起 */
const cs = getComputedStyle(panel);
if (cs.display === 'none') return false;
/* ③ dock 子元素是不是真的出现在视口里
(隐藏/位移都落在它们身上,所以这里才是可信的几何) */
const vw = window.innerWidth;
const hosts = panel.querySelectorAll('[data-dockkit-host], [data-dockkit-pane], [data-dockkit-float]');
for (const h of hosts) {
const hcs = getComputedStyle(h);
if (hcs.display === 'none' || hcs.visibility === 'hidden') continue;
const hr = h.getBoundingClientRect();
if (hr.width < 1 || hr.height < 1) continue;
if (hr.right <= 1 || hr.left >= vw - 1) continue;
return true;
}
/* ④ 一个 dock 子元素都没有(空面板):只要它在视口内就算开着 */
const r = panel.getBoundingClientRect();
return r.width > 1 && r.right > 1 && r.left < vw - 1;
} catch {
return false;
}
}
/**
* 右栏整体是否处于「已收起」。
*
* 为什么不只用 isOpenPanel(某一块面):探测有可能选到轨道而不是面板本体,
* 那时 isOpenPanel 会答「收起」,而**真正还开着的那块面板**却保留着玻璃。
* 所以这里独立判一次:只要容器里存在任何一个**开着**的 session 面板,
* 右栏就算开着;一个都没有,就整列按收起处理。
* 这样无论探测选中的是哪一层,收起后都不会留下玻璃。
*/
function rightColumnCollapsed(container, fallbackSurface) {
if (!container) return !isOpenPanel(fallbackSurface);
const panels = container.querySelectorAll('[data-sidebar-right-session]');
if (panels.length > 0) {
for (const p of panels) {
if (isOpenPanel(p)) return false; // 有一个开着 → 整列算开着
}
return true; // 全收起 → 整列收起
}
return !isOpenPanel(fallbackSurface);
}
/**
* 给一个元素套玻璃前,先找一个更合适的宿主。
*
* `backdrop-filter` 只作用于**自己身后的背景**,对后代无效:
* · 如果宿主自己背景不透明 → 后代其实压在这块不透明底上,玻璃等于没用;
* · 如果宿主是纯布局容器(背景透明)→ 直接套在上面,让玻璃盖住整个子树。
* 所以「有底色就地用,没底色往上顶一层」。
*
* @param stop 到这一层就绝不再往上(一般是整帧,别把整个界面糊掉)
* @param bounds 只许在这些祖先里找(空 = 从 el 到 stop 都行)。
* 传入 [container] 就等于「就地用,不许爬出去」——
* 右栏面板、审批卡面都用这个,因为它们本身就是要糊的那块面。
*/
function hostFor(el, stop, maxUp = 3, bounds = null) {
if (!el || !el.ownerDocument) return el;
const frame = stop ?? null;
const base = el.getBoundingClientRect();
let node = el;
for (let i = 0; i < maxUp; i++) {
const bg = getComputedStyle(node).backgroundColor;
if (!isClear(bg)) return node; // 自己有底色 → 就地套
const parent = node.parentElement;
if (!parent || parent === document.body || parent === document.documentElement) return node;
if (frame && parent === frame) return node; // 别把整帧吃掉
if (bounds && !bounds.includes(parent)) return node; // 越界了 → 就地套
const pr = parent.getBoundingClientRect();
// 父节点明显比目标大(或者根本不是一块面)→ 顶上去就把别的区域也糊了
if (pr.width > base.width * 1.6 || pr.height > base.height * 1.2) return node;
if (parent.querySelectorAll('button, input, textarea, a[href]').length > 80) return node;
node = parent;
}
return node;
}
/**
* 找模态层 / 模态卡。
*
* 两种形态都要认:
* · 外层「遮罩容器」自己带 backdrop-filter(DSH 的 Modal.root 就是这种)
* → 返回它,调用方再往里找 .dialog;
* · 遮罩只是个普通全屏层、模糊在 mask 兄弟节点上
* → 直接找 role=dialog / 类名含 dialog 的那张卡,就地糊它。
*
* 判据放宽到「近全屏 + fixed/absolute + 有模糊」,是因为
* 不同 DSH 版本把 backdrop-filter 放在 root 还是 mask 上并不固定,
* 按老写法(要求 display:flex + z-index≥100 + 自己带模糊)会整片漏掉。
*/
function findModalCandidate(root, vw, vh) {
let outer = null;
for (const el of root.querySelectorAll('*')) {
const cs = getComputedStyle(el);
if (cs.position !== 'fixed' && cs.position !== 'absolute') continue;
const r = el.getBoundingClientRect();
if (r.width < vw * 0.85 || r.height < vh * 0.85) continue;
const blurry = !isClear(cs.backdropFilter) || !isClear(cs.webkitBackdropFilter);
if (!blurry) continue;
if (outer === null || depthOf(el) < depthOf(outer)) outer = el;
}
if (outer) {
const dialog = outer.querySelector('[role="dialog"], [class*="_dialog"]');
if (dialog) return dialog;
}
// 兜底:直接找可见的对话框卡片(不管遮罩长什么样)
let dialog = null;
for (const el of root.querySelectorAll('[role="dialog"], [class*="_dialog"]')) {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
const r = el.getBoundingClientRect();
if (r.width < 200 || r.height < 80) continue;
if (dialog === null || depthOf(el) < depthOf(dialog)) dialog = el;
}
return outer ?? dialog;
}
function discover() {
const out = {
editor: null, composer: null, toolbar: null,
sidebar: null, right: null, approval: null, modal: null,
notes: [],
};
const root = document.getElementById('root') ?? document.body;
const frame = root.querySelector('[class*="_frame"]') ?? root;
const vw = window.innerWidth;
const vh = window.innerHeight;
// 1) 消息输入框
const editor =
document.querySelector('[contenteditable="true"]') ||
document.querySelector('[contenteditable=""]') ||
document.querySelector('textarea') ||
/* ProseMirror / 富文本编辑器的常见落点 */
root.querySelector('[class*="_editor"]') ||
root.querySelector('[class*="_composer"] [contenteditable]') ||
null;
out.editor = editor;
if (!editor) {
out.notes.push('没找到输入框([contenteditable] / textarea)');
}
// 2) 聊天框卡片:从输入框往上,找「圆角 >= 4 且内部至少 2 个按钮」的最近祖先
if (editor) {
let node = editor.parentElement;
for (let depth = 0; node && depth < 10; depth++, node = node.parentElement) {
if (node === document.body || node === document.documentElement) break;
const buttons = node.querySelectorAll('button').length;
if (radiusOf(node) >= 4 && buttons >= 2) {
out.composer = node;
out.notes.push(`聊天框:向上 ${depth + 1} 层命中(按钮 ${buttons} 个)`);
break;
}
}
if (!out.composer) {
out.composer = editor.parentElement;
out.notes.push('聊天框:未命中理想判据,回落到输入框的直接父节点');
}
}
// 3) 工具栏:卡片范围内「含 >=2 个按钮、且不含输入框」的最外层元素
const scope = out.composer ?? editor?.parentElement ?? null;
if (scope && editor) {
const all = Array.from(scope.querySelectorAll('*'));
const candidates = all.filter((el) => {
if (el.contains(editor)) return false; // 排除整个卡片
if (el.querySelectorAll('button').length < 2) return false;
return true;
});
out.toolbar = candidates.find((el) => !candidates.some((o) => o !== el && o.contains(el))) ?? null;
if (out.toolbar) out.notes.push(`工具栏:在聊天框内找到(按钮 ${out.toolbar.querySelectorAll('button').length} 个)`);
else out.notes.push('工具栏:聊天框内没找到按钮行');
}
// 4) 左侧会话工作区
if (CONFIG.sidebar) {
out.sidebar = findLeftColumn(root, vw, vh);
if (out.sidebar) {
const r = rectOf(out.sidebar);
out.notes.push(
`左栏:几何命中(${r.w}×${r.h},按钮 ${out.sidebar.querySelectorAll('button').length} 个)`,
);
} else {
out.notes.push('左栏:几何判据没命中(窗口太窄?或布局不是左右分栏)');
}
}
// 5) 右侧栏
if (CONFIG.right) {
// 先认 DSH 自己发布的钩子(data-sidebar-right-*),命不中再用几何兜底
const hooked = resolveRightPanel(root, frame);
if (hooked) {
const open = isOpenPanel(hooked);
const r = rectOf(hooked);
out.right = hooked;
out.rightOpen = open;
out.rightVia = 'hook';
out.notes.push(
`右栏:钩子命中 data-sidebar-right-session(${r.w}×${r.h})→ ${open ? '展开' : '收起'}`,
);
} else {
const geo = findRightPanel(root, frame, vw, vh);
out.right = geo;
out.rightOpen = geo ? isOpenPanel(geo) : false;
out.rightVia = geo ? 'geometry' : null;
out.notes.push(
geo
? `右栏:几何命中(${rectOf(geo).w}×${rectOf(geo).h})→ ${out.rightOpen ? '展开' : '收起'}`
: '右栏:钩子与几何都没命中(窗口太窄?右栏没打开?)',
);
}
}
// 6) 审批卡 / 模态弹窗
if (CONFIG.approval) {
// 几何宿主是整块「composer takeover」容器,但**有内容的是里面那张卡**
// (圆角 + 描边 + 文字都在卡上),所以玻璃要贴在卡上,
// 不能让卡里的字压在壁纸上。
const approvalRoot = document.querySelector('[data-approval-key]') ?? null;
out.approval = approvalRoot ? (approvalRoot.querySelector('[class*="_card"]') ?? approvalRoot) : null;
if (out.approval) {
out.notes.push(
`审批卡:命中 [data-approval-key]${out.approval !== approvalRoot ? ' → 卡面' : '(找不到卡面,用容器兜底)'}`,
);
}
}
if (CONFIG.modal) {
out.modal = findModalCandidate(root, vw, vh);
if (out.modal) {
out.notes.push(`弹窗:命中「${(out.modal.className || out.modal.tagName).toString().slice(0, 40)}」`);
}
}
return out;
}
/** 打标记;返回是否新打上。 */
function tag(el, cls) {
if (!el || !(el instanceof Element)) return false;
if (el.classList.contains(cls)) return false;
el.classList.add(cls);
el.dataset.dshlg = cls;
return true;
}
function untagAll() {
document.querySelectorAll('[data-dshlg]').forEach((el) => {
el.classList.remove(CLS_GLASS, CLS_TOOLBAR, CLS_SIDE, CLS_RIGHT, CLS_APPROVAL, CLS_MODAL);
delete el.dataset.dshlg;
});
document.querySelectorAll('[data-dshlg-closed]').forEach((el) => {
delete el.dataset.dshlgClosed;
});
}
/* ── 背景层(给玻璃一点可折射的东西)───────────────── */
function buildBackdrop(theme) {
if (!CONFIG.backdrop) return '';
const base = theme.base ?? { r: 13, g: 17, b: 23 };
// 色斑用「明暗两套」固定色调,而不是从底色推 —— 推算出来的太接近底色,
// 玻璃等于没有东西可折射(这正是第一版看起来"没效果"的原因)。
const palette = theme.dark
? ['#1d3fa8', '#5b1f96', '#0e6b60']
: ['#b9d2ff', '#ffc9e2', '#bdf0e6'];
const [c1, c2, c3] = palette.map(parseColor);
const blobOpacity = theme.dark ? 0.72 : 0.9;
return `
#${BG_ID} {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background: ${toHex(base)};
overflow: hidden;
}
#${BG_ID} i {
position: absolute;
width: 80vmax;
height: 80vmax;
border-radius: 50%;
filter: blur(16vmax);
opacity: ${blobOpacity};
mix-blend-mode: ${theme.dark ? 'screen' : 'normal'};
}
#${BG_ID} i:nth-child(1) { background: radial-gradient(circle, ${toHex(c1)} 0%, transparent 68%); animation: dshlg-a 58s ease-in-out infinite; }
#${BG_ID} i:nth-child(2) { background: radial-gradient(circle, ${toHex(c2)} 0%, transparent 68%); animation: dshlg-b 74s ease-in-out infinite; }
#${BG_ID} i:nth-child(3) { background: radial-gradient(circle, ${toHex(c3)} 0%, transparent 68%); animation: dshlg-c 92s ease-in-out infinite; }
@keyframes dshlg-a { 0%,100% { transform: translate(-24%,-18%) } 50% { transform: translate(12%,16%) } }
@keyframes dshlg-b { 0%,100% { transform: translate(56%,10%) } 50% { transform: translate(16%,-22%) } }
@keyframes dshlg-c { 0%,100% { transform: translate(6%,54%) } 50% { transform: translate(50%,24%) } }
@media (prefers-reduced-motion: reduce) { #${BG_ID} i { animation: none } }
`;
}
function ensureBackdrop(theme) {
if (!CONFIG.backdrop) return;
let el = document.getElementById(BG_ID);
if (!el) {
el = document.createElement('div');
el.id = BG_ID;
el.setAttribute('aria-hidden', 'true');
el.innerHTML = '';
document.body.prepend(el);
}
const want = theme.dark ? 'dark' : 'light';
// 只在真的变了才写:这个属性也是 上的变动,会惊动我们自己的 observer
if (el.dataset.theme !== want) el.dataset.theme = want;
}
/**
* 移除内置背景层。
*
* ⚠️ 必须先判存在再删。`?.remove()` 对**不存在的节点**也是安全的,
* 但它在旧实现里每遍都会被调用 —— 而 `ChildList` 型 MutationObserver
* 连「删除一个不存在的节点」这种空操作都不会触发,真正的问题在别处:
* 一旦这个节点在两种状态间反复创建/删除,observer 就会互相触发形成churn。
* 所以这里保持「不存在就什么都不做」的语义,并且调用方也只在需要时调。
*/
function removeBackdrop() {
const el = document.getElementById(BG_ID);
if (el) el.remove();
}
/* ── 基础样式:透明化 + 令牌 + 内容块底色 ───────────── */
/**
* 应用自身底色透明化 + 把 DSH 的背景令牌改成(半)透明。
*
* 不管用不用壁纸都必须有这一段 —— 否则应用是不透明的,
* 壁纸看不见、玻璃也没东西可折射。
*
* 令牌**沿用应用自己的颜色**(readTheme 记下的原色),只改 alpha:
* 浅色主题得浅色面板、深色主题得深色面板,
* 文字对比度始终由应用自己的配色保证,不需要判断主题。
*/
function buildBaseCss(theme) {
const captured = theme?.tokens ?? {};
const fallback = theme?.base ?? { r: 13, g: 17, b: 23 };
const dark = theme?.dark !== false;
const panel = clamp01(CONFIG.panelAlpha);
const overlay = clamp01(CONFIG.overlayAlpha);
/** 令牌取原色 + 指定透明度;全透明时直接输出 transparent,更干净。 */
const tokenValue = (name, alpha) => {
const a = Math.max(0, alpha);
if (a <= 0) return 'transparent';
return rgbaOf(captured[name] ?? fallback, a);
};
// 文字衬底阴影:跟随「实际文字色」而不是主题 ——
// 强制白字时,浅色主题原本的白阴影会失效,必须反转成深色。
const ts = clamp01(CONFIG.textShadow);
const textColor = String(CONFIG.textColor ?? '').trim();
const tcRgb = parseColor(textColor);
// 文字偏亮 → 用黑阴影;文字偏暗 → 用白阴影
const textIsLight = tcRgb
? (0.2126 * tcRgb.r + 0.7152 * tcRgb.g + 0.0722 * tcRgb.b) / 255 > 0.5
: !dark;
const shadowColor = (a) =>
textIsLight ? `rgba(0,0,0,${a})` : `rgba(255,255,255,${Math.min(1, a + 0.25)})`;
const shadowCss =
ts > 0
? `
/* 面板很透时,给文字加一层衬底 —— 压在壁纸上可读性会好很多。
text-shadow 是可继承属性,写在 #root 上即可覆盖整棵树。
阴影颜色跟着文字色走:白字配黑晕,深字配白晕。 */
#root {
text-shadow: 0 1px 2px ${shadowColor(ts)},
0 0 2px ${shadowColor(ts * 0.7)};
}
`
: '';
// 文字颜色覆盖
let textCss = '';
if (textColor) {
const secondary = CONFIG.textColorSecondaryToo ? textColor : '';
textCss = `
/* 强制文字颜色 */
html, body, #root {
--dsw-alias-label-primary: ${textColor} !important;${secondary ? `\n --dsw-alias-label-secondary: ${secondary} !important;` : ''}
}
${
CONFIG.textColorAggressive
? `/* 连写死颜色的元素也一起覆盖(含子元素) */
#root, #root * { color: ${textColor} !important; }`
: ''
}
`;
}
/* ── 清除背景块 ────────────────────────────────────────
* 这是"不要白块"的主力,但**分层清**:
*
* · 不透明底色一律清掉 → 壁纸才透得出来;
* · 内容块(对话卡片、代码块、工具调用框)另给一层**极淡的玻璃底**,
* 否则文字直接压在壁纸上(v1.8 的老毛病);
* · 侧栏 / 右栏 / 浮层 / 审批卡 的玻璃底**永不参与清理** —— 它们是
* 靠本插件自己的 `data-dshlg` / `data-dshlg-region` 标记认出来的,
* 下面用 :not() 排除。
*/
const fill = String(CONFIG.contentFill ?? 'glass');
const fillAlpha = clamp01(CONFIG.contentFillAlpha, 0.06);
const keepList = ['[data-dshlg-keep]', '[data-dshlg]', '[data-dshlg-region]'];
const notKeep = keepList.map((s) => `:not(${s})`).join('');
let structural = '';
let contentGlassCss = '';
if (fill === 'glass' || fill === 'none') {
structural = `
/* 清掉不透明底色(保留 background-image:图标与渐变不受影响)。
!important 能压过元素上的内联 style="background-color:…"。
下面 :not() 里那几类是本插件自己的玻璃面,绝不能清。 */
#root, #root *${notKeep} {
background-color: transparent !important;
}
`;
if (fill === 'glass') {
contentGlassCss = `
/* 内容块补一层极淡的玻璃底:只清不铺的话,字会直接压在壁纸上。
放在 html 上、用 :where() 保持零特异性 —— 任何组件自己的
背景规则(哪怕只写 background:)都能压过它。 */
html {
--dshlg-content-fill: rgba(255, 255, 255, ${dark ? fillAlpha : Math.min(1, fillAlpha + 0.1)});
}
:where(#root, #root *)${notKeep} {
background-color: var(--dshlg-content-fill, transparent);
}
`;
}
} else {
const depth = Math.max(0, Math.min(8, Number(CONFIG.clearBackgroundDepth) || 0));
if (depth > 0) {
const selectors = [];
let sel = '#root';
for (let i = 0; i < depth; i++) {
sel += ' > *';
selectors.push(sel);
}
structural = `
/* 逐层清掉容器底色(${depth} 层)—— 应对没走主题令牌、写死底色的容器 */
${selectors.join(',\n')} {
background-color: transparent !important;
}
`;
}
}
return `
html, body { background: transparent !important; background-color: transparent !important; }
#root { position: relative; z-index: 1; background: transparent !important; }
${structural}${contentGlassCss}${shadowCss}${textCss}
/* 半透明化 DSH 的背景令牌。!important 是关键:这些令牌是作为
内联样式写在 body 上的,而内联样式优先级高于普通样式表规则。
注意这里**不再**把层令牌做成全透明:中间工作区之所以能全透明,
是因为它的底色本来就来自 bg-base(上一行直接置 transparent);
两侧栏另有自己的玻璃面(见 buildRegionCss),不靠令牌「透」。 */
html, body, #root {
--dsw-alias-bg-base: transparent !important;
--dsw-alias-bg-layer-1: ${tokenValue('--dsw-alias-bg-layer-1', panel)} !important;
--dsw-alias-bg-layer-2: ${tokenValue('--dsw-alias-bg-layer-2', Math.max(0, panel - 0.08))} !important;
--dsw-alias-bg-overlay: ${tokenValue('--dsw-alias-bg-overlay', overlay)} !important;
--dsw-specific-sidebar-fill: ${tokenValue('--dsw-specific-sidebar-fill', Math.max(0, panel - 0.05))} !important;
}
`;
}
function buildWallpaperCss() {
const w = CONFIG.wallpaper;
return `
#${WALL_ID} {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
background: #000;
opacity: ${clamp01(w.opacity, 1)};
}
#${WALL_ID} iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
${num(w.blur) > 0 ? `filter: blur(${num(w.blur)}px);` : ''}
}
/* 压暗层:壁纸太亮会影响读字 */
#${WALL_ID}::after {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, ${clamp01(w.dim)});
}
`;
}
/* ── 分层玻璃 CSS(iOS 26 Liquid Glass)─────────────── */
/** 边缘带占短边的比例 → 让透镜感在「细边」上也是同一段厚度。 */
function edgeBandPct(widthRatio) {
const ratio = Math.max(0.03, Math.min(0.45, num(widthRatio, 0.12)));
return Math.max(3, Math.min(45, ratio * 200)); // 长边 / 短边 = 2 倍关系
}
/**
* 边缘透镜让位:折射滤镜的变量(全局一份)。
*
* 只弯折**边缘一圈**,中间不扭:
* · feTurbulence 造噪声 → feGaussianBlur 柔化 → feDisplacementMap 位移像素
* · 位移图用 `` 裁成「外圈亮、内部黑」,并且把外圈亮度调低(约 35%)
* —— 因为 feDisplacementMap 对 0.5 灰就取 1.0 的位移量(0.5 灰 = 满幅),
* 不压亮度的话边缘会扭得过分。
* · maskUnits=objectBoundingBox:掩码跟着**每块面自己的尺寸**缩放,
* 所以侧栏(整列)与审批卡(小卡片)拿到的是同一条相对厚度的透镜边。
*
* 不支持 backdrop-filter 里用 SVG 滤镜时,CSS 里的兜底声明(纯 blur)仍然生效。
*/
/**
* 关键材质兜底(**内联、永远在**)。
*
* 为什么要有这一层:styles.css 是外置文件,加载可能失败 ——
* 实测 `file://` 页面下 `` 会被当成跨源请求直接拦掉
* (file:// 是不透明源),于是整份材质都不生效,玻璃只剩一层白纱。
* 所以这里内联最关键的十几条:玻璃/模糊、收起透明、中栏透明、
* 高光与圆角。它保证「无论 styles.css 在不在,界面都是对的」。
* styles.css 里的完整版会在加载成功后覆盖这些(同选择器、更靠后)。
*/
/** styles.css 只尝试加载一次 */
let styleLoadTried = false;
const CRITICAL_CSS = `
/* 一块玻璃面:底色 + 模糊 + 描边 + 顶光(细节在 styles.css)
⚠️ 必须带 #root 前缀(提高一级特异性):动态样式里有一条
\`#root, #root * { background-color: transparent !important }\`,
单靠 \`[data-dshlg-region]\` 会在层叠里输给它 —— 结果就是
「有底色、没模糊」的半成品玻璃(实测踩过)。 */
#root [data-dshlg-region]:not(.dshlg-brand) {
box-sizing: border-box !important;
background-color: rgba(var(--dshlg-tone, 255, 255, 255), var(--dshlg-alpha, 0.08)) !important;
border: var(--dshlg-border, 1px) solid rgba(255, 255, 255, var(--dshlg-border-a, 0.18)) !important;
border-radius: var(--dshlg-radius, 20px) !important;
backdrop-filter: var(--dshlg-filter, blur(18px) saturate(1.6));
-webkit-backdrop-filter: var(--dshlg-filter, blur(18px) saturate(1.6));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, var(--dshlg-topline, 0.3)) !important;
}
/* 中栏与收起态:透明(同样带特异性,免得被兜底规则按回去) */
#root [data-dshlg-region="middle"],
#root [data-dshlg-region="middle"] *,
#root [data-dshlg-region="right"][data-dshlg-closed],
#root [data-dshlg-region="right"][data-dshlg-closed] * {
background-color: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
#root [data-dshlg-region="middle"],
#root [data-dshlg-region="right"][data-dshlg-closed] {
box-shadow: none !important;
border: 0 !important;
}
/* 玻璃预算:超出的面不吃 backdrop-filter */
#root [data-dshlg-glass-over] {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* 官方品牌节点被替换时隐藏(display:none 比 visibility 干净,不占位) */
#root .dshlg-brand [data-dshlg-hidden] {
display: none !important;
}
/* 自定义品牌文字:深蓝宝石渐变(文字渐变不受 SVG 底板问题影响)。
注意特异性 (1,2,0) 高于上面那条清底的 (1,1,0),所以这里的渐变不会被清掉。 */
/* ══ 品牌文字:宝石质感 ══════════════════════════════════════
两层背景(先写的在上层):
① 窄白高光带 —— 扫过字面时就是「布灵」的那一下
② 切面渐变 —— 铺满文字,让每个字都有明暗过渡
配色按 html[data-dshlg-gem] 切换;默认 ice(整体提亮,暗色壁纸上清楚)。 */
/* 预设一:冰钻白蓝 —— 整体亮,暗色壁纸上最清楚(默认) */
/* 预设二:香槟金 —— 最像珠宝,冷暖壁纸都压得住 */
/* 预设三:青玉 —— 冷色系,偏翡翠 */
/* 放大 + 居中:字更大,并在品牌行内水平居中 */
/* 承载容器也要能居中(DSH 的品牌按钮默认左对齐) */
/* 鲸鱼/鱼标:彻底不显示(用户要求去掉)。直接按稳定类名后缀隐藏 ——
不依赖 JS 打的 data 属性,避免「标记没打上就不生效」。 */
#root .dshlg-brand [class*="_brandMark"] {
display: none !important;
}
#root .dshlg-brand [class*="_brand"] {
display: flex !important;
justify-content: flex-start !important; /* 文字靠左 */
align-items: center !important;
width: 100% !important;
}
#root .dshlg-brand .dshlg-brand-label {
/* ══ 宝石质感(两层背景,先写的在上层)══════════════════════
① 窄白高光带 —— 260% 宽、左右扫动,扫过字面时就是「布灵」那一下
② 切面渐变 —— 铺满文字,每个字都有明暗过渡
两层都被 background-clip:text 裁进文字,只作用在字上。
配色用**具体值**写,不用 CSS 变量:变量一旦在计算值阶段无效,
整条 background-image 会退化成 none(实测踩过)。 */
background-image:
linear-gradient(100deg,
rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0) 41%,
rgba(255, 255, 255, 0.98) 50%,
rgba(255, 255, 255, 0) 59%, rgba(255, 255, 255, 0) 100%),
linear-gradient(165deg,
#ffffff 0%, #e0f2fe 16%, #bae6fd 32%, #7dd3fc 48%,
#a5f3fc 64%, #e0f2fe 80%, #ffffff 100%) !important;
background-size: 260% 100%, 100% 100% !important;
background-position: -70% 50%, 0 0 !important;
background-repeat: no-repeat, no-repeat !important;
background-color: transparent !important;
-webkit-background-clip: text !important;
background-clip: text !important;
-webkit-text-fill-color: transparent !important;
color: #7dd3fc;
/* 更大、更粗、居中 */
font: 800 23px/1.25 "Segoe UI", system-ui, "Microsoft YaHei", sans-serif !important;
letter-spacing: .05em !important;
white-space: nowrap;
display: inline-flex;
align-items: center;
flex: 1 1 auto;
width: 100%;
padding-left: 2px;
justify-content: flex-start;
text-align: left;
/* 外侧深投影(浅壁纸上读得出)+ 内侧蓝光(暗壁纸上发光) */
filter:
drop-shadow(0 1px 1px rgba(8, 20, 60, 0.6))
drop-shadow(0 2px 6px rgba(8, 20, 60, 0.5))
drop-shadow(0 0 12px rgba(186, 230, 253, 0.75));
animation: dshlg-gem-shimmer 5.5s linear infinite;
}
/* 承载容器居中(DSH 的品牌按钮默认左对齐) */
#root .dshlg-brand [class*="_brand"] {
display: flex !important;
justify-content: center !important;
width: 100% !important;
}
/* 预设二:香槟金 */
html[data-dshlg-gem='gold'] #root .dshlg-brand .dshlg-brand-label {
background-image:
linear-gradient(100deg,
rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0) 41%,
rgba(255, 255, 255, 0.98) 50%,
rgba(255, 255, 255, 0) 59%, rgba(255, 255, 255, 0) 100%),
linear-gradient(165deg,
#fffdf5 0%, #fef3c7 18%, #fcd34d 36%, #f59e0b 54%,
#fbbf24 70%, #fef9c3 86%, #ffffff 100%) !important;
}
/* 预设三:青玉 */
html[data-dshlg-gem='aqua'] #root .dshlg-brand .dshlg-brand-label {
background-image:
linear-gradient(100deg,
rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0) 41%,
rgba(255, 255, 255, 0.98) 50%,
rgba(255, 255, 255, 0) 59%, rgba(255, 255, 255, 0) 100%),
linear-gradient(165deg,
#ffffff 0%, #ccfbf1 18%, #5eead4 36%, #2dd4bf 54%,
#14b8a6 70%, #99f6e4 86%, #ffffff 100%) !important;
}
@keyframes dshlg-gem-shimmer {
0% { background-position: -70% 50%, 0 0; }
100% { background-position: 170% 50%, 0 0; }
}
/* 尊重「减少动态效果」:保留静态切面,不流光 */
@media (prefers-reduced-motion: reduce) {
/* 品牌流光必须在这里关掉 —— 之前是个空规则体,等于没生效。 */
#root .dshlg-brand .dshlg-brand-label {
animation: none !important;
background-position: -20% 50%, 0 0 !important;
}
}
/* ══ 品牌区内**任何**后代都不许有自己的底 ══════════════════════
用户反馈:「harness 背后是一个蓝块」。这块底不是字标自带的(官方 BrandWordmark
是 7 个 fill=currentColor 的 path),而是某个包裹元素/伪元素照样带着背景。
与其逐个猜是哪个元素,这里一次性立规矩:品牌区内所有后代一律无底、无描边、
无阴影,伪元素不产生内容 —— 唯一允许的底是品牌行自己的 ::before 玻璃。
需要保留的交互反馈(悬停)由下面单独一条补回来。 */
#root .dshlg-brand *,
#root .dshlg-brand *::before,
#root .dshlg-brand *::after {
background: none !important;
background-color: transparent !important;
background-image: none !important;
border-color: transparent !important;
box-shadow: none !important;
}
#root .dshlg-brand *::before,
#root .dshlg-brand *::after {
content: none !important;
}
/* 补两个「不是 background 也能画出底」的属性:
border-image 会绕过 border-color: transparent 继续画;
background-clip 也可能把渐变裁成色块。 */
#root .dshlg-brand *,
#root .dshlg-brand *::before,
#root .dshlg-brand *::after {
border-image: none !important;
background-clip: border-box !important;
-webkit-background-clip: border-box !important;
mix-blend-mode: normal !important;
}
#root .dshlg-brand [data-dshlg-flat]:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
border-radius: 10px !important;
}
/* 品牌区内的任何「行级玻璃」一律透明 —— 品牌按钮不该有自己的底。 */
#root .dshlg-brand .dshlg-bar,
#root .dshlg-brand [data-dshlg-flat] {
background: none !important;
background-color: transparent !important;
background-image: none !important;
border: 0 !important;
box-shadow: none !important;
}
/* 左侧栏「行级玻璃」:淡底 + 内高光 + 圆角,**不做模糊**(一列十几行太贵) */
#root .dshlg-bar {
/* 0.05 → 0.028:侧栏整列已有一层 0.138,行上再叠 0.05 会让侧栏
整体比右栏「厚」—— 用户反馈「右栏更透明」就是这么来的。 */
background-color: transparent !important;
background-image: none !important;
border: 1px solid rgba(255, 255, 255, 0.06) !important;
border-radius: 10px !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* ══ 左上角品牌:蓝色液态玻璃 ══════════════════════════════════
⚠️ 关键:玻璃画在 ::before 上,.dshlg-brand 本身**绝不碰 backdrop-filter**。
原因:DSH 在 Windows 下把「收起侧边栏」按钮放在 logoRow **内部**,
而它是 position:fixed。按 CSS 规范,backdrop-filter 取 none 以外的值时,
该元素会成为 fixed 后代的**包含块** —— 于是那个按钮不再相对视口定位,
而是相对这块玻璃定位,**正好压在 DeepSeek Harness 字上**(用户报的重叠)。
挪到伪元素后,行本身不产生包含块,按钮回到视口定位。
(注意:这段在模板字符串内部,注释里不能出现反引号。) */
#root .dshlg-brand {
position: relative;
box-sizing: border-box;
isolation: isolate;
background: none !important;
border: 0 !important;
box-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
margin: 2px 4px 6px !important;
padding: 4px 8px !important;
}
#root .dshlg-brand::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: 14px;
background-color: rgba(var(--dshlg-brand-tone, 147, 197, 253),
calc(var(--dshlg-brand-a, 0.42) * 0.62));
background-image:
radial-gradient(120% 160% at 10% 0%,
rgba(255, 255, 255, calc(var(--dshlg-brand-a, 0.42) * 0.75)) 0%,
rgba(255, 255, 255, 0) 62%),
linear-gradient(180deg,
rgba(255, 255, 255, calc(var(--dshlg-brand-a, 0.42) * 0.5)) 0%,
rgba(255, 255, 255, 0) 58%);
border: 1px solid rgba(var(--dshlg-brand-tone, 147, 197, 253),
calc(var(--dshlg-brand-a, 0.42) * 1.6));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, calc(var(--dshlg-topline, 0.3) * 1.5)),
inset 0 0 12px rgba(var(--dshlg-brand-tone, 147, 197, 253),
calc(var(--dshlg-brand-a, 0.42) * 0.8)),
0 4px 18px rgba(0, 0, 0, 0.2);
backdrop-filter: var(--dshlg-brand-filter, none);
-webkit-backdrop-filter: var(--dshlg-brand-filter, none);
}
/* 品牌文字:白 → 蓝 渐变填充。
只写 background-image —— background 简写会把背景色一起重置,渐变丢失、
文字退回灰白(实测踩过)。配色从纯白过渡到深蓝,保证在浅色玻璃上也读得出。 */
/* ══ 关键:官方字标是 SVG,不是文字 ══════════════════════════
DSH 的品牌名由 dsh-client-ui-brand-official 注册,渲染的是
BrandWordmark —— 一个 viewBox 为 26 0 156 24 的矢量字标(宽 = 高 × 6.5),
内部 fill="none"、path 各自带 fill。
所以:对 _brandName 做文字渐变填充**完全没有作用**(没有文本节点),
真机上看到的就是一块被填成白色的字标 —— 用户说的「白块,都看不出来」。
正确做法是直接改 path 的 fill。 */
/* 鱼标与字标:深蓝宝石质感。
fill 指向运行时注入的渐变(#dshlg-sapphire)—— SVG 形状不能直接吃 CSS 渐变,
但 fill: url(#id) 可以引用文档里的 。
再叠一层「顶部白色高光 + 底部深色投影」,做出宝石的立体感。 */
#root .dshlg-brand svg path,
#root .dshlg-brand svg rect,
#root .dshlg-brand svg circle,
#root .dshlg-brand svg polygon {
fill: url(#dshlg-sapphire) !important;
color: rgb(var(--dshlg-brand-ink, 30, 64, 175)) !important;
}
#root .dshlg-brand svg {
filter:
drop-shadow(0 1px 0 rgba(255, 255, 255, 0.65))
drop-shadow(0 2px 3px rgba(12, 30, 80, 0.45));
}
/* 文字兜底:若某版本品牌名真的是文本(fallbackBrandName / localBuildTitle),
深蓝实色填充即可(不用渐变,避免又走回被覆盖的老路)。 */
#root .dshlg-brand [class*="_brandName"],
#root .dshlg-brand [class*="_fallbackBrandName"],
#root .dshlg-brand [class*="_localBuildTitle"],
#root .dshlg-brand [class*="_buildVersion"] {
background-image: none !important;
background-color: transparent !important;
-webkit-text-fill-color: rgb(var(--dshlg-brand-ink, 30, 64, 175)) !important;
color: rgb(var(--dshlg-brand-ink, 30, 64, 175)) !important;
font-weight: 600 !important;
}
/* 鱼标:递归上蓝(DSH 的 SVG 可能是 currentColor,也可能是写死的白) */
#root .dshlg-brand [class*="_brandMark"] svg,
#root .dshlg-brand [class*="_brandMark"] svg *,
#root .dshlg-brand [class*="_brandName"] svg,
#root .dshlg-brand [class*="_brandName"] svg * {
color: rgb(var(--dshlg-brand-ink, 30, 64, 175)) !important;
/* 宝石渐变(而不是纯色)—— 规则顺序上这条比 svg path 那条更靠后、特异性更高,
所以渐变必须也写在这里,否则会被纯色盖掉。 */
fill: url(#dshlg-sapphire) !important;
}
#root .dshlg-brand [class*="_brandMark"] {
filter: drop-shadow(0 1px 1px rgba(255, 255, 255, 0.6));
}
/* ══ 边缘渐隐带(借鉴 Aqua)══════════════════════════════════
顶/底各一条 13px 模糊带:聊天内容滚到边缘时渐入模糊,
但更高的玻璃面(侧栏 / 输入栏 / 控制条)不受影响,保持清晰。 */
#dshlg-fade-top,
#dshlg-fade-bottom {
position: fixed;
left: 0;
right: 0;
height: 13px;
z-index: 7;
pointer-events: none;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
/* 极淡的一层纱:太浓会在玻璃面下显出一道膜 */
background: rgba(255, 255, 255, 0.2);
}
body[data-ds-dark-theme] #dshlg-fade-top,
body[data-ds-dark-theme] #dshlg-fade-bottom {
background: rgba(0, 0, 0, 0.15);
}
#dshlg-fade-top {
top: 0;
-webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 100%);
mask-image: linear-gradient(180deg, #000 0%, transparent 100%);
}
#dshlg-fade-bottom {
bottom: 0;
-webkit-mask-image: linear-gradient(0deg, #000 0%, transparent 100%);
mask-image: linear-gradient(0deg, #000 0%, transparent 100%);
}
@media (prefers-reduced-transparency: reduce) {
#dshlg-fade-top, #dshlg-fade-bottom { display: none; }
}
/* ══ 可读性:正文暗描影(只在亮壁纸上开)═══════════════════════
全透明下文字边缘直接贴壁纸,亮壁纸会「糊边」。
这里用 1px 暗描影把字边缘压住 —— 比盖纱轻得多,且不改变玻璃观感。
只在实测壁纸偏亮时开(html[data-dshlg-bright-wall]),暗壁纸保持纯净。
选择器放宽到 #root 整体(原规则盯的 [data-conversation-region] 真机上
只出现在输入区,等于没命中)。 */
html[data-dshlg-bright-wall] #root,
html[data-dshlg-bright-wall] #root * {
text-shadow: 0 0 1px rgba(0, 0, 0, 0.55), 0 1px 2px rgba(0, 0, 0, 0.35) !important;
}
/* 自制界面不跟着加(它们自带底色与描边)。
⚠️ 但设置面板 / 控制中心现在是**深底**:亮壁纸下它们要的是暗描影(把白字
压住),不是 none —— 正确值在它们自己那条 data-dshlg-bright-wall 规则里。 */
html[data-dshlg-bright-wall] #dshlg-controls,
html[data-dshlg-bright-wall] #dshlg-controls * {
text-shadow: none !important;
}
/* ══ 弹窗/设置面板必须是**可读卡片**,不能全透明 ══════════════════
结构性窗格(左右侧栏、输入栏)走全透明是设计定调;
但弹窗、菜单、设置对话框是「浮在内容之上的可读面」——
全透明会让它和背后的工作区文字叠在一起,两边都读不了。
这里给它们一层真正的板(不是玻璃纱),保证字读得出。 */
#root [role='dialog'],
#root [role='dialog'] *,
#root [data-menu-material] {
--dshlg-plate: 1;
}
#root [role='dialog'] {
background-color: rgba(var(--dshlg-tone, 255, 255, 255), 0.88) !important;
background-image: none !important;
backdrop-filter: var(--dshlg-filter-plate, blur(18px) saturate(1.5)) !important;
-webkit-backdrop-filter: var(--dshlg-filter-plate, blur(18px) saturate(1.5)) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
0 24px 64px rgba(0, 0, 0, 0.42) !important;
border: 1px solid rgba(255, 255, 255, 0.28) !important;
}
/* 深色主题下用深板,否则白板刺眼 */
body[data-ds-dark-theme] #root [role='dialog'] {
background-color: rgba(24, 28, 38, 0.9) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
}
/* 厚底上的字必须是深色(深色主题下保持浅色)——底与字绑成一个决策 */
#root [role='dialog'],
#root [role='dialog'] * {
color: #16233a !important;
}
body[data-ds-dark-theme] #root [role='dialog'],
body[data-ds-dark-theme] #root [role='dialog'] * {
color: #eaf0fa !important;
}
/* 菜单/popover:与面板同一块深色玻璃(2026-09-27 用户定稿)。
⚠️ 这条比 buildOverlayCss 里那条**特异性更高**(多了 #root),真正生效的是这里。
两处必须一起改 —— 只改一处会得到「改了没反应」的假象
(本项目的老坑:行内变量与特异性都会悄悄压过你以为生效的那条)。 */
#root [data-menu-material][data-menu-material][data-menu-material][data-menu-material] {
/* 真正的底色在内部 .material 层(z-index:-1,用 --dsw-menu-surface-fill)——
见上面 buildOverlayCss 里的详细说明;这里同样必须改令牌。 */
--dsw-menu-surface-fill: rgba(10, 14, 20, 0.82) !important;
background-color: rgba(10, 14, 20, 0.82) !important;
background-image: none !important;
color: #eaf2ff !important;
}
/* 浅字只给「不带危险色」的项(保住「退出登录」这类红项) */
#root [data-menu-material] :not([class*='danger']):not([class*='Danger']) {
color: #eaf2ff !important;
}
body[data-ds-dark-theme] #root [data-menu-material][data-menu-material][data-menu-material][data-menu-material] {
background-color: rgba(8, 11, 17, 0.86) !important;
}
/* ⚠️ 上面属性为什么写四遍:动态样式里有一条
「#root, #root *:not([data-dshlg-keep]):not([data-dshlg]):not([data-dshlg-region])
{ background-color: transparent !important }」,特异性高达 **(0,4,0)**,
会把 #root 里任何元素的底色清成透明。
真机那个账号菜单是 **portal 到 body** 的(不在 #root 里),所以它躲过了那条;
但万一某个版本把菜单放进 #root,普通写法就会被清掉 → 这里靠重复属性把
特异性抬到 (0,5,0) 压过它。重复四次是 CSS 里最省事的「提权」写法,
不是手滑,别删。(这类「改了没反应」的坑本项目踩过:行内变量、特异性。) */
/* ══ DSH 自带设置面板(SettingsShell 覆盖层)必须不透明 ══════════════
真身:_overlay{position:fixed;inset:0;background:var(--dsw-alias-bg-base)}
本插件把 --dsw-alias-bg-base 改成透明(为了让壁纸透出),
这个全屏覆盖层就跟着变透明 → 面板文字与工作区文字叠在一起看不清。
按**稳定语义后缀**命中(DSH 的类名是模块哈希 + 语义后缀,后缀稳定)。
⚠️ 2026-09-27 用户改主意了:要「深色半透明」(照图一那张任务卡),
不要白色实板(原话:图四「太实了」)。所以这里换成**深色半透明**。
随之而来的一条:字色不能再交给 DSH 令牌 —— 用户是浅色主题,DSH 给的是
深字,压在深底上就看不见了。改成在**面板**上强制浅字,见下面 _panel 段。
真身结构(从 DSH 自己的 app.asar 里挖出来的 CSS,不是猜的):
.y7bFDa_overlay{position:fixed;inset:0;display:flex;justify-content:center;align-items:center}
.y7bFDa_mask{background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur);position:absolute;inset:0}
.y7bFDa_panel{z-index:1;width:800px}
→ 全屏那层白纱来自 **overlay 自己的 background**,而 panel 是 overlay 的**子元素**。 */
[class*='_overlay'],
[class*='_scrim'] {
background-color: var(--dshlg-plate-bg, rgba(9, 12, 18, 0.6)) !important;
background-image: none !important;
}
body[data-ds-dark-theme] [class*='_overlay'],
body[data-ds-dark-theme] [class*='_scrim'] {
background-color: var(--dshlg-plate-bg-dark, rgba(8, 11, 17, 0.68)) !important;
}
/* 覆盖层里通常还有一层内层「面板」,一并给底,避免只有边缘实、内容区透 */
[class*='_overlay'] [class*='_panel'],
[class*='_overlay'] [class*='_card'],
[class*='_overlay'] [class*='_content'],
[class*='_overlay'] [class*='_body'],
[class*='_overlay'] [class*='_nav'],
[class*='_overlay'] [class*='_sidebar'],
[class*='_overlay'] [class*='_list'] {
background-color: transparent !important;
background-image: none !important;
}
/* ══ DSH 自带设置浮层的**面板本体**:也给同一块深色玻璃(2026-09-27 定稿)══
用户要求「图四那张设置卡也按图一的样式」。它原来是一块**浅色实板**,
压在深色壁纸上很跳。
真身类名(从 DSH 自己的 app.asar 里挖出来的,不是猜的):
y7bFDa_overlay(全屏层)+ y7bFDa_panel(卡片)+ _header / _nav / _content / _close
—— 哈希会随 DSH 版本变,所以只认**语义后缀**(本项目一贯做法)。
⚠️ 只动 _panel 那一层(卡片本体),**不动** _overlay(全屏那层):
[class*='_overlay'] 这个选择器很宽,DSH 别的浮层(如 vPH_UG_overlay)也会命中。
底与字绑成一个决策:底深了字必须跟着浅,否则就是深底压深字。
⚠️ 若 DSH 自己的**填充型**小控件(自带浅底的按钮)出现「白底白字」,
给它单独加一条 color 覆盖即可 —— 目前已核过 _header/_nav/_content/_close,
没有自带底的类型。 */
[class*='_overlay'] [class*='_panel'] {
background-color: rgba(10, 14, 20, 0.82) !important;
border: 1px solid rgba(147, 197, 253, 0.45) !important;
border-radius: 16px !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 12px 34px rgba(0, 0, 0, 0.42) !important;
}
[class*='_overlay'] [class*='_panel'],
[class*='_overlay'] [class*='_panel'] * {
color: #eaf2ff !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
/* ⚠️ 上面那条会把 DSH **自带浅底**的按钮也刷成白字 → 白底白字(实测踩到:
「打开配置文件」「查询用量」两个浅底按钮看不见字)。
这里把面板里的按钮/输入框统一成插件自己那一套(半透明白底 + 浅描边 + 白字),
既不会白底白字,观感也和插件面板一致 —— 顺带把 DSH 的主/次按钮差异抹平,
这正是「几个面板看起来像同一块材料」需要的。 */
[class*='_overlay'] [class*='_panel'] button,
[class*='_overlay'] [class*='_panel'] [role='button'] {
background-color: rgba(255, 255, 255, 0.1) !important;
background-image: none !important;
border: 1px solid rgba(255, 255, 255, 0.22) !important;
color: #eaf2ff !important;
}
[class*='_overlay'] [class*='_panel'] button:hover,
[class*='_overlay'] [class*='_panel'] [role='button']:hover {
background-color: rgba(255, 255, 255, 0.2) !important;
}
[class*='_overlay'] [class*='_panel'] input,
[class*='_overlay'] [class*='_panel'] select,
[class*='_overlay'] [class*='_panel'] textarea {
background-color: rgba(255, 255, 255, 0.08) !important;
border: 1px solid rgba(255, 255, 255, 0.22) !important;
color: #eaf2ff !important;
}
/* 自制界面不受影响(它们自带底色与描边) */
#dshlg-settings, #dshlg-controls, #dshlg-gallery, #dshlg-gear, #dshlg-restore {
--dshlg-plate-bg: unset;
}
/* ══ 可读性:正文 1px 描影(借鉴 Aqua)════════════════════════
全透明背景下文字边缘直接贴着壁纸,容易糊边。
1px 描影把字边缘的背景压住一点,是最省事的可读性提升。 */
#root [data-conversation-scroll],
#root [data-conversation-region] {
text-shadow: 0 0 1px rgba(0, 0, 0, 0.4);
}
body:not([data-ds-dark-theme]) #root [data-conversation-scroll],
body:not([data-ds-dark-theme]) #root [data-conversation-region] {
text-shadow: 0 0 1px rgba(255, 255, 255, 0.55), 0 1px 2px rgba(19, 45, 83, 0.08);
}
/* ══ 无障碍:焦点环 + 选区色(借鉴 Aqua)══════════════════════
半透明界面上默认焦点环对比不足,这两条成本极低但影响可用性。 */
#root :focus-visible {
outline: 2px solid rgba(110, 155, 232, 0.85);
outline-offset: 1px;
}
#root ::selection {
background: rgba(110, 155, 232, 0.35);
}
/* ══ 选中会话:左侧强调条 + 柔光(借鉴 Aqua)═════════════════ */
#root [role='treeitem'][aria-selected='true'] {
box-shadow:
inset 2px 0 0 var(--dsw-specific-sidebar-nav-item-active-accent, rgba(110, 155, 232, 0.9)),
0 0 16px rgba(110, 155, 232, 0.14);
}
/* ══ 关键防护(Aqua 源码里明确记录过的坑)═════════════════════
侧栏内出现固定定位弹层时,侧栏不能带 backdrop-filter:
否则该弹层会被重新锚定进侧栏(Aqua 实测弹窗宽度被压到 254px)。
这与本项目踩到的「包含块」问题同源,故再兜一道,
防止将来把侧栏的 frost 调回非零时复发。 */
#root [data-dshlg-region="side"]:has([role='dialog']),
#root .dshlg-brand:has([role='dialog']) {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* ══ 品牌里那个「白块 / 蓝块」的真身 ══════════════════════════
DSH 把整组标识(鱼标 + 字标)包在一个按钮里(_brand),
这个按钮自带底色 —— 用户看到的「白块」就是它。
⚠️ 不要给它画任何底(无论是白、透明还是蓝胶囊):
· 画成透明 → 字标 currentColor 是白色,白字在浅底上看不见
· 画成蓝色胶囊 → 字标改深蓝后又变成「深蓝字在蓝块上」,一样看不见
正解:让它彻底无底、无描边、无阴影,字标直接落在品牌行的浅蓝玻璃上。
也**不能**在这里强制子 svg 变白 —— 那会和品牌墨色规则打架。 */
#root [data-dshlg-flat] {
background: none !important;
background-color: transparent !important;
background-image: none !important;
border: 0 !important;
box-shadow: none !important;
outline: none !important;
}
#root [data-dshlg-flat]:hover {
background-color: rgba(255, 255, 255, 0.07) !important;
border-radius: 10px !important;
}
`;
/**
* 找一个「脚本自己的基准 URL」。
*
* 为什么不能用 import.meta.url:客户端半体是被 DSH 的加载器当作**普通
* 函数体**执行的(`window.__ModuleLoader__.load({factory})`),不是 ESM 模块,
* 所以 `import.meta` 根本不存在。
* 也不能只靠 document.baseURI:那是页面地址(dsh-app://app/),
* 插件文件却在 node_modules 下,拼出来必然 404。
*
* 可靠办法是从