ananyajoshi Claude Opus 5 (1M context) commited on
Commit
f81fd66
·
1 Parent(s): b172c8d

Make the steady-surface listener idempotent; add a motion kill switch

Browse files

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

Files changed (1) hide show
  1. app/ui/motion_runtime.py +46 -20
app/ui/motion_runtime.py CHANGED
@@ -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
- reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
 
 
 
 
 
 
 
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
- var STEADY = [];
 
 
 
 
 
 
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
- var steadyFrame = 0;
237
- document.addEventListener('pointermove', function (e) {
238
- if (steadyFrame) return;
239
- var px = e.clientX, py = e.clientY;
240
- steadyFrame = requestAnimationFrame(function () {
241
- steadyFrame = 0;
242
- for (var i = STEADY.length - 1; i >= 0; i--) {
243
- var el = STEADY[i];
244
- // Streamlit replaces nodes on every rerun; drop the stale ones.
245
- if (!el.isConnected) { STEADY.splice(i, 1); continue; }
246
- var r = el.getBoundingClientRect();
247
- var near = px >= r.left - STEADY_MARGIN && px <= r.right + STEADY_MARGIN &&
248
- py >= r.top - STEADY_MARGIN && py <= r.bottom + STEADY_MARGIN;
249
- if (near && !el.__upsInside) { el.__upsInside = 1; holdFloat(el); }
250
- else if (!near && el.__upsInside) { el.__upsInside = 0; resumeFloat(el); }
251
- }
252
- });
253
- }, true);
 
 
 
 
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"