fix: restore skin controls and desktop portrait compatibility

Respect host root layout, draw the scrim once, discover appearance controls without build hashes, and recover failed portraits independently. Add unit and real-browser regression coverage.

Refs #8, #12, #15, #10, #13. Native Desktop acceptance remains pending; no release.
This commit is contained in:
Leon
2026-10-16 15:02:25 +08:00
parent a25d4c8360
commit d0b7849c02
13 changed files with 1690 additions and 399 deletions

View File

@@ -60,10 +60,11 @@ dsh plugin --profile web add 'github:kingOfSoySauce/dsh-liang-skin'
- `滑动变祖`:显示滑块并启用人物、背景和自适应配色。 - `滑动变祖`:显示滑块并启用人物、背景和自适应配色。
- `原生`:移除背景层、皮肤变量和滑块,恢复 Harness 原生界面。 - `原生`:移除背景层、皮肤变量和滑块,恢复 Harness 原生界面。
- `滑动变祖绑定思考等级`:关闭后滑块只切换人物和配色,不修改模型的推理等级。
皮肤保持启用时,人物与背景配色由滑块档位决定;切换 DSH 原生深浅主题不会改变当前档位的背景。 皮肤保持启用时,人物与背景配色由滑块档位决定;切换 DSH 原生深浅主题不会改变当前档位的背景。
选择保存在当前浏览器本地,不会改动模型配置。 绑定开关保存在当前浏览器本地。切回原生外观只对本次客户端激活有效;刷新页面或重新激活插件后默认开启皮肤。外观选择的跨刷新记忆暂未实现。绑定开启时,滑块会提交模型的推理档位;关闭绑定后只改变外观。
<a id="cli-install"></a> <a id="cli-install"></a>
@@ -134,10 +135,13 @@ dsh plugin --profile web remove dsh-client-liang-intensity-skin
npm install npm install
npm test npm test
npm run build npm run build
npm run test:compatibility
``` ```
修改 client 源码后需要运行 `npm run build`,并一起提交更新后的 `lib/client.js` 和 source map。 修改 client 源码后需要运行 `npm run build`,并一起提交更新后的 `lib/client.js` 和 source map。
浏览器兼容性回归使用本机 Chrome / Chromium;可通过 `CHROME_PATH` 指定浏览器可执行文件,不会自动下载浏览器。测试使用宿主布局夹具,不能代替官方 Desktop 真机验收。本轮未发布修复的验证步骤见 [兼容性验证说明](docs/compatibility-validation.md)。
## 灵感与素材来源 ## 灵感与素材来源
本插件的“滑动变祖”视觉概念与人物图片素材源自 本插件的“滑动变祖”视觉概念与人物图片素材源自

View File

@@ -0,0 +1,35 @@
# 未发布的兼容性修复验证
本轮以已发布的 0.1.10 为基线,修复留在独立分支,不发布 npm 或版本标签。
## 变更
- 保留宿主的根容器定位和层级,背景使用独立层级;兼容社区 Desktop 的标题栏高度。
- 保留官方 Windows 框架透明与 macOS 侧栏、新会话按钮配色修复;半透明背景遮罩统一绘制一次。
- 外观入口按原生三个主题按钮的结构识别,兼容构建类名哈希变化;停用时清理入口和监听。
- 人像逐张加载和解码,单张失败不阻止其他帧使用;失败后分别等待 0.5、1.5、3 秒重试,最多额外三次。
- 未加载的目标帧保留上一张可用人像;全部失败时保留配色背景。停用时取消重试和请求,重新启用后重新加载。
- 保持素材原色、面板透明度及原有推理等级绑定语义。本轮不处理跨刷新外观记忆,也不修改皮肤市场。
## 本地验证包
验证包是完整的 `.tgz`,包含客户端构建与 24 张 WebP 人像;只用于本地验证。具体包路径、校验值和对应修复提交由交付消息提供。
官方 Desktop:使用当前宿主支持的本地包安装入口加载该 `.tgz`,由用户点击安装、启用和重启;不要把 Web profile 的安装结果当成 Desktop 已安装。若当前宿主没有本地包入口,先提供其实际入口信息,再选择安装路径,不直接编辑 profile。
DSH Web:可用 `dsh plugin --profile web add /绝对路径/测试包.tgz` 安装。安装或重新安装后完全停止并重新启动对应宿主;浏览器刷新不能替代 Desktop 完全退出重开。
## 用户验收
1. 安装测试包并完全退出、重开官方 Desktop,确认加载的是测试包;保留其他皮肤停用的状态。
2. 打开「设置 → 通用设置 → 外观皮肤」:应有「滑动变祖」和推理等级绑定开关;切原生后人物及滑块消失,点击滑动变祖后恢复。
3. 在浅色档和黑金档分别观察人物、侧栏和输入区;调整窗口大小、切换侧栏,确认无半屏断层、文字可读。
4. 拖动滑块:人物应随档位改变;关闭绑定后只改变人物和配色,不修改模型推理等级。
5. 打开「完全权限」、模型选择和其他弹窗,确认显示层级正常且可以点击;标题栏仍可拖动,窗口按钮可用。
6. 刷新或重启时,外观仍按本次激活默认启用皮肤;跨刷新记住原生选择属于暂缓的 #9。
自动化测试使用浏览器宿主夹具并模拟图片失败;它们不代表官方 Windows Desktop 真机已经验收。
## Issue / PR 收尾
已确认可收尾的 #2、#3、#6 关闭。#8、#12、#14、#15 与 PR #10、#13 在用户验证前保持开放;PR #9 留待后续处理。

View File

