atagen

malice

public
Star0
Watch0

Commit 0d4a4ccdc6de

tarball

atagen <boss@atagen.co>2026-10-27 05:25 UTC

1 file changed+25-6
web: animate row heights in whole pixels

Rows easing in or settling together carried sub-pixel heights, and as their curves flattened the summed height kept crossing rounding boundaries, so the pinned bottom flipped by a pixel each frame and the timeline shook at the end of every load-in.
Modified file: web/src/lib/motion.ts+25 additions−6 deletions
@@ -29,12 +29,17 @@ function rowKey(node: Element): string {
2929 return item ?? row ?? '';
3030 }
3131
32+/** Sub-pixel sizes from many rows easing out together made the pinned bottom flip between roundings. */
33+function wholePixels(css: string): string {
34+ return css.replace(/(-?\d*\.?\d+)px/g, (_, n: string) => `${Math.round(Number(n))}px`);
35+}
36+
3237 function reveal(node: Element, ms: number, axis: 'x' | 'y' = 'y'): TransitionConfig {
3338 const duration = motionDuration(ms);
3439 if (duration === 0) return { duration: 0 };
3540 noteBusy(duration);
3641 const base = slide(node, { duration, easing: cubicOut, axis });
37- return { duration, easing: cubicOut, css: (t, u) => `${base.css?.(t, u) ?? ''};opacity: ${t};` };
42+ return { duration, easing: cubicOut, css: (t, u) => `${wholePixels(base.css?.(t, u) ?? '')};opacity: ${t};` };
3843 }
3944
4045 export function rowIn(node: Element): TransitionConfig {
@@ -55,13 +60,27 @@ export function dissolve(node: Element): TransitionConfig {
5560 return fade(node, { duration });
5661 }
5762
63+const settling = new WeakSet<HTMLElement>();
64+
5865 export function settleHeight(node: HTMLElement, from: number): void {
5966 const duration = motionDuration(180);
6067 const to = node.offsetHeight;
61- if (duration === 0 || Math.abs(to - from) < 2 || node.getAnimations({ subtree: true }).length > 0) return;
68+ if (duration === 0 || Math.abs(to - from) < 2 || settling.has(node) || node.getAnimations({ subtree: true }).length > 0) return;
6269 noteBusy(duration);
63- node.animate(
64- [{ height: `${from}px`, overflow: 'hidden' }, { height: `${to}px`, overflow: 'hidden' }],
65- { duration, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' }
66- );
70+ settling.add(node);
71+ const start = performance.now();
72+ node.style.overflow = 'hidden';
73+ const step = (now: number) => {
74+ const t = Math.min(1, (now - start) / duration);
75+ if (t < 1) {
76+ node.style.height = `${Math.round(from + (to - from) * cubicOut(t))}px`;
77+ requestAnimationFrame(step);
78+ } else {
79+ node.style.height = '';
80+ node.style.overflow = '';
81+ settling.delete(node);
82+ }
83+ };
84+ node.style.height = `${Math.round(from)}px`;
85+ requestAnimationFrame(step);
6786 }