@lemoncat7/dsh-theme-xiaohei 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { XIAOHEI_BRAND_AVATAR } from './generated-identity.js';
4
+ // Native wide/rail switches remount this slot. A tiny navigation mark does not
5
+ // need a fresh GPU context and shader compilation on every toggle.
6
+ const metallicSnapshots = new Map();
4
7
  const VERTEX_SHADER = `#version 300 es
5
8
  precision highp float;
6
9
  in vec2 a_position;
@@ -167,13 +170,16 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
167
170
  const [ready, setReady] = useState(false);
168
171
  const visualSize = Math.max(24, size + 4);
169
172
  const context = size >= 32 ? 'hero' : 'sidebar';
173
+ const [snapshot, setSnapshot] = useState(metallicSnapshots.get(visualSize));
170
174
  useEffect(() => {
175
+ if (snapshot !== undefined)
176
+ return;
171
177
  const canvas = canvasRef.current;
172
178
  if (canvas === null)
173
179
  return;
174
180
  const gl = canvas.getContext('webgl2', {
175
181
  alpha: true,
176
- antialias: true,
182
+ antialias: false,
177
183
  powerPreference: 'low-power',
178
184
  premultipliedAlpha: true,
179
185
  });
@@ -207,7 +213,7 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
207
213
  }
208
214
  const uniforms = Object.fromEntries(UNIFORM_NAMES.map(name => [name, gl.getUniformLocation(program, name)]));
209
215
  const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
210
- const renderSize = Math.max(96, Math.ceil(visualSize * pixelRatio * 2));
216
+ const renderSize = Math.max(64, Math.ceil(visualSize * pixelRatio));
211
217
  canvas.width = renderSize;
212
218
  canvas.height = renderSize;
213
219
  gl.viewport(0, 0, renderSize, renderSize);
@@ -238,28 +244,13 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
238
244
  setRgb(gl, uniforms.u_darkColor, [0.025, 0.04, 0.045]);
239
245
  setRgb(gl, uniforms.u_tint, [0.84, 0.92, 0.89]);
240
246
  let disposed = false;
241
- let visible = true;
242
- let animationFrame;
243
- let lastFrame = 0;
244
- let metallicTime = 1450;
245
- const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
247
+ const metallicTime = 1450;
246
248
  const texture = gl.createTexture();
247
249
  const image = new Image();
248
250
  const draw = () => {
249
251
  gl.uniform1f(uniforms.u_time, metallicTime);
250
252
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
251
253
  };
252
- const render = (now) => {
253
- if (disposed)
254
- return;
255
- if (visible && document.visibilityState !== 'hidden' && now - lastFrame >= 40) {
256
- const delta = lastFrame === 0 ? 0 : Math.min(80, now - lastFrame);
257
- lastFrame = now;
258
- metallicTime += delta * 0.11;
259
- draw();
260
- }
261
- animationFrame = window.requestAnimationFrame(render);
262
- };
263
254
  image.onload = () => {
264
255
  if (disposed || texture === null)
265
256
  return;
@@ -273,34 +264,30 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
273
264
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
274
265
  gl.uniform1i(uniforms.u_tex, 0);
275
266
  draw();
267
+ // Preserve the metallic appearance without a permanent GPU animation.
268
+ // Updating snapshot unmounts the canvas and runs the resource cleanup.
269
+ const result = canvas.toDataURL();
270
+ metallicSnapshots.set(visualSize, result);
271
+ setSnapshot(result);
276
272
  setReady(true);
277
- if (!reduceMotion.matches)
278
- animationFrame = window.requestAnimationFrame(render);
279
273
  };
280
274
  image.src = XIAOHEI_BRAND_AVATAR;
281
- const visibilityObserver = typeof IntersectionObserver === 'function'
282
- ? new IntersectionObserver(entries => {
283
- visible = entries.some(entry => entry.isIntersecting);
284
- if (visible)
285
- lastFrame = 0;
286
- })
287
- : undefined;
288
- visibilityObserver?.observe(canvas);
289
275
  return () => {
290
276
  disposed = true;
291
- if (animationFrame !== undefined)
292
- window.cancelAnimationFrame(animationFrame);
293
- visibilityObserver?.disconnect();
277
+ image.onload = null;
294
278
  if (texture !== null)
295
279
  gl.deleteTexture(texture);
296
280
  gl.deleteBuffer(buffer);
297
281
  gl.deleteProgram(program);
282
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
298
283
  };
299
- }, [visualSize]);
284
+ }, [visualSize, context, snapshot]);
300
285
  const geometry = {
301
286
  '--xiaohei-brand-mark-size': `${visualSize}px`,
302
287
  };
303
- return (_jsxs("span", { className: className === undefined ? 'xiaohei-brand-mark' : `${className} xiaohei-brand-mark`, "data-brand-context": context, "data-metallic-ready": ready ? 'true' : 'false', style: geometry, "aria-hidden": "true", children: [_jsx("img", { className: "xiaohei-brand-mark__fallback", src: XIAOHEI_BRAND_AVATAR, alt: "" }), _jsx("canvas", { className: "xiaohei-brand-mark__metal", ref: canvasRef })] }));
288
+ return (_jsxs("span", { className: className === undefined ? 'xiaohei-brand-mark' : `${className} xiaohei-brand-mark`, "data-brand-context": context, "data-metallic-ready": ready || snapshot !== undefined ? 'true' : 'false', style: geometry, "aria-hidden": "true", children: [_jsx("img", { className: "xiaohei-brand-mark__fallback", src: XIAOHEI_BRAND_AVATAR, alt: "" }), snapshot !== undefined
289
+ ? _jsx("img", { className: "xiaohei-brand-mark__metal", src: snapshot, alt: "" })
290
+ : _jsx("canvas", { className: "xiaohei-brand-mark__metal", ref: canvasRef })] }));
304
291
  }
305
292
  /** Official sidebar brand-name occupant paired with the Xiaohei mark. */
306
293
  export function XiaoheiBrandName() {
@@ -0,0 +1,18 @@
1
+ interface Clock {
2
+ setTimeout(callback: () => void, delay: number): number;
3
+ clearTimeout(handle: number): void;
4
+ performance: {
5
+ now(): number;
6
+ };
7
+ }
8
+ export interface CharacterMotionClip {
9
+ duration: number;
10
+ }
11
+ /** One deadline; elapsed-time continuous sampling, capped at 30 fps. */
12
+ export declare function createCharacterMotionClock(clock: Clock, changed: (action: string | undefined, progress: number) => void, random?: () => number): {
13
+ setClips(next: Readonly<Record<string, CharacterMotionClip>> | undefined): void;
14
+ attention(): void;
15
+ dispose(): void;
16
+ };
17
+ export {};
18
+ //# sourceMappingURL=character-motion-clock.d.ts.map
@@ -0,0 +1,57 @@
1
+ /** One deadline; elapsed-time continuous sampling, capped at 30 fps. */
2
+ export function createCharacterMotionClock(clock, changed, random = Math.random) {
3
+ let timer, disposed = false, turn = 0, active = '';
4
+ let clips;
5
+ const clear = () => { if (timer !== undefined)
6
+ clock.clearTimeout(timer); timer = undefined; };
7
+ function rest() {
8
+ active = '';
9
+ changed(undefined, 0);
10
+ if (clips && !disposed)
11
+ timer = clock.setTimeout(start, 2800 + random() * 2800);
12
+ }
13
+ function play(action) {
14
+ const clip = clips?.[action];
15
+ if (!clip || disposed)
16
+ return;
17
+ clear();
18
+ active = action;
19
+ const started = clock.performance.now();
20
+ const step = () => {
21
+ timer = undefined;
22
+ if (!clips || disposed)
23
+ return;
24
+ const t = Math.min(1, (clock.performance.now() - started) / clip.duration);
25
+ changed(active, t);
26
+ if (t >= 1) {
27
+ rest();
28
+ return;
29
+ }
30
+ timer = clock.setTimeout(step, 1000 / 30);
31
+ };
32
+ step();
33
+ }
34
+ function start() {
35
+ timer = undefined;
36
+ if (!clips || disposed)
37
+ return;
38
+ const gestures = Object.keys(clips).filter(name => name !== 'blink' && name !== 'attention');
39
+ play((turn++ % 2 === 0 && clips.blink) || !gestures.length ? 'blink' : gestures[Math.floor(random() * gestures.length)]);
40
+ }
41
+ return {
42
+ setClips(next) {
43
+ if (disposed || next === clips)
44
+ return;
45
+ clear();
46
+ clips = next;
47
+ active = '';
48
+ changed(undefined, 0);
49
+ if (clips)
50
+ timer = clock.setTimeout(start, 1200 + random() * 800);
51
+ },
52
+ attention() { if (!active)
53
+ play('attention'); },
54
+ dispose() { disposed = true; clear(); clips = undefined; changed(undefined, 0); },
55
+ };
56
+ }
57
+ //# sourceMappingURL=character-motion-clock.js.map
@@ -0,0 +1,12 @@
1
+ export type Point = readonly [number, number];
2
+ export interface CharacterLayer {
3
+ readonly src: string;
4
+ readonly box: readonly [number, number, number, number];
5
+ }
6
+ export interface CharacterRig {
7
+ readonly layers: Readonly<Record<string, CharacterLayer>>;
8
+ readonly ears: readonly Point[];
9
+ readonly size: Point;
10
+ }
11
+ export type CharacterMotionCatalog = Readonly<Record<string, Readonly<Record<'light' | 'dark', CharacterRig>>>>;
12
+ //# sourceMappingURL=character-motion-types.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=character-motion-types.js.map
@@ -0,0 +1,6 @@
1
+ /** Owns loading and lifecycle only. Rig math and painting stay independent. */
2
+ export declare function createCharacterMotion(doc: Document): {
3
+ setPose(nextPart: HTMLElement | undefined, nextPose: string): void;
4
+ dispose(): void;
5
+ };
6
+ //# sourceMappingURL=character-motion.d.ts.map
@@ -0,0 +1,126 @@
1
+ import { CHARACTER_MOTION } from '../generated-character-motion.js';
2
+ import { createCharacterMotionClock } from './character-motion-clock.js';
3
+ import { createRigRenderer } from './character-rig-renderer.js';
4
+ /** Owns loading and lifecycle only. Rig math and painting stay independent. */
5
+ export function createCharacterMotion(doc) {
6
+ const win = doc.defaultView, reduced = win.matchMedia('(prefers-reduced-motion: reduce)');
7
+ const images = new Map(), loading = new Map();
8
+ let disposed = false, part, rig;
9
+ let canvas, render;
10
+ let pose = '', appearance = '', revision = 0, nearby = false, lastPointer = 0;
11
+ function decode(src) {
12
+ if (loading.has(src))
13
+ return loading.get(src);
14
+ if (images.has(src))
15
+ return Promise.resolve();
16
+ const image = new win.Image();
17
+ image.src = src;
18
+ const pending = image.decode().then(() => { if (!disposed)
19
+ images.set(src, image); }, () => { })
20
+ .finally(() => loading.delete(src));
21
+ loading.set(src, pending);
22
+ return pending;
23
+ }
24
+ const clock = createCharacterMotionClock(win, (action, progress) => {
25
+ if (!canvas || !part || !render)
26
+ return;
27
+ // Keep the same layered rest drawing after an action: no plate swap/pop.
28
+ render(action, progress);
29
+ part.dataset.motion = action ?? 'rig-rest';
30
+ });
31
+ function update() {
32
+ if (disposed)
33
+ return;
34
+ const mode = doc.documentElement.dataset.xiaoheiAppearance === 'light' ? 'light' : 'dark';
35
+ const moving = doc.documentElement.hasAttribute('data-xiaohei-sidebar-resizing');
36
+ const next = !doc.hidden && !reduced.matches && !moving && part ? CHARACTER_MOTION[pose]?.[mode] : undefined;
37
+ if (next === rig && appearance === mode)
38
+ return;
39
+ clock.setClips(undefined);
40
+ render = undefined;
41
+ const current = ++revision;
42
+ canvas?.remove();
43
+ canvas = undefined;
44
+ if (part)
45
+ part.dataset.motion = 'rest';
46
+ rig = next;
47
+ appearance = mode;
48
+ nearby = false;
49
+ if (!next || !part)
50
+ return;
51
+ const currentPart = part;
52
+ void Promise.all(Object.values(next.layers).map(layer => decode(layer.src))).then(() => {
53
+ if (disposed || current !== revision || Object.values(next.layers).some(layer => !images.get(layer.src)))
54
+ return;
55
+ canvas = doc.createElement('canvas');
56
+ canvas.className = 'xiaohei-character-motion';
57
+ canvas.width = next.size[0];
58
+ canvas.height = next.size[1];
59
+ const ctx = canvas.getContext('2d');
60
+ if (!ctx) {
61
+ canvas = undefined;
62
+ return;
63
+ }
64
+ currentPart.append(canvas);
65
+ render = createRigRenderer(ctx, next, images);
66
+ clock.setClips({
67
+ blink: { duration: 330 },
68
+ ...Object.fromEntries(next.ears.map((_, i) => ['ear' + i, { duration: 1050 }])),
69
+ ...(next.layers.tail ? { tail: { duration: 2400 } } : {}),
70
+ attention: { duration: 2400 },
71
+ });
72
+ });
73
+ }
74
+ const pointer = (event) => {
75
+ if (!part || !render || event.pointerType !== 'mouse')
76
+ return;
77
+ const now = win.performance.now();
78
+ if (now - lastPointer < 100)
79
+ return;
80
+ lastPointer = now;
81
+ const rect = part.getBoundingClientRect();
82
+ const near = event.clientX >= rect.left - 70 && event.clientX <= rect.right + 70
83
+ && event.clientY >= rect.top - 50 && event.clientY <= rect.bottom + 50;
84
+ if (near && !nearby)
85
+ clock.attention();
86
+ nearby = near;
87
+ };
88
+ reduced.addEventListener('change', update);
89
+ doc.addEventListener('visibilitychange', update);
90
+ doc.addEventListener('pointermove', pointer, { passive: true });
91
+ const observer = new win.MutationObserver(update);
92
+ observer.observe(doc.documentElement, { attributes: true, attributeFilter: ['data-xiaohei-appearance', 'data-xiaohei-sidebar-resizing'] });
93
+ return {
94
+ setPose(nextPart, nextPose) {
95
+ if (nextPart !== part || pose !== nextPose) {
96
+ revision++;
97
+ clock.setClips(undefined);
98
+ render = undefined;
99
+ canvas?.remove();
100
+ canvas = undefined;
101
+ rig = undefined;
102
+ if (part)
103
+ part.dataset.motion = 'rest';
104
+ }
105
+ part = nextPart;
106
+ pose = nextPose;
107
+ update();
108
+ },
109
+ dispose() {
110
+ disposed = true;
111
+ revision++;
112
+ clock.dispose();
113
+ canvas?.remove();
114
+ render = undefined;
115
+ images.clear();
116
+ loading.clear();
117
+ if (part)
118
+ part.dataset.motion = 'rest';
119
+ reduced.removeEventListener('change', update);
120
+ observer.disconnect();
121
+ doc.removeEventListener('visibilitychange', update);
122
+ doc.removeEventListener('pointermove', pointer);
123
+ },
124
+ };
125
+ }
126
+ //# sourceMappingURL=character-motion.js.map
@@ -0,0 +1,4 @@
1
+ import type { CharacterRig } from './character-motion-types.js';
2
+ /** Small CPU-skinned tail only; body and ears use normal image draws. */
3
+ export declare function createRigRenderer(ctx: CanvasRenderingContext2D, rig: CharacterRig, images: ReadonlyMap<string, HTMLImageElement | null>): (action: string | undefined, progress: number) => void;
4
+ //# sourceMappingURL=character-rig-renderer.d.ts.map
@@ -0,0 +1,85 @@
1
+ import { bindVertex, rigAngles, skinVertex, solveBones, TAIL_JOINTS } from './character-rig.js';
2
+ /** Small CPU-skinned tail only; body and ears use normal image draws. */
3
+ export function createRigRenderer(ctx, rig, images) {
4
+ const tail = rig.layers.tail;
5
+ const vertices = [], triangles = [];
6
+ if (tail) {
7
+ const [x, y, r, b] = tail.box, cols = Math.ceil((r - x) / 10), rows = Math.ceil((b - y) / 10);
8
+ for (let row = 0; row <= rows; row++)
9
+ for (let col = 0; col <= cols; col++)
10
+ vertices.push(bindVertex(x + (r - x) * col / cols, y + (b - y) * row / rows));
11
+ for (let row = 0; row < rows; row++)
12
+ for (let col = 0; col < cols; col++) {
13
+ const i = row * (cols + 1) + col;
14
+ triangles.push([i, i + 1, i + cols + 1], [i + 1, i + cols + 2, i + cols + 1]);
15
+ }
16
+ }
17
+ function layer(name) {
18
+ const layer = rig.layers[name], image = layer && images.get(layer.src);
19
+ if (layer && image) {
20
+ const [x, y, r, b] = layer.box;
21
+ ctx.drawImage(image, x, y, r - x, b - y);
22
+ }
23
+ }
24
+ function drawTail(progress) {
25
+ if (!tail)
26
+ return;
27
+ const image = images.get(tail.src);
28
+ if (!image)
29
+ return;
30
+ if (progress <= 0 || progress >= 1) {
31
+ layer('tail');
32
+ return;
33
+ }
34
+ const joints = solveBones(TAIL_JOINTS, rigAngles(progress, TAIL_JOINTS.length, true));
35
+ const posed = vertices.map(v => skinVertex(v, joints));
36
+ const [left, top] = tail.box;
37
+ for (const indices of triangles) {
38
+ const [ia, ib, ic] = indices;
39
+ const a = vertices[ia], b = vertices[ib], c = vertices[ic];
40
+ const p = posed[ia], q = posed[ib], r = posed[ic];
41
+ const ux = b.x - a.x, uy = b.y - a.y, vx = c.x - a.x, vy = c.y - a.y, det = ux * vy - uy * vx;
42
+ const aa = ((q[0] - p[0]) * vy - (r[0] - p[0]) * uy) / det;
43
+ const bb = ((q[1] - p[1]) * vy - (r[1] - p[1]) * uy) / det;
44
+ const cc = ((r[0] - p[0]) * ux - (q[0] - p[0]) * vx) / det;
45
+ const dd = ((r[1] - p[1]) * ux - (q[1] - p[1]) * vx) / det;
46
+ ctx.save();
47
+ ctx.beginPath();
48
+ // Slightly overlap the interior triangles to avoid antialias hairlines.
49
+ const cx = (p[0] + q[0] + r[0]) / 3, cy = (p[1] + q[1] + r[1]) / 3;
50
+ [p, q, r].forEach((v, i) => {
51
+ const dx = v[0] - cx, dy = v[1] - cy, scale = 1 + 1.2 / Math.max(1, Math.hypot(dx, dy));
52
+ if (i)
53
+ ctx.lineTo(cx + dx * scale, cy + dy * scale);
54
+ else
55
+ ctx.moveTo(cx + dx * scale, cy + dy * scale);
56
+ });
57
+ ctx.closePath();
58
+ ctx.clip();
59
+ ctx.transform(aa, bb, cc, dd, p[0] - aa * (a.x - left) - cc * (a.y - top), p[1] - bb * (a.x - left) - dd * (a.y - top));
60
+ ctx.drawImage(image, 0, 0);
61
+ ctx.restore();
62
+ }
63
+ }
64
+ return (action, progress) => {
65
+ ctx.clearRect(0, 0, rig.size[0], rig.size[1]);
66
+ const ears = rigAngles(progress, rig.ears.length, false);
67
+ rig.ears.forEach(([x, y], i) => {
68
+ ctx.save();
69
+ ctx.translate(x, y);
70
+ ctx.rotate(action === 'attention' || action === 'ear' + i ? ears[i] : 0);
71
+ ctx.translate(-x, -y);
72
+ layer('ear' + i);
73
+ ctx.restore();
74
+ });
75
+ layer('body');
76
+ drawTail(action === 'tail' || action === 'attention' ? progress : 0);
77
+ if (action === 'blink') {
78
+ ctx.save();
79
+ ctx.globalAlpha = Math.min(1, Math.sin(Math.PI * progress) * 2);
80
+ layer('blink');
81
+ ctx.restore();
82
+ }
83
+ };
84
+ }
85
+ //# sourceMappingURL=character-rig-renderer.js.map
@@ -0,0 +1,20 @@
1
+ import type { Point } from './character-motion-types.js';
2
+ /** Bind-space joint positions. Each child inherits its parent's rotation. */
3
+ export declare const TAIL_JOINTS: readonly Point[];
4
+ export interface Joint {
5
+ x: number;
6
+ y: number;
7
+ angle: number;
8
+ }
9
+ export interface Vertex {
10
+ x: number;
11
+ y: number;
12
+ bone: number;
13
+ weight: number;
14
+ }
15
+ export declare function solveBones(rest: readonly Point[], angles: readonly number[]): Joint[];
16
+ export declare function bindVertex(x: number, y: number, rest?: readonly Point[]): Vertex;
17
+ /** Linear blend skinning, inverse bind translation followed by posed rotation. */
18
+ export declare function skinVertex(v: Vertex, joints: readonly Joint[], rest?: readonly Point[]): Point;
19
+ export declare function rigAngles(progress: number, count: number, tail: boolean): number[];
20
+ //# sourceMappingURL=character-rig.d.ts.map
@@ -0,0 +1,53 @@
1
+ /** Bind-space joint positions. Each child inherits its parent's rotation. */
2
+ export const TAIL_JOINTS = [
3
+ [284, 328], [310, 347], [324, 373], [319, 398],
4
+ [307, 423], [307, 448], [319, 464],
5
+ ];
6
+ export function solveBones(rest, angles) {
7
+ return rest.reduce((out, p, i) => {
8
+ const parent = out[i - 1];
9
+ const angle = (parent?.angle ?? 0) + (angles[i] ?? 0);
10
+ if (!parent)
11
+ out.push({ x: p[0], y: p[1], angle });
12
+ else {
13
+ const prev = rest[i - 1], dx = p[0] - prev[0], dy = p[1] - prev[1];
14
+ const c = Math.cos(parent.angle), s = Math.sin(parent.angle);
15
+ out.push({ x: parent.x + dx * c - dy * s, y: parent.y + dx * s + dy * c, angle });
16
+ }
17
+ return out;
18
+ }, []);
19
+ }
20
+ export function bindVertex(x, y, rest = TAIL_JOINTS) {
21
+ let distance = Infinity, bone = 0, weight = 0;
22
+ for (let i = 0; i < rest.length - 1; i++) {
23
+ const a = rest[i], b = rest[i + 1], dx = b[0] - a[0], dy = b[1] - a[1];
24
+ const t = Math.max(0, Math.min(1, ((x - a[0]) * dx + (y - a[1]) * dy) / (dx * dx + dy * dy)));
25
+ const d = (x - a[0] - dx * t) ** 2 + (y - a[1] - dy * t) ** 2;
26
+ if (d < distance) {
27
+ distance = d;
28
+ bone = i;
29
+ weight = t;
30
+ }
31
+ }
32
+ return { x, y, bone, weight };
33
+ }
34
+ /** Linear blend skinning, inverse bind translation followed by posed rotation. */
35
+ export function skinVertex(v, joints, rest = TAIL_JOINTS) {
36
+ let x = 0, y = 0;
37
+ for (let k = 0; k < 2; k++) {
38
+ const i = v.bone + k, p = rest[i], j = joints[i], w = k ? v.weight : 1 - v.weight;
39
+ const dx = v.x - p[0], dy = v.y - p[1], c = Math.cos(j.angle), s = Math.sin(j.angle);
40
+ x += w * (j.x + dx * c - dy * s);
41
+ y += w * (j.y + dx * s + dy * c);
42
+ }
43
+ return [x, y];
44
+ }
45
+ export function rigAngles(progress, count, tail) {
46
+ const t = Math.max(0, Math.min(1, progress));
47
+ const envelope = Math.sin(Math.PI * t) ** 2;
48
+ // Delayed distal joints create follow-through, with zero displacement/velocity
49
+ // at both ends; no discrete frame lookup and no idle simulation.
50
+ return Array.from({ length: count }, (_, i) => envelope * (tail ? (i ? .12 : 0) : .22)
51
+ * Math.sin(t * Math.PI * (tail ? 3 : 4) - i * (tail ? .65 : 1.1)));
52
+ }
53
+ //# sourceMappingURL=character-rig.js.map
@@ -8,6 +8,9 @@ export const XIAOHEI_WALLPAPER_CHARACTER_CSS = `
8
8
  pointer-events: none;