@@ -29,7 +29,7 @@ var import_react = require("react");
// src/client/skin.css // src/client/skin.css
var skin_default = `.liang-skin-backdrop { var skin_default = `.liang-skin-backdrop {
position: fixed; position: fixed;
z-index: 0; z-index: -1;
inset: 0; inset: 0;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
@@ -48,7 +48,7 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
right: 0; right: 0;
display: block; display: block;
width: min(42vw, 720px); width: min(42vw, 720px);
height: 100vh; height: 100%;
object-fit: cover; object-fit: cover;
object-position: 50% 42%; object-position: 50% 42%;
opacity: var(--liang-portrait-opacity, 0.45); opacity: var(--liang-portrait-opacity, 0.45);
@@ -75,11 +75,16 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
position: absolute; position: absolute;
inset: 0; inset: 0;
content: ""; content: "";
background: linear-gradient(90deg, var(--liang-page, transparent) 0 28%, transparent 57% 100%); background:
linear-gradient(90deg, var(--liang-page, transparent) 0 28%, transparent 57% 100%),
linear-gradient(var(--liang-bg-base, transparent), var(--liang-bg-base, transparent));
} }
body[data-liang-skin="on"] { body[data-liang-skin="on"] {
--dsw-alias-bg-base: var(--liang-bg-base) !important; /* Keep the backdrop above the canvas but below content and native portals.
The host owns root positioning, dimensions, and its own stacking order. */
isolation: isolate;
--dsw-alias-bg-base: transparent !important;
--dsw-alias-bg-layer-1: var(--liang-layer-1) !important; --dsw-alias-bg-layer-1: var(--liang-layer-1) !important;
--dsw-alias-bg-layer-2: var(--liang-layer-2) !important; --dsw-alias-bg-layer-2: var(--liang-layer-2) !important;
--dsw-alias-bg-layer-3: var(--liang-layer-3) !important; --dsw-alias-bg-layer-3: var(--liang-layer-3) !important;
@@ -239,11 +244,16 @@ body[data-liang-skin="on"]:is(
} }
body[data-liang-skin="on"] > #root { body[data-liang-skin="on"] > #root {
position: relative;
z-index: 1;
background: transparent !important; background: transparent !important;
} }
body[data-liang-skin="on"]:is(
[data-dsh-desktop-mode="compatibility"],
[data-dsh-desktop-mode="extended"]
) .liang-skin-backdrop {
top: var(--dsh-desktop-frame-height, 0px);
}
/* Windows paints the sidebar fill across the host frame, covering the portrait. /* Windows paints the sidebar fill across the host frame, covering the portrait.
Match macOS's transparent frame while keeping the sidebar, caption, and Match macOS's transparent frame while keeping the sidebar, caption, and
conversation surfaces painted by their own host rules. */ conversation surfaces painted by their own host rules. */
@@ -480,7 +490,7 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
display: flex; display: flex;
} }
[class*="_8HJdBW_cubeRow"]:has(.liang-appearance-choice) { [class*="_cubeRow"]:has(.liang-appearance-choice) {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
@@ -626,6 +636,324 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
} }
`; `;
// src/client/appearance.ts
var NATIVE_APPEARANCE_ROW = '[class*="_cubeRow"]';
var LIANG_APPEARANCE_BUTTON = "liang-appearance-choice";
var LIANG_BINDING_CONTROL = "liang-appearance-binding";
var LIANG_BINDING_INPUT = "liang-appearance-binding__input";
function hasClassSuffix(node, suffix) {
return [...node.classList].some((name) => name.endsWith(suffix));
}
function findAppearanceRow() {
for (const row of document.querySelectorAll(NATIVE_APPEARANCE_ROW)) {
if (!hasClassSuffix(row, "_cubeRow")) continue;
const nativeButtons = [...row.children].filter(
(node) => node instanceof HTMLButtonElement && hasClassSuffix(node, "_themeCube")
);
if (nativeButtons.length < 3) continue;
let group = row.parentElement;
while (group !== null && !hasClassSuffix(group, "_group")) {
group = group.parentElement;
}
group ??= row.parentElement;
if (group !== null) return { row, group, nativeButtons };
}
return null;
}
function installLiangAppearanceButton(scope, presenter, packageId) {
let pending = false;
let disposed = false;
let mounted = null;
const nativeClickHandlers = /* @__PURE__ */ new Map();
const unmount = () => {
for (const [button, handler] of nativeClickHandlers) {
button.removeEventListener("click", handler, { capture: true });
}
nativeClickHandlers.clear();
if (mounted === null) return;
mounted.button.removeEventListener("click", mounted.handleCustomClick);
mounted.bindingInput.removeEventListener("change", mounted.handleBindingChange);
mounted.button.remove();
mounted.binding.remove();
mounted = null;
};
const choose = async (enabled) => {
if (disposed || pending || scope.getSnapshot().enabled === enabled) return;
pending = true;
sync();
try {
await presenter.choose(enabled);
} catch {
} finally {
pending = false;
if (!disposed) sync();
}
};
const createMount = (row, group) => {
const english = document.documentElement.lang.toLowerCase().startsWith("en");
const button = document.createElement("button");
button.className = LIANG_APPEARANCE_BUTTON;
button.type = "button";
button.dataset.plugin = packageId;
button.setAttribute("aria-label", "\u6ED1\u52A8\u53D8\u7956");
const icon = document.createElement("span");
icon.className = "liang-appearance-choice__icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = "\u25C8";
const label = document.createElement("span");
label.className = "liang-appearance-choice__label";
label.textContent = english ? "Slider" : "\u6ED1\u52A8\u53D8\u7956";
button.append(icon, label);
const handleCustomClick = () => {
void choose(true);
};
button.addEventListener("click", handleCustomClick);
const binding = document.createElement("label");
binding.className = LIANG_BINDING_CONTROL;
binding.dataset.plugin = packageId;
const bindingCopy = document.createElement("span");
bindingCopy.className = "liang-appearance-binding__copy";
const bindingLabel = document.createElement("span");
bindingLabel.className = "liang-appearance-binding__label";
const bindingText = english ? "Bind slider to reasoning level" : "\u6ED1\u52A8\u53D8\u7956\u7ED1\u5B9A\u601D\u8003\u7B49\u7EA7";
bindingLabel.textContent = bindingText;
const bindingDescription = document.createElement("span");
bindingDescription.className = "liang-appearance-binding__description";
bindingDescription.id = "liang-appearance-binding-description";
bindingDescription.textContent = english ? "When off, the slider does not change the reasoning level." : "\u5173\u95ED\u4E4B\u540E\u6ED1\u5757\u4E0D\u8054\u52A8\u601D\u8003\u7B49\u7EA7";
bindingCopy.append(bindingLabel, bindingDescription);
const bindingInput = document.createElement("input");
bindingInput.className = LIANG_BINDING_INPUT;
bindingInput.type = "checkbox";
bindingInput.setAttribute("role", "switch");
bindingInput.setAttribute("aria-label", bindingText);
bindingInput.setAttribute("aria-describedby", bindingDescription.id);
const updateBinding = async () => {
if (disposed || pending) return;
try {
await scope.setBindEffort(bindingInput.checked);
} catch {
} finally {
if (!disposed) sync();
}
};
const handleBindingChange = () => {
void updateBinding();
};
bindingInput.addEventListener("change", handleBindingChange);
binding.append(bindingCopy, bindingInput);
return { row, group, button, binding, bindingInput, handleCustomClick, handleBindingChange };
};
const sync = () => {
if (disposed) return;
const target = findAppearanceRow();
if (target === null) {
unmount();
return;
}
if (mounted !== null && (mounted.row !== target.row || mounted.group !== target.group)) {
unmount();
}
mounted ??= createMount(target.row, target.group);
const { row, group, button, binding, bindingInput } = mounted;
if (button.parentElement !== row) row.append(button);
const snapshot = scope.getSnapshot();
button.disabled = pending;
button.setAttribute("aria-pressed", String(snapshot.enabled));
if (snapshot.enabled) {
if (binding.parentElement !== group) group.append(binding);
bindingInput.checked = snapshot.bindEffort;
bindingInput.setAttribute("aria-checked", String(snapshot.bindEffort));
bindingInput.disabled = pending;
} else {
binding.remove();
}
for (const [nativeButton, handler] of nativeClickHandlers) {
if (target.nativeButtons.includes(nativeButton)) continue;
nativeButton.removeEventListener("click", handler, { capture: true });
nativeClickHandlers.delete(nativeButton);
}
for (const nativeButton of target.nativeButtons) {
if (nativeClickHandlers.has(nativeButton)) continue;
const handleNativeClick = () => {
void choose(false);
};
nativeClickHandlers.set(nativeButton, handleNativeClick);
nativeButton.addEventListener("click", handleNativeClick, { capture: true });
}
};
const observer = new MutationObserver(sync);
observer.observe(document.body, { childList: true, subtree: true });
const unsubscribe = scope.subscribe(sync);
sync();
return () => {
if (disposed) return;
disposed = true;
observer.disconnect();
unsubscribe();
unmount();
};
}
// src/client/portrait-loader.ts
var RETRY_DELAYS = [500, 1500, 3e3];
function retryUrl(source, retry) {
if (retry === 0) return source;
const hashIndex = source.indexOf("#");
const hash = hashIndex < 0 ? "" : source.slice(hashIndex);
const base = hashIndex < 0 ? source : source.slice(0, hashIndex);
const queryIndex = base.indexOf("?");
const path = queryIndex < 0 ? base : base.slice(0, queryIndex);
const query = new URLSearchParams(queryIndex < 0 ? "" : base.slice(queryIndex + 1));
query.set("retry", String(retry));
return `${path}?${query}${hash}`;
}
function createPortraitLoader(options) {
const sources = options.sources.map((source) => ({ ...source }));
const assets = /* @__PURE__ */ new Map();
for (const { url } of sources) {
if (!assets.has(url)) {
assets.set(url, {
url,
status: "idle",
loadedUrl: null,
retries: 0,
image: null,
timer: null,
detach: null
});
}
}
const createImage = options.createImage ?? (() => new Image());
let onChange = options.onChange;
let active = false;
let disposed = false;
let generation = 0;
let targetLevel = sources[0]?.level ?? 0;
let displayedIndex = null;
let lastState = null;
const isCurrent = (run) => active && !disposed && generation === run;
const nearestIndex = (available) => {
let nearest = null;
for (let index = 0; index < sources.length; index += 1) {
if (!available(index)) continue;
const distance = Math.abs(sources[index].level - targetLevel);
const previous = nearest === null ? Infinity : Math.abs(sources[nearest].level - targetLevel);
if (nearest === null || distance < previous || distance === previous && sources[index].level > sources[nearest].level) {
nearest = index;
}
}
return nearest;
};
const isReady = (index) => assets.get(sources[index].url)?.status === "ready";
const emit = (state) => {
if (lastState?.index === state.index && lastState.url === state.url && lastState.status === state.status) return;
lastState = state;
onChange?.(state);
};
const reconcile = () => {
if (!active || disposed) return;
const target = nearestIndex(() => true);
if (target !== null && isReady(target)) displayedIndex = target;
else if (displayedIndex === null) displayedIndex = nearestIndex(isReady);
if (displayedIndex !== null) {
const asset = assets.get(sources[displayedIndex].url);
emit({ index: displayedIndex, url: asset.loadedUrl, image: asset.image, status: "ready" });
} else {
const exhausted = [...assets.values()].every((asset) => asset.status === "failed");
emit({ index: null, url: null, image: null, status: exhausted ? "failed" : "loading" });
}
};
const request = (asset, run) => {
if (!isCurrent(run)) return;
asset.status = "loading";
const image = createImage();
asset.image = image;
image.loading = "eager";
image.decoding = "async";
const url = retryUrl(asset.url, asset.retries);
let settled = false;
let decoding = false;
const current = () => !settled && isCurrent(run) && asset.image === image;
const detach = () => {
image.removeEventListener("load", handleLoad);
image.removeEventListener("error", handleError);
asset.detach = null;
};
const handleError = () => {
if (!current()) return;
settled = true;
detach();
image.removeAttribute("src");
asset.image = null;
if (asset.retries < RETRY_DELAYS.length) {
const delay = RETRY_DELAYS[asset.retries];
asset.retries += 1;
asset.timer = setTimeout(() => {
if (!isCurrent(run)) return;
asset.timer = null;
request(asset, run);
}, delay);
} else {
asset.status = "failed";
reconcile();
}
};
const handleLoad = () => {
if (!current() || decoding) return;
decoding = true;
void image.decode().then(() => {
if (!current()) return;
settled = true;
detach();
asset.loadedUrl = url;
asset.status = "ready";
reconcile();
}, handleError);
};
asset.detach = detach;
image.addEventListener("load", handleLoad);
image.addEventListener("error", handleError);
image.src = url;
};
const stop = () => {
active = false;
generation += 1;
for (const asset of assets.values()) {
if (asset.timer !== null) clearTimeout(asset.timer);
asset.timer = null;
asset.detach?.();
asset.image?.removeAttribute("src");
asset.image = null;
asset.status = "idle";
asset.loadedUrl = null;
asset.retries = 0;
}
displayedIndex = null;
lastState = null;
};
return {
start() {
if (disposed || active) return;
active = true;
const run = ++generation;
reconcile();
for (const asset of assets.values()) request(asset, run);
},
select(level) {
if (disposed || !Number.isFinite(level)) return;
targetLevel = level;
reconcile();
},
stop,
dispose() {
stop();
disposed = true;
onChange = null;
}
};
}
// src/client/logic.ts // src/client/logic.ts
var PREVIEW_MAX_FRAME = 240; var PREVIEW_MAX_FRAME = 240;
var MAX_LEVEL = 30; var MAX_LEVEL = 30;
@@ -665,19 +993,6 @@ function selectedEffortIndex(efforts, selectedId, defaultId) {
const id = selectedId ?? defaultId; const id = selectedId ?? defaultId;
return id === void 0 ? -1 : efforts.findIndex((effort) => effort.id === id); return id === void 0 ? -1 : efforts.findIndex((effort) => effort.id === id);
} }
function portraitBlendForLevel(level, anchors) {
if (anchors.length === 0) return { lowerIndex: -1, upperIndex: -1, mix: 0 };
const safeLevel = Number.isFinite(level) ? Math.min(anchors[anchors.length - 1], Math.max(anchors[0], level)) : anchors[0];
let upperIndex = anchors.findIndex((anchor) => anchor >= safeLevel);
if (upperIndex < 0) upperIndex = anchors.length - 1;
const lowerIndex = Math.max(0, upperIndex - (anchors[upperIndex] > safeLevel ? 1 : 0));
const span = anchors[upperIndex] - anchors[lowerIndex];
return {
lowerIndex,
upperIndex,
mix: span === 0 ? 0 : (safeLevel - anchors[lowerIndex]) / span
};
}
function liangRankForFrame(rawFrame) { function liangRankForFrame(rawFrame) {
const level = clampFrame(rawFrame) / PREVIEW_MAX_FRAME * MAX_LEVEL; const level = clampFrame(rawFrame) / PREVIEW_MAX_FRAME * MAX_LEVEL;
const index = level >= MAX_LEVEL ? LIANG_RANKS.length - 1 : Math.floor(level / 6); const index = level >= MAX_LEVEL ? LIANG_RANKS.length - 1 : Math.floor(level / 6);
@@ -750,7 +1065,6 @@ var import_jsx_runtime = require("react/jsx-runtime");
var PACKAGE_ID = "dsh-client-liang-intensity-skin"; var PACKAGE_ID = "dsh-client-liang-intensity-skin";
var LOCALE_NAMESPACE = "liang.skin"; var LOCALE_NAMESPACE = "liang.skin";
var ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets`; var ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets`;
var FIRST_PORTRAIT_FILE = "portrait-source-v2/stage-00.webp";
var BIND_EFFORT_KEY = "dsh-liang-intensity-skin.bind-effort"; var BIND_EFFORT_KEY = "dsh-liang-intensity-skin.bind-effort";
var PORTRAIT_ANCHORS = [ var PORTRAIT_ANCHORS = [
{ level: 0, file: "stage-00.webp" }, { level: 0, file: "stage-00.webp" },
@@ -778,7 +1092,6 @@ var PORTRAIT_ANCHORS = [
{ level: 29, file: "level-29.webp" }, { level: 29, file: "level-29.webp" },
{ level: 30, file: "stage-30.webp" } { level: 30, file: "stage-30.webp" }
]; ];
var ANCHOR_LEVELS = PORTRAIT_ANCHORS.map((anchor) => anchor.level);
var cssVariables = [ var cssVariables = [
"--liang-strength", "--liang-strength",
"--liang-page", "--liang-page",
@@ -800,9 +1113,7 @@ var SkinPresenter = class {
scope; scope;
theme; theme;
root; root;
portrait; portraits;
preloads;
portraitReady = false;
enabled = true; enabled = true;
// Default to the max frame so the first paint after load is the dark shell; // Default to the max frame so the first paint after load is the dark shell;
// starting at 0 flashed the light palette before the directory resolved. // starting at 0 flashed the light palette before the directory resolved.
@@ -817,46 +1128,31 @@ var SkinPresenter = class {
this.root = document.createElement("div"); this.root = document.createElement("div");
this.root.className = "liang-skin-backdrop"; this.root.className = "liang-skin-backdrop";
this.root.dataset.plugin = PACKAGE_ID; this.root.dataset.plugin = PACKAGE_ID;
this.root.dataset.media = "sequence"; this.root.dataset.media = "color";
this.root.setAttribute("aria-hidden", "true"); this.root.setAttribute("aria-hidden", "true");
this.portrait = document.createElement("img"); this.portraits = createPortraitLoader({
this.portrait.className = "liang-skin-sequence-frame"; sources: PORTRAIT_ANCHORS.map(({ level, file }) => ({
this.portrait.alt = ""; level,
this.portrait.draggable = false; url: `${ASSET_PREFIX}/portrait-source-v2/${file}`
this.portrait.decoding = "async"; })),
this.portrait.src = `${ASSET_PREFIX}/${FIRST_PORTRAIT_FILE}`; onChange: ({ image }) => {
this.portrait.addEventListener("error", this.handleSequenceError); if (this.disposed || !this.enabled) return;
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => { if (image === null) {
const image = new Image(); this.root.dataset.media = "color";
image.loading = "eager"; this.root.replaceChildren();
image.decoding = "async"; return;
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`; }
return image; const portrait = image;
}); portrait.className = "liang-skin-sequence-frame";
void Promise.all(this.preloads.map((image) => image.decode())).then( portrait.alt = "";
() => { portrait.draggable = false;
if (this.disposed) return; if (this.root.firstChild !== portrait) this.root.replaceChildren(portrait);
this.portraitReady = true;
this.root.dataset.media = "sequence"; this.root.dataset.media = "sequence";
this.updatePortrait(paletteForFrame(this.frame).level);
},
() => {
} }
); });
this.root.append(this.portrait);
document.body.prepend(this.root);
this.unsubscribe = scope.subscribe(() => this.syncSettings()); this.unsubscribe = scope.subscribe(() => this.syncSettings());
this.syncSettings(); this.syncSettings();
} }
handleSequenceError = () => {
if (this.portrait.src.endsWith(FIRST_PORTRAIT_FILE)) {
this.root.dataset.media = "color";
return;
}
this.portraitReady = false;
this.portrait.src = `${ASSET_PREFIX}/${FIRST_PORTRAIT_FILE}`;
this.root.dataset.media = "sequence";
};
syncSettings() { syncSettings() {
this.setEnabled(this.scope.getSnapshot().enabled); this.setEnabled(this.scope.getSnapshot().enabled);
} }
@@ -867,19 +1163,27 @@ var SkinPresenter = class {
return this.frame; return this.frame;
} }
setEnabled(enabled) { setEnabled(enabled) {
if (this.disposed) return;
this.enabled = enabled; this.enabled = enabled;
if (enabled) { if (enabled) {
if (!this.root.isConnected) document.body.prepend(this.root); if (!this.root.isConnected) document.body.prepend(this.root);
document.body.dataset.liangSkin = "on"; document.body.dataset.liangSkin = "on";
this.applyFrame(); this.applyFrame();
this.portraits.start();
} else { } else {
if (this.raf !== 0) cancelAnimationFrame(this.raf);
this.raf = 0;
this.root.remove(); this.root.remove();
this.portraits.stop();
this.root.replaceChildren();
this.root.dataset.media = "color";
delete document.body.dataset.liangSkin; delete document.body.dataset.liangSkin;
delete document.body.dataset.liangStage; delete document.body.dataset.liangStage;
for (const name of cssVariables) document.body.style.removeProperty(name); for (const name of cssVariables) document.body.style.removeProperty(name);
} }
} }
setFrame(frame) { setFrame(frame) {
if (this.disposed) return;
this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame))); this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame)));
this.frame = this.pendingFrame; this.frame = this.pendingFrame;
if (!this.enabled) return; if (!this.enabled) return;
@@ -891,6 +1195,7 @@ var SkinPresenter = class {
}); });
} }
applyFrame() { applyFrame() {
if (this.disposed || !this.enabled) return;
const palette = paletteForFrame(this.frame); const palette = paletteForFrame(this.frame);
const body = document.body; const body = document.body;
this.syncNativeTheme(palette.stage === 5 ? "dark" : "light"); this.syncNativeTheme(palette.stage === 5 ? "dark" : "light");
@@ -917,16 +1222,7 @@ var SkinPresenter = class {
this.theme.setTheme(theme); this.theme.setTheme(theme);
} }
updatePortrait(level) { updatePortrait(level) {
if (!this.portraitReady) return; this.portraits.select(level);
const { lowerIndex, upperIndex, mix: mix2 } = portraitBlendForLevel(
level,
ANCHOR_LEVELS
);
const lower = PORTRAIT_ANCHORS[lowerIndex];
const upper = PORTRAIT_ANCHORS[upperIndex];
const selected = mix2 >= 0.5 ? upper : lower;
const source = `${ASSET_PREFIX}/portrait-source-v2/${selected.file}`;
if (this.portrait.getAttribute("src") !== source) this.portrait.src = source;
} }
async choose(enabled) { async choose(enabled) {
this.setEnabled(enabled); this.setEnabled(enabled);
@@ -942,8 +1238,7 @@ var SkinPresenter = class {
this.disposed = true; this.disposed = true;
this.unsubscribe(); this.unsubscribe();
if (this.raf !== 0) cancelAnimationFrame(this.raf); if (this.raf !== 0) cancelAnimationFrame(this.raf);
this.portrait.removeEventListener("error", this.handleSequenceError); this.portraits.dispose();
for (const image of this.preloads) image.src = "";
this.root.remove(); this.root.remove();
document.body.removeAttribute("data-liang-skin"); document.body.removeAttribute("data-liang-skin");
delete document.body.dataset.liangStage; delete document.body.dataset.liangStage;
@@ -1110,117 +1405,6 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }) {
} }
); );
} }
var NATIVE_APPEARANCE_GROUP = '[class*="_8HJdBW_group"]';
var NATIVE_APPEARANCE_ROW = '[class*="_8HJdBW_cubeRow"]';
var LIANG_APPEARANCE_BUTTON = "liang-appearance-choice";
var LIANG_BINDING_CONTROL = "liang-appearance-binding";
var LIANG_BINDING_INPUT = "liang-appearance-binding__input";
function installLiangAppearanceButton(scope, presenter) {
let pending = false;
const hookedNativeButtons = /* @__PURE__ */ new Set();
const nativeClickHandlers = /* @__PURE__ */ new Map();
const sync = () => {
const group = [...document.querySelectorAll(NATIVE_APPEARANCE_GROUP)].find((node) => node.querySelector('[class*="_8HJdBW_themeCube"]'));
const row = group?.querySelector(NATIVE_APPEARANCE_ROW);
if (row === void 0 || row === null) return;
let customButton = row.querySelector(`.${LIANG_APPEARANCE_BUTTON}`);
if (customButton === null) {
customButton = document.createElement("button");
customButton.className = LIANG_APPEARANCE_BUTTON;
customButton.type = "button";
customButton.dataset.plugin = PACKAGE_ID;
customButton.setAttribute("aria-label", "\u6ED1\u52A8\u53D8\u7956");
const icon = document.createElement("span");
icon.className = "liang-appearance-choice__icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = "\u25C8";
const label = document.createElement("span");
label.className = "liang-appearance-choice__label";
label.textContent = document.documentElement.lang.toLowerCase().startsWith("en") ? "Slider" : "\u6ED1\u52A8\u53D8\u7956";
customButton.append(icon, label);
customButton.addEventListener("click", () => {
if (pending || scope.getSnapshot().enabled) return;
pending = true;
sync();
void presenter.choose(true).finally(() => {
pending = false;
sync();
});
});
}
if (customButton.parentElement !== row) row.append(customButton);
const snapshot = scope.getSnapshot();
customButton.disabled = pending;
customButton.setAttribute("aria-pressed", String(snapshot.enabled));
let bindingControl = group.querySelector(`.${LIANG_BINDING_CONTROL}`);
if (!snapshot.enabled) {
bindingControl?.remove();
bindingControl = null;
} else if (bindingControl === null) {
bindingControl = document.createElement("label");
bindingControl.className = LIANG_BINDING_CONTROL;
bindingControl.dataset.plugin = PACKAGE_ID;
const bindingCopy = document.createElement("span");
bindingCopy.className = "liang-appearance-binding__copy";
const bindingLabel = document.createElement("span");
bindingLabel.className = "liang-appearance-binding__label";
const bindingText = document.documentElement.lang.toLowerCase().startsWith("en") ? "Bind slider to reasoning level" : "\u6ED1\u52A8\u53D8\u7956\u7ED1\u5B9A\u601D\u8003\u7B49\u7EA7";
bindingLabel.textContent = bindingText;
const bindingDescription = document.createElement("span");
bindingDescription.className = "liang-appearance-binding__description";
bindingDescription.id = "liang-appearance-binding-description";
bindingDescription.textContent = document.documentElement.lang.toLowerCase().startsWith("en") ? "When off, the slider does not change the reasoning level." : "\u5173\u95ED\u4E4B\u540E\u6ED1\u5757\u4E0D\u8054\u52A8\u601D\u8003\u7B49\u7EA7";
bindingCopy.append(bindingLabel, bindingDescription);
const bindingInput = document.createElement("input");
bindingInput.className = LIANG_BINDING_INPUT;
bindingInput.type = "checkbox";
bindingInput.setAttribute("role", "switch");
bindingInput.setAttribute("aria-label", bindingText);
bindingInput.setAttribute("aria-describedby", bindingDescription.id);
bindingInput.addEventListener("change", () => {
void scope.setBindEffort(bindingInput.checked).catch(() => sync());
});
bindingControl.append(bindingCopy, bindingInput);
group.append(bindingControl);
}
if (bindingControl !== null) {
const bindingInput = bindingControl.querySelector(`.${LIANG_BINDING_INPUT}`);
if (bindingInput !== null) {
bindingInput.checked = snapshot.bindEffort;
bindingInput.setAttribute("aria-checked", String(snapshot.bindEffort));
bindingInput.disabled = pending;
}
}
for (const nativeButton of row.querySelectorAll('[class*="_8HJdBW_themeCube"]')) {
if (hookedNativeButtons.has(nativeButton)) continue;
hookedNativeButtons.add(nativeButton);
const handleNativeClick = () => {
if (pending || !scope.getSnapshot().enabled) return;
pending = true;
sync();
void presenter.choose(false).finally(() => {
pending = false;
sync();
});
};
nativeClickHandlers.set(nativeButton, handleNativeClick);
nativeButton.addEventListener("click", handleNativeClick, { capture: true });
}
};
const observer = new MutationObserver(sync);
observer.observe(document.body, { childList: true, subtree: true });
const unsubscribe = scope.subscribe(sync);
sync();
return () => {
observer.disconnect();
unsubscribe();
for (const [nativeButton, handleNativeClick] of nativeClickHandlers) {
nativeButton.removeEventListener("click", handleNativeClick, { capture: true });
}
document.querySelectorAll(`.${LIANG_APPEARANCE_BUTTON}`).forEach((button) => button.remove());
document.querySelectorAll(`.${LIANG_BINDING_CONTROL}`).forEach((control) => control.remove());
};
}
var inject = [ var inject = [
"slots", "slots",
"sessions", "sessions",
@@ -1284,7 +1468,7 @@ function apply(ctx) {
const presenter = new SkinPresenter(scope, theme); const presenter = new SkinPresenter(scope, theme);
ctx.effect(() => () => presenter.dispose(), "liang-intensity-skin: backdrop presenter"); ctx.effect(() => () => presenter.dispose(), "liang-intensity-skin: backdrop presenter");
ctx.effect( ctx.effect(
() => installLiangAppearanceButton(scope, presenter), () => installLiangAppearanceButton(scope, presenter, PACKAGE_ID),
"liang-intensity-skin: native appearance extension" "liang-intensity-skin: native appearance extension"
); );
ctx.effect( ctx.effect(

File diff suppressed because one or more lines are too long

14
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"devDependencies": { "devDependencies": {
"@types/react": "~18.3.1", "@types/react": "~18.3.1",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"playwright-core": "^1.64.1",
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
}, },
@@ -1330,6 +1331,19 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.26", "version": "8.5.26",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz",

View File

@@ -48,7 +48,8 @@
], ],
"scripts": { "scripts": {
"build": "node ./scripts/build-client.mjs", "build": "node ./scripts/build-client.mjs",
"test": "vitest run" "test": "vitest run",
"test:compatibility": "npm run build && node ./scripts/test-client-compatibility.mjs && node ./scripts/test-client-runtime.mjs"
}, },
"dsh": { "dsh": {
"bundle": { "bundle": {
@@ -70,6 +71,7 @@
"devDependencies": { "devDependencies": {
"@types/react": "~18.3.1", "@types/react": "~18.3.1",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"playwright-core": "^1.64.1",
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
} }

View File

@@ -0,0 +1,280 @@
import assert from "node:assert/strict";
import { constants } from "node:fs";
import { access, readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { inflateSync } from "node:zlib";
import { build } from "esbuild";
import { chromium } from "playwright-core";
// Self-contained host contracts; native Desktop acceptance remains manual.
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
const packageId = "@leon___/dsh-client-liang-intensity-skin";
const viewport = { width: 1100, height: 700 };
const css = await readFile(new URL("../src/client/skin.css", import.meta.url), "utf8");
const compiled = await build({ absWorkingDir: projectRoot, entryPoints: ["src/client/appearance.ts"],
bundle: true, write: false, format: "iife", globalName: "LiangAppearance", platform: "browser", target: "es2022" });
async function chromeExecutable() {
const explicit = process.env.CHROME_PATH;
const candidates = explicit ? [explicit] : [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium",
"/usr/bin/chromium-browser", "/opt/google/chrome/chrome",
];
for (const candidate of candidates) {
try { await access(candidate, constants.X_OK); return candidate; }
catch { /* Use an existing local browser, never download one. */ }
}
throw new Error(`Chrome was not found. Set CHROME_PATH to an installed Chrome/Chromium executable. Checked: ${candidates.join(", ")}`);
}
const portrait = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="rgb(32,208,96)"/></svg>')}`;
function fixture({ desktop = false, top = 0, windows = false, mode = "compatibility" } = {}) {
return `<!doctype html><html lang="zh-CN" ${desktop ? `data-platform="${windows ? "win32" : "darwin"}"` : ""} ${windows ? 'data-windows-titlebar=""' : ""}>
<head><meta charset="utf-8"><style>
* { box-sizing: border-box; transition: none !important; animation: none !important; }
html, body { margin: 0; min-height: 100vh; }
body { --dsw-alias-bg-base: rgb(240,244,248); --dsw-specific-sidebar-fill: rgb(11,29,49);
--liang-page: rgb(240,244,248); --liang-bg-base: rgb(0 0 0 / 25%); --liang-portrait-opacity: 1;
--liang-sidebar: rgb(11,29,49); --liang-ink: rgb(20,25,30); --liang-secondary: rgb(55,60,65);
--liang-border: rgb(100,110,120); --liang-layer-1: white; --dsh-desktop-frame-height: ${top}px;
background: rgb(240,244,248); }
#root { position: ${desktop ? "fixed" : "static"}; ${desktop ? `inset: ${top}px 0 0;` : ""}
height: ${desktop ? `calc(100vh - ${top}px)` : "100vh"}; z-index: auto; background: var(--dsw-alias-bg-base); }
.fixture_frame { display: flex; height: 100%; ${windows ? "background: var(--dsw-specific-sidebar-fill);" : ""} }
.fixture_sidebarCol { flex: 0 0 200px; background: var(--dsw-specific-sidebar-fill); }
.fixture_centerCol { flex: 1; background: var(--dsw-alias-bg-base); }
#caption { position: fixed; inset: 0 0 auto; height: ${top}px; background: rgb(90,40,80); }
#portal { position: fixed; right: 30px; top: 85px; width: 150px; height: 50px; border: 0;
background: rgb(140,60,180); color: white; }
#appearance-host { position: absolute; top: ${top + 155}px; left: 225px; width: 620px; }
.fixture_group { margin-bottom: 8px; } .fixture_group > div { display: flex; gap: 4px; }
.native-choice { padding: 16px; flex: 1; }
</style><style id="liang-styles">${css}</style></head>
<body ${desktop ? `data-dsh-desktop-mode="${mode}"` : ""}>
<div class="liang-skin-backdrop" data-media="sequence" aria-hidden="true"><img class="liang-skin-sequence-frame" alt="" src="${portrait}"></div>
<div id="root"><div class="fixture_frame"><aside class="fixture_sidebarCol"></aside><main class="fixture_centerCol"></main></div></div>
<div id="caption"></div><div id="appearance-host"></div><button id="portal" type="button">Native portal</button>
</body></html>`;
}
async function prepare(context, options = {}) {
const page = await context.newPage();
if (options.viewport) await page.setViewportSize(options.viewport);
const errors = [];
page.on("pageerror", error => errors.push(error.message));
await page.setContent(fixture(options));
await page.addScriptTag({ content: compiled.outputFiles[0].text });
await page.evaluate(({ packageId, enabled }) => {
let snapshot = { enabled, bindEffort: true };
const listeners = new Set();
const calls = [];
let releaseChoice, holdNextChoice = false, failNextChoice = false, failNextBinding = false;
let dispose = () => {};
const publish = patch => {
snapshot = { ...snapshot, ...patch };
if (snapshot.enabled) document.body.dataset.liangSkin = "on";
else delete document.body.dataset.liangSkin;
for (const listener of listeners) listener();
};
const scope = {
getSnapshot: () => snapshot,
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
async setBindEffort(value) {
calls.push(["binding", value]);
if (failNextBinding) { failNextBinding = false; throw new Error("fixture write rejected"); }
publish({ bindEffort: value });
},
};
const presenter = {
async choose(value) {
calls.push(["choose", value]);
if (holdNextChoice) { holdNextChoice = false; await new Promise(resolve => { releaseChoice = resolve; }); }
if (failNextChoice) { failNextChoice = false; throw new Error("fixture choice rejected"); }
publish({ enabled: value });
},
};
const markup = hash => `<section class="fixture_group ${hash}_group extra-group"><div class="layout-row ${hash}_cubeRow extra-row" id="native-row">${["light", "dark", "system"].map(theme => `<button type="button" class="native-choice extra-button ${hash}_themeCube" data-theme="${theme}">${theme}</button>`).join("")}</div></section>`;
const connectNative = () => {
for (const button of document.querySelectorAll("#native-row > button[data-theme]")) {
button.addEventListener("click", () => { window.fixture.nativeClicks++; window.fixture.nativeTheme = button.dataset.theme; });
}
};
window.fixture = {
calls, nativeClicks: 0, nativeTheme: null, portalClicks: 0,
snapshot: () => ({ ...snapshot }), listenerCount: () => listeners.size,
install(hash = "_8HJdBW") {
document.querySelector("#appearance-host").innerHTML =
'<section class="fixture_group other_group"><div class="other_cubeRow" id="unrelated"><button>one</button><button>two</button><button>three</button></div></section>' +
'<section class="fixture_group nested_group"><div class="nested_cubeRow" id="nested-unrelated"><span><button class="nested_themeCube">one</button><button class="nested_themeCube">two</button><button class="nested_themeCube">three</button></span></div></section>' + markup(hash);
connectNative();
dispose = window.LiangAppearance.installLiangAppearanceButton(scope, presenter, packageId);
},
rebuild(hash = "_newHash42") {
document.querySelector("#native-row").parentElement.outerHTML = markup(hash); connectNative();
},
removeRow() { document.querySelector("#native-row").parentElement.remove(); },
restoreRow(hash = "_restored") {
document.querySelector("#appearance-host").insertAdjacentHTML("beforeend", markup(hash)); connectNative();
},
dispose() { dispose(); }, setEnabled(value) { publish({ enabled: value }); },
holdChoice() { holdNextChoice = true; }, releaseChoice() { releaseChoice?.(); },
rejectChoice() { failNextChoice = true; }, rejectBinding() { failNextBinding = true; },
};
document.querySelector("#portal").addEventListener("click", () => window.fixture.portalClicks++);
publish({});
}, { packageId, enabled: options.enabled ?? true });
await page.locator(".liang-skin-sequence-frame").evaluate(image => image.decode());
return { page, errors };
}
// For the first pixel of a 1x1 PNG, every PNG filter's predictors are zero.
// Real rendered pixels catch opaque host layers that computed z-index misses.
async function pixelAt(page, x, y) {
const png = await page.screenshot({ clip: { x, y, width: 1, height: 1 }, animations: "disabled" });
assert.equal(png.readUInt32BE(16), 1); assert.equal(png.readUInt32BE(20), 1);
assert.equal(png[24], 8, "Expected an 8-bit screenshot");
assert.ok(png[25] === 2 || png[25] === 6, "Expected an RGB/RGBA screenshot");
const chunks = [];
for (let offset = 8; offset < png.length;) {
const length = png.readUInt32BE(offset);
if (png.toString("ascii", offset + 4, offset + 8) === "IDAT") chunks.push(png.subarray(offset + 8, offset + 8 + length));
offset += length + 12;
}
return [...inflateSync(Buffer.concat(chunks)).subarray(1, 4)];
}
function closeColor(actual, expected, label) {
assert.ok(actual.every((channel, i) => Math.abs(channel - expected[i]) <= 1), `${label}: expected ${expected}, received ${actual}`);
}
async function assertLayout(page, options, enabled = true) {
const size = page.viewportSize();
const result = await page.evaluate(() => {
const root = document.querySelector("#root"), backdrop = document.querySelector(".liang-skin-backdrop");
const box = element => { const rect = element.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; };
return { root: box(root), rootPosition: getComputedStyle(root).position, rootZ: getComputedStyle(root).zIndex,
backdrop: box(backdrop), portrait: box(backdrop.querySelector("img")), backdropZ: getComputedStyle(backdrop).zIndex, isolation: getComputedStyle(document.body).isolation,
bgBase: getComputedStyle(document.body).getPropertyValue("--dsw-alias-bg-base").trim(),
frameColor: getComputedStyle(document.querySelector(".fixture_frame")).backgroundColor };
});
assert.equal(result.rootPosition, options.desktop ? "fixed" : "static", "The host owns root positioning");
assert.equal(result.rootZ, "auto", "The skin must not raise the root above native portals");
const bounds = { x: 0, y: options.top ?? 0, width: size.width, height: size.height - (options.top ?? 0) };
assert.deepEqual(result.root, bounds);
if (enabled) {
assert.equal(result.isolation, "isolate"); assert.equal(result.backdropZ, "-1");
assert.equal(result.bgBase, "transparent", "Base surfaces must not apply a second portrait mask");
assert.deepEqual(result.backdrop, bounds);
assert.equal(result.portrait.height, bounds.height, "Portrait follows the background container height");
closeColor(await pixelAt(page, size.width - 30, 430), [24, 156, 72], "Portrait visible with exactly one 25% mask");
if (options.windows) assert.equal(result.frameColor, "rgba(0, 0, 0, 0)", "Only the Windows outer frame becomes transparent");
} else {
assert.equal(result.isolation, "auto", "Skin-off restores native stacking");
assert.equal(result.bgBase, "rgb(240,244,248)");
closeColor(await pixelAt(page, size.width - 30, 430), [240, 244, 248], "Skin-off native background");
if (options.windows) assert.equal(result.frameColor, "rgb(11, 29, 49)");
}
closeColor(await pixelAt(page, 100, 430), [11, 29, 49], "Sidebar keeps its fill");
if ((options.top ?? 0) > 0) closeColor(await pixelAt(page, 500, 5), [90, 40, 80], "Native titlebar remains painted");
await page.locator("#portal").click();
assert.equal(await page.evaluate(() => window.fixture.portalClicks), enabled ? 1 : 2, "Native body portal remains clickable");
}
async function appearanceChecks(page, hash) {
await page.evaluate(hash => window.fixture.install(hash), hash);
const custom = page.locator("#native-row > .liang-appearance-choice"), binding = page.locator(".liang-appearance-binding__input");
await custom.waitFor();
assert.equal(await page.locator(".liang-appearance-choice").count(), 1);
assert.equal(await page.locator("#unrelated .liang-appearance-choice, #nested-unrelated .liang-appearance-choice").count(), 0);
assert.equal(await custom.getAttribute("data-plugin"), packageId);
assert.equal(await custom.getAttribute("aria-pressed"), "true");
await binding.uncheck();
await page.waitForFunction(() => !window.fixture.snapshot().bindEffort);
assert.equal(await binding.getAttribute("aria-checked"), "false");
await binding.check(); await page.waitForFunction(() => window.fixture.snapshot().bindEffort);
for (const theme of ["light", "dark", "system"]) {
await page.locator(`[data-theme="${theme}"]`).click();
await page.waitForFunction(() => !window.fixture.snapshot().enabled);
assert.equal(await page.locator(".liang-appearance-binding").count(), 0);
assert.equal(await custom.getAttribute("aria-pressed"), "false");
assert.equal(await page.evaluate(() => window.fixture.nativeTheme), theme);
await custom.click(); await page.waitForFunction(() => window.fixture.snapshot().enabled); await binding.waitFor();
}
assert.equal(await page.evaluate(() => window.fixture.nativeClicks), 3, "Native theme handlers still run");
await page.evaluate(() => {
window.oldNative = document.querySelector('[data-theme="light"]');
window.oldCustom = document.querySelector(".liang-appearance-choice");
window.oldBinding = document.querySelector(".liang-appearance-binding__input"); window.fixture.rebuild();
});
await page.locator("#native-row._newHash42_cubeRow > .liang-appearance-choice").waitFor();
assert.equal(await page.locator(".liang-appearance-choice").count(), 1, "DOM reconstruction produces one control");
const beforeStaleClicks = await page.evaluate(() => window.fixture.calls.length);
await page.evaluate(() => { window.oldNative.click(); window.oldCustom.click(); window.oldBinding.dispatchEvent(new Event("change", { bubbles: true })); });
assert.equal(await page.evaluate(() => window.fixture.calls.length), beforeStaleClicks, "Replaced nodes lose plugin listeners");
await page.locator('[data-theme="dark"]').click(); await page.waitForFunction(() => !window.fixture.snapshot().enabled);
await custom.click(); await page.waitForFunction(() => window.fixture.snapshot().enabled);
await page.evaluate(() => window.fixture.removeRow());
await page.waitForFunction(() => document.querySelector(".liang-appearance-choice") === null);
await page.evaluate(() => window.fixture.restoreRow()); await custom.waitFor();
await page.evaluate(() => {
window.fixture.rejectBinding(); const input = document.querySelector(".liang-appearance-binding__input");
input.checked = false; input.dispatchEvent(new Event("change", { bubbles: true }));
});
await page.waitForFunction(() => document.querySelector(".liang-appearance-binding__input").checked);
await page.evaluate(() => window.fixture.rejectChoice()); await page.locator('[data-theme="light"]').click();
await page.waitForFunction(() => !document.querySelector(".liang-appearance-choice").disabled);
assert.equal(await custom.getAttribute("aria-pressed"), "true", "Rejected preference preserves skin state");
await page.evaluate(() => window.fixture.holdChoice()); await page.locator('[data-theme="light"]').click();
assert.equal(await custom.isDisabled(), true, "Outstanding choices disable injected controls");
await page.evaluate(() => {
window.oldCustom = document.querySelector(".liang-appearance-choice");
window.fixture.dispose(); window.fixture.dispose(); window.fixture.releaseChoice();
});
await page.waitForFunction(() => !window.fixture.snapshot().enabled);
assert.equal(await page.locator(".liang-appearance-choice, .liang-appearance-binding").count(), 0, "Late choice cannot resurrect disposed controls");
assert.equal(await page.evaluate(() => window.fixture.listenerCount()), 0);
const beforeDisposedClicks = await page.evaluate(() => window.fixture.calls.length);
await page.evaluate(() => {
window.oldCustom.click(); document.querySelector('[data-theme="dark"]').click();
document.querySelector("#appearance-host").append(document.createElement("span"));
});
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve)));
assert.equal(await page.evaluate(() => window.fixture.calls.length), beforeDisposedClicks);
assert.equal(await page.locator(".liang-appearance-choice, .liang-appearance-binding").count(), 0);
}
const browser = await chromium.launch({ executablePath: await chromeExecutable(), headless: true });
try {
const context = await browser.newContext({ viewport, reducedMotion: "reduce", deviceScaleFactor: 1 });
await context.route("**/*", route => route.abort()); // No network needed by the fixture.
const cases = [
{ name: "Web static root" }, { name: "Desktop fixed top 0", desktop: true, top: 0 },
{ name: "Windows fixed top 36", desktop: true, windows: true, top: 36 },
{ name: "Windows extended top 40", desktop: true, windows: true, top: 40, mode: "extended" },
{ name: "macOS extended top 36", desktop: true, top: 36, mode: "extended" },
{ name: "macOS fixed top 40", desktop: true, top: 40 },
{ name: "Compact Desktop", desktop: true, windows: true, top: 36, viewport: { width: 800, height: 600 } },
{ name: "Large Web", viewport: { width: 1800, height: 1300 } },
];
for (const options of cases) {
const { page, errors } = await prepare(context, options);
await assertLayout(page, options); await page.evaluate(() => window.fixture.setEnabled(false));
await assertLayout(page, options, false); assert.deepEqual(errors, [], `${options.name}: browser errors`);
await page.close(); console.log(`PASS ${options.name}: backdrop, host geometry, native portal, skin off`);
}
for (const hash of ["_8HJdBW", "_currentHash89"]) {
const { page, errors } = await prepare(context); await appearanceChecks(page, hash);
assert.deepEqual(errors, [], `${hash}: browser errors`); await page.close();
console.log(`PASS appearance ${hash}: multi-class discovery, choices, binding, DOM rebuild, disposal race`);
}
const first = await prepare(context), second = await prepare(context, { desktop: true, windows: true, top: 40 });
await first.page.evaluate(() => window.fixture.install()); await second.page.evaluate(() => window.fixture.install("_otherWindow"));
await first.page.locator('[data-theme="light"]').click(); await first.page.waitForFunction(() => !window.fixture.snapshot().enabled);
assert.equal(await second.page.evaluate(() => window.fixture.snapshot().enabled), true);
closeColor(await pixelAt(first.page, 1070, 430), [240, 244, 248], "First window skin off");
closeColor(await pixelAt(second.page, 1070, 430), [24, 156, 72], "Second window remains visible");
assert.deepEqual([...first.errors, ...second.errors], []); await context.close();
console.log("PASS independent windows: host geometry and appearance state stay isolated");
console.log("Client compatibility browser checks passed (11 scenarios). Native Desktop validation remains manual.");
} finally { await browser.close(); }

View File

@@ -0,0 +1,307 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { chromium } from 'playwright-core';
const root = new URL('../', import.meta.url);
const manifest = JSON.parse(readFileSync(new URL('package.json', root), 'utf8'));
const assetPaths = manifest.files.filter((file) => /^assets\/portrait-source-v2\/[^/]+\.webp$/.test(file));
assert.equal(assetPaths.length, 24, 'The published client must contain all 24 portrait anchors');
const assets = new Map(assetPaths.map((file) => [file.split('/').at(-1), readFileSync(new URL(file, root))]));
const client = readFileSync(new URL('lib/client.js', root), 'utf8');
const imageSelector = '.liang-skin-backdrop[data-media="sequence"] > img';
const browser = await chromium.launch({
headless: true,
...(process.env.CHROME_PATH
? { executablePath: process.env.CHROME_PATH }
: process.platform === 'darwin'
? { executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' }
: {}),
});
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
async function bounded(promise, message) {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), 15000); }),
]);
} finally {
clearTimeout(timer);
}
}
async function fixture({ fail = () => false, holdRetry = null } = {}) {
const context = await browser.newContext({ viewport: { width: 1200, height: 900 } });
const page = await context.newPage();
const errors = [];
const requests = [];
const retriesReleased = deferred();
let closing = false;
let hold = null;
page.on('pageerror', (error) => errors.push(error.message));
await page.route('http://liang.test/**', async (route) => {
const url = new URL(route.request().url());
if (url.pathname === '/') {
await route.fulfill({ contentType: 'text/html', body: '<!doctype html><html lang="zh-CN"><head></head><body><main id="root"><section class="current_group"><div class="current_cubeRow"><button type="button" class="current_themeCube">浅色</button><button type="button" class="current_themeCube">深色</button><button type="button" class="current_themeCube">跟随系统</button></div></section></main></body></html>' });
return;
}
const match = url.pathname.match(/^\/plugins\/.+\/assets\/portrait-source-v2\/([^/]+\.webp)$/);
assert.ok(match && assets.has(match[1]), `Unexpected runtime request: ${url.pathname}`);
const name = match[1];
const retry = Number(url.searchParams.get('retry') ?? 0);
requests.push({ name, retry, url: url.href });
const currentHold = hold;
if (currentHold !== null) {
currentHold.count += 1;
if (currentHold.count === assets.size) currentHold.started.resolve();
await currentHold.release.promise;
}
if (name === holdRetry && retry > 0) await retriesReleased.promise;
const unavailable = fail(name, retry);
try {
await route.fulfill({
status: unavailable ? 503 : 200,
contentType: 'image/webp',
headers: { 'cache-control': 'no-store' },
body: unavailable ? Buffer.from('fixture: unavailable portrait') : assets.get(name),
});
} catch (error) {
// Removing an Image's src during disposal may cancel its held request.
if (!closing) throw error;
} finally {
if (currentHold !== null) {
currentHold.finishedCount += 1;
if (currentHold.finishedCount === assets.size) currentHold.finished.resolve();
}
}
});
await page.goto('http://liang.test/');
await page.evaluate(() => {
const runtime = {
cleanups: [], decoded: new WeakSet(), decodedImages: [],
registered: null, scope: null, presenter: null, module: null,
activeLocales: 0, activeSlots: 0, errors: [],
};
window.__runtime = runtime;
window.addEventListener('unhandledrejection', (event) => runtime.errors.push(String(event.reason)));
// Observe successful native decodes without substituting Image, pixels,
// timing, or the browser's decode result.
const decode = HTMLImageElement.prototype.decode;
HTMLImageElement.prototype.decode = function (...args) {
return decode.apply(this, args).then((value) => {
runtime.decoded.add(this);
runtime.decodedImages.push(this);
return value;
});
};
const unexpectedRender = () => { throw new Error('The runtime fixture must not render React components'); };
window.__ModuleLoader__ = {
load({ id, factory }) {
runtime.moduleId = id;
runtime.module = factory((name) => {
if (name === 'react') return {
useEffect: unexpectedRender, useMemo: unexpectedRender,
useRef: unexpectedRender, useState: unexpectedRender,
useSyncExternalStore: unexpectedRender,
};
if (name === 'react/jsx-runtime') return { jsx: unexpectedRender, jsxs: unexpectedRender };
throw new Error(`Unexpected client import: ${name}`);
});
},
};
});
await page.addScriptTag({ content: client });
await page.evaluate(() => {
const runtime = window.__runtime;
let preference = 'system';
const theme = {
getTheme: () => ({ preference }),
setTheme: (next) => {
preference = next;
document.body.toggleAttribute('data-ds-dark-theme', next === 'dark');
},
};
const ctx = {
effect(setup) {
const cleanup = setup();
if (typeof cleanup === 'function') runtime.cleanups.push(cleanup);
},
get(name) {
if (name === 'theme') return theme;
throw new Error(`Unexpected host service: ${name}`);
},
locale: {
register() {
runtime.activeLocales += 1;
return () => { runtime.activeLocales -= 1; };
},
},
slots: {
inject(name, callback) {
const cleanup = callback();
if (typeof cleanup === 'function') runtime.cleanups.push(cleanup);
},
register(definition) {
runtime.activeSlots += 1;
runtime.registered = definition.inject('fixture-session');
runtime.scope = runtime.registered.scope;
runtime.presenter = runtime.registered.presenter;
return () => { runtime.activeSlots -= 1; };
},
},
sessions: { subagentAddress: () => undefined },
modelDirectories: {
directoryFor: () => ({
store: { getSnapshot: () => ({ current: null, groups: [], status: 'ready', error: null }), subscribe: () => () => {} },
load: async () => {},
select: async () => ({ ok: true }),
}),
},
};
if (typeof runtime.module?.apply !== 'function') throw new Error('Built client did not export apply(ctx)');
runtime.module.apply(ctx);
runtime.dispose = () => {
for (const cleanup of runtime.cleanups.splice(0).reverse()) cleanup();
};
});
assert.equal(await page.getAttribute('body', 'data-liang-skin'), 'on', 'apply(ctx) enables the real presenter');
return {
page, requests, errors,
releaseRetries: () => retriesReleased.resolve(),
holdImages() {
hold = { count: 0, finishedCount: 0, started: deferred(), release: deferred(), finished: deferred() };
return hold;
},
async dispose() {
closing = true;
await page.evaluate(() => window.__runtime.dispose());
},
async close() {
closing = true;
hold?.release.resolve();
retriesReleased.resolve();
await context.close();
},
};
}
async function waitForPortrait(page, filename, retry = null) {
await page.waitForFunction(({ selector, filename, retry }) => {
const image = document.querySelector(selector);
if (!(image instanceof HTMLImageElement) || !image.currentSrc || !image.complete || image.naturalWidth === 0) return false;
const url = new URL(image.currentSrc);
return url.pathname.endsWith(`/${filename}`)
&& (retry === null || url.searchParams.get('retry') === String(retry))
&& window.__runtime.decoded.has(image)
&& getComputedStyle(image).display !== 'none'
&& image.getBoundingClientRect().height > 0;
}, { selector: imageSelector, filename, retry }, { timeout: 15000 });
}
async function assertRemoved(page, fullyDisposed = false) {
const result = await page.evaluate(() => ({
backdrop: document.querySelectorAll('.liang-skin-backdrop').length,
skin: document.body.hasAttribute('data-liang-skin'),
stage: document.body.hasAttribute('data-liang-stage'),
variables: [...document.body.style].filter((name) => name.startsWith('--liang-')),
retainedSources: window.__runtime.decodedImages.filter((image) => image.hasAttribute('src')).length,
styles: [...document.head.querySelectorAll('style')].filter((style) => style.dataset.plugin).length,
controls: document.querySelectorAll('.liang-appearance-choice, .liang-appearance-binding').length,
locales: window.__runtime.activeLocales,
slots: window.__runtime.activeSlots,
}));
assert.equal(result.backdrop, 0, 'No portrait backdrop remains');
assert.equal(result.skin, false, 'Skin marker is removed');
assert.equal(result.stage, false, 'Stage marker is removed');
assert.deepEqual(result.variables, [], 'All presenter CSS variables are removed');
assert.equal(result.retainedSources, 0, 'Decoded images release their network sources');
if (fullyDisposed) {
assert.equal(result.styles, 0, 'Client stylesheet is removed');
assert.equal(result.controls, 0, 'Appearance controls are removed');
assert.equal(result.locales, 0, 'Locale registration is released');
assert.equal(result.slots, 0, 'Slot registration is released');
}
}
async function assertNoErrors(test) {
assert.deepEqual(test.errors, [], 'No browser runtime exception');
assert.deepEqual(await test.page.evaluate(() => window.__runtime.errors), [], 'No unhandled runtime promise rejection');
}
try {
const success = await fixture();
try {
await success.page.waitForFunction(() => window.__runtime.decodedImages.length === 24, null, { timeout: 15000 });
await waitForPortrait(success.page, 'stage-30.webp');
assert.equal(success.requests.length, 24, 'Decoded display nodes do not start duplicate requests');
assert.equal(new Set(success.requests.map(({ name }) => name)).size, 24, 'Every shipped anchor is requested');
await success.page.evaluate(() => window.__runtime.presenter.setFrame(48));
await waitForPortrait(success.page, 'stage-06.webp');
await success.page.evaluate(() => window.__runtime.presenter.choose(false));
await assertRemoved(success.page);
await success.page.evaluate(() => window.__runtime.presenter.choose(true));
await waitForPortrait(success.page, 'stage-06.webp');
await success.page.evaluate(() => window.__runtime.presenter.choose(false));
const held = success.holdImages();
await success.page.evaluate(() => window.__runtime.presenter.choose(true));
await bounded(held.started.promise, 'Reactivation did not launch all 24 real image requests');
await success.page.evaluate(() => window.__runtime.presenter.setFrame(240));
await success.dispose();
held.release.resolve();
await bounded(held.finished.promise, 'Held image responses did not finish');
await success.page.evaluate(async () => {
await window.__runtime.presenter.choose(true);
window.__runtime.presenter.setFrame(0);
window.__runtime.dispose();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
await assertRemoved(success.page, true);
await assertNoErrors(success);
} finally {
await success.close();
}
const partial = await fixture({ fail: (name) => name === 'stage-12.webp' || name === 'level-01.webp' });
try {
await waitForPortrait(partial.page, 'stage-30.webp');
const exhausted = partial.page.waitForResponse((response) => {
const url = new URL(response.url());
return url.pathname.endsWith('/stage-12.webp') && url.searchParams.get('retry') === '3';
}, { timeout: 15000 });
await partial.page.evaluate(() => window.__runtime.presenter.setFrame(96));
await exhausted;
await partial.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await waitForPortrait(partial.page, 'stage-30.webp');
assert.deepEqual(partial.requests.filter(({ name }) => name === 'stage-12.webp').map(({ retry }) => retry), [0, 1, 2, 3], 'A permanently failed target gets bounded cache-bypassing retries');
await partial.dispose();
await assertRemoved(partial.page, true);
await assertNoErrors(partial);
} finally {
await partial.close();
}
const recovery = await fixture({ fail: (name, retry) => name === 'stage-00.webp' && retry === 0, holdRetry: 'stage-00.webp' });
try {
await waitForPortrait(recovery.page, 'stage-30.webp');
await recovery.page.evaluate(() => window.__runtime.presenter.setFrame(0));
await recovery.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await waitForPortrait(recovery.page, 'stage-30.webp');
recovery.releaseRetries();
await waitForPortrait(recovery.page, 'stage-00.webp', 1);
assert.deepEqual(recovery.requests.filter(({ name }) => name === 'stage-00.webp').map(({ retry }) => retry), [0, 1], 'First-frame recovery retries the actual WebP request');
await recovery.dispose();
await assertRemoved(recovery.page, true);
await assertNoErrors(recovery);
} finally {
await recovery.close();
}
console.log('PASS: built apply(ctx), 24 native WebP decodes, frame swaps, partial failure, first-frame retry, disable/enable, and disposal with late responses.');
} finally {
await browser.close();
}

208
src/client/appearance.ts Normal file
View File

@@ -0,0 +1,208 @@
export interface AppearanceSnapshot {
enabled: boolean;
bindEffort: boolean;
}
export interface AppearanceScope {
getSnapshot(): AppearanceSnapshot;
subscribe(listener: () => void): () => void;
setBindEffort(enabled: boolean): Promise<void>;
}
export interface AppearancePresenter {
choose(enabled: boolean): Promise<void>;
}
const NATIVE_APPEARANCE_ROW = '[class*="_cubeRow"]';
const LIANG_APPEARANCE_BUTTON = "liang-appearance-choice";
const LIANG_BINDING_CONTROL = "liang-appearance-binding";
const LIANG_BINDING_INPUT = "liang-appearance-binding__input";
function hasClassSuffix(node: Element, suffix: string) {
return [...node.classList].some((name) => name.endsWith(suffix));
}
function findAppearanceRow() {
for (const row of document.querySelectorAll<HTMLElement>(NATIVE_APPEARANCE_ROW)) {
if (!hasClassSuffix(row, "_cubeRow")) continue;
// All three native appearance choices are direct button children. Validate
// the structure as well as the suffix so unrelated cube rows stay alone.
const nativeButtons = [...row.children].filter(
(node): node is HTMLButtonElement => node instanceof HTMLButtonElement
&& hasClassSuffix(node, "_themeCube"),
);
if (nativeButtons.length < 3) continue;
let group = row.parentElement;
while (group !== null && !hasClassSuffix(group, "_group")) {
group = group.parentElement;
}
group ??= row.parentElement;
if (group !== null) return { row, group, nativeButtons };
}
return null;
}
interface MountedAppearance {
row: HTMLElement;
group: HTMLElement;
button: HTMLButtonElement;
binding: HTMLLabelElement;
bindingInput: HTMLInputElement;
handleCustomClick: () => void;
handleBindingChange: () => void;
}
export function installLiangAppearanceButton(
scope: AppearanceScope,
presenter: AppearancePresenter,
packageId: string,
): () => void {
let pending = false;
let disposed = false;
let mounted: MountedAppearance | null = null;
const nativeClickHandlers = new Map<HTMLButtonElement, () => void>();
const unmount = () => {
for (const [button, handler] of nativeClickHandlers) {
button.removeEventListener("click", handler, { capture: true });
}
nativeClickHandlers.clear();
if (mounted === null) return;
mounted.button.removeEventListener("click", mounted.handleCustomClick);
mounted.bindingInput.removeEventListener("change", mounted.handleBindingChange);
mounted.button.remove();
mounted.binding.remove();
mounted = null;
};
const choose = async (enabled: boolean) => {
if (disposed || pending || scope.getSnapshot().enabled === enabled) return;
pending = true;
sync();
try {
await presenter.choose(enabled);
} catch {
// The preference store remains the source of truth after a failed choice.
} finally {
pending = false;
if (!disposed) sync();
}
};
const createMount = (row: HTMLElement, group: HTMLElement): MountedAppearance => {
const english = document.documentElement.lang.toLowerCase().startsWith("en");
const button = document.createElement("button");
button.className = LIANG_APPEARANCE_BUTTON;
button.type = "button";
button.dataset.plugin = packageId;
button.setAttribute("aria-label", "滑动变祖");
const icon = document.createElement("span");
icon.className = "liang-appearance-choice__icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = "◈";
const label = document.createElement("span");
label.className = "liang-appearance-choice__label";
label.textContent = english ? "Slider" : "滑动变祖";
button.append(icon, label);
const handleCustomClick = () => { void choose(true); };
button.addEventListener("click", handleCustomClick);
const binding = document.createElement("label");
binding.className = LIANG_BINDING_CONTROL;
binding.dataset.plugin = packageId;
const bindingCopy = document.createElement("span");
bindingCopy.className = "liang-appearance-binding__copy";
const bindingLabel = document.createElement("span");
bindingLabel.className = "liang-appearance-binding__label";
const bindingText = english ? "Bind slider to reasoning level" : "滑动变祖绑定思考等级";
bindingLabel.textContent = bindingText;
const bindingDescription = document.createElement("span");
bindingDescription.className = "liang-appearance-binding__description";
bindingDescription.id = "liang-appearance-binding-description";
bindingDescription.textContent = english
? "When off, the slider does not change the reasoning level."
: "关闭之后滑块不联动思考等级";
bindingCopy.append(bindingLabel, bindingDescription);
const bindingInput = document.createElement("input");
bindingInput.className = LIANG_BINDING_INPUT;
bindingInput.type = "checkbox";
bindingInput.setAttribute("role", "switch");
bindingInput.setAttribute("aria-label", bindingText);
bindingInput.setAttribute("aria-describedby", bindingDescription.id);
const updateBinding = async () => {
if (disposed || pending) return;
try {
await scope.setBindEffort(bindingInput.checked);
} catch {
// Restore the persisted selection if writing the preference fails.
} finally {
if (!disposed) sync();
}
};
const handleBindingChange = () => { void updateBinding(); };
bindingInput.addEventListener("change", handleBindingChange);
binding.append(bindingCopy, bindingInput);
return { row, group, button, binding, bindingInput, handleCustomClick, handleBindingChange };
};
const sync = () => {
if (disposed) return;
const target = findAppearanceRow();
if (target === null) {
unmount();
return;
}
if (mounted !== null && (mounted.row !== target.row || mounted.group !== target.group)) {
unmount();
}
mounted ??= createMount(target.row, target.group);
const { row, group, button, binding, bindingInput } = mounted;
if (button.parentElement !== row) row.append(button);
const snapshot = scope.getSnapshot();
button.disabled = pending;
button.setAttribute("aria-pressed", String(snapshot.enabled));
if (snapshot.enabled) {
if (binding.parentElement !== group) group.append(binding);
bindingInput.checked = snapshot.bindEffort;
bindingInput.setAttribute("aria-checked", String(snapshot.bindEffort));
bindingInput.disabled = pending;
} else {
binding.remove();
}
for (const [nativeButton, handler] of nativeClickHandlers) {
if (target.nativeButtons.includes(nativeButton)) continue;
nativeButton.removeEventListener("click", handler, { capture: true });
nativeClickHandlers.delete(nativeButton);
}
for (const nativeButton of target.nativeButtons) {
if (nativeClickHandlers.has(nativeButton)) continue;
const handleNativeClick = () => { void choose(false); };
nativeClickHandlers.set(nativeButton, handleNativeClick);
nativeButton.addEventListener("click", handleNativeClick, { capture: true });
}
};
const observer = new MutationObserver(sync);
observer.observe(document.body, { childList: true, subtree: true });
const unsubscribe = scope.subscribe(sync);
sync();
return () => {
if (disposed) return;
// Mark disposal first: outstanding choices and queued observer callbacks
// must never attach new controls after the client has been deactivated.
disposed = true;
observer.disconnect();
unsubscribe();
unmount();
};
}

View File

@@ -11,13 +11,14 @@ import type {} from "@deepseek-ai/dsh-client-ui-conversation/client";
import type {} from "@deepseek-ai/dsh-client-ui-settings/client"; import type {} from "@deepseek-ai/dsh-client-ui-settings/client";
import type {} from "@deepseek-ai/dsh-client-ui-model-selection/client"; import type {} from "@deepseek-ai/dsh-client-ui-model-selection/client";
import styles from "./skin.css"; import styles from "./skin.css";
import { installLiangAppearanceButton } from "./appearance";
import { createPortraitLoader } from "./portrait-loader";
import { import {
PREVIEW_MAX_FRAME, PREVIEW_MAX_FRAME,
frameForEffort, frameForEffort,
indicatorLabel, indicatorLabel,
nearestEffortIndex, nearestEffortIndex,
paletteForFrame, paletteForFrame,
portraitBlendForLevel,
selectedEffortIndex, selectedEffortIndex,
type EffortLike, type EffortLike,
} from "./logic"; } from "./logic";
@@ -25,7 +26,6 @@ import {
const PACKAGE_ID = "dsh-client-liang-intensity-skin"; const PACKAGE_ID = "dsh-client-liang-intensity-skin";
const LOCALE_NAMESPACE = "liang.skin"; const LOCALE_NAMESPACE = "liang.skin";
const ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets`; const ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets`;
const FIRST_PORTRAIT_FILE = "portrait-source-v2/stage-00.webp";
const BIND_EFFORT_KEY = "dsh-liang-intensity-skin.bind-effort"; const BIND_EFFORT_KEY = "dsh-liang-intensity-skin.bind-effort";
const PORTRAIT_ANCHORS = [ const PORTRAIT_ANCHORS = [
@@ -55,8 +55,6 @@ const PORTRAIT_ANCHORS = [
{ level: 30, file: "stage-30.webp" }, { level: 30, file: "stage-30.webp" },
] as const; ] as const;
const ANCHOR_LEVELS = PORTRAIT_ANCHORS.map((anchor) => anchor.level);
interface SkinSettings { interface SkinSettings {
enabled: boolean; enabled: boolean;
bindEffort: boolean; bindEffort: boolean;
@@ -142,9 +140,7 @@ class SkinPresenter {
private readonly scope: PreferenceStore; private readonly scope: PreferenceStore;
private readonly theme: ThemeService; private readonly theme: ThemeService;
private readonly root: HTMLDivElement; private readonly root: HTMLDivElement;
private readonly portrait: HTMLImageElement; private readonly portraits: ReturnType<typeof createPortraitLoader>;
private readonly preloads: HTMLImageElement[];
private portraitReady = false;
private enabled = true; private enabled = true;
// Default to the max frame so the first paint after load is the dark shell; // Default to the max frame so the first paint after load is the dark shell;
// starting at 0 flashed the light palette before the directory resolved. // starting at 0 flashed the light palette before the directory resolved.
@@ -160,62 +156,34 @@ class SkinPresenter {
this.root = document.createElement("div"); this.root = document.createElement("div");
this.root.className = "liang-skin-backdrop"; this.root.className = "liang-skin-backdrop";
this.root.dataset.plugin = PACKAGE_ID; this.root.dataset.plugin = PACKAGE_ID;
// Show the first half-body frame immediately while the remaining frames this.root.dataset.media = "color";
// are fetched and decoded. A request being complete does not mean the
// bitmap is ready for a tear-free first swap.
this.root.dataset.media = "sequence";
this.root.setAttribute("aria-hidden", "true"); this.root.setAttribute("aria-hidden", "true");
this.portraits = createPortraitLoader({
this.portrait = document.createElement("img"); sources: PORTRAIT_ANCHORS.map(({ level, file }) => ({
this.portrait.className = "liang-skin-sequence-frame"; level,
this.portrait.alt = ""; url: `${ASSET_PREFIX}/portrait-source-v2/${file}`,
this.portrait.draggable = false; })),
this.portrait.decoding = "async"; onChange: ({ image }) => {
this.portrait.src = `${ASSET_PREFIX}/${FIRST_PORTRAIT_FILE}`; if (this.disposed || !this.enabled) return;
this.portrait.addEventListener("error", this.handleSequenceError); if (image === null) {
this.root.dataset.media = "color";
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => { this.root.replaceChildren();
const image = new Image(); return;
image.loading = "eager"; }
image.decoding = "async"; // Reuse the decoded node so a frame swap cannot start a second fetch.
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`; const portrait = image as HTMLImageElement;
return image; portrait.className = "liang-skin-sequence-frame";
}); portrait.alt = "";
portrait.draggable = false;
// `new Image()` starts the requests, but the browser may still defer if (this.root.firstChild !== portrait) this.root.replaceChildren(portrait);
// decoding until the image is attached to the document. Wait for all
// frames up front so the first slider interaction never reveals a blank
// or half-painted frame.
void Promise.all(this.preloads.map((image) => image.decode())).then(
() => {
if (this.disposed) return;
this.portraitReady = true;
this.root.dataset.media = "sequence"; this.root.dataset.media = "sequence";
this.updatePortrait(paletteForFrame(this.frame).level);
}, },
() => { });
// Keep the already-visible first half-body frame if another optional
// frame cannot be decoded. It is still a valid skin fallback.
},
);
this.root.append(this.portrait);
document.body.prepend(this.root);
this.unsubscribe = scope.subscribe(() => this.syncSettings()); this.unsubscribe = scope.subscribe(() => this.syncSettings());
this.syncSettings(); this.syncSettings();
} }
private readonly handleSequenceError = () => {
if (this.portrait.src.endsWith(FIRST_PORTRAIT_FILE)) {
this.root.dataset.media = "color";
return;
}
this.portraitReady = false;
this.portrait.src = `${ASSET_PREFIX}/${FIRST_PORTRAIT_FILE}`;
this.root.dataset.media = "sequence";
};
private syncSettings() { private syncSettings() {
this.setEnabled(this.scope.getSnapshot().enabled); this.setEnabled(this.scope.getSnapshot().enabled);
} }
@@ -229,13 +197,20 @@ class SkinPresenter {
} }
setEnabled(enabled: boolean) { setEnabled(enabled: boolean) {
if (this.disposed) return;
this.enabled = enabled; this.enabled = enabled;
if (enabled) { if (enabled) {
if (!this.root.isConnected) document.body.prepend(this.root); if (!this.root.isConnected) document.body.prepend(this.root);
document.body.dataset.liangSkin = "on"; document.body.dataset.liangSkin = "on";
this.applyFrame(); this.applyFrame();
this.portraits.start();
} else { } else {
if (this.raf !== 0) cancelAnimationFrame(this.raf);
this.raf = 0;
this.root.remove(); this.root.remove();
this.portraits.stop();
this.root.replaceChildren();
this.root.dataset.media = "color";
delete document.body.dataset.liangSkin; delete document.body.dataset.liangSkin;
delete document.body.dataset.liangStage; delete document.body.dataset.liangStage;
for (const name of cssVariables) document.body.style.removeProperty(name); for (const name of cssVariables) document.body.style.removeProperty(name);
@@ -243,6 +218,7 @@ class SkinPresenter {
} }
setFrame(frame: number) { setFrame(frame: number) {
if (this.disposed) return;
this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame))); this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame)));
this.frame = this.pendingFrame; this.frame = this.pendingFrame;
if (!this.enabled) return; if (!this.enabled) return;
@@ -255,6 +231,7 @@ class SkinPresenter {
} }
private applyFrame() { private applyFrame() {
if (this.disposed || !this.enabled) return;
const palette = paletteForFrame(this.frame); const palette = paletteForFrame(this.frame);
const body = document.body; const body = document.body;
this.syncNativeTheme(palette.stage === 5 ? "dark" : "light"); this.syncNativeTheme(palette.stage === 5 ? "dark" : "light");
@@ -283,16 +260,7 @@ class SkinPresenter {
} }
private updatePortrait(level: number) { private updatePortrait(level: number) {
if (!this.portraitReady) return; this.portraits.select(level);
const { lowerIndex, upperIndex, mix } = portraitBlendForLevel(
level,
ANCHOR_LEVELS,
);
const lower = PORTRAIT_ANCHORS[lowerIndex];
const upper = PORTRAIT_ANCHORS[upperIndex];
const selected = mix >= 0.5 ? upper : lower;
const source = `${ASSET_PREFIX}/portrait-source-v2/${selected.file}`;
if (this.portrait.getAttribute("src") !== source) this.portrait.src = source;
} }
async choose(enabled: boolean) { async choose(enabled: boolean) {
@@ -310,8 +278,7 @@ class SkinPresenter {
this.disposed = true; this.disposed = true;
this.unsubscribe(); this.unsubscribe();
if (this.raf !== 0) cancelAnimationFrame(this.raf); if (this.raf !== 0) cancelAnimationFrame(this.raf);
this.portrait.removeEventListener("error", this.handleSequenceError); this.portraits.dispose();
for (const image of this.preloads) image.src = "";
this.root.remove(); this.root.remove();
document.body.removeAttribute("data-liang-skin"); document.body.removeAttribute("data-liang-skin");
delete document.body.dataset.liangStage; delete document.body.dataset.liangStage;
@@ -590,141 +557,6 @@ function AppearanceSkinRow({ scope, presenter, theme, subscribeTheme, t }: Setti
); );
} }
const NATIVE_APPEARANCE_GROUP = '[class*="_8HJdBW_group"]';
const NATIVE_APPEARANCE_ROW = '[class*="_8HJdBW_cubeRow"]';
const LIANG_APPEARANCE_BUTTON = "liang-appearance-choice";
const LIANG_BINDING_CONTROL = "liang-appearance-binding";
const LIANG_BINDING_INPUT = "liang-appearance-binding__input";
function installLiangAppearanceButton(scope: PreferenceStore, presenter: SkinPresenter) {
let pending = false;
const hookedNativeButtons = new Set<HTMLButtonElement>();
const nativeClickHandlers = new Map<HTMLButtonElement, () => void>();
const sync = () => {
const group = [...document.querySelectorAll<HTMLElement>(NATIVE_APPEARANCE_GROUP)]
.find((node) => node.querySelector('[class*="_8HJdBW_themeCube"]'));
const row = group?.querySelector<HTMLElement>(NATIVE_APPEARANCE_ROW);
if (row === undefined || row === null) return;
let customButton = row.querySelector<HTMLButtonElement>(`.${LIANG_APPEARANCE_BUTTON}`);
if (customButton === null) {
customButton = document.createElement("button");
customButton.className = LIANG_APPEARANCE_BUTTON;
customButton.type = "button";
customButton.dataset.plugin = PACKAGE_ID;
customButton.setAttribute("aria-label", "滑动变祖");
const icon = document.createElement("span");
icon.className = "liang-appearance-choice__icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = "◈";
const label = document.createElement("span");
label.className = "liang-appearance-choice__label";
label.textContent = document.documentElement.lang.toLowerCase().startsWith("en")
? "Slider"
: "滑动变祖";
customButton.append(icon, label);
customButton.addEventListener("click", () => {
if (pending || scope.getSnapshot().enabled) return;
pending = true;
sync();
void presenter.choose(true).finally(() => {
pending = false;
sync();
});
});
}
if (customButton.parentElement !== row) row.append(customButton);
const snapshot = scope.getSnapshot();
customButton.disabled = pending;
customButton.setAttribute("aria-pressed", String(snapshot.enabled));
let bindingControl = group.querySelector<HTMLElement>(`.${LIANG_BINDING_CONTROL}`);
if (!snapshot.enabled) {
bindingControl?.remove();
bindingControl = null;
} else if (bindingControl === null) {
bindingControl = document.createElement("label");
bindingControl.className = LIANG_BINDING_CONTROL;
bindingControl.dataset.plugin = PACKAGE_ID;
const bindingCopy = document.createElement("span");
bindingCopy.className = "liang-appearance-binding__copy";
const bindingLabel = document.createElement("span");
bindingLabel.className = "liang-appearance-binding__label";
const bindingText = document.documentElement.lang.toLowerCase().startsWith("en")
? "Bind slider to reasoning level"
: "滑动变祖绑定思考等级";
bindingLabel.textContent = bindingText;
const bindingDescription = document.createElement("span");
bindingDescription.className = "liang-appearance-binding__description";
bindingDescription.id = "liang-appearance-binding-description";
bindingDescription.textContent = document.documentElement.lang.toLowerCase().startsWith("en")
? "When off, the slider does not change the reasoning level."
: "关闭之后滑块不联动思考等级";
bindingCopy.append(bindingLabel, bindingDescription);
const bindingInput = document.createElement("input");
bindingInput.className = LIANG_BINDING_INPUT;
bindingInput.type = "checkbox";
bindingInput.setAttribute("role", "switch");
bindingInput.setAttribute("aria-label", bindingText);
bindingInput.setAttribute("aria-describedby", bindingDescription.id);
bindingInput.addEventListener("change", () => {
void scope.setBindEffort(bindingInput.checked).catch(() => sync());
});
bindingControl.append(bindingCopy, bindingInput);
group.append(bindingControl);
}
if (bindingControl !== null) {
const bindingInput = bindingControl.querySelector<HTMLInputElement>(`.${LIANG_BINDING_INPUT}`);
if (bindingInput !== null) {
bindingInput.checked = snapshot.bindEffort;
bindingInput.setAttribute("aria-checked", String(snapshot.bindEffort));
bindingInput.disabled = pending;
}
}
for (const nativeButton of row.querySelectorAll<HTMLButtonElement>('[class*="_8HJdBW_themeCube"]')) {
if (hookedNativeButtons.has(nativeButton)) continue;
hookedNativeButtons.add(nativeButton);
const handleNativeClick = () => {
if (pending || !scope.getSnapshot().enabled) return;
pending = true;
sync();
void presenter.choose(false).finally(() => {
pending = false;
sync();
});
};
nativeClickHandlers.set(nativeButton, handleNativeClick);
nativeButton.addEventListener("click", handleNativeClick, { capture: true });
}
};
const observer = new MutationObserver(sync);
observer.observe(document.body, { childList: true, subtree: true });
const unsubscribe = scope.subscribe(sync);
sync();
return () => {
observer.disconnect();
unsubscribe();
for (const [nativeButton, handleNativeClick] of nativeClickHandlers) {
nativeButton.removeEventListener("click", handleNativeClick, { capture: true });
}
document.querySelectorAll(`.${LIANG_APPEARANCE_BUTTON}`).forEach((button) => button.remove());
document.querySelectorAll(`.${LIANG_BINDING_CONTROL}`).forEach((control) => control.remove());
};
}
export const inject = [ export const inject = [
"slots", "slots",
"sessions", "sessions",
@@ -795,7 +627,7 @@ export function apply(ctx: ClientContext) {
const presenter = new SkinPresenter(scope, theme); const presenter = new SkinPresenter(scope, theme);
ctx.effect(() => () => presenter.dispose(), "liang-intensity-skin: backdrop presenter"); ctx.effect(() => () => presenter.dispose(), "liang-intensity-skin: backdrop presenter");
ctx.effect( ctx.effect(
() => installLiangAppearanceButton(scope, presenter), () => installLiangAppearanceButton(scope, presenter, PACKAGE_ID),
"liang-intensity-skin: native appearance extension", "liang-intensity-skin: native appearance extension",
); );
ctx.effect( ctx.effect(

View File

@@ -0,0 +1,214 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPortraitLoader, type PortraitImage, type PortraitState } from "./portrait-loader";
class FakeImage implements PortraitImage {
loading: "eager" | "lazy" = "eager";
decoding: "async" | "sync" | "auto" = "async";
private source = "";
readonly requests: string[] = [];
readonly listeners = new Map<string, Set<() => void>>();
resolveDecode!: () => void;
rejectDecode!: (reason?: unknown) => void;
private readonly decoded = new Promise<void>((resolve, reject) => {
this.resolveDecode = resolve;
this.rejectDecode = reject;
});
get src() { return this.source; }
set src(value: string) { this.source = value; this.requests.push(value); }
decode() { return this.decoded; }
addEventListener(type: "load" | "error", listener: () => void) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type)!.add(listener);
}
removeEventListener(type: "load" | "error", listener: () => void) {
this.listeners.get(type)?.delete(listener);
}
removeAttribute(name: string) { if (name === "src") this.source = ""; }
emit(type: "load" | "error") {
for (const listener of [...(this.listeners.get(type) ?? [])]) listener();
}
async succeed() {
this.emit("load");
this.resolveDecode();
await Promise.resolve();
}
listenerCount() {
return [...this.listeners.values()].reduce((total, entries) => total + entries.size, 0);
}
}
function harness(urls = ["/portrait-0.webp", "/portrait-10.webp", "/portrait-20.webp"]) {
const images: FakeImage[] = [];
const states: PortraitState[] = [];
const loader = createPortraitLoader({
sources: urls.map((url, index) => ({ level: index * 10, url })),
createImage: () => {
const image = new FakeImage();
images.push(image);
return image;
},
onChange: (state) => states.push(state),
});
return { loader, images, states, state: () => states.at(-1) };
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe("portrait loading recovery", () => {
it("shows each decoded frame without waiting for other requests or decodes", async () => {
const { loader, images, state } = harness();
loader.select(20);
loader.start();
images[0].emit("load"); // Leave this decode pending.
await images[1].succeed();
expect(state()).toEqual({ index: 1, url: "/portrait-10.webp", image: images[1], status: "ready" });
images[2].emit("error");
await vi.advanceTimersByTimeAsync(500);
await images[3].succeed();
expect(state()).toEqual({ index: 2, url: "/portrait-20.webp?retry=1", image: images[3], status: "ready" });
loader.dispose();
});
it("keeps the displayed frame until the requested target becomes available", async () => {
const { loader, images, state } = harness();
loader.start();
await images[0].succeed();
loader.select(20);
await images[1].succeed();
expect(state()?.index).toBe(0);
await images[2].succeed();
expect(state()?.index).toBe(2);
loader.select(5); // Midpoint follows the upper anchor, like portraitBlendForLevel.
expect(state()?.index).toBe(1);
loader.dispose();
});
it("limits each URL to three retries with the required backoff and cache keys", async () => {
const { loader, images, state } = harness(["/portrait.webp?size=2#frame"]);
loader.start();
images[0].emit("error");
for (const [attempt, delay] of [500, 1500, 3000].entries()) {
await vi.advanceTimersByTimeAsync(delay - 1);
expect(images).toHaveLength(attempt + 1);
await vi.advanceTimersByTimeAsync(1);
expect(images[attempt + 1].src).toBe(`/portrait.webp?size=2&retry=${attempt + 1}#frame`);
images[attempt + 1].emit("error");
}
expect(state()).toEqual({ index: null, url: null, image: null, status: "failed" });
await vi.runAllTimersAsync();
expect(images).toHaveLength(4);
expect(images.every((image) => image.src === "" && image.listenerCount() === 0)).toBe(true);
loader.dispose();
});
it("falls back to color only after all frame requests exhaust their retries", async () => {
const { loader, images, state } = harness(["/a.webp", "/b.webp"]);
loader.start();
const failPending = () => images.filter((image) => image.src).forEach((image) => image.emit("error"));
failPending();
expect(state()?.status).toBe("loading");
for (const delay of [500, 1500, 3000]) {
await vi.advanceTimersByTimeAsync(delay);
failPending();
}
expect(images).toHaveLength(8);
expect(state()).toEqual({ index: null, url: null, image: null, status: "failed" });
loader.dispose();
});
it("preserves a usable frame when another URL exhausts every retry", async () => {
const { loader, images, state } = harness(["/a.webp", "/b.webp"]);
loader.select(10);
loader.start();
await images[0].succeed();
images[1].emit("error");
for (const [attempt, delay] of [500, 1500, 3000].entries()) {
await vi.advanceTimersByTimeAsync(delay);
images[attempt + 2].emit("error");
}
expect(state()).toEqual({ index: 0, url: "/a.webp", image: images[0], status: "ready" });
loader.dispose();
});
it("retries a decode failure and accepts a recovered target frame", async () => {
const { loader, images, state } = harness(["/a.webp"]);
loader.start();
images[0].emit("load");
images[0].rejectDecode(new Error("incomplete bitmap"));
await Promise.resolve();
await vi.advanceTimersByTimeAsync(500);
await images[1].succeed();
expect(state()).toEqual({ index: 0, url: "/a.webp?retry=1", image: images[1], status: "ready" });
loader.dispose();
});
it("cancels retries and releases ready, decoding, and loading images on stop", async () => {
const { loader, images, states } = harness();
loader.start();
await images[0].succeed();
images[1].emit("load");
images[2].emit("error");
const beforeStop = states.length;
loader.stop();
expect(images.every((image) => image.src === "" && image.listenerCount() === 0)).toBe(true);
images[1].resolveDecode();
await vi.runAllTimersAsync();
expect(images).toHaveLength(3);
expect(states).toHaveLength(beforeStop);
loader.dispose();
});
it("rejects stale decode results after stop and restart", async () => {
const { loader, images, states, state } = harness(["/a.webp"]);
loader.start();
images[0].emit("load");
loader.stop();
loader.start();
const restartedStates = states.length;
images[0].resolveDecode();
await Promise.resolve();
expect(states).toHaveLength(restartedStates);
expect(state()?.status).toBe("loading");
await images[1].succeed();
expect(state()?.status).toBe("ready");
loader.dispose();
});
it("makes disposal permanent and ignores saved callbacks from cancelled attempts", async () => {
const { loader, images, states } = harness(["/a.webp"]);
loader.start();
const staleLoad = [...images[0].listeners.get("load")!][0];
const staleError = [...images[0].listeners.get("error")!][0];
loader.dispose();
const disposedStates = states.length;
staleLoad();
staleError();
loader.select(10);
loader.start();
loader.dispose();
await vi.runAllTimersAsync();
expect(images).toHaveLength(1);
expect(images[0].src).toBe("");
expect(states).toHaveLength(disposedStates);
});
it("loads duplicate source URLs once while selecting their anchor levels", async () => {
const { loader, images, state } = harness(["/shared.webp", "/shared.webp"]);
loader.select(10);
loader.start();
loader.start();
expect(images).toHaveLength(1);
await images[0].succeed();
expect(state()).toEqual({ index: 1, url: "/shared.webp", image: images[0], status: "ready" });
loader.dispose();
});
it("handles an empty source list without creating image requests", () => {
const { loader, images, state } = harness([]);
loader.start();
expect(images).toHaveLength(0);
expect(state()).toEqual({ index: null, url: null, image: null, status: "failed" });
loader.dispose();
});
});

View File

@@ -0,0 +1,201 @@
export interface PortraitSource {
level: number;
url: string;
}
export interface PortraitState {
index: number | null;
url: string | null;
status: "loading" | "ready" | "failed";
image: PortraitImage | null;
}
export interface PortraitImage {
src: string;
loading: "eager" | "lazy";
decoding: "async" | "sync" | "auto";
decode(): Promise<void>;
addEventListener(type: "load" | "error", listener: () => void): void;
removeEventListener(type: "load" | "error", listener: () => void): void;
removeAttribute(name: string): void;
}
export interface PortraitLoaderOptions {
sources: readonly PortraitSource[];
onChange: (state: PortraitState) => void;
createImage?: () => PortraitImage;
}
interface Asset {
url: string;
status: "idle" | "loading" | "ready" | "failed";
loadedUrl: string | null;
retries: number;
image: PortraitImage | null;
timer: ReturnType<typeof setTimeout> | null;
detach: (() => void) | null;
}
const RETRY_DELAYS = [500, 1500, 3000] as const;
function retryUrl(source: string, retry: number) {
if (retry === 0) return source;
const hashIndex = source.indexOf("#");
const hash = hashIndex < 0 ? "" : source.slice(hashIndex);
const base = hashIndex < 0 ? source : source.slice(0, hashIndex);
const queryIndex = base.indexOf("?");
const path = queryIndex < 0 ? base : base.slice(0, queryIndex);
const query = new URLSearchParams(queryIndex < 0 ? "" : base.slice(queryIndex + 1));
query.set("retry", String(retry));
return `${path}?${query}${hash}`;
}
/**
* Decode frames independently and return the decoded image itself for display.
* start() resumes after stop(); stop() silently releases every request/image.
* dispose() is permanent. select() also accepts a target before start().
*/
export function createPortraitLoader(options: PortraitLoaderOptions) {
const sources = options.sources.map((source) => ({ ...source }));
const assets = new Map<string, Asset>();
for (const { url } of sources) {
if (!assets.has(url)) {
assets.set(url, {
url, status: "idle", loadedUrl: null, retries: 0,
image: null, timer: null, detach: null,
});
}
}
const createImage = options.createImage ?? (() => new Image());
let onChange: PortraitLoaderOptions["onChange"] | null = options.onChange;
let active = false;
let disposed = false;
let generation = 0;
let targetLevel = sources[0]?.level ?? 0;
let displayedIndex: number | null = null;
let lastState: PortraitState | null = null;
const isCurrent = (run: number) => active && !disposed && generation === run;
const nearestIndex = (available: (index: number) => boolean) => {
let nearest: number | null = null;
for (let index = 0; index < sources.length; index += 1) {
if (!available(index)) continue;
const distance = Math.abs(sources[index].level - targetLevel);
const previous = nearest === null ? Infinity : Math.abs(sources[nearest].level - targetLevel);
if (nearest === null || distance < previous || (distance === previous && sources[index].level > sources[nearest].level)) {
nearest = index;
}
}
return nearest;
};
const isReady = (index: number) => assets.get(sources[index].url)?.status === "ready";
const emit = (state: PortraitState) => {
if (lastState?.index === state.index && lastState.url === state.url && lastState.status === state.status) return;
lastState = state;
onChange?.(state);
};
const reconcile = () => {
if (!active || disposed) return;
const target = nearestIndex(() => true);
if (target !== null && isReady(target)) displayedIndex = target;
else if (displayedIndex === null) displayedIndex = nearestIndex(isReady);
if (displayedIndex !== null) {
const asset = assets.get(sources[displayedIndex].url)!;
emit({ index: displayedIndex, url: asset.loadedUrl, image: asset.image, status: "ready" });
} else {
const exhausted = [...assets.values()].every((asset) => asset.status === "failed");
emit({ index: null, url: null, image: null, status: exhausted ? "failed" : "loading" });
}
};
const request = (asset: Asset, run: number) => {
if (!isCurrent(run)) return;
asset.status = "loading";
const image = createImage();
asset.image = image;
image.loading = "eager";
image.decoding = "async";
const url = retryUrl(asset.url, asset.retries);
let settled = false;
let decoding = false;
const current = () => !settled && isCurrent(run) && asset.image === image;
const detach = () => {
image.removeEventListener("load", handleLoad);
image.removeEventListener("error", handleError);
asset.detach = null;
};
const handleError = () => {
if (!current()) return;
settled = true;
detach();
image.removeAttribute("src");
asset.image = null;
if (asset.retries < RETRY_DELAYS.length) {
const delay = RETRY_DELAYS[asset.retries];
asset.retries += 1;
asset.timer = setTimeout(() => {
if (!isCurrent(run)) return;
asset.timer = null;
request(asset, run);
}, delay);
} else {
asset.status = "failed";
reconcile();
}
};
const handleLoad = () => {
if (!current() || decoding) return;
decoding = true;
void image.decode().then(() => {
if (!current()) return;
settled = true;
detach();
asset.loadedUrl = url;
asset.status = "ready";
reconcile();
}, handleError);
};
asset.detach = detach;
image.addEventListener("load", handleLoad);
image.addEventListener("error", handleError);
image.src = url;
};
const stop = () => {
active = false;
generation += 1;
for (const asset of assets.values()) {
if (asset.timer !== null) clearTimeout(asset.timer);
asset.timer = null;
asset.detach?.();
asset.image?.removeAttribute("src");
asset.image = null;
asset.status = "idle";
asset.loadedUrl = null;
asset.retries = 0;
}
displayedIndex = null;
lastState = null;
};
return {
start() {
if (disposed || active) return;
active = true;
const run = ++generation;
reconcile();
for (const asset of assets.values()) request(asset, run);
},
select(level: number) {
if (disposed || !Number.isFinite(level)) return;
targetLevel = level;
reconcile();
},
stop,
dispose() {
stop();
disposed = true;
onChange = null;
},
};
}

View File

@@ -1,6 +1,6 @@
.liang-skin-backdrop { .liang-skin-backdrop {
position: fixed; position: fixed;
z-index: 0; z-index: -1;
inset: 0; inset: 0;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
@@ -19,7 +19,7 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
right: 0; right: 0;
display: block; display: block;
width: min(42vw, 720px); width: min(42vw, 720px);
height: 100vh; height: 100%;
object-fit: cover; object-fit: cover;
object-position: 50% 42%; object-position: 50% 42%;
opacity: var(--liang-portrait-opacity, 0.45); opacity: var(--liang-portrait-opacity, 0.45);
@@ -46,11 +46,16 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
position: absolute; position: absolute;
inset: 0; inset: 0;
content: ""; content: "";
background: linear-gradient(90deg, var(--liang-page, transparent) 0 28%, transparent 57% 100%); background:
linear-gradient(90deg, var(--liang-page, transparent) 0 28%, transparent 57% 100%),
linear-gradient(var(--liang-bg-base, transparent), var(--liang-bg-base, transparent));
} }
body[data-liang-skin="on"] { body[data-liang-skin="on"] {
--dsw-alias-bg-base: var(--liang-bg-base) !important; /* Keep the backdrop above the canvas but below content and native portals.
The host owns root positioning, dimensions, and its own stacking order. */
isolation: isolate;
--dsw-alias-bg-base: transparent !important;
--dsw-alias-bg-layer-1: var(--liang-layer-1) !important; --dsw-alias-bg-layer-1: var(--liang-layer-1) !important;
--dsw-alias-bg-layer-2: var(--liang-layer-2) !important; --dsw-alias-bg-layer-2: var(--liang-layer-2) !important;
--dsw-alias-bg-layer-3: var(--liang-layer-3) !important; --dsw-alias-bg-layer-3: var(--liang-layer-3) !important;
@@ -210,11 +215,16 @@ body[data-liang-skin="on"]:is(
} }
body[data-liang-skin="on"] > #root { body[data-liang-skin="on"] > #root {
position: relative;
z-index: 1;
background: transparent !important; background: transparent !important;
} }
body[data-liang-skin="on"]:is(
[data-dsh-desktop-mode="compatibility"],
[data-dsh-desktop-mode="extended"]
) .liang-skin-backdrop {
top: var(--dsh-desktop-frame-height, 0px);
}
/* Windows paints the sidebar fill across the host frame, covering the portrait. /* Windows paints the sidebar fill across the host frame, covering the portrait.
Match macOS's transparent frame while keeping the sidebar, caption, and Match macOS's transparent frame while keeping the sidebar, caption, and
conversation surfaces painted by their own host rules. */ conversation surfaces painted by their own host rules. */
@@ -451,7 +461,7 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
display: flex; display: flex;
} }
[class*="_8HJdBW_cubeRow"]:has(.liang-appearance-choice) { [class*="_cubeRow"]:has(.liang-appearance-choice) {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }