2
0

fix: restore skin controls and desktop portrait compatibility

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

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

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

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

View File

@@ -11,13 +11,14 @@ import type {} from "@deepseek-ai/dsh-client-ui-conversation/client";
import type {} from "@deepseek-ai/dsh-client-ui-settings/client";
import type {} from "@deepseek-ai/dsh-client-ui-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(

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
.liang-skin-backdrop {
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));
}