9
9
  user-select: none;
10
10
  }
11
+ html[data-xiaohei-sidebar-resizing] .xiaohei-wallpaper-character {
12
+ visibility: hidden;
13
+ }
11
14
  html[data-xiaohei-appearance='light'] .xiaohei-wallpaper-character {
12
15
  ${CHARACTER_POSE_NAMES.map(pose => `--xiaohei-${pose}-art: url("${CHARACTER_POSES[pose].light}");`).join('\n ')}
13
16
  }
@@ -21,6 +24,13 @@ html[data-xiaohei-appearance='light'] .xiaohei-wallpaper-character {
21
24
  opacity: 0;
22
25
  transition: opacity 220ms ease, transform 220ms cubic-bezier(.2,.8,.2,1);
23
26
  }
27
+ .xiaohei-wallpaper-character .xiaohei-character-motion {
28
+ position: absolute; inset: 0; width: 100%; height: 100%;
29
+ object-fit: contain; object-position: left center; pointer-events: none;
30
+ }
31
+ .xiaohei-wallpaper-character > span[data-motion]:not([data-motion='rest']) {
32
+ background-image: none;
33
+ }
24
34
  ${CHARACTER_POSE_NAMES.map(pose => `
25
35
  .xiaohei-wallpaper-character__${pose}[data-ready] {
26
36
  background-image: var(--xiaohei-${pose}-art);
@@ -1,4 +1,4 @@
1
1
  export declare const XIAOHEI_WALLPAPER_CHARACTER_ID = "dsh-theme-xiaohei/wallpaper-character";
2
- /** Complete raster poses, no skeleton deformation or perpetual animation loop. */
2
+ /** Places the layered character only when the host geometry is settled. */
3
3
  export declare function installXiaoheiWallpaperCharacter(doc?: Document | undefined): () => void;
4
4
  //# sourceMappingURL=wallpaper-character.d.ts.map
@@ -4,8 +4,9 @@ import { XIAOHEI_SCENE_LAYER_ID } from './styles.js';
4
4
  import { resolveCharacterPlacement } from './character-layout.js';
5
5
  import { createCharacterIdleController } from './character-idle.js';
6
6
  import { CHARACTER_POSES, CHARACTER_POSE_NAMES } from './character-poses.js';
7
+ import { createCharacterMotion } from './character-motion.js';
7
8
  export const XIAOHEI_WALLPAPER_CHARACTER_ID = 'dsh-theme-xiaohei/wallpaper-character';
8
- /** Complete raster poses, no skeleton deformation or perpetual animation loop. */
9
+ /** Places the layered character only when the host geometry is settled. */
9
10
  export function installXiaoheiWallpaperCharacter(doc = typeof document === 'undefined' ? undefined : document) {
10
11
  const win = doc?.defaultView;
11
12
  if (!doc || !win)
@@ -20,6 +21,7 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
20
21
  const ready = new Set();
21
22
  const loading = new Set();
22
23
  const failed = new Set();
24
+ const motion = typeof win.matchMedia === 'function' ? createCharacterMotion(doc) : undefined;
23
25
  const idle = createCharacterIdleController({ now: () => win.performance.now(),
24
26
  setTimeout: (fn, delay) => win.setTimeout(fn, delay), clearTimeout: handle => win.clearTimeout(handle),
25
27
  }, () => schedule());
@@ -43,6 +45,17 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
43
45
  function reconcile() {
44
46
  if (disposed || !doc)
45
47
  return;
48
+ // The glass owner already tracks the native column's animated geometry.
49
+ // Do not measure controls or rebuild a different rig at intermediate widths.
50
+ if (doc.documentElement?.hasAttribute('data-xiaohei-sidebar-resizing')) {
51
+ dirty = true;
52
+ if (host && host.dataset.pose !== 'hidden')
53
+ host.dataset.pose = 'hidden';
54
+ idle.setPaused(true);
55
+ motion?.setPose(undefined, 'hidden');
56
+ scheduleSettled();
57
+ return;
58
+ }
46
59
  const layer = doc.getElementById(XIAOHEI_SCENE_LAYER_ID);
47
60
  const next = [
48
61
  doc.querySelector(XIAOHEI_HOST_SELECTORS.sidebarShell),
@@ -62,6 +75,7 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
62
75
  host?.remove();
63
76
  host = undefined;
64
77
  idle.setPaused(true);
78
+ motion?.setPose(undefined, 'hidden');
65
79
  return;
66
80
  }
67
81
  if (!host || host.parentElement !== layer) {
@@ -115,9 +129,11 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
115
129
  if (pose === 'hidden') {
116
130
  if (host.dataset.pose !== 'hidden')
117
131
  host.dataset.pose = 'hidden';
132
+ motion?.setPose(undefined, 'hidden');
118
133
  return;
119
134
  }
120
135
  if (!ready.has(pose)) {
136
+ motion?.setPose(undefined, 'hidden');
121
137
  // Do not retain the old location across a layout change: it could now
122
138
  // overlap a message. Idle changes keep the already decoded seated pose.
123
139
  if (placement.pose !== 'seated' || !['seated', 'chin', 'doze'].includes(host.dataset.pose ?? ''))
@@ -152,6 +168,7 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
152
168
  part.dataset.ready = 'true';
153
169
  if (host.dataset.pose !== pose)
154
170
  host.dataset.pose = pose;
171
+ motion?.setPose(part, pose);
155
172
  }
156
173
  function schedule() {
157
174
  if (disposed || frame !== undefined)
@@ -185,6 +202,7 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
185
202
  unsubscribe();
186
203
  observer?.disconnect();
187
204
  idle.dispose();
205
+ motion?.dispose();
188
206
  for (const event of events)
189
207
  doc.removeEventListener(event, activity, true);
190
208
  doc.removeEventListener('visibilitychange', visibility);