forked from 27hrs/dsh-long-skin
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:
@@ -60,10 +60,11 @@ dsh plugin --profile web add 'github:kingOfSoySauce/dsh-liang-skin'
|
||||
|
||||
- `滑动变祖`:显示滑块并启用人物、背景和自适应配色。
|
||||
- `原生`:移除背景层、皮肤变量和滑块,恢复 Harness 原生界面。
|
||||
- `滑动变祖绑定思考等级`:关闭后滑块只切换人物和配色,不修改模型的推理等级。
|
||||
|
||||
皮肤保持启用时,人物与背景配色由滑块档位决定;切换 DSH 原生深浅主题不会改变当前档位的背景。
|
||||
|
||||
选择保存在当前浏览器本地,不会改动模型配置。
|
||||
绑定开关保存在当前浏览器本地。切回原生外观只对本次客户端激活有效;刷新页面或重新激活插件后默认开启皮肤。外观选择的跨刷新记忆暂未实现。绑定开启时,滑块会提交模型的推理档位;关闭绑定后只改变外观。
|
||||
|
||||
<a id="cli-install"></a>
|
||||
|
||||
@@ -134,10 +135,13 @@ dsh plugin --profile web remove dsh-client-liang-intensity-skin
|
||||
npm install
|
||||
npm test
|
||||
npm run build
|
||||
npm run test:compatibility
|
||||
```
|
||||
|
||||
修改 client 源码后需要运行 `npm run build`,并一起提交更新后的 `lib/client.js` 和 source map。
|
||||
|
||||
浏览器兼容性回归使用本机 Chrome / Chromium;可通过 `CHROME_PATH` 指定浏览器可执行文件,不会自动下载浏览器。测试使用宿主布局夹具,不能代替官方 Desktop 真机验收。本轮未发布修复的验证步骤见 [兼容性验证说明](docs/compatibility-validation.md)。
|
||||
|
||||
## 灵感与素材来源
|
||||
|
||||
本插件的“滑动变祖”视觉概念与人物图片素材源自
|
||||
|
||||
35
docs/compatibility-validation.md
Normal file
35
docs/compatibility-validation.md
Normal 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 留待后续处理。
|
||||
552
lib/client.js
552
lib/client.js
@@ -29,7 +29,7 @@ var import_react = require("react");
|
||||
// src/client/skin.css
|
||||
var skin_default = `.liang-skin-backdrop {
|
||||
position: fixed;
|
||||
z-index: 0;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
@@ -48,7 +48,7 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
||||
right: 0;
|
||||
display: block;
|
||||
width: min(42vw, 720px);
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 42%;
|
||||
opacity: var(--liang-portrait-opacity, 0.45);
|
||||
@@ -75,11 +75,16 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
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"] {
|
||||
--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-2: var(--liang-layer-2) !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 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
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.
|
||||
Match macOS's transparent frame while keeping the sidebar, caption, and
|
||||
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;
|
||||
}
|
||||
|
||||
[class*="_8HJdBW_cubeRow"]:has(.liang-appearance-choice) {
|
||||
[class*="_cubeRow"]:has(.liang-appearance-choice) {
|
||||
display: grid;
|
||||
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
|
||||
var PREVIEW_MAX_FRAME = 240;
|
||||
var MAX_LEVEL = 30;
|
||||
@@ -665,19 +993,6 @@ function selectedEffortIndex(efforts, selectedId, defaultId) {
|
||||
const id = selectedId ?? defaultId;
|
||||
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) {
|
||||
const level = clampFrame(rawFrame) / PREVIEW_MAX_FRAME * MAX_LEVEL;
|
||||
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 LOCALE_NAMESPACE = "liang.skin";
|
||||
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 PORTRAIT_ANCHORS = [
|
||||
{ level: 0, file: "stage-00.webp" },
|
||||
@@ -778,7 +1092,6 @@ var PORTRAIT_ANCHORS = [
|
||||
{ level: 29, file: "level-29.webp" },
|
||||
{ level: 30, file: "stage-30.webp" }
|
||||
];
|
||||
var ANCHOR_LEVELS = PORTRAIT_ANCHORS.map((anchor) => anchor.level);
|
||||
var cssVariables = [
|
||||
"--liang-strength",
|
||||
"--liang-page",
|
||||
@@ -800,9 +1113,7 @@ var SkinPresenter = class {
|
||||
scope;
|
||||
theme;
|
||||
root;
|
||||
portrait;
|
||||
preloads;
|
||||
portraitReady = false;
|
||||
portraits;
|
||||
enabled = true;
|
||||
// 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.
|
||||
@@ -817,46 +1128,31 @@ var SkinPresenter = class {
|
||||
this.root = document.createElement("div");
|
||||
this.root.className = "liang-skin-backdrop";
|
||||
this.root.dataset.plugin = PACKAGE_ID;
|
||||
this.root.dataset.media = "sequence";
|
||||
this.root.dataset.media = "color";
|
||||
this.root.setAttribute("aria-hidden", "true");
|
||||
this.portrait = document.createElement("img");
|
||||
this.portrait.className = "liang-skin-sequence-frame";
|
||||
this.portrait.alt = "";
|
||||
this.portrait.draggable = false;
|
||||
this.portrait.decoding = "async";
|
||||
this.portrait.src = `${ASSET_PREFIX}/${FIRST_PORTRAIT_FILE}`;
|
||||
this.portrait.addEventListener("error", this.handleSequenceError);
|
||||
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
|
||||
const image = new Image();
|
||||
image.loading = "eager";
|
||||
image.decoding = "async";
|
||||
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
|
||||
return image;
|
||||
});
|
||||
void Promise.all(this.preloads.map((image) => image.decode())).then(
|
||||
() => {
|
||||
if (this.disposed) return;
|
||||
this.portraitReady = true;
|
||||
this.root.dataset.media = "sequence";
|
||||
this.updatePortrait(paletteForFrame(this.frame).level);
|
||||
},
|
||||
() => {
|
||||
this.portraits = createPortraitLoader({
|
||||
sources: PORTRAIT_ANCHORS.map(({ level, file }) => ({
|
||||
level,
|
||||
url: `${ASSET_PREFIX}/portrait-source-v2/${file}`
|
||||
})),
|
||||
onChange: ({ image }) => {
|
||||
if (this.disposed || !this.enabled) return;
|
||||
if (image === null) {
|
||||
this.root.dataset.media = "color";
|
||||
this.root.replaceChildren();
|
||||
return;
|
||||
}
|
||||
);
|
||||
this.root.append(this.portrait);
|
||||
document.body.prepend(this.root);
|
||||
const portrait = image;
|
||||
portrait.className = "liang-skin-sequence-frame";
|
||||
portrait.alt = "";
|
||||
portrait.draggable = false;
|
||||
if (this.root.firstChild !== portrait) this.root.replaceChildren(portrait);
|
||||
this.root.dataset.media = "sequence";
|
||||
}
|
||||
});
|
||||
this.unsubscribe = scope.subscribe(() => 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() {
|
||||
this.setEnabled(this.scope.getSnapshot().enabled);
|
||||
}
|
||||
@@ -867,19 +1163,27 @@ var SkinPresenter = class {
|
||||
return this.frame;
|
||||
}
|
||||
setEnabled(enabled) {
|
||||
if (this.disposed) return;
|
||||
this.enabled = enabled;
|
||||
if (enabled) {
|
||||
if (!this.root.isConnected) document.body.prepend(this.root);
|
||||
document.body.dataset.liangSkin = "on";
|
||||
this.applyFrame();
|
||||
this.portraits.start();
|
||||
} else {
|
||||
if (this.raf !== 0) cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
this.root.remove();
|
||||
this.portraits.stop();
|
||||
this.root.replaceChildren();
|
||||
this.root.dataset.media = "color";
|
||||
delete document.body.dataset.liangSkin;
|
||||
delete document.body.dataset.liangStage;
|
||||
for (const name of cssVariables) document.body.style.removeProperty(name);
|
||||
}
|
||||
}
|
||||
setFrame(frame) {
|
||||
if (this.disposed) return;
|
||||
this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame)));
|
||||
this.frame = this.pendingFrame;
|
||||
if (!this.enabled) return;
|
||||
@@ -891,6 +1195,7 @@ var SkinPresenter = class {
|
||||
});
|
||||
}
|
||||
applyFrame() {
|
||||
if (this.disposed || !this.enabled) return;
|
||||
const palette = paletteForFrame(this.frame);
|
||||
const body = document.body;
|
||||
this.syncNativeTheme(palette.stage === 5 ? "dark" : "light");
|
||||
@@ -917,16 +1222,7 @@ var SkinPresenter = class {
|
||||
this.theme.setTheme(theme);
|
||||
}
|
||||
updatePortrait(level) {
|
||||
if (!this.portraitReady) return;
|
||||
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;
|
||||
this.portraits.select(level);
|
||||
}
|
||||
async choose(enabled) {
|
||||
this.setEnabled(enabled);
|
||||
@@ -942,8 +1238,7 @@ var SkinPresenter = class {
|
||||
this.disposed = true;
|
||||
this.unsubscribe();
|
||||
if (this.raf !== 0) cancelAnimationFrame(this.raf);
|
||||
this.portrait.removeEventListener("error", this.handleSequenceError);
|
||||
for (const image of this.preloads) image.src = "";
|
||||
this.portraits.dispose();
|
||||
this.root.remove();
|
||||
document.body.removeAttribute("data-liang-skin");
|
||||
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 = [
|
||||
"slots",
|
||||
"sessions",
|
||||
@@ -1284,7 +1468,7 @@ function apply(ctx) {
|
||||
const presenter = new SkinPresenter(scope, theme);
|
||||
ctx.effect(() => () => presenter.dispose(), "liang-intensity-skin: backdrop presenter");
|
||||
ctx.effect(
|
||||
() => installLiangAppearanceButton(scope, presenter),
|
||||
() => installLiangAppearanceButton(scope, presenter, PACKAGE_ID),
|
||||
"liang-intensity-skin: native appearance extension"
|
||||
);
|
||||
ctx.effect(
|
||||
|
||||
File diff suppressed because one or more lines are too long
14
package-lock.json
generated
14
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "~18.3.1",
|
||||
"esbuild": "^0.25.0",
|
||||
"playwright-core": "^1.64.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
},
|
||||
@@ -1330,6 +1331,19 @@
|
||||
"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": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz",
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
],
|
||||
"scripts": {
|
||||
"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": {
|
||||
"bundle": {
|
||||
@@ -70,6 +71,7 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "~18.3.1",
|
||||
"esbuild": "^0.25.0",
|
||||
"playwright-core": "^1.64.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
280
scripts/test-client-compatibility.mjs
Normal file
280
scripts/test-client-compatibility.mjs
Normal 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(); }
|
||||
307
scripts/test-client-runtime.mjs
Normal file
307
scripts/test-client-runtime.mjs
Normal 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
208
src/client/appearance.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -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-model-selection/client";
|
||||
import styles from "./skin.css";
|
||||
import { installLiangAppearanceButton } from "./appearance";
|
||||
import { createPortraitLoader } from "./portrait-loader";
|
||||
import {
|
||||
PREVIEW_MAX_FRAME,
|
||||
frameForEffort,
|
||||
indicatorLabel,
|
||||
nearestEffortIndex,
|
||||
paletteForFrame,
|
||||
portraitBlendForLevel,
|
||||
selectedEffortIndex,
|
||||
type EffortLike,
|
||||
} from "./logic";
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
const PACKAGE_ID = "dsh-client-liang-intensity-skin";
|
||||
const LOCALE_NAMESPACE = "liang.skin";
|
||||
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 PORTRAIT_ANCHORS = [
|
||||
@@ -55,8 +55,6 @@ const PORTRAIT_ANCHORS = [
|
||||
{ level: 30, file: "stage-30.webp" },
|
||||
] as const;
|
||||
|
||||
const ANCHOR_LEVELS = PORTRAIT_ANCHORS.map((anchor) => anchor.level);
|
||||
|
||||
interface SkinSettings {
|
||||
enabled: boolean;
|
||||
bindEffort: boolean;
|
||||
@@ -142,9 +140,7 @@ class SkinPresenter {
|
||||
private readonly scope: PreferenceStore;
|
||||
private readonly theme: ThemeService;
|
||||
private readonly root: HTMLDivElement;
|
||||
private readonly portrait: HTMLImageElement;
|
||||
private readonly preloads: HTMLImageElement[];
|
||||
private portraitReady = false;
|
||||
private readonly portraits: ReturnType<typeof createPortraitLoader>;
|
||||
private enabled = true;
|
||||
// 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.
|
||||
@@ -160,62 +156,34 @@ class SkinPresenter {
|
||||
this.root = document.createElement("div");
|
||||
this.root.className = "liang-skin-backdrop";
|
||||
this.root.dataset.plugin = PACKAGE_ID;
|
||||
// Show the first half-body frame immediately while the remaining frames
|
||||
// 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.dataset.media = "color";
|
||||
this.root.setAttribute("aria-hidden", "true");
|
||||
|
||||
this.portrait = document.createElement("img");
|
||||
this.portrait.className = "liang-skin-sequence-frame";
|
||||
this.portrait.alt = "";
|
||||
this.portrait.draggable = false;
|
||||
this.portrait.decoding = "async";
|
||||
this.portrait.src = `${ASSET_PREFIX}/${FIRST_PORTRAIT_FILE}`;
|
||||
this.portrait.addEventListener("error", this.handleSequenceError);
|
||||
|
||||
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
|
||||
const image = new Image();
|
||||
image.loading = "eager";
|
||||
image.decoding = "async";
|
||||
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
|
||||
return image;
|
||||
});
|
||||
|
||||
// `new Image()` starts the requests, but the browser may still defer
|
||||
// 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.portraits = createPortraitLoader({
|
||||
sources: PORTRAIT_ANCHORS.map(({ level, file }) => ({
|
||||
level,
|
||||
url: `${ASSET_PREFIX}/portrait-source-v2/${file}`,
|
||||
})),
|
||||
onChange: ({ image }) => {
|
||||
if (this.disposed || !this.enabled) return;
|
||||
if (image === null) {
|
||||
this.root.dataset.media = "color";
|
||||
this.root.replaceChildren();
|
||||
return;
|
||||
}
|
||||
// Reuse the decoded node so a frame swap cannot start a second fetch.
|
||||
const portrait = image as HTMLImageElement;
|
||||
portrait.className = "liang-skin-sequence-frame";
|
||||
portrait.alt = "";
|
||||
portrait.draggable = false;
|
||||
if (this.root.firstChild !== portrait) this.root.replaceChildren(portrait);
|
||||
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.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() {
|
||||
this.setEnabled(this.scope.getSnapshot().enabled);
|
||||
}
|
||||
@@ -229,13 +197,20 @@ class SkinPresenter {
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean) {
|
||||
if (this.disposed) return;
|
||||
this.enabled = enabled;
|
||||
if (enabled) {
|
||||
if (!this.root.isConnected) document.body.prepend(this.root);
|
||||
document.body.dataset.liangSkin = "on";
|
||||
this.applyFrame();
|
||||
this.portraits.start();
|
||||
} else {
|
||||
if (this.raf !== 0) cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
this.root.remove();
|
||||
this.portraits.stop();
|
||||
this.root.replaceChildren();
|
||||
this.root.dataset.media = "color";
|
||||
delete document.body.dataset.liangSkin;
|
||||
delete document.body.dataset.liangStage;
|
||||
for (const name of cssVariables) document.body.style.removeProperty(name);
|
||||
@@ -243,6 +218,7 @@ class SkinPresenter {
|
||||
}
|
||||
|
||||
setFrame(frame: number) {
|
||||
if (this.disposed) return;
|
||||
this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame)));
|
||||
this.frame = this.pendingFrame;
|
||||
if (!this.enabled) return;
|
||||
@@ -255,6 +231,7 @@ class SkinPresenter {
|
||||
}
|
||||
|
||||
private applyFrame() {
|
||||
if (this.disposed || !this.enabled) return;
|
||||
const palette = paletteForFrame(this.frame);
|
||||
const body = document.body;
|
||||
this.syncNativeTheme(palette.stage === 5 ? "dark" : "light");
|
||||
@@ -283,16 +260,7 @@ class SkinPresenter {
|
||||
}
|
||||
|
||||
private updatePortrait(level: number) {
|
||||
if (!this.portraitReady) return;
|
||||
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;
|
||||
this.portraits.select(level);
|
||||
}
|
||||
|
||||
async choose(enabled: boolean) {
|
||||
@@ -310,8 +278,7 @@ class SkinPresenter {
|
||||
this.disposed = true;
|
||||
this.unsubscribe();
|
||||
if (this.raf !== 0) cancelAnimationFrame(this.raf);
|
||||
this.portrait.removeEventListener("error", this.handleSequenceError);
|
||||
for (const image of this.preloads) image.src = "";
|
||||
this.portraits.dispose();
|
||||
this.root.remove();
|
||||
document.body.removeAttribute("data-liang-skin");
|
||||
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 = [
|
||||
"slots",
|
||||
"sessions",
|
||||
@@ -795,7 +627,7 @@ export function apply(ctx: ClientContext) {
|
||||
const presenter = new SkinPresenter(scope, theme);
|
||||
ctx.effect(() => () => presenter.dispose(), "liang-intensity-skin: backdrop presenter");
|
||||
ctx.effect(
|
||||
() => installLiangAppearanceButton(scope, presenter),
|
||||
() => installLiangAppearanceButton(scope, presenter, PACKAGE_ID),
|
||||
"liang-intensity-skin: native appearance extension",
|
||||
);
|
||||
ctx.effect(
|
||||
|
||||
214
src/client/portrait-loader.test.ts
Normal file
214
src/client/portrait-loader.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
201
src/client/portrait-loader.ts
Normal file
201
src/client/portrait-loader.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
.liang-skin-backdrop {
|
||||
position: fixed;
|
||||
z-index: 0;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
@@ -19,7 +19,7 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
||||
right: 0;
|
||||
display: block;
|
||||
width: min(42vw, 720px);
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 42%;
|
||||
opacity: var(--liang-portrait-opacity, 0.45);
|
||||
@@ -46,11 +46,16 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
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"] {
|
||||
--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-2: var(--liang-layer-2) !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 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
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.
|
||||
Match macOS's transparent frame while keeping the sidebar, caption, and
|
||||
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;
|
||||
}
|
||||
|
||||
[class*="_8HJdBW_cubeRow"]:has(.liang-appearance-choice) {
|
||||
[class*="_cubeRow"]:has(.liang-appearance-choice) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user