Spaces:
Sleeping
Make the steady-surface listener idempotent; add a motion kill switch
Browse filesTwo things, both learned from this fix failing repeatedly in the hand
while passing automated tests.
1. Real bug introduced by the previous commit. inject() appends the
whole driver to the parent document on EVERY Streamlit rerun, so the
driver's top level runs many times per page. The document-level
pointermove listener and its surface list were being recreated each
time: N reruns meant N listeners, each holding a private list the
others could not see, and surfaces that survived a rerun were left
registered only in an older list. Both now hang off `window` behind
a guard, so exactly one listener and one list exist per page.
2. UPS_REDUCE_MOTION=1 loads the driver but starts nothing, through the
same bail-out the OS accessibility preference already used. It exists
to settle whether the motion layer is responsible for an interaction
problem, without an OS setting or a code edit — a question three
rounds of this fix could not answer from test evidence alone.
Verified with the switch on: page fully static (0.0px drift) and save
lands on one click.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- app/ui/motion_runtime.py +46 -20
|
@@ -42,6 +42,7 @@ loaded from a CDN, because this app also ships as an offline desktop launcher.
|
|
| 42 |
|
| 43 |
from __future__ import annotations
|
| 44 |
|
|
|
|
| 45 |
from functools import lru_cache
|
| 46 |
|
| 47 |
import streamlit as st
|
|
@@ -72,7 +73,14 @@ _DRIVER = r"""
|
|
| 72 |
var SPRING = { type: 'spring', stiffness: 320, damping: 30, mass: 0.9 };
|
| 73 |
var reduce = false;
|
| 74 |
try {
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
} catch (e) {}
|
| 77 |
|
| 78 |
// Transforms are always written as Motion's discrete properties (y, scale,
|
|
@@ -223,7 +231,13 @@ _DRIVER = r"""
|
|
| 223 |
// which also sidesteps every attach-ordering trap that sank the earlier
|
| 224 |
// attempts — it does not matter when a surface was registered, only where
|
| 225 |
// the pointer is on the next move.
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
var STEADY_MARGIN = 140; // px of clearance — stops before you arrive
|
| 228 |
|
| 229 |
function steadyForPointer(el) {
|
|
@@ -233,24 +247,28 @@ _DRIVER = r"""
|
|
| 233 |
STEADY.push(el);
|
| 234 |
}
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
// Pointer tilt. Hands over from the float and hands back on leave, because
|
| 256 |
// two animations writing the same transform component fight each other.
|
|
@@ -545,6 +563,13 @@ def inject() -> None:
|
|
| 545 |
# upgrade. In JavaScript "<\\/script" and "</script" are the same string.
|
| 546 |
lib = lib.replace("</script", "<\\/script")
|
| 547 |
driver = _DRIVER.replace("</script", "<\\/script")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
components.html(
|
| 549 |
# The payloads come first: the bootstrap below reads them by id, and a
|
| 550 |
# <script> only sees the document that exists above it.
|
|
@@ -560,6 +585,7 @@ def inject() -> None:
|
|
| 560 |
" try {\n"
|
| 561 |
" var w = window.parent, d = w.document;\n"
|
| 562 |
" if (!d || !d.head) return false;\n"
|
|
|
|
| 563 |
" if (!w.Motion) {\n"
|
| 564 |
" var s = d.createElement('script');\n"
|
| 565 |
" s.id = 'ups-motion-lib';\n"
|
|
|
|
| 42 |
|
| 43 |
from __future__ import annotations
|
| 44 |
|
| 45 |
+
import os
|
| 46 |
from functools import lru_cache
|
| 47 |
|
| 48 |
import streamlit as st
|
|
|
|
| 73 |
var SPRING = { type: 'spring', stiffness: 320, damping: 30, mass: 0.9 };
|
| 74 |
var reduce = false;
|
| 75 |
try {
|
| 76 |
+
// UPS_REDUCE_MOTION=1 forces this on from the server side — an escape
|
| 77 |
+
// hatch that needs no OS setting and no code edit, for when the motion
|
| 78 |
+
// layer has to be taken out of the picture (e.g. to determine whether it
|
| 79 |
+
// is responsible for an interaction problem). It routes through the same
|
| 80 |
+
// already-proven bail-out as the accessibility preference below rather
|
| 81 |
+
// than adding a second, less-tested way to switch motion off.
|
| 82 |
+
reduce = !!window.__upsReduceMotion ||
|
| 83 |
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
| 84 |
} catch (e) {}
|
| 85 |
|
| 86 |
// Transforms are always written as Motion's discrete properties (y, scale,
|
|
|
|
| 231 |
// which also sidesteps every attach-ordering trap that sank the earlier
|
| 232 |
// attempts — it does not matter when a surface was registered, only where
|
| 233 |
// the pointer is on the next move.
|
| 234 |
+
// NOTE ON LIFETIME: inject() appends this whole driver to the parent
|
| 235 |
+
// document on EVERY Streamlit rerun, so this file's top level runs many
|
| 236 |
+
// times per page. Anything registered here must therefore be idempotent —
|
| 237 |
+
// both the surface list and the listener hang off `window`, guarded, or a
|
| 238 |
+
// busy session would accumulate one listener and one orphaned list per
|
| 239 |
+
// rerun, each holding nodes the others cannot see.
|
| 240 |
+
var STEADY = (window.__upsSteady = window.__upsSteady || []);
|
| 241 |
var STEADY_MARGIN = 140; // px of clearance — stops before you arrive
|
| 242 |
|
| 243 |
function steadyForPointer(el) {
|
|
|
|
| 247 |
STEADY.push(el);
|
| 248 |
}
|
| 249 |
|
| 250 |
+
if (!window.__upsSteadyBound) {
|
| 251 |
+
window.__upsSteadyBound = 1;
|
| 252 |
+
var steadyFrame = 0;
|
| 253 |
+
document.addEventListener('pointermove', function (e) {
|
| 254 |
+
if (steadyFrame) return;
|
| 255 |
+
var px = e.clientX, py = e.clientY;
|
| 256 |
+
steadyFrame = requestAnimationFrame(function () {
|
| 257 |
+
steadyFrame = 0;
|
| 258 |
+
var list = window.__upsSteady || [];
|
| 259 |
+
for (var i = list.length - 1; i >= 0; i--) {
|
| 260 |
+
var el = list[i];
|
| 261 |
+
// Streamlit replaces nodes on every rerun; drop the stale ones.
|
| 262 |
+
if (!el.isConnected) { list.splice(i, 1); continue; }
|
| 263 |
+
var r = el.getBoundingClientRect();
|
| 264 |
+
var near = px >= r.left - STEADY_MARGIN && px <= r.right + STEADY_MARGIN &&
|
| 265 |
+
py >= r.top - STEADY_MARGIN && py <= r.bottom + STEADY_MARGIN;
|
| 266 |
+
if (near && !el.__upsInside) { el.__upsInside = 1; holdFloat(el); }
|
| 267 |
+
else if (!near && el.__upsInside) { el.__upsInside = 0; resumeFloat(el); }
|
| 268 |
+
}
|
| 269 |
+
});
|
| 270 |
+
}, true);
|
| 271 |
+
}
|
| 272 |
|
| 273 |
// Pointer tilt. Hands over from the float and hands back on leave, because
|
| 274 |
// two animations writing the same transform component fight each other.
|
|
|
|
| 563 |
# upgrade. In JavaScript "<\\/script" and "</script" are the same string.
|
| 564 |
lib = lib.replace("</script", "<\\/script")
|
| 565 |
driver = _DRIVER.replace("</script", "<\\/script")
|
| 566 |
+
# Server-side motion kill switch. Set UPS_REDUCE_MOTION=1 to load the
|
| 567 |
+
# driver but start nothing, exactly as the OS accessibility preference
|
| 568 |
+
# does. Useful for isolating whether the motion layer is behind an
|
| 569 |
+
# interaction problem without touching code or system settings.
|
| 570 |
+
reduce_motion = (os.getenv("UPS_REDUCE_MOTION", "") or "").strip().lower() in (
|
| 571 |
+
"1", "true", "yes", "on"
|
| 572 |
+
)
|
| 573 |
components.html(
|
| 574 |
# The payloads come first: the bootstrap below reads them by id, and a
|
| 575 |
# <script> only sees the document that exists above it.
|
|
|
|
| 585 |
" try {\n"
|
| 586 |
" var w = window.parent, d = w.document;\n"
|
| 587 |
" if (!d || !d.head) return false;\n"
|
| 588 |
+
" w.__upsReduceMotion = " + ("true" if reduce_motion else "false") + ";\n"
|
| 589 |
" if (!w.Motion) {\n"
|
| 590 |
" var s = d.createElement('script');\n"
|
| 591 |
" s.id = 'ups-motion-lib';\n"
|