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:
